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: {