From 4364d121899ae0c8473b5abe56fc4419f8520db0 Mon Sep 17 00:00:00 2001
From: Innei
Date: Fri, 10 Jul 2026 22:19:50 +0800
Subject: [PATCH 01/15] docs: add admin resource collection data layer design
spec
---
...n-resource-collection-data-layer-design.md | 236 ++++++++++++++++++
1 file changed, 236 insertions(+)
create mode 100644 docs/superpowers/specs/2026-07-10-admin-resource-collection-data-layer-design.md
diff --git a/docs/superpowers/specs/2026-07-10-admin-resource-collection-data-layer-design.md b/docs/superpowers/specs/2026-07-10-admin-resource-collection-data-layer-design.md
new file mode 100644
index 00000000000..68b460c635b
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-10-admin-resource-collection-data-layer-design.md
@@ -0,0 +1,236 @@
+# Admin Resource Collection Data Layer — Design
+
+Date: 2026-07-10
+Status: Approved
+Supersedes: PR #2741 POC (`apps/admin/src/data/post-category-resource/`)
+
+## Background
+
+PR #2741 validated a core thesis for admin UI state: business entities should
+live in a normalized client store, with React Query demoted to a transport
+layer, and optimistic writes expressed as pending transactions that survive
+stale query hydration. The POC proved the thesis (9 passing store tests,
+including the stale-hydration case) but its implementation has known defects:
+
+1. `upsertPost` replaces entities wholesale, so a partial list row clobbers a
+ full detail entity (edit-page data-loss risk).
+2. `categoryIds` mixes two sources of truth (managed list membership vs.
+ entities seen via embedded post categories).
+3. `ResourceCategory` union punched through with `as CategoryModel` casts.
+4. Transaction commit/rollback depends on caller-written handlers; a throwing
+ handler leaks pending ops forever.
+5. Selector identity churn re-renders every list subscriber on any store
+ change.
+
+This design replaces the POC with a generic, reusable kernel. Semantics are
+deliberately aligned with TanStack DB (collections, optimistic transactions,
+query-backed collections) so a future migration to it, post-1.0, stays cheap.
+We build our own light kernel now (TanStack DB is beta 0.6).
+
+## Goals
+
+- One generic data layer for all admin entities; post/category migrate first.
+- React Query stays as transport: retry, dedupe, invalidation, devtools.
+ Query cache holds only hydration receipts, never business entities.
+- Optimistic writes with automatic commit/rollback; pending state survives
+ stale hydration.
+- Call-site DX: one-line collection ops for the common case, an explicit
+ transaction for multi-op/batch.
+- No unbounded re-render storms; entity-level subscription granularity.
+
+## Non-Goals
+
+- Entity-level refcount GC (admin is single-user; entity counts are small).
+- Offline persistence, sync engines, cross-tab state.
+- Migrating note/page/comment/etc. in this iteration.
+
+## Architecture
+
+```
+apps/admin/src/data/
+ resource/ # kernel, zero business knowledge
+ collection.ts # defineCollection: entity table + lifecycle
+ transaction.ts # optimistic overlay + createTransaction
+ list-index.ts # query-keyed ordered id lists + pagination
+ hooks.ts # useEntity / useEntityList / useCollectionListQuery / useCollectionDetailQuery
+ key.ts # stable stringify (key-order independent)
+ resources/ # one file per domain
+ post.ts # posts collection: normalize + persistence
+ category.ts # categories collection
+```
+
+## Collection Kernel
+
+Each collection owns an independent zustand store (subscription isolation,
+per-collection reset/GC later).
+
+```ts
+export const categories = defineCollection({
+ name: 'category',
+ getKey: (c) => c.id,
+ onUpdate: ({ id, patch }) => updateCategory(id, patch),
+ onDelete: ({ id }) => deleteCategory(id),
+})
+
+export const posts = defineCollection({
+ name: 'post',
+ getKey: (p) => p.id,
+ normalize: (post) => {
+ if (post.category) categories.upsert(post.category)
+ },
+ onUpdate: ({ id, patch }) => patchPost(id, patch),
+ onDelete: ({ id }) => deletePost(id),
+})
+```
+
+Internal state, three layers; base/overlay separation is what makes pending
+writes survive stale hydration (inherited from the POC):
+
+- `entitiesById` — server truth (base). Only hydration and transaction commit
+ write here.
+- `pendingOpsByKey` — ordered optimistic op chains, layered over base at read
+ time.
+- `versionByKey` — monotonic per-entity version for cheap selector equality.
+
+Rules:
+
+- **Upsert always merges** (`{ ...prev, ...next }` by default), never
+ replaces. A partial list row can no longer clobber a hydrated detail
+ entity. Domains may override with a custom `merge(prev, next)`.
+- **Membership is not collection state.** A collection is only an entity
+ table. "Which categories appear in the management list" is a list-index
+ written exclusively by its owning query. Embedded categories enter the
+ table but never any list.
+
+## Write Path
+
+Common case, one line:
+
+```ts
+posts.update(id, (draft) => {
+ draft.isPublished = true
+})
+```
+
+Internal flow: compute patch via immer → register pending op (overlay is
+immediately visible) → call `onUpdate` → on resolve, commit (merge server
+echo into base, drop op) → on reject, rollback (drop op, record error in
+`errorsByKey`). **Commit/rollback is guaranteed by the kernel in a `finally`
+path — a throwing user handler cannot leak a pending op.**
+
+`posts.delete(id)` and `posts.insert(entity)` follow the same lifecycle with
+`onDelete`/`onInsert`.
+
+Multi-op / batch escape hatch:
+
+```ts
+const tx = createTransaction()
+ids.forEach((id) => tx.delete(posts, id))
+await tx.commit(async () => {
+ const results = await Promise.allSettled(ids.map(deletePost))
+ return { fulfilledKeys: succeeded(results, ids) }
+})
+```
+
+`tx.commit(request)` semantics:
+
+- Request resolves with `{ fulfilledKeys }`: ops on those keys commit, all
+ other ops roll back (partial success).
+- Request resolves without `fulfilledKeys`: all ops commit.
+- Request rejects: all ops roll back, error rethrown.
+
+Side effects (toasts, navigation, query invalidation) live at the call site
+via `.then`/`.catch`, never inside the kernel.
+
+## Read Path
+
+```ts
+const post = useEntity(posts, id) // base ⊕ overlay
+const { items, pagination, status } = useEntityList(posts, queryKey)
+const category = useEntity(categories, post?.categoryId)
+```
+
+Re-render control:
+
+- `useEntity` subscribes to `versionByKey[id]`; unchanged version returns the
+ previous reference.
+- `useEntityList` subscribes to the id array (shallow) plus the sum of member
+ versions; a missing index returns a module-level `EMPTY` constant.
+- Joins (post→category) are composed in the hook layer with memoization.
+ No store-side reverse indexes and no full-table rebuild routines
+ (`postIdsByCategoryId` from the POC is dropped). If a join proves slow
+ later, add a targeted index then.
+
+## React Query Integration
+
+```ts
+const listQuery = useCollectionListQuery(posts, {
+ queryKey: adminQueryKeys.posts.list(params),
+ queryFn: () => getPosts(params),
+ toPage: (r) => ({ items: r.data, pagination: r.pagination }),
+})
+```
+
+- Hydration runs each entity through `normalize` then merge-upsert, and
+ writes the list-index under the stable-serialized query key.
+- The query cache stores only a receipt (`{ hydratedAt }`).
+- `useCollectionDetailQuery(collection, options)` is the single-entity
+ variant.
+- `keepPrevious: true` option on `useEntityList`: while the new key has no
+ index and its query is pending, fall back to the previous key's index —
+ restores the `placeholderData` pagination UX the POC lost.
+
+## Types
+
+No entity unions, no casts. One entity type per collection; progressively
+hydrated fields are explicitly optional:
+
+```ts
+interface CategoryEntity {
+ id: string
+ name: string
+ slug: string
+ type: CategoryType
+ count?: number // present only after a list/detail query hydrates it
+ createdAt?: string
+}
+```
+
+Consumers handle optional fields directly. Type guards that fake completeness
+(`isCategoryModel`) are removed.
+
+## Lifecycle
+
+- `collection.reset()` for every collection on logout.
+- List-index LRU cap per collection (e.g. 50 keys).
+- No entity-level GC in v1 (YAGNI for a single-user admin).
+
+## Error Handling
+
+- Per-entity last error in `errorsByKey`, cleared on the next successful op
+ or hydration for that key.
+- Transaction rollback restores visible state to base ⊕ remaining ops (later
+ pending ops on the same entity replay over base, as in the POC).
+
+## Testing
+
+Kernel unit tests (vitest, node environment), extending the POC's nine store
+tests:
+
+- merge-upsert: partial list row does not clobber detail fields
+- pending op survives stale hydration; commit clears it
+- rollback of one op replays later pending ops
+- throwing `onUpdate`/user handler still releases the pending op
+- partial batch: `fulfilledKeys` commit, remainder rolls back
+- `useEntity`/`useEntityList` reference stability across unrelated writes
+- stable key serialization is object-key-order independent
+- list-index LRU eviction
+
+## Rollout
+
+1. Kernel + unit tests.
+2. Migrate category domain (small surface, validates DX).
+3. Migrate post domain (list, detail, write page — the POC's footprint,
+ rewritten on the new kernel).
+4. Delete `apps/admin/src/data/post-category-resource/`.
+5. note/page/comment and other domains in later iterations.
From ca3a0ec24b96df827b88ddc4cf5abc687d7c0c98 Mon Sep 17 00:00:00 2001
From: Innei
Date: Fri, 10 Jul 2026 22:30:59 +0800
Subject: [PATCH 02/15] feat(admin): add resource collection kernel
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.
---
apps/admin/package.json | 1 +
.../src/data/resource/collection.test.ts | 382 +++++++++++
apps/admin/src/data/resource/collection.ts | 323 +++++++++
apps/admin/src/data/resource/key.ts | 22 +
apps/admin/src/data/resource/transaction.ts | 131 ++++
pnpm-lock.yaml | 612 ++++++------------
6 files changed, 1055 insertions(+), 416 deletions(-)
create mode 100644 apps/admin/src/data/resource/collection.test.ts
create mode 100644 apps/admin/src/data/resource/collection.ts
create mode 100644 apps/admin/src/data/resource/key.ts
create mode 100644 apps/admin/src/data/resource/transaction.ts
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 4c62754957a..9f8c6f21633 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -84,6 +84,7 @@
"event-source-polyfill": "1.0.31",
"exifr": "^7.1.3",
"fuse.js": "7.4.2",
+ "immer": "11.1.8",
"jotai": "2.20.1",
"js-cookie": "3.0.8",
"js-yaml": "5.2.0",
diff --git a/apps/admin/src/data/resource/collection.test.ts b/apps/admin/src/data/resource/collection.test.ts
new file mode 100644
index 00000000000..2caa42e45ba
--- /dev/null
+++ b/apps/admin/src/data/resource/collection.test.ts
@@ -0,0 +1,382 @@
+// @vitest-environment node
+
+import { describe, expect, it, vi } from 'vitest'
+
+import { defineCollection } from './collection'
+import { serializeListKey } from './key'
+import { createTransaction } from './transaction'
+
+interface TestEntity {
+ id: string
+ text?: string
+ title?: string
+ count?: number
+}
+
+describe('serializeListKey', () => {
+ it('does not change output based on object key order', () => {
+ const a = serializeListKey([{ a: 1, b: 2 }])
+ const b = serializeListKey([{ b: 2, a: 1 }])
+ expect(a).toBe(b)
+ })
+
+ it('is order independent for nested objects', () => {
+ const a = serializeListKey([{ outer: { a: 1, b: 2 }, z: 1 }])
+ const b = serializeListKey([{ z: 1, outer: { b: 2, a: 1 } }])
+ expect(a).toBe(b)
+ })
+
+ it('produces different output for different values', () => {
+ const a = serializeListKey([{ a: 1 }])
+ const b = serializeListKey([{ a: 2 }])
+ expect(a).not.toBe(b)
+ })
+
+ it('keeps array order significant', () => {
+ const a = serializeListKey([1, 2, 3])
+ const b = serializeListKey([3, 2, 1])
+ expect(a).not.toBe(b)
+ })
+
+ it('drops undefined values inside objects like JSON.stringify', () => {
+ const a = serializeListKey([{ a: 1, b: undefined }])
+ const b = serializeListKey([{ a: 1 }])
+ expect(a).toBe(b)
+ })
+})
+
+describe('defineCollection: merge-upsert', () => {
+ it('preserves fields absent from a later partial hydrate', () => {
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ })
+
+ collection.hydrate([{ id: '1', text: 'full text', title: 'hello' }])
+ collection.hydrate([{ id: '1', title: 'updated title' }])
+
+ expect(collection.get('1')).toEqual({
+ id: '1',
+ text: 'full text',
+ title: 'updated title',
+ })
+ })
+})
+
+describe('defineCollection: pending update survives stale hydrate', () => {
+ it('keeps optimistic patch visible over a stale hydrate; commit clears it', async () => {
+ let resolveUpdate: (value: TestEntity) => void = () => {}
+ const onUpdate = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveUpdate = resolve
+ }),
+ )
+
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ onUpdate,
+ })
+
+ collection.hydrate([{ id: '1', text: 'original', title: 'original title' }])
+
+ const updatePromise = collection.update('1', (draft) => {
+ draft.title = 'optimistic title'
+ })
+
+ collection.hydrate([{ id: '1', text: 'original', title: 'stale title' }])
+
+ expect(collection.get('1')?.title).toBe('optimistic title')
+
+ resolveUpdate({ id: '1', text: 'original', title: 'server title' })
+ await updatePromise
+
+ expect(collection.get('1')?.title).toBe('server title')
+ expect(collection.getBase('1')?.title).toBe('server title')
+ })
+})
+
+describe('defineCollection: rollback replays remaining pending ops', () => {
+ it('replays the second op over base after the first rolls back', async () => {
+ let rejectFirst: (error: unknown) => void = () => {}
+ const onUpdate = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((_, reject) => {
+ rejectFirst = reject
+ }),
+ )
+ .mockImplementationOnce(({ next }: { next: TestEntity }) =>
+ Promise.resolve(next),
+ )
+
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ onUpdate,
+ })
+
+ collection.hydrate([{ id: '1', count: 0 }])
+
+ const firstUpdate = collection.update('1', (draft) => {
+ draft.count = 1
+ })
+ const secondUpdate = collection.update('1', (draft) => {
+ draft.count = 2
+ })
+
+ expect(collection.get('1')?.count).toBe(2)
+
+ rejectFirst(new Error('boom'))
+ await expect(firstUpdate).rejects.toThrow('boom')
+ await secondUpdate
+
+ expect(collection.get('1')?.count).toBe(2)
+ expect(collection.getBase('1')?.count).toBe(2)
+ })
+})
+
+describe('defineCollection: optimistic delete', () => {
+ it('hides the entity while pending; rollback restores it', async () => {
+ let rejectDelete: (error: unknown) => void = () => {}
+ const onDelete = vi.fn(
+ () =>
+ new Promise((_, reject) => {
+ rejectDelete = reject
+ }),
+ )
+
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ onDelete,
+ })
+
+ collection.hydrate([{ id: '1', text: 'entity' }])
+
+ const deletePromise = collection.delete('1')
+ expect(collection.get('1')).toBeUndefined()
+
+ rejectDelete(new Error('nope'))
+ await expect(deletePromise).rejects.toThrow('nope')
+
+ expect(collection.get('1')).toEqual({ id: '1', text: 'entity' })
+ })
+})
+
+describe('defineCollection: onUpdate rejection', () => {
+ it('releases the op, records the error, rejects, and reverts the visible value', async () => {
+ const onUpdate = vi.fn(() => Promise.reject(new Error('update failed')))
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ onUpdate,
+ })
+
+ collection.hydrate([{ id: '1', title: 'original' }])
+
+ await expect(
+ collection.update('1', (draft) => {
+ draft.title = 'optimistic'
+ }),
+ ).rejects.toThrow('update failed')
+
+ expect(collection.get('1')?.title).toBe('original')
+ expect(collection.store.getState().errorsByKey['1']).toBeInstanceOf(Error)
+ expect(collection.store.getState().pendingOpsByKey['1']).toBeUndefined()
+ })
+})
+
+describe('defineCollection: onUpdate resolving with server echo', () => {
+ it('merges the echo into base', async () => {
+ const onUpdate = vi.fn(() =>
+ Promise.resolve({ id: '1', title: 'server echo', text: 'server text' }),
+ )
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ onUpdate,
+ })
+
+ collection.hydrate([{ id: '1', title: 'original', text: 'original' }])
+
+ await collection.update('1', (draft) => {
+ draft.title = 'optimistic'
+ })
+
+ expect(collection.getBase('1')).toEqual({
+ id: '1',
+ title: 'server echo',
+ text: 'server text',
+ })
+ })
+})
+
+describe('defineCollection: update on unknown entity', () => {
+ it('throws', async () => {
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ })
+
+ await expect(
+ collection.update('missing', (draft) => {
+ draft.title = 'x'
+ }),
+ ).rejects.toThrow('[collection:test] update on unknown entity missing')
+ })
+})
+
+describe('defineCollection: version bumps', () => {
+ it('bumps on hydrate, op begin, commit, and rollback', async () => {
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ onUpdate: () => Promise.reject(new Error('fail')),
+ })
+
+ collection.hydrate([{ id: '1', title: 'a' }])
+ const afterHydrate = collection.store.getState().versionByKey['1']
+ expect(afterHydrate).toBe(1)
+
+ const updatePromise = collection.update('1', (draft) => {
+ draft.title = 'b'
+ })
+ const afterBegin = collection.store.getState().versionByKey['1']
+ expect(afterBegin).toBe(2)
+
+ await expect(updatePromise).rejects.toThrow('fail')
+ const afterRollback = collection.store.getState().versionByKey['1']
+ expect(afterRollback).toBe(3)
+ })
+})
+
+describe('defineCollection: reset', () => {
+ it('restores initial empty state', () => {
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ })
+
+ collection.hydrate([{ id: '1', title: 'a' }])
+ collection.reset()
+
+ expect(collection.store.getState()).toEqual({
+ entitiesById: {},
+ versionByKey: {},
+ pendingOpsByKey: {},
+ errorsByKey: {},
+ })
+ })
+})
+
+describe('createTransaction: partial success', () => {
+ it('commits fulfilled entities and rolls back the rest', async () => {
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ })
+
+ collection.hydrate([
+ { id: '1', title: 'a' },
+ { id: '2', title: 'b' },
+ { id: '3', title: 'c' },
+ ])
+
+ const tx = createTransaction()
+ tx.delete(collection, '1')
+ tx.delete(collection, '2')
+ tx.delete(collection, '3')
+
+ await tx.commit(async () => ({ fulfilledKeys: ['1', '2'] }))
+
+ expect(collection.getBase('1')).toBeUndefined()
+ expect(collection.getBase('2')).toBeUndefined()
+ expect(collection.getBase('3')).toEqual({ id: '3', title: 'c' })
+ expect(collection.get('3')).toEqual({ id: '3', title: 'c' })
+ })
+})
+
+describe('createTransaction: request rejection', () => {
+ it('rolls back everything and rethrows', async () => {
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ })
+
+ collection.hydrate([
+ { id: '1', title: 'a' },
+ { id: '2', title: 'b' },
+ ])
+
+ const tx = createTransaction()
+ tx.delete(collection, '1')
+ tx.delete(collection, '2')
+
+ await expect(
+ tx.commit(async () => {
+ throw new Error('network down')
+ }),
+ ).rejects.toThrow('network down')
+
+ expect(collection.get('1')).toEqual({ id: '1', title: 'a' })
+ expect(collection.get('2')).toEqual({ id: '2', title: 'b' })
+ })
+})
+
+describe('createTransaction: registration after commit', () => {
+ it('throws', async () => {
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ })
+ collection.hydrate([{ id: '1', title: 'a' }])
+
+ const tx = createTransaction()
+ tx.delete(collection, '1')
+ await tx.commit(async () => ({}))
+
+ expect(() => tx.delete(collection, '1')).toThrow()
+ })
+
+ it('throws on double commit', async () => {
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ })
+ collection.hydrate([{ id: '1', title: 'a' }])
+
+ const tx = createTransaction()
+ tx.delete(collection, '1')
+ await tx.commit(async () => ({}))
+
+ await expect(tx.commit(async () => ({}))).rejects.toThrow()
+ })
+})
+
+describe('createTransaction: commit all when no fulfilledKeys', () => {
+ it('commits every registered op', async () => {
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ })
+
+ collection.hydrate([
+ { id: '1', title: 'a' },
+ { id: '2', title: 'b' },
+ ])
+
+ const tx = createTransaction()
+ tx.update(collection, '1', (draft) => {
+ draft.title = 'updated a'
+ })
+ tx.delete(collection, '2')
+
+ await tx.commit(async () => {})
+
+ expect(collection.getBase('1')?.title).toBe('updated a')
+ expect(collection.getBase('2')).toBeUndefined()
+ })
+})
diff --git a/apps/admin/src/data/resource/collection.ts b/apps/admin/src/data/resource/collection.ts
new file mode 100644
index 00000000000..3053aec4a6c
--- /dev/null
+++ b/apps/admin/src/data/resource/collection.ts
@@ -0,0 +1,323 @@
+import { produce } from 'immer'
+import type { StoreApi } from 'zustand/vanilla'
+import { createStore } from 'zustand/vanilla'
+
+export interface CollectionConfig {
+ name: string
+ getKey: (entity: T) => string
+ merge?: (prev: T, next: T) => T
+ normalize?: (entity: T) => void
+ onInsert?: (args: { entity: T }) => Promise
+ onUpdate?: (args: {
+ id: string
+ patch: Partial
+ next: T
+ }) => Promise
+ onDelete?: (args: { id: string }) => Promise
+}
+
+export interface PendingOp {
+ opId: string
+ kind: 'insert' | 'update' | 'delete'
+ entityId: string
+ patch?: Partial
+ entity?: T
+}
+
+export interface CollectionState {
+ entitiesById: Record
+ versionByKey: Record
+ pendingOpsByKey: Record[]>
+ errorsByKey: Record
+}
+
+export interface Collection {
+ name: string
+ store: StoreApi>
+ getKey: (entity: T) => string
+ get: (id: string) => T | undefined
+ getBase: (id: string) => T | undefined
+ hydrate: (entities: T[]) => void
+ upsert: (entity: T) => void
+ update: (id: string, recipe: (draft: T) => void) => Promise
+ insert: (entity: T) => Promise
+ delete: (id: string) => Promise
+ reset: () => void
+ _ops: {
+ begin: (op: Omit, 'opId'>) => string
+ commit: (opId: string, serverEcho?: T) => void
+ rollback: (opId: string, error?: unknown) => void
+ }
+}
+
+let opCounter = 0
+
+function createInitialState(): CollectionState {
+ return {
+ entitiesById: {},
+ versionByKey: {},
+ pendingOpsByKey: {},
+ errorsByKey: {},
+ }
+}
+
+function applyRecipe(base: T, recipe: (draft: T) => void): T {
+ return produce(base, recipe as (draft: T) => void) as T
+}
+
+export function defineCollection(
+ config: CollectionConfig,
+): Collection {
+ const { name, getKey, merge, normalize, onInsert, onUpdate, onDelete } =
+ config
+
+ const store = createStore>()(() => createInitialState())
+
+ function withNextVersion(
+ versionByKey: Record,
+ id: string,
+ ): Record {
+ return { ...versionByKey, [id]: (versionByKey[id] ?? 0) + 1 }
+ }
+
+ function withoutError(
+ errorsByKey: Record,
+ id: string,
+ ): Record {
+ if (!(id in errorsByKey)) return errorsByKey
+ const next = { ...errorsByKey }
+ delete next[id]
+ return next
+ }
+
+ function getBase(id: string): T | undefined {
+ return store.getState().entitiesById[id]
+ }
+
+ function get(id: string): T | undefined {
+ const state = store.getState()
+ let visible: T | undefined = state.entitiesById[id]
+ const ops = state.pendingOpsByKey[id]
+ if (!ops || ops.length === 0) return visible
+
+ for (const op of ops) {
+ switch (op.kind) {
+ case 'update': {
+ if (visible === undefined) continue
+ visible = { ...visible, ...op.patch }
+ break
+ }
+ case 'delete': {
+ return undefined
+ }
+ case 'insert': {
+ if (visible === undefined && op.entity !== undefined) {
+ visible = op.entity
+ }
+ break
+ }
+ }
+ }
+ return visible
+ }
+
+ function upsertToBase(entity: T) {
+ const id = getKey(entity)
+ store.setState((state) => {
+ const prev = state.entitiesById[id]
+ const merged = prev
+ ? merge
+ ? merge(prev, entity)
+ : { ...prev, ...entity }
+ : entity
+ return {
+ entitiesById: { ...state.entitiesById, [id]: merged },
+ versionByKey: withNextVersion(state.versionByKey, id),
+ errorsByKey: withoutError(state.errorsByKey, id),
+ }
+ })
+ }
+
+ function hydrate(entities: T[]) {
+ for (const entity of entities) {
+ normalize?.(entity)
+ upsertToBase(entity)
+ }
+ }
+
+ function upsert(entity: T) {
+ upsertToBase(entity)
+ }
+
+ function begin(op: Omit, 'opId'>): string {
+ const opId = `op-${opCounter++}`
+ store.setState((state) => {
+ const list = state.pendingOpsByKey[op.entityId] ?? []
+ return {
+ pendingOpsByKey: {
+ ...state.pendingOpsByKey,
+ [op.entityId]: [...list, { ...op, opId }],
+ },
+ versionByKey: withNextVersion(state.versionByKey, op.entityId),
+ errorsByKey: withoutError(state.errorsByKey, op.entityId),
+ }
+ })
+ return opId
+ }
+
+ function removeOp(entityId: string, opId: string) {
+ store.setState((state) => {
+ const list = state.pendingOpsByKey[entityId]
+ if (!list) return state
+
+ const filtered = list.filter((op) => op.opId !== opId)
+ const pendingOpsByKey = { ...state.pendingOpsByKey }
+ if (filtered.length > 0) {
+ pendingOpsByKey[entityId] = filtered
+ } else {
+ delete pendingOpsByKey[entityId]
+ }
+
+ return {
+ pendingOpsByKey,
+ versionByKey: withNextVersion(state.versionByKey, entityId),
+ }
+ })
+ }
+
+ function findOp(
+ opId: string,
+ ): { entityId: string; op: PendingOp } | undefined {
+ const state = store.getState()
+ for (const [entityId, ops] of Object.entries(state.pendingOpsByKey)) {
+ const op = ops.find((candidate) => candidate.opId === opId)
+ if (op) return { entityId, op }
+ }
+ return undefined
+ }
+
+ function commit(opId: string, serverEcho?: T) {
+ const found = findOp(opId)
+ if (!found) return
+ const { entityId, op } = found
+
+ removeOp(entityId, opId)
+
+ if (op.kind === 'delete') {
+ store.setState((state) => {
+ const entitiesById = { ...state.entitiesById }
+ delete entitiesById[entityId]
+ return {
+ entitiesById,
+ errorsByKey: withoutError(state.errorsByKey, entityId),
+ versionByKey: withNextVersion(state.versionByKey, entityId),
+ }
+ })
+ return
+ }
+
+ if (serverEcho !== undefined) {
+ upsertToBase(serverEcho)
+ }
+ }
+
+ function rollback(opId: string, error?: unknown) {
+ const found = findOp(opId)
+ if (!found) return
+ const { entityId } = found
+
+ removeOp(entityId, opId)
+ store.setState((state) => ({
+ errorsByKey: { ...state.errorsByKey, [entityId]: error },
+ }))
+ }
+
+ async function update(
+ id: string,
+ recipe: (draft: T) => void,
+ ): Promise {
+ const current = get(id)
+ if (current === undefined) {
+ throw new Error(`[collection:${name}] update on unknown entity ${id}`)
+ }
+
+ const next = applyRecipe(current, recipe)
+ const patch: Partial = {}
+ for (const key of Object.keys(next) as (keyof T)[]) {
+ if (!Object.is(next[key], current[key])) {
+ patch[key] = next[key]
+ }
+ }
+
+ const opId = begin({ kind: 'update', entityId: id, patch })
+
+ if (!onUpdate) {
+ commit(opId, next)
+ return next
+ }
+
+ try {
+ const echo = await onUpdate({ id, patch, next })
+ commit(opId, echo ?? next)
+ return echo ?? next
+ } catch (error) {
+ rollback(opId, error)
+ throw error
+ }
+ }
+
+ async function insert(entity: T): Promise {
+ const id = getKey(entity)
+ const opId = begin({ kind: 'insert', entityId: id, entity })
+
+ if (!onInsert) {
+ commit(opId, entity)
+ return entity
+ }
+
+ try {
+ const echo = await onInsert({ entity })
+ commit(opId, echo ?? entity)
+ return echo ?? entity
+ } catch (error) {
+ rollback(opId, error)
+ throw error
+ }
+ }
+
+ async function del(id: string): Promise {
+ const opId = begin({ kind: 'delete', entityId: id })
+
+ if (!onDelete) {
+ commit(opId)
+ return
+ }
+
+ try {
+ await onDelete({ id })
+ commit(opId)
+ } catch (error) {
+ rollback(opId, error)
+ throw error
+ }
+ }
+
+ function reset() {
+ store.setState(createInitialState(), true)
+ }
+
+ return {
+ name,
+ store,
+ getKey,
+ get,
+ getBase,
+ hydrate,
+ upsert,
+ update,
+ insert,
+ delete: del,
+ reset,
+ _ops: { begin, commit, rollback },
+ }
+}
diff --git a/apps/admin/src/data/resource/key.ts b/apps/admin/src/data/resource/key.ts
new file mode 100644
index 00000000000..f69187bfcfb
--- /dev/null
+++ b/apps/admin/src/data/resource/key.ts
@@ -0,0 +1,22 @@
+function stableStringify(value: unknown): string {
+ if (value === null || typeof value !== 'object') {
+ return JSON.stringify(value)
+ }
+
+ if (Array.isArray(value)) {
+ return `[${value.map((item) => (item === undefined ? 'null' : stableStringify(item))).join(',')}]`
+ }
+
+ const keys = Object.keys(value as Record).sort()
+ const entries: string[] = []
+ for (const key of keys) {
+ const entryValue = (value as Record)[key]
+ if (entryValue === undefined) continue
+ entries.push(`${JSON.stringify(key)}:${stableStringify(entryValue)}`)
+ }
+ return `{${entries.join(',')}}`
+}
+
+export function serializeListKey(queryKey: readonly unknown[]): string {
+ return stableStringify(queryKey)
+}
diff --git a/apps/admin/src/data/resource/transaction.ts b/apps/admin/src/data/resource/transaction.ts
new file mode 100644
index 00000000000..25ba492b050
--- /dev/null
+++ b/apps/admin/src/data/resource/transaction.ts
@@ -0,0 +1,131 @@
+import { produce } from 'immer'
+
+import type { Collection } from './collection'
+
+export interface TransactionResult {
+ fulfilledKeys?: string[]
+}
+
+export interface ResourceTransaction {
+ update: (
+ collection: Collection,
+ id: string,
+ recipe: (draft: T) => void,
+ ) => this
+ delete: (collection: Collection, id: string) => this
+ insert: (collection: Collection, entity: T) => this
+ commit: (
+ request: () => Promise,
+ ) => Promise
+}
+
+interface Registration {
+ entityId: string
+ commitOp: () => void
+ rollbackOp: (error?: unknown) => void
+}
+
+export function createTransaction(): ResourceTransaction {
+ const registrations: Registration[] = []
+ let committed = false
+
+ function assertNotCommitted() {
+ if (committed) {
+ throw new Error('[transaction] cannot register ops after commit')
+ }
+ }
+
+ const tx: ResourceTransaction = {
+ update(
+ collection: Collection,
+ id: string,
+ recipe: (draft: T) => void,
+ ) {
+ assertNotCommitted()
+ const current = collection.get(id)
+ if (current === undefined) {
+ throw new Error(
+ `[collection:${collection.name}] update on unknown entity ${id}`,
+ )
+ }
+
+ const next = produce(current, recipe as (draft: T) => void) as T
+ const patch: Partial = {}
+ for (const key of Object.keys(next) as (keyof T)[]) {
+ if (!Object.is(next[key], current[key])) {
+ patch[key] = next[key]
+ }
+ }
+
+ const opId = collection._ops.begin({
+ kind: 'update',
+ entityId: id,
+ patch,
+ })
+ registrations.push({
+ entityId: id,
+ commitOp: () => collection._ops.commit(opId, next),
+ rollbackOp: (error) => collection._ops.rollback(opId, error),
+ })
+ return tx
+ },
+ delete(collection: Collection, id: string) {
+ assertNotCommitted()
+ const opId = collection._ops.begin({ kind: 'delete', entityId: id })
+ registrations.push({
+ entityId: id,
+ commitOp: () => collection._ops.commit(opId),
+ rollbackOp: (error) => collection._ops.rollback(opId, error),
+ })
+ return tx
+ },
+ insert(collection: Collection, entity: T) {
+ assertNotCommitted()
+ const id = collection.getKey(entity)
+ const opId = collection._ops.begin({
+ kind: 'insert',
+ entityId: id,
+ entity,
+ })
+ registrations.push({
+ entityId: id,
+ commitOp: () => collection._ops.commit(opId, entity),
+ rollbackOp: (error) => collection._ops.rollback(opId, error),
+ })
+ return tx
+ },
+ async commit(
+ request: () => Promise,
+ ) {
+ assertNotCommitted()
+ committed = true
+
+ let result: R
+ try {
+ result = await request()
+ } catch (error) {
+ for (const registration of registrations) {
+ registration.rollbackOp(error)
+ }
+ throw error
+ }
+
+ const fulfilledKeys =
+ result && typeof result === 'object' && 'fulfilledKeys' in result
+ ? result.fulfilledKeys
+ : undefined
+
+ for (const registration of registrations) {
+ if (!fulfilledKeys || fulfilledKeys.includes(registration.entityId)) {
+ registration.commitOp()
+ } else {
+ registration.rollbackOp()
+ }
+ }
+
+ return result
+ },
+ }
+
+ return tx
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index beae02aa3c4..a4da0e1c2ea 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -37,7 +37,7 @@ importers:
version: 4.7.1(content-tag@4.2.0)(prettier-plugin-ember-template-tag@2.1.7(prettier@3.9.4))(prettier@3.9.4)
'@lobehub/eslint-config':
specifier: ^2.1.5
- version: 2.1.5(@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3))(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3)
+ version: 2.1.5(@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3)
'@mx-space/cli':
specifier: workspace:*
version: link:packages/cli
@@ -49,7 +49,7 @@ importers:
version: 10.1.0
eslint:
specifier: ^10.6.0
- version: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ version: 10.6.0(jiti@2.7.0)
lint-staged:
specifier: 17.0.8
version: 17.0.8
@@ -136,7 +136,7 @@ importers:
version: 0.30.0(@haklex/rich-editor@0.30.0(@base-ui/react@1.6.0(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@lexical/extension@0.46.0(typescript@6.0.3))(@lexical/link@0.46.0(typescript@6.0.3))(@lexical/list@0.46.0(typescript@6.0.3))(@lexical/markdown@0.46.0(typescript@6.0.3))(@lexical/react@0.46.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(yjs@13.6.30))(@lexical/rich-text@0.46.0(typescript@6.0.3))(@lexical/table@0.46.0(typescript@6.0.3))(katex@0.17.0)(lexical@0.46.0(typescript@6.0.3))(lucide-react@1.23.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(shiki@4.3.1)(typescript@6.0.3))(@types/react@19.2.17)(immer@11.1.8)(lexical@0.46.0(typescript@6.0.3))(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.6.0(react@19.2.7))
'@haklex/rich-compose':
specifier: 0.30.1
- version: 0.30.1(1ed76c1aa7fc7a9de3569ca4e04d15c3)
+ version: 0.30.1(4835166580ed2b95940b53bc35c6fac7)
'@haklex/rich-diff':
specifier: 0.30.0
version: 0.30.0(@haklex/rich-compose@0.30.1(1ed76c1aa7fc7a9de3569ca4e04d15c3))(@haklex/rich-editor@0.30.0(@base-ui/react@1.6.0(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@lexical/extension@0.46.0(typescript@6.0.3))(@lexical/link@0.46.0(typescript@6.0.3))(@lexical/list@0.46.0(typescript@6.0.3))(@lexical/markdown@0.46.0(typescript@6.0.3))(@lexical/react@0.46.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(yjs@13.6.30))(@lexical/rich-text@0.46.0(typescript@6.0.3))(@lexical/table@0.46.0(typescript@6.0.3))(katex@0.17.0)(lexical@0.46.0(typescript@6.0.3))(lucide-react@1.23.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(shiki@4.3.1)(typescript@6.0.3))(@haklex/rich-style-token@0.30.0(react@19.2.7))(lexical@0.46.0(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -305,6 +305,9 @@ importers:
fuse.js:
specifier: 7.4.2
version: 7.4.2
+ immer:
+ specifier: 11.1.8
+ version: 11.1.8
jotai:
specifier: 2.20.1
version: 2.20.1(@babel/core@8.0.1)(@babel/template@8.0.0)(@types/react@19.2.17)(react@19.2.7)
@@ -849,7 +852,7 @@ importers:
version: 3.1.14
redis-memory-server:
specifier: ^0.17.0
- version: 0.17.0
+ version: 0.17.0(supports-color@5.5.0)
rimraf:
specifier: 6.1.3
version: 6.1.3
@@ -879,7 +882,7 @@ importers:
version: 6.0.0(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)
vite-tsconfig-paths:
specifier: 6.1.1
- version: 6.1.1(typescript@6.0.3)(vite@8.1.3(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))
+ version: 6.1.1(supports-color@5.5.0)(typescript@6.0.3)(vite@8.1.3(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))
vitest:
specifier: 4.1.9
version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6)(vite@8.1.3(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))
@@ -971,7 +974,7 @@ importers:
version: 0.29.0(effect@3.21.4)(vitest@4.1.9)
'@haklex/rich-compose':
specifier: 0.30.1
- version: 0.30.1(1ed76c1aa7fc7a9de3569ca4e04d15c3)
+ version: 0.30.1(4835166580ed2b95940b53bc35c6fac7)
'@haklex/rich-headless':
specifier: 0.30.0
version: 0.30.0(lexical@0.46.0(typescript@6.0.3))(typescript@6.0.3)
@@ -980,7 +983,7 @@ importers:
version: 0.30.0(typescript@6.0.3)
'@haklex/rich-litexml-cli':
specifier: 0.30.1
- version: 0.30.1(36c78dd46cffded22823badc289ea152)
+ version: 0.30.1(34362a7140922261aab67a966eaad37b)
'@lexical/headless':
specifier: ^0.46.0
version: 0.46.0(typescript@6.0.3)
@@ -1108,7 +1111,7 @@ importers:
version: 8.1.3(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)
vite-tsconfig-paths:
specifier: 6.1.1
- version: 6.1.1(typescript@6.0.3)(vite@8.1.3(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))
+ version: 6.1.1(supports-color@5.5.0)(typescript@6.0.3)(vite@8.1.3(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))
vitest:
specifier: 4.1.9
version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.6)(vite@8.1.3(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))
@@ -1179,7 +1182,7 @@ importers:
devDependencies:
express:
specifier: 5.2.1
- version: 5.2.1
+ version: 5.2.1(supports-color@5.5.0)
tsdown:
specifier: 0.22.3
version: 0.22.3(tsx@4.23.0)(typescript@6.0.3)
@@ -11655,11 +11658,6 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
- tsx@4.22.4:
- resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==}
- engines: {node: '>=18.0.0'}
- hasBin: true
-
tsx@4.23.0:
resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==}
engines: {node: '>=18.0.0'}
@@ -13093,7 +13091,7 @@ snapshots:
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/types': 7.29.7
- debug: 4.4.3
+ debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -13326,16 +13324,6 @@ snapshots:
'@cloudflare/workers-types@4.20260702.1': {}
- '@code-inspector/core@1.6.3':
- dependencies:
- '@vue/compiler-dom': 3.5.38
- chalk: 4.1.2
- dotenv: 16.6.1
- launch-ide: 1.4.3
- portfinder: 1.0.38
- transitivePeerDependencies:
- - supports-color
-
'@code-inspector/core@1.6.3(supports-color@5.5.0)':
dependencies:
'@vue/compiler-dom': 3.5.38
@@ -13348,33 +13336,33 @@ snapshots:
'@code-inspector/esbuild@1.6.3':
dependencies:
- '@code-inspector/core': 1.6.3
+ '@code-inspector/core': 1.6.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
'@code-inspector/mako@1.6.3':
dependencies:
- '@code-inspector/core': 1.6.3
+ '@code-inspector/core': 1.6.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
'@code-inspector/turbopack@1.6.3':
dependencies:
- '@code-inspector/core': 1.6.3
+ '@code-inspector/core': 1.6.3(supports-color@5.5.0)
'@code-inspector/webpack': 1.6.3
transitivePeerDependencies:
- supports-color
'@code-inspector/vite@1.6.3':
dependencies:
- '@code-inspector/core': 1.6.3
+ '@code-inspector/core': 1.6.3(supports-color@5.5.0)
chalk: 4.1.1
transitivePeerDependencies:
- supports-color
'@code-inspector/webpack@1.6.3':
dependencies:
- '@code-inspector/core': 1.6.3
+ '@code-inspector/core': 1.6.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -13688,7 +13676,7 @@ snapshots:
'@opentelemetry/api': 1.9.0
'@smithy/node-http-handler': 4.7.3
http-proxy-agent: 7.0.2(supports-color@5.5.0)
- https-proxy-agent: 7.0.6
+ https-proxy-agent: 7.0.6(supports-color@5.5.0)
openai: 6.26.0(ws@8.21.0)(zod@4.4.3)
partial-json: 0.1.7
typebox: 1.1.38
@@ -13725,7 +13713,7 @@ snapshots:
effect: 3.21.4
uuid: 11.1.1
optionalDependencies:
- ioredis: 5.11.1
+ ioredis: 5.11.1(supports-color@5.5.0)
'@effect/platform-node-shared@0.60.0(@effect/cluster@0.58.2(@effect/platform@0.96.2(effect@3.21.4))(@effect/rpc@0.75.1(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1))(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/workflow@0.18.1(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1))(@effect/platform@0.96.2(effect@3.21.4))(@effect/rpc@0.75.1(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(effect@3.21.4))(effect@3.21.4))(@effect/platform@0.96.2(effect@3.21.4))(@effect/rpc@0.75.1(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4)(ioredis@5.11.1))(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(effect@3.21.4)':
dependencies:
@@ -14069,47 +14057,41 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
- '@eslint-community/eslint-plugin-eslint-comments@4.7.2(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))':
+ '@eslint-community/eslint-plugin-eslint-comments@4.7.2(eslint@10.6.0(jiti@2.7.0))':
dependencies:
escape-string-regexp: 4.0.0
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
ignore: 7.0.5
- '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))':
- dependencies:
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
- eslint-visitor-keys: 3.4.3
-
'@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0))':
dependencies:
eslint: 10.6.0(jiti@2.7.0)
eslint-visitor-keys: 3.4.3
- optional: true
'@eslint-community/regexpp@4.12.2': {}
- '@eslint-react/ast@2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)':
+ '@eslint-react/ast@2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@eslint-react/eff': 2.13.0
'@typescript-eslint/types': 8.61.1
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.6.0(jiti@2.7.0)
string-ts: 2.3.1
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@eslint-react/core@2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)':
+ '@eslint-react/core@2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
- '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@eslint-react/eff': 2.13.0
- '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.61.1
'@typescript-eslint/types': 8.61.1
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.6.0(jiti@2.7.0)
ts-pattern: 5.9.0
typescript: 6.0.3
transitivePeerDependencies:
@@ -14117,61 +14099,52 @@ snapshots:
'@eslint-react/eff@2.13.0': {}
- '@eslint-react/eslint-plugin@2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)':
+ '@eslint-react/eslint-plugin@2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@eslint-react/eff': 2.13.0
- '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.61.1
- '@typescript-eslint/type-utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@typescript-eslint/type-utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/types': 8.61.1
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
- eslint-plugin-react-dom: 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint-plugin-react-hooks-extra: 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint-plugin-react-naming-convention: 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint-plugin-react-rsc: 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint-plugin-react-web-api: 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint-plugin-react-x: 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.6.0(jiti@2.7.0)
+ eslint-plugin-react-dom: 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint-plugin-react-hooks-extra: 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint-plugin-react-naming-convention: 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint-plugin-react-rsc: 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint-plugin-react-web-api: 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint-plugin-react-x: 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- '@eslint-react/shared@2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)':
+ '@eslint-react/shared@2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@eslint-react/eff': 2.13.0
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.6.0(jiti@2.7.0)
ts-pattern: 5.9.0
typescript: 6.0.3
zod: 4.4.3
transitivePeerDependencies:
- supports-color
- '@eslint-react/var@2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)':
+ '@eslint-react/var@2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
- '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@eslint-react/eff': 2.13.0
- '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.61.1
'@typescript-eslint/types': 8.61.1
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.6.0(jiti@2.7.0)
ts-pattern: 5.9.0
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@eslint/config-array@0.23.5':
- dependencies:
- '@eslint/object-schema': 3.0.5
- debug: 4.4.3
- minimatch: 10.2.5
- transitivePeerDependencies:
- - supports-color
- optional: true
-
- '@eslint/config-array@0.23.5(supports-color@5.5.0)':
dependencies:
'@eslint/object-schema': 3.0.5
debug: 4.4.3(supports-color@5.5.0)
@@ -14187,9 +14160,9 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
- '@eslint/js@10.0.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))':
+ '@eslint/js@10.0.1(eslint@10.6.0(jiti@2.7.0))':
optionalDependencies:
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
'@eslint/object-schema@3.0.5': {}
@@ -14402,7 +14375,7 @@ snapshots:
- typescript
- use-sync-external-store
- '@haklex/rich-compose@0.30.1(1ed76c1aa7fc7a9de3569ca4e04d15c3)':
+ '@haklex/rich-compose@0.30.1(4835166580ed2b95940b53bc35c6fac7)':
dependencies:
'@haklex/rich-editor': 0.30.0(@base-ui/react@1.6.0(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@lexical/extension@0.46.0(typescript@6.0.3))(@lexical/link@0.46.0(typescript@6.0.3))(@lexical/list@0.46.0(typescript@6.0.3))(@lexical/markdown@0.46.0(typescript@6.0.3))(@lexical/react@0.46.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(yjs@13.6.30))(@lexical/rich-text@0.46.0(typescript@6.0.3))(@lexical/table@0.46.0(typescript@6.0.3))(katex@0.17.0)(lexical@0.46.0(typescript@6.0.3))(lucide-react@1.23.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(shiki@4.3.1)(typescript@6.0.3)
'@haklex/rich-ext-chat': 0.30.0(8952475260347690a1aa8566172628a6)
@@ -14452,7 +14425,7 @@ snapshots:
'@haklex/rich-diff@0.30.0(@haklex/rich-compose@0.30.1(1ed76c1aa7fc7a9de3569ca4e04d15c3))(@haklex/rich-editor@0.30.0(@base-ui/react@1.6.0(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@lexical/extension@0.46.0(typescript@6.0.3))(@lexical/link@0.46.0(typescript@6.0.3))(@lexical/list@0.46.0(typescript@6.0.3))(@lexical/markdown@0.46.0(typescript@6.0.3))(@lexical/react@0.46.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(yjs@13.6.30))(@lexical/rich-text@0.46.0(typescript@6.0.3))(@lexical/table@0.46.0(typescript@6.0.3))(katex@0.17.0)(lexical@0.46.0(typescript@6.0.3))(lucide-react@1.23.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(shiki@4.3.1)(typescript@6.0.3))(@haklex/rich-style-token@0.30.0(react@19.2.7))(lexical@0.46.0(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
- '@haklex/rich-compose': 0.30.1(1ed76c1aa7fc7a9de3569ca4e04d15c3)
+ '@haklex/rich-compose': 0.30.1(4835166580ed2b95940b53bc35c6fac7)
'@haklex/rich-diff-core': 0.30.0(lexical@0.46.0(typescript@6.0.3))
'@haklex/rich-editor': 0.30.0(@base-ui/react@1.6.0(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@lexical/extension@0.46.0(typescript@6.0.3))(@lexical/link@0.46.0(typescript@6.0.3))(@lexical/list@0.46.0(typescript@6.0.3))(@lexical/markdown@0.46.0(typescript@6.0.3))(@lexical/react@0.46.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(yjs@13.6.30))(@lexical/rich-text@0.46.0(typescript@6.0.3))(@lexical/table@0.46.0(typescript@6.0.3))(katex@0.17.0)(lexical@0.46.0(typescript@6.0.3))(lucide-react@1.23.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(shiki@4.3.1)(typescript@6.0.3)
'@haklex/rich-style-token': 0.30.0(react@19.2.7)
@@ -14507,7 +14480,7 @@ snapshots:
'@haklex/rich-ext-ai-agent@0.30.0(b2d4068b0bb58b9098f8e407398e5c5e)':
dependencies:
'@haklex/rich-agent-core': 0.30.0(@haklex/rich-editor@0.30.0(@base-ui/react@1.6.0(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@lexical/extension@0.46.0(typescript@6.0.3))(@lexical/link@0.46.0(typescript@6.0.3))(@lexical/list@0.46.0(typescript@6.0.3))(@lexical/markdown@0.46.0(typescript@6.0.3))(@lexical/react@0.46.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(yjs@13.6.30))(@lexical/rich-text@0.46.0(typescript@6.0.3))(@lexical/table@0.46.0(typescript@6.0.3))(katex@0.17.0)(lexical@0.46.0(typescript@6.0.3))(lucide-react@1.23.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(shiki@4.3.1)(typescript@6.0.3))(@types/react@19.2.17)(immer@11.1.8)(lexical@0.46.0(typescript@6.0.3))(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.6.0(react@19.2.7))
- '@haklex/rich-compose': 0.30.1(1ed76c1aa7fc7a9de3569ca4e04d15c3)
+ '@haklex/rich-compose': 0.30.1(4835166580ed2b95940b53bc35c6fac7)
'@haklex/rich-diff-core': 0.30.0(lexical@0.46.0(typescript@6.0.3))
'@haklex/rich-editor': 0.30.0(@base-ui/react@1.6.0(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@lexical/extension@0.46.0(typescript@6.0.3))(@lexical/link@0.46.0(typescript@6.0.3))(@lexical/list@0.46.0(typescript@6.0.3))(@lexical/markdown@0.46.0(typescript@6.0.3))(@lexical/react@0.46.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(yjs@13.6.30))(@lexical/rich-text@0.46.0(typescript@6.0.3))(@lexical/table@0.46.0(typescript@6.0.3))(katex@0.17.0)(lexical@0.46.0(typescript@6.0.3))(lucide-react@1.23.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(shiki@4.3.1)(typescript@6.0.3)
'@haklex/rich-editor-ui': 0.30.0(@base-ui/react@1.6.0(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@lexical/react@0.46.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(yjs@13.6.30))(lexical@0.46.0(typescript@6.0.3))(lucide-react@1.23.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(shiki@4.3.1)
@@ -14657,9 +14630,9 @@ snapshots:
transitivePeerDependencies:
- typescript
- '@haklex/rich-litexml-cli@0.30.1(36c78dd46cffded22823badc289ea152)':
+ '@haklex/rich-litexml-cli@0.30.1(34362a7140922261aab67a966eaad37b)':
dependencies:
- '@haklex/rich-compose': 0.30.1(1ed76c1aa7fc7a9de3569ca4e04d15c3)
+ '@haklex/rich-compose': 0.30.1(4835166580ed2b95940b53bc35c6fac7)
'@haklex/rich-headless': 0.30.0(lexical@0.46.0(typescript@6.0.3))(typescript@6.0.3)
'@haklex/rich-litexml': 0.30.0(typescript@6.0.3)
'@lexical/headless': 0.46.0(typescript@6.0.3)
@@ -15510,7 +15483,7 @@ snapshots:
'@kwsites/file-exists@1.1.1':
dependencies:
- debug: 4.4.3
+ debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -15847,30 +15820,30 @@ snapshots:
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
- '@lobehub/eslint-config@2.1.5(@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3))(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3)':
+ '@lobehub/eslint-config@2.1.5(@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3)':
dependencies:
- '@eslint-community/eslint-plugin-eslint-comments': 4.7.2(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
- '@eslint-react/eslint-plugin': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint/js': 10.0.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
+ '@eslint-community/eslint-plugin-eslint-comments': 4.7.2(eslint@10.6.0(jiti@2.7.0))
+ '@eslint-react/eslint-plugin': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint/js': 10.0.1(eslint@10.6.0(jiti@2.7.0))
'@next/eslint-plugin-next': 16.2.9
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
- eslint-config-prettier: 9.1.2(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
- eslint-import-resolver-typescript: 4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)
- eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)
- eslint-plugin-jsx-a11y: 6.10.2(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
- eslint-plugin-perfectionist: 5.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint-plugin-react: 7.37.5(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
- eslint-plugin-react-compiler: 19.1.0-rc.2(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)
- eslint-plugin-react-hooks: 5.2.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
- eslint-plugin-react-native: 5.0.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
- eslint-plugin-react-refresh: 0.5.3(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
- eslint-plugin-regexp: 3.1.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
- eslint-plugin-simple-import-sort: 12.1.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
- eslint-plugin-unicorn: 63.0.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
- eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
- eslint-plugin-yml: 3.4.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
+ eslint: 10.6.0(jiti@2.7.0)
+ eslint-config-prettier: 9.1.2(eslint@10.6.0(jiti@2.7.0))
+ eslint-import-resolver-typescript: 4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0))(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)
+ eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)
+ eslint-plugin-jsx-a11y: 6.10.2(eslint@10.6.0(jiti@2.7.0))
+ eslint-plugin-perfectionist: 5.9.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint-plugin-react: 7.37.5(eslint@10.6.0(jiti@2.7.0))
+ eslint-plugin-react-compiler: 19.1.0-rc.2(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)
+ eslint-plugin-react-hooks: 5.2.0(eslint@10.6.0(jiti@2.7.0))
+ eslint-plugin-react-native: 5.0.0(eslint@10.6.0(jiti@2.7.0))
+ eslint-plugin-react-refresh: 0.5.3(eslint@10.6.0(jiti@2.7.0))
+ eslint-plugin-regexp: 3.1.0(eslint@10.6.0(jiti@2.7.0))
+ eslint-plugin-simple-import-sort: 12.1.1(eslint@10.6.0(jiti@2.7.0))
+ eslint-plugin-unicorn: 63.0.0(eslint@10.6.0(jiti@2.7.0))
+ eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))
+ eslint-plugin-yml: 3.4.0(eslint@10.6.0(jiti@2.7.0))
globals: 17.6.0
- typescript-eslint: 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3)
+ typescript-eslint: 8.61.1(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
transitivePeerDependencies:
@@ -17223,7 +17196,7 @@ snapshots:
dependencies:
debug: 4.3.7
notepack.io: 3.0.1
- socket.io-parser: 4.2.6
+ socket.io-parser: 4.2.6(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -17419,7 +17392,7 @@ snapshots:
'@tokenizer/inflate@0.4.1':
dependencies:
- debug: 4.4.3
+ debug: 4.4.3(supports-color@5.5.0)
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
@@ -17781,15 +17754,15 @@ snapshots:
dependencies:
'@types/node': 26.0.1
- '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)':
+ '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.61.1(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.61.1
- '@typescript-eslint/type-utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@typescript-eslint/type-utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.61.1
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -17797,14 +17770,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3)':
+ '@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.61.1
'@typescript-eslint/types': 8.61.1
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.61.1
debug: 4.4.3(supports-color@5.5.0)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -17813,7 +17786,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3)
'@typescript-eslint/types': 8.61.1
- debug: 4.4.3
+ debug: 4.4.3(supports-color@5.5.0)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -17827,13 +17800,13 @@ snapshots:
dependencies:
typescript: 6.0.3
- '@typescript-eslint/type-utils@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)':
+ '@typescript-eslint/type-utils@8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/types': 8.61.1
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- debug: 4.4.3
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ debug: 4.4.3(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
@@ -17847,7 +17820,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3)
'@typescript-eslint/types': 8.61.1
'@typescript-eslint/visitor-keys': 8.61.1
- debug: 4.4.3
+ debug: 4.4.3(supports-color@5.5.0)
minimatch: 10.2.5
semver: 7.8.5
tinyglobby: 0.2.17
@@ -17856,13 +17829,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)':
+ '@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0))
'@typescript-eslint/scope-manager': 8.61.1
'@typescript-eslint/types': 8.61.1
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -18733,20 +18706,6 @@ snapshots:
blake3-wasm@2.1.5: {}
- body-parser@2.3.0:
- dependencies:
- bytes: 3.1.2
- content-type: 2.0.0
- debug: 4.4.3
- http-errors: 2.0.1
- iconv-lite: 0.7.2
- on-finished: 2.4.1
- qs: 6.15.3
- raw-body: 3.0.2
- type-is: 2.1.0
- transitivePeerDependencies:
- - supports-color
-
body-parser@2.3.0(supports-color@5.5.0):
dependencies:
bytes: 3.1.2
@@ -19415,10 +19374,6 @@ snapshots:
dependencies:
ms: 2.1.3
- debug@4.4.3:
- dependencies:
- ms: 2.1.3
-
debug@4.4.3(supports-color@10.2.2):
dependencies:
ms: 2.1.3
@@ -19510,7 +19465,7 @@ snapshots:
docker-modem@5.0.7:
dependencies:
- debug: 4.4.3
+ debug: 4.4.3(supports-color@5.5.0)
readable-stream: 3.6.2
split-ca: 1.0.1
ssh2: 1.17.0
@@ -19571,7 +19526,7 @@ snapshots:
'@drizzle-team/brocli': 0.10.2
'@esbuild-kit/esm-loader': 2.6.5
esbuild: 0.25.12
- tsx: 4.22.4
+ tsx: 4.23.0
drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260701.1)(@opentelemetry/api@1.9.0)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.22.0):
optionalDependencies:
@@ -19660,7 +19615,7 @@ snapshots:
base64id: 2.0.0
cookie: 0.7.2
cors: 2.8.6
- debug: 4.4.3
+ debug: 4.4.3(supports-color@5.5.0)
engine.io-parser: 5.2.3
ws: 8.21.0
transitivePeerDependencies:
@@ -19901,9 +19856,9 @@ snapshots:
escape-string-regexp@5.0.0: {}
- eslint-config-prettier@9.1.2(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0)):
+ eslint-config-prettier@9.1.2(eslint@10.6.0(jiti@2.7.0)):
dependencies:
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
eslint-import-context@0.1.9(unrs-resolver@1.12.2):
dependencies:
@@ -19912,10 +19867,10 @@ snapshots:
optionalDependencies:
unrs-resolver: 1.12.2
- eslint-import-resolver-typescript@4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0):
+ eslint-import-resolver-typescript@4.4.5(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0))(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0):
dependencies:
debug: 4.4.3(supports-color@5.5.0)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
eslint-import-context: 0.1.9(unrs-resolver@1.12.2)
get-tsconfig: 4.14.0
is-bun-module: 2.0.0
@@ -19923,17 +19878,17 @@ snapshots:
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
- eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)
+ eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
- eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0):
+ eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0):
dependencies:
'@package-json/types': 0.0.12
'@typescript-eslint/types': 8.61.1
comment-parser: 1.4.7
debug: 4.4.3(supports-color@5.5.0)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
eslint-import-context: 0.1.9(unrs-resolver@1.12.2)
is-glob: 4.0.3
minimatch: 10.2.5
@@ -19941,11 +19896,11 @@ snapshots:
stable-hash-x: 0.2.0
unrs-resolver: 1.12.2
optionalDependencies:
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
transitivePeerDependencies:
- supports-color
- eslint-plugin-jsx-a11y@6.10.2(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0)):
+ eslint-plugin-jsx-a11y@6.10.2(eslint@10.6.0(jiti@2.7.0)):
dependencies:
aria-query: 5.3.2
array-includes: 3.1.9
@@ -19955,7 +19910,7 @@ snapshots:
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
hasown: 2.0.4
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@@ -19964,78 +19919,78 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
- eslint-plugin-perfectionist@5.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3):
+ eslint-plugin-perfectionist@5.9.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3):
dependencies:
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.6.0(jiti@2.7.0)
natural-orderby: 5.0.0
transitivePeerDependencies:
- supports-color
- typescript
- eslint-plugin-react-compiler@19.1.0-rc.2(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0):
+ eslint-plugin-react-compiler@19.1.0-rc.2(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0):
dependencies:
'@babel/core': 7.29.7(supports-color@5.5.0)
'@babel/parser': 7.29.7
'@babel/plugin-proposal-private-methods': 7.18.6(@babel/core@7.29.7(supports-color@5.5.0))
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
hermes-parser: 0.25.1
zod: 3.25.76
zod-validation-error: 3.5.4(zod@3.25.76)
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-dom@2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3):
+ eslint-plugin-react-dom@2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3):
dependencies:
- '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/core': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/core': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@eslint-react/eff': 2.13.0
- '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.61.1
'@typescript-eslint/types': 8.61.1
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
compare-versions: 6.1.1
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
ts-pattern: 5.9.0
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-hooks-extra@2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3):
+ eslint-plugin-react-hooks-extra@2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3):
dependencies:
- '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/core': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/core': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@eslint-react/eff': 2.13.0
- '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.61.1
- '@typescript-eslint/type-utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@typescript-eslint/type-utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/types': 8.61.1
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.6.0(jiti@2.7.0)
ts-pattern: 5.9.0
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-hooks@5.2.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0)):
+ eslint-plugin-react-hooks@5.2.0(eslint@10.6.0(jiti@2.7.0)):
dependencies:
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
- eslint-plugin-react-naming-convention@2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3):
+ eslint-plugin-react-naming-convention@2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3):
dependencies:
- '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/core': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/core': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@eslint-react/eff': 2.13.0
- '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.61.1
- '@typescript-eslint/type-utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@typescript-eslint/type-utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/types': 8.61.1
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
compare-versions: 6.1.1
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
string-ts: 2.3.1
ts-pattern: 5.9.0
typescript: 6.0.3
@@ -20044,66 +19999,66 @@ snapshots:
eslint-plugin-react-native-globals@0.1.2: {}
- eslint-plugin-react-native@5.0.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0)):
+ eslint-plugin-react-native@5.0.0(eslint@10.6.0(jiti@2.7.0)):
dependencies:
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
eslint-plugin-react-native-globals: 0.1.2
- eslint-plugin-react-refresh@0.5.3(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0)):
+ eslint-plugin-react-refresh@0.5.3(eslint@10.6.0(jiti@2.7.0)):
dependencies:
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
- eslint-plugin-react-rsc@2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3):
+ eslint-plugin-react-rsc@2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3):
dependencies:
- '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/types': 8.61.1
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.6.0(jiti@2.7.0)
ts-pattern: 5.9.0
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-web-api@2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3):
+ eslint-plugin-react-web-api@2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3):
dependencies:
- '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/core': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/core': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@eslint-react/eff': 2.13.0
- '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.61.1
'@typescript-eslint/types': 8.61.1
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
birecord: 0.1.1
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
ts-pattern: 5.9.0
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-x@2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3):
+ eslint-plugin-react-x@2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3):
dependencies:
- '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/core': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/ast': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/core': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@eslint-react/eff': 2.13.0
- '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@eslint-react/shared': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@eslint-react/var': 2.13.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.61.1
- '@typescript-eslint/type-utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@typescript-eslint/type-utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/types': 8.61.1
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
compare-versions: 6.1.1
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
- is-immutable-type: 5.0.4(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ eslint: 10.6.0(jiti@2.7.0)
+ is-immutable-type: 5.0.4(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
ts-api-utils: 2.5.0(typescript@6.0.3)
ts-pattern: 5.9.0
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
- eslint-plugin-react@7.37.5(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0)):
+ eslint-plugin-react@7.37.5(eslint@10.6.0(jiti@2.7.0)):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
@@ -20111,7 +20066,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.3.3
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
estraverse: 5.3.0
hasown: 2.0.4
jsx-ast-utils: 3.3.5
@@ -20125,30 +20080,30 @@ snapshots:
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
- eslint-plugin-regexp@3.1.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0)):
+ eslint-plugin-regexp@3.1.0(eslint@10.6.0(jiti@2.7.0)):
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0))
'@eslint-community/regexpp': 4.12.2
comment-parser: 1.4.7
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
jsdoc-type-pratt-parser: 7.2.0
refa: 0.12.1
regexp-ast-analysis: 0.7.1
scslre: 0.3.0
- eslint-plugin-simple-import-sort@12.1.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0)):
+ eslint-plugin-simple-import-sort@12.1.1(eslint@10.6.0(jiti@2.7.0)):
dependencies:
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
- eslint-plugin-unicorn@63.0.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0)):
+ eslint-plugin-unicorn@63.0.0(eslint@10.6.0(jiti@2.7.0)):
dependencies:
'@babel/helper-validator-identifier': 7.29.7
- '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0))
change-case: 5.4.4
ci-info: 4.4.0
clean-regexp: 1.0.0
core-js-compat: 3.49.0
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
find-up-simple: 1.0.1
globals: 16.5.0
indent-string: 5.0.0
@@ -20160,20 +20115,20 @@ snapshots:
semver: 7.8.5
strip-indent: 4.1.1
- eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0)):
+ eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)):
dependencies:
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
optionalDependencies:
- '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
+ '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
- eslint-plugin-yml@3.4.0(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0)):
+ eslint-plugin-yml@3.4.0(eslint@10.6.0(jiti@2.7.0)):
dependencies:
'@eslint/core': 1.2.1
'@eslint/plugin-kit': 0.7.2
'@ota-meshi/ast-token-store': 0.3.0
diff-sequences: 29.6.3
escape-string-regexp: 5.0.0
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ eslint: 10.6.0(jiti@2.7.0)
natural-compare: 1.4.0
yaml-eslint-parser: 2.0.0
@@ -20207,44 +20162,6 @@ snapshots:
'@types/estree': 1.0.9
ajv: 6.15.0
cross-spawn: 7.0.6
- debug: 4.4.3
- escape-string-regexp: 4.0.0
- eslint-scope: 9.1.2
- eslint-visitor-keys: 5.0.1
- espree: 11.2.0
- esquery: 1.7.0
- esutils: 2.0.3
- fast-deep-equal: 3.1.3
- file-entry-cache: 8.0.0
- find-up: 5.0.0
- glob-parent: 6.0.2
- ignore: 5.3.2
- imurmurhash: 0.1.4
- is-glob: 4.0.3
- json-stable-stringify-without-jsonify: 1.0.1
- minimatch: 10.2.5
- natural-compare: 1.4.0
- optionator: 0.9.4
- optionalDependencies:
- jiti: 2.7.0
- transitivePeerDependencies:
- - supports-color
- optional: true
-
- eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0):
- dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))
- '@eslint-community/regexpp': 4.12.2
- '@eslint/config-array': 0.23.5(supports-color@5.5.0)
- '@eslint/config-helpers': 0.6.0
- '@eslint/core': 1.2.1
- '@eslint/plugin-kit': 0.7.2
- '@humanfs/node': 0.16.8
- '@humanwhocodes/module-importer': 1.0.1
- '@humanwhocodes/retry': 0.4.3
- '@types/estree': 1.0.9
- ajv: 6.15.0
- cross-spawn: 7.0.6
debug: 4.4.3(supports-color@5.5.0)
escape-string-regexp: 4.0.0
eslint-scope: 9.1.2
@@ -20347,39 +20264,6 @@ snapshots:
expect-type@1.3.0: {}
- express@5.2.1:
- dependencies:
- accepts: 2.0.0
- body-parser: 2.3.0
- content-disposition: 1.1.0
- content-type: 1.0.5
- cookie: 0.7.2
- cookie-signature: 1.2.2
- debug: 4.4.3
- depd: 2.0.0
- encodeurl: 2.0.0
- escape-html: 1.0.3
- etag: 1.8.1
- finalhandler: 2.1.1
- fresh: 2.0.0
- http-errors: 2.0.1
- merge-descriptors: 2.0.0
- mime-types: 3.0.2
- on-finished: 2.4.1
- once: 1.4.0
- parseurl: 1.3.3
- proxy-addr: 2.0.7
- qs: 6.15.3
- range-parser: 1.2.1
- router: 2.2.0
- send: 1.2.1
- serve-static: 2.2.1
- statuses: 2.0.2
- type-is: 2.1.0
- vary: 1.1.2
- transitivePeerDependencies:
- - supports-color
-
express@5.2.1(supports-color@5.5.0):
dependencies:
accepts: 2.0.0
@@ -20569,17 +20453,6 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
- finalhandler@2.1.1:
- dependencies:
- debug: 4.4.3
- encodeurl: 2.0.0
- escape-html: 1.0.3
- on-finished: 2.4.1
- parseurl: 1.3.3
- statuses: 2.0.2
- transitivePeerDependencies:
- - supports-color
-
finalhandler@2.1.1(supports-color@5.5.0):
dependencies:
debug: 4.4.3(supports-color@5.5.0)
@@ -20730,7 +20603,7 @@ snapshots:
gaxios@7.1.5:
dependencies:
extend: 3.0.2
- https-proxy-agent: 7.0.6
+ https-proxy-agent: 7.0.6(supports-color@5.5.0)
node-fetch: 3.3.2
transitivePeerDependencies:
- supports-color
@@ -21070,13 +20943,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- https-proxy-agent@7.0.6:
- dependencies:
- agent-base: 7.1.4
- debug: 4.4.3
- transitivePeerDependencies:
- - supports-color
-
https-proxy-agent@7.0.6(supports-color@5.5.0):
dependencies:
agent-base: 7.1.4
@@ -21163,19 +21029,6 @@ snapshots:
internmap@2.0.3: {}
- ioredis@5.11.1:
- dependencies:
- '@ioredis/commands': 1.10.0
- cluster-key-slot: 1.1.1
- debug: 4.4.3
- denque: 2.1.0
- redis-errors: 1.2.0
- redis-parser: 3.0.0
- standard-as-callback: 2.1.0
- transitivePeerDependencies:
- - supports-color
- optional: true
-
ioredis@5.11.1(supports-color@5.5.0):
dependencies:
'@ioredis/commands': 1.10.0
@@ -21289,10 +21142,10 @@ snapshots:
is-hexadecimal@2.0.1: {}
- is-immutable-type@5.0.4(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3):
+ is-immutable-type@5.0.4(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3):
dependencies:
- '@typescript-eslint/type-utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ '@typescript-eslint/type-utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.6.0(jiti@2.7.0)
ts-api-utils: 2.5.0(typescript@6.0.3)
ts-declaration-location: 1.0.7(typescript@6.0.3)
typescript: 6.0.3
@@ -22248,7 +22101,7 @@ snapshots:
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.13
- debug: 4.4.3
+ debug: 4.4.3(supports-color@5.5.0)
decode-named-character-reference: 1.3.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
@@ -22850,13 +22703,6 @@ snapshots:
path-data-parser: 0.1.0
points-on-curve: 0.2.0
- portfinder@1.0.38:
- dependencies:
- async: 3.2.6
- debug: 4.4.3
- transitivePeerDependencies:
- - supports-color
-
portfinder@1.0.38(supports-color@5.5.0):
dependencies:
async: 3.2.6
@@ -23143,23 +22989,6 @@ snapshots:
redis-errors@1.2.0: {}
- redis-memory-server@0.17.0:
- dependencies:
- camelcase: 6.3.0
- cross-spawn: 7.0.6
- debug: 4.4.3
- find-cache-dir: 3.3.2
- find-package-json: 1.2.0
- get-port: 5.1.1
- https-proxy-agent: 7.0.6
- lockfile: 1.0.4
- rimraf: 5.0.10
- semver: 7.8.5
- tar: 7.5.16
- tmp: 0.2.7
- transitivePeerDependencies:
- - supports-color
-
redis-memory-server@0.17.0(supports-color@5.5.0):
dependencies:
camelcase: 6.3.0
@@ -23438,16 +23267,6 @@ snapshots:
points-on-curve: 0.2.0
points-on-path: 0.2.1
- router@2.2.0:
- dependencies:
- debug: 4.4.3
- depd: 2.0.0
- is-promise: 4.0.0
- parseurl: 1.3.3
- path-to-regexp: 8.4.2
- transitivePeerDependencies:
- - supports-color
-
router@2.2.0(supports-color@5.5.0):
dependencies:
debug: 4.4.3(supports-color@5.5.0)
@@ -23546,22 +23365,6 @@ snapshots:
semver@7.8.5: {}
- send@1.2.1:
- dependencies:
- debug: 4.4.3
- encodeurl: 2.0.0
- escape-html: 1.0.3
- etag: 1.8.1
- fresh: 2.0.0
- http-errors: 2.0.1
- mime-types: 3.0.2
- ms: 2.1.3
- on-finished: 2.4.1
- range-parser: 1.2.1
- statuses: 2.0.2
- transitivePeerDependencies:
- - supports-color
-
send@1.2.1(supports-color@5.5.0):
dependencies:
debug: 4.4.3(supports-color@5.5.0)
@@ -23583,7 +23386,7 @@ snapshots:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
- send: 1.2.1
+ send: 1.2.1(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -23762,7 +23565,7 @@ snapshots:
socket.io-adapter@2.5.8:
dependencies:
- debug: 4.4.3
+ debug: 4.4.3(supports-color@5.5.0)
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
@@ -23780,13 +23583,6 @@ snapshots:
- supports-color
- utf-8-validate
- socket.io-parser@4.2.6:
- dependencies:
- '@socket.io/component-emitter': 3.1.2
- debug: 4.4.3
- transitivePeerDependencies:
- - supports-color
-
socket.io-parser@4.2.6(supports-color@5.5.0):
dependencies:
'@socket.io/component-emitter': 3.1.2
@@ -23799,10 +23595,10 @@ snapshots:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.6
- debug: 4.4.3
+ debug: 4.4.3(supports-color@5.5.0)
engine.io: 6.6.9
socket.io-adapter: 2.5.8
- socket.io-parser: 4.2.6
+ socket.io-parser: 4.2.6(supports-color@5.5.0)
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -24180,7 +23976,7 @@ snapshots:
archiver: 7.0.1
async-lock: 1.4.1
byline: 5.0.0
- debug: 4.4.3
+ debug: 4.4.3(supports-color@5.5.0)
docker-compose: 1.4.2
dockerode: 5.0.0
get-port: 5.1.1
@@ -24343,12 +24139,6 @@ snapshots:
tslib@2.8.1: {}
- tsx@4.22.4:
- dependencies:
- esbuild: 0.28.1
- optionalDependencies:
- fsevents: 2.3.3
-
tsx@4.23.0:
dependencies:
esbuild: 0.28.1
@@ -24422,13 +24212,13 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
- typescript-eslint@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3):
+ typescript-eslint@8.61.1(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- '@typescript-eslint/parser': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@6.0.3)
+ '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.61.1(eslint@10.6.0(jiti@2.7.0))(supports-color@5.5.0)(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
- '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0)(supports-color@5.5.0))(typescript@6.0.3)
- eslint: 10.6.0(jiti@2.7.0)(supports-color@5.5.0)
+ '@typescript-eslint/utils': 8.61.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.6.0(jiti@2.7.0)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -24703,16 +24493,6 @@ snapshots:
- supports-color
- typescript
- vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.1.3(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)):
- dependencies:
- debug: 4.4.3
- globrex: 0.1.2
- tsconfck: 3.1.6(typescript@6.0.3)
- vite: 8.1.3(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)
- transitivePeerDependencies:
- - supports-color
- - typescript
-
vite@8.1.3(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
From 16d558b5d42dedca9e193b5cbb535b6623e05bfb Mon Sep 17 00:00:00 2001
From: Innei
Date: Fri, 10 Jul 2026 22:38:28 +0800
Subject: [PATCH 03/15] fix(admin): don't record empty rollback errors, dedupe
update-op derivation
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.
---
.../src/data/resource/collection.test.ts | 66 +++++++++++++++++++
apps/admin/src/data/resource/collection.ts | 24 ++++---
apps/admin/src/data/resource/transaction.ts | 11 +---
3 files changed, 84 insertions(+), 17 deletions(-)
diff --git a/apps/admin/src/data/resource/collection.test.ts b/apps/admin/src/data/resource/collection.test.ts
index 2caa42e45ba..f5a6f3ec7cc 100644
--- a/apps/admin/src/data/resource/collection.test.ts
+++ b/apps/admin/src/data/resource/collection.test.ts
@@ -251,6 +251,24 @@ describe('defineCollection: version bumps', () => {
const afterRollback = collection.store.getState().versionByKey['1']
expect(afterRollback).toBe(3)
})
+
+ it('strictly increases across a successful commit', async () => {
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ onUpdate: ({ next }) => Promise.resolve(next),
+ })
+
+ collection.hydrate([{ id: '1', title: 'a' }])
+ const beforeUpdate = collection.store.getState().versionByKey['1']
+
+ await collection.update('1', (draft) => {
+ draft.title = 'b'
+ })
+
+ const afterCommit = collection.store.getState().versionByKey['1']
+ expect(afterCommit).toBeGreaterThan(beforeUpdate)
+ })
})
describe('defineCollection: reset', () => {
@@ -356,6 +374,54 @@ describe('createTransaction: registration after commit', () => {
})
})
+describe('createTransaction: partial success does not record an error for the rolled-back entity', () => {
+ it('leaves errorsByKey untouched for the entity rolled back without an error', async () => {
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ })
+
+ collection.hydrate([
+ { id: '1', title: 'a' },
+ { id: '2', title: 'b' },
+ ])
+
+ const tx = createTransaction()
+ tx.delete(collection, '1')
+ tx.delete(collection, '2')
+
+ await tx.commit(async () => ({ fulfilledKeys: ['1'] }))
+
+ const state = collection.store.getState()
+ expect('2' in state.errorsByKey).toBe(false)
+ })
+})
+
+describe('createTransaction: update with partial fulfilledKeys', () => {
+ it('lands the optimistic value in base after commit and clears the pending op', async () => {
+ const collection = defineCollection({
+ name: 'test',
+ getKey: (e) => e.id,
+ })
+
+ collection.hydrate([
+ { id: '1', title: 'a' },
+ { id: '2', title: 'b' },
+ ])
+
+ const tx = createTransaction()
+ tx.update(collection, '1', (draft) => {
+ draft.title = 'updated a'
+ })
+ tx.delete(collection, '2')
+
+ await tx.commit(async () => ({ fulfilledKeys: ['1'] }))
+
+ expect(collection.getBase('1')?.title).toBe('updated a')
+ expect(collection.store.getState().pendingOpsByKey['1']).toBeUndefined()
+ })
+})
+
describe('createTransaction: commit all when no fulfilledKeys', () => {
it('commits every registered op', async () => {
const collection = defineCollection({
diff --git a/apps/admin/src/data/resource/collection.ts b/apps/admin/src/data/resource/collection.ts
index 3053aec4a6c..d31e6c16d23 100644
--- a/apps/admin/src/data/resource/collection.ts
+++ b/apps/admin/src/data/resource/collection.ts
@@ -65,6 +65,20 @@ function applyRecipe(base: T, recipe: (draft: T) => void): T {
return produce(base, recipe as (draft: T) => void) as T
}
+export function deriveUpdate(
+ current: T,
+ recipe: (draft: T) => void,
+): { next: T; patch: Partial } {
+ const next = applyRecipe(current, recipe)
+ const patch: Partial = {}
+ for (const key of Object.keys(next) as (keyof T)[]) {
+ if (!Object.is(next[key], current[key])) {
+ patch[key] = next[key]
+ }
+ }
+ return { next, patch }
+}
+
export function defineCollection(
config: CollectionConfig,
): Collection {
@@ -103,7 +117,6 @@ export function defineCollection(
for (const op of ops) {
switch (op.kind) {
case 'update': {
- if (visible === undefined) continue
visible = { ...visible, ...op.patch }
break
}
@@ -227,6 +240,7 @@ export function defineCollection(
const { entityId } = found
removeOp(entityId, opId)
+ if (error === undefined) return
store.setState((state) => ({
errorsByKey: { ...state.errorsByKey, [entityId]: error },
}))
@@ -241,13 +255,7 @@ export function defineCollection(
throw new Error(`[collection:${name}] update on unknown entity ${id}`)
}
- const next = applyRecipe(current, recipe)
- const patch: Partial = {}
- for (const key of Object.keys(next) as (keyof T)[]) {
- if (!Object.is(next[key], current[key])) {
- patch[key] = next[key]
- }
- }
+ const { next, patch } = deriveUpdate(current, recipe)
const opId = begin({ kind: 'update', entityId: id, patch })
diff --git a/apps/admin/src/data/resource/transaction.ts b/apps/admin/src/data/resource/transaction.ts
index 25ba492b050..2bbcd023bed 100644
--- a/apps/admin/src/data/resource/transaction.ts
+++ b/apps/admin/src/data/resource/transaction.ts
@@ -1,6 +1,5 @@
-import { produce } from 'immer'
-
import type { Collection } from './collection'
+import { deriveUpdate } from './collection'
export interface TransactionResult {
fulfilledKeys?: string[]
@@ -49,13 +48,7 @@ export function createTransaction(): ResourceTransaction {
)
}
- const next = produce(current, recipe as (draft: T) => void) as T
- const patch: Partial = {}
- for (const key of Object.keys(next) as (keyof T)[]) {
- if (!Object.is(next[key], current[key])) {
- patch[key] = next[key]
- }
- }
+ const { next, patch } = deriveUpdate(current, recipe)
const opId = collection._ops.begin({
kind: 'update',
From ad8d118a6be6e248c5bbfbb28c7e73401fe14235 Mon Sep 17 00:00:00 2001
From: Innei
Date: Fri, 10 Jul 2026 22:49:30 +0800
Subject: [PATCH 04/15] feat(admin): add resource list index and hooks
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 with listIndexes, keeping React Query as a transport-only
layer that stores hydration receipts.
---
.../src/data/resource/collection.test.ts | 1 +
apps/admin/src/data/resource/collection.ts | 4 +
apps/admin/src/data/resource/hooks.test.tsx | 308 ++++++++++++++++++
apps/admin/src/data/resource/hooks.ts | 223 +++++++++++++
apps/admin/src/data/resource/list-index.ts | 90 +++++
5 files changed, 626 insertions(+)
create mode 100644 apps/admin/src/data/resource/hooks.test.tsx
create mode 100644 apps/admin/src/data/resource/hooks.ts
create mode 100644 apps/admin/src/data/resource/list-index.ts
diff --git a/apps/admin/src/data/resource/collection.test.ts b/apps/admin/src/data/resource/collection.test.ts
index f5a6f3ec7cc..f29f71fa0ef 100644
--- a/apps/admin/src/data/resource/collection.test.ts
+++ b/apps/admin/src/data/resource/collection.test.ts
@@ -286,6 +286,7 @@ describe('defineCollection: reset', () => {
versionByKey: {},
pendingOpsByKey: {},
errorsByKey: {},
+ listIndexes: {},
})
})
})
diff --git a/apps/admin/src/data/resource/collection.ts b/apps/admin/src/data/resource/collection.ts
index d31e6c16d23..abbed92e2f1 100644
--- a/apps/admin/src/data/resource/collection.ts
+++ b/apps/admin/src/data/resource/collection.ts
@@ -2,6 +2,8 @@ import { produce } from 'immer'
import type { StoreApi } from 'zustand/vanilla'
import { createStore } from 'zustand/vanilla'
+import type { ListIndex } from './list-index'
+
export interface CollectionConfig {
name: string
getKey: (entity: T) => string
@@ -29,6 +31,7 @@ export interface CollectionState {
versionByKey: Record
pendingOpsByKey: Record[]>
errorsByKey: Record
+ listIndexes: Record
}
export interface Collection {
@@ -58,6 +61,7 @@ function createInitialState(): CollectionState {
versionByKey: {},
pendingOpsByKey: {},
errorsByKey: {},
+ listIndexes: {},
}
}
diff --git a/apps/admin/src/data/resource/hooks.test.tsx b/apps/admin/src/data/resource/hooks.test.tsx
new file mode 100644
index 00000000000..fd4df499ecc
--- /dev/null
+++ b/apps/admin/src/data/resource/hooks.test.tsx
@@ -0,0 +1,308 @@
+import { act, createElement, useEffect } from 'react'
+import type { Root } from 'react-dom/client'
+import { createRoot } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import type { Collection } from './collection'
+import { defineCollection } from './collection'
+import type { EntityListResult } from './hooks'
+import { useEntity, useEntityList } from './hooks'
+import { serializeListKey } from './key'
+import { hydrateList, readList } from './list-index'
+
+interface TestEntity {
+ id: string
+ title?: string
+}
+
+function makeCollection(name = 'test'): Collection {
+ return defineCollection({ name, getKey: (e) => e.id })
+}
+
+const mounted: (() => void)[] = []
+
+afterEach(() => {
+ while (mounted.length > 0) {
+ mounted.pop()?.()
+ }
+})
+
+function mountEntity(
+ collection: Collection,
+ id: string | undefined,
+) {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root: Root = createRoot(container)
+ const box: { value: TestEntity | undefined } = { value: undefined }
+
+ function Probe({ id }: { id: string | undefined }) {
+ const value = useEntity(collection, id)
+ useEffect(() => {
+ box.value = value
+ })
+ box.value = value
+ return null
+ }
+
+ act(() => {
+ root.render(createElement(Probe, { id }))
+ })
+
+ const unmount = () => {
+ act(() => root.unmount())
+ container.remove()
+ }
+ mounted.push(unmount)
+
+ return {
+ get value() {
+ return box.value
+ },
+ rerender: (nextId: string | undefined) => {
+ act(() => {
+ root.render(createElement(Probe, { id: nextId }))
+ })
+ },
+ unmount,
+ }
+}
+
+function mountEntityList(
+ collection: Collection,
+ initialQueryKey: readonly unknown[],
+ options?: { keepPrevious?: boolean },
+) {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root: Root = createRoot(container)
+ const box: { value: EntityListResult | undefined } = {
+ value: undefined,
+ }
+
+ function Probe({ queryKey }: { queryKey: readonly unknown[] }) {
+ const value = useEntityList(collection, queryKey, options)
+ useEffect(() => {
+ box.value = value
+ })
+ box.value = value
+ return null
+ }
+
+ act(() => {
+ root.render(createElement(Probe, { queryKey: initialQueryKey }))
+ })
+
+ const unmount = () => {
+ act(() => root.unmount())
+ container.remove()
+ }
+ mounted.push(unmount)
+
+ return {
+ get value() {
+ return box.value as EntityListResult
+ },
+ rerender: (queryKey: readonly unknown[]) => {
+ act(() => {
+ root.render(createElement(Probe, { queryKey }))
+ })
+ },
+ unmount,
+ }
+}
+
+describe('hydrateList / readList', () => {
+ it('writes the entities and the list index; readList returns ids and pagination', () => {
+ const collection = makeCollection()
+
+ hydrateList(collection, 'k1', {
+ items: [
+ { id: '1', title: 'a' },
+ { id: '2', title: 'b' },
+ ],
+ pagination: { page: 1, size: 20, total: 2, totalPages: 1 },
+ })
+
+ expect(collection.get('1')).toEqual({ id: '1', title: 'a' })
+ expect(collection.get('2')).toEqual({ id: '2', title: 'b' })
+
+ const index = readList(collection.store.getState(), collection, 'k1')
+ expect(index?.ids).toEqual(['1', '2'])
+ expect(index?.pagination).toEqual({
+ page: 1,
+ size: 20,
+ total: 2,
+ totalPages: 1,
+ })
+ expect(index?.updatedAt).toBeGreaterThan(0)
+ })
+
+ it('returns undefined for a key that was never hydrated', () => {
+ const collection = makeCollection()
+ expect(
+ readList(collection.store.getState(), collection, 'missing'),
+ ).toBeUndefined()
+ })
+})
+
+describe('hydrateList LRU eviction', () => {
+ it('evicts the least-recently-used index at the 51st key; entities are untouched', () => {
+ vi.useFakeTimers()
+ try {
+ const collection = makeCollection()
+ const base = Date.now()
+
+ for (let i = 0; i < 50; i++) {
+ vi.setSystemTime(base + i)
+ hydrateList(collection, `k${i}`, { items: [{ id: `e${i}` }] })
+ }
+
+ vi.setSystemTime(base + 50)
+ hydrateList(collection, 'k50', { items: [{ id: 'e50' }] })
+
+ const state = collection.store.getState()
+ expect(Object.keys(state.listIndexes)).toHaveLength(50)
+ expect(state.listIndexes.k0).toBeUndefined()
+ expect(state.listIndexes.k50).toBeDefined()
+
+ for (let i = 0; i < 50; i++) {
+ expect(collection.get(`e${i}`)).toEqual({ id: `e${i}` })
+ }
+ expect(collection.get('e50')).toEqual({ id: 'e50' })
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+})
+
+describe('useEntityList reference stability', () => {
+ it('returns the same reference across an unrelated write; a new one once a member changes', () => {
+ const collection = makeCollection()
+ hydrateList(collection, serializeListKey(['list']), {
+ items: [
+ { id: '1', title: 'a' },
+ { id: '2', title: 'b' },
+ ],
+ })
+ collection.upsert({ id: 'other', title: 'x' })
+
+ const harness = mountEntityList(collection, ['list'])
+ const first = harness.value
+
+ act(() => {
+ collection.upsert({ id: 'other', title: 'y' })
+ })
+ expect(harness.value).toBe(first)
+
+ act(() => {
+ collection.upsert({ id: '1', title: 'updated' })
+ })
+ expect(harness.value).not.toBe(first)
+ expect(harness.value.items.find((item) => item.id === '1')?.title).toBe(
+ 'updated',
+ )
+ })
+})
+
+describe('useEntityList missing index', () => {
+ it('returns a stable EMPTY reference across reads and unrelated writes', () => {
+ const collection = makeCollection()
+ const harness = mountEntityList(collection, ['missing'])
+ const first = harness.value
+
+ expect(first.isHydrated).toBe(false)
+ expect(first.items).toEqual([])
+
+ act(() => {
+ collection.upsert({ id: 'unrelated' })
+ })
+ expect(harness.value).toBe(first)
+ })
+})
+
+describe('useEntityList keepPrevious', () => {
+ it('falls back to the previous key items until the new key hydrates', () => {
+ const collection = makeCollection()
+ hydrateList(collection, serializeListKey(['a']), {
+ items: [{ id: '1', title: 'from-a' }],
+ })
+
+ const harness = mountEntityList(collection, ['a'], {
+ keepPrevious: true,
+ })
+ expect(harness.value.items.map((item) => item.id)).toEqual(['1'])
+
+ harness.rerender(['b'])
+ expect(harness.value.items.map((item) => item.id)).toEqual(['1'])
+ expect(harness.value.isHydrated).toBe(true)
+
+ act(() => {
+ hydrateList(collection, serializeListKey(['b']), {
+ items: [{ id: '2', title: 'from-b' }],
+ })
+ })
+ expect(harness.value.items.map((item) => item.id)).toEqual(['2'])
+ })
+})
+
+describe('useEntityList pending-delete filtering', () => {
+ it('filters an entity mid pending-delete out of items', () => {
+ const onDelete = () => new Promise(() => {})
+ const collection = defineCollection({
+ name: 'del-test',
+ getKey: (e) => e.id,
+ onDelete,
+ })
+ hydrateList(collection, serializeListKey(['list']), {
+ items: [{ id: '1' }, { id: '2' }],
+ })
+
+ void collection.delete('1')
+
+ const harness = mountEntityList(collection, ['list'])
+ expect(harness.value.items.map((item) => item.id)).toEqual(['2'])
+ })
+})
+
+describe('useEntity reference stability', () => {
+ it('returns the same reference when the version is unchanged; a new value after commit', async () => {
+ const onUpdate = vi.fn(({ next }: { next: TestEntity }) =>
+ Promise.resolve(next),
+ )
+ const collection = defineCollection({
+ name: 'entity-test',
+ getKey: (e) => e.id,
+ onUpdate,
+ })
+ collection.hydrate([{ id: '1', title: 'a' }])
+
+ const harness = mountEntity(collection, '1')
+ const first = harness.value
+ expect(first).toEqual({ id: '1', title: 'a' })
+
+ act(() => {
+ collection.upsert({ id: 'other', title: 'x' })
+ })
+ expect(harness.value).toBe(first)
+
+ await act(async () => {
+ await collection.update('1', (draft) => {
+ draft.title = 'b'
+ })
+ })
+ expect(harness.value).not.toBe(first)
+ expect(harness.value?.title).toBe('b')
+ })
+
+ it('returns undefined for an undefined id with no subscription churn', () => {
+ const collection = makeCollection()
+ const harness = mountEntity(collection, undefined)
+ expect(harness.value).toBeUndefined()
+
+ act(() => {
+ collection.upsert({ id: 'irrelevant' })
+ })
+ expect(harness.value).toBeUndefined()
+ })
+})
diff --git a/apps/admin/src/data/resource/hooks.ts b/apps/admin/src/data/resource/hooks.ts
new file mode 100644
index 00000000000..c01d046e6a1
--- /dev/null
+++ b/apps/admin/src/data/resource/hooks.ts
@@ -0,0 +1,223 @@
+import type { QueryKey, UseQueryResult } from '@tanstack/react-query'
+import { useQuery } from '@tanstack/react-query'
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useSyncExternalStore,
+} from 'react'
+
+import type { Pager } from '~/models/base'
+
+import type { Collection } from './collection'
+import { serializeListKey } from './key'
+import type { ListPage } from './list-index'
+import { hydrateList, readList, touchList } from './list-index'
+
+export function useEntity(
+ collection: Collection,
+ id: string | undefined,
+): T | undefined {
+ const cache = useRef<{ id: string; version: number; value: T | undefined }>(
+ undefined,
+ )
+
+ const subscribe = useCallback(
+ (onStoreChange: () => void) => {
+ if (!id) return () => {}
+ return collection.store.subscribe((state, prevState) => {
+ if (state.versionByKey[id] !== prevState.versionByKey[id]) {
+ onStoreChange()
+ }
+ })
+ },
+ [collection, id],
+ )
+
+ const getSnapshot = useCallback((): T | undefined => {
+ if (!id) return undefined
+
+ const version = collection.store.getState().versionByKey[id] ?? 0
+ const cached = cache.current
+ if (cached && cached.id === id && cached.version === version) {
+ return cached.value
+ }
+
+ const value = collection.get(id)
+ cache.current = { id, version, value }
+ return value
+ }, [collection, id])
+
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
+}
+
+export interface EntityListResult {
+ items: T[]
+ pagination?: Pager
+ updatedAt: number
+ isHydrated: boolean
+}
+
+const EMPTY_LIST_RESULT: EntityListResult = Object.freeze({
+ items: Object.freeze([]) as never[],
+ pagination: undefined,
+ updatedAt: 0,
+ isHydrated: false,
+})
+
+interface ListResultCache {
+ activeKey: string
+ updatedAt: number
+ versionSignature: string
+ result: EntityListResult
+}
+
+export function useEntityList(
+ collection: Collection,
+ queryKey: readonly unknown[],
+ options?: { keepPrevious?: boolean },
+): EntityListResult {
+ const listKey = useMemo(() => serializeListKey(queryKey), [queryKey])
+ const keepPrevious = options?.keepPrevious ?? false
+
+ const lastHydratedKeyRef = useRef(undefined)
+ const activeKeyRef = useRef(undefined)
+ const touchedKeyRef = useRef(undefined)
+ const cacheRef = useRef | undefined>(undefined)
+
+ const subscribe = useCallback(
+ (onStoreChange: () => void) => collection.store.subscribe(onStoreChange),
+ [collection],
+ )
+
+ const getSnapshot = useCallback((): EntityListResult => {
+ const state = collection.store.getState()
+
+ let activeKey = listKey
+ let index = readList(state, collection, listKey)
+
+ if (!index && keepPrevious && lastHydratedKeyRef.current) {
+ const previousIndex = readList(
+ state,
+ collection,
+ lastHydratedKeyRef.current,
+ )
+ if (previousIndex) {
+ activeKey = lastHydratedKeyRef.current
+ index = previousIndex
+ }
+ }
+
+ if (!index) {
+ activeKeyRef.current = undefined
+ return EMPTY_LIST_RESULT
+ }
+
+ if (activeKey === listKey) {
+ lastHydratedKeyRef.current = listKey
+ }
+ activeKeyRef.current = activeKey
+
+ const versionSignature = index.ids
+ .map((id) => `${id}:${state.versionByKey[id] ?? 0}`)
+ .join('|')
+
+ const cached = cacheRef.current
+ if (
+ cached &&
+ cached.activeKey === activeKey &&
+ cached.updatedAt === index.updatedAt &&
+ cached.versionSignature === versionSignature
+ ) {
+ return cached.result
+ }
+
+ const items: T[] = []
+ for (const id of index.ids) {
+ const entity = collection.get(id)
+ if (entity !== undefined) items.push(entity)
+ }
+
+ const result: EntityListResult = {
+ items,
+ pagination: index.pagination,
+ updatedAt: index.updatedAt,
+ isHydrated: true,
+ }
+
+ cacheRef.current = {
+ activeKey,
+ updatedAt: index.updatedAt,
+ versionSignature,
+ result,
+ }
+
+ return result
+ }, [collection, listKey, keepPrevious])
+
+ const result = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
+
+ useEffect(() => {
+ const key = activeKeyRef.current
+ if (key && touchedKeyRef.current !== key) {
+ touchedKeyRef.current = key
+ touchList(collection, key)
+ }
+ })
+
+ return result
+}
+
+export interface CollectionQueryReceipt {
+ hydratedAt: number
+}
+
+export function useCollectionListQuery(
+ collection: Collection,
+ options: {
+ queryKey: QueryKey
+ queryFn: () => Promise
+ toPage: (result: TResult) => ListPage
+ enabled?: boolean
+ },
+): UseQueryResult {
+ const listKey = useMemo(
+ () => serializeListKey(options.queryKey),
+ [options.queryKey],
+ )
+
+ return useQuery({
+ queryKey: options.queryKey,
+ enabled: options.enabled,
+ queryFn: async (): Promise => {
+ const result = await options.queryFn()
+ const page = options.toPage(result)
+ hydrateList(collection, listKey, page)
+ return { hydratedAt: Date.now() }
+ },
+ })
+}
+
+export function useCollectionDetailQuery(
+ collection: Collection,
+ options: {
+ queryKey: QueryKey
+ queryFn: () => Promise
+ toEntity?: (result: TResult) => T
+ enabled?: boolean
+ },
+): UseQueryResult {
+ return useQuery({
+ queryKey: options.queryKey,
+ enabled: options.enabled,
+ queryFn: async (): Promise => {
+ const result = await options.queryFn()
+ const entity = options.toEntity
+ ? options.toEntity(result)
+ : (result as unknown as T)
+ collection.hydrate([entity])
+ return { hydratedAt: Date.now() }
+ },
+ })
+}
diff --git a/apps/admin/src/data/resource/list-index.ts b/apps/admin/src/data/resource/list-index.ts
new file mode 100644
index 00000000000..c8d8b1dcc7b
--- /dev/null
+++ b/apps/admin/src/data/resource/list-index.ts
@@ -0,0 +1,90 @@
+import type { Pager } from '~/models/base'
+
+import type { Collection, CollectionState } from './collection'
+
+export interface ListIndex {
+ ids: string[]
+ pagination?: Pager
+ updatedAt: number
+ lruAt: number
+}
+
+export interface ListPage {
+ items: T[]
+ pagination?: Pager
+}
+
+const MAX_LIST_KEYS = 50
+
+export function hydrateList(
+ collection: Collection,
+ listKey: string,
+ page: ListPage,
+): void {
+ collection.hydrate(page.items)
+
+ const ids = page.items.map((item) => collection.getKey(item))
+ const now = Date.now()
+
+ collection.store.setState((state) => {
+ const listIndexes = {
+ ...state.listIndexes,
+ [listKey]: {
+ ids,
+ pagination: page.pagination,
+ updatedAt: now,
+ lruAt: now,
+ },
+ }
+ return { listIndexes: evictOverflow(listIndexes) }
+ })
+}
+
+function evictOverflow(
+ listIndexes: Record,
+): Record {
+ const keys = Object.keys(listIndexes)
+ if (keys.length <= MAX_LIST_KEYS) return listIndexes
+
+ const sortedByLru = keys.sort(
+ (a, b) => listIndexes[a].lruAt - listIndexes[b].lruAt,
+ )
+ const evictCount = keys.length - MAX_LIST_KEYS
+ const evicted = new Set(sortedByLru.slice(0, evictCount))
+
+ const next: Record = {}
+ for (const key of keys) {
+ if (!evicted.has(key)) next[key] = listIndexes[key]
+ }
+ return next
+}
+
+export function readList(
+ state: CollectionState,
+ _collection: Collection,
+ listKey: string,
+): { ids: string[]; pagination?: Pager; updatedAt: number } | undefined {
+ const index = state.listIndexes[listKey]
+ if (!index) return undefined
+ return {
+ ids: index.ids,
+ pagination: index.pagination,
+ updatedAt: index.updatedAt,
+ }
+}
+
+export function touchList(
+ collection: Collection,
+ listKey: string,
+): void {
+ collection.store.setState((state) => {
+ const index = state.listIndexes[listKey]
+ if (!index) return state
+ return {
+ listIndexes: {
+ ...state.listIndexes,
+ [listKey]: { ...index, lruAt: Date.now() },
+ },
+ }
+ })
+}
From 8b0a3536d22eddb57d4ded3cd20930f5fe23f12d Mon Sep 17 00:00:00 2001
From: Innei
Date: Fri, 10 Jul 2026 22:58:27 +0800
Subject: [PATCH 05/15] feat(admin): migrate category domain to resource
collections
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.
---
apps/admin/src/data/resources/category.ts | 28 ++++++++++++++
.../components/CategoriesRouteViewContent.tsx | 6 +--
.../categories/components/CategoryDetail.tsx | 10 ++---
.../components/CategoryDetailRoute.tsx | 38 +++++++++----------
.../components/CategoryFormModal.tsx | 17 ++++++---
.../categories/components/CategoryRow.tsx | 6 +--
.../components/categories-route-context.ts | 6 +--
.../categories/hooks/use-categories-list.ts | 14 +++++--
.../hooks/use-category-mutations.ts | 4 +-
.../features/categories/types/categories.ts | 4 +-
10 files changed, 85 insertions(+), 48 deletions(-)
create mode 100644 apps/admin/src/data/resources/category.ts
diff --git a/apps/admin/src/data/resources/category.ts b/apps/admin/src/data/resources/category.ts
new file mode 100644
index 00000000000..6726f63b90a
--- /dev/null
+++ b/apps/admin/src/data/resources/category.ts
@@ -0,0 +1,28 @@
+import type { UpdateCategoryData } from '~/api/categories'
+import { deleteCategory, updateCategory } from '~/api/categories'
+import { defineCollection } from '~/data/resource/collection'
+import type { CategoryType } from '~/models/category'
+
+export interface CategoryEntity {
+ id: string
+ name: string
+ slug: string
+ type: CategoryType
+ count?: number
+ createdAt?: string
+}
+
+export const categories = defineCollection({
+ name: 'category',
+ getKey: (category) => category.id,
+ onUpdate: async ({ id, patch }) => {
+ const data: UpdateCategoryData = {}
+ if (patch.name !== undefined) data.name = patch.name
+ if (patch.slug !== undefined) data.slug = patch.slug
+ if (patch.type !== undefined) data.type = patch.type
+ return await updateCategory(id, data)
+ },
+ onDelete: async ({ id }) => {
+ await deleteCategory(id)
+ },
+})
diff --git a/apps/admin/src/features/categories/components/CategoriesRouteViewContent.tsx b/apps/admin/src/features/categories/components/CategoriesRouteViewContent.tsx
index 464195c2bdb..a38be0c2eba 100644
--- a/apps/admin/src/features/categories/components/CategoriesRouteViewContent.tsx
+++ b/apps/admin/src/features/categories/components/CategoriesRouteViewContent.tsx
@@ -3,8 +3,8 @@ import { useCallback, useMemo } from 'react'
import { useNavigate, useParams } from 'react-router'
import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout'
+import type { CategoryEntity } from '~/data/resources/category'
import { useI18n } from '~/i18n'
-import type { CategoryModel } from '~/models/category'
import { MasterDetailShell } from '~/ui/layout/master-detail-shell'
import { MobileHeaderAffordance } from '~/ui/layout/mobile-header-affordance'
import { Button } from '~/ui/primitives/button'
@@ -81,14 +81,14 @@ export function CategoriesRouteViewContent() {
() => ({
deleting: deleteMutation.isPending,
onBack: closeDetail,
- onDelete: (category: CategoryModel) => {
+ onDelete: (category: CategoryEntity) => {
if (
window.confirm(t('categories.confirmDelete', { name: category.name }))
) {
deleteMutation.mutate(category.id)
}
},
- onEdit: (category: CategoryModel) =>
+ onEdit: (category: CategoryEntity) =>
void openForm({ category, kind: 'edit' }),
}),
[closeDetail, deleteMutation, openForm, t],
diff --git a/apps/admin/src/features/categories/components/CategoryDetail.tsx b/apps/admin/src/features/categories/components/CategoryDetail.tsx
index ed77ed915ee..90dc7846a34 100644
--- a/apps/admin/src/features/categories/components/CategoryDetail.tsx
+++ b/apps/admin/src/features/categories/components/CategoryDetail.tsx
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { Edit3, FolderOpen, Loader2, Trash2 } from 'lucide-react'
-import type { CategoryModel } from '~/models/category'
import { getPosts } from '~/api/posts'
+import type { CategoryEntity } from '~/data/resources/category'
import { useI18n } from '~/i18n'
import { adminQueryKeys } from '~/query/keys'
import { Button } from '~/ui/primitives/button'
@@ -14,11 +14,11 @@ import { EntitySummary } from './EntitySummary'
import { PostListSection } from './PostListSection'
export function CategoryDetail(props: {
- category: CategoryModel
+ category: CategoryEntity
deleting: boolean
onBack: () => void
- onDelete: (category: CategoryModel) => void
- onEdit: (category: CategoryModel) => void
+ onDelete: (category: CategoryEntity) => void
+ onEdit: (category: CategoryEntity) => void
}) {
const { t } = useI18n()
const postsQuery = useQuery({
@@ -64,7 +64,7 @@ export function CategoryDetail(props: {
}
meta={props.category.slug}
diff --git a/apps/admin/src/features/categories/components/CategoryDetailRoute.tsx b/apps/admin/src/features/categories/components/CategoryDetailRoute.tsx
index 02779817e8f..06de3ed81e6 100644
--- a/apps/admin/src/features/categories/components/CategoryDetailRoute.tsx
+++ b/apps/admin/src/features/categories/components/CategoryDetailRoute.tsx
@@ -1,10 +1,15 @@
-import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useQuery } from '@tanstack/react-query'
import { useParams } from 'react-router'
import { getCategories, getCategory, getTags } from '~/api/categories'
-import { findInListCache } from '~/api/list-cache'
+import {
+ useCollectionDetailQuery,
+ useCollectionListQuery,
+ useEntity,
+} from '~/data/resource/hooks'
+import { categories } from '~/data/resources/category'
import { useDocumentTitle } from '~/hooks/use-document-title'
-import type { CategoryModel, TagModel } from '~/models/category'
+import type { TagModel } from '~/models/category'
import { adminQueryKeys } from '~/query/keys'
import { useCategoriesRouteContext } from './categories-route-context'
@@ -32,42 +37,34 @@ function parseId(raw: string | undefined): ParsedTarget | null {
export function CategoryDetailRoute() {
const { id } = useParams<{ id: string }>()
- const queryClient = useQueryClient()
const ctx = useCategoriesRouteContext()
const parsed = parseId(id)
- const initialCategory =
- parsed?.kind === 'category'
- ? findInListCache(
- queryClient,
- CATEGORY_LIST_KEY,
- parsed.value,
- )
- : undefined
+ const category = useEntity(
+ categories,
+ parsed?.kind === 'category' ? parsed.value : undefined,
+ )
- const categoryQuery = useQuery({
+ useCollectionDetailQuery(categories, {
enabled: parsed?.kind === 'category',
- initialData: initialCategory,
queryFn: () => getCategory(parsed!.value),
queryKey: parsed?.value
? adminQueryKeys.categories.detail(parsed.value)
: adminQueryKeys.categories.root,
- staleTime: initialCategory ? 30_000 : 0,
})
- // Tag detail isn't fetched by id; reach into the list cache to find it.
const tagsListCacheQuery = useQuery({
enabled: parsed?.kind === 'tag',
queryFn: getTags,
queryKey: TAGS_LIST_KEY,
})
- // Keep the categories list warm in case the user opened a deep link directly.
- useQuery({
- enabled: parsed?.kind === 'category' && !initialCategory,
+ useCollectionListQuery(categories, {
+ enabled: parsed?.kind === 'category' && !category,
queryFn: () => getCategories({ type: 'Category' }),
queryKey: CATEGORY_LIST_KEY,
+ toPage: (result) => ({ items: result }),
})
const dynamicTitle =
@@ -75,7 +72,7 @@ export function CategoryDetailRoute() {
? (tagsListCacheQuery.data?.find(
(entry: TagModel) => entry.name === parsed.value,
)?.name ?? parsed.value)
- : (categoryQuery.data?.name ?? undefined)
+ : (category?.name ?? undefined)
useDocumentTitle(dynamicTitle)
if (!parsed) return
@@ -89,7 +86,6 @@ export function CategoryDetailRoute() {
return
}
- const category = categoryQuery.data
if (!category) return
return (
diff --git a/apps/admin/src/features/categories/components/CategoryFormModal.tsx b/apps/admin/src/features/categories/components/CategoryFormModal.tsx
index a8af37d34f5..34a22a3e5cf 100644
--- a/apps/admin/src/features/categories/components/CategoryFormModal.tsx
+++ b/apps/admin/src/features/categories/components/CategoryFormModal.tsx
@@ -5,9 +5,9 @@ import { useState } from 'react'
import { toast } from 'sonner'
import type { CreateCategoryData } from '~/api/categories'
-import { createCategory, updateCategory } from '~/api/categories'
+import { createCategory } from '~/api/categories'
+import { categories, type CategoryEntity } from '~/data/resources/category'
import { useI18n } from '~/i18n'
-import type { CategoryModel } from '~/models/category'
import { ModalFooter, ModalHeader } from '~/ui/feedback/modal'
import { present, useModal } from '~/ui/feedback/modal-imperative'
import { Button } from '~/ui/primitives/button'
@@ -22,7 +22,7 @@ interface CategoryFormModalProps {
function CategoryFormModal(props: CategoryFormModalProps) {
const { t } = useI18n()
- const modal = useModal()
+ const modal = useModal()
const [name, setName] = useState(
props.mode.kind === 'edit' ? props.mode.category.name : '',
)
@@ -37,11 +37,16 @@ function CategoryFormModal(props: CategoryFormModalProps) {
const mutation = useMutation({
mutationFn: (data: CreateCategoryData) =>
props.mode.kind === 'edit'
- ? updateCategory(props.mode.category.id, { ...data, type: 0 })
+ ? categories.update(props.mode.category.id, (draft) => {
+ draft.name = data.name
+ draft.slug = data.slug
+ })
: createCategory(data),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('categories.form.saveFailed'))),
onSuccess: (category) => {
+ if (!category) return
+ if (props.mode.kind === 'create') categories.upsert(category)
toast.success(
props.mode.kind === 'edit'
? t('categories.form.updated')
@@ -105,8 +110,8 @@ function CategoryFormModal(props: CategoryFormModalProps) {
*/
export async function presentCategoryForm(
mode: CategoryFormMode,
-): Promise {
- const handle = present(
+): Promise {
+ const handle = present(
CategoryFormModal,
{ mode },
{
diff --git a/apps/admin/src/features/categories/components/CategoryRow.tsx b/apps/admin/src/features/categories/components/CategoryRow.tsx
index c8df265d6b0..8c0d5806360 100644
--- a/apps/admin/src/features/categories/components/CategoryRow.tsx
+++ b/apps/admin/src/features/categories/components/CategoryRow.tsx
@@ -1,10 +1,10 @@
import { FolderOpen, Hash } from 'lucide-react'
-import type { CategoryModel } from '~/models/category'
+import type { CategoryEntity } from '~/data/resources/category'
import { cn } from '~/utils/cn'
export function CategoryRow(props: {
- category: CategoryModel
+ category: CategoryEntity
onSelect: () => void
selected: boolean
}) {
@@ -33,7 +33,7 @@ export function CategoryRow(props: {
- {props.category.count}
+ {props.category.count ?? 0}
)
diff --git a/apps/admin/src/features/categories/components/categories-route-context.ts b/apps/admin/src/features/categories/components/categories-route-context.ts
index 6e0bd3b522f..a4fa8778f80 100644
--- a/apps/admin/src/features/categories/components/categories-route-context.ts
+++ b/apps/admin/src/features/categories/components/categories-route-context.ts
@@ -1,12 +1,12 @@
import { createContext, useContext } from 'react'
-import type { CategoryModel } from '~/models/category'
+import type { CategoryEntity } from '~/data/resources/category'
export interface CategoriesRouteContextValue {
deleting: boolean
onBack: () => void
- onDelete: (category: CategoryModel) => void
- onEdit: (category: CategoryModel) => void
+ onDelete: (category: CategoryEntity) => void
+ onEdit: (category: CategoryEntity) => void
}
export const CategoriesRouteContext =
diff --git a/apps/admin/src/features/categories/hooks/use-categories-list.ts b/apps/admin/src/features/categories/hooks/use-categories-list.ts
index d6f59033eee..f2a0ed7e49a 100644
--- a/apps/admin/src/features/categories/hooks/use-categories-list.ts
+++ b/apps/admin/src/features/categories/hooks/use-categories-list.ts
@@ -1,20 +1,28 @@
import { useQuery } from '@tanstack/react-query'
import { getCategories, getTags } from '~/api/categories'
+import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks'
+import { categories } from '~/data/resources/category'
import { adminQueryKeys } from '~/query/keys'
export function useCategoriesList() {
- const categoriesQuery = useQuery({
- queryFn: () => getCategories({ type: 'Category' }),
+ const categoriesQuery = useCollectionListQuery(categories, {
queryKey: adminQueryKeys.categories.list(),
+ queryFn: () => getCategories({ type: 'Category' }),
+ toPage: (result) => ({ items: result }),
})
+ const categoriesList = useEntityList(
+ categories,
+ adminQueryKeys.categories.list(),
+ )
+
const tagsQuery = useQuery({
queryFn: getTags,
queryKey: adminQueryKeys.categories.tags(),
})
return {
- categories: categoriesQuery.data ?? [],
+ categories: categoriesList.items,
categoriesQuery,
tags: tagsQuery.data ?? [],
tagsQuery,
diff --git a/apps/admin/src/features/categories/hooks/use-category-mutations.ts b/apps/admin/src/features/categories/hooks/use-category-mutations.ts
index deec2bff616..00cda618c66 100644
--- a/apps/admin/src/features/categories/hooks/use-category-mutations.ts
+++ b/apps/admin/src/features/categories/hooks/use-category-mutations.ts
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { toast } from 'sonner'
-import { deleteCategory } from '~/api/categories'
+import { categories } from '~/data/resources/category'
import { useI18n } from '~/i18n'
import { adminQueryKeys } from '~/query/keys'
@@ -25,7 +25,7 @@ export function useCategoryMutations(
}, [queryClient])
const deleteMutation = useMutation({
- mutationFn: deleteCategory,
+ mutationFn: (id: string) => categories.delete(id),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('categories.toast.deleteFailed'))),
onSuccess: async () => {
diff --git a/apps/admin/src/features/categories/types/categories.ts b/apps/admin/src/features/categories/types/categories.ts
index 27812179f00..536e86ecbbe 100644
--- a/apps/admin/src/features/categories/types/categories.ts
+++ b/apps/admin/src/features/categories/types/categories.ts
@@ -1,4 +1,4 @@
-import type { CategoryModel } from '~/models/category'
+import type { CategoryEntity } from '~/data/resources/category'
export type SelectedItem =
| {
@@ -15,6 +15,6 @@ export type CategoryFormMode =
kind: 'create'
}
| {
- category: CategoryModel
+ category: CategoryEntity
kind: 'edit'
}
From 106123a45d0fed887de2040649e7ed4826678441 Mon Sep 17 00:00:00 2001
From: Innei
Date: Fri, 10 Jul 2026 23:20:12 +0800
Subject: [PATCH 06/15] feat(admin): migrate post domain to resource
collections
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.
---
apps/admin/src/data/resource/transaction.ts | 21 +-
apps/admin/src/data/resources/post.ts | 29 +++
.../categories/components/CategoryDetail.tsx | 17 +-
.../categories/components/TagDetail.tsx | 14 +-
.../posts/hooks/use-post-mutations.ts | 44 ++--
.../features/posts/hooks/use-posts-list.ts | 40 ++--
.../components/WriteRouteViewsContent.tsx | 207 +++++++++++++-----
7 files changed, 266 insertions(+), 106 deletions(-)
create mode 100644 apps/admin/src/data/resources/post.ts
diff --git a/apps/admin/src/data/resource/transaction.ts b/apps/admin/src/data/resource/transaction.ts
index 2bbcd023bed..1e3b736d5ce 100644
--- a/apps/admin/src/data/resource/transaction.ts
+++ b/apps/admin/src/data/resource/transaction.ts
@@ -13,9 +13,15 @@ export interface ResourceTransaction {
) => this
delete: (collection: Collection, id: string) => this
insert: (collection: Collection, entity: T) => this
- commit: (
- request: () => Promise,
- ) => Promise
+ commit: (request: () => Promise) => Promise
+}
+
+function extractFulfilledKeys(result: unknown): string[] | undefined {
+ if (typeof result !== 'object' || result === null) return undefined
+ if (!('fulfilledKeys' in result)) return undefined
+ const keys = result.fulfilledKeys
+ if (!Array.isArray(keys)) return undefined
+ return keys.filter((key): key is string => typeof key === 'string')
}
interface Registration {
@@ -87,9 +93,7 @@ export function createTransaction(): ResourceTransaction {
})
return tx
},
- async commit(
- request: () => Promise,
- ) {
+ async commit(request: () => Promise) {
assertNotCommitted()
committed = true
@@ -103,10 +107,7 @@ export function createTransaction(): ResourceTransaction {
throw error
}
- const fulfilledKeys =
- result && typeof result === 'object' && 'fulfilledKeys' in result
- ? result.fulfilledKeys
- : undefined
+ const fulfilledKeys = extractFulfilledKeys(result)
for (const registration of registrations) {
if (!fulfilledKeys || fulfilledKeys.includes(registration.entityId)) {
diff --git a/apps/admin/src/data/resources/post.ts b/apps/admin/src/data/resources/post.ts
new file mode 100644
index 00000000000..2cd7ae34481
--- /dev/null
+++ b/apps/admin/src/data/resources/post.ts
@@ -0,0 +1,29 @@
+import { deletePost, patchPost } from '~/api/posts'
+import { defineCollection } from '~/data/resource/collection'
+import { CategoryType } from '~/models/category'
+import type { Category, PostModel } from '~/models/post'
+
+import type { CategoryEntity } from './category'
+import { categories } from './category'
+
+function toCategoryEntity(category: Category): CategoryEntity {
+ return {
+ id: category.id,
+ name: category.name,
+ slug: category.slug,
+ type:
+ category.type === CategoryType.Tag
+ ? CategoryType.Tag
+ : CategoryType.Category,
+ }
+}
+
+export const posts = defineCollection({
+ name: 'post',
+ getKey: (post) => post.id,
+ normalize: (post) => {
+ if (post.category) categories.upsert(toCategoryEntity(post.category))
+ },
+ onUpdate: ({ id, patch }) => patchPost(id, patch),
+ onDelete: ({ id }) => deletePost(id),
+})
diff --git a/apps/admin/src/features/categories/components/CategoryDetail.tsx b/apps/admin/src/features/categories/components/CategoryDetail.tsx
index 90dc7846a34..6ab455135ed 100644
--- a/apps/admin/src/features/categories/components/CategoryDetail.tsx
+++ b/apps/admin/src/features/categories/components/CategoryDetail.tsx
@@ -1,8 +1,9 @@
-import { useQuery } from '@tanstack/react-query'
import { Edit3, FolderOpen, Loader2, Trash2 } from 'lucide-react'
import { getPosts } from '~/api/posts'
+import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks'
import type { CategoryEntity } from '~/data/resources/category'
+import { posts } from '~/data/resources/post'
import { useI18n } from '~/i18n'
import { adminQueryKeys } from '~/query/keys'
import { Button } from '~/ui/primitives/button'
@@ -21,7 +22,8 @@ export function CategoryDetail(props: {
onEdit: (category: CategoryEntity) => void
}) {
const { t } = useI18n()
- const postsQuery = useQuery({
+ const postsListKey = adminQueryKeys.posts.categoryDetail(props.category.id)
+ const postsQuery = useCollectionListQuery(posts, {
enabled: !!props.category.id,
queryFn: () =>
getPosts({
@@ -30,9 +32,14 @@ export function CategoryDetail(props: {
size: categoryDetailPostPageSize,
sort_by: 'createdAt',
sort_order: 'desc',
- }).then((result) => result.data),
- queryKey: adminQueryKeys.posts.categoryDetail(props.category.id),
+ }),
+ queryKey: postsListKey,
+ toPage: (result) => ({
+ items: result.data,
+ pagination: result.pagination,
+ }),
})
+ const postsList = useEntityList(posts, postsListKey)
return (
@@ -73,7 +80,7 @@ export function CategoryDetail(props: {
diff --git a/apps/admin/src/features/categories/components/TagDetail.tsx b/apps/admin/src/features/categories/components/TagDetail.tsx
index 04ad77c7b56..f04efa3ae85 100644
--- a/apps/admin/src/features/categories/components/TagDetail.tsx
+++ b/apps/admin/src/features/categories/components/TagDetail.tsx
@@ -1,9 +1,10 @@
-import { useQuery } from '@tanstack/react-query'
import { Tag } from 'lucide-react'
-import type { TagModel } from '~/models/category'
import { getPostsByTag } from '~/api/categories'
+import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks'
+import { posts } from '~/data/resources/post'
import { useI18n } from '~/i18n'
+import type { TagModel } from '~/models/category'
import { adminQueryKeys } from '~/query/keys'
import { Scroll } from '~/ui/primitives/scroll'
@@ -13,11 +14,14 @@ import { PostListSection } from './PostListSection'
export function TagDetail(props: { onBack: () => void; tag: TagModel }) {
const { t } = useI18n()
- const postsQuery = useQuery({
+ const postsListKey = adminQueryKeys.posts.tagDetail(props.tag.name)
+ const postsQuery = useCollectionListQuery(posts, {
enabled: !!props.tag.name,
queryFn: () => getPostsByTag(props.tag.name),
- queryKey: adminQueryKeys.posts.tagDetail(props.tag.name),
+ queryKey: postsListKey,
+ toPage: (result) => ({ items: result }),
})
+ const postsList = useEntityList(posts, postsListKey)
return (
@@ -36,7 +40,7 @@ export function TagDetail(props: { onBack: () => void; tag: TagModel }) {
- patchPost(payload.id, { isPublished: payload.isPublished }),
+ posts.update(payload.id, (draft) => {
+ draft.isPublished = payload.isPublished
+ }),
onSuccess: invalidatePosts,
})
const categoryMutation = useMutation({
mutationFn: (payload: { categoryId: string; id: string }) =>
- patchPost(payload.id, { categoryId: payload.categoryId }),
+ posts.update(payload.id, (draft) => {
+ draft.categoryId = payload.categoryId
+ }),
onError: (error: unknown) =>
toast.error(
getErrorMessage(error, t('posts.toast.categoryUpdateFailed')),
@@ -36,7 +42,7 @@ export function usePostMutations(options: UsePostMutationsOptions = {}) {
})
const deleteMutation = useMutation({
- mutationFn: deletePost,
+ mutationFn: (id: string) => posts.delete(id),
onSuccess: async () => {
toast.success(t('posts.toast.deleted'))
await invalidatePosts()
@@ -45,16 +51,24 @@ export function usePostMutations(options: UsePostMutationsOptions = {}) {
const batchDeleteMutation = useMutation({
mutationFn: async (ids: string[]) => {
- const results = await Promise.allSettled(ids.map((id) => deletePost(id)))
- const successfulIds = ids.filter(
- (_, index) => results[index].status === 'fulfilled',
- )
+ const tx = createTransaction()
+ ids.forEach((id) => tx.delete(posts, id))
- return {
- failedCount: ids.length - successfulIds.length,
- successfulIds,
- successCount: successfulIds.length,
- }
+ return tx.commit(async () => {
+ const results = await Promise.allSettled(
+ ids.map((id) => deletePost(id)),
+ )
+ const successfulIds = ids.filter(
+ (_, index) => results[index].status === 'fulfilled',
+ )
+
+ return {
+ failedCount: ids.length - successfulIds.length,
+ fulfilledKeys: successfulIds,
+ successfulIds,
+ successCount: successfulIds.length,
+ }
+ })
},
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('posts.toast.batchDeleteFailed'))),
@@ -78,8 +92,8 @@ export function usePostMutations(options: UsePostMutationsOptions = {}) {
const pinMutation = useMutation({
mutationFn: (payload: { id: string; isPinned: boolean }) =>
- patchPost(payload.id, {
- pinAt: payload.isPinned ? new Date().toISOString() : null,
+ posts.update(payload.id, (draft) => {
+ draft.pinAt = payload.isPinned ? new Date().toISOString() : null
}),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('posts.toast.pinFailed'))),
diff --git a/apps/admin/src/features/posts/hooks/use-posts-list.ts b/apps/admin/src/features/posts/hooks/use-posts-list.ts
index e7805e6a71b..5cac98926e2 100644
--- a/apps/admin/src/features/posts/hooks/use-posts-list.ts
+++ b/apps/admin/src/features/posts/hooks/use-posts-list.ts
@@ -1,8 +1,10 @@
-import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { getCategories } from '~/api/categories'
import { getPosts, searchPosts } from '~/api/posts'
+import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks'
+import { categories } from '~/data/resources/category'
+import { posts } from '~/data/resources/post'
import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state'
import { adminQueryKeys } from '~/query/keys'
@@ -54,13 +56,25 @@ export function usePostsList() {
setKeywordInput(state.keyword)
}, [state.keyword])
- const categoriesQuery = useQuery({
+ const categoriesQuery = useCollectionListQuery(categories, {
queryFn: () => getCategories({ type: 'Category' }),
queryKey: adminQueryKeys.categories.postFilter(),
+ toPage: (result) => ({ items: result }),
})
+ const categoriesList = useEntityList(
+ categories,
+ adminQueryKeys.categories.postFilter(),
+ )
- const postsQuery = useQuery({
- placeholderData: (previous) => previous,
+ const postsListKey = adminQueryKeys.posts.list({
+ categoryId: state.categoryId,
+ keyword: state.keyword,
+ page: state.page,
+ size: postsPageSize,
+ sortKey: state.sortKey,
+ sortOrder: state.sortOrder,
+ })
+ const postsQuery = useCollectionListQuery(posts, {
queryFn: () =>
state.keyword
? searchPosts({
@@ -78,18 +92,16 @@ export function usePostsList() {
sort_by: state.sortKey,
sort_order: state.sortOrder,
}),
- queryKey: adminQueryKeys.posts.list({
- categoryId: state.categoryId,
- keyword: state.keyword,
- page: state.page,
- size: postsPageSize,
- sortKey: state.sortKey,
- sortOrder: state.sortOrder,
+ queryKey: postsListKey,
+ toPage: (result) => ({
+ items: result.data,
+ pagination: result.pagination,
}),
})
+ const postsList = useEntityList(posts, postsListKey, { keepPrevious: true })
return {
- categories: categoriesQuery.data ?? [],
+ categories: categoriesList.items,
categoriesQuery,
categoryId: state.categoryId,
clearSearch: () => {
@@ -99,8 +111,8 @@ export function usePostsList() {
keyword: state.keyword,
keywordInput,
page: state.page,
- pagination: postsQuery.data?.pagination,
- posts: postsQuery.data?.data ?? [],
+ pagination: postsList.pagination,
+ posts: postsList.items,
postsQuery,
rootQueryKey: postsQueryKey,
setCategoryId: (categoryId: string) =>
diff --git a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
index aa308f98e1d..7f27f8913f9 100644
--- a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
+++ b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
@@ -59,6 +59,16 @@ import {
APP_SHELL_HEADER_HEIGHT_CLASS,
APP_SHELL_HEADER_HEIGHT_VALUE,
} from '~/constants/layout'
+import {
+ useCollectionDetailQuery,
+ useCollectionListQuery,
+ useEntity,
+ useEntityList,
+} from '~/data/resource/hooks'
+import { createTransaction } from '~/data/resource/transaction'
+import type { CategoryEntity } from '~/data/resources/category'
+import { categories as categoriesCollection } from '~/data/resources/category'
+import { posts as postsCollection } from '~/data/resources/post'
import { DraftStatusTag } from '~/features/drafts/components/draft-status-tag'
import { AgentPanel, useWriteAgent } from '~/features/write/components/agent'
import { DraftHintBanner } from '~/features/write/components/DraftHintBanner'
@@ -73,7 +83,6 @@ import type { TranslationKey } from '~/i18n/types'
import { prepareImageFileForUpload } from '~/lib/image-upload-privacy'
import type { Amap, AMapSearch } from '~/models/amap'
import type { Image as ImageModel } from '~/models/base'
-import type { CategoryModel } from '~/models/category'
import type { DraftModel } from '~/models/draft'
import { DraftRefType } from '~/models/draft'
import type { NoteModel } from '~/models/note'
@@ -177,7 +186,6 @@ const emptyState: WriteFormState = {
weather: '',
}
-const emptyCategories: CategoryModel[] = []
const emptyTopics: TopicModel[] = []
const PREFERRED_CONTENT_FORMAT_STORAGE_KEY = 'preferred-content-format'
const RichEditorWithAgent = lazy(() =>
@@ -376,17 +384,23 @@ function WritePage(props: { kind: WriteKind }) {
draftId: string
} | null>(null)
const appliedRouteDraftIdRef = useRef(null)
+ const formSeededKeyRef = useRef(null)
const draftDirtyRef = useRef(false)
const lastSavedDraftFingerprintRef = useRef('')
const latestDraftFingerprintRef = useRef('')
const [lastSavedFingerprint, setLastSavedFingerprint] = useState('')
const draftRefType = draftRefTypeByKind[props.kind]
- const categoriesQuery = useQuery({
+ useCollectionListQuery(categoriesCollection, {
enabled: props.kind === 'post',
queryFn: () => getCategories({ type: 'Category' }),
queryKey: adminQueryKeys.categories.list(),
+ toPage: (result) => ({ items: result }),
})
+ const categoriesList = useEntityList(
+ categoriesCollection,
+ adminQueryKeys.categories.list(),
+ )
const topicsQuery = useQuery({
enabled: props.kind === 'note',
queryFn: () => getTopics({ page: 1, size: 100 }),
@@ -397,7 +411,7 @@ function WritePage(props: { kind: WriteKind }) {
queryFn: getTags,
queryKey: adminQueryKeys.categories.tags(),
})
- const relatedPostsQuery = useQuery({
+ useCollectionListQuery(postsCollection, {
enabled: props.kind === 'post',
queryFn: () =>
getPosts({
@@ -407,12 +421,39 @@ function WritePage(props: { kind: WriteKind }) {
sort_order: 'desc',
}),
queryKey: adminQueryKeys.posts.relatedOptions('write'),
+ toPage: (result) => ({
+ items: result.data,
+ pagination: result.pagination,
+ }),
})
- const detailQuery = useQuery({
- enabled: isEditing,
+ const relatedPostsList = useEntityList(
+ postsCollection,
+ adminQueryKeys.posts.relatedOptions('write'),
+ )
+ const postDetailQuery = useCollectionDetailQuery(postsCollection, {
+ enabled: isEditing && props.kind === 'post',
+ queryFn: () => getPostById(id),
+ queryKey: adminQueryKeys.write.detail({ id, kind: props.kind }),
+ })
+ const postEntity = useEntity(
+ postsCollection,
+ props.kind === 'post' && isEditing ? id : undefined,
+ )
+ const nonPostDetailQuery = useQuery({
+ enabled: isEditing && props.kind !== 'post',
queryFn: () => getWriteDetail(props.kind, id),
queryKey: adminQueryKeys.write.detail({ id, kind: props.kind }),
})
+ const detailModel: WriteModel | undefined =
+ props.kind === 'post' ? postEntity : nonPostDetailQuery.data
+ const isDetailLoaded =
+ props.kind === 'post'
+ ? postDetailQuery.isSuccess
+ : nonPostDetailQuery.isSuccess
+ const detailLoading =
+ props.kind === 'post'
+ ? postDetailQuery.isLoading
+ : nonPostDetailQuery.isLoading
const refDraftQuery = useQuery({
enabled: isEditing,
queryFn: () => getDraftByRef(draftRefType, id),
@@ -429,10 +470,10 @@ function WritePage(props: { kind: WriteKind }) {
queryKey: adminQueryKeys.drafts.newDraft(draftRefType),
})
- const categories = categoriesQuery.data ?? emptyCategories
+ const categories = categoriesList.items
const tags = tagsQuery.data ?? []
const topics = topicsQuery.data?.data ?? emptyTopics
- const relatedPosts = relatedPostsQuery.data?.data ?? []
+ const relatedPosts = relatedPostsList.items
const firstCategoryId = categories[0]?.id ?? ''
const activeCategory =
categories.find((category) => category.id === state.categoryId) ??
@@ -445,21 +486,19 @@ function WritePage(props: { kind: WriteKind }) {
)[0]
}, [newDraftsQuery.data, refDraftQuery.data])
const publishedContent = useMemo(
- () => (detailQuery.data ? getPublishedContent(detailQuery.data) : null),
- [detailQuery.data],
+ () => (detailModel ? getPublishedContent(detailModel) : null),
+ [detailModel],
)
const defaultNoteTitle = useMemo(() => {
- if (props.kind === 'note' && detailQuery.data) {
- return getDefaultNoteTitle(
- new Date((detailQuery.data as NoteModel).createdAt),
- )
+ if (props.kind === 'note' && detailModel) {
+ return getDefaultNoteTitle(new Date((detailModel as NoteModel).createdAt))
}
return getDefaultNoteTitle()
- }, [detailQuery.data, props.kind])
+ }, [detailModel, props.kind])
const notePublicPath =
props.kind === 'note'
- ? buildNotePublicPath(state, detailQuery.data as NoteModel | undefined)
+ ? buildNotePublicPath(state, detailModel as NoteModel | undefined)
: ''
const postPublicPath =
props.kind === 'post' ? buildPostPublicPath(state, activeCategory) : ''
@@ -514,7 +553,7 @@ function WritePage(props: { kind: WriteKind }) {
}, [blocker, t])
useEffect(() => {
- if (!detailQuery.data) {
+ if (!detailModel || !isDetailLoaded) {
if (props.kind !== 'post' || !firstCategoryId) return
setState((previous) =>
previous.categoryId
@@ -527,10 +566,23 @@ function WritePage(props: { kind: WriteKind }) {
return
}
- if (!routeDraftId) {
- setState(fromModel(props.kind, detailQuery.data))
+ if (routeDraftId) return
+
+ if (props.kind === 'post') {
+ const seedKey = `post:${id}`
+ if (formSeededKeyRef.current === seedKey) return
+ formSeededKeyRef.current = seedKey
}
- }, [detailQuery.data, firstCategoryId, props.kind, routeDraftId])
+
+ setState(fromModel(props.kind, detailModel))
+ }, [
+ detailModel,
+ firstCategoryId,
+ id,
+ isDetailLoaded,
+ props.kind,
+ routeDraftId,
+ ])
useEffect(() => {
if (refDraftQuery.data && !draftId) {
@@ -540,11 +592,11 @@ function WritePage(props: { kind: WriteKind }) {
const recoveryHintDraft = useMemo(() => {
const draft = refDraftQuery.data
- const published = detailQuery.data
+ const published = detailModel
if (!isEditing || routeDraftId || !draft || !published) return null
if (!isDraftNewerThanPublished(draft, published)) return null
return draft
- }, [detailQuery.data, isEditing, refDraftQuery.data, routeDraftId])
+ }, [detailModel, isEditing, refDraftQuery.data, routeDraftId])
useEffect(() => {
const draft = routeDraftQuery.data
@@ -811,8 +863,8 @@ function WritePage(props: { kind: WriteKind }) {
isEditing,
isPendingDraftSave: draftMutation.isPending,
latestDraft,
- publishedUpdatedAt: detailQuery.data
- ? getPublishedContent(detailQuery.data).updatedAt
+ publishedUpdatedAt: detailModel
+ ? getPublishedContent(detailModel).updatedAt
: undefined,
})
@@ -915,7 +967,7 @@ function WritePage(props: { kind: WriteKind }) {
- {detailQuery.isLoading ? (
+ {detailLoading ? (
@@ -1724,7 +1776,7 @@ function SlugPill(props: {
}
function PostFields(props: {
- categories: CategoryModel[]
+ categories: CategoryEntity[]
currentPostId: string
relatedPosts: PostModel[]
state: WriteFormState
@@ -3170,41 +3222,82 @@ function resolveDraftPasswordProtected(
return previous.passwordProtected
}
+function buildPostWriteData(state: WriteFormState, draftId?: string) {
+ const projected = projectWriteState(state)
+ return {
+ categoryId: projected.categoryId,
+ content:
+ projected.contentFormat === 'lexical' ? projected.content : undefined,
+ contentFormat: projected.contentFormat,
+ copyright: projected.copyright,
+ draftId,
+ images:
+ projected.contentFormat === 'lexical'
+ ? undefined
+ : buildWriteImages(projected),
+ isPublished: projected.isPublished,
+ meta: projected.meta,
+ pin: projected.pin ? new Date().toISOString() : null,
+ pinOrder: projected.pin ? Number(projected.pinOrder) || 1 : null,
+ relatedId: splitCommaList(projected.relatedId),
+ slug: projected.slug,
+ summary: projected.summary || null,
+ tags: projected.tags
+ .split(',')
+ .map((tag) => tag.trim())
+ .filter(Boolean),
+ text: projected.text,
+ title: projected.title,
+ }
+}
+
+async function savePostWrite(
+ id: string,
+ state: WriteFormState,
+ draftId?: string,
+): Promise {
+ const data = buildPostWriteData(state, draftId)
+
+ if (!id) {
+ const result = await createPost(data)
+ postsCollection.upsert(result)
+ return result
+ }
+
+ const tx = createTransaction()
+ tx.update(postsCollection, id, (draft) => {
+ draft.title = data.title
+ draft.text = data.text
+ draft.slug = data.slug
+ draft.categoryId = data.categoryId
+ draft.copyright = data.copyright
+ draft.isPublished = data.isPublished
+ draft.contentFormat = data.contentFormat
+ draft.meta = data.meta
+ draft.summary = data.summary
+ draft.tags = data.tags
+ draft.pinAt = data.pin
+ draft.pinOrder = data.pinOrder
+ if (data.content !== undefined) draft.content = data.content
+ if (data.images !== undefined) draft.images = data.images
+ })
+ const result = await tx.commit(() => updatePost(id, data))
+ postsCollection.hydrate([result])
+ return result
+}
+
function saveWrite(
kind: WriteKind,
id: string,
state: WriteFormState,
draftId?: string,
): Promise {
- state = projectWriteState(state)
-
if (kind === 'post') {
- const data = {
- categoryId: state.categoryId,
- content: state.contentFormat === 'lexical' ? state.content : undefined,
- contentFormat: state.contentFormat,
- copyright: state.copyright,
- draftId,
- images:
- state.contentFormat === 'lexical' ? undefined : buildWriteImages(state),
- isPublished: state.isPublished,
- meta: state.meta,
- pin: state.pin ? new Date().toISOString() : null,
- pinOrder: state.pin ? Number(state.pinOrder) || 1 : null,
- relatedId: splitCommaList(state.relatedId),
- slug: state.slug,
- summary: state.summary || null,
- tags: state.tags
- .split(',')
- .map((tag) => tag.trim())
- .filter(Boolean),
- text: state.text,
- title: state.title,
- }
-
- return id ? updatePost(id, data) : createPost(data)
+ return savePostWrite(id, state, draftId)
}
+ state = projectWriteState(state)
+
if (kind === 'note') {
const data = {
bookmark: state.bookmark,
@@ -3518,7 +3611,7 @@ function formatDateTime(value: string | null | undefined) {
function validateState(
kind: WriteKind,
state: WriteFormState,
- categories: CategoryModel[],
+ categories: CategoryEntity[],
isEditing: boolean,
) {
if (kind !== 'note' && !state.title.trim())
@@ -3727,7 +3820,7 @@ function buildNotePublicPath(
function buildPostPublicPath(
state: Pick,
- category: CategoryModel | undefined,
+ category: CategoryEntity | undefined,
) {
if (!state.slug.trim() || !category?.slug) return ''
return `/posts/${category.slug}/${state.slug.trim()}`
From d2cf180e2f5a27f2f8eb75bb9c13d19301257342 Mon Sep 17 00:00:00 2001
From: Innei
Date: Fri, 10 Jul 2026 23:43:16 +0800
Subject: [PATCH 07/15] fix(admin): reset resource collections on logout, stop
search hydrating 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.
---
.../src/data/resource/collection.test.ts | 30 +++++++++-
apps/admin/src/data/resource/collection.ts | 18 +++++-
.../features/posts/hooks/use-posts-list.ts | 56 ++++++++++++-------
.../components/account/SessionSection.tsx | 4 +-
.../components/modals/ChangePasswordModal.tsx | 2 +
apps/admin/src/ui/layout/sidebar-body.tsx | 2 +
6 files changed, 90 insertions(+), 22 deletions(-)
diff --git a/apps/admin/src/data/resource/collection.test.ts b/apps/admin/src/data/resource/collection.test.ts
index f29f71fa0ef..5dd2b53411e 100644
--- a/apps/admin/src/data/resource/collection.test.ts
+++ b/apps/admin/src/data/resource/collection.test.ts
@@ -2,7 +2,7 @@
import { describe, expect, it, vi } from 'vitest'
-import { defineCollection } from './collection'
+import { defineCollection, resetAllCollections } from './collection'
import { serializeListKey } from './key'
import { createTransaction } from './transaction'
@@ -291,6 +291,34 @@ describe('defineCollection: reset', () => {
})
})
+describe('resetAllCollections', () => {
+ it('resets every collection registered via defineCollection', () => {
+ const collectionA = defineCollection({
+ name: 'reset-all-a',
+ getKey: (e) => e.id,
+ })
+ const collectionB = defineCollection({
+ name: 'reset-all-b',
+ getKey: (e) => e.id,
+ })
+
+ collectionA.hydrate([{ id: '1', title: 'a' }])
+ collectionB.hydrate([{ id: '2', title: 'b' }])
+
+ resetAllCollections()
+
+ const initialState = {
+ entitiesById: {},
+ versionByKey: {},
+ pendingOpsByKey: {},
+ errorsByKey: {},
+ listIndexes: {},
+ }
+ expect(collectionA.store.getState()).toEqual(initialState)
+ expect(collectionB.store.getState()).toEqual(initialState)
+ })
+})
+
describe('createTransaction: partial success', () => {
it('commits fulfilled entities and rolls back the rest', async () => {
const collection = defineCollection({
diff --git a/apps/admin/src/data/resource/collection.ts b/apps/admin/src/data/resource/collection.ts
index abbed92e2f1..b5a1060730e 100644
--- a/apps/admin/src/data/resource/collection.ts
+++ b/apps/admin/src/data/resource/collection.ts
@@ -55,6 +55,18 @@ export interface Collection {
let opCounter = 0
+interface Resettable {
+ reset: () => void
+}
+
+const registeredCollections = new Set()
+
+export function resetAllCollections(): void {
+ for (const collection of registeredCollections) {
+ collection.reset()
+ }
+}
+
function createInitialState(): CollectionState {
return {
entitiesById: {},
@@ -318,7 +330,7 @@ export function defineCollection(
store.setState(createInitialState(), true)
}
- return {
+ const collection: Collection = {
name,
store,
getKey,
@@ -332,4 +344,8 @@ export function defineCollection(
reset,
_ops: { begin, commit, rollback },
}
+
+ registeredCollections.add(collection)
+
+ return collection
}
diff --git a/apps/admin/src/features/posts/hooks/use-posts-list.ts b/apps/admin/src/features/posts/hooks/use-posts-list.ts
index 5cac98926e2..fa61e067d61 100644
--- a/apps/admin/src/features/posts/hooks/use-posts-list.ts
+++ b/apps/admin/src/features/posts/hooks/use-posts-list.ts
@@ -1,3 +1,4 @@
+import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { getCategories } from '~/api/categories'
@@ -6,6 +7,8 @@ import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks'
import { categories } from '~/data/resources/category'
import { posts } from '~/data/resources/post'
import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state'
+import type { Pager } from '~/models/base'
+import type { PostModel } from '~/models/post'
import { adminQueryKeys } from '~/query/keys'
import { allCategoriesValue, postsPageSize, postsQueryKey } from '../constants'
@@ -74,24 +77,19 @@ export function usePostsList() {
sortKey: state.sortKey,
sortOrder: state.sortOrder,
})
- const postsQuery = useCollectionListQuery(posts, {
+ const collectionQuery = useCollectionListQuery(posts, {
+ enabled: !state.keyword,
queryFn: () =>
- state.keyword
- ? searchPosts({
- keyword: state.keyword,
- page: state.page,
- size: postsPageSize,
- })
- : getPosts({
- categoryIds:
- state.categoryId === allCategoriesValue
- ? undefined
- : [state.categoryId],
- page: state.page,
- size: postsPageSize,
- sort_by: state.sortKey,
- sort_order: state.sortOrder,
- }),
+ getPosts({
+ categoryIds:
+ state.categoryId === allCategoriesValue
+ ? undefined
+ : [state.categoryId],
+ page: state.page,
+ size: postsPageSize,
+ sort_by: state.sortKey,
+ sort_order: state.sortOrder,
+ }),
queryKey: postsListKey,
toPage: (result) => ({
items: result.data,
@@ -100,6 +98,26 @@ export function usePostsList() {
})
const postsList = useEntityList(posts, postsListKey, { keepPrevious: true })
+ const searchQuery = useQuery({
+ enabled: !!state.keyword,
+ placeholderData: (previous) => previous,
+ queryFn: () =>
+ searchPosts({
+ keyword: state.keyword,
+ page: state.page,
+ size: postsPageSize,
+ }),
+ queryKey: postsListKey,
+ })
+
+ const postsQuery = state.keyword ? searchQuery : collectionQuery
+ const searchItems: PostModel[] = state.keyword
+ ? (searchQuery.data?.data ?? [])
+ : []
+ const searchPagination: Pager | undefined = state.keyword
+ ? searchQuery.data?.pagination
+ : undefined
+
return {
categories: categoriesList.items,
categoriesQuery,
@@ -111,8 +129,8 @@ export function usePostsList() {
keyword: state.keyword,
keywordInput,
page: state.page,
- pagination: postsList.pagination,
- posts: postsList.items,
+ pagination: state.keyword ? searchPagination : postsList.pagination,
+ posts: state.keyword ? searchItems : postsList.items,
postsQuery,
rootQueryKey: postsQueryKey,
setCategoryId: (categoryId: string) =>
diff --git a/apps/admin/src/features/settings/components/account/SessionSection.tsx b/apps/admin/src/features/settings/components/account/SessionSection.tsx
index 0c20ac88395..8c968b14f77 100644
--- a/apps/admin/src/features/settings/components/account/SessionSection.tsx
+++ b/apps/admin/src/features/settings/components/account/SessionSection.tsx
@@ -2,8 +2,8 @@ import { useMutation, useQuery } from '@tanstack/react-query'
import { Globe, Shield } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
-import type { AccountSession } from '../../types/settings'
+import { resetAllCollections } from '~/data/resource/collection'
import { IpInfoPopover } from '~/features/_shared/components/ip-info-popover'
import { useI18n } from '~/i18n'
import { adminQueryKeys } from '~/query/keys'
@@ -11,6 +11,7 @@ import { Button } from '~/ui/primitives/button'
import { authClient } from '~/utils/authjs/auth'
import { cn } from '~/utils/cn'
+import type { AccountSession } from '../../types/settings'
import { listSessions } from '../../utils/account-sessions'
import { formatDateTime, getErrorMessage } from '../../utils/settings'
import { SettingsSection } from '../SettingsPrimitives'
@@ -31,6 +32,7 @@ export function SessionSection() {
throw new Error(
result.error.message || t('settings.session.error.revokeFailed'),
)
+ resetAllCollections()
} else {
const result = await authClient.revokeSession({ token: session.token })
if (result.error)
diff --git a/apps/admin/src/features/settings/components/modals/ChangePasswordModal.tsx b/apps/admin/src/features/settings/components/modals/ChangePasswordModal.tsx
index 900ec146782..4d417a9fb54 100644
--- a/apps/admin/src/features/settings/components/modals/ChangePasswordModal.tsx
+++ b/apps/admin/src/features/settings/components/modals/ChangePasswordModal.tsx
@@ -3,6 +3,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router'
import { toast } from 'sonner'
+import { resetAllCollections } from '~/data/resource/collection'
import { useI18n } from '~/i18n'
import { ModalHeader } from '~/ui/feedback/modal'
import { present, useModal } from '~/ui/feedback/modal-imperative'
@@ -42,6 +43,7 @@ function ChangePasswordModal() {
toast.success(t('settings.password.success'))
modal.close(true)
await authClient.signOut()
+ resetAllCollections()
navigate('/login')
},
})
diff --git a/apps/admin/src/ui/layout/sidebar-body.tsx b/apps/admin/src/ui/layout/sidebar-body.tsx
index 1e9a841c778..e6cf5ee971d 100644
--- a/apps/admin/src/ui/layout/sidebar-body.tsx
+++ b/apps/admin/src/ui/layout/sidebar-body.tsx
@@ -21,6 +21,7 @@ import { getAppInfo } from '~/api/system'
import { API_URL, GATEWAY_URL, WEB_URL } from '~/constants/env'
import { SESSION_WITH_LOGIN } from '~/constants/keys'
import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout'
+import { resetAllCollections } from '~/data/resource/collection'
import { useI18n } from '~/i18n'
import { SUPPORTED_LOCALES } from '~/i18n/resources'
import type { Locale, TranslationKey } from '~/i18n/types'
@@ -138,6 +139,7 @@ export function SidebarBody(props: { onCollapseSidebar?: () => void }) {
await authClient.signOut()
sessionStorage.removeItem(SESSION_WITH_LOGIN)
queryClient.clear()
+ resetAllCollections()
navigate('/login', { replace: true })
}
const handleBrandContextMenu = (event: ReactMouseEvent) => {
From 0810088b0ce8263133af24835a8c3a673f5c65d7 Mon Sep 17 00:00:00 2001
From: Innei
Date: Sat, 11 Jul 2026 00:56:33 +0800
Subject: [PATCH 08/15] refactor(admin): extract post/category mutation modules
---
.../src/data/resources/category.mutations.ts | 26 +++++
.../src/data/resources/post.mutations.ts | 101 ++++++++++++++++++
.../components/CategoryFormModal.tsx | 20 ++--
.../hooks/use-category-mutations.ts | 4 +-
.../posts/hooks/use-post-mutations.ts | 45 +++-----
.../components/WriteRouteViewsContent.tsx | 47 ++------
6 files changed, 158 insertions(+), 85 deletions(-)
create mode 100644 apps/admin/src/data/resources/category.mutations.ts
create mode 100644 apps/admin/src/data/resources/post.mutations.ts
diff --git a/apps/admin/src/data/resources/category.mutations.ts b/apps/admin/src/data/resources/category.mutations.ts
new file mode 100644
index 00000000000..dacbab2abde
--- /dev/null
+++ b/apps/admin/src/data/resources/category.mutations.ts
@@ -0,0 +1,26 @@
+import type { CreateCategoryData } from '~/api/categories'
+import { createCategory } from '~/api/categories'
+
+import type { CategoryEntity } from './category'
+import { categories } from './category'
+
+export async function saveCategory(
+ mode: { kind: 'create' } | { kind: 'edit'; id: string },
+ form: { name: string; slug: string },
+): Promise {
+ if (mode.kind === 'edit') {
+ return categories.update(mode.id, (draft) => {
+ draft.name = form.name
+ draft.slug = form.slug
+ })
+ }
+
+ const data: CreateCategoryData = { name: form.name, slug: form.slug }
+ const result = await createCategory(data)
+ categories.upsert(result)
+ return result
+}
+
+export function removeCategory(id: string): Promise {
+ return categories.delete(id)
+}
diff --git a/apps/admin/src/data/resources/post.mutations.ts b/apps/admin/src/data/resources/post.mutations.ts
new file mode 100644
index 00000000000..84bd049e126
--- /dev/null
+++ b/apps/admin/src/data/resources/post.mutations.ts
@@ -0,0 +1,101 @@
+import type { CreatePostData } from '~/api/posts'
+import { createPost, deletePost, updatePost } from '~/api/posts'
+import { createTransaction } from '~/data/resource/transaction'
+import type { PostModel } from '~/models/post'
+
+import { posts } from './post'
+
+export function publishPost(
+ id: string,
+ isPublished: boolean,
+): Promise {
+ return posts.update(id, (draft) => {
+ draft.isPublished = isPublished
+ })
+}
+
+export function pinPost(
+ id: string,
+ isPinned: boolean,
+): Promise {
+ return posts.update(id, (draft) => {
+ draft.pinAt = isPinned ? new Date().toISOString() : null
+ })
+}
+
+export function movePostCategory(
+ id: string,
+ categoryId: string,
+): Promise {
+ return posts.update(id, (draft) => {
+ draft.categoryId = categoryId
+ })
+}
+
+export function removePost(id: string): Promise {
+ return posts.delete(id)
+}
+
+export interface BatchRemoveResult {
+ failedCount: number
+ fulfilledKeys: string[]
+ successCount: number
+}
+
+export function removePosts(ids: string[]): Promise {
+ const tx = createTransaction()
+ ids.forEach((id) => tx.delete(posts, id))
+
+ return tx.commit(async () => {
+ const results = await Promise.allSettled(ids.map((id) => deletePost(id)))
+ const fulfilledKeys = ids.filter(
+ (_, index) => results[index].status === 'fulfilled',
+ )
+
+ return {
+ failedCount: ids.length - fulfilledKeys.length,
+ fulfilledKeys,
+ successCount: fulfilledKeys.length,
+ }
+ })
+}
+
+function toOptimisticPostPatch(data: CreatePostData): Partial {
+ const patch: Partial = {
+ categoryId: data.categoryId,
+ contentFormat: data.contentFormat,
+ copyright: data.copyright,
+ isPublished: data.isPublished,
+ meta: data.meta,
+ pinAt: data.pin,
+ pinOrder: data.pinOrder,
+ slug: data.slug,
+ summary: data.summary,
+ tags: data.tags,
+ text: data.text,
+ title: data.title,
+ }
+ if (data.content !== undefined) patch.content = data.content
+ if (data.images !== undefined) patch.images = data.images
+ return patch
+}
+
+export async function savePost(
+ id: string,
+ data: CreatePostData,
+): Promise {
+ if (!id) {
+ const result = await createPost(data)
+ posts.upsert(result)
+ return result
+ }
+
+ const patch = toOptimisticPostPatch(data)
+ const tx = createTransaction()
+ tx.update(posts, id, (draft) => {
+ Object.assign(draft, patch)
+ })
+ const result = await tx.commit(() => updatePost(id, data))
+ posts.hydrate([result])
+ return result
+}
diff --git a/apps/admin/src/features/categories/components/CategoryFormModal.tsx b/apps/admin/src/features/categories/components/CategoryFormModal.tsx
index 34a22a3e5cf..f63ed266400 100644
--- a/apps/admin/src/features/categories/components/CategoryFormModal.tsx
+++ b/apps/admin/src/features/categories/components/CategoryFormModal.tsx
@@ -4,9 +4,8 @@ import type { FormEvent } from 'react'
import { useState } from 'react'
import { toast } from 'sonner'
-import type { CreateCategoryData } from '~/api/categories'
-import { createCategory } from '~/api/categories'
-import { categories, type CategoryEntity } from '~/data/resources/category'
+import type { CategoryEntity } from '~/data/resources/category'
+import { saveCategory } from '~/data/resources/category.mutations'
import { useI18n } from '~/i18n'
import { ModalFooter, ModalHeader } from '~/ui/feedback/modal'
import { present, useModal } from '~/ui/feedback/modal-imperative'
@@ -35,18 +34,17 @@ function CategoryFormModal(props: CategoryFormModalProps) {
: t('categories.form.createTitle')
const mutation = useMutation({
- mutationFn: (data: CreateCategoryData) =>
- props.mode.kind === 'edit'
- ? categories.update(props.mode.category.id, (draft) => {
- draft.name = data.name
- draft.slug = data.slug
- })
- : createCategory(data),
+ mutationFn: (data: { name: string; slug: string }) =>
+ saveCategory(
+ props.mode.kind === 'edit'
+ ? { id: props.mode.category.id, kind: 'edit' }
+ : { kind: 'create' },
+ data,
+ ),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('categories.form.saveFailed'))),
onSuccess: (category) => {
if (!category) return
- if (props.mode.kind === 'create') categories.upsert(category)
toast.success(
props.mode.kind === 'edit'
? t('categories.form.updated')
diff --git a/apps/admin/src/features/categories/hooks/use-category-mutations.ts b/apps/admin/src/features/categories/hooks/use-category-mutations.ts
index 00cda618c66..3450555094e 100644
--- a/apps/admin/src/features/categories/hooks/use-category-mutations.ts
+++ b/apps/admin/src/features/categories/hooks/use-category-mutations.ts
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { toast } from 'sonner'
-import { categories } from '~/data/resources/category'
+import { removeCategory } from '~/data/resources/category.mutations'
import { useI18n } from '~/i18n'
import { adminQueryKeys } from '~/query/keys'
@@ -25,7 +25,7 @@ export function useCategoryMutations(
}, [queryClient])
const deleteMutation = useMutation({
- mutationFn: (id: string) => categories.delete(id),
+ mutationFn: (id: string) => removeCategory(id),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('categories.toast.deleteFailed'))),
onSuccess: async () => {
diff --git a/apps/admin/src/features/posts/hooks/use-post-mutations.ts b/apps/admin/src/features/posts/hooks/use-post-mutations.ts
index 22c09c6cbff..709f765b1c1 100644
--- a/apps/admin/src/features/posts/hooks/use-post-mutations.ts
+++ b/apps/admin/src/features/posts/hooks/use-post-mutations.ts
@@ -1,9 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
-import { deletePost } from '~/api/posts'
-import { createTransaction } from '~/data/resource/transaction'
-import { posts } from '~/data/resources/post'
+import {
+ movePostCategory,
+ pinPost,
+ publishPost,
+ removePost,
+ removePosts,
+} from '~/data/resources/post.mutations'
import { useI18n } from '~/i18n'
import { postsQueryKey } from '../constants'
@@ -23,17 +27,13 @@ export function usePostMutations(options: UsePostMutationsOptions = {}) {
const publishMutation = useMutation({
mutationFn: (payload: { id: string; isPublished: boolean }) =>
- posts.update(payload.id, (draft) => {
- draft.isPublished = payload.isPublished
- }),
+ publishPost(payload.id, payload.isPublished),
onSuccess: invalidatePosts,
})
const categoryMutation = useMutation({
mutationFn: (payload: { categoryId: string; id: string }) =>
- posts.update(payload.id, (draft) => {
- draft.categoryId = payload.categoryId
- }),
+ movePostCategory(payload.id, payload.categoryId),
onError: (error: unknown) =>
toast.error(
getErrorMessage(error, t('posts.toast.categoryUpdateFailed')),
@@ -42,7 +42,7 @@ export function usePostMutations(options: UsePostMutationsOptions = {}) {
})
const deleteMutation = useMutation({
- mutationFn: (id: string) => posts.delete(id),
+ mutationFn: (id: string) => removePost(id),
onSuccess: async () => {
toast.success(t('posts.toast.deleted'))
await invalidatePosts()
@@ -50,26 +50,7 @@ export function usePostMutations(options: UsePostMutationsOptions = {}) {
})
const batchDeleteMutation = useMutation({
- mutationFn: async (ids: string[]) => {
- const tx = createTransaction()
- ids.forEach((id) => tx.delete(posts, id))
-
- return tx.commit(async () => {
- const results = await Promise.allSettled(
- ids.map((id) => deletePost(id)),
- )
- const successfulIds = ids.filter(
- (_, index) => results[index].status === 'fulfilled',
- )
-
- return {
- failedCount: ids.length - successfulIds.length,
- fulfilledKeys: successfulIds,
- successfulIds,
- successCount: successfulIds.length,
- }
- })
- },
+ mutationFn: (ids: string[]) => removePosts(ids),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('posts.toast.batchDeleteFailed'))),
onSuccess: async ({ failedCount, successCount }) => {
@@ -92,9 +73,7 @@ export function usePostMutations(options: UsePostMutationsOptions = {}) {
const pinMutation = useMutation({
mutationFn: (payload: { id: string; isPinned: boolean }) =>
- posts.update(payload.id, (draft) => {
- draft.pinAt = payload.isPinned ? new Date().toISOString() : null
- }),
+ pinPost(payload.id, payload.isPinned),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('posts.toast.pinFailed'))),
onSuccess: invalidatePosts,
diff --git a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
index 7f27f8913f9..047dbaa331b 100644
--- a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
+++ b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
@@ -51,7 +51,8 @@ import {
import { uploadFile, uploadFileWithProgress } from '~/api/files'
import { createNote, getNoteById, updateNote } from '~/api/notes'
import { createPage, getPageById, updatePage } from '~/api/pages'
-import { createPost, getPostById, getPosts, updatePost } from '~/api/posts'
+import type { CreatePostData } from '~/api/posts'
+import { getPostById, getPosts } from '~/api/posts'
import { callBuiltInFunction } from '~/api/system'
import { getTopics } from '~/api/topics'
import { API_URL, WEB_URL } from '~/constants/env'
@@ -65,10 +66,10 @@ import {
useEntity,
useEntityList,
} from '~/data/resource/hooks'
-import { createTransaction } from '~/data/resource/transaction'
import type { CategoryEntity } from '~/data/resources/category'
import { categories as categoriesCollection } from '~/data/resources/category'
import { posts as postsCollection } from '~/data/resources/post'
+import { savePost } from '~/data/resources/post.mutations'
import { DraftStatusTag } from '~/features/drafts/components/draft-status-tag'
import { AgentPanel, useWriteAgent } from '~/features/write/components/agent'
import { DraftHintBanner } from '~/features/write/components/DraftHintBanner'
@@ -3222,7 +3223,10 @@ function resolveDraftPasswordProtected(
return previous.passwordProtected
}
-function buildPostWriteData(state: WriteFormState, draftId?: string) {
+function buildPostWriteData(
+ state: WriteFormState,
+ draftId?: string,
+): CreatePostData {
const projected = projectWriteState(state)
return {
categoryId: projected.categoryId,
@@ -3251,41 +3255,6 @@ function buildPostWriteData(state: WriteFormState, draftId?: string) {
}
}
-async function savePostWrite(
- id: string,
- state: WriteFormState,
- draftId?: string,
-): Promise {
- const data = buildPostWriteData(state, draftId)
-
- if (!id) {
- const result = await createPost(data)
- postsCollection.upsert(result)
- return result
- }
-
- const tx = createTransaction()
- tx.update(postsCollection, id, (draft) => {
- draft.title = data.title
- draft.text = data.text
- draft.slug = data.slug
- draft.categoryId = data.categoryId
- draft.copyright = data.copyright
- draft.isPublished = data.isPublished
- draft.contentFormat = data.contentFormat
- draft.meta = data.meta
- draft.summary = data.summary
- draft.tags = data.tags
- draft.pinAt = data.pin
- draft.pinOrder = data.pinOrder
- if (data.content !== undefined) draft.content = data.content
- if (data.images !== undefined) draft.images = data.images
- })
- const result = await tx.commit(() => updatePost(id, data))
- postsCollection.hydrate([result])
- return result
-}
-
function saveWrite(
kind: WriteKind,
id: string,
@@ -3293,7 +3262,7 @@ function saveWrite(
draftId?: string,
): Promise {
if (kind === 'post') {
- return savePostWrite(id, state, draftId)
+ return savePost(id, buildPostWriteData(state, draftId))
}
state = projectWriteState(state)
From a9ff52d5722064f7fd1cdba0b69fe7dbe0df814a Mon Sep 17 00:00:00 2001
From: Innei
Date: Sat, 11 Jul 2026 01:49:22 +0800
Subject: [PATCH 09/15] feat(admin): migrate topic domain to resource
collections
---
.../src/data/resources/topic.mutations.ts | 53 +++++++++++++++++++
apps/admin/src/data/resources/topic.ts | 10 ++++
.../topics/components/TopicDetail.tsx | 6 ++-
.../topics/components/TopicDetailRoute.tsx | 20 ++-----
.../topics/components/TopicFormModal.tsx | 9 +++-
.../topics/hooks/use-topic-mutations.ts | 12 ++---
.../features/topics/hooks/use-topics-list.ts | 23 +++++---
7 files changed, 97 insertions(+), 36 deletions(-)
create mode 100644 apps/admin/src/data/resources/topic.mutations.ts
create mode 100644 apps/admin/src/data/resources/topic.ts
diff --git a/apps/admin/src/data/resources/topic.mutations.ts b/apps/admin/src/data/resources/topic.mutations.ts
new file mode 100644
index 00000000000..b51c0b4dfee
--- /dev/null
+++ b/apps/admin/src/data/resources/topic.mutations.ts
@@ -0,0 +1,53 @@
+import type { CreateTopicData } from '~/api/topics'
+import { createTopic, deleteTopic } from '~/api/topics'
+import { createTransaction } from '~/data/resource/transaction'
+import type { TopicModel } from '~/models/topic'
+
+import { topics } from './topic'
+
+export async function saveTopic(
+ mode: { kind: 'create' } | { id: string; kind: 'edit' },
+ form: CreateTopicData,
+): Promise {
+ if (mode.kind === 'edit') {
+ return topics.update(mode.id, (draft) => {
+ draft.name = form.name
+ draft.slug = form.slug
+ draft.introduce = form.introduce
+ draft.description = form.description ?? ''
+ draft.icon = form.icon ?? null
+ })
+ }
+
+ const result = await createTopic(form)
+ topics.upsert(result)
+ return result
+}
+
+export function removeTopic(id: string): Promise {
+ return topics.delete(id)
+}
+
+export interface BatchRemoveResult {
+ failedCount: number
+ fulfilledKeys: string[]
+ successCount: number
+}
+
+export function removeTopics(ids: string[]): Promise {
+ const tx = createTransaction()
+ ids.forEach((id) => tx.delete(topics, id))
+
+ return tx.commit(async () => {
+ const results = await Promise.allSettled(ids.map((id) => deleteTopic(id)))
+ const fulfilledKeys = ids.filter(
+ (_, index) => results[index].status === 'fulfilled',
+ )
+
+ return {
+ failedCount: ids.length - fulfilledKeys.length,
+ fulfilledKeys,
+ successCount: fulfilledKeys.length,
+ }
+ })
+}
diff --git a/apps/admin/src/data/resources/topic.ts b/apps/admin/src/data/resources/topic.ts
new file mode 100644
index 00000000000..3bb35e7c35a
--- /dev/null
+++ b/apps/admin/src/data/resources/topic.ts
@@ -0,0 +1,10 @@
+import { deleteTopic, patchTopic } from '~/api/topics'
+import { defineCollection } from '~/data/resource/collection'
+import type { TopicModel } from '~/models/topic'
+
+export const topics = defineCollection({
+ name: 'topic',
+ getKey: (topic) => topic.id,
+ onUpdate: ({ id, patch }) => patchTopic(id, patch),
+ onDelete: ({ id }) => deleteTopic(id),
+})
diff --git a/apps/admin/src/features/topics/components/TopicDetail.tsx b/apps/admin/src/features/topics/components/TopicDetail.tsx
index 7544e77b0b2..648826c99a1 100644
--- a/apps/admin/src/features/topics/components/TopicDetail.tsx
+++ b/apps/admin/src/features/topics/components/TopicDetail.tsx
@@ -6,6 +6,8 @@ import { toast } from 'sonner'
import { patchNote } from '~/api/notes'
import { getNotesByTopic, getTopic } from '~/api/topics'
import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout'
+import { useCollectionDetailQuery, useEntity } from '~/data/resource/hooks'
+import { topics } from '~/data/resources/topic'
import { useI18n } from '~/i18n'
import type { TopicModel } from '~/models/topic'
import { adminQueryKeys } from '~/query/keys'
@@ -33,7 +35,8 @@ export function TopicDetail(props: {
const queryClient = useQueryClient()
const [notesPage, setNotesPage] = useState(1)
- const topicQuery = useQuery({
+ const topic = useEntity(topics, props.topicId)
+ const topicQuery = useCollectionDetailQuery(topics, {
queryFn: () => getTopic(props.topicId),
queryKey: adminQueryKeys.topics.detail(props.topicId),
})
@@ -55,7 +58,6 @@ export function TopicDetail(props: {
setNotesPage(1)
}, [props.topicId])
- const topic = topicQuery.data
const notes = notesQuery.data?.data ?? []
const notesPagination = notesQuery.data?.pagination
diff --git a/apps/admin/src/features/topics/components/TopicDetailRoute.tsx b/apps/admin/src/features/topics/components/TopicDetailRoute.tsx
index f52b7b3a172..355a443e061 100644
--- a/apps/admin/src/features/topics/components/TopicDetailRoute.tsx
+++ b/apps/admin/src/features/topics/components/TopicDetailRoute.tsx
@@ -1,40 +1,30 @@
-import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams } from 'react-router'
-import { findInListCache } from '~/api/list-cache'
import { getTopic } from '~/api/topics'
+import { useCollectionDetailQuery, useEntity } from '~/data/resource/hooks'
+import { topics } from '~/data/resources/topic'
import { useDocumentTitle } from '~/hooks/use-document-title'
-import type { TopicModel } from '~/models/topic'
import { adminQueryKeys } from '~/query/keys'
import { TopicDetail } from './TopicDetail'
import { TopicDetailEmpty } from './TopicDetailEmpty'
import { useTopicsRouteContext } from './topics-route-context'
-const LIST_PREFIX = adminQueryKeys.topics.listRoot
-
export function TopicDetailRoute() {
const { id } = useParams<{ id: string }>()
- const queryClient = useQueryClient()
const ctx = useTopicsRouteContext()
- const initialTopic = id
- ? findInListCache(queryClient, LIST_PREFIX, id)
- : undefined
+ const topic = useEntity(topics, id)
- // Prime the detail cache so the inner TopicDetail useQuery resolves
- // synchronously on mount.
- const topicQuery = useQuery({
+ useCollectionDetailQuery(topics, {
enabled: Boolean(id),
- initialData: initialTopic,
queryFn: () => getTopic(id!),
queryKey: id
? adminQueryKeys.topics.detail(id)
: adminQueryKeys.topics.root,
- staleTime: initialTopic ? 30_000 : 0,
})
- useDocumentTitle(topicQuery.data?.name)
+ useDocumentTitle(topic?.name)
if (!id) return
diff --git a/apps/admin/src/features/topics/components/TopicFormModal.tsx b/apps/admin/src/features/topics/components/TopicFormModal.tsx
index fd2e04daa87..11f66b2c53f 100644
--- a/apps/admin/src/features/topics/components/TopicFormModal.tsx
+++ b/apps/admin/src/features/topics/components/TopicFormModal.tsx
@@ -6,7 +6,8 @@ import { toast } from 'sonner'
import { uploadFile } from '~/api/files'
import type { CreateTopicData } from '~/api/topics'
-import { createTopic, getTopic, updateTopic } from '~/api/topics'
+import { getTopic } from '~/api/topics'
+import { saveTopic } from '~/data/resources/topic.mutations'
import { useI18n } from '~/i18n'
import type { TopicModel } from '~/models/topic'
import { adminQueryKeys } from '~/query/keys'
@@ -63,10 +64,14 @@ function TopicFormModal(props: TopicFormModalProps) {
const mutation = useMutation({
mutationFn: (data: CreateTopicData) =>
- editId ? updateTopic(editId, data) : createTopic(data),
+ saveTopic(
+ editId ? { id: editId, kind: 'edit' } : { kind: 'create' },
+ data,
+ ),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('topics.form.saveFailed'))),
onSuccess: (topic) => {
+ if (!topic) return
toast.success(
isEdit ? t('topics.form.savedUpdated') : t('topics.form.savedCreated'),
)
diff --git a/apps/admin/src/features/topics/hooks/use-topic-mutations.ts b/apps/admin/src/features/topics/hooks/use-topic-mutations.ts
index 56b5b9bdd2b..99cf1ebfa6d 100644
--- a/apps/admin/src/features/topics/hooks/use-topic-mutations.ts
+++ b/apps/admin/src/features/topics/hooks/use-topic-mutations.ts
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { toast } from 'sonner'
-import { deleteTopic } from '~/api/topics'
+import { removeTopic, removeTopics } from '~/data/resources/topic.mutations'
import { useI18n } from '~/i18n'
import { topicsQueryKey } from '../constants'
@@ -21,7 +21,7 @@ export function useTopicMutations(options: UseTopicMutationsOptions = {}) {
}, [queryClient])
const deleteMutation = useMutation({
- mutationFn: deleteTopic,
+ mutationFn: removeTopic,
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('topics.list.deleteFailed'))),
onSuccess: async () => {
@@ -32,13 +32,7 @@ export function useTopicMutations(options: UseTopicMutationsOptions = {}) {
})
const batchDeleteMutation = useMutation({
- mutationFn: async (ids: string[]) => {
- const results = await Promise.allSettled(ids.map((id) => deleteTopic(id)))
- return {
- failedCount: results.filter((r) => r.status === 'rejected').length,
- successCount: results.filter((r) => r.status === 'fulfilled').length,
- }
- },
+ mutationFn: (ids: string[]) => removeTopics(ids),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('topics.list.deleteFailed'))),
onSuccess: async ({ failedCount, successCount }) => {
diff --git a/apps/admin/src/features/topics/hooks/use-topics-list.ts b/apps/admin/src/features/topics/hooks/use-topics-list.ts
index 8000efdc961..d5b13104e53 100644
--- a/apps/admin/src/features/topics/hooks/use-topics-list.ts
+++ b/apps/admin/src/features/topics/hooks/use-topics-list.ts
@@ -1,7 +1,8 @@
-import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { getTopics } from '~/api/topics'
+import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks'
+import { topics } from '~/data/resources/topic'
import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state'
import { adminQueryKeys } from '~/query/keys'
@@ -29,20 +30,26 @@ export function useTopicsList() {
const [state, setState] = useUrlListState(urlStateOptions)
- const topicsQuery = useQuery({
- placeholderData: (previous) => previous,
+ const listKey = adminQueryKeys.topics.list({
+ page: state.page,
+ size: topicPageSize,
+ })
+
+ const topicsQuery = useCollectionListQuery(topics, {
queryFn: () => getTopics({ page: state.page, size: topicPageSize }),
- queryKey: adminQueryKeys.topics.list({
- page: state.page,
- size: topicPageSize,
+ queryKey: listKey,
+ toPage: (result) => ({
+ items: result.data,
+ pagination: result.pagination,
}),
})
+ const topicsList = useEntityList(topics, listKey, { keepPrevious: true })
return {
page: state.page,
- pagination: topicsQuery.data?.pagination,
+ pagination: topicsList.pagination,
setPage: (page: number) => setState({ page }),
- topics: topicsQuery.data?.data ?? [],
+ topics: topicsList.items,
topicsQuery,
}
}
From f32b991ba5b4fe24f85f26406b31cef62e746116 Mon Sep 17 00:00:00 2001
From: Innei
Date: Sat, 11 Jul 2026 01:55:31 +0800
Subject: [PATCH 10/15] fix(admin): switch topic edit to PUT transaction, echo
full entity on 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.
---
apps/admin/src/data/resources/topic.mutations.ts | 11 ++++++++---
apps/admin/src/data/resources/topic.ts | 11 +++++++++--
2 files changed, 17 insertions(+), 5 deletions(-)
diff --git a/apps/admin/src/data/resources/topic.mutations.ts b/apps/admin/src/data/resources/topic.mutations.ts
index b51c0b4dfee..91f40ee0572 100644
--- a/apps/admin/src/data/resources/topic.mutations.ts
+++ b/apps/admin/src/data/resources/topic.mutations.ts
@@ -1,5 +1,5 @@
import type { CreateTopicData } from '~/api/topics'
-import { createTopic, deleteTopic } from '~/api/topics'
+import { createTopic, deleteTopic, updateTopic } from '~/api/topics'
import { createTransaction } from '~/data/resource/transaction'
import type { TopicModel } from '~/models/topic'
@@ -8,15 +8,20 @@ import { topics } from './topic'
export async function saveTopic(
mode: { kind: 'create' } | { id: string; kind: 'edit' },
form: CreateTopicData,
-): Promise {
+): Promise {
if (mode.kind === 'edit') {
- return topics.update(mode.id, (draft) => {
+ const { id } = mode
+ const tx = createTransaction()
+ tx.update(topics, id, (draft) => {
draft.name = form.name
draft.slug = form.slug
draft.introduce = form.introduce
draft.description = form.description ?? ''
draft.icon = form.icon ?? null
})
+ const result = await tx.commit(() => updateTopic(id, form))
+ topics.hydrate([result])
+ return result
}
const result = await createTopic(form)
diff --git a/apps/admin/src/data/resources/topic.ts b/apps/admin/src/data/resources/topic.ts
index 3bb35e7c35a..bcc90010ddf 100644
--- a/apps/admin/src/data/resources/topic.ts
+++ b/apps/admin/src/data/resources/topic.ts
@@ -1,10 +1,17 @@
-import { deleteTopic, patchTopic } from '~/api/topics'
+import { deleteTopic, updateTopic } from '~/api/topics'
import { defineCollection } from '~/data/resource/collection'
import type { TopicModel } from '~/models/topic'
export const topics = defineCollection({
name: 'topic',
getKey: (topic) => topic.id,
- onUpdate: ({ id, patch }) => patchTopic(id, patch),
+ onUpdate: ({ id, next }) =>
+ updateTopic(id, {
+ description: next.description ?? undefined,
+ icon: next.icon || undefined,
+ introduce: next.introduce ?? '',
+ name: next.name,
+ slug: next.slug,
+ }),
onDelete: ({ id }) => deleteTopic(id),
})
From 94e7ca35ca215486a8328ae97eed4eec7048c2ce Mon Sep 17 00:00:00 2001
From: Innei
Date: Sat, 11 Jul 2026 02:10:58 +0800
Subject: [PATCH 11/15] feat(admin): migrate note domain to resource
collections
---
.../src/data/resources/note.mutations.ts | 102 +++++++++++++++
apps/admin/src/data/resources/note.ts | 15 +++
.../notes/hooks/use-note-mutations.ts | 26 ++--
.../features/notes/hooks/use-notes-list.ts | 80 +++++++-----
.../components/AddNotesToTopicModal.tsx | 25 ++--
.../topics/components/TopicDetail.tsx | 38 ++++--
.../components/WriteRouteViewsContent.tsx | 116 +++++++++++-------
7 files changed, 294 insertions(+), 108 deletions(-)
create mode 100644 apps/admin/src/data/resources/note.mutations.ts
create mode 100644 apps/admin/src/data/resources/note.ts
diff --git a/apps/admin/src/data/resources/note.mutations.ts b/apps/admin/src/data/resources/note.mutations.ts
new file mode 100644
index 00000000000..d0fca59708c
--- /dev/null
+++ b/apps/admin/src/data/resources/note.mutations.ts
@@ -0,0 +1,102 @@
+import type { CreateNoteData, PatchNoteData } from '~/api/notes'
+import {
+ createNote,
+ deleteNote,
+ patchNotePublish,
+ updateNote,
+} from '~/api/notes'
+import { createTransaction } from '~/data/resource/transaction'
+import type { NoteModel } from '~/models/note'
+
+import { notes } from './note'
+
+export async function publishNote(
+ id: string,
+ isPublished: boolean,
+): Promise {
+ const tx = createTransaction()
+ tx.update(notes, id, (draft) => {
+ draft.isPublished = isPublished
+ })
+ const result = await tx.commit(() => patchNotePublish(id, isPublished))
+ notes.hydrate([result])
+ return result
+}
+
+export function patchNoteFields(
+ id: string,
+ patch: PatchNoteData,
+): Promise {
+ return notes.update(id, (draft) => {
+ Object.assign(draft, patch)
+ })
+}
+
+export function removeNote(id: string): Promise {
+ return notes.delete(id)
+}
+
+export interface BatchRemoveResult {
+ failedCount: number
+ fulfilledKeys: string[]
+ successCount: number
+}
+
+export function removeNotes(ids: string[]): Promise {
+ const tx = createTransaction()
+ ids.forEach((id) => tx.delete(notes, id))
+
+ return tx.commit(async () => {
+ const results = await Promise.allSettled(ids.map((id) => deleteNote(id)))
+ const fulfilledKeys = ids.filter(
+ (_, index) => results[index].status === 'fulfilled',
+ )
+
+ return {
+ failedCount: ids.length - fulfilledKeys.length,
+ fulfilledKeys,
+ successCount: fulfilledKeys.length,
+ }
+ })
+}
+
+function toOptimisticNotePatch(data: CreateNoteData): Partial {
+ const patch: Partial = {
+ bookmark: data.bookmark,
+ contentFormat: data.contentFormat,
+ coordinates: data.coordinates,
+ isPublished: data.isPublished,
+ location: data.location,
+ meta: data.meta,
+ publicAt: data.publicAt,
+ text: data.text,
+ title: data.title,
+ topicId: data.topicId,
+ }
+ if (data.content !== undefined) patch.content = data.content
+ if (data.images !== undefined) patch.images = data.images
+ if (data.mood !== undefined) patch.mood = data.mood
+ if (data.slug !== undefined) patch.slug = data.slug
+ if (data.weather !== undefined) patch.weather = data.weather
+ return patch
+}
+
+export async function saveNote(
+ id: string,
+ data: CreateNoteData,
+): Promise {
+ if (!id) {
+ const result = await createNote(data)
+ notes.upsert(result)
+ return result
+ }
+
+ const patch = toOptimisticNotePatch(data)
+ const tx = createTransaction()
+ tx.update(notes, id, (draft) => {
+ Object.assign(draft, patch)
+ })
+ const result = await tx.commit(() => updateNote(id, data))
+ notes.hydrate([result])
+ return result
+}
diff --git a/apps/admin/src/data/resources/note.ts b/apps/admin/src/data/resources/note.ts
new file mode 100644
index 00000000000..6a8f8fcaf71
--- /dev/null
+++ b/apps/admin/src/data/resources/note.ts
@@ -0,0 +1,15 @@
+import { deleteNote, patchNote } from '~/api/notes'
+import { defineCollection } from '~/data/resource/collection'
+import type { NoteModel } from '~/models/note'
+
+import { topics } from './topic'
+
+export const notes = defineCollection({
+ name: 'note',
+ getKey: (note) => note.id,
+ normalize: (note) => {
+ if (note.topic) topics.upsert(note.topic)
+ },
+ onUpdate: ({ id, patch }) => patchNote(id, patch),
+ onDelete: ({ id }) => deleteNote(id),
+})
diff --git a/apps/admin/src/features/notes/hooks/use-note-mutations.ts b/apps/admin/src/features/notes/hooks/use-note-mutations.ts
index de050da6bfa..18444da70fb 100644
--- a/apps/admin/src/features/notes/hooks/use-note-mutations.ts
+++ b/apps/admin/src/features/notes/hooks/use-note-mutations.ts
@@ -1,7 +1,12 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
-import { deleteNote, patchNote, patchNotePublish } from '~/api/notes'
+import {
+ patchNoteFields,
+ publishNote,
+ removeNote,
+ removeNotes,
+} from '~/data/resources/note.mutations'
import { useI18n } from '~/i18n'
import { notesQueryKey } from '../constants'
@@ -22,7 +27,7 @@ export function useNoteMutations(options: UseNoteMutationsOptions = {}) {
const publishMutation = useMutation({
mutationFn: (payload: { id: string; isPublished: boolean }) =>
- patchNotePublish(payload.id, payload.isPublished),
+ publishNote(payload.id, payload.isPublished),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('notes.toast.updateFailed'))),
onSuccess: invalidateNotes,
@@ -30,14 +35,14 @@ export function useNoteMutations(options: UseNoteMutationsOptions = {}) {
const patchMutation = useMutation({
mutationFn: (payload: { data: NoteMetadataUpdate; id: string }) =>
- patchNote(payload.id, payload.data),
+ patchNoteFields(payload.id, payload.data),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('notes.toast.updateFailed'))),
onSuccess: invalidateNotes,
})
const deleteMutation = useMutation({
- mutationFn: deleteNote,
+ mutationFn: (id: string) => removeNote(id),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('notes.toast.deleteFailed'))),
onSuccess: async () => {
@@ -47,18 +52,7 @@ export function useNoteMutations(options: UseNoteMutationsOptions = {}) {
})
const batchDeleteMutation = useMutation({
- mutationFn: async (ids: string[]) => {
- const results = await Promise.allSettled(ids.map((id) => deleteNote(id)))
- const successfulIds = ids.filter(
- (_, index) => results[index].status === 'fulfilled',
- )
-
- return {
- failedCount: ids.length - successfulIds.length,
- successfulIds,
- successCount: successfulIds.length,
- }
- },
+ mutationFn: (ids: string[]) => removeNotes(ids),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('notes.toast.batchDeleteFailed'))),
onSuccess: async ({ failedCount, successCount }) => {
diff --git a/apps/admin/src/features/notes/hooks/use-notes-list.ts b/apps/admin/src/features/notes/hooks/use-notes-list.ts
index fc44e05f10d..1ce1c8d1e01 100644
--- a/apps/admin/src/features/notes/hooks/use-notes-list.ts
+++ b/apps/admin/src/features/notes/hooks/use-notes-list.ts
@@ -2,7 +2,11 @@ import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { getNotes, searchNotes } from '~/api/notes'
+import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks'
+import { notes } from '~/data/resources/note'
import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state'
+import type { Pager } from '~/models/base'
+import type { NoteModel } from '~/models/note'
import { adminQueryKeys } from '~/query/keys'
import { notesPageSize, notesQueryKey } from '../constants'
@@ -53,38 +57,58 @@ export function useNotesList() {
setKeywordInput(state.keyword)
}, [state.keyword])
- const notesQuery = useQuery({
- placeholderData: (previous) => previous,
+ const notesListKey = adminQueryKeys.notes.list({
+ filter: state.filter,
+ keyword: state.keyword,
+ page: state.page,
+ size: notesPageSize,
+ sortKey: state.sortKey,
+ sortOrder: state.sortOrder,
+ })
+ const collectionQuery = useCollectionListQuery(notes, {
+ enabled: !state.keyword,
queryFn: () =>
- state.keyword
- ? searchNotes({
- keyword: state.keyword,
+ state.filter === 'all'
+ ? getNotes({
page: state.page,
size: notesPageSize,
+ sort_by: state.sortKey,
+ sort_order: state.sortOrder,
})
- : state.filter === 'all'
- ? getNotes({
- page: state.page,
- size: notesPageSize,
- sort_by: state.sortKey,
- sort_order: state.sortOrder,
- })
- : getFilteredNotes({
- filter: state.filter,
- page: state.page,
- size: notesPageSize,
- sortKey: state.sortKey,
- sortOrder: state.sortOrder,
- }),
- queryKey: adminQueryKeys.notes.list({
- filter: state.filter,
- keyword: state.keyword,
- page: state.page,
- size: notesPageSize,
- sortKey: state.sortKey,
- sortOrder: state.sortOrder,
+ : getFilteredNotes({
+ filter: state.filter,
+ page: state.page,
+ size: notesPageSize,
+ sortKey: state.sortKey,
+ sortOrder: state.sortOrder,
+ }),
+ queryKey: notesListKey,
+ toPage: (result) => ({
+ items: result.data,
+ pagination: result.pagination,
}),
})
+ const notesList = useEntityList(notes, notesListKey, { keepPrevious: true })
+
+ const searchQuery = useQuery({
+ enabled: !!state.keyword,
+ placeholderData: (previous) => previous,
+ queryFn: () =>
+ searchNotes({
+ keyword: state.keyword,
+ page: state.page,
+ size: notesPageSize,
+ }),
+ queryKey: [...notesListKey, 'search'],
+ })
+
+ const notesQuery = state.keyword ? searchQuery : collectionQuery
+ const searchItems: NoteModel[] = state.keyword
+ ? (searchQuery.data?.data ?? [])
+ : []
+ const searchPagination: Pager | undefined = state.keyword
+ ? searchQuery.data?.pagination
+ : undefined
return {
clearSearch: () => {
@@ -94,10 +118,10 @@ export function useNotesList() {
filter: state.filter,
keyword: state.keyword,
keywordInput,
- notes: notesQuery.data?.data ?? [],
+ notes: state.keyword ? searchItems : notesList.items,
notesQuery,
page: state.page,
- pagination: notesQuery.data?.pagination,
+ pagination: state.keyword ? searchPagination : notesList.pagination,
rootQueryKey: notesQueryKey,
setFilter: (filter: NoteFilter) =>
setState((current) => ({ ...current, filter, page: 1 })),
diff --git a/apps/admin/src/features/topics/components/AddNotesToTopicModal.tsx b/apps/admin/src/features/topics/components/AddNotesToTopicModal.tsx
index a0ed1b95227..92eee6405de 100644
--- a/apps/admin/src/features/topics/components/AddNotesToTopicModal.tsx
+++ b/apps/admin/src/features/topics/components/AddNotesToTopicModal.tsx
@@ -1,15 +1,17 @@
+import type { InfiniteData } from '@tanstack/react-query'
import { useInfiniteQuery, useMutation } from '@tanstack/react-query'
import { Check, Inbox, Loader2, Plus } from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
-import type { InfiniteData } from '@tanstack/react-query'
-import type { PaginateResult } from '~/models/base'
-import type { NoteModel } from '~/models/note'
-import { getNotes, patchNote } from '~/api/notes'
+import { getNotes } from '~/api/notes'
+import { notes as notesCollection } from '~/data/resources/note'
+import { patchNoteFields } from '~/data/resources/note.mutations'
import { useI18n } from '~/i18n'
-import { ModalHeader } from '~/ui/feedback/modal'
+import type { PaginateResult } from '~/models/base'
+import type { NoteModel } from '~/models/note'
import { adminQueryKeys } from '~/query/keys'
+import { ModalHeader } from '~/ui/feedback/modal'
import { present, useModal } from '~/ui/feedback/modal-imperative'
import { Button } from '~/ui/primitives/button'
import { Scroll } from '~/ui/primitives/scroll'
@@ -41,11 +43,14 @@ function AddNotesToTopicModal(props: AddNotesToTopicModalProps) {
? lastPage.pagination.page + 1
: undefined,
initialPageParam: 1,
- queryFn: ({ pageParam }) =>
- getNotes({
+ queryFn: async ({ pageParam }) => {
+ const result = await getNotes({
page: pageParam,
size: topicPickerPageSize,
- }),
+ })
+ notesCollection.hydrate(result.data)
+ return result
+ },
queryKey: adminQueryKeys.notes.topicPicker(props.topicId),
})
@@ -74,7 +79,9 @@ function AddNotesToTopicModal(props: AddNotesToTopicModalProps) {
mutationFn: async () => {
const noteIds = Array.from(selectedIds)
await Promise.all(
- noteIds.map((noteId) => patchNote(noteId, { topicId: props.topicId })),
+ noteIds.map((noteId) =>
+ patchNoteFields(noteId, { topicId: props.topicId }),
+ ),
)
},
onError: (error: unknown) =>
diff --git a/apps/admin/src/features/topics/components/TopicDetail.tsx b/apps/admin/src/features/topics/components/TopicDetail.tsx
index 648826c99a1..6243981fd65 100644
--- a/apps/admin/src/features/topics/components/TopicDetail.tsx
+++ b/apps/admin/src/features/topics/components/TopicDetail.tsx
@@ -1,14 +1,21 @@
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Edit3, Loader2, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
-import { patchNote } from '~/api/notes'
import { getNotesByTopic, getTopic } from '~/api/topics'
import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout'
-import { useCollectionDetailQuery, useEntity } from '~/data/resource/hooks'
+import {
+ useCollectionDetailQuery,
+ useCollectionListQuery,
+ useEntity,
+ useEntityList,
+} from '~/data/resource/hooks'
+import { notes as notesCollection } from '~/data/resources/note'
+import { patchNoteFields } from '~/data/resources/note.mutations'
import { topics } from '~/data/resources/topic'
import { useI18n } from '~/i18n'
+import type { NoteModel } from '~/models/note'
import type { TopicModel } from '~/models/topic'
import { adminQueryKeys } from '~/query/keys'
import { MobileHeaderAffordance } from '~/ui/layout/mobile-header-affordance'
@@ -40,29 +47,36 @@ export function TopicDetail(props: {
queryFn: () => getTopic(props.topicId),
queryKey: adminQueryKeys.topics.detail(props.topicId),
})
- const notesQuery = useQuery({
- placeholderData: (previous) => previous,
+ const topicNotesKey = adminQueryKeys.topics.notes({
+ page: notesPage,
+ size: topicNotesPageSize,
+ topicId: props.topicId,
+ })
+ const notesQuery = useCollectionListQuery(notesCollection, {
queryFn: () =>
getNotesByTopic(props.topicId, {
page: notesPage,
size: topicNotesPageSize,
}),
- queryKey: adminQueryKeys.topics.notes({
- page: notesPage,
- size: topicNotesPageSize,
- topicId: props.topicId,
+ queryKey: topicNotesKey,
+ toPage: (result) => ({
+ items: result.data as NoteModel[],
+ pagination: result.pagination,
}),
})
+ const notesList = useEntityList(notesCollection, topicNotesKey, {
+ keepPrevious: true,
+ })
useEffect(() => {
setNotesPage(1)
}, [props.topicId])
- const notes = notesQuery.data?.data ?? []
- const notesPagination = notesQuery.data?.pagination
+ const notes = notesList.items
+ const notesPagination = notesList.pagination
const removeNoteMutation = useMutation({
- mutationFn: (noteId: string) => patchNote(noteId, { topicId: null }),
+ mutationFn: (noteId: string) => patchNoteFields(noteId, { topicId: null }),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('topics.detail.removeRefFailed'))),
onSuccess: async () => {
diff --git a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
index 047dbaa331b..d381a005a4a 100644
--- a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
+++ b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
@@ -49,7 +49,8 @@ import {
updateDraft,
} from '~/api/drafts'
import { uploadFile, uploadFileWithProgress } from '~/api/files'
-import { createNote, getNoteById, updateNote } from '~/api/notes'
+import type { CreateNoteData } from '~/api/notes'
+import { getNoteById } from '~/api/notes'
import { createPage, getPageById, updatePage } from '~/api/pages'
import type { CreatePostData } from '~/api/posts'
import { getPostById, getPosts } from '~/api/posts'
@@ -68,8 +69,11 @@ import {
} from '~/data/resource/hooks'
import type { CategoryEntity } from '~/data/resources/category'
import { categories as categoriesCollection } from '~/data/resources/category'
+import { notes as notesCollection } from '~/data/resources/note'
+import { saveNote } from '~/data/resources/note.mutations'
import { posts as postsCollection } from '~/data/resources/post'
import { savePost } from '~/data/resources/post.mutations'
+import { topics as topicsCollection } from '~/data/resources/topic'
import { DraftStatusTag } from '~/features/drafts/components/draft-status-tag'
import { AgentPanel, useWriteAgent } from '~/features/write/components/agent'
import { DraftHintBanner } from '~/features/write/components/DraftHintBanner'
@@ -187,7 +191,6 @@ const emptyState: WriteFormState = {
weather: '',
}
-const emptyTopics: TopicModel[] = []
const PREFERRED_CONTENT_FORMAT_STORAGE_KEY = 'preferred-content-format'
const RichEditorWithAgent = lazy(() =>
import('~/vendor/rich-editor/components/RichEditorWithAgent').then(
@@ -402,11 +405,19 @@ function WritePage(props: { kind: WriteKind }) {
categoriesCollection,
adminQueryKeys.categories.list(),
)
- const topicsQuery = useQuery({
+ useCollectionListQuery(topicsCollection, {
enabled: props.kind === 'note',
queryFn: () => getTopics({ page: 1, size: 100 }),
queryKey: adminQueryKeys.topics.list({ page: 1, size: 100 }),
+ toPage: (result) => ({
+ items: result.data,
+ pagination: result.pagination,
+ }),
})
+ const topicsList = useEntityList(
+ topicsCollection,
+ adminQueryKeys.topics.list({ page: 1, size: 100 }),
+ )
const tagsQuery = useQuery({
enabled: props.kind === 'post',
queryFn: getTags,
@@ -440,21 +451,33 @@ function WritePage(props: { kind: WriteKind }) {
postsCollection,
props.kind === 'post' && isEditing ? id : undefined,
)
+ const noteDetailQuery = useCollectionDetailQuery(notesCollection, {
+ enabled: isEditing && props.kind === 'note',
+ queryFn: () => getNoteById(id, { single: true }),
+ queryKey: adminQueryKeys.write.detail({ id, kind: props.kind }),
+ })
+ const noteEntity = useEntity(
+ notesCollection,
+ props.kind === 'note' && isEditing ? id : undefined,
+ )
const nonPostDetailQuery = useQuery({
- enabled: isEditing && props.kind !== 'post',
+ enabled: isEditing && props.kind === 'page',
queryFn: () => getWriteDetail(props.kind, id),
queryKey: adminQueryKeys.write.detail({ id, kind: props.kind }),
})
+ const storeEntity = props.kind === 'note' ? noteEntity : postEntity
+ const storeDetailQuery =
+ props.kind === 'note' ? noteDetailQuery : postDetailQuery
const detailModel: WriteModel | undefined =
- props.kind === 'post' ? postEntity : nonPostDetailQuery.data
+ props.kind === 'page' ? nonPostDetailQuery.data : storeEntity
const isDetailLoaded =
- props.kind === 'post'
- ? postDetailQuery.isSuccess
- : nonPostDetailQuery.isSuccess
+ props.kind === 'page'
+ ? nonPostDetailQuery.isSuccess
+ : storeDetailQuery.isSuccess
const detailLoading =
- props.kind === 'post'
- ? postDetailQuery.isLoading
- : nonPostDetailQuery.isLoading
+ props.kind === 'page'
+ ? nonPostDetailQuery.isLoading
+ : storeDetailQuery.isLoading
const refDraftQuery = useQuery({
enabled: isEditing,
queryFn: () => getDraftByRef(draftRefType, id),
@@ -473,7 +496,7 @@ function WritePage(props: { kind: WriteKind }) {
const categories = categoriesList.items
const tags = tagsQuery.data ?? []
- const topics = topicsQuery.data?.data ?? emptyTopics
+ const topics = topicsList.items
const relatedPosts = relatedPostsList.items
const firstCategoryId = categories[0]?.id ?? ''
const activeCategory =
@@ -569,11 +592,9 @@ function WritePage(props: { kind: WriteKind }) {
if (routeDraftId) return
- if (props.kind === 'post') {
- const seedKey = `post:${id}`
- if (formSeededKeyRef.current === seedKey) return
- formSeededKeyRef.current = seedKey
- }
+ const seedKey = `${props.kind}:${id}`
+ if (formSeededKeyRef.current === seedKey) return
+ formSeededKeyRef.current = seedKey
setState(fromModel(props.kind, detailModel))
}, [
@@ -3255,6 +3276,38 @@ function buildPostWriteData(
}
}
+function buildNoteWriteData(
+ state: WriteFormState,
+ draftId?: string,
+): CreateNoteData {
+ const projected = projectWriteState(state)
+ return {
+ bookmark: projected.bookmark,
+ content:
+ projected.contentFormat === 'lexical' ? projected.content : undefined,
+ contentFormat: projected.contentFormat,
+ coordinates: parseCoordinates(projected),
+ draftId,
+ images:
+ projected.contentFormat === 'lexical'
+ ? undefined
+ : buildWriteImages(projected),
+ isPublished: projected.isPublished,
+ location: projected.location || null,
+ meta: projected.meta,
+ mood: projected.mood || undefined,
+ password: projected.passwordProtected
+ ? projected.password.trim() || undefined
+ : null,
+ publicAt: normalizeFutureDatetimeIso(projected.publicAt),
+ slug: projected.slug || undefined,
+ text: projected.text,
+ title: resolveWriteTitle('note', projected),
+ topicId: projected.topicId || null,
+ weather: projected.weather || undefined,
+ }
+}
+
function saveWrite(
kind: WriteKind,
id: string,
@@ -3265,35 +3318,12 @@ function saveWrite(
return savePost(id, buildPostWriteData(state, draftId))
}
- state = projectWriteState(state)
-
if (kind === 'note') {
- const data = {
- bookmark: state.bookmark,
- content: state.contentFormat === 'lexical' ? state.content : undefined,
- contentFormat: state.contentFormat,
- coordinates: parseCoordinates(state),
- draftId,
- images:
- state.contentFormat === 'lexical' ? undefined : buildWriteImages(state),
- isPublished: state.isPublished,
- location: state.location || null,
- meta: state.meta,
- mood: state.mood || undefined,
- password: state.passwordProtected
- ? state.password.trim() || undefined
- : null,
- publicAt: normalizeFutureDatetimeIso(state.publicAt),
- slug: state.slug || undefined,
- text: state.text,
- title: resolveWriteTitle(kind, state),
- topicId: state.topicId || null,
- weather: state.weather || undefined,
- }
-
- return id ? updateNote(id, data) : createNote(data)
+ return saveNote(id, buildNoteWriteData(state, draftId))
}
+ state = projectWriteState(state)
+
const data = {
content: state.contentFormat === 'lexical' ? state.content : undefined,
contentFormat: state.contentFormat,
From 23136276fafc33a796b46e50c7ec5be307c2a732 Mon Sep 17 00:00:00 2001
From: Innei
Date: Sat, 11 Jul 2026 02:21:52 +0800
Subject: [PATCH 12/15] feat(admin): migrate page domain to resource
collections
---
.../src/data/resources/page.mutations.ts | 57 ++++++++++++
apps/admin/src/data/resources/page.ts | 9 ++
.../components/PagesRouteViewContent.tsx | 50 ++++++-----
.../components/WriteRouteViewsContent.tsx | 86 +++++++++++--------
4 files changed, 143 insertions(+), 59 deletions(-)
create mode 100644 apps/admin/src/data/resources/page.mutations.ts
create mode 100644 apps/admin/src/data/resources/page.ts
diff --git a/apps/admin/src/data/resources/page.mutations.ts b/apps/admin/src/data/resources/page.mutations.ts
new file mode 100644
index 00000000000..bfc00631890
--- /dev/null
+++ b/apps/admin/src/data/resources/page.mutations.ts
@@ -0,0 +1,57 @@
+import type { CreatePageData } from '~/api/pages'
+import { createPage, reorderPages, updatePage } from '~/api/pages'
+import { createTransaction } from '~/data/resource/transaction'
+import type { PageModel } from '~/models/page'
+
+import { pages } from './page'
+
+export function removePage(id: string): Promise {
+ return pages.delete(id)
+}
+
+function toOptimisticPagePatch(data: CreatePageData): Partial {
+ const patch: Partial = {
+ contentFormat: data.contentFormat,
+ meta: data.meta,
+ slug: data.slug,
+ subtitle: data.subtitle,
+ text: data.text,
+ title: data.title,
+ }
+ if (data.content !== undefined) patch.content = data.content
+ if (data.images !== undefined) patch.images = data.images
+ if (data.order !== undefined) patch.order = data.order
+ return patch
+}
+
+export async function savePage(
+ id: string,
+ data: CreatePageData,
+): Promise {
+ if (!id) {
+ const result = await createPage(data)
+ pages.upsert(result)
+ return result
+ }
+
+ const patch = toOptimisticPagePatch(data)
+ const tx = createTransaction()
+ tx.update(pages, id, (draft) => {
+ Object.assign(draft, patch)
+ })
+ const result = await tx.commit(() => updatePage(id, data))
+ pages.hydrate([result])
+ return result
+}
+
+export function reorderPagesOptimistic(
+ seq: Array<{ id: string; order: number }>,
+): Promise {
+ const tx = createTransaction()
+ seq.forEach(({ id, order }) => {
+ tx.update(pages, id, (draft) => {
+ draft.order = order
+ })
+ })
+ return tx.commit(() => reorderPages(seq))
+}
diff --git a/apps/admin/src/data/resources/page.ts b/apps/admin/src/data/resources/page.ts
new file mode 100644
index 00000000000..676a6f9d352
--- /dev/null
+++ b/apps/admin/src/data/resources/page.ts
@@ -0,0 +1,9 @@
+import { deletePage } from '~/api/pages'
+import { defineCollection } from '~/data/resource/collection'
+import type { PageModel } from '~/models/page'
+
+export const pages = defineCollection({
+ name: 'page',
+ getKey: (page) => page.id,
+ onDelete: ({ id }) => deletePage(id),
+})
diff --git a/apps/admin/src/features/pages/components/PagesRouteViewContent.tsx b/apps/admin/src/features/pages/components/PagesRouteViewContent.tsx
index 74729d51133..aaa01507c6d 100644
--- a/apps/admin/src/features/pages/components/PagesRouteViewContent.tsx
+++ b/apps/admin/src/features/pages/components/PagesRouteViewContent.tsx
@@ -1,10 +1,16 @@
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, Plus, RefreshCw } from 'lucide-react'
-import { useEffect, useState } from 'react'
+import { useMemo, useState } from 'react'
import { toast } from 'sonner'
-import { deletePage, getPages, reorderPages } from '~/api/pages'
+import { getPages } from '~/api/pages'
import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout'
+import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks'
+import { pages } from '~/data/resources/page'
+import {
+ removePage,
+ reorderPagesOptimistic,
+} from '~/data/resources/page.mutations'
import { useI18n } from '~/i18n'
import type { PageModel } from '~/models/page'
import { adminQueryKeys } from '~/query/keys'
@@ -22,18 +28,29 @@ import { PagesEmpty } from './PagesEmpty'
import { PagesError } from './PagesError'
import { PagesSkeleton } from './PagesSkeleton'
+const pagesListQueryKey = adminQueryKeys.pages.list({ page: 1, size: 100 })
+
export function PagesRouteViewContent() {
const { t } = useI18n()
const queryClient = useQueryClient()
- const [orderedPages, setOrderedPages] = useState([])
const [draggingId, setDraggingId] = useState('')
- const pagesQuery = useQuery({
+
+ const pagesQuery = useCollectionListQuery(pages, {
queryFn: () => getPages({ page: 1, size: 100 }),
- queryKey: adminQueryKeys.pages.list({ page: 1, size: 100 }),
+ queryKey: pagesListQueryKey,
+ toPage: (result) => ({
+ items: result.data,
+ pagination: result.pagination,
+ }),
})
+ const pagesList = useEntityList(pages, pagesListQueryKey)
+ const orderedPages = useMemo(
+ () => [...pagesList.items].sort((a, b) => (b.order ?? 0) - (a.order ?? 0)),
+ [pagesList.items],
+ )
const deleteMutation = useMutation({
- mutationFn: deletePage,
+ mutationFn: removePage,
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('pages.toast.deleteFailed'))),
onSuccess: async () => {
@@ -42,18 +59,10 @@ export function PagesRouteViewContent() {
},
})
- const pages = pagesQuery.data?.data ?? []
-
- useEffect(() => {
- setOrderedPages(pages)
- }, [pages])
-
const reorderMutation = useMutation({
- mutationFn: reorderPages,
- onError: (error: unknown) => {
- setOrderedPages(pages)
- toast.error(getErrorMessage(error, t('pages.toast.reorderFailed')))
- },
+ mutationFn: reorderPagesOptimistic,
+ onError: (error: unknown) =>
+ toast.error(getErrorMessage(error, t('pages.toast.reorderFailed'))),
onSuccess: async () => {
toast.success(t('pages.toast.reorderSaved'))
await queryClient.invalidateQueries({ queryKey: pagesQueryKey })
@@ -61,7 +70,6 @@ export function PagesRouteViewContent() {
})
const commitReorder = (nextPages: PageModel[]) => {
- setOrderedPages(nextPages)
const seq = [...nextPages]
.reverse()
.map((page, index) => ({ id: page.id, order: index + 1 }))
@@ -97,7 +105,7 @@ export function PagesRouteViewContent() {
{t('pages.header.count', {
- count: pagesQuery.data?.pagination.total ?? 0,
+ count: pagesList.pagination?.total ?? 0,
})}
@@ -123,7 +131,7 @@ export function PagesRouteViewContent() {
- {pagesQuery.isLoading ? (
+ {pagesQuery.isLoading && orderedPages.length === 0 ? (
) : pagesQuery.isError ? (
void pagesQuery.refetch()} />
diff --git a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
index d381a005a4a..e992f4e3d98 100644
--- a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
+++ b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
@@ -51,7 +51,8 @@ import {
import { uploadFile, uploadFileWithProgress } from '~/api/files'
import type { CreateNoteData } from '~/api/notes'
import { getNoteById } from '~/api/notes'
-import { createPage, getPageById, updatePage } from '~/api/pages'
+import type { CreatePageData } from '~/api/pages'
+import { getPageById } from '~/api/pages'
import type { CreatePostData } from '~/api/posts'
import { getPostById, getPosts } from '~/api/posts'
import { callBuiltInFunction } from '~/api/system'
@@ -71,6 +72,8 @@ import type { CategoryEntity } from '~/data/resources/category'
import { categories as categoriesCollection } from '~/data/resources/category'
import { notes as notesCollection } from '~/data/resources/note'
import { saveNote } from '~/data/resources/note.mutations'
+import { pages as pagesCollection } from '~/data/resources/page'
+import { savePage } from '~/data/resources/page.mutations'
import { posts as postsCollection } from '~/data/resources/post'
import { savePost } from '~/data/resources/post.mutations'
import { topics as topicsCollection } from '~/data/resources/topic'
@@ -460,24 +463,30 @@ function WritePage(props: { kind: WriteKind }) {
notesCollection,
props.kind === 'note' && isEditing ? id : undefined,
)
- const nonPostDetailQuery = useQuery({
+ const pageDetailQuery = useCollectionDetailQuery(pagesCollection, {
enabled: isEditing && props.kind === 'page',
- queryFn: () => getWriteDetail(props.kind, id),
+ queryFn: () => getPageById(id),
queryKey: adminQueryKeys.write.detail({ id, kind: props.kind }),
})
- const storeEntity = props.kind === 'note' ? noteEntity : postEntity
+ const pageEntity = useEntity(
+ pagesCollection,
+ props.kind === 'page' && isEditing ? id : undefined,
+ )
+ const storeEntity =
+ props.kind === 'note'
+ ? noteEntity
+ : props.kind === 'page'
+ ? pageEntity
+ : postEntity
const storeDetailQuery =
- props.kind === 'note' ? noteDetailQuery : postDetailQuery
- const detailModel: WriteModel | undefined =
- props.kind === 'page' ? nonPostDetailQuery.data : storeEntity
- const isDetailLoaded =
- props.kind === 'page'
- ? nonPostDetailQuery.isSuccess
- : storeDetailQuery.isSuccess
- const detailLoading =
- props.kind === 'page'
- ? nonPostDetailQuery.isLoading
- : storeDetailQuery.isLoading
+ props.kind === 'note'
+ ? noteDetailQuery
+ : props.kind === 'page'
+ ? pageDetailQuery
+ : postDetailQuery
+ const detailModel: WriteModel | undefined = storeEntity
+ const isDetailLoaded = storeDetailQuery.isSuccess
+ const detailLoading = storeDetailQuery.isLoading
const refDraftQuery = useQuery({
enabled: isEditing,
queryFn: () => getDraftByRef(draftRefType, id),
@@ -3005,12 +3014,6 @@ const draftRefTypeByKind: Record = {
post: DraftRefType.Post,
}
-function getWriteDetail(kind: WriteKind, id: string): Promise {
- if (kind === 'post') return getPostById(id)
- if (kind === 'note') return getNoteById(id, { single: true })
- return getPageById(id)
-}
-
function getPublishedContent(model: WriteModel): PublishedWriteContent {
return {
content: model.content ?? undefined,
@@ -3308,6 +3311,29 @@ function buildNoteWriteData(
}
}
+function buildPageWriteData(
+ state: WriteFormState,
+ draftId?: string,
+): CreatePageData {
+ const projected = projectWriteState(state)
+ return {
+ content:
+ projected.contentFormat === 'lexical' ? projected.content : undefined,
+ contentFormat: projected.contentFormat,
+ draftId,
+ images:
+ projected.contentFormat === 'lexical'
+ ? undefined
+ : buildWriteImages(projected),
+ meta: projected.meta,
+ order: projected.order ? Number(projected.order) : undefined,
+ slug: projected.slug,
+ subtitle: projected.subtitle,
+ text: projected.text,
+ title: projected.title,
+ }
+}
+
function saveWrite(
kind: WriteKind,
id: string,
@@ -3322,23 +3348,7 @@ function saveWrite(
return saveNote(id, buildNoteWriteData(state, draftId))
}
- state = projectWriteState(state)
-
- const data = {
- content: state.contentFormat === 'lexical' ? state.content : undefined,
- contentFormat: state.contentFormat,
- draftId,
- images:
- state.contentFormat === 'lexical' ? undefined : buildWriteImages(state),
- meta: state.meta,
- order: state.order ? Number(state.order) : undefined,
- slug: state.slug,
- subtitle: state.subtitle,
- text: state.text,
- title: state.title,
- }
-
- return id ? updatePage(id, data) : createPage(data)
+ return savePage(id, buildPageWriteData(state, draftId))
}
function toDraftData(
From de2569ead3f41fc561237ae2c05d9837676d2ac0 Mon Sep 17 00:00:00 2001
From: Innei
Date: Sat, 11 Jul 2026 02:35:42 +0800
Subject: [PATCH 13/15] feat(admin): migrate say and recently domains to
resource collections
---
apps/admin/src/data/resource/hooks.test.tsx | 126 +++++++++++++++++-
apps/admin/src/data/resource/hooks.ts | 70 +++++++++-
apps/admin/src/data/resource/list-index.ts | 24 +++-
.../src/data/resources/recently.mutations.ts | 45 +++++++
apps/admin/src/data/resources/recently.ts | 9 ++
.../admin/src/data/resources/say.mutations.ts | 32 +++++
apps/admin/src/data/resources/say.ts | 9 ++
.../components/RecentlyEditorModal.tsx | 10 +-
.../components/RecentlyRouteViewContent.tsx | 70 ++++------
.../says/components/SayEditorModal.tsx | 9 +-
.../features/says/hooks/use-say-mutations.ts | 4 +-
.../src/features/says/hooks/use-says-list.ts | 23 ++--
12 files changed, 365 insertions(+), 66 deletions(-)
create mode 100644 apps/admin/src/data/resources/recently.mutations.ts
create mode 100644 apps/admin/src/data/resources/recently.ts
create mode 100644 apps/admin/src/data/resources/say.mutations.ts
create mode 100644 apps/admin/src/data/resources/say.ts
diff --git a/apps/admin/src/data/resource/hooks.test.tsx b/apps/admin/src/data/resource/hooks.test.tsx
index fd4df499ecc..5b76a86a151 100644
--- a/apps/admin/src/data/resource/hooks.test.tsx
+++ b/apps/admin/src/data/resource/hooks.test.tsx
@@ -1,3 +1,4 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, createElement, useEffect } from 'react'
import type { Root } from 'react-dom/client'
import { createRoot } from 'react-dom/client'
@@ -6,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Collection } from './collection'
import { defineCollection } from './collection'
import type { EntityListResult } from './hooks'
-import { useEntity, useEntityList } from './hooks'
+import { useCollectionInfiniteQuery, useEntity, useEntityList } from './hooks'
import { serializeListKey } from './key'
import { hydrateList, readList } from './list-index'
@@ -146,6 +147,68 @@ describe('hydrateList / readList', () => {
})
})
+describe('hydrateList append mode', () => {
+ it('dedupe-concats incoming ids onto the existing index, preserving order', () => {
+ const collection = makeCollection()
+
+ hydrateList(collection, 'feed', {
+ items: [
+ { id: '1', title: 'a' },
+ { id: '2', title: 'b' },
+ ],
+ })
+ hydrateList(
+ collection,
+ 'feed',
+ {
+ items: [
+ { id: '2', title: 'b-updated' },
+ { id: '3', title: 'c' },
+ ],
+ },
+ { mode: 'append' },
+ )
+
+ const index = readList(collection.store.getState(), collection, 'feed')
+ expect(index?.ids).toEqual(['1', '2', '3'])
+ expect(collection.get('2')).toEqual({ id: '2', title: 'b-updated' })
+ })
+
+ it('behaves like a replace when there is no existing index yet', () => {
+ const collection = makeCollection()
+
+ hydrateList(
+ collection,
+ 'feed',
+ { items: [{ id: '1', title: 'a' }] },
+ { mode: 'append' },
+ )
+
+ const index = readList(collection.store.getState(), collection, 'feed')
+ expect(index?.ids).toEqual(['1'])
+ })
+
+ it('leaves replace mode (default and explicit) unchanged', () => {
+ const collection = makeCollection()
+
+ hydrateList(collection, 'feed', {
+ items: [
+ { id: '1', title: 'a' },
+ { id: '2', title: 'b' },
+ ],
+ })
+ hydrateList(
+ collection,
+ 'feed',
+ { items: [{ id: '3', title: 'c' }] },
+ { mode: 'replace' },
+ )
+
+ const index = readList(collection.store.getState(), collection, 'feed')
+ expect(index?.ids).toEqual(['3'])
+ })
+})
+
describe('hydrateList LRU eviction', () => {
it('evicts the least-recently-used index at the 51st key; entities are untouched', () => {
vi.useFakeTimers()
@@ -306,3 +369,64 @@ describe('useEntity reference stability', () => {
expect(harness.value).toBeUndefined()
})
})
+
+describe('useCollectionInfiniteQuery', () => {
+ it('replaces the list index on the first page and appends on later pages', async () => {
+ const collection = makeCollection()
+ const queryClient = new QueryClient()
+ const queryKey = ['feed'] as const
+ const firstPage: TestEntity[] = [{ id: '1' }, { id: '2' }]
+ const secondPage: TestEntity[] = [{ id: '2' }, { id: '3' }]
+
+ const box: { fetchNextPage: (() => Promise) | undefined } = {
+ fetchNextPage: undefined,
+ }
+
+ function Probe() {
+ const query = useCollectionInfiniteQuery(collection, {
+ queryKey,
+ initialPageParam: null as null | string,
+ queryFn: (pageParam) =>
+ Promise.resolve(pageParam === null ? firstPage : secondPage),
+ getNextPageParam: (_lastResult, pageParam) =>
+ pageParam === null ? '2' : undefined,
+ toItems: (result) => result,
+ })
+ useEffect(() => {
+ box.fetchNextPage = () => query.fetchNextPage()
+ })
+ return null
+ }
+
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root: Root = createRoot(container)
+
+ await act(async () => {
+ root.render(
+ createElement(
+ QueryClientProvider,
+ { client: queryClient },
+ createElement(Probe),
+ ),
+ )
+ await Promise.resolve()
+ })
+
+ const listKey = serializeListKey(queryKey)
+ expect(
+ readList(collection.store.getState(), collection, listKey)?.ids,
+ ).toEqual(['1', '2'])
+
+ await act(async () => {
+ await box.fetchNextPage?.()
+ })
+
+ expect(
+ readList(collection.store.getState(), collection, listKey)?.ids,
+ ).toEqual(['1', '2', '3'])
+
+ act(() => root.unmount())
+ container.remove()
+ })
+})
diff --git a/apps/admin/src/data/resource/hooks.ts b/apps/admin/src/data/resource/hooks.ts
index c01d046e6a1..b822312242f 100644
--- a/apps/admin/src/data/resource/hooks.ts
+++ b/apps/admin/src/data/resource/hooks.ts
@@ -1,5 +1,10 @@
-import type { QueryKey, UseQueryResult } from '@tanstack/react-query'
-import { useQuery } from '@tanstack/react-query'
+import type {
+ InfiniteData,
+ QueryKey,
+ UseInfiniteQueryResult,
+ UseQueryResult,
+} from '@tanstack/react-query'
+import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import {
useCallback,
useEffect,
@@ -199,6 +204,67 @@ export function useCollectionListQuery(
})
}
+export interface CollectionInfiniteReceipt {
+ hydratedAt: number
+ nextPageParam: TPageParam | undefined
+}
+
+export function useCollectionInfiniteQuery<
+ T extends object,
+ TResult,
+ TPageParam,
+>(
+ collection: Collection,
+ options: {
+ queryKey: QueryKey
+ queryFn: (pageParam: TPageParam) => Promise
+ getNextPageParam: (
+ lastResult: TResult,
+ pageParam: TPageParam,
+ ) => TPageParam | undefined
+ initialPageParam: TPageParam
+ toItems: (result: TResult) => T[]
+ enabled?: boolean
+ },
+): UseInfiniteQueryResult<
+ InfiniteData, TPageParam>
+> {
+ const listKey = useMemo(
+ () => serializeListKey(options.queryKey),
+ [options.queryKey],
+ )
+
+ return useInfiniteQuery<
+ CollectionInfiniteReceipt,
+ Error,
+ InfiniteData, TPageParam>,
+ QueryKey,
+ TPageParam
+ >({
+ queryKey: options.queryKey,
+ enabled: options.enabled,
+ initialPageParam: options.initialPageParam,
+ queryFn: async (context: {
+ pageParam?: unknown
+ }): Promise> => {
+ const pageParam = context.pageParam as TPageParam
+ const result = await options.queryFn(pageParam)
+ const items = options.toItems(result)
+ hydrateList(
+ collection,
+ listKey,
+ { items },
+ { mode: pageParam ? 'append' : 'replace' },
+ )
+ return {
+ hydratedAt: Date.now(),
+ nextPageParam: options.getNextPageParam(result, pageParam),
+ }
+ },
+ getNextPageParam: (lastPage) => lastPage.nextPageParam,
+ })
+}
+
export function useCollectionDetailQuery(
collection: Collection,
options: {
diff --git a/apps/admin/src/data/resource/list-index.ts b/apps/admin/src/data/resource/list-index.ts
index c8d8b1dcc7b..077e0e335c2 100644
--- a/apps/admin/src/data/resource/list-index.ts
+++ b/apps/admin/src/data/resource/list-index.ts
@@ -16,17 +16,39 @@ export interface ListPage {
const MAX_LIST_KEYS = 50
+export interface HydrateListOptions {
+ mode?: 'append' | 'replace'
+}
+
+function dedupeConcat(existingIds: string[], incomingIds: string[]): string[] {
+ const seen = new Set(existingIds)
+ const appended = incomingIds.filter((id) => {
+ if (seen.has(id)) return false
+ seen.add(id)
+ return true
+ })
+ return [...existingIds, ...appended]
+}
+
export function hydrateList(
collection: Collection,
listKey: string,
page: ListPage,
+ options?: HydrateListOptions,
): void {
collection.hydrate(page.items)
- const ids = page.items.map((item) => collection.getKey(item))
+ const incomingIds = page.items.map((item) => collection.getKey(item))
+ const mode = options?.mode ?? 'replace'
const now = Date.now()
collection.store.setState((state) => {
+ const existing = state.listIndexes[listKey]
+ const ids =
+ mode === 'append' && existing
+ ? dedupeConcat(existing.ids, incomingIds)
+ : incomingIds
+
const listIndexes = {
...state.listIndexes,
[listKey]: {
diff --git a/apps/admin/src/data/resources/recently.mutations.ts b/apps/admin/src/data/resources/recently.mutations.ts
new file mode 100644
index 00000000000..d25b5f2b702
--- /dev/null
+++ b/apps/admin/src/data/resources/recently.mutations.ts
@@ -0,0 +1,45 @@
+import type { RecentlyInput } from '~/api/recently'
+import { createRecently, updateRecently } from '~/api/recently'
+import { createTransaction } from '~/data/resource/transaction'
+import type { EnrichmentResult } from '~/models/enrichment'
+import type { RecentlyModel } from '~/models/recently'
+
+import { recentlies } from './recently'
+
+export async function saveRecently(
+ mode: { kind: 'create' } | { id: string; kind: 'edit' },
+ input: RecentlyInput,
+): Promise {
+ if (mode.kind === 'edit') {
+ const { id } = mode
+ const tx = createTransaction()
+ tx.update(recentlies, id, (draft) => {
+ draft.content = input.content
+ })
+ const result = await tx.commit(() => updateRecently(id, input))
+ recentlies.hydrate([result])
+ return result
+ }
+
+ const result = await createRecently(input)
+ recentlies.upsert(result)
+ return result
+}
+
+export function removeRecently(id: string): Promise {
+ return recentlies.delete(id)
+}
+
+export function applyRecentlyEnrichment(
+ id: string,
+ url: string,
+ enrichment: EnrichmentResult,
+): void {
+ const current = recentlies.get(id)
+ if (!current) return
+
+ recentlies.upsert({
+ ...current,
+ enrichments: { ...current.enrichments, [url]: enrichment },
+ })
+}
diff --git a/apps/admin/src/data/resources/recently.ts b/apps/admin/src/data/resources/recently.ts
new file mode 100644
index 00000000000..678e7cbe1b4
--- /dev/null
+++ b/apps/admin/src/data/resources/recently.ts
@@ -0,0 +1,9 @@
+import { deleteRecently } from '~/api/recently'
+import { defineCollection } from '~/data/resource/collection'
+import type { RecentlyModel } from '~/models/recently'
+
+export const recentlies = defineCollection({
+ name: 'recently',
+ getKey: (recently) => recently.id,
+ onDelete: ({ id }) => deleteRecently(id),
+})
diff --git a/apps/admin/src/data/resources/say.mutations.ts b/apps/admin/src/data/resources/say.mutations.ts
new file mode 100644
index 00000000000..194afc772ca
--- /dev/null
+++ b/apps/admin/src/data/resources/say.mutations.ts
@@ -0,0 +1,32 @@
+import type { SayInput } from '~/api/says'
+import { createSay, updateSay } from '~/api/says'
+import { createTransaction } from '~/data/resource/transaction'
+import type { SayModel } from '~/models/say'
+
+import { says } from './say'
+
+export async function saveSay(
+ mode: { kind: 'create' } | { id: string; kind: 'edit' },
+ form: SayInput,
+): Promise {
+ if (mode.kind === 'edit') {
+ const { id } = mode
+ const tx = createTransaction()
+ tx.update(says, id, (draft) => {
+ draft.text = form.text
+ draft.author = form.author ?? null
+ draft.source = form.source ?? null
+ })
+ const result = await tx.commit(() => updateSay(id, form))
+ says.hydrate([result])
+ return result
+ }
+
+ const result = await createSay(form)
+ says.upsert(result)
+ return result
+}
+
+export function removeSay(id: string): Promise {
+ return says.delete(id)
+}
diff --git a/apps/admin/src/data/resources/say.ts b/apps/admin/src/data/resources/say.ts
new file mode 100644
index 00000000000..bdd48a78fbe
--- /dev/null
+++ b/apps/admin/src/data/resources/say.ts
@@ -0,0 +1,9 @@
+import { deleteSay } from '~/api/says'
+import { defineCollection } from '~/data/resource/collection'
+import type { SayModel } from '~/models/say'
+
+export const says = defineCollection({
+ name: 'say',
+ getKey: (say) => say.id,
+ onDelete: ({ id }) => deleteSay(id),
+})
diff --git a/apps/admin/src/features/recently/components/RecentlyEditorModal.tsx b/apps/admin/src/features/recently/components/RecentlyEditorModal.tsx
index 96268cc69c7..5b54858f0ef 100644
--- a/apps/admin/src/features/recently/components/RecentlyEditorModal.tsx
+++ b/apps/admin/src/features/recently/components/RecentlyEditorModal.tsx
@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { resolveEnrichment } from '~/api/enrichment'
-import { createRecently, updateRecently } from '~/api/recently'
+import { saveRecently } from '~/data/resources/recently.mutations'
import { useI18n } from '~/i18n'
import type { RecentlyModel } from '~/models/recently'
import { ModalFooter, ModalHeader } from '~/ui/feedback/modal'
@@ -82,8 +82,12 @@ function RecentlyEditorModal(props: RecentlyEditorModalProps) {
const mutation = useMutation({
mutationFn: async () => {
const data = { content: content.trim() }
- if (props.item?.id) return updateRecently(props.item.id, data)
- return createRecently(data)
+ return saveRecently(
+ props.item?.id
+ ? { id: props.item.id, kind: 'edit' }
+ : { kind: 'create' },
+ data,
+ )
},
onSuccess: () => {
toast.success(t('recently.editor.saveSuccess'))
diff --git a/apps/admin/src/features/recently/components/RecentlyRouteViewContent.tsx b/apps/admin/src/features/recently/components/RecentlyRouteViewContent.tsx
index fa5f0f78837..54c7671392e 100644
--- a/apps/admin/src/features/recently/components/RecentlyRouteViewContent.tsx
+++ b/apps/admin/src/features/recently/components/RecentlyRouteViewContent.tsx
@@ -1,16 +1,20 @@
-import type { InfiniteData } from '@tanstack/react-query'
-import {
- useInfiniteQuery,
- useMutation,
- useQueryClient,
-} from '@tanstack/react-query'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, StickyNote } from 'lucide-react'
-import { useEffect, useMemo, useRef } from 'react'
+import { useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router'
import { toast } from 'sonner'
-import { deleteRecently, getRecentlyList } from '~/api/recently'
+import { getRecentlyList } from '~/api/recently'
import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout'
+import {
+ useCollectionInfiniteQuery,
+ useEntityList,
+} from '~/data/resource/hooks'
+import { recentlies } from '~/data/resources/recently'
+import {
+ applyRecentlyEnrichment,
+ removeRecently,
+} from '~/data/resources/recently.mutations'
import { useI18n } from '~/i18n'
import type { EnrichmentResult } from '~/models/enrichment'
import type { RecentlyModel } from '~/models/recently'
@@ -40,28 +44,28 @@ export function RecentlyRouteViewContent() {
const scrollContainerRef = useRef(null)
const loadMoreRef = useRef(null)
- const recentlyQuery = useInfiniteQuery<
+ const recentlyQuery = useCollectionInfiniteQuery<
+ RecentlyModel,
RecentlyModel[],
- Error,
- InfiniteData,
- typeof recentlyListQueryKey,
null | string
- >({
- getNextPageParam: (lastPage) =>
- lastPage.length >= RECENTLY_PAGE_SIZE
- ? (lastPage.at(-1)?.id ?? null)
- : null,
+ >(recentlies, {
+ getNextPageParam: (lastResult) =>
+ lastResult.length >= RECENTLY_PAGE_SIZE
+ ? (lastResult.at(-1)?.id ?? undefined)
+ : undefined,
initialPageParam: null as null | string,
- queryFn: ({ pageParam }) =>
+ queryFn: (pageParam) =>
getRecentlyList({
before: pageParam ?? undefined,
size: RECENTLY_PAGE_SIZE,
}),
queryKey: recentlyListQueryKey,
+ toItems: (result) => result,
})
+ const recentlyList = useEntityList(recentlies, recentlyListQueryKey)
const deleteMutation = useMutation({
- mutationFn: deleteRecently,
+ mutationFn: removeRecently,
onSuccess: async () => {
toast.success(t('recently.deleteSuccess'))
await queryClient.invalidateQueries({ queryKey: recentlyQueryKey })
@@ -90,34 +94,10 @@ export function RecentlyRouteViewContent() {
url: string,
enrichment: EnrichmentResult,
) => {
- queryClient.setQueryData>(
- recentlyListQueryKey,
- (current) =>
- current
- ? {
- ...current,
- pages: current.pages.map((page) =>
- page.map((item) =>
- item.id === itemId
- ? {
- ...item,
- enrichments: {
- ...item.enrichments,
- [url]: enrichment,
- },
- }
- : item,
- ),
- ),
- }
- : current,
- )
+ applyRecentlyEnrichment(itemId, url, enrichment)
}
- const items = useMemo(
- () => recentlyQuery.data?.pages.flat() ?? [],
- [recentlyQuery.data],
- )
+ const items = recentlyList.items
const hasNextPage = Boolean(recentlyQuery.hasNextPage)
useEffect(() => {
diff --git a/apps/admin/src/features/says/components/SayEditorModal.tsx b/apps/admin/src/features/says/components/SayEditorModal.tsx
index df225d27488..fbdb6b11855 100644
--- a/apps/admin/src/features/says/components/SayEditorModal.tsx
+++ b/apps/admin/src/features/says/components/SayEditorModal.tsx
@@ -3,7 +3,7 @@ import type { FormEvent } from 'react'
import { useState } from 'react'
import { toast } from 'sonner'
-import { createSay, updateSay } from '~/api/says'
+import { saveSay } from '~/data/resources/say.mutations'
import { useI18n } from '~/i18n'
import type { SayModel } from '~/models/say'
import { ModalFooter, ModalHeader } from '~/ui/feedback/modal'
@@ -32,9 +32,10 @@ function SayEditorModal(props: SayEditorModalProps) {
text: text.trim(),
}
- if (props.say?.id) return updateSay(props.say.id, data)
-
- return createSay(data)
+ return saveSay(
+ props.say?.id ? { id: props.say.id, kind: 'edit' } : { kind: 'create' },
+ data,
+ )
},
onSuccess: () => {
toast.success(
diff --git a/apps/admin/src/features/says/hooks/use-say-mutations.ts b/apps/admin/src/features/says/hooks/use-say-mutations.ts
index 8e6f3560db9..52b115ecde8 100644
--- a/apps/admin/src/features/says/hooks/use-say-mutations.ts
+++ b/apps/admin/src/features/says/hooks/use-say-mutations.ts
@@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
-import { deleteSay } from '~/api/says'
+import { removeSay } from '~/data/resources/say.mutations'
import { useI18n } from '~/i18n'
import { saysQueryKey } from '../constants'
@@ -15,7 +15,7 @@ export function useSayMutations() {
}
const deleteMutation = useMutation({
- mutationFn: deleteSay,
+ mutationFn: removeSay,
onSuccess: async () => {
toast.success(t('says.deleteSuccess'))
await invalidateSays()
diff --git a/apps/admin/src/features/says/hooks/use-says-list.ts b/apps/admin/src/features/says/hooks/use-says-list.ts
index efc2da28b6a..499b3d0fa08 100644
--- a/apps/admin/src/features/says/hooks/use-says-list.ts
+++ b/apps/admin/src/features/says/hooks/use-says-list.ts
@@ -1,7 +1,8 @@
-import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { getSays } from '~/api/says'
+import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks'
+import { says } from '~/data/resources/say'
import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state'
import { adminQueryKeys } from '~/query/keys'
@@ -29,19 +30,25 @@ export function useSaysList() {
const [state, setState] = useUrlListState(urlStateOptions)
- const saysQuery = useQuery({
- placeholderData: (previous) => previous,
+ const listKey = adminQueryKeys.says.list({
+ page: state.page,
+ size: saysPageSize,
+ })
+
+ const saysQuery = useCollectionListQuery(says, {
queryFn: () => getSays({ page: state.page, size: saysPageSize }),
- queryKey: adminQueryKeys.says.list({
- page: state.page,
- size: saysPageSize,
+ queryKey: listKey,
+ toPage: (result) => ({
+ items: result.data,
+ pagination: result.pagination,
}),
})
+ const saysList = useEntityList(says, listKey, { keepPrevious: true })
return {
page: state.page,
- pagination: saysQuery.data?.pagination,
- says: saysQuery.data?.data ?? [],
+ pagination: saysList.pagination,
+ says: saysList.items,
saysQuery,
setPage: (page: number) => setState({ page }),
}
From ebad6afe75d78882c3e33b36b4ddf36a8d7146a8 Mon Sep 17 00:00:00 2001
From: Innei
Date: Sat, 11 Jul 2026 02:46:46 +0800
Subject: [PATCH 14/15] feat(admin): migrate project and link domains to
resource collections
---
.../src/data/resources/link.mutations.ts | 63 +++++++++++++++++++
apps/admin/src/data/resources/link.ts | 11 ++++
.../src/data/resources/project.mutations.ts | 37 +++++++++++
apps/admin/src/data/resources/project.ts | 11 ++++
.../friends/components/AuditReasonModal.tsx | 4 +-
.../friends/components/FriendEditorModal.tsx | 9 ++-
.../friends/hooks/use-friend-mutations.ts | 12 ++--
.../friends/hooks/use-friends-list.ts | 25 +++++---
.../components/ProjectDetailRoute.tsx | 32 ++++------
.../projects/components/ProjectFormPanel.tsx | 9 +--
.../projects/hooks/use-project-mutations.ts | 4 +-
.../projects/hooks/use-projects-list.ts | 26 +++++---
12 files changed, 188 insertions(+), 55 deletions(-)
create mode 100644 apps/admin/src/data/resources/link.mutations.ts
create mode 100644 apps/admin/src/data/resources/link.ts
create mode 100644 apps/admin/src/data/resources/project.mutations.ts
create mode 100644 apps/admin/src/data/resources/project.ts
diff --git a/apps/admin/src/data/resources/link.mutations.ts b/apps/admin/src/data/resources/link.mutations.ts
new file mode 100644
index 00000000000..b645cd89fc2
--- /dev/null
+++ b/apps/admin/src/data/resources/link.mutations.ts
@@ -0,0 +1,63 @@
+import type { LinkInput } from '~/api/links'
+import {
+ auditLinkWithReason,
+ auditPassLink,
+ createLink,
+ updateLink,
+} from '~/api/links'
+import { createTransaction } from '~/data/resource/transaction'
+import type { LinkModel } from '~/models/link'
+import { LinkState } from '~/models/link'
+
+import { links } from './link'
+
+export async function saveLink(
+ mode: { kind: 'create' } | { id: string; kind: 'edit' },
+ form: LinkInput,
+): Promise {
+ if (mode.kind === 'edit') {
+ const { id } = mode
+ const tx = createTransaction()
+ tx.update(links, id, (draft) => {
+ draft.name = form.name
+ draft.url = form.url
+ draft.description = form.description
+ if (form.avatar !== undefined) draft.avatar = form.avatar
+ if (form.type !== undefined) draft.type = form.type
+ if (form.state !== undefined) draft.state = form.state
+ })
+ const result = await tx.commit(() => updateLink(id, form))
+ links.hydrate([result])
+ return result
+ }
+
+ const result = await createLink(form)
+ links.upsert(result)
+ return result
+}
+
+export function removeLink(id: string): Promise {
+ return links.delete(id)
+}
+
+export async function auditPass(id: string): Promise {
+ const tx = createTransaction()
+ tx.update(links, id, (draft) => {
+ draft.state = LinkState.Pass
+ })
+ const result = await tx.commit(() => auditPassLink(id))
+ links.hydrate([result])
+ return result
+}
+
+export function auditWithReason(
+ id: string,
+ reason: string,
+ state: number,
+): Promise {
+ const tx = createTransaction()
+ tx.update(links, id, (draft) => {
+ draft.state = state
+ })
+ return tx.commit(() => auditLinkWithReason(id, { reason, state }))
+}
diff --git a/apps/admin/src/data/resources/link.ts b/apps/admin/src/data/resources/link.ts
new file mode 100644
index 00000000000..615a3eab1dd
--- /dev/null
+++ b/apps/admin/src/data/resources/link.ts
@@ -0,0 +1,11 @@
+import { deleteLink } from '~/api/links'
+import { defineCollection } from '~/data/resource/collection'
+import type { LinkModel } from '~/models/link'
+
+export const links = defineCollection({
+ name: 'link',
+ getKey: (link) => link.id,
+ onDelete: async ({ id }) => {
+ await deleteLink(id)
+ },
+})
diff --git a/apps/admin/src/data/resources/project.mutations.ts b/apps/admin/src/data/resources/project.mutations.ts
new file mode 100644
index 00000000000..6fcdcc12c93
--- /dev/null
+++ b/apps/admin/src/data/resources/project.mutations.ts
@@ -0,0 +1,37 @@
+import type { ProjectInput } from '~/api/projects'
+import { createProject, updateProject } from '~/api/projects'
+import { createTransaction } from '~/data/resource/transaction'
+import type { ProjectModel } from '~/models/project'
+
+import { projects } from './project'
+
+export async function saveProject(
+ mode: { kind: 'create' } | { id: string; kind: 'edit' },
+ form: ProjectInput,
+): Promise {
+ if (mode.kind === 'edit') {
+ const { id } = mode
+ const tx = createTransaction()
+ tx.update(projects, id, (draft) => {
+ draft.name = form.name
+ draft.description = form.description
+ draft.previewUrl = form.previewUrl ?? null
+ draft.docUrl = form.docUrl ?? null
+ draft.projectUrl = form.projectUrl ?? null
+ draft.images = form.images ?? null
+ draft.avatar = form.avatar ?? null
+ draft.text = form.text
+ })
+ const result = await tx.commit(() => updateProject(id, form))
+ projects.hydrate([result])
+ return result
+ }
+
+ const result = await createProject(form)
+ projects.upsert(result)
+ return result
+}
+
+export function removeProject(id: string): Promise {
+ return projects.delete(id)
+}
diff --git a/apps/admin/src/data/resources/project.ts b/apps/admin/src/data/resources/project.ts
new file mode 100644
index 00000000000..df2d253d107
--- /dev/null
+++ b/apps/admin/src/data/resources/project.ts
@@ -0,0 +1,11 @@
+import { deleteProject } from '~/api/projects'
+import { defineCollection } from '~/data/resource/collection'
+import type { ProjectModel } from '~/models/project'
+
+export const projects = defineCollection({
+ name: 'project',
+ getKey: (project) => project.id,
+ onDelete: async ({ id }) => {
+ await deleteProject(id)
+ },
+})
diff --git a/apps/admin/src/features/friends/components/AuditReasonModal.tsx b/apps/admin/src/features/friends/components/AuditReasonModal.tsx
index af4ea366ebf..d0d00dad073 100644
--- a/apps/admin/src/features/friends/components/AuditReasonModal.tsx
+++ b/apps/admin/src/features/friends/components/AuditReasonModal.tsx
@@ -2,7 +2,7 @@ import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { toast } from 'sonner'
-import { auditLinkWithReason } from '~/api/links'
+import { auditWithReason } from '~/data/resources/link.mutations'
import { useI18n } from '~/i18n'
import type { LinkModel } from '~/models/link'
import { LinkState, LinkStateNameKeys } from '~/models/link'
@@ -23,7 +23,7 @@ function AuditReasonModal(props: AuditReasonModalProps) {
const [reason, setReason] = useState('')
const mutation = useMutation({
- mutationFn: () => auditLinkWithReason(props.link.id, { reason, state }),
+ mutationFn: () => auditWithReason(props.link.id, reason, state),
onSuccess: () => {
toast.success(t('friends.toast.auditSent'))
modal.close(true)
diff --git a/apps/admin/src/features/friends/components/FriendEditorModal.tsx b/apps/admin/src/features/friends/components/FriendEditorModal.tsx
index fbe0ca6b28c..78a8c622023 100644
--- a/apps/admin/src/features/friends/components/FriendEditorModal.tsx
+++ b/apps/admin/src/features/friends/components/FriendEditorModal.tsx
@@ -3,7 +3,7 @@ import type { FormEvent } from 'react'
import { useState } from 'react'
import { toast } from 'sonner'
-import { createLink, updateLink } from '~/api/links'
+import { saveLink } from '~/data/resources/link.mutations'
import { useI18n } from '~/i18n'
import type { LinkModel } from '~/models/link'
import { LinkState, LinkStateNameKeys, LinkType } from '~/models/link'
@@ -41,8 +41,11 @@ function FriendEditorModal(props: FriendEditorModalProps) {
url: url.trim(),
}
- if (props.link?.id) return updateLink(props.link.id, data)
- return createLink(data)
+ const mode = props.link?.id
+ ? { id: props.link.id, kind: 'edit' as const }
+ : { kind: 'create' as const }
+
+ return saveLink(mode, data)
},
onSuccess: () => {
toast.success(t('friends.toast.saved'))
diff --git a/apps/admin/src/features/friends/hooks/use-friend-mutations.ts b/apps/admin/src/features/friends/hooks/use-friend-mutations.ts
index 946d3c22948..f09b69b0816 100644
--- a/apps/admin/src/features/friends/hooks/use-friend-mutations.ts
+++ b/apps/admin/src/features/friends/hooks/use-friend-mutations.ts
@@ -2,12 +2,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { toast } from 'sonner'
-import {
- auditPassLink,
- checkLinksHealth,
- deleteLink,
- migrateLinkAvatars,
-} from '~/api/links'
+import { checkLinksHealth, migrateLinkAvatars } from '~/api/links'
+import { auditPass, removeLink } from '~/data/resources/link.mutations'
import { useI18n } from '~/i18n'
import { friendsQueryKey } from '../constants'
@@ -27,7 +23,7 @@ export function useFriendMutations(options: UseFriendMutationsOptions = {}) {
}, [queryClient])
const deleteMutation = useMutation({
- mutationFn: deleteLink,
+ mutationFn: removeLink,
onSuccess: async () => {
toast.success(t('friends.toast.deleted'))
await invalidateFriends()
@@ -35,7 +31,7 @@ export function useFriendMutations(options: UseFriendMutationsOptions = {}) {
})
const auditPassMutation = useMutation({
- mutationFn: auditPassLink,
+ mutationFn: auditPass,
onSuccess: async () => {
toast.success(t('friends.toast.auditPass'))
await invalidateFriends()
diff --git a/apps/admin/src/features/friends/hooks/use-friends-list.ts b/apps/admin/src/features/friends/hooks/use-friends-list.ts
index 5d4055cae62..683b8fb32c9 100644
--- a/apps/admin/src/features/friends/hooks/use-friends-list.ts
+++ b/apps/admin/src/features/friends/hooks/use-friends-list.ts
@@ -2,6 +2,8 @@ import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { getLinks, getLinkStateCount } from '~/api/links'
+import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks'
+import { links } from '~/data/resources/link'
import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state'
import type { LinkState } from '~/models/link'
import { adminQueryKeys } from '~/query/keys'
@@ -33,21 +35,28 @@ export function useFriendsList() {
const [state, setState] = useUrlListState(urlStateOptions)
- const linksQuery = useQuery({
- placeholderData: (previous) => previous,
+ const linksListKey = adminQueryKeys.links.list({
+ page: state.page,
+ size: friendsPageSize,
+ state: state.state,
+ })
+
+ const linksQuery = useCollectionListQuery(links, {
queryFn: () =>
getLinks({
page: state.page,
size: friendsPageSize,
state: state.state,
}),
- queryKey: adminQueryKeys.links.list({
- page: state.page,
- size: friendsPageSize,
- state: state.state,
+ queryKey: linksListKey,
+ toPage: (result) => ({
+ items: result.data,
+ pagination: result.pagination,
}),
})
+ const linksList = useEntityList(links, linksListKey, { keepPrevious: true })
+
const countsQuery = useQuery({
queryFn: getLinkStateCount,
queryKey: adminQueryKeys.links.stateCount(),
@@ -56,10 +65,10 @@ export function useFriendsList() {
return {
counts: countsQuery.data,
countsQuery,
- links: linksQuery.data?.data ?? [],
+ links: linksList.items,
linksQuery,
page: state.page,
- pagination: linksQuery.data?.pagination,
+ pagination: linksList.pagination,
setPage: (page: number | ((current: number) => number)) => {
setState((current) => ({
...current,
diff --git a/apps/admin/src/features/projects/components/ProjectDetailRoute.tsx b/apps/admin/src/features/projects/components/ProjectDetailRoute.tsx
index f9c996a03bf..165ecffc5c3 100644
--- a/apps/admin/src/features/projects/components/ProjectDetailRoute.tsx
+++ b/apps/admin/src/features/projects/components/ProjectDetailRoute.tsx
@@ -1,13 +1,11 @@
-import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams, useSearchParams } from 'react-router'
-import { findInListCache } from '~/api/list-cache'
import { getProject } from '~/api/projects'
+import { useCollectionDetailQuery, useEntity } from '~/data/resource/hooks'
+import { projects } from '~/data/resources/project'
import { useDocumentTitle } from '~/hooks/use-document-title'
-import type { ProjectModel } from '~/models/project'
import { adminQueryKeys } from '~/query/keys'
-import { projectsQueryKey } from '../constants'
import { ProjectDetailPanel } from './ProjectDetailPanel'
import { ProjectFormPanel } from './ProjectFormPanel'
import {
@@ -16,30 +14,24 @@ import {
} from './ProjectPrimitives'
import { useProjectsRouteContext } from './projects-route-context'
-const LIST_PREFIX = [...projectsQueryKey, 'list'] as const
-
export function ProjectDetailRoute() {
const { id } = useParams<{ id: string }>()
const [searchParams] = useSearchParams()
- const queryClient = useQueryClient()
const ctx = useProjectsRouteContext()
const isCreating = id === 'new'
const isEditing = !isCreating && searchParams.get('edit') === '1'
- const initialProject =
- !isCreating && id
- ? findInListCache(queryClient, LIST_PREFIX, id)
- : undefined
+ const project = useEntity(projects, isCreating ? undefined : id)
- const projectQuery = useQuery({
+ const projectQuery = useCollectionDetailQuery(projects, {
enabled: Boolean(id) && !isCreating,
- initialData: initialProject,
queryFn: () => getProject(id!),
- queryKey: id ? adminQueryKeys.projects.detail(id) : projectsQueryKey,
- staleTime: initialProject ? 30_000 : 0,
+ queryKey: id
+ ? adminQueryKeys.projects.detail(id)
+ : adminQueryKeys.projects.root,
})
- useDocumentTitle(projectQuery.data?.name)
+ useDocumentTitle(project?.name)
if (isCreating) {
return (
@@ -57,7 +49,7 @@ export function ProjectDetailRoute() {
if (!id) return
- if (projectQuery.isLoading && !projectQuery.data) {
+ if (projectQuery.isLoading && !project) {
return (
@@ -65,7 +57,7 @@ export function ProjectDetailRoute() {
)
}
- if (!projectQuery.data) return
+ if (!project) return
if (isEditing) {
return (
@@ -75,7 +67,7 @@ export function ProjectDetailRoute() {
onCancel={ctx.onStopEditing}
onMobileBack={ctx.onMobileBack}
onSuccess={ctx.onSaved}
- project={projectQuery.data}
+ project={project}
/>
)
@@ -88,7 +80,7 @@ export function ProjectDetailRoute() {
onDeleted={ctx.onDeleted}
onEdit={ctx.onEdit}
onMobileBack={ctx.onMobileBack}
- project={projectQuery.data}
+ project={project}
/>
)
diff --git a/apps/admin/src/features/projects/components/ProjectFormPanel.tsx b/apps/admin/src/features/projects/components/ProjectFormPanel.tsx
index d3509f2cb8c..a52cbed683d 100644
--- a/apps/admin/src/features/projects/components/ProjectFormPanel.tsx
+++ b/apps/admin/src/features/projects/components/ProjectFormPanel.tsx
@@ -5,8 +5,8 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import type { GithubRepo } from '~/api/github-repo'
-import { createProject, updateProject } from '~/api/projects'
import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout'
+import { saveProject } from '~/data/resources/project.mutations'
import { useI18n } from '~/i18n'
import type { ProjectModel } from '~/models/project'
import { MobileHeaderAffordance } from '~/ui/layout/mobile-header-affordance'
@@ -42,10 +42,11 @@ export function ProjectFormPanel(props: {
const mutation = useMutation({
mutationFn: async () => {
const payload = formToPayload(form)
+ const mode = props.project?.id
+ ? { id: props.project.id, kind: 'edit' as const }
+ : { kind: 'create' as const }
- if (props.project?.id) return updateProject(props.project.id, payload)
-
- return createProject(payload)
+ return saveProject(mode, payload)
},
onSuccess: async (project) => {
toast.success(
diff --git a/apps/admin/src/features/projects/hooks/use-project-mutations.ts b/apps/admin/src/features/projects/hooks/use-project-mutations.ts
index f01ff6d883c..ce208ac25e0 100644
--- a/apps/admin/src/features/projects/hooks/use-project-mutations.ts
+++ b/apps/admin/src/features/projects/hooks/use-project-mutations.ts
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { toast } from 'sonner'
-import { deleteProject } from '~/api/projects'
+import { removeProject } from '~/data/resources/project.mutations'
import { useI18n } from '~/i18n'
import { projectsQueryKey } from '../constants'
@@ -20,7 +20,7 @@ export function useProjectMutations(options: UseProjectMutationsOptions = {}) {
}, [queryClient])
const deleteMutation = useMutation({
- mutationFn: deleteProject,
+ mutationFn: removeProject,
onSuccess: async () => {
toast.success(t('projects.detail.deleteSuccess'))
await options.onDeleted?.()
diff --git a/apps/admin/src/features/projects/hooks/use-projects-list.ts b/apps/admin/src/features/projects/hooks/use-projects-list.ts
index 0d394187bb8..cf7399f17e1 100644
--- a/apps/admin/src/features/projects/hooks/use-projects-list.ts
+++ b/apps/admin/src/features/projects/hooks/use-projects-list.ts
@@ -1,7 +1,8 @@
-import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { getProjects } from '~/api/projects'
+import { useCollectionListQuery, useEntityList } from '~/data/resource/hooks'
+import { projects } from '~/data/resources/project'
import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state'
import { adminQueryKeys } from '~/query/keys'
@@ -29,19 +30,28 @@ export function useProjectsList() {
const [state, setState] = useUrlListState(urlStateOptions)
- const projectsQuery = useQuery({
- placeholderData: (previous) => previous,
+ const projectsListKey = adminQueryKeys.projects.list({
+ page: state.page,
+ size: projectsPageSize,
+ })
+
+ const projectsQuery = useCollectionListQuery(projects, {
queryFn: () => getProjects({ page: state.page, size: projectsPageSize }),
- queryKey: adminQueryKeys.projects.list({
- page: state.page,
- size: projectsPageSize,
+ queryKey: projectsListKey,
+ toPage: (result) => ({
+ items: result.data,
+ pagination: result.pagination,
}),
})
+ const projectsList = useEntityList(projects, projectsListKey, {
+ keepPrevious: true,
+ })
+
return {
page: state.page,
- pagination: projectsQuery.data?.pagination,
- projects: projectsQuery.data?.data ?? [],
+ pagination: projectsList.pagination,
+ projects: projectsList.items,
projectsQuery,
setPage: (page: number) => setState({ page }),
}
From 88e7c2d2039b86b17cbc2ed74e1bceff9da60c95 Mon Sep 17 00:00:00 2001
From: Innei
Date: Sat, 11 Jul 2026 03:05:51 +0800
Subject: [PATCH 15/15] fix(admin): dedupe collection/plain query keys, ensure
entity hydration 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).
---
.../admin/src/data/resources/note.mutations.ts | 12 +++++++++++-
.../admin/src/data/resources/post.mutations.ts | 18 ++++++++++++++----
apps/admin/src/data/resources/topic.ts | 10 +---------
.../src/features/posts/hooks/use-posts-list.ts | 2 +-
.../topics/components/TopicFormModal.tsx | 2 +-
.../components/WriteRouteViewsContent.tsx | 6 +++---
6 files changed, 31 insertions(+), 19 deletions(-)
diff --git a/apps/admin/src/data/resources/note.mutations.ts b/apps/admin/src/data/resources/note.mutations.ts
index d0fca59708c..f1a245ef2c5 100644
--- a/apps/admin/src/data/resources/note.mutations.ts
+++ b/apps/admin/src/data/resources/note.mutations.ts
@@ -2,6 +2,7 @@ import type { CreateNoteData, PatchNoteData } from '~/api/notes'
import {
createNote,
deleteNote,
+ getNoteById,
patchNotePublish,
updateNote,
} from '~/api/notes'
@@ -10,10 +11,17 @@ import type { NoteModel } from '~/models/note'
import { notes } from './note'
+async function ensureNoteHydrated(id: string): Promise {
+ if (notes.get(id) !== undefined) return
+ const entity = await getNoteById(id, { single: true })
+ notes.hydrate([entity])
+}
+
export async function publishNote(
id: string,
isPublished: boolean,
): Promise {
+ await ensureNoteHydrated(id)
const tx = createTransaction()
tx.update(notes, id, (draft) => {
draft.isPublished = isPublished
@@ -23,10 +31,11 @@ export async function publishNote(
return result
}
-export function patchNoteFields(
+export async function patchNoteFields(
id: string,
patch: PatchNoteData,
): Promise {
+ await ensureNoteHydrated(id)
return notes.update(id, (draft) => {
Object.assign(draft, patch)
})
@@ -91,6 +100,7 @@ export async function saveNote(
return result
}
+ await ensureNoteHydrated(id)
const patch = toOptimisticNotePatch(data)
const tx = createTransaction()
tx.update(notes, id, (draft) => {
diff --git a/apps/admin/src/data/resources/post.mutations.ts b/apps/admin/src/data/resources/post.mutations.ts
index 84bd049e126..3b55f7befbb 100644
--- a/apps/admin/src/data/resources/post.mutations.ts
+++ b/apps/admin/src/data/resources/post.mutations.ts
@@ -1,32 +1,41 @@
import type { CreatePostData } from '~/api/posts'
-import { createPost, deletePost, updatePost } from '~/api/posts'
+import { createPost, deletePost, getPostById, updatePost } from '~/api/posts'
import { createTransaction } from '~/data/resource/transaction'
import type { PostModel } from '~/models/post'
import { posts } from './post'
-export function publishPost(
+async function ensurePostHydrated(id: string): Promise {
+ if (posts.get(id) !== undefined) return
+ const entity = await getPostById(id)
+ posts.hydrate([entity])
+}
+
+export async function publishPost(
id: string,
isPublished: boolean,
): Promise {
+ await ensurePostHydrated(id)
return posts.update(id, (draft) => {
draft.isPublished = isPublished
})
}
-export function pinPost(
+export async function pinPost(
id: string,
isPinned: boolean,
): Promise {
+ await ensurePostHydrated(id)
return posts.update(id, (draft) => {
draft.pinAt = isPinned ? new Date().toISOString() : null
})
}
-export function movePostCategory(
+export async function movePostCategory(
id: string,
categoryId: string,
): Promise {
+ await ensurePostHydrated(id)
return posts.update(id, (draft) => {
draft.categoryId = categoryId
})
@@ -90,6 +99,7 @@ export async function savePost(
return result
}
+ await ensurePostHydrated(id)
const patch = toOptimisticPostPatch(data)
const tx = createTransaction()
tx.update(posts, id, (draft) => {
diff --git a/apps/admin/src/data/resources/topic.ts b/apps/admin/src/data/resources/topic.ts
index bcc90010ddf..d8a23fa8e5b 100644
--- a/apps/admin/src/data/resources/topic.ts
+++ b/apps/admin/src/data/resources/topic.ts
@@ -1,17 +1,9 @@
-import { deleteTopic, updateTopic } from '~/api/topics'
+import { deleteTopic } from '~/api/topics'
import { defineCollection } from '~/data/resource/collection'
import type { TopicModel } from '~/models/topic'
export const topics = defineCollection({
name: 'topic',
getKey: (topic) => topic.id,
- onUpdate: ({ id, next }) =>
- updateTopic(id, {
- description: next.description ?? undefined,
- icon: next.icon || undefined,
- introduce: next.introduce ?? '',
- name: next.name,
- slug: next.slug,
- }),
onDelete: ({ id }) => deleteTopic(id),
})
diff --git a/apps/admin/src/features/posts/hooks/use-posts-list.ts b/apps/admin/src/features/posts/hooks/use-posts-list.ts
index fa61e067d61..f8b5f73521d 100644
--- a/apps/admin/src/features/posts/hooks/use-posts-list.ts
+++ b/apps/admin/src/features/posts/hooks/use-posts-list.ts
@@ -107,7 +107,7 @@ export function usePostsList() {
page: state.page,
size: postsPageSize,
}),
- queryKey: postsListKey,
+ queryKey: [...postsListKey, 'search'],
})
const postsQuery = state.keyword ? searchQuery : collectionQuery
diff --git a/apps/admin/src/features/topics/components/TopicFormModal.tsx b/apps/admin/src/features/topics/components/TopicFormModal.tsx
index 11f66b2c53f..b85f169bd21 100644
--- a/apps/admin/src/features/topics/components/TopicFormModal.tsx
+++ b/apps/admin/src/features/topics/components/TopicFormModal.tsx
@@ -33,7 +33,7 @@ function TopicFormModal(props: TopicFormModalProps) {
enabled: isEdit,
queryFn: () => getTopic(editId ?? ''),
queryKey: editId
- ? adminQueryKeys.topics.detail(editId)
+ ? [...adminQueryKeys.topics.detail(editId), 'form']
: adminQueryKeys.topics.root,
})
const [name, setName] = useState('')
diff --git a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
index e992f4e3d98..fb3793e4b31 100644
--- a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
+++ b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx
@@ -448,7 +448,7 @@ function WritePage(props: { kind: WriteKind }) {
const postDetailQuery = useCollectionDetailQuery(postsCollection, {
enabled: isEditing && props.kind === 'post',
queryFn: () => getPostById(id),
- queryKey: adminQueryKeys.write.detail({ id, kind: props.kind }),
+ queryKey: adminQueryKeys.write.detail({ id, kind: 'post' }),
})
const postEntity = useEntity(
postsCollection,
@@ -457,7 +457,7 @@ function WritePage(props: { kind: WriteKind }) {
const noteDetailQuery = useCollectionDetailQuery(notesCollection, {
enabled: isEditing && props.kind === 'note',
queryFn: () => getNoteById(id, { single: true }),
- queryKey: adminQueryKeys.write.detail({ id, kind: props.kind }),
+ queryKey: adminQueryKeys.write.detail({ id, kind: 'note' }),
})
const noteEntity = useEntity(
notesCollection,
@@ -466,7 +466,7 @@ function WritePage(props: { kind: WriteKind }) {
const pageDetailQuery = useCollectionDetailQuery(pagesCollection, {
enabled: isEditing && props.kind === 'page',
queryFn: () => getPageById(id),
- queryKey: adminQueryKeys.write.detail({ id, kind: props.kind }),
+ queryKey: adminQueryKeys.write.detail({ id, kind: 'page' }),
})
const pageEntity = useEntity(
pagesCollection,