@prometheus-ags/prometheus-entity-management 1.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 +119 -0
- package/README.md +525 -0
- package/dist/index.d.mts +2608 -0
- package/dist/index.d.ts +2608 -0
- package/dist/index.js +8060 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +7926 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +92 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## [1.0.0] — 2026-04-04
|
|
9
|
+
|
|
10
|
+
Production-ready semantic version with CI, tests, documentation, and skills export verification.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Vitest smoke tests for `graph`, `engine`, and `RealtimeManager`.
|
|
15
|
+
- `pnpm run test`, `refresh:exports`, `verify:skills` scripts; `prepublishOnly` runs typecheck, build, test, and skills verification.
|
|
16
|
+
- `skills/_shared/references/library-exports.json` ledger (generated from `dist/index.mjs`) for agent skill ↔ runtime export alignment.
|
|
17
|
+
- GitHub Actions workflow: install, typecheck, build, test, verify:skills, typecheck for Vite and Next.js examples.
|
|
18
|
+
- Docs: `docs/tanstack-query-and-table.md`, `docs/advanced.md`, `RELEASING.md`; README documentation map and honest bundle-size guidance.
|
|
19
|
+
- Vite example route `/tanstack-bridge`: TanStack Query + sync into `upsertEntity`; example READMEs for Vite and Next.js.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- README comparison table: bundle size row points to measured guidance instead of a fixed “~15KB” claim.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## [0.1.0] — 2025-01-15
|
|
28
|
+
|
|
29
|
+
Initial release.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
**Core graph (`src/graph.ts`)**
|
|
34
|
+
- Zustand store with immer middleware for immutable entity mutations
|
|
35
|
+
- `entities[type][id]` — normalized entity storage
|
|
36
|
+
- `patches[type][id]` — local UI augmentation layer, merged at read time
|
|
37
|
+
- `lists[queryKey]` — list state holding ordered ID arrays (refs, not copies)
|
|
38
|
+
- `invalidateEntity`, `invalidateLists`, `invalidateType` — stale marking
|
|
39
|
+
- `removeIdFromAllLists` — surgical list cleanup on entity delete
|
|
40
|
+
|
|
41
|
+
**Engine (`src/engine.ts`)**
|
|
42
|
+
- In-flight deduplication via process-global `Map<key, Promise>`
|
|
43
|
+
- Subscriber ref-counting via `Symbol` tokens
|
|
44
|
+
- `fetchEntity` and `fetchList` with exponential backoff retry
|
|
45
|
+
- `attachGlobalListeners` for focus/reconnect revalidation
|
|
46
|
+
- `configureEngine` for global defaults
|
|
47
|
+
|
|
48
|
+
**Hooks (`src/hooks.ts`)**
|
|
49
|
+
- `useEntity<TRaw, TEntity>` — single entity fetch and subscription
|
|
50
|
+
- `useEntityList<TRaw, TEntity>` — list fetch with mode=replace|append
|
|
51
|
+
- `useEntityMutation<TInput, TRaw, TEntity>` — optimistic mutations with rollback
|
|
52
|
+
- `useEntityAugment<TEntity>` — local UI patches visible to all entity subscribers
|
|
53
|
+
|
|
54
|
+
**View layer (`src/view/`)**
|
|
55
|
+
- `FilterSpec` — transport-agnostic filter description (and/or, 16 operators)
|
|
56
|
+
- `SortSpec` — multi-field sort with null handling and custom comparators
|
|
57
|
+
- `toRestParams` — FilterSpec → REST query string params
|
|
58
|
+
- `toGraphQLVariables` — FilterSpec → Hasura/Postgraphile-style GQL variables
|
|
59
|
+
- `toSQLClauses` — FilterSpec → parameterized SQL WHERE + ORDER BY
|
|
60
|
+
- `matchesFilter`, `matchesSearch`, `compareEntities` — local JS evaluation
|
|
61
|
+
- `findInsertionIndex` — O(log n) binary search for realtime sorted insertion
|
|
62
|
+
- `useEntityView` — local/remote/hybrid completeness mode, debounced remote fetch,
|
|
63
|
+
realtime filter evaluation and sorted insertion, `setFilter/setSort/setSearch`
|
|
64
|
+
|
|
65
|
+
**CRUD lifecycle (`src/crud/`)**
|
|
66
|
+
- `registerSchema` / `getSchema` — relation schema registry
|
|
67
|
+
- `cascadeInvalidation` — automatic stale marking on mutation, follows FK changes
|
|
68
|
+
- `readRelations` — resolve related entities from graph using schema
|
|
69
|
+
- `useEntityCRUD` — unified list+detail+edit+create+delete hook
|
|
70
|
+
- Isolated edit buffer (never bleeds to other views until `save()`)
|
|
71
|
+
- Field-level dirty tracking (`dirty.changed: Set<keyof T>`)
|
|
72
|
+
- Optimistic create with temp ID insertion
|
|
73
|
+
- Optimistic delete with rollback on failure
|
|
74
|
+
- `applyOptimistic()` for instant cross-view feedback on toggle/slider fields
|
|
75
|
+
- `cascadeInvalidation` fires automatically after every mutation
|
|
76
|
+
|
|
77
|
+
**Realtime adapters (`src/adapters/`)**
|
|
78
|
+
- `RealtimeAdapter` / `SyncAdapter` interfaces
|
|
79
|
+
- `RealtimeManager` — change coalescing (16ms flush window), adapter registry
|
|
80
|
+
- `createWebSocketAdapter` — reconnect with exponential backoff, ping/keepalive
|
|
81
|
+
- `createSupabaseRealtimeAdapter` — Postgres Changes via logical replication
|
|
82
|
+
- `createConvexAdapter` — snapshot diffing for reactive query results
|
|
83
|
+
- `createGraphQLSubscriptionAdapter` — graphql-ws protocol
|
|
84
|
+
- `createElectricAdapter` — PGlite + ElectricSQL shape sync + NOTIFY listener
|
|
85
|
+
- `useLocalFirst` — isSynced state, local query/execute surface
|
|
86
|
+
- `usePGliteQuery` — raw SQL → entity graph population
|
|
87
|
+
|
|
88
|
+
**GraphQL (`src/graphql/`)**
|
|
89
|
+
- `GQLClient` — query/mutate/subscribe with `EntityDescriptor` normalization
|
|
90
|
+
- `normalizeGQLResponse` — recursive response walker, writes to entity graph
|
|
91
|
+
- `createGQLClient` factory
|
|
92
|
+
- `useGQLEntity` — mirrors `useEntity` over GraphQL
|
|
93
|
+
- `useGQLList` — mirrors `useEntityList` over GraphQL with cursor pagination
|
|
94
|
+
- `useGQLMutation` — mirrors `useEntityMutation` over GraphQL
|
|
95
|
+
- `useGQLSubscription` — graphql-ws subscription → entity graph updates
|
|
96
|
+
|
|
97
|
+
**UI layer (`src/ui/`)**
|
|
98
|
+
- `selectionColumn`, `textColumn`, `numberColumn`, `dateColumn`, `enumColumn`,
|
|
99
|
+
`booleanColumn`, `actionsColumn` — typed TanStack Table column builders
|
|
100
|
+
- `SortHeader` — sort indicator button wired to column sort state
|
|
101
|
+
- `EntityTable` — full table component with TanStack Table, inline editing,
|
|
102
|
+
load-more / page pagination, skeleton loading, empty state, toolbar
|
|
103
|
+
- `InlineCellEditor` — double-click cell editing with Enter/Escape handling
|
|
104
|
+
- `Sheet` — side drawer with backdrop, keyboard dismiss, footer slot
|
|
105
|
+
- `EntityDetailSheet` — detail view with edit/delete actions, field rendering,
|
|
106
|
+
delete confirmation dialog
|
|
107
|
+
- `EntityFormSheet` — create/edit form with field dirty indicators, error display
|
|
108
|
+
|
|
109
|
+
**Examples**
|
|
110
|
+
- `examples/vite-app` — React 19 + Vite 6, TanStack Router, full CRUD demo
|
|
111
|
+
(Dashboard, Projects, Tasks, Team) with Prometheus AGS mock data
|
|
112
|
+
- `examples/nextjs-app` — Next.js 15, Server Component SSR hydration,
|
|
113
|
+
`GraphHydrationProvider`, product catalog with REST API routes
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Roadmap
|
|
118
|
+
|
|
119
|
+
See README.md § Roadmap.
|
package/README.md
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
# @prometheus-ags/prometheus-entity-management
|
|
2
|
+
|
|
3
|
+
**Normalized, globally-reactive entity graph store for React**
|
|
4
|
+
|
|
5
|
+
Update a post in one screen and every list row, detail panel, and badge that reads that entity updates automatically—without hand-maintained query keys. Normalization is built around your `type` + `id` + `normalize` function, not a separate cache product. The same graph holds data from **REST**, **GraphQL**, **WebSocket / Supabase / Convex**, **Prisma-shaped APIs**, and **ElectricSQL + PGlite** local-first sync.
|
|
6
|
+
|
|
7
|
+
### Documentation map
|
|
8
|
+
|
|
9
|
+
| Doc | Purpose |
|
|
10
|
+
|-----|---------|
|
|
11
|
+
| [docs/tanstack-query-and-table.md](docs/tanstack-query-and-table.md) | How this library fits with TanStack Query and TanStack Table |
|
|
12
|
+
| [docs/advanced.md](docs/advanced.md) | Engine, GC, Suspense, DevTools, SSR, testing |
|
|
13
|
+
| [RELEASING.md](RELEASING.md) | Versioning, `prepublishOnly`, npm publish |
|
|
14
|
+
| [CHANGELOG.md](CHANGELOG.md) | Release history |
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
### 1. Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @prometheus-ags/prometheus-entity-management zustand immer
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
(`pnpm` and `yarn` work the same way for app dependencies.)
|
|
27
|
+
|
|
28
|
+
### 2. Define an entity type
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
type Post = { id: string; title: string; status: string };
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 3. Fetch and render
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { useEntity } from "@prometheus-ags/prometheus-entity-management";
|
|
38
|
+
|
|
39
|
+
export function PostCard({ postId }: { postId: string }) {
|
|
40
|
+
const { data, isLoading, error } = useEntity<Post, Post>({
|
|
41
|
+
type: "Post",
|
|
42
|
+
id: postId,
|
|
43
|
+
fetch: async (id) => {
|
|
44
|
+
const res = await fetch(`/api/posts/${id}`);
|
|
45
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
46
|
+
return res.json() as Post;
|
|
47
|
+
},
|
|
48
|
+
normalize: (raw) => raw,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (isLoading) return <p>Loading…</p>;
|
|
52
|
+
if (error) return <p>{error}</p>;
|
|
53
|
+
if (!data) return null;
|
|
54
|
+
return <article>{data.title}</article>;
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Any other component that calls `useEntity` with the same `type` and `id` reads the **same** normalized record from the graph.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Core concepts
|
|
63
|
+
|
|
64
|
+
### Entities live exactly once
|
|
65
|
+
|
|
66
|
+
Each `(entityType, id)` maps to a single canonical object in the Zustand graph (`entities[type][id]`). Lists and detail views never keep their own full copies; they resolve through that node.
|
|
67
|
+
|
|
68
|
+
### Queries are instructions, not containers
|
|
69
|
+
|
|
70
|
+
`useEntity`, `useEntityList`, and GraphQL hooks describe **how** to load data and **how** to normalize it into the graph. They do not own isolated cache entries the way query-key–scoped caches do.
|
|
71
|
+
|
|
72
|
+
### Lists store IDs, not data
|
|
73
|
+
|
|
74
|
+
List state keeps ordered **IDs** plus pagination metadata. Rows are `items` joined from `entities` at render time, so when `Post:123` changes, every list that includes that ID re-renders consistently.
|
|
75
|
+
|
|
76
|
+
### Three-layer model
|
|
77
|
+
|
|
78
|
+
Data flows **up** into the graph; UI reads **down** through hooks (see [Architecture](#architecture)).
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
82
|
+
│ Layer 3: UI Components (optional, users can build their own)│
|
|
83
|
+
│ src/ui/ │
|
|
84
|
+
│ EntityTable · EntityDetailSheet · EntityFormSheet · columns │
|
|
85
|
+
├─────────────────────────────────────────────────────────────┤
|
|
86
|
+
│ Layer 2: Access Patterns (hooks - how components read data) │
|
|
87
|
+
│ src/hooks.ts, src/graphql/hooks.ts, src/crud/ │
|
|
88
|
+
│ useEntity · useEntityList · useEntityView · useEntityCRUD │
|
|
89
|
+
│ useGQLEntity · useEntityMutation · useEntityAugment │
|
|
90
|
+
├─────────────────────────────────────────────────────────────┤
|
|
91
|
+
│ Layer 1: Entity Graph (Zustand store - canonical data) │
|
|
92
|
+
│ src/graph.ts │
|
|
93
|
+
│ entities[type][id] · patches[type][id] · lists[queryKey] │
|
|
94
|
+
└─────────────────────────────────────────────────────────────┘
|
|
95
|
+
▲ ▲ ▲ ▲
|
|
96
|
+
REST fetch GraphQL WebSocket ElectricSQL
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Feature comparison
|
|
102
|
+
|
|
103
|
+
| Feature | `@prometheus-ags/prometheus-entity-management` | TanStack Query | Apollo Client | SWR |
|
|
104
|
+
|--------|--------------------------------------------------|----------------|---------------|-----|
|
|
105
|
+
| Normalized cache | Yes (automatic) | No (manual) | Yes (manual config) | No |
|
|
106
|
+
| Cross-view reactivity | Yes | No | Partial | No |
|
|
107
|
+
| REST support | Yes | Yes | No | Yes |
|
|
108
|
+
| GraphQL support | Yes | No (separate client) | Yes | No |
|
|
109
|
+
| Realtime / WebSocket | Yes (built-in adapters) | No (manual) | Yes (subscriptions) | No |
|
|
110
|
+
| Local-first (ElectricSQL) | Yes | No | No | No |
|
|
111
|
+
| Prisma integration | Yes | No | No | No |
|
|
112
|
+
| CRUD lifecycle | Yes (`useEntityCRUD`) | No | No | No |
|
|
113
|
+
| Relation schemas | Yes (cascade invalidation) | No | Yes (type policies) | No |
|
|
114
|
+
| Suspense hooks | Yes | Yes | Yes | Yes |
|
|
115
|
+
| SSR hydration | Yes | Yes | Yes | Yes |
|
|
116
|
+
| Garbage collection | Yes (automatic, configurable) | Yes | Yes | No |
|
|
117
|
+
| Bundle size | See [Bundle size](#bundle-size) | ~39KB | ~130KB | ~4KB |
|
|
118
|
+
|
|
119
|
+
Peer dependencies (`react`, `react-dom`, optional `@tanstack/react-table`) are **not** included in any column. Published `dist` sizes change with each release—measure before quoting numbers in docs or talks.
|
|
120
|
+
|
|
121
|
+
### Bundle size
|
|
122
|
+
|
|
123
|
+
The npm package ships a **single large entry** (`dist/index.mjs`) that re-exports the full surface (hooks, GraphQL, CRUD, view, UI, adapters). **Your** app’s gzipped cost depends on **tree-shaking**, **minification**, and **which imports you use**.
|
|
124
|
+
|
|
125
|
+
**Maintainers:** after `pnpm run build`, a rough gzip size of the ESM bundle is:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
gzip -c dist/index.mjs | wc -c
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Compare against peers only when measurement methodology matches (minified vs unminified, gzip vs brotli, ESM vs CJS).
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## API reference (brief)
|
|
136
|
+
|
|
137
|
+
### Core
|
|
138
|
+
|
|
139
|
+
| Export | Description |
|
|
140
|
+
|--------|-------------|
|
|
141
|
+
| `useGraphStore` | Zustand store: `entities`, `patches`, `lists`, and graph mutations. Prefer hooks in UI; `getState()` is for effects/adapters. |
|
|
142
|
+
| `configureEngine` | App-wide defaults: stale time, retries, GC interval, GC time, etc. |
|
|
143
|
+
| `getEngineOptions` | Read merged engine options. |
|
|
144
|
+
| `serializeKey` | Stable string key for list `queryKey` serialization. |
|
|
145
|
+
| `fetchEntity` | Imperative single-entity fetch with dedupe and graph write (for custom hooks/adapters). |
|
|
146
|
+
| `fetchList` | Imperative list fetch with dedupe and graph write. |
|
|
147
|
+
| `dedupe` | Process-global in-flight promise deduplication helper. |
|
|
148
|
+
| `startGarbageCollector` / `stopGarbageCollector` | Periodic eviction of unsubscribed, stale entities (also started via `configureEngine`). |
|
|
149
|
+
|
|
150
|
+
### Hooks (REST-oriented)
|
|
151
|
+
|
|
152
|
+
| Export | Description |
|
|
153
|
+
|--------|-------------|
|
|
154
|
+
| `useEntity` | Subscribe to one entity; fetch/normalize into graph; SWR + subscriber-aware refetch. |
|
|
155
|
+
| `useEntityList` | Subscribe to a list query key; stores IDs; merges row data from graph. |
|
|
156
|
+
| `useEntityMutation` | Mutate with optional optimistic updates and list invalidation hooks. |
|
|
157
|
+
| `useEntityAugment` | Patch UI-only fields merged at read time across all subscribers. |
|
|
158
|
+
| `useSuspenseEntity` | Suspense variant of `useEntity` (non-null `id` required). |
|
|
159
|
+
| `useSuspenseEntityList` | Suspense variant of `useEntityList`. |
|
|
160
|
+
|
|
161
|
+
### View
|
|
162
|
+
|
|
163
|
+
| Export | Description |
|
|
164
|
+
|--------|-------------|
|
|
165
|
+
| `useEntityView` | Filter/sort/search with `local` / `remote` / `hybrid` completeness modes. |
|
|
166
|
+
| `FilterSpec`, `SortSpec` | Transport-agnostic filter and sort AST types. |
|
|
167
|
+
| `toRestParams` | Compile view → REST query params. |
|
|
168
|
+
| `toSQLClauses` | Compile view → SQL-style WHERE / ORDER BY fragments. |
|
|
169
|
+
| `toGraphQLVariables` | Compile view → common GraphQL variable shapes. |
|
|
170
|
+
| `toPrismaWhere` / `toPrismaOrderBy` | Compile view → Prisma-style `where` / `orderBy` objects. |
|
|
171
|
+
| `applyView`, `compareEntities`, `matchesFilter`, `matchesSearch`, `checkCompleteness` | Local evaluation and completeness helpers. |
|
|
172
|
+
| `flattenClauses`, `hasCustomPredicates` | Filter introspection utilities. |
|
|
173
|
+
|
|
174
|
+
### CRUD
|
|
175
|
+
|
|
176
|
+
| Export | Description |
|
|
177
|
+
|--------|-------------|
|
|
178
|
+
| `useEntityCRUD` | Unified list + detail + edit + create flow, edit buffer, dirty tracking, optimistic helpers. |
|
|
179
|
+
| `registerSchema` | Register entity relations for cascade invalidation after mutations. |
|
|
180
|
+
| `getSchema`, `readRelations`, `cascadeInvalidation` | Introspection and imperative cascade invalidation. |
|
|
181
|
+
|
|
182
|
+
### Realtime
|
|
183
|
+
|
|
184
|
+
| Export | Description |
|
|
185
|
+
|--------|-------------|
|
|
186
|
+
| `RealtimeManager` | Registers adapters, coalesces changes (16 ms window), writes to graph. |
|
|
187
|
+
| `getRealtimeManager`, `resetRealtimeManager` | Singleton access and test resets. |
|
|
188
|
+
| `createWebSocketAdapter` | Generic WebSocket → graph changes. |
|
|
189
|
+
| `createSupabaseRealtimeAdapter` | Supabase Realtime payloads → graph. |
|
|
190
|
+
| `createConvexAdapter` | Convex-shaped streams → graph. |
|
|
191
|
+
| `createGraphQLSubscriptionAdapter` | GraphQL over WebSocket subscriptions → graph. |
|
|
192
|
+
|
|
193
|
+
### GraphQL
|
|
194
|
+
|
|
195
|
+
| Export | Description |
|
|
196
|
+
|--------|-------------|
|
|
197
|
+
| `createGQLClient` | Configure endpoint, fetcher, and entity descriptors for normalization. |
|
|
198
|
+
| `GQLClient` | Client class instance type. |
|
|
199
|
+
| `normalizeGQLResponse` / `executeGQL` | Normalize and execute with the same descriptor model. |
|
|
200
|
+
| `useGQLEntity`, `useGQLList` | Graph-backed entity and list hooks. |
|
|
201
|
+
| `useGQLMutation`, `useGQLSubscription` | GraphQL mutation and subscription hooks tied to the graph. |
|
|
202
|
+
|
|
203
|
+
### Prisma
|
|
204
|
+
|
|
205
|
+
| Export | Description |
|
|
206
|
+
|--------|-------------|
|
|
207
|
+
| `createPrismaEntityConfig` | Factory for REST endpoints that speak Prisma-style `where` / `orderBy` query params. |
|
|
208
|
+
| `prismaRelationsToSchema` | Convert Prisma-style relation map → `EntitySchema` for `registerSchema`. |
|
|
209
|
+
| `toPrismaInclude` | Build an `include` map from relation descriptors. |
|
|
210
|
+
|
|
211
|
+
### Local-first
|
|
212
|
+
|
|
213
|
+
| Export | Description |
|
|
214
|
+
|--------|-------------|
|
|
215
|
+
| `createElectricAdapter` | ElectricSQL / PGlite changes → graph. |
|
|
216
|
+
| `useLocalFirst` | Hook for local-first workflows with the adapter. |
|
|
217
|
+
| `usePGliteQuery` | Run queries against PGlite in sync with the graph story. |
|
|
218
|
+
|
|
219
|
+
### DevTools
|
|
220
|
+
|
|
221
|
+
| Export | Description |
|
|
222
|
+
|--------|-------------|
|
|
223
|
+
| `useGraphDevTools` | Hook for debugging graph shape and activity in development. |
|
|
224
|
+
|
|
225
|
+
### UI (optional)
|
|
226
|
+
|
|
227
|
+
| Export | Description |
|
|
228
|
+
|--------|-------------|
|
|
229
|
+
| `EntityTable`, `InlineCellEditor` | Table + inline cell editing wired to the graph / view layer. |
|
|
230
|
+
| `EntityDetailSheet`, `EntityFormSheet`, `Sheet` | CRUD-oriented sheet primitives. |
|
|
231
|
+
| `selectionColumn`, `textColumn`, `numberColumn`, `dateColumn`, `enumColumn`, `booleanColumn`, `actionsColumn`, `SortHeader` | Column helpers with filter metadata for tooling. |
|
|
232
|
+
|
|
233
|
+
### Types (high level)
|
|
234
|
+
|
|
235
|
+
`GraphState`, `EntityState`, `ListState`, `EntityType`, `EntityId`, `EngineOptions`, `EntityQueryOptions`, `ListQueryOptions`, `ViewDescriptor`, `EntitySchema`, `RelationDescriptor`, realtime adapter types, GraphQL types, CRUD types, column meta types — all exported from the package entry.
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Migration from TanStack Query
|
|
240
|
+
|
|
241
|
+
### Single record: `useQuery` → `useEntity`
|
|
242
|
+
|
|
243
|
+
**Before (TanStack Query)**
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
const { data, isLoading } = useQuery({
|
|
247
|
+
queryKey: ["post", id],
|
|
248
|
+
queryFn: () => fetch(`/api/posts/${id}`).then((r) => r.json()),
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**After (entity graph)**
|
|
253
|
+
|
|
254
|
+
```tsx
|
|
255
|
+
const { data, isLoading } = useEntity<Post, Post>({
|
|
256
|
+
type: "Post",
|
|
257
|
+
id,
|
|
258
|
+
fetch: (postId) => fetch(`/api/posts/${postId}`).then((r) => r.json()),
|
|
259
|
+
normalize: (raw) => raw,
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**Difference:** the graph key is `(type, id)`, not an opaque query key. Anything else that uses the same `type`/`id` shares that record—no `setQueryData` across keys.
|
|
264
|
+
|
|
265
|
+
### Lists: `useQuery` + key → `useEntityList`
|
|
266
|
+
|
|
267
|
+
**Before**
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
const { data } = useQuery({
|
|
271
|
+
queryKey: ["posts", { status }],
|
|
272
|
+
queryFn: () => api.posts.list({ status }),
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**After**
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
const { items, isLoading } = useEntityList<Post, Post>({
|
|
280
|
+
type: "Post",
|
|
281
|
+
queryKey: ["posts", { status }],
|
|
282
|
+
fetch: (p) => api.posts.list({ status, page: p.page, pageSize: p.pageSize, cursor: p.cursor }),
|
|
283
|
+
normalize: (row) => ({ id: row.id, data: row }),
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
**Difference:** the list stores **IDs**; row objects are always read through the normalized `Post` map, so updates propagate everywhere.
|
|
288
|
+
|
|
289
|
+
### Mutations: `useMutation` → `useEntityMutation`
|
|
290
|
+
|
|
291
|
+
**Before**
|
|
292
|
+
|
|
293
|
+
```tsx
|
|
294
|
+
const qc = useQueryClient();
|
|
295
|
+
const mutation = useMutation({
|
|
296
|
+
mutationFn: (id: string) => api.posts.archive(id),
|
|
297
|
+
onSuccess: () => {
|
|
298
|
+
qc.invalidateQueries({ queryKey: ["posts"] });
|
|
299
|
+
qc.invalidateQueries({ queryKey: ["post"] });
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**After**
|
|
305
|
+
|
|
306
|
+
```tsx
|
|
307
|
+
import { serializeKey, useEntityMutation } from "@prometheus-ags/prometheus-entity-management";
|
|
308
|
+
|
|
309
|
+
const { mutate } = useEntityMutation<string, Post, Post>({
|
|
310
|
+
type: "Post",
|
|
311
|
+
mutate: (id) => api.posts.archive(id),
|
|
312
|
+
normalize: (raw) => ({ id: raw.id, data: raw }),
|
|
313
|
+
optimistic: (id) => ({ id, patch: { status: "archived" } }),
|
|
314
|
+
invalidateLists: [serializeKey(["posts"])],
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**Difference:** optimistic updates target the **entity**; optional list invalidation is declarative. Cross-view consistency comes from normalization, not from remembering every query key.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Migration from Apollo Client
|
|
323
|
+
|
|
324
|
+
### Query: `useQuery` → `useGQLEntity` / `useGQLList`
|
|
325
|
+
|
|
326
|
+
**Before (Apollo)**
|
|
327
|
+
|
|
328
|
+
```tsx
|
|
329
|
+
const { data } = useQuery(GET_POST, { variables: { id } });
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**After**
|
|
333
|
+
|
|
334
|
+
```tsx
|
|
335
|
+
const { data } = useGQLEntity({
|
|
336
|
+
client: gqlClient,
|
|
337
|
+
document: GET_POST,
|
|
338
|
+
variables: {},
|
|
339
|
+
type: "Post",
|
|
340
|
+
id,
|
|
341
|
+
descriptor: postDescriptor,
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Use `useGQLList` with `document`, `queryKey`, `descriptor`, and `getItems` to map the response into rows.
|
|
346
|
+
|
|
347
|
+
**Difference:** you describe entities with **descriptors** (how to normalize IDs and nested types) once; you do not maintain a parallel universe of type policies and merge functions for every edge case.
|
|
348
|
+
|
|
349
|
+
### Mutation: `useMutation` → `useGQLMutation`
|
|
350
|
+
|
|
351
|
+
**Before**
|
|
352
|
+
|
|
353
|
+
```tsx
|
|
354
|
+
const [mutate] = useMutation(UPDATE_POST);
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
**After**
|
|
358
|
+
|
|
359
|
+
```tsx
|
|
360
|
+
const { mutate } = useGQLMutation({
|
|
361
|
+
client: gqlClient,
|
|
362
|
+
document: UPDATE_POST,
|
|
363
|
+
type: "Post",
|
|
364
|
+
descriptors: [postDescriptor],
|
|
365
|
+
});
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Descriptors tell the client how to write normalized entities from the mutation payload—no Apollo-style type policies.
|
|
369
|
+
|
|
370
|
+
### Subscriptions: `useSubscription` → `useGQLSubscription`
|
|
371
|
+
|
|
372
|
+
**Before**
|
|
373
|
+
|
|
374
|
+
```tsx
|
|
375
|
+
useSubscription(POST_UPDATED, { variables: { id } });
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
**After**
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
useGQLSubscription({
|
|
382
|
+
client: gqlClient,
|
|
383
|
+
wsClient: gqlWsClient,
|
|
384
|
+
document: POST_UPDATED_SUB,
|
|
385
|
+
variables: { id },
|
|
386
|
+
descriptors: [postDescriptor],
|
|
387
|
+
});
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
**Difference:** GraphQL, REST, and realtime adapters can all write the **same** entity graph, so mixed stacks do not need two caches.
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
## Prisma integration
|
|
395
|
+
|
|
396
|
+
`createPrismaEntityConfig` targets REST APIs that accept Prisma-style `where` and `orderBy` as JSON query parameters (typical for Prisma-backed route handlers).
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
import {
|
|
400
|
+
createPrismaEntityConfig,
|
|
401
|
+
registerSchema,
|
|
402
|
+
useEntity,
|
|
403
|
+
useEntityList,
|
|
404
|
+
} from "@prometheus-ags/prometheus-entity-management";
|
|
405
|
+
|
|
406
|
+
type Post = { id: string; title: string; authorId: string };
|
|
407
|
+
|
|
408
|
+
const Posts = createPrismaEntityConfig<Post>({
|
|
409
|
+
type: "Post",
|
|
410
|
+
endpoint: "/api/posts",
|
|
411
|
+
relations: {
|
|
412
|
+
author: { type: "User", foreignKey: "authorId", relation: "belongsTo" },
|
|
413
|
+
comments: { type: "Comment", foreignKey: "postId", relation: "hasMany" },
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Register cascade rules once (e.g. app init)
|
|
418
|
+
Posts.schemas().forEach(registerSchema);
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
```tsx
|
|
422
|
+
function PostDetail({ postId }: { postId: string }) {
|
|
423
|
+
const { data } = useEntity(Posts.entity(postId));
|
|
424
|
+
return data ? <h1>{data.title}</h1> : null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function PostList() {
|
|
428
|
+
const { items } = useEntityList(
|
|
429
|
+
Posts.list({
|
|
430
|
+
filter: [{ field: "status", op: "eq", value: "published" }],
|
|
431
|
+
sort: [{ field: "createdAt", direction: "desc" }],
|
|
432
|
+
})
|
|
433
|
+
);
|
|
434
|
+
return (
|
|
435
|
+
<ul>
|
|
436
|
+
{items.map((p) => (
|
|
437
|
+
<li key={p.id}>{p.title}</li>
|
|
438
|
+
))}
|
|
439
|
+
</ul>
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Use `Posts.crud()` with `useEntityCRUD` when you want the full list + detail + forms pipeline against the same endpoints.
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## Examples
|
|
449
|
+
|
|
450
|
+
| Example | Path | What it demonstrates |
|
|
451
|
+
|--------|------|---------------------|
|
|
452
|
+
| **Vite app** | [`examples/vite-app/`](examples/vite-app/) | Full CRUD, realtime adapters, **TanStack Query → graph bridge** (`/tanstack-bridge`), `EntityTable` / sheets, mock API with latency |
|
|
453
|
+
| **Next.js app** | [`examples/nextjs-app/`](examples/nextjs-app/) | Same feature set as the Vite example (Project/Task/User CRUD, realtime, engine settings, pure list view, TanStack Query → graph bridge). **SSR:** `GraphHydrationProvider` seeds the client graph from the shared demo data on first load |
|
|
454
|
+
|
|
455
|
+
From the repo root (this monorepo uses **pnpm**):
|
|
456
|
+
|
|
457
|
+
```bash
|
|
458
|
+
pnpm install
|
|
459
|
+
pnpm run dev:vite # http://localhost:5173
|
|
460
|
+
pnpm run dev:next # http://localhost:3000
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## Architecture
|
|
466
|
+
|
|
467
|
+
### Data flow rules
|
|
468
|
+
|
|
469
|
+
- **Components → Hooks → Stores → APIs / realtime** — UI uses hooks only; hooks orchestrate; network and adapters update the graph.
|
|
470
|
+
- **Up into the graph:** fetches, mutations, and realtime events call into the Zustand store.
|
|
471
|
+
- **Down from hooks:** `useEntity`, `useEntityList`, `useEntityView`, GraphQL hooks, and CRUD read merged `entities` + `patches`.
|
|
472
|
+
|
|
473
|
+
### Graph structures (`src/graph.ts`)
|
|
474
|
+
|
|
475
|
+
1. **`entities`** — Canonical server-shaped records per `(type, id)`.
|
|
476
|
+
2. **`patches`** — Local-only overlays (`_selected`, `_loading`, …) merged at read time.
|
|
477
|
+
3. **`lists`** — Ordered `ids[]`, pagination, and fetch flags — **not** duplicated row payloads.
|
|
478
|
+
|
|
479
|
+
### Engine (`src/engine.ts`)
|
|
480
|
+
|
|
481
|
+
In-flight deduplication, retries, subscriber ref-counting, stale-while-revalidate, optional periodic garbage collection for entities without subscribers.
|
|
482
|
+
|
|
483
|
+
### Realtime (`src/adapters/realtime-manager.ts`)
|
|
484
|
+
|
|
485
|
+
Adapters emit a shared change shape; the manager batches updates per animation frame to avoid UI thrash.
|
|
486
|
+
|
|
487
|
+
### View layer (`src/view/`)
|
|
488
|
+
|
|
489
|
+
One `FilterSpec` / `SortSpec` can compile to REST, SQL, GraphQL variables, or Prisma shapes, and can run locally when the graph already holds enough data.
|
|
490
|
+
|
|
491
|
+
### CRUD (`src/crud/`)
|
|
492
|
+
|
|
493
|
+
`useEntityCRUD` keeps the edit buffer in React state so other views stay on committed data until save; `registerSchema` drives relation-aware cascade invalidation.
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## Development (this repository)
|
|
498
|
+
|
|
499
|
+
```bash
|
|
500
|
+
pnpm install
|
|
501
|
+
|
|
502
|
+
# Examples
|
|
503
|
+
pnpm run dev:vite
|
|
504
|
+
pnpm run dev:next
|
|
505
|
+
|
|
506
|
+
# Typecheck
|
|
507
|
+
pnpm run typecheck
|
|
508
|
+
pnpm run typecheck:vite
|
|
509
|
+
pnpm run typecheck:next
|
|
510
|
+
|
|
511
|
+
# Production builds of examples
|
|
512
|
+
pnpm run build:vite
|
|
513
|
+
pnpm run build:next
|
|
514
|
+
|
|
515
|
+
# Clean artifacts
|
|
516
|
+
pnpm run clean
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
The library is consumed from source via path aliases in examples during development (no separate build step required for local hacking).
|
|
520
|
+
|
|
521
|
+
---
|
|
522
|
+
|
|
523
|
+
## License
|
|
524
|
+
|
|
525
|
+
MIT © Prometheus AGS / KnowMe LLC
|