@prometheus-ags/prometheus-entity-management 1.2.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,269 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
5
5
 
6
6
  ---
7
7
 
8
+ ## [2.0.0] — 2026-05-25 — BREAKING
9
+
10
+ ### Overview
11
+
12
+ 2.0 eliminates the **"transport leak"** — the 1.x pattern where every
13
+ `useEntityView` / `useEntityList` call accepted its own `remoteFetch`,
14
+ `normalize`, `queryKey`, `enabled`, and error-handling strategy. Each
15
+ call site reinvented the same retry-loop bug in subtly different ways.
16
+
17
+ The new model: register ONE transport per entity type at app boot.
18
+ Every hook thereafter looks it up by name. Error handling, retry policy,
19
+ AbortController threading, and SWR staleness are enforced once, in the
20
+ library, not at every call site.
21
+
22
+ ### BREAKING CHANGES
23
+
24
+ #### Removed (runtime-warning shims remain — they log a migration message and continue working)
25
+
26
+ - `useEntityList(opts)` — inline `fetch`/`normalize` closure form
27
+ - `useEntityView(opts)` — inline `remoteFetch`/`normalize` closure form
28
+
29
+ Both names still export. Calling them logs:
30
+ ```
31
+ [entity-management] useEntityView("Foo") is deprecated in 2.0.
32
+ Register a transport: registerEntityTransport("Foo", makeRestTransport(...))
33
+ Then replace this call with: useEntityQuery<T>("Foo", { view })
34
+ ```
35
+
36
+ TypeScript types remain unchanged — existing consumers compile without
37
+ modification. Runtime behavior is identical. The warning is the only
38
+ observable change for existing call sites.
39
+
40
+ #### Migration guide
41
+
42
+ **Step 1 — Register transports at app boot (once per entity type):**
43
+
44
+ ```ts
45
+ import { registerEntityTransport, makeRestTransport } from "@prometheus-ags/prometheus-entity-management";
46
+ import { supabase } from "@/shared/db/supabase";
47
+
48
+ registerEntityTransport("Invoice", makeRestTransport({
49
+ supabase,
50
+ table: "invoice",
51
+ authoritative: false,
52
+ }));
53
+ ```
54
+
55
+ **Step 2a — Simple lists: replace `useEntityList` with `useEntities`:**
56
+
57
+ ```ts
58
+ // BEFORE
59
+ const { items, isLoading, error } = useEntityList({
60
+ type: "Invoice",
61
+ queryKey: ["Invoice", companyId],
62
+ fetch: () => supabase.from("invoice").select("*"),
63
+ normalize: (raw) => ({ id: String(raw.id), data: raw }),
64
+ enabled: !!companyId,
65
+ });
66
+
67
+ // AFTER
68
+ const { items, isLoading, error } = useEntities<Invoice>("Invoice", {
69
+ filter: { field: "company_id", op: "eq", value: companyId },
70
+ enabled: !!companyId,
71
+ });
72
+ // error is now TerminalError | TransientError | null (instanceof-checkable)
73
+ ```
74
+
75
+ **Step 2b — Rich views with toolbars: replace `useEntityView` with `useEntityQuery`:**
76
+
77
+ ```ts
78
+ // BEFORE
79
+ const { items, setFilter, setSort } = useEntityView({
80
+ type: "Client",
81
+ baseQueryKey: ["clients", workspaceId],
82
+ view: { filter, sort },
83
+ remoteFetch: (params) => api.clients(params.rest),
84
+ normalize: (raw) => ({ id: raw.id, data: raw }),
85
+ });
86
+
87
+ // AFTER
88
+ const { items, setFilter, setSort } = useEntityQuery<Client>("Client", { view: { filter, sort } });
89
+ ```
90
+
91
+ ### Added
92
+
93
+ - **`TerminalError`** — 4xx / permanent failures. `instanceof`-checkable.
94
+ `kind: "terminal"`, optional `status: number`.
95
+ Engine does NOT retry on TerminalError.
96
+
97
+ - **`TransientError`** — 5xx / network failures. `instanceof`-checkable.
98
+ `kind: "transient"`, optional `status: number`.
99
+ Engine retries with exponential backoff (up to `maxRetries`, default 3).
100
+
101
+ - **`toEntityError(err)`** — Converts unknown thrown values to
102
+ `TerminalError | TransientError`:
103
+ - 4xx status → TerminalError
104
+ - AbortError → TerminalError
105
+ - 5xx / network → TransientError
106
+ - Plain Error → TransientError
107
+
108
+ - **`EntityTransport<T>` interface** — One implementation per entity type:
109
+ ```ts
110
+ interface EntityTransport<T extends object> {
111
+ identify: (row: T) => string;
112
+ authoritative: boolean;
113
+ staleTime?: number;
114
+ list: (q: ListQuery) => Promise<ListResult<T>>;
115
+ get?: (id: string, signal?: AbortSignal) => Promise<T | null>;
116
+ subscribe?: (onChange: (ev: ChangeEvent<T>) => void) => () => void;
117
+ }
118
+ ```
119
+
120
+ - **`registerEntityTransport(type, transport)`** — Register at app boot.
121
+ Re-registering replaces (useful in tests).
122
+
123
+ - **`getEntityTransport<T>(type)`** — Look up registered transport.
124
+ Throws `TerminalError` if not found.
125
+
126
+ - **`makeRestTransport(opts)`** — PostgREST/Supabase transport builder.
127
+ Maps `ListQuery` → query params, parses `Content-Range` for total,
128
+ maps 4xx → TerminalError, 5xx/network → TransientError, threads signal.
129
+
130
+ - **`useEntities<T>(type, opts)`** — Thin replacement for `useEntityList`.
131
+ 5-field return: `{ items, isLoading, isError, error, refetch }`.
132
+ - `error` is typed `TerminalError | TransientError | null`.
133
+ - `isLoading` is `lastFetched === null && isFetching` — never stuck at true.
134
+ - AbortController per fetch; aborts on unmount/key-change/refetch.
135
+ - 4xx → TerminalError, no retry.
136
+ - 5xx → TransientError, retry with exponential backoff.
137
+
138
+ - **`useEntityQuery<T>(type, opts)`** — Rich replacement for `useEntityView`.
139
+ Full toolbar API: `setFilter`, `setSort`, `setSearch`, `fetchNextPage`,
140
+ `setView`, `clearView`, `refetch`. Transport looked up from registry
141
+ (no inline closure). `error` is typed.
142
+
143
+ ### Fixed (preserved from 1.3.2)
144
+
145
+ - `setListError` stamps `lastFetched` — terminal failures no longer cause
146
+ infinite retry loops.
147
+ - `useEntityView` writes errors to the base key — closes the Quick Stats
148
+ staleness trap.
149
+
150
+ ---
151
+
152
+ ## [1.3.2] — 2026-05-25
153
+
154
+ ### Fixed
155
+
156
+ - **`setListError` now stamps `lastFetched` and clears `stale`.**
157
+ Previously, a failed list fetch only set `error` + cleared
158
+ `isFetching` — leaving `lastFetched: null`. Every consumer hook's
159
+ SWR staleness check
160
+ (`Date.now() - (lastFetched ?? 0) > staleTime`) then returned
161
+ `true` on the very next render, refiring the fetcher in an
162
+ infinite loop. A 404 on a missing table (e.g. against a schema
163
+ that hasn't been migrated yet) became a perpetual retry storm.
164
+ After this fix, a terminal failure is treated as a completed
165
+ attempt: consumers see a stable `error` and `isFetching: false`,
166
+ and the fetcher runs once. Manual `refetch()` is still available
167
+ for explicit retries.
168
+ - **`useEntityView` writes errors to the BASE key**, not just to
169
+ the remote-result key. The base key is the one
170
+ `isLoading` / `isStale` read from — without this, the staleness
171
+ check kept refiring even after the catch. Combined with the
172
+ `setListError` fix, this closes the terminal-error trap for
173
+ `useEntityView` consumers (Quick Stats, Active Trial Performance,
174
+ Revenue Trend, Recent Activity, etc.).
175
+ - **`useEntityView`'s `isLoading` no longer defaults to `true` when
176
+ there is no list state.** The previous `listState?.isFetching ??
177
+ true` was the actual symptom of the trap: when no list state
178
+ existed (because the failed fetch never seeded the base key), the
179
+ `?? true` kept `isLoading` at `true` forever. Changed to `?? false`
180
+ to match `useEntityList`'s symmetric behaviour (it reads
181
+ `EMPTY_LIST_STATE` which has `isFetching: false` by default).
182
+
183
+ ### Added
184
+
185
+ - **`isError: boolean`** added to both `UseEntityViewResult` and
186
+ `UseEntityListResult`. Convenience for `error !== null`,
187
+ matching TanStack Query's hook ergonomics. Purely additive on
188
+ the return — no breaking API change.
189
+
190
+ ### Notes
191
+
192
+ - This release does NOT add an `onError` callback option to either
193
+ hook. The decision is deliberate: TanStack Query deprecated
194
+ per-query `onError` callbacks in v5 because they fire per
195
+ observer (calling the same hook from N components produces N
196
+ notifications on a single failure). Consumers should read
197
+ `error` / `isError` from the hook return and decide their own
198
+ display strategy. See
199
+ https://tkdodo.eu/blog/react-query-error-handling for the
200
+ research that drove this decision.
201
+
202
+ ---
203
+
204
+ ## [1.3.1] — 2026-05-25
205
+
206
+ ### Fixed
207
+
208
+ - **`useEntityList` no longer triggers React 19's "The result of
209
+ getSnapshot should be cached to avoid an infinite loop" warning.**
210
+ The hook's return shape was a fresh object literal on every render,
211
+ which `useSyncExternalStore` (via Zustand's `useStore`) interpreted
212
+ as a changed snapshot. Wrapping the return in `useMemo` keyed on
213
+ `[items, listState, fetchNextPage, doFetch]` stabilises the
214
+ identity. `items` was already identity-stable via the
215
+ `useShallow(itemsSelector)` call on the `useStore` read; this fix
216
+ closes the gap for the outer shape consumers depend on (e.g. hook
217
+ composition chains like `useTeam` → `useQuickStats` → widget).
218
+ See [pmndrs/zustand discussion #1936](https://github.com/pmndrs/zustand/discussions/1936)
219
+ and [React's `useSyncExternalStore` docs](https://react.dev/reference/react/useSyncExternalStore)
220
+ for the contract being honoured.
221
+ - Downstream effect: consumers stuck at first commit because of the
222
+ warning-loop guard now hydrate normally, so Tier-A and hybrid list
223
+ views render data on first paint instead of showing perpetual
224
+ loading skeletons.
225
+
226
+ ---
227
+
228
+ ## [1.3.0] — 2026-05-23
229
+
230
+ Upstream features driven by the `hotseaters-pglite-port` phase — every
231
+ consumer of the library benefits from these primitives, but the focal use
232
+ case is a tenant-scoped, PGlite-backed local-first React app talking to a
233
+ self-hosted Supabase + ElectricSQL stack.
234
+
235
+ ### Added
236
+
237
+ - **`createPGlitePersistenceAdapter(pglite, options?)`** in
238
+ `src/adapters/pglite-persistence.ts` — a `GraphPersistenceAdapter` that
239
+ stores the local-first runtime's graph snapshot in a PGlite table
240
+ (`_graph_snapshot` by default), instead of `localStorage`/`IndexedDB`.
241
+ - **`createTenantScopedElectricAdapter(opts)`** in
242
+ `src/adapters/electricsql-tenant.ts` — Electric adapter wrapper that
243
+ refuses to attach a shape unless it declares a `tenantColumn` (string or
244
+ explicit `null` for the tenant root). Builds the `WHERE` clause from a
245
+ validated `{ companyId }` claim so shape predicates can never widen past
246
+ RLS by accident. Implements RULE 5 (shape predicates ⊆ RLS) and the
247
+ auth-claim-aware shape registration helper (Change 13 item 11).
248
+ - **`registerEntityFromSql({ entityType, createTableSql, overrides })`** in
249
+ `src/schema-from-sql.ts` — generates and registers a JSON Schema directly
250
+ from a Postgres `CREATE TABLE` block, removing the need to hand-maintain
251
+ TypeScript schema duplicates.
252
+ - **`useEntityListAsTable(opts)`** in `src/table/use-entity-list-as-table.ts`
253
+ — wraps `useEntityList` and returns a referentially-stable `data` array
254
+ suitable for TanStack Table's `data` prop. Does not pull
255
+ `@tanstack/react-table` as a dep.
256
+ - **Retry-with-backoff replay** for pending offline actions in
257
+ `startLocalFirstGraph(...)` via a new `retryPolicy` option
258
+ (`{ maxAttempts, initialDelayMs, maxDelayMs, backoffFactor, jitter, poisonHandler }`).
259
+ Exhausted actions go to a poison handler instead of looping forever.
260
+
261
+ ### Notes
262
+
263
+ - No new runtime dependencies. PGlite and ElectricSQL are still consumed
264
+ through minimal structural types, exactly like the existing
265
+ `adapters/electricsql.ts`.
266
+ - Backward compatible: every existing export remains. Consumers can adopt
267
+ the new APIs incrementally.
268
+
269
+ ---
270
+
8
271
  ## [1.2.0] — 2026-04-05
9
272
 
10
273
  PWA/local-first and schema-driven entity release focused on dynamic JSON-column UI, markdown-aware rendering, and IPC-safe graph persistence.
package/README.md CHANGED
@@ -99,6 +99,45 @@ Data flows **up** into the graph; UI reads **down** through hooks (see [Architec
99
99
 
100
100
  ---
101
101
 
102
+ ## v1.3 additions
103
+
104
+ Eight focused additions across three patch releases for tenant-scoped,
105
+ PGlite-backed local-first apps. All backward compatible; no new runtime
106
+ dependencies.
107
+
108
+ ### v1.3.0 — new APIs
109
+
110
+ | API | File | Purpose |
111
+ |-----|------|---------|
112
+ | `createPGlitePersistenceAdapter(pglite, options?)` | `src/adapters/pglite-persistence.ts` | `GraphPersistenceAdapter` that stores the snapshot in a PGlite table (`_graph_snapshot` by default) |
113
+ | `createTenantScopedElectricAdapter(opts)` | `src/adapters/electricsql-tenant.ts` | Refuses to attach Electric shapes that lack a `tenantColumn`; builds the `WHERE` from a validated `{ companyId }` claim so shape predicates can never widen past RLS |
114
+ | `registerEntityFromSql({ entityType, createTableSql, overrides })` | `src/schema-from-sql.ts` | Generates and registers a JSON Schema directly from a Postgres `CREATE TABLE` block — no hand-maintained TypeScript schema duplicates |
115
+ | `useEntityListAsTable(opts)` | `src/table/use-entity-list-as-table.ts` | Wraps `useEntityList` for TanStack Table — returns a referentially-stable `data` array and `rowCount`; no TanStack Table dep required |
116
+ | `startLocalFirstGraph({ ..., retryPolicy })` | `src/local-first-runtime.ts` | Retry-with-backoff for pending offline action replay; exhausted actions go to an opt-in `poisonHandler` instead of looping forever |
117
+
118
+ ### v1.3.1 — stability fix
119
+
120
+ - **`useEntityList`** return shape is now `useMemo`-stabilised. React 19's
121
+ `useSyncExternalStore` was detecting a fresh object on every render and
122
+ emitting an infinite-loop warning. Identity-stable `items` + stable
123
+ pagination state means no more perpetual loading skeletons from hook
124
+ composition chains.
125
+
126
+ ### v1.3.2 — error handling
127
+
128
+ - **`isError: boolean`** added to both `UseEntityListResult` and
129
+ `UseEntityViewResult` as a convenience alias for `error !== null`,
130
+ matching TanStack Query's hook ergonomics.
131
+ - **`setListError`** now stamps `lastFetched` and clears `stale`, closing
132
+ a terminal-error retry loop where a 404 on a missing table triggered an
133
+ infinite refetch storm.
134
+ - **`useEntityView`** writes errors to the base key (the one `isLoading`
135
+ reads from) and defaults `isLoading` to `false` when no list state
136
+ exists, so a failed first fetch no longer leaves consumers stuck in a
137
+ perpetual loading state.
138
+
139
+ ---
140
+
102
141
  ## New in v1.2
103
142
 
104
143
  The graph runtime now exposes a focused set of non-hook helpers for loaders, workflows, and orchestration:
@@ -183,6 +222,7 @@ Compare against peers only when measurement methodology matches (minified vs unm
183
222
  | Export | Description |
184
223
  |--------|-------------|
185
224
  | `registerEntityJsonSchema` / `registerRuntimeSchema` | Register static or runtime-generated JSON Schemas for an entity type or JSON column. |
225
+ | `registerEntityFromSql` | Generate and register a JSON Schema from a Postgres `CREATE TABLE` block — eliminates hand-maintained TypeScript schema duplicates. |
186
226
  | `getEntityJsonSchema` | Resolve the active schema by entity type, schema id, or field. |
187
227
  | `buildEntityFieldsFromSchema` | Generate entity field descriptors from JSON Schema for dynamic forms and detail views. |
188
228
  | `useSchemaEntityFields` | Hook that resolves a registered schema and returns generated field descriptors. |
@@ -193,19 +233,23 @@ Compare against peers only when measurement methodology matches (minified vs unm
193
233
 
194
234
  | Export | Description |
195
235
  |--------|-------------|
196
- | `startLocalFirstGraph` | Starts a higher-level local-first runtime for graph hydration, persistence, action replay, and sync status. |
236
+ | `startLocalFirstGraph` | Starts a higher-level local-first runtime for graph hydration, persistence, action replay, and sync status. Accepts optional `retryPolicy` for offline action replay. |
197
237
  | `hydrateGraphFromStorage` | Restore graph state from a storage adapter using a JSON-serializable snapshot payload. |
198
238
  | `persistGraphToStorage` | Persist graph state and pending action metadata through a storage adapter. |
199
239
  | `useGraphSyncStatus` | Hook exposing online/offline/hydrating/syncing/ready state for PWAs and IPC-safe hosts. |
240
+ | `replayActionWithRetry` | Replay a single pending action with configurable exponential-backoff retry. |
241
+ | `createPGlitePersistenceAdapter` | PGlite-backed `GraphPersistenceAdapter`; stores the snapshot in a PGlite table alongside synced data. |
200
242
 
201
243
  ### Hooks (REST-oriented)
202
244
 
203
245
  | Export | Description |
204
246
  |--------|-------------|
205
- | `useEntity` | Subscribe to one entity; fetch/normalize into graph; SWR + subscriber-aware refetch. |
206
- | `useEntityList` | Subscribe to a list query key; stores IDs; merges row data from graph. |
247
+ | `useEntity` | Subscribe to one entity; fetch/normalize into graph; SWR + subscriber-aware refetch. Returns `{ data, isLoading, isError, error, refetch }`. |
248
+ | `useEntityList` | Subscribe to a list query key; stores IDs; merges row data from graph. Returns `{ items, isLoading, isError, error, isFetching, fetchNextPage, refetch }`. |
249
+ | `useEntityView` | Filter/sort/search with local/remote/hybrid completeness modes. Returns `{ items, isLoading, isError, error, setFilter, setSort, setSearch }`. |
207
250
  | `useEntityMutation` | Mutate with optional optimistic updates and list invalidation hooks. |
208
251
  | `useEntityAugment` | Patch UI-only fields merged at read time across all subscribers. |
252
+ | `useEntityListAsTable` | Wraps `useEntityList` with a referentially-stable `data` array + `rowCount` for TanStack Table. |
209
253
  | `useSuspenseEntity` | Suspense variant of `useEntity` (non-null `id` required). |
210
254
  | `useSuspenseEntityList` | Suspense variant of `useEntityList`. |
211
255
 
@@ -213,7 +257,6 @@ Compare against peers only when measurement methodology matches (minified vs unm
213
257
 
214
258
  | Export | Description |
215
259
  |--------|-------------|
216
- | `useEntityView` | Filter/sort/search with `local` / `remote` / `hybrid` completeness modes. |
217
260
  | `FilterSpec`, `SortSpec` | Transport-agnostic filter and sort AST types. |
218
261
  | `toRestParams` | Compile view → REST query params. |
219
262
  | `toSQLClauses` | Compile view → SQL-style WHERE / ORDER BY fragments. |
@@ -259,11 +302,12 @@ Compare against peers only when measurement methodology matches (minified vs unm
259
302
  | `prismaRelationsToSchema` | Convert Prisma-style relation map → `EntitySchema` for `registerSchema`. |
260
303
  | `toPrismaInclude` | Build an `include` map from relation descriptors. |
261
304
 
262
- ### Local-first
305
+ ### Local-first adapters
263
306
 
264
307
  | Export | Description |
265
308
  |--------|-------------|
266
- | `createElectricAdapter` | ElectricSQL / PGlite changes → graph. |
309
+ | `createElectricAdapter` | ElectricSQL / PGlite shape changes → graph. |
310
+ | `createTenantScopedElectricAdapter` | Safety wrapper: refuses to attach a shape unless it declares a `tenantColumn`; builds the `WHERE` clause from a validated `{ companyId }` claim. |
267
311
  | `useLocalFirst` | Hook for local-first workflows with the adapter. |
268
312
  | `usePGliteQuery` | Run queries against PGlite in sync with the graph story. |
269
313
 
@@ -337,6 +381,41 @@ const { items, isLoading } = useEntityList<Post, Post>({
337
381
 
338
382
  **Difference:** the list stores **IDs**; row objects are always read through the normalized `Post` map, so updates propagate everywhere.
339
383
 
384
+ ### TanStack Table: `useQuery` data prop → `useEntityListAsTable`
385
+
386
+ If you wire `useEntityList` directly into TanStack Table's `data` prop, the table treats a
387
+ new array reference as new data on every render. Use `useEntityListAsTable` instead—it
388
+ returns a referentially-stable `data` array that only changes when the underlying items
389
+ actually change.
390
+
391
+ **Before**
392
+
393
+ ```tsx
394
+ const { data = [] } = useQuery<Post[]>({
395
+ queryKey: ["posts"],
396
+ queryFn: () => api.posts.list(),
397
+ });
398
+ const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
399
+ ```
400
+
401
+ **After**
402
+
403
+ ```tsx
404
+ import { useEntityListAsTable } from "@prometheus-ags/prometheus-entity-management";
405
+
406
+ const { data, rowCount, isLoading, isError, error } = useEntityListAsTable<Post, Post>({
407
+ type: "Post",
408
+ fetch: (p) => api.posts.list(p),
409
+ normalize: (row) => ({ id: row.id, data: row }),
410
+ });
411
+
412
+ const table = useReactTable({ data, rowCount, columns, getCoreRowModel: getCoreRowModel() });
413
+ ```
414
+
415
+ **Difference:** `data` identity is stable across renders where items haven't changed, so
416
+ TanStack Table's memoization works correctly and row state (selection, expansion) is
417
+ preserved between refetches.
418
+
340
419
  ### Mutations: `useMutation` → `useEntityMutation`
341
420
 
342
421
  **Before**