Skip to content

feat(admin): generic resource collection data layer (all content domains)#2764

Open
Innei wants to merge 15 commits into
masterfrom
feat/admin-resource-collection-data-layer
Open

feat(admin): generic resource collection data layer (all content domains)#2764
Innei wants to merge 15 commits into
masterfrom
feat/admin-resource-collection-data-layer

Conversation

@Innei

@Innei Innei commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Production implementation of the resource-store idea validated by the POC in #2741: a generic normalized client store for admin entities, with React Query demoted to pure transport and optimistic writes as kernel-guaranteed transactions. Design spec: docs/superpowers/specs/2026-07-10-admin-resource-collection-data-layer-design.md.

All content domains are migrated: category, post, topic, note, page, say, recently, project, link. Comments are deferred (nested/stateful shape needs its own design).

Kernel (apps/admin/src/data/resource/)

  • Normalized entity tables (zustand vanilla stores) with base/overlay separation, merge-upsert (partial list rows never clobber hydrated detail entities), monotonic per-entity versions, and kernel-guaranteed optimistic commit/rollback — no leaked pending ops even when handlers throw.
  • createTransaction for multi-op/batch writes with partial-success semantics (fulfilledKeys).
  • Query-keyed list indexes (LRU-capped) + reference-stable hooks: useEntity, useEntityList (keepPrevious pagination), useCollectionListQuery / useCollectionDetailQuery / useCollectionInfiniteQuery (cursor lists hydrate via list-index append mode). Only { hydratedAt } receipts enter the Query cache.
  • resetAllCollections() wired into every sign-out path.

Per-domain mutation modules (apps/admin/src/data/resources/)

*.mutations.ts own transaction composition, optimistic patches, persistence calls, and server-echo reconcile as plain async functions; the feature layer keeps only useMutation shells, toasts, invalidations, and navigation. Highlights:

  • Write page (post/note/page) is fully store-backed with a once-per-(kind, id) form-seed guard — background list hydrations can never reset in-progress edits.
  • Page drag-reorder went from hand-rolled optimistic state + manual revert to a single kernel transaction.
  • Recently's enrichment injection replaced setQueryData cache surgery with a plain entity upsert.
  • Search results (posts/notes) stay presentation-only queries — decorated rows (translated titles, highlights) never pollute canonical stores; row mutations ensure-hydrate the canonical entity on demand.
  • Embedded relations normalize on hydration (post→category, note→topic).

Verification

  • pnpm -C apps/admin exec vitest run src/data/resource/ src/features/write/ — 49/49
  • pnpm -C apps/admin run typecheck, pnpm -C apps/admin run build, oxlint on all changed files — clean
  • Every task passed an independent spec + quality review; two whole-branch final reviews (one per wave) with findings fixed and re-approved
  • Browser E2E on wave 1 against a live server: optimistic rename fanning out to all projections, failure rollback (duplicate slug → revert + toast; killed server → pin reverts + toast), form-seed guard under background hydration, search isolation, optimistic delete

Supersedes #2741.

Follow-ups (non-blocking, tracked in review notes)

  • Extract a shared batchRemove kernel helper (3× duplication)
  • Infinite-list refetch: interim collapse flicker + page-1 convergence test; pageParam != null check
  • PostRow category via hook-layer join (optimistic category rename on rows)
  • Add oxlint to devDependencies; LRU active-key eviction guard
  • Backend: PartialCategorySchema fires type default on partial updates; duplicate slug returns 500 instead of 409

Innei added 7 commits July 10, 2026 22:19
Non-React kernel for the generic resource-collection data layer: stable
list-key serialization, per-entity base/overlay collection store with
optimistic update/insert/delete lifecycle and version tracking, and a
multi-op transaction with partial-success commit/rollback.
…vation

errorsByKey used to gain a key with an undefined value whenever a
transaction rolled back an unfulfilled op without an error; only write
the key when an error is actually present. Also drop the now-unreachable
overlay guard in get()'s update branch, and extract the immer
produce+patch-derivation logic shared by collection.ts and
transaction.ts into deriveUpdate(). Adds tests for the errorsByKey
fix, a version bump across a successful commit, and a transaction
update with partial fulfilledKeys.
Adds list-index.ts (hydrateList/readList/touchList with a per-collection
50-key LRU cap) and hooks.ts (useEntity, useEntityList, useCollectionListQuery,
useCollectionDetailQuery) to apps/admin/src/data/resource/. Extends
CollectionState<T> with listIndexes, keeping React Query as a transport-only
layer that stores hydration receipts.
Route the admin category list/detail/mutation flow through the new
resource-collection kernel (apps/admin/src/data/resource/) instead of
raw useQuery + list-cache lookups. Adds apps/admin/src/data/resources/category.ts
defining the categories collection (optimistic update/delete backed by
updateCategory/deleteCategory), and retypes the category feature's
props/context from CategoryModel to the collection's CategoryEntity.
Tag handling is untouched (tags are not a collection yet); post list
wiring inside category detail stays on useQuery pending Task 4.
Route the admin post list/detail/write/mutation flows through the
resource-collection kernel instead of raw useQuery + list-cache lookups.

- Add apps/admin/src/data/resources/post.ts: the posts collection with a
  normalize that mirrors the embedded category snapshot into the categories
  collection, and patchPost/deletePost persistence.
- use-posts-list: posts + category-filter lists via useCollectionListQuery +
  useEntityList; keepPrevious replaces placeholderData.
- use-post-mutations: publish/pin/category via posts.update, delete via
  posts.delete, batch delete via a transaction with fulfilledKeys partial
  commit/rollback. Toasts and invalidatePosts preserved.
- CategoryDetail/TagDetail post lists move onto the posts collection.
- Write page (post kind only): split detail into useCollectionDetailQuery +
  useEntity; seed the form at most once per (kind,id) so background hydration
  cannot wipe in-progress edits; save-edit path applies an optimistic
  transaction and reconciles the server echo via hydrate. note/page paths
  unchanged.

Relax ResourceTransaction.commit to return the request result so the write
path can reconcile the server echo; fulfilledKeys detection unchanged.
…ng posts store

Register every defineCollection() instance in a module-level registry and
expose resetAllCollections(), called from all three sign-out paths
(sidebar logout, change-password sign-out, session self-revoke) so stale
entity state doesn't survive an account switch.

Split usePostsList's keyword-search branch off the shared posts
collection: search results (decorated with translated title/lang/
isFallback/highlight) now flow through a plain useQuery scoped to the
Query cache instead of collection.hydrate(), so they can no longer
pollute the canonical posts store or leak into the write form.
@safedep

safedep Bot commented Jul 10, 2026

Copy link
Copy Markdown

SafeDep Report Summary

Green Malicious Packages Badge Green Vulnerable Packages Badge Green Risky License Badge

Package Details
Package Malware Vulnerability Risky License Report
icon immer @ 11.1.8
pnpm-lock.yaml apps/admin/package.json
ok icon
ok icon
ok icon
🔗

View complete scan results →

This report is generated by SafeDep Github App

Innei added 8 commits July 11, 2026 00:56
… update

saveTopic's edit branch went through PATCH (204, no body), so the client's
optimistic value was committed without ever seeing the server's slugify()
normalization. Mirror savePost: explicit transaction + PUT round-trip that
hydrates the collection with the real server echo. Also switch topic.ts's
onUpdate from patchTopic to updateTopic (PUT) since no remaining caller uses
topics.update() directly, following the category.ts precedent.
…on before optimistic updates

- Give TopicFormModal's prefill query its own key so it never collides with
  the receipt-producing useCollectionDetailQuery consumers (TopicDetail,
  TopicDetailRoute), which was causing the edit form to transiently render
  empty and clobber typed input on refill.
- Ensure post/note entities are hydrated into their collections before
  registering optimistic update ops, fixing "update on unknown entity"
  thrown by search-mode row actions (publish/pin/move/patch) where the
  search branch doesn't hydrate collections.
- Give the posts search query its own key, matching the notes convention,
  instead of sharing the list key with the receipt-producing query.
- Use literal kinds for the three write.detail query keys so disabled
  detail observers target distinct cache entries instead of collapsing
  onto the active kind's key.
- Remove topic.ts's dead onUpdate handler (no callers; edit path uses an
  explicit PUT transaction instead).
@Innei Innei changed the title feat(admin): generic resource collection data layer (post/category) feat(admin): generic resource collection data layer (all content domains) Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant