@prisma-idb/sync-server 0.3.1 → 0.3.2
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/README.md +21 -197
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +1 -1
- package/dist/server.mjs.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,217 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@prisma-idb/sync-server`
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Never ships to the browser — this is server-only logic, built specifically so client bundles never carry authorization decisions (see ADR 014's rationale).
|
|
6
|
-
|
|
7
|
-
**Family-agnostic.** The DAG only ever walks `contract.domain` — model names, relations — which is identical in shape across every family (IDB, SQL, Mongo, whatever). The one thing that genuinely varies by family is how a model's primary key is stored (IDB: a flat `storage.keyPath` string; SQL: a possibly-compound `primaryKey.columns` array living on the table, not the model), so that's the one pluggable extension point — `createSyncServer`'s `getKeyField` option. The default resolver assumes IDB's shape (this package still ships under the `@prisma-idb/*` namespace, and IDB-shaped contracts are the zero-config path), but nothing in the core DAG/path-resolution logic imports an IDB type, and there's no dependency on `target-idb` outside the test suite.
|
|
8
|
-
|
|
9
|
-
## Installation
|
|
3
|
+
The server side of Prisma 8 IDB sync. From your schema's relations, it works out who owns each synced record, and tells you what to check before accepting a push or returning a pulled record. It never touches your database or HTTP framework, so it works with any of them.
|
|
10
4
|
|
|
11
5
|
```bash
|
|
12
|
-
|
|
6
|
+
npm install @prisma-idb/sync-server
|
|
13
7
|
```
|
|
14
8
|
|
|
15
|
-
## Quick Start
|
|
16
|
-
|
|
17
9
|
```ts
|
|
18
10
|
import { createSyncServer } from "@prisma-idb/sync-server";
|
|
19
|
-
import {
|
|
20
|
-
import { postgresContract } from "./prisma/schema.postgres.generated"; // the real server contract
|
|
21
|
-
import { clientContract } from "./prisma/contract"; // the browser's IDB contract
|
|
11
|
+
import { sqlGetKeyField } from "@prisma-idb/sync-server-sql";
|
|
22
12
|
|
|
23
13
|
const syncServer = createSyncServer({
|
|
24
|
-
contract:
|
|
25
|
-
clientContract, //
|
|
14
|
+
contract: serverContract, // the server's full contract
|
|
15
|
+
clientContract, // the browser's contract: only its models sync
|
|
26
16
|
rootModel: "User",
|
|
27
|
-
//
|
|
28
|
-
// storage.keyPath, which postgresContract doesn't have. See the API section
|
|
29
|
-
// below for the full resolver.
|
|
30
|
-
getKeyField: (contract, modelName) => {
|
|
31
|
-
const model = domainModelsAtDefaultNamespace(contract.domain)[modelName]!;
|
|
32
|
-
const table = (model.storage as { table: string; namespaceId: string }).table;
|
|
33
|
-
const ns = (model.storage as { namespaceId: string }).namespaceId;
|
|
34
|
-
const columns = contract.storage.namespaces[ns]!.entries.table[table]!.primaryKey.columns;
|
|
35
|
-
if (columns.length !== 1) throw new Error(`Compound keys aren't supported here (model "${modelName}")`);
|
|
36
|
-
return columns[0]!;
|
|
37
|
-
},
|
|
17
|
+
getKeyField: sqlGetKeyField, // for a SQL contract
|
|
38
18
|
});
|
|
39
19
|
|
|
40
|
-
|
|
41
|
-
const pushChecks = syncServer.validatePush(events, { scopeKey: currentUserId });
|
|
42
|
-
for (const { eventId, check } of pushChecks) {
|
|
43
|
-
if (check.kind === "unknown-model") throw new PermanentSyncError("INVALID_MODEL", eventId);
|
|
44
|
-
|
|
45
|
-
// Authorize, write, and record the changelog row in ONE transaction —
|
|
46
|
-
// ownership check last, immediately before the write it authorizes, not
|
|
47
|
-
// before the transaction opens. Otherwise the record's ownership chain
|
|
48
|
-
// (e.g. a Board.userId) could be reassigned in the gap between the check
|
|
49
|
-
// and the write it was meant to gate (ADR 014's transactional boundary).
|
|
50
|
-
await prisma.$transaction(async (tx) => {
|
|
51
|
-
if (check.kind === "root") {
|
|
52
|
-
if (!check.authorized) throw new PermanentSyncError("SCOPE_VIOLATION", eventId);
|
|
53
|
-
} else {
|
|
54
|
-
// check.kind === "scoped" — authorized via ANY ONE path resolving to scopeKey.
|
|
55
|
-
// Promise.all + some, not Promise.any: Promise.any resolves on the first
|
|
56
|
-
// *fulfilled* promise regardless of its value, so a path that legitimately
|
|
57
|
-
// resolves to `null` (not found) would short-circuit before a later path
|
|
58
|
-
// that does resolve — silently rejecting a valid ownership chain.
|
|
59
|
-
const pathResults = await Promise.all(
|
|
60
|
-
check.paths.map((path) => findFirstAlongPath(tx, /* model */ eventModel, check.key, path, check.scopeKey))
|
|
61
|
-
);
|
|
62
|
-
if (!pathResults.some(Boolean)) throw new PermanentSyncError("SCOPE_VIOLATION", eventId);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
await applyEvent(tx, eventModel, event); // the actual write
|
|
66
|
-
await tx.changelog.create({ data: { model: eventModel, scopeKey: currentUserId /* ... */ } });
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Pull: `logs` must already be pre-filtered to this caller (see "Pull is two
|
|
71
|
-
// steps" below) — buildPullQueries does the live re-check, not the initial fetch.
|
|
72
|
-
const scoped = syncServer.buildPullQueries(logs, { scopeKey: currentUserId });
|
|
20
|
+
const checks = syncServer.validatePush(events, { scopeKey: signedInUserId });
|
|
73
21
|
```
|
|
74
22
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
`sync-server` doesn't fetch changelog rows; the caller does. The expected shape mirrors the old generator's `pullAndMaterializeLogs`:
|
|
78
|
-
|
|
79
|
-
1. **Cheap pre-filter, on the caller's own changelog storage.** Stamp the resolved `scopeKey` onto each changelog row at push time (right after `validatePush` authorizes it — reuse the same `scopeKey` you passed in, don't recompute it), then query `WHERE scopeKey = ? AND id > lastChangelogId`. This is an index-friendly filter over the caller's own storage; `sync-server` has no opinion on it and never sees it.
|
|
80
|
-
2. **Live re-check, via `buildPullQueries`.** For every row step 1 returned, resolve its current `OwnershipCheck` and have the caller execute it (`findFirst` down `check.paths`, or the root-key comparison) _before_ returning the record. This is not redundant with step 1 — it re-derives ownership from the record's live relations, not from what was stamped at push time.
|
|
81
|
-
|
|
82
|
-
Step 2 exists because a stamped `scopeKey` is a snapshot, and ownership can move after it's taken. Example, kanban:
|
|
83
|
-
|
|
84
|
-
- Alice creates `Todo T1` under `Board B1`, which she owns. Push authorizes it (`T1 → board → owner === "alice"`), and the caller stamps `scopeKey: "alice"` on that changelog row.
|
|
85
|
-
- Later, `B1` is reassigned to Bob (`Board.update({ owner: "bob" })`).
|
|
86
|
-
- Alice pulls. Step 1's flat filter (`scopeKey: "alice"`) still returns `T1`'s changelog row — it was true when it was written. Without step 2, Alice's client would materialize `T1` as if she still owned it.
|
|
87
|
-
- Step 2 re-resolves live: `check.paths` (`[["board", "owner"]]`) now points at Bob, not Alice, so the caller's query for `T1` scoped to `"alice"` returns nothing — the caller treats that as "no longer accessible" instead of serving stale access.
|
|
88
|
-
|
|
89
|
-
`SyncPullLogEntry` deliberately has no `scopeKey` field — it isn't `sync-server`'s job to know how you filter your changelog, only to tell you, per row, what's still true right now.
|
|
90
|
-
|
|
91
|
-
**Known limitation:** this only converges correctly for domains where ownership never shifts away from an already-synced client (e.g. this repo's own MyFit — a user's data is never handed to another user). If a record's ownership chain is reassigned after a client has already synced it, and that client has no further changelog rows of its own pending for it, that client will never receive another row about it — its stale local copy just sits there, even while online. See ADR 014's "Known limitation" section for why, and the deferred live-filter alternative (Firestore/Zero-style) that would fix it for domains that need reassignment safety.
|
|
92
|
-
|
|
93
|
-
## Schema: no more hand-authored `Changelog` model
|
|
94
|
-
|
|
95
|
-
The old generator required a `Changelog` model + `ChangeOperation` enum hand-typed into your `schema.prisma`, and threw one of a dozen validation errors (wrong type, missing `@unique`, extra field, ...) if it drifted from the exact expected shape. `sync-server/schema` owns that shape instead.
|
|
96
|
-
|
|
97
|
-
`Changelog` has to live wherever your server's data actually lives — a real Postgres database, in a normal deployment (never IndexedDB: it's a browser-only storage engine, so an "IDB as the server too" path never made sense as a real target, and this package doesn't offer one). For Postgres, `@prisma-idb/sync-server/postgres`'s `defineConfig` takes the _same_ `schema.prisma` your IDB client config already parses and does the rest — strips `@idb.exclude`/`@@idb.exclude` (meaningless to a real server, and the SQL family's parser hard-errors on that unrecognized namespace otherwise), appends a SQL-flavored `Changelog` (real enum, real DB-generated `autoincrement()` id), and wires the SQL family/Postgres target/adapter/driver descriptors — all entirely in memory, no generated `.prisma` file lands on disk:
|
|
98
|
-
|
|
99
|
-
```ts
|
|
100
|
-
// prisma.config.postgres.ts
|
|
101
|
-
import { definePrismaConfig } from "@prisma/cli-engine";
|
|
102
|
-
import { defineConfig } from "@prisma-idb/sync-server/postgres";
|
|
103
|
-
|
|
104
|
-
export default definePrismaConfig({
|
|
105
|
-
orm: defineConfig({
|
|
106
|
-
schema: "src/lib/prisma/schema.prisma",
|
|
107
|
-
// Explicit: the default derives from the schema's own directory
|
|
108
|
-
// (src/lib/prisma/contract.json), which collides with the IDB side's
|
|
109
|
-
// own contract.json living in the same directory.
|
|
110
|
-
output: "src/lib/prisma/schema.postgres.generated.json",
|
|
111
|
-
db: { connection: process.env.DATABASE_URL },
|
|
112
|
-
}),
|
|
113
|
-
});
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
`createSyncServer`'s `contract` can then be this real Postgres contract directly (with a `getKeyField` override — see the API section above) — no IDB-shaped stand-in needed just to feed the DAG.
|
|
117
|
-
|
|
118
|
-
**Upgrading from a hand-authored `Changelog`:** delete the `Changelog` model and `ChangeOperation` enum from `schema.prisma` before switching to `sqlContractWithSync`/this facade. Both are appended for you — leaving your own declarations in place produces duplicate PSL declarations, which fail contract generation.
|
|
119
|
-
|
|
120
|
-
### Other targets, or composing your own config
|
|
121
|
-
|
|
122
|
-
`@prisma-idb/sync-server/postgres` is a thin wrapper around `sqlContractWithSync`, the lower-level, target-agnostic building block. Reach for it directly if you're on a different SQL target, need `extensions`, or otherwise need control the facade doesn't expose:
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
// prisma.config.postgres.ts — the manual wiring the facade above does for you
|
|
126
|
-
import { definePrismaConfig } from "@prisma/cli-engine";
|
|
127
|
-
import { defineConfig } from "@prisma/orm-framework/config/config-types";
|
|
128
|
-
import postgresAdapter from "@prisma/orm-postgres/adapter/control";
|
|
129
|
-
import postgresDriver from "@prisma/orm-postgres/driver/control";
|
|
130
|
-
import sql from "@prisma/orm-postgres/family/control";
|
|
131
|
-
import { PG_INT_CODEC_ID, PG_TEXT_CODEC_ID } from "@prisma/orm-postgres/target/codec-ids";
|
|
132
|
-
import postgres from "@prisma/orm-postgres/target/control";
|
|
133
|
-
import postgresPackRef from "@prisma/orm-postgres/target/pack";
|
|
134
|
-
import { postgresCreateNamespace } from "@prisma/orm-postgres/target/types";
|
|
135
|
-
import { sqlContractWithSync } from "@prisma-idb/sync-server/schema";
|
|
136
|
-
|
|
137
|
-
export default definePrismaConfig({
|
|
138
|
-
orm: defineConfig({
|
|
139
|
-
family: sql,
|
|
140
|
-
target: postgres,
|
|
141
|
-
adapter: postgresAdapter,
|
|
142
|
-
driver: postgresDriver,
|
|
143
|
-
contract: sqlContractWithSync("src/lib/prisma/schema.prisma", {
|
|
144
|
-
target: postgresPackRef,
|
|
145
|
-
createNamespace: postgresCreateNamespace,
|
|
146
|
-
enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID },
|
|
147
|
-
// Explicit: the default derives from the schema's own directory
|
|
148
|
-
// (src/lib/prisma/contract.json), which collides with the IDB side's
|
|
149
|
-
// own contract.json living in the same directory.
|
|
150
|
-
output: "src/lib/prisma/schema.postgres.generated.json",
|
|
151
|
-
}),
|
|
152
|
-
db: { connection: process.env.DATABASE_URL },
|
|
153
|
-
}),
|
|
154
|
-
});
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
There's no `injectSchemaText`-style hook on the SQL family's own schema loader to plug this into directly (`family-idb`'s `prismaIdbContract` has one; the SQL family's `prismaContract` doesn't — see [prisma/orm#30115](https://github.com/prisma/orm/issues/30115)), so `sqlContractWithSync` decomposes `prismaContract()` into its component parts and substitutes an in-memory `load()` instead. The one cost is that it needs the core `defineConfig` wired by hand, as above — a target's own convenience `defineConfig` (e.g. `@prisma/orm-postgres/config`) only accepts a schema _path_ for `contract`, since it builds its own internal `prismaContract(...)` call, so it can't take a `ContractConfig` directly. `@prisma-idb/sync-server/postgres` exists precisely to hide this wiring for the common Postgres case.
|
|
158
|
-
|
|
159
|
-
`prepareSqlSchemaWithSync` (pure text in, text out, no file I/O) and `injectChangelogModelSql` (just the `Changelog` append, no stripping) are also exported on their own, for building against a schema loader `sqlContractWithSync` doesn't target directly.
|
|
160
|
-
|
|
161
|
-
## Why "descriptions", not query results
|
|
162
|
-
|
|
163
|
-
`sync-server` doesn't know Prisma, SQL, or any specific storage engine — `contract.storage` shape aside, the actual `findFirst`/equivalent lookup is the caller's job. `check.paths` is a list of relation-name chains (e.g. `["todo", "board", "owner"]`); the caller is responsible for turning each chain into a real nested query (or however their storage layer expresses "traverse these relations, then compare the root's key to `scopeKey`") and interpreting a miss as unauthorized.
|
|
164
|
-
|
|
165
|
-
## API
|
|
166
|
-
|
|
167
|
-
### `createSyncServer({ contract, clientContract, rootModel, getKeyField? })`
|
|
168
|
-
|
|
169
|
-
Builds the ownership DAG once — throws immediately on a broken schema (a cycle, or a client-synced model with no relation chain back to `rootModel`) rather than per-request. `contract`/`clientContract` can be any family's `Contract` — a real Postgres/SQL contract works directly, no IDB-shaped intermediary needed. Returns a `SyncServer`:
|
|
170
|
-
|
|
171
|
-
- `validatePush(events, { scopeKey })` — resolves an `OwnershipCheck` per event.
|
|
172
|
-
- `buildPullQueries(logs, { scopeKey })` — resolves an `OwnershipCheck` per changelog row.
|
|
173
|
-
|
|
174
|
-
`getKeyField?: (contract, modelName) => string` resolves a model's primary-key field name. Defaults to `defaultGetKeyField`, which duck-types IDB's flat `storage.keyPath` — works for any IDB-shaped contract with zero config. For a family whose storage shape differs (SQL's key lives on the table's `primaryKey.columns`, potentially compound), pass your own:
|
|
175
|
-
|
|
176
|
-
```ts
|
|
177
|
-
import { createSyncServer } from "@prisma-idb/sync-server";
|
|
178
|
-
import { domainModelsAtDefaultNamespace } from "@prisma/orm-framework/contract/types";
|
|
179
|
-
|
|
180
|
-
const syncServer = createSyncServer({
|
|
181
|
-
contract: postgresContract, // the real server contract — no IDB intermediary
|
|
182
|
-
clientContract: idbClientContract, // still the browser's IDB contract
|
|
183
|
-
rootModel: "User",
|
|
184
|
-
getKeyField: (contract, modelName) => {
|
|
185
|
-
const model = domainModelsAtDefaultNamespace(contract.domain)[modelName]!;
|
|
186
|
-
const table = (model.storage as { table: string; namespaceId: string }).table;
|
|
187
|
-
const ns = (model.storage as { namespaceId: string }).namespaceId;
|
|
188
|
-
const columns = contract.storage.namespaces[ns]!.entries.table[table]!.primaryKey.columns;
|
|
189
|
-
if (columns.length !== 1) throw new Error(`Compound keys aren't supported here (model "${modelName}")`);
|
|
190
|
-
return columns[0]!;
|
|
191
|
-
},
|
|
192
|
-
});
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
### `OwnershipCheck`
|
|
196
|
-
|
|
197
|
-
| `kind` | Meaning |
|
|
198
|
-
| ----------------- | -------------------------------------------------------------------------------------------------------- |
|
|
199
|
-
| `"unknown-model"` | The model isn't in `clientContract` — a real client could never have produced this. Reject outright. |
|
|
200
|
-
| `"root"` | The record _is_ the root model — `authorized` is already computed (`key === scopeKey`), no query needed. |
|
|
201
|
-
| `"scoped"` | Non-root record — authorized if _any one_ of `paths` resolves to `scopeKey` when the caller queries it. |
|
|
23
|
+
For a SQL database, [`@prisma-idb/sync-server-sql`](https://www.npmjs.com/package/@prisma-idb/sync-server-sql) runs the checks and writes for you.
|
|
202
24
|
|
|
203
|
-
|
|
25
|
+
## Entry points
|
|
204
26
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
-
|
|
27
|
+
| Import | Contains |
|
|
28
|
+
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
29
|
+
| `@prisma-idb/sync-server` | `createSyncServer`, with `validatePush` and `buildPullQueries`. Also `buildOwnershipDag`, `resolveAuthorizationPaths` and `defaultGetKeyField`. |
|
|
30
|
+
| `@prisma-idb/sync-server/postgres` | `defineConfig`: a Postgres config that reads the shared schema, removes the `idb` attributes and adds the `Changelog` model. |
|
|
31
|
+
| `@prisma-idb/sync-server/schema` | `sqlContractWithSync` for other SQL targets, and the text transforms `prepareSqlSchemaWithSync` and `injectChangelogModelSql`. |
|
|
208
32
|
|
|
209
|
-
|
|
33
|
+
## Documentation
|
|
210
34
|
|
|
211
|
-
-
|
|
35
|
+
- [Sync tutorial](https://prisma-idb.dev/docs/prisma-8/sync)
|
|
36
|
+
- [Sync server reference](https://prisma-idb.dev/docs/prisma-8/sync/server)
|
|
37
|
+
- [Client Contracts](https://prisma-idb.dev/docs/prisma-8/sync/client-contracts)
|
|
212
38
|
|
|
213
|
-
|
|
39
|
+
## License
|
|
214
40
|
|
|
215
|
-
|
|
216
|
-
- `prepareSqlSchemaWithSync(schema)` — the pure text transform underneath (strip `@idb.exclude`/`@@idb.exclude` + append `Changelog`), no file I/O.
|
|
217
|
-
- `injectChangelogModelSql(schema)` — just the `Changelog` append, no stripping.
|
|
41
|
+
MIT
|
package/dist/server.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.mts","names":[],"sources":["../src/core/ownership-dag.ts","../src/core/authorization-paths.ts","../src/core/sync-server.ts"],"mappings":";;;;;;;;;;;KAYY,qBAAqB;;;;;;;UAQhB;WACN;WACA,OAAO,oBAAoB;;WAE3B,cAAc;;;;;;;;;;;;;;iBAeT,kBACd,UAAU,oBACV,gBAAgB,oBAChB,oBACC;;;;;;;;;;;;;;iBC7Ba,0BACd,UAAU,oBACV,mBACA;;;;UCXe;WACN;WACA;WACA;WACA,SAAS;;;;;;;;;;;;;UAcH;WACN;WACA;WACA;;;;;;;KAQC;WACG;;WAEA;WACA;WACA;WACA;;WAEA;;WAGA;WACA;WACA;WACA;WACA;;;;;;WAMA;;UAGE;WACN;WACA;WACA,OAAO;;UAGD;WACN;WACA;WACA,OAAO;;;;;;;;;;;KAYN,eAAe,UAAU,oBAAoB;;;;;;;;;cAU5C,oBAAoB;
|
|
1
|
+
{"version":3,"file":"server.d.mts","names":[],"sources":["../src/core/ownership-dag.ts","../src/core/authorization-paths.ts","../src/core/sync-server.ts"],"mappings":";;;;;;;;;;;KAYY,qBAAqB;;;;;;;UAQhB;WACN;WACA,OAAO,oBAAoB;;WAE3B,cAAc;;;;;;;;;;;;;;iBAeT,kBACd,UAAU,oBACV,gBAAgB,oBAChB,oBACC;;;;;;;;;;;;;;iBC7Ba,0BACd,UAAU,oBACV,mBACA;;;;UCXe;WACN;WACA;WACA;WACA,SAAS;;;;;;;;;;;;;UAcH;WACN;WACA;WACA;;;;;;;KAQC;WACG;;WAEA;WACA;WACA;WACA;;WAEA;;WAGA;WACA;WACA;WACA;WACA;;;;;;WAMA;;UAGE;WACN;WACA;WACA,OAAO;;UAGD;WACN;WACA;WACA,OAAO;;;;;;;;;;;KAYN,eAAe,UAAU,oBAAoB;;;;;;;;;cAU5C,oBAAoB;iBAmCjB,aACd,KAAK,cACL,UAAU,oBACV,aAAa,aACb,iBAAiB,iBACjB;WAAoB;aACV;iBAqBI,iBACd,KAAK,cACL,UAAU,oBACV,aAAa,aACb,eAAe,oBACf;WAAoB;aACV;UAaK;;WAEN,UAAU;;WAEV,gBAAgB;WAChB;;WAEA,cAAc;;UAGR;WACN;;EAET,aACE,iBAAiB,iBACjB;aAAoB;eACV;;EAEZ,iBACE,eAAe,oBACf;aAAoB;eACV;;;;;;iBAOE,iBAAiB,SAAS,0BAA0B"}
|
package/dist/server.mjs
CHANGED
|
@@ -104,7 +104,7 @@ function resolveAuthorizationPaths(contract, rootModel, modelName) {
|
|
|
104
104
|
*/
|
|
105
105
|
const defaultGetKeyField = (contract, modelName) => {
|
|
106
106
|
const keyPath = (domainModelsAtDefaultNamespace(contract.domain)[modelName]?.storage)?.keyPath;
|
|
107
|
-
if (typeof keyPath !== "string") throw new Error(`Model "${modelName}" has no string storage.keyPath in the contract. This contract's storage shape isn't IDB's — pass a getKeyField option to createSyncServer
|
|
107
|
+
if (typeof keyPath !== "string") throw new Error(`Model "${modelName}" has no single string storage.keyPath in the contract. ${Array.isArray(keyPath) ? "This model has a compound primary key (an array keyPath) — sync-server does not support compound-key models yet. Pass a getKeyField option to createSyncServer, or keep this model out of the sync scope." : "This contract's storage shape isn't IDB's — pass a getKeyField option to createSyncServer."}`);
|
|
108
108
|
return keyPath;
|
|
109
109
|
};
|
|
110
110
|
function buildOwnershipCheck(dag, contract, getKeyField, modelName, keyField, key, scopeKey) {
|
package/dist/server.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.mjs","names":[],"sources":["../src/core/ownership-dag.ts","../src/core/authorization-paths.ts","../src/core/sync-server.ts"],"sourcesContent":["import type { Contract } from \"@prisma/orm-framework/contract/types\";\nimport { domainModelsAtDefaultNamespace } from \"@prisma/orm-framework/contract/types\";\n\n/**\n * The contract shape `sync-server` operates on — any family's `Contract`\n * (see ADR 014's \"Genuinely family-agnostic\"). The DAG only ever walks\n * `contract.domain` (relations, model names), which is framework-level and\n * identical in shape across IDB, SQL, Mongo, or any future family — nothing\n * here reads `contract.storage` except through the injectable `getKeyField`\n * resolver in `sync-server.ts`, which is the one place storage shape\n * actually varies by family.\n */\nexport type SyncServerContract = Contract;\n\n/**\n * The ownership graph: `edges.get(model)` is every model reachable from\n * `model` via one of its own `N:1` relations (the models it could be\n * \"owned by\"). Built once, at `createSyncServer()` construction — never\n * per-request (ADR 014).\n */\nexport interface OwnershipDag {\n readonly rootModel: string;\n readonly edges: ReadonlyMap<string, ReadonlySet<string>>;\n /** Model names present in the client-projected contract (ADR 012). */\n readonly clientModels: ReadonlySet<string>;\n}\n\n/**\n * Builds the ownership DAG from the full server-side contract, walking\n * `model.relations` for every domain model. Throws if the graph has a\n * cycle, or if any *client* model (a model that survives ADR 012's\n * projection) has no chain of owning relations back to `rootModel`.\n *\n * Server-only models (present in `contract` but not `clientContract`) are\n * exempt from the reachability requirement — they can never appear in an\n * outbox event or a pull scope check, so there's nothing to authorize. They\n * still count as graph nodes, since a client model's authorization chain\n * may legitimately pass through one (see ADR 014's \"why the full graph\").\n */\nexport function buildOwnershipDag(\n contract: SyncServerContract,\n clientContract: SyncServerContract,\n rootModel: string\n): OwnershipDag {\n const models = domainModelsAtDefaultNamespace(contract.domain);\n const clientModels = new Set(Object.keys(domainModelsAtDefaultNamespace(clientContract.domain)));\n\n if (!(rootModel in models)) {\n throw new Error(`Root model \"${rootModel}\" is not present in the contract's domain models.`);\n }\n if (!clientModels.has(rootModel)) {\n throw new Error(`Root model \"${rootModel}\" is not present in the client contract's domain models.`);\n }\n\n const edges = new Map<string, Set<string>>();\n for (const modelName of Object.keys(models)) {\n edges.set(modelName, new Set());\n }\n for (const [modelName, model] of Object.entries(models)) {\n for (const relation of Object.values(model.relations)) {\n if (relation.cardinality === \"N:1\" && models[relation.to.model]) {\n edges.get(modelName)?.add(relation.to.model);\n }\n }\n }\n\n validateAcyclic(edges);\n validateClientReachability(edges, clientModels, rootModel);\n\n return { rootModel, edges, clientModels };\n}\n\nfunction validateAcyclic(edges: ReadonlyMap<string, ReadonlySet<string>>): void {\n const state = new Map<string, \"visiting\" | \"done\">();\n\n function visit(node: string): void {\n if (state.get(node) === \"done\") return;\n if (state.get(node) === \"visiting\") {\n throw new Error(`Cycle detected in the ownership graph involving model \"${node}\".`);\n }\n state.set(node, \"visiting\");\n for (const neighbor of edges.get(node) ?? []) {\n visit(neighbor);\n }\n state.set(node, \"done\");\n }\n\n for (const node of edges.keys()) {\n visit(node);\n }\n}\n\nfunction validateClientReachability(\n edges: ReadonlyMap<string, ReadonlySet<string>>,\n clientModels: ReadonlySet<string>,\n rootModel: string\n): void {\n const reverseEdges = new Map<string, Set<string>>();\n for (const modelName of edges.keys()) {\n reverseEdges.set(modelName, new Set());\n }\n for (const [modelName, targets] of edges) {\n for (const target of targets) {\n reverseEdges.get(target)?.add(modelName);\n }\n }\n\n const visited = new Set<string>([rootModel]);\n const stack = [rootModel];\n while (stack.length > 0) {\n const current = stack.pop();\n if (current === undefined) continue;\n for (const neighbor of reverseEdges.get(current) ?? []) {\n if (!visited.has(neighbor)) {\n visited.add(neighbor);\n stack.push(neighbor);\n }\n }\n }\n\n for (const modelName of clientModels) {\n if (!visited.has(modelName)) {\n throw new Error(\n `Model \"${modelName}\" has no path of owning relations back to root model \"${rootModel}\". ` +\n `Add a relation chain to \"${rootModel}\", or exclude the model from the client contract if it shouldn't sync.`\n );\n }\n }\n}\n","import { domainModelsAtDefaultNamespace } from \"@prisma/orm-framework/contract/types\";\nimport type { SyncServerContract } from \"./ownership-dag\";\n\n/**\n * Every structural path of `N:1` relation names from `modelName` back to\n * `rootModel` — not just the shortest. A record legitimately reachable\n * through relation A *or* relation B is authorized either way (ADR 014's\n * \"any one path, not shortest path\"), so callers must check all of them.\n *\n * Sorted shortest-first, then lexicographically by relation-name chain, for\n * deterministic output — matches the old generator's `buildAllAuthorizationPaths`\n * precedent, minus the \"prefer required\" tiebreak (this DAG doesn't\n * distinguish nullable from required FKs; see ADR 014's rationale).\n */\nexport function resolveAuthorizationPaths(\n contract: SyncServerContract,\n rootModel: string,\n modelName: string\n): readonly (readonly string[])[] {\n if (modelName === rootModel) return [];\n\n const models = domainModelsAtDefaultNamespace(contract.domain);\n const paths: string[][] = [];\n\n const dfs = (current: string, path: string[], visited: Set<string>): void => {\n const model = models[current];\n if (!model) return;\n\n for (const [relationName, relation] of Object.entries(model.relations)) {\n if (relation.cardinality !== \"N:1\") continue;\n const target = relation.to.model;\n if (!models[target] || visited.has(target)) continue;\n\n path.push(relationName);\n if (target === rootModel) {\n paths.push([...path]);\n } else {\n visited.add(target);\n dfs(target, path, visited);\n visited.delete(target);\n }\n path.pop();\n }\n };\n\n dfs(modelName, [], new Set([modelName]));\n\n return paths.sort((a, b) => a.length - b.length || a.join(\".\").localeCompare(b.join(\".\")));\n}\n","import { domainModelsAtDefaultNamespace } from \"@prisma/orm-framework/contract/types\";\nimport { resolveAuthorizationPaths } from \"./authorization-paths\";\nimport { buildOwnershipDag } from \"./ownership-dag\";\nimport type { OwnershipDag, SyncServerContract } from \"./ownership-dag\";\n\n/** An outbox event pending push, transport-agnostic (no browser types). */\nexport interface SyncPushEvent {\n readonly id: string;\n readonly model: string;\n readonly operation: \"create\" | \"update\" | \"delete\";\n readonly payload: Record<string, unknown>;\n}\n\n/**\n * A changelog row pending pull; `key` is the row's own primary-key value.\n *\n * Expected to already be pre-filtered to the caller — e.g. by a `scopeKey`\n * column the caller stamped on the changelog row at push time (reusing the\n * `scopeKey` that authorized it) and queried flatly (`WHERE scopeKey = ?`).\n * `buildPullQueries` doesn't repeat that filter; it re-derives ownership\n * live, since the stamped value is a push-time snapshot and can go stale if\n * the record's ownership chain changes afterward (e.g. a `Board` handed to\n * a different owner after a `Todo` under it was already pushed).\n */\nexport interface SyncPullLogEntry {\n readonly changelogId: string;\n readonly model: string;\n readonly key: unknown;\n}\n\n/**\n * What the caller must check to authorize a record. Never a query result —\n * `sync-server` never touches a database (ADR 014's transport-agnostic\n * boundary). The caller executes the check against whatever storage it uses.\n */\nexport type OwnershipCheck =\n | { readonly kind: \"unknown-model\" }\n | {\n readonly kind: \"root\";\n readonly keyField: string;\n readonly key: unknown;\n readonly scopeKey: string;\n /** The row *is* the root — no query needed, this is already decided. */\n readonly authorized: boolean;\n }\n | {\n readonly kind: \"scoped\";\n readonly keyField: string;\n readonly key: unknown;\n readonly rootKeyField: string;\n readonly scopeKey: string;\n /**\n * Every relation-name chain from this model to `rootModel`. The\n * caller is authorized via *any one* resolving to `scopeKey`\n * (ADR 014) — e.g. `findFirst` with an `OR` across paths.\n */\n readonly paths: readonly (readonly string[])[];\n };\n\nexport interface PushValidationResult {\n readonly eventId: string;\n readonly model: string;\n readonly check: OwnershipCheck;\n}\n\nexport interface PullScopeResult {\n readonly changelogId: string;\n readonly model: string;\n readonly check: OwnershipCheck;\n}\n\n/**\n * Resolves a model's primary-key field name from the contract. Storage\n * shape is the one thing that genuinely varies by family (IDB: a flat\n * `model.storage.keyPath` string; SQL: a possibly-compound\n * `contract.storage.namespaces[ns].entries.table[table].primaryKey.columns`\n * array; Mongo, whatever Mongo does) — so this is the sole extension point\n * `createSyncServer` exposes (`CreateSyncServerOptions.getKeyField`) rather\n * than something the DAG tries to introspect generically.\n */\nexport type GetKeyField = (contract: SyncServerContract, modelName: string) => string;\n\n/**\n * Default resolver: duck-types `model.storage.keyPath` as a string. This\n * happens to be exactly IDB's storage shape, but nothing here imports an\n * IDB type to know that — a family whose storage shape doesn't expose a\n * flat `keyPath` (SQL's compound-capable `primaryKey.columns`, for one)\n * simply won't match, and `createSyncServer` throws asking for an explicit\n * `getKeyField` instead of silently misresolving.\n */\nexport const defaultGetKeyField: GetKeyField = (contract, modelName) => {\n const model = domainModelsAtDefaultNamespace(contract.domain)[modelName];\n const keyPath = (model?.storage as { readonly keyPath?: unknown } | undefined)?.keyPath;\n if (typeof keyPath !== \"string\") {\n throw new Error(\n `Model \"${modelName}\" has no string storage.keyPath in the contract. ` +\n `This contract's storage shape isn't IDB's — pass a getKeyField option to createSyncServer.`\n );\n }\n return keyPath;\n};\n\nfunction buildOwnershipCheck(\n dag: OwnershipDag,\n contract: SyncServerContract,\n getKeyField: GetKeyField,\n modelName: string,\n keyField: string,\n key: unknown,\n scopeKey: string\n): OwnershipCheck {\n if (modelName === dag.rootModel) {\n return { kind: \"root\", keyField, key, scopeKey, authorized: key === scopeKey };\n }\n return {\n kind: \"scoped\",\n keyField,\n key,\n rootKeyField: getKeyField(contract, dag.rootModel),\n scopeKey,\n paths: resolveAuthorizationPaths(contract, dag.rootModel, modelName),\n };\n}\n\nexport function validatePush(\n dag: OwnershipDag,\n contract: SyncServerContract,\n getKeyField: GetKeyField,\n events: readonly SyncPushEvent[],\n options: { readonly scopeKey: string }\n): readonly PushValidationResult[] {\n const models = domainModelsAtDefaultNamespace(contract.domain);\n\n return events.map((event) => {\n if (!dag.clientModels.has(event.model) || !models[event.model]) {\n return { eventId: event.id, model: event.model, check: { kind: \"unknown-model\" } };\n }\n const keyField = getKeyField(contract, event.model);\n const check = buildOwnershipCheck(\n dag,\n contract,\n getKeyField,\n event.model,\n keyField,\n event.payload[keyField],\n options.scopeKey\n );\n return { eventId: event.id, model: event.model, check };\n });\n}\n\nexport function buildPullQueries(\n dag: OwnershipDag,\n contract: SyncServerContract,\n getKeyField: GetKeyField,\n logs: readonly SyncPullLogEntry[],\n options: { readonly scopeKey: string }\n): readonly PullScopeResult[] {\n const models = domainModelsAtDefaultNamespace(contract.domain);\n\n return logs.map((log) => {\n if (!dag.clientModels.has(log.model) || !models[log.model]) {\n return { changelogId: log.changelogId, model: log.model, check: { kind: \"unknown-model\" } };\n }\n const keyField = getKeyField(contract, log.model);\n const check = buildOwnershipCheck(dag, contract, getKeyField, log.model, keyField, log.key, options.scopeKey);\n return { changelogId: log.changelogId, model: log.model, check };\n });\n}\n\nexport interface CreateSyncServerOptions {\n /** The full server-side contract (ADR 012) — includes client-excluded models. Any family. */\n readonly contract: SyncServerContract;\n /** The client-projected contract (ADR 012) — defines which models are ever synced. Any family. */\n readonly clientContract: SyncServerContract;\n readonly rootModel: string;\n /** @default defaultGetKeyField (IDB-shaped storage.keyPath) */\n readonly getKeyField?: GetKeyField;\n}\n\nexport interface SyncServer {\n readonly rootModel: string;\n /** Resolve every event's ownership check. Pure — never touches a database. */\n validatePush(\n events: readonly SyncPushEvent[],\n options: { readonly scopeKey: string }\n ): readonly PushValidationResult[];\n /** Resolve every changelog row's pull-scope check. Pure — never touches a database. */\n buildPullQueries(\n logs: readonly SyncPullLogEntry[],\n options: { readonly scopeKey: string }\n ): readonly PullScopeResult[];\n}\n\n/**\n * Builds the ownership DAG once (throws on a broken schema — see\n * `buildOwnershipDag`) and returns a `SyncServer` bound to it.\n */\nexport function createSyncServer(options: CreateSyncServerOptions): SyncServer {\n const dag = buildOwnershipDag(options.contract, options.clientContract, options.rootModel);\n const getKeyField = options.getKeyField ?? defaultGetKeyField;\n\n return {\n rootModel: dag.rootModel,\n validatePush: (events, pushOptions) => validatePush(dag, options.contract, getKeyField, events, pushOptions),\n buildPullQueries: (logs, pullOptions) => buildPullQueries(dag, options.contract, getKeyField, logs, pullOptions),\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"server.mjs","names":[],"sources":["../src/core/ownership-dag.ts","../src/core/authorization-paths.ts","../src/core/sync-server.ts"],"sourcesContent":["import type { Contract } from \"@prisma/orm-framework/contract/types\";\nimport { domainModelsAtDefaultNamespace } from \"@prisma/orm-framework/contract/types\";\n\n/**\n * The contract shape `sync-server` operates on — any family's `Contract`\n * (see ADR 014's \"Genuinely family-agnostic\"). The DAG only ever walks\n * `contract.domain` (relations, model names), which is framework-level and\n * identical in shape across IDB, SQL, Mongo, or any future family — nothing\n * here reads `contract.storage` except through the injectable `getKeyField`\n * resolver in `sync-server.ts`, which is the one place storage shape\n * actually varies by family.\n */\nexport type SyncServerContract = Contract;\n\n/**\n * The ownership graph: `edges.get(model)` is every model reachable from\n * `model` via one of its own `N:1` relations (the models it could be\n * \"owned by\"). Built once, at `createSyncServer()` construction — never\n * per-request (ADR 014).\n */\nexport interface OwnershipDag {\n readonly rootModel: string;\n readonly edges: ReadonlyMap<string, ReadonlySet<string>>;\n /** Model names present in the client-projected contract (ADR 012). */\n readonly clientModels: ReadonlySet<string>;\n}\n\n/**\n * Builds the ownership DAG from the full server-side contract, walking\n * `model.relations` for every domain model. Throws if the graph has a\n * cycle, or if any *client* model (a model that survives ADR 012's\n * projection) has no chain of owning relations back to `rootModel`.\n *\n * Server-only models (present in `contract` but not `clientContract`) are\n * exempt from the reachability requirement — they can never appear in an\n * outbox event or a pull scope check, so there's nothing to authorize. They\n * still count as graph nodes, since a client model's authorization chain\n * may legitimately pass through one (see ADR 014's \"why the full graph\").\n */\nexport function buildOwnershipDag(\n contract: SyncServerContract,\n clientContract: SyncServerContract,\n rootModel: string\n): OwnershipDag {\n const models = domainModelsAtDefaultNamespace(contract.domain);\n const clientModels = new Set(Object.keys(domainModelsAtDefaultNamespace(clientContract.domain)));\n\n if (!(rootModel in models)) {\n throw new Error(`Root model \"${rootModel}\" is not present in the contract's domain models.`);\n }\n if (!clientModels.has(rootModel)) {\n throw new Error(`Root model \"${rootModel}\" is not present in the client contract's domain models.`);\n }\n\n const edges = new Map<string, Set<string>>();\n for (const modelName of Object.keys(models)) {\n edges.set(modelName, new Set());\n }\n for (const [modelName, model] of Object.entries(models)) {\n for (const relation of Object.values(model.relations)) {\n if (relation.cardinality === \"N:1\" && models[relation.to.model]) {\n edges.get(modelName)?.add(relation.to.model);\n }\n }\n }\n\n validateAcyclic(edges);\n validateClientReachability(edges, clientModels, rootModel);\n\n return { rootModel, edges, clientModels };\n}\n\nfunction validateAcyclic(edges: ReadonlyMap<string, ReadonlySet<string>>): void {\n const state = new Map<string, \"visiting\" | \"done\">();\n\n function visit(node: string): void {\n if (state.get(node) === \"done\") return;\n if (state.get(node) === \"visiting\") {\n throw new Error(`Cycle detected in the ownership graph involving model \"${node}\".`);\n }\n state.set(node, \"visiting\");\n for (const neighbor of edges.get(node) ?? []) {\n visit(neighbor);\n }\n state.set(node, \"done\");\n }\n\n for (const node of edges.keys()) {\n visit(node);\n }\n}\n\nfunction validateClientReachability(\n edges: ReadonlyMap<string, ReadonlySet<string>>,\n clientModels: ReadonlySet<string>,\n rootModel: string\n): void {\n const reverseEdges = new Map<string, Set<string>>();\n for (const modelName of edges.keys()) {\n reverseEdges.set(modelName, new Set());\n }\n for (const [modelName, targets] of edges) {\n for (const target of targets) {\n reverseEdges.get(target)?.add(modelName);\n }\n }\n\n const visited = new Set<string>([rootModel]);\n const stack = [rootModel];\n while (stack.length > 0) {\n const current = stack.pop();\n if (current === undefined) continue;\n for (const neighbor of reverseEdges.get(current) ?? []) {\n if (!visited.has(neighbor)) {\n visited.add(neighbor);\n stack.push(neighbor);\n }\n }\n }\n\n for (const modelName of clientModels) {\n if (!visited.has(modelName)) {\n throw new Error(\n `Model \"${modelName}\" has no path of owning relations back to root model \"${rootModel}\". ` +\n `Add a relation chain to \"${rootModel}\", or exclude the model from the client contract if it shouldn't sync.`\n );\n }\n }\n}\n","import { domainModelsAtDefaultNamespace } from \"@prisma/orm-framework/contract/types\";\nimport type { SyncServerContract } from \"./ownership-dag\";\n\n/**\n * Every structural path of `N:1` relation names from `modelName` back to\n * `rootModel` — not just the shortest. A record legitimately reachable\n * through relation A *or* relation B is authorized either way (ADR 014's\n * \"any one path, not shortest path\"), so callers must check all of them.\n *\n * Sorted shortest-first, then lexicographically by relation-name chain, for\n * deterministic output — matches the old generator's `buildAllAuthorizationPaths`\n * precedent, minus the \"prefer required\" tiebreak (this DAG doesn't\n * distinguish nullable from required FKs; see ADR 014's rationale).\n */\nexport function resolveAuthorizationPaths(\n contract: SyncServerContract,\n rootModel: string,\n modelName: string\n): readonly (readonly string[])[] {\n if (modelName === rootModel) return [];\n\n const models = domainModelsAtDefaultNamespace(contract.domain);\n const paths: string[][] = [];\n\n const dfs = (current: string, path: string[], visited: Set<string>): void => {\n const model = models[current];\n if (!model) return;\n\n for (const [relationName, relation] of Object.entries(model.relations)) {\n if (relation.cardinality !== \"N:1\") continue;\n const target = relation.to.model;\n if (!models[target] || visited.has(target)) continue;\n\n path.push(relationName);\n if (target === rootModel) {\n paths.push([...path]);\n } else {\n visited.add(target);\n dfs(target, path, visited);\n visited.delete(target);\n }\n path.pop();\n }\n };\n\n dfs(modelName, [], new Set([modelName]));\n\n return paths.sort((a, b) => a.length - b.length || a.join(\".\").localeCompare(b.join(\".\")));\n}\n","import { domainModelsAtDefaultNamespace } from \"@prisma/orm-framework/contract/types\";\nimport { resolveAuthorizationPaths } from \"./authorization-paths\";\nimport { buildOwnershipDag } from \"./ownership-dag\";\nimport type { OwnershipDag, SyncServerContract } from \"./ownership-dag\";\n\n/** An outbox event pending push, transport-agnostic (no browser types). */\nexport interface SyncPushEvent {\n readonly id: string;\n readonly model: string;\n readonly operation: \"create\" | \"update\" | \"delete\";\n readonly payload: Record<string, unknown>;\n}\n\n/**\n * A changelog row pending pull; `key` is the row's own primary-key value.\n *\n * Expected to already be pre-filtered to the caller — e.g. by a `scopeKey`\n * column the caller stamped on the changelog row at push time (reusing the\n * `scopeKey` that authorized it) and queried flatly (`WHERE scopeKey = ?`).\n * `buildPullQueries` doesn't repeat that filter; it re-derives ownership\n * live, since the stamped value is a push-time snapshot and can go stale if\n * the record's ownership chain changes afterward (e.g. a `Board` handed to\n * a different owner after a `Todo` under it was already pushed).\n */\nexport interface SyncPullLogEntry {\n readonly changelogId: string;\n readonly model: string;\n readonly key: unknown;\n}\n\n/**\n * What the caller must check to authorize a record. Never a query result —\n * `sync-server` never touches a database (ADR 014's transport-agnostic\n * boundary). The caller executes the check against whatever storage it uses.\n */\nexport type OwnershipCheck =\n | { readonly kind: \"unknown-model\" }\n | {\n readonly kind: \"root\";\n readonly keyField: string;\n readonly key: unknown;\n readonly scopeKey: string;\n /** The row *is* the root — no query needed, this is already decided. */\n readonly authorized: boolean;\n }\n | {\n readonly kind: \"scoped\";\n readonly keyField: string;\n readonly key: unknown;\n readonly rootKeyField: string;\n readonly scopeKey: string;\n /**\n * Every relation-name chain from this model to `rootModel`. The\n * caller is authorized via *any one* resolving to `scopeKey`\n * (ADR 014) — e.g. `findFirst` with an `OR` across paths.\n */\n readonly paths: readonly (readonly string[])[];\n };\n\nexport interface PushValidationResult {\n readonly eventId: string;\n readonly model: string;\n readonly check: OwnershipCheck;\n}\n\nexport interface PullScopeResult {\n readonly changelogId: string;\n readonly model: string;\n readonly check: OwnershipCheck;\n}\n\n/**\n * Resolves a model's primary-key field name from the contract. Storage\n * shape is the one thing that genuinely varies by family (IDB: a flat\n * `model.storage.keyPath` string; SQL: a possibly-compound\n * `contract.storage.namespaces[ns].entries.table[table].primaryKey.columns`\n * array; Mongo, whatever Mongo does) — so this is the sole extension point\n * `createSyncServer` exposes (`CreateSyncServerOptions.getKeyField`) rather\n * than something the DAG tries to introspect generically.\n */\nexport type GetKeyField = (contract: SyncServerContract, modelName: string) => string;\n\n/**\n * Default resolver: duck-types `model.storage.keyPath` as a string. This\n * happens to be exactly IDB's storage shape, but nothing here imports an\n * IDB type to know that — a family whose storage shape doesn't expose a\n * flat `keyPath` (SQL's compound-capable `primaryKey.columns`, for one)\n * simply won't match, and `createSyncServer` throws asking for an explicit\n * `getKeyField` instead of silently misresolving.\n */\nexport const defaultGetKeyField: GetKeyField = (contract, modelName) => {\n const model = domainModelsAtDefaultNamespace(contract.domain)[modelName];\n const keyPath = (model?.storage as { readonly keyPath?: unknown } | undefined)?.keyPath;\n if (typeof keyPath !== \"string\") {\n const hint = Array.isArray(keyPath)\n ? \"This model has a compound primary key (an array keyPath) — sync-server does not support compound-key \" +\n \"models yet. Pass a getKeyField option to createSyncServer, or keep this model out of the sync scope.\"\n : \"This contract's storage shape isn't IDB's — pass a getKeyField option to createSyncServer.\";\n throw new Error(`Model \"${modelName}\" has no single string storage.keyPath in the contract. ${hint}`);\n }\n return keyPath;\n};\n\nfunction buildOwnershipCheck(\n dag: OwnershipDag,\n contract: SyncServerContract,\n getKeyField: GetKeyField,\n modelName: string,\n keyField: string,\n key: unknown,\n scopeKey: string\n): OwnershipCheck {\n if (modelName === dag.rootModel) {\n return { kind: \"root\", keyField, key, scopeKey, authorized: key === scopeKey };\n }\n return {\n kind: \"scoped\",\n keyField,\n key,\n rootKeyField: getKeyField(contract, dag.rootModel),\n scopeKey,\n paths: resolveAuthorizationPaths(contract, dag.rootModel, modelName),\n };\n}\n\nexport function validatePush(\n dag: OwnershipDag,\n contract: SyncServerContract,\n getKeyField: GetKeyField,\n events: readonly SyncPushEvent[],\n options: { readonly scopeKey: string }\n): readonly PushValidationResult[] {\n const models = domainModelsAtDefaultNamespace(contract.domain);\n\n return events.map((event) => {\n if (!dag.clientModels.has(event.model) || !models[event.model]) {\n return { eventId: event.id, model: event.model, check: { kind: \"unknown-model\" } };\n }\n const keyField = getKeyField(contract, event.model);\n const check = buildOwnershipCheck(\n dag,\n contract,\n getKeyField,\n event.model,\n keyField,\n event.payload[keyField],\n options.scopeKey\n );\n return { eventId: event.id, model: event.model, check };\n });\n}\n\nexport function buildPullQueries(\n dag: OwnershipDag,\n contract: SyncServerContract,\n getKeyField: GetKeyField,\n logs: readonly SyncPullLogEntry[],\n options: { readonly scopeKey: string }\n): readonly PullScopeResult[] {\n const models = domainModelsAtDefaultNamespace(contract.domain);\n\n return logs.map((log) => {\n if (!dag.clientModels.has(log.model) || !models[log.model]) {\n return { changelogId: log.changelogId, model: log.model, check: { kind: \"unknown-model\" } };\n }\n const keyField = getKeyField(contract, log.model);\n const check = buildOwnershipCheck(dag, contract, getKeyField, log.model, keyField, log.key, options.scopeKey);\n return { changelogId: log.changelogId, model: log.model, check };\n });\n}\n\nexport interface CreateSyncServerOptions {\n /** The full server-side contract (ADR 012) — includes client-excluded models. Any family. */\n readonly contract: SyncServerContract;\n /** The client-projected contract (ADR 012) — defines which models are ever synced. Any family. */\n readonly clientContract: SyncServerContract;\n readonly rootModel: string;\n /** @default defaultGetKeyField (IDB-shaped storage.keyPath) */\n readonly getKeyField?: GetKeyField;\n}\n\nexport interface SyncServer {\n readonly rootModel: string;\n /** Resolve every event's ownership check. Pure — never touches a database. */\n validatePush(\n events: readonly SyncPushEvent[],\n options: { readonly scopeKey: string }\n ): readonly PushValidationResult[];\n /** Resolve every changelog row's pull-scope check. Pure — never touches a database. */\n buildPullQueries(\n logs: readonly SyncPullLogEntry[],\n options: { readonly scopeKey: string }\n ): readonly PullScopeResult[];\n}\n\n/**\n * Builds the ownership DAG once (throws on a broken schema — see\n * `buildOwnershipDag`) and returns a `SyncServer` bound to it.\n */\nexport function createSyncServer(options: CreateSyncServerOptions): SyncServer {\n const dag = buildOwnershipDag(options.contract, options.clientContract, options.rootModel);\n const getKeyField = options.getKeyField ?? defaultGetKeyField;\n\n return {\n rootModel: dag.rootModel,\n validatePush: (events, pushOptions) => validatePush(dag, options.contract, getKeyField, events, pushOptions),\n buildPullQueries: (logs, pullOptions) => buildPullQueries(dag, options.contract, getKeyField, logs, pullOptions),\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prisma-idb/sync-server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Prisma 8 IDB — sync ownership DAG (server-side push authorization + pull scoping, ADR 014)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -41,13 +41,13 @@
|
|
|
41
41
|
"./package.json": "./package.json"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@prisma-idb/family-idb": "0.
|
|
44
|
+
"@prisma-idb/family-idb": "0.7.0",
|
|
45
45
|
"@prisma/orm-family-sql": "8.0.0-rc.5",
|
|
46
46
|
"@prisma/orm-framework": "8.0.0-rc.5",
|
|
47
47
|
"@prisma/orm-postgres": "8.0.0-rc.5"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@prisma-idb/target-idb": "0.
|
|
50
|
+
"@prisma-idb/target-idb": "0.7.0",
|
|
51
51
|
"@types/node": "^24.13.3",
|
|
52
52
|
"tsdown": "^0.22.14",
|
|
53
53
|
"typescript": "^6.0.3",
|