@prisma-idb/sync-server 0.7.2 → 0.8.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/README.md CHANGED
@@ -1,11 +1,17 @@
1
1
  # `@prisma-idb/sync-server`
2
2
 
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.
3
+ This package describes authorization checks for Prisma 8 IDB sync. It follows your schema's relations to decide who may push or pull each record. Your adapter runs the checks against your database.
4
+
5
+ ## Set up the server
6
+
7
+ 1. Install the package.
4
8
 
5
9
  ```bash
6
10
  npm install @prisma-idb/sync-server
7
11
  ```
8
12
 
13
+ 2. Create the server from your contracts. Take `scopeKey` from the authenticated session.
14
+
9
15
  ```ts
10
16
  import { createSyncServer } from "@prisma-idb/sync-server";
11
17
  import { sqlGetKeyField } from "@prisma-idb/sync-server-sql";
@@ -26,25 +32,83 @@ for (const { check } of checks) {
26
32
  // Reject with check.error: RECORD_VALIDATION_FAILURE or KEYPATH_VALIDATION_FAILURE.
27
33
  continue;
28
34
  }
29
- // Execute the ownership check using your database.
35
+ // In one transaction, authorize the current row and the proposed candidate.
36
+ // For create/update, also execute check.parentReferences before writing.
30
37
  }
31
38
  ```
32
39
 
33
- Records validate against the client projection and reject extra fields and undeclared enum values. Creates require all client-visible non-nullable fields; updates validate supplied fields, and deletes validate only keys. Validation failures occur before ownership paths are resolved. The server builds validators for every client-visible model at construction and throws with the model and codec ID if a validator is unavailable. Server-only models and fields are excluded from this check. Key validation and ownership checks use the full server contract. The server must fill required server-only fields before insertion; omitting them can still fail at the database boundary.
40
+ 3. Run the returned checks in your adapter before writing the record and changelog entry. Use one transaction for all three operations.
41
+
42
+ For SQL databases, [`@prisma-idb/sync-server-sql`](https://www.npmjs.com/package/@prisma-idb/sync-server-sql) runs these checks and writes for you.
43
+
44
+ ## Contract identity
45
+
46
+ `syncServer.clientStorageHash` is `clientContract.storage.storageHash`. Load the same emitted client `contract.json` that the browser uses, including any extension stores it contains. IDB storage hashes cover stores, indexes, and stored record shapes. Do not compare clients against the SQL server contract's hash.
47
+
48
+ The SQL adapter checks this value before reading or writing. See its [contract check](../sync-server-sql/README.md#contract-check).
49
+
50
+ ## Validation rules
51
+
52
+ - Records must match the client contract. Extra fields and undeclared enum values fail validation.
53
+ - Creates require every client-visible, non-nullable field.
54
+ - Updates validate supplied fields. Deletes validate only keys.
55
+ - Validation runs before the server resolves ownership paths.
56
+ - The server builds validators at construction. Missing validators cause an error that names the model and codec ID.
57
+ - Record validation excludes server-only models and fields. Key validation and authorization use the full server contract.
58
+ - The server must supply required server-only fields before insertion. Otherwise, the database can reject the write.
59
+
60
+ ## Tenant parent rules
61
+
62
+ Creates and updates require every populated tenant parent to belong to the caller's scope.
63
+
64
+ | Term | Meaning |
65
+ | ------------- | ---------------------------------------------------------------------------------------- |
66
+ | Scope | The caller's root record, such as Alice's `User` row. `scopeKey` identifies this record. |
67
+ | Tenant parent | A row referenced by an outgoing foreign key whose relation has a path to the root model. |
68
+ | Candidate row | The proposed row: the create payload, or the stored row merged with an update patch. |
69
+
70
+ - Existing-row access, pull and delete require at least one path to the caller's scope.
71
+ - A candidate row must have at least one path to that scope. Detaching every path fails.
72
+ - `parentReferences` groups paths by their first relation. Each tenant parent needs at least one matching path within its group.
73
+ - Null optional tenant parents need no check. A populated foreign key that references a missing tenant parent fails.
74
+ - Updates check the complete candidate row, including foreign keys omitted from the patch.
75
+ - Checks include server-only tenant parents. They exclude inverse collections and global parents with no path to the root.
76
+ - Checked relations must use a single-field foreign key to the tenant parent's primary key.
77
+ - `resolveParentReferenceChecks(contract, getKeyField, model, check.paths)` derives parent checks when an older scoped check omits `parentReferences`.
78
+
79
+ ### Example: Alice's FoodEntry
80
+
81
+ Alice creates a `FoodEntry` with `userId: "alice"` and `mealId` pointing to Bob's `Meal`.
82
+ The candidate row fails the tenant parent check, even though its direct `User` path reaches Alice.
83
+ The same rule applies if `recipeId` points to Bob's `Recipe`.
84
+
85
+ ### Historical rows
86
+
87
+ - Historical rows with tenant parents in different scopes remain readable or deletable through any matching path.
88
+ - An update that retains a tenant parent outside the caller's scope fails.
89
+ - A complete repair to the caller's scope may pass. No single relation determines who may repair the row.
90
+ - These checks do not clean up historical data.
91
+ - Non-retryable rejections reconcile under ordinary pull authorization. See [ADR 022](../docs/adrs/ADR%20022%20-%20Rejected%20Pushes%20Reconcile%20to%20the%20Server%20Row.md).
92
+
93
+ ### Upgrade impact
94
+
95
+ - Upgrading the server and adapter packages tightens create and update acceptance by default.
96
+ - No relation policy configuration is required.
97
+ - Adapters must run the tenant parent checks before writing the record and changelog entry, within the same transaction.
98
+
99
+ ## Codec validation
34
100
 
35
101
  The default SQL lookup validates Date codecs (including legacy `pg/date@1`, `pg/timestamp@1`, `pg/timestamptz@1` and `sql/timestamp@1`), string date/time codecs, primitive scalars, JSON, bytes and text arrays against their application values. `pg/*-temporal@1` values are Temporal objects, and `pg/interval@1` values are `{ months, days, micros }` objects; neither has a default validator. Choose Date/string representations for synced date/time fields. Historical codec validation does not restore codecs removed from the installed SQL runtime: re-emit old contracts before using them with rc.12.
36
102
 
37
103
  Other families can extend the exported `defaultValidationCodecs` with a `codecLookup` that returns supported application names: `string`, `string[]`, `number`, `boolean`, `bigint`, `Date`, `Uint8Array` or `unknown`. Names such as `Temporal.Instant` are rejected at construction. `unknown` deliberately skips scalar type checking and requires the caller to validate that codec's value before pushing; it is not a Temporal or interval validator.
38
104
 
39
- 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.
40
-
41
105
  ## Entry points
42
106
 
43
- | Import | Contains |
44
- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
45
- | `@prisma-idb/sync-server` | `createSyncServer`, with `validatePush` and `buildPullQueries`. Also `buildOwnershipDag`, `resolveAuthorizationPaths` and `defaultGetKeyField`. |
46
- | `@prisma-idb/sync-server/postgres` | `defineConfig`: a Postgres config that reads the shared schema, removes the `idb` attributes and adds the `Changelog` model. |
47
- | `@prisma-idb/sync-server/schema` | `sqlContractWithSync` for other SQL targets, and the text transforms `prepareSqlSchemaWithSync` and `injectChangelogModelSql`. |
107
+ | Import | Contains |
108
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
109
+ | `@prisma-idb/sync-server` | `createSyncServer`, with `validatePush`, `buildPullQueries` and `projectRecord`. Also `buildOwnershipDag`, `resolveAuthorizationPaths`, `resolveParentReferenceChecks` and `defaultGetKeyField`. |
110
+ | `@prisma-idb/sync-server/postgres` | `defineConfig`: a Postgres config that reads the shared schema, removes the `idb` attributes and adds the `Changelog` model. |
111
+ | `@prisma-idb/sync-server/schema` | `sqlContractWithSync` for other SQL targets, and the text transforms `prepareSqlSchemaWithSync` and `injectChangelogModelSql`. |
48
112
 
49
113
  ## Documentation
50
114
 
@@ -1,5 +1,6 @@
1
1
  import { PrismaNextConfig } from "@prisma/orm-framework/config/config-types";
2
2
  //#region src/exports/postgres.d.ts
3
+ /** Configures a shared PSL schema, emitted server contract, and Postgres connection. */
3
4
  interface PostgresSyncConfigOptions {
4
5
  /** Path to the shared `schema.prisma` — the same file the browser/IDB config parses. */
5
6
  readonly schema: string;
@@ -1 +1 @@
1
- {"version":3,"file":"postgres.d.mts","names":[],"sources":["../src/exports/postgres.ts"],"mappings":";;UAYiB;;WAEN;;;;;;WAMA;WACA;aAAgB;;WAChB;aAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAkDnB,aAAa,SAAS,4BAA4B"}
1
+ {"version":3,"file":"postgres.d.mts","names":[],"sources":["../src/exports/postgres.ts"],"mappings":";;;UAaiB;;WAEN;;;;;;WAMA;WACA;aAAgB;;WAChB;aAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAkDnB,aAAa,SAAS,4BAA4B"}
package/dist/postgres.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as sqlContractWithSync } from "./sql-contract-CPS2Bh0c.mjs";
1
+ import { t as sqlContractWithSync } from "./sql-contract-DC7961z-.mjs";
2
2
  import { defineConfig as defineConfig$1 } from "@prisma/orm-framework/config/config-types";
3
3
  import { ifDefined } from "@prisma/orm-framework/utils/defined";
4
4
  import postgresAdapter from "@prisma/orm-postgres/adapter/control";
@@ -26,10 +26,7 @@ const syncPostgresAdapter = {
26
26
  DateTime: {
27
27
  kind: "typeConstructor",
28
28
  documentation: "An instant stored as PostgreSQL timestamptz and represented as a JavaScript Date.",
29
- output: {
30
- codecId: PG_TIMESTAMPTZ_DATE_CODEC_ID,
31
- nativeType: "timestamptz"
32
- }
29
+ output: { codecId: PG_TIMESTAMPTZ_DATE_CODEC_ID }
33
30
  }
34
31
  }
35
32
  }
@@ -1 +1 @@
1
- {"version":3,"file":"postgres.mjs","names":["coreDefineConfig"],"sources":["../src/exports/postgres.ts"],"sourcesContent":["import type { PrismaNextConfig } from \"@prisma/orm-framework/config/config-types\";\nimport { defineConfig as coreDefineConfig } from \"@prisma/orm-framework/config/config-types\";\nimport { ifDefined } from \"@prisma/orm-framework/utils/defined\";\nimport postgresAdapter from \"@prisma/orm-postgres/adapter/control\";\nimport { PG_INT_CODEC_ID, PG_TEXT_CODEC_ID, PG_TIMESTAMPTZ_DATE_CODEC_ID } from \"@prisma/orm-postgres/target/codec-ids\";\nimport postgresDriver from \"@prisma/orm-postgres/driver/control\";\nimport sql from \"@prisma/orm-postgres/family/control\";\nimport postgres from \"@prisma/orm-postgres/target/control\";\nimport postgresPackRef from \"@prisma/orm-postgres/target/pack\";\nimport { postgresCreateNamespace } from \"@prisma/orm-postgres/target/types\";\nimport { sqlContractWithSync } from \"../core/sql-contract\";\n\nexport interface PostgresSyncConfigOptions {\n /** Path to the shared `schema.prisma` — the same file the browser/IDB config parses. */\n readonly schema: string;\n /**\n * Defaults to deriving from `schema`'s own directory (e.g.\n * `src/lib/prisma/contract.json`). Override this when that collides with\n * an IDB-family `contract.json` living alongside it.\n */\n readonly output?: string;\n readonly db?: { readonly connection?: string };\n readonly migrations?: { readonly dir?: string };\n}\n\n/**\n * `@prisma/orm-postgres`'s adapter with PSL `DateTime` bound to the JS-`Date`\n * codec. From rc.12 the stock adapter binds it to `pg/timestamptz-temporal@1`,\n * which throws on decode unless the runtime has a global `Temporal` (Node 24\n * LTS does not) and hands back `Temporal.Instant`s where every IDB client\n * reads and writes `Date`s. Same `timestamptz` column either way, so this\n * changes the JS representation only, not the database schema.\n */\nconst syncPostgresAdapter: typeof postgresAdapter = {\n ...postgresAdapter,\n authoring: {\n ...postgresAdapter.authoring,\n type: {\n ...postgresAdapter.authoring?.type,\n DateTime: {\n kind: \"typeConstructor\",\n documentation: \"An instant stored as PostgreSQL timestamptz and represented as a JavaScript Date.\",\n output: { codecId: PG_TIMESTAMPTZ_DATE_CODEC_ID, nativeType: \"timestamptz\" },\n },\n },\n },\n};\n\n/**\n * The Postgres-target config for a sync server: wires `@prisma/orm-postgres`'s\n * family/target/adapter/driver descriptors and {@link sqlContractWithSync}\n * through the core `defineConfig` (`@prisma/orm-framework/config/config-types`)\n * for you. That wiring can't go through `@prisma/orm-postgres/config`'s own\n * `defineConfig` — its `contract` option only accepts a schema path and\n * builds its own internal `prismaContract(...)` call, so it can't take\n * `sqlContractWithSync`'s `ContractConfig` return value.\n *\n * @example\n * ```ts\n * import { definePrismaConfig } from \"@prisma/cli-engine\";\n * import { defineConfig } from \"@prisma-idb/sync-server/postgres\";\n *\n * export default definePrismaConfig({\n * orm: defineConfig({\n * schema: \"src/lib/prisma/schema.prisma\",\n * output: \"src/lib/prisma/schema.postgres.generated.json\",\n * db: { connection: process.env.DATABASE_URL },\n * migrations: { dir: \"migrations-postgres\" },\n * }),\n * });\n * ```\n */\nexport function defineConfig(options: PostgresSyncConfigOptions): PrismaNextConfig<\"sql\", \"postgres\"> {\n return coreDefineConfig({\n family: sql,\n target: postgres,\n adapter: syncPostgresAdapter,\n driver: postgresDriver,\n contract: sqlContractWithSync(options.schema, {\n target: postgresPackRef,\n createNamespace: postgresCreateNamespace,\n enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID },\n ...ifDefined(\"output\", options.output),\n }),\n ...ifDefined(\"db\", options.db),\n ...ifDefined(\"migrations\", options.migrations),\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAiCA,MAAM,sBAA8C;CAClD,GAAG;CACH,WAAW;EACT,GAAG,gBAAgB;EACnB,MAAM;GACJ,GAAG,gBAAgB,WAAW;GAC9B,UAAU;IACR,MAAM;IACN,eAAe;IACf,QAAQ;KAAE,SAAS;KAA8B,YAAY;IAAc;GAC7E;EACF;CACF;AACF;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,SAAgB,aAAa,SAAyE;CACpG,OAAOA,eAAiB;EACtB,QAAQ;EACR,QAAQ;EACR,SAAS;EACT,QAAQ;EACR,UAAU,oBAAoB,QAAQ,QAAQ;GAC5C,QAAQ;GACR,iBAAiB;GACjB,qBAAqB;IAAE,MAAM;IAAkB,KAAK;GAAgB;GACpE,GAAG,UAAU,UAAU,QAAQ,MAAM;EACvC,CAAC;EACD,GAAG,UAAU,MAAM,QAAQ,EAAE;EAC7B,GAAG,UAAU,cAAc,QAAQ,UAAU;CAC/C,CAAC;AACH"}
1
+ {"version":3,"file":"postgres.mjs","names":["coreDefineConfig"],"sources":["../src/exports/postgres.ts"],"sourcesContent":["import type { PrismaNextConfig } from \"@prisma/orm-framework/config/config-types\";\nimport { defineConfig as coreDefineConfig } from \"@prisma/orm-framework/config/config-types\";\nimport { ifDefined } from \"@prisma/orm-framework/utils/defined\";\nimport postgresAdapter from \"@prisma/orm-postgres/adapter/control\";\nimport { PG_INT_CODEC_ID, PG_TEXT_CODEC_ID, PG_TIMESTAMPTZ_DATE_CODEC_ID } from \"@prisma/orm-postgres/target/codec-ids\";\nimport postgresDriver from \"@prisma/orm-postgres/driver/control\";\nimport sql from \"@prisma/orm-postgres/family/control\";\nimport postgres from \"@prisma/orm-postgres/target/control\";\nimport postgresPackRef from \"@prisma/orm-postgres/target/pack\";\nimport { postgresCreateNamespace } from \"@prisma/orm-postgres/target/types\";\nimport { sqlContractWithSync } from \"../core/sql-contract\";\n\n/** Configures a shared PSL schema, emitted server contract, and Postgres connection. */\nexport interface PostgresSyncConfigOptions {\n /** Path to the shared `schema.prisma` — the same file the browser/IDB config parses. */\n readonly schema: string;\n /**\n * Defaults to deriving from `schema`'s own directory (e.g.\n * `src/lib/prisma/contract.json`). Override this when that collides with\n * an IDB-family `contract.json` living alongside it.\n */\n readonly output?: string;\n readonly db?: { readonly connection?: string };\n readonly migrations?: { readonly dir?: string };\n}\n\n/**\n * `@prisma/orm-postgres`'s adapter with PSL `DateTime` bound to the JS-`Date`\n * codec. From rc.12 the stock adapter binds it to `pg/timestamptz-temporal@1`,\n * which throws on decode unless the runtime has a global `Temporal` (Node 24\n * LTS does not) and hands back `Temporal.Instant`s where every IDB client\n * reads and writes `Date`s. Same `timestamptz` column either way, so this\n * changes the JS representation only, not the database schema.\n */\nconst syncPostgresAdapter: typeof postgresAdapter = {\n ...postgresAdapter,\n authoring: {\n ...postgresAdapter.authoring,\n type: {\n ...postgresAdapter.authoring?.type,\n DateTime: {\n kind: \"typeConstructor\",\n documentation: \"An instant stored as PostgreSQL timestamptz and represented as a JavaScript Date.\",\n output: { codecId: PG_TIMESTAMPTZ_DATE_CODEC_ID },\n },\n },\n },\n};\n\n/**\n * The Postgres-target config for a sync server: wires `@prisma/orm-postgres`'s\n * family/target/adapter/driver descriptors and {@link sqlContractWithSync}\n * through the core `defineConfig` (`@prisma/orm-framework/config/config-types`)\n * for you. That wiring can't go through `@prisma/orm-postgres/config`'s own\n * `defineConfig` — its `contract` option only accepts a schema path and\n * builds its own internal `prismaContract(...)` call, so it can't take\n * `sqlContractWithSync`'s `ContractConfig` return value.\n *\n * @example\n * ```ts\n * import { definePrismaConfig } from \"@prisma/cli-engine\";\n * import { defineConfig } from \"@prisma-idb/sync-server/postgres\";\n *\n * export default definePrismaConfig({\n * orm: defineConfig({\n * schema: \"src/lib/prisma/schema.prisma\",\n * output: \"src/lib/prisma/schema.postgres.generated.json\",\n * db: { connection: process.env.DATABASE_URL },\n * migrations: { dir: \"migrations-postgres\" },\n * }),\n * });\n * ```\n */\nexport function defineConfig(options: PostgresSyncConfigOptions): PrismaNextConfig<\"sql\", \"postgres\"> {\n return coreDefineConfig({\n family: sql,\n target: postgres,\n adapter: syncPostgresAdapter,\n driver: postgresDriver,\n contract: sqlContractWithSync(options.schema, {\n target: postgresPackRef,\n createNamespace: postgresCreateNamespace,\n enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID },\n ...ifDefined(\"output\", options.output),\n }),\n ...ifDefined(\"db\", options.db),\n ...ifDefined(\"migrations\", options.migrations),\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAkCA,MAAM,sBAA8C;CAClD,GAAG;CACH,WAAW;EACT,GAAG,gBAAgB;EACnB,MAAM;GACJ,GAAG,gBAAgB,WAAW;GAC9B,UAAU;IACR,MAAM;IACN,eAAe;IACf,QAAQ,EAAE,SAAS,6BAA6B;GAClD;EACF;CACF;AACF;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,SAAgB,aAAa,SAAyE;CACpG,OAAOA,eAAiB;EACtB,QAAQ;EACR,QAAQ;EACR,SAAS;EACT,QAAQ;EACR,UAAU,oBAAoB,QAAQ,QAAQ;GAC5C,QAAQ;GACR,iBAAiB;GACjB,qBAAqB;IAAE,MAAM;IAAkB,KAAK;GAAgB;GACpE,GAAG,UAAU,UAAU,QAAQ,MAAM;EACvC,CAAC;EACD,GAAG,UAAU,MAAM,QAAQ,EAAE;EAC7B,GAAG,UAAU,cAAc,QAAQ,UAAU;CAC/C,CAAC;AACH"}
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.mts","names":[],"sources":["../src/core/changelog-schema.ts","../src/core/sql-contract.ts"],"mappings":";;;;iBAgEgB,wBAAwB;;;;;;;;;;iBAaxB,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBC1CzB,oBAAoB,oBAAoB,SAAS,wBAAwB"}
1
+ {"version":3,"file":"schema.d.mts","names":[],"sources":["../src/core/changelog-schema.ts","../src/core/sql-contract.ts"],"mappings":";;;;iBAgCgB,wBAAwB;;;;;;;;;;iBAaxB,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBCVzB,oBAAoB,oBAAoB,SAAS,wBAAwB"}
package/dist/schema.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as injectChangelogModelSql, r as prepareSqlSchemaWithSync, t as sqlContractWithSync } from "./sql-contract-CPS2Bh0c.mjs";
1
+ import { n as injectChangelogModelSql, r as prepareSqlSchemaWithSync, t as sqlContractWithSync } from "./sql-contract-DC7961z-.mjs";
2
2
  export { injectChangelogModelSql, prepareSqlSchemaWithSync, sqlContractWithSync };
package/dist/server.d.mts CHANGED
@@ -37,20 +37,6 @@ interface OwnershipDag {
37
37
  */
38
38
  declare function buildOwnershipDag(contract: SyncServerContract, clientContract: SyncServerContract, rootModel: string): OwnershipDag;
39
39
  //#endregion
40
- //#region src/core/authorization-paths.d.ts
41
- /**
42
- * Every structural path of `N:1` relation names from `modelName` back to
43
- * `rootModel` — not just the shortest. A record legitimately reachable
44
- * through relation A *or* relation B is authorized either way (ADR 014's
45
- * "any one path, not shortest path"), so callers must check all of them.
46
- *
47
- * Sorted shortest-first, then lexicographically by relation-name chain, for
48
- * deterministic output — matches the old generator's `buildAllAuthorizationPaths`
49
- * precedent, minus the "prefer required" tiebreak (this DAG doesn't
50
- * distinguish nullable from required FKs; see ADR 014's rationale).
51
- */
52
- declare function resolveAuthorizationPaths(contract: SyncServerContract, rootModel: string, modelName: string): readonly (readonly string[])[];
53
- //#endregion
54
40
  //#region src/core/sync-server.d.ts
55
41
  /** An outbox event pending push, transport-agnostic (no browser types). */
56
42
  interface SyncPushEvent {
@@ -110,16 +96,26 @@ type OwnershipCheck = {
110
96
  */
111
97
  readonly paths: readonly (readonly string[])[];
112
98
  };
113
- type PushCheck = OwnershipCheck | {
99
+ /** An ownership check with candidate-parent integrity requirements, or a validation refusal. */
100
+ type PushCheck = Exclude<OwnershipCheck, {
101
+ kind: "scoped";
102
+ }> | (Extract<OwnershipCheck, {
103
+ kind: "scoped";
104
+ }> & {
105
+ /** Additional candidate integrity checks for create/update. Adapters must derive these when absent. */
106
+ readonly parentReferences?: readonly ParentReferenceCheck[];
107
+ }) | {
114
108
  readonly kind: "validation-failure";
115
109
  readonly error: "RECORD_VALIDATION_FAILURE" | "KEYPATH_VALIDATION_FAILURE";
116
110
  readonly issues: readonly string[];
117
111
  };
112
+ /** Associates an event with its validation and ownership check. */
118
113
  interface PushValidationResult {
119
114
  readonly eventId: string;
120
115
  readonly model: string;
121
116
  readonly check: PushCheck;
122
117
  }
118
+ /** Associates a changelog row with the ownership check its reader must execute. */
123
119
  interface PullScopeResult {
124
120
  readonly changelogId: string;
125
121
  readonly model: string;
@@ -144,13 +140,16 @@ type GetKeyField = (contract: SyncServerContract, modelName: string) => string;
144
140
  * `getKeyField` instead of silently misresolving.
145
141
  */
146
142
  declare const defaultGetKeyField: GetKeyField;
143
+ /** Validates native payloads and keys before deriving ownership and candidate-parent checks. */
147
144
  declare function validatePush(dag: OwnershipDag, contract: SyncServerContract, clientContract: SyncServerContract, getKeyField: GetKeyField, events: readonly SyncPushEvent[], options: {
148
145
  readonly scopeKey: string;
149
146
  readonly codecLookup?: ValidationCodecLookup;
150
147
  }): readonly PushValidationResult[];
148
+ /** Builds live ownership checks for the supplied changelog rows. */
151
149
  declare function buildPullQueries(dag: OwnershipDag, contract: SyncServerContract, getKeyField: GetKeyField, logs: readonly SyncPullLogEntry[], options: {
152
150
  readonly scopeKey: string;
153
151
  }): readonly PullScopeResult[];
152
+ /** Binds full and projected contracts to an authenticated ownership root. */
154
153
  interface CreateSyncServerOptions {
155
154
  /** The full server-side contract (ADR 012) — includes client-excluded models. Any family. */
156
155
  readonly contract: SyncServerContract;
@@ -162,8 +161,13 @@ interface CreateSyncServerOptions {
162
161
  /** Native application types; unsupported validators throw at construction. */
163
162
  readonly codecLookup?: ValidationCodecLookup;
164
163
  }
164
+ /** Pure validation, ownership checks, and projection bound to one client contract. */
165
165
  interface SyncServer {
166
166
  readonly rootModel: string;
167
+ /** The storage hash of the emitted client contract, including extension stores (ADR 015). */
168
+ readonly clientStorageHash: string;
169
+ /** Copies only top-level client-contract fields. Throws for an unknown client model. */
170
+ projectRecord(model: string, row: Readonly<Record<string, unknown>>): Record<string, unknown>;
167
171
  /** Resolve every event's ownership check. Pure — never touches a database. */
168
172
  validatePush(events: readonly SyncPushEvent[], options: {
169
173
  readonly scopeKey: string;
@@ -179,8 +183,36 @@ interface SyncServer {
179
183
  */
180
184
  declare function createSyncServer(options: CreateSyncServerOptions): SyncServer;
181
185
  //#endregion
186
+ //#region src/core/authorization-paths.d.ts
187
+ /**
188
+ * Every structural path of `N:1` relation names from `modelName` back to
189
+ * `rootModel` — not just the shortest. A record legitimately reachable
190
+ * through relation A *or* relation B is authorized either way (ADR 014's
191
+ * "any one path, not shortest path"), so callers must check all of them.
192
+ *
193
+ * Sorted shortest-first, then lexicographically by relation-name chain, for
194
+ * deterministic output — matches the old generator's `buildAllAuthorizationPaths`
195
+ * precedent, minus the "prefer required" tiebreak (this DAG doesn't
196
+ * distinguish nullable from required FKs; see ADR 014's rationale).
197
+ */
198
+ declare function resolveAuthorizationPaths(contract: SyncServerContract, rootModel: string, modelName: string): readonly (readonly string[])[];
199
+ /** One outgoing tenant parent, with alternate routes to the caller's root. */
200
+ interface ParentReferenceCheck {
201
+ readonly relation: string;
202
+ readonly localField: string;
203
+ readonly nullable: boolean;
204
+ readonly paths: readonly (readonly string[])[];
205
+ }
206
+ /**
207
+ * Groups root paths by their first parent. Every populated group must pass;
208
+ * alternate routes within a group retain any-path authorization.
209
+ * @throws {Error} If a checked path is not a single-field FK-to-key join.
210
+ */
211
+ declare function resolveParentReferenceChecks(contract: SyncServerContract, getKeyField: GetKeyField, modelName: string, paths: readonly (readonly string[])[]): readonly ParentReferenceCheck[];
212
+ //#endregion
182
213
  //#region src/core/validation-codecs.d.ts
214
+ /** Resolves native application types for the IDB and SQL codecs supported by sync validation. */
183
215
  declare const defaultValidationCodecs: ValidationCodecLookup;
184
216
  //#endregion
185
- export { type CreateSyncServerOptions, type GetKeyField, type OwnershipCheck, type OwnershipDag, type PullScopeResult, type PushCheck, type PushValidationResult, type SyncPullLogEntry, type SyncPushEvent, type SyncServer, type SyncServerContract, buildOwnershipDag, buildPullQueries, createSyncServer, defaultGetKeyField, defaultValidationCodecs, resolveAuthorizationPaths, validatePush };
217
+ export { type CreateSyncServerOptions, type GetKeyField, type OwnershipCheck, type OwnershipDag, type ParentReferenceCheck, type PullScopeResult, type PushCheck, type PushValidationResult, type SyncPullLogEntry, type SyncPushEvent, type SyncServer, type SyncServerContract, buildOwnershipDag, buildPullQueries, createSyncServer, defaultGetKeyField, defaultValidationCodecs, resolveAuthorizationPaths, resolveParentReferenceChecks, validatePush };
186
218
  //# sourceMappingURL=server.d.mts.map
@@ -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","../src/core/validation-codecs.ts"],"mappings":";;;;;;;;;;;;KAYY,qBAAqB;;;;;;;UAQhB;WACN;WACA,OAAO,oBAAoB;;WAE3B,cAAc;;;;;;;;;;;;;;iBAeT,kBACd,UAAU,oBACV,gBAAgB,oBAChB,oBACC;;;;;;;;;;;;;;iBC7Ba,0BACd,UAAU,oBACV,mBACA;;;;UCRe;WACN;WACA;WACA;;WAEA,SAAS;;;;;;;WAOT;;;;;;;;;;;;;UAcM;WACN;WACA;WACA;;;;;;;KAQC;WACG;;WAEA;WACA;WACA;WACA;;WAEA;;WAGA;WACA;WACA;WACA;WACA;;;;;;WAMA;;KAGH,YACR;WAEW;WACA;WACA;;UAGE;WACN;WACA;WACA,OAAO;;UAGD;WACN;WACA;WACA,OAAO;;;;;;;;;;;KAYN,eAAe,UAAU,oBAAoB;;;;;;;;;cAU5C,oBAAoB;iBAmCjB,aACd,KAAK,cACL,UAAU,oBACV,gBAAgB,oBAChB,aAAa,aACb,iBAAiB,iBACjB;WAAoB;WAA2B,cAAc;aACnD;iBAqCI,iBACd,KAAK,cACL,UAAU,oBACV,aAAa,aACb,eAAe,oBACf;WAAoB;aACV;UAaK;;WAEN,UAAU;;WAEV,gBAAgB;WAChB;;WAEA,cAAc;;WAEd,cAAc;;UAGR;WACN;;EAET,aACE,iBAAiB,iBACjB;aAAoB;eACV;;EAEZ,iBACE,eAAe,oBACf;aAAoB;eACV;;;;;;iBAOE,iBAAiB,SAAS,0BAA0B;;;cC/LvD,yBAAyB"}
1
+ {"version":3,"file":"server.d.mts","names":[],"sources":["../src/core/ownership-dag.ts","../src/core/sync-server.ts","../src/core/authorization-paths.ts","../src/core/validation-codecs.ts"],"mappings":";;;;;;;;;;;;KAYY,qBAAqB;;;;;;;UAQhB;WACN;WACA,OAAO,oBAAoB;;WAE3B,cAAc;;;;;;;;;;;;;;iBAeT,kBACd,UAAU,oBACV,gBAAgB,oBAChB,oBACC;;;;UCjCc;WACN;WACA;WACA;;WAEA,SAAS;;;;;;;WAOT;;;;;;;;;;;;;UAcM;WACN;WACA;WACA;;;;;;;KAQC;WACG;;WAEA;WACA;WACA;WACA;;WAEA;;WAGA;WACA;WACA;WACA;WACA;;;;;;WAMA;;;KAIH,YACR,QAAQ;EAAkB;MACzB,QAAQ;EAAkB;;;WAEhB,4BAA4B;;WAG5B;WACA;WACA;;;UAIE;WACN;WACA;WACA,OAAO;;;UAID;WACN;WACA;WACA,OAAO;;;;;;;;;;;KAYN,eAAe,UAAU,oBAAoB;;;;;;;;;cAU5C,oBAAoB;;iBAqCjB,aACd,KAAK,cACL,UAAU,oBACV,gBAAgB,oBAChB,aAAa,aACb,iBAAiB,iBACjB;WAAoB;WAA2B,cAAc;aACnD;;iBAgDI,iBACd,KAAK,cACL,UAAU,oBACV,aAAa,aACb,eAAe,oBACf;WAAoB;aACV;;UAcK;;WAEN,UAAU;;WAEV,gBAAgB;WAChB;;WAEA,cAAc;;WAEd,cAAc;;;UAIR;WACN;;WAEA;;EAET,cAAc,eAAe,KAAK,SAAS,2BAA2B;;EAEtE,aACE,iBAAiB,iBACjB;aAAoB;eACV;;EAEZ,iBACE,eAAe,oBACf;aAAoB;eACV;;;;;;iBAOE,iBAAiB,SAAS,0BAA0B;;;;;;;;;;;;;;iBCzPpD,0BACd,UAAU,oBACV,mBACA;;UAmCe;WACN;WACA;WACA;WACA;;;;;;;iBAQK,6BACd,UAAU,oBACV,aAAa,aACb,mBACA,iDACU;;;;cCvBC,yBAAyB"}
package/dist/server.mjs CHANGED
@@ -29,8 +29,10 @@ function buildOwnershipDag(contract, clientContract, rootModel) {
29
29
  clientModels
30
30
  };
31
31
  }
32
+ /** Rejects relation cycles before ownership paths are used to authorize requests. */
32
33
  function validateAcyclic(edges) {
33
34
  const state = /* @__PURE__ */ new Map();
35
+ /** Visits each ownership node once and detects back edges on the active path. */
34
36
  function visit(node) {
35
37
  if (state.get(node) === "done") return;
36
38
  if (state.get(node) === "visiting") throw new Error(`Cycle detected in the ownership graph involving model "${node}".`);
@@ -40,6 +42,7 @@ function validateAcyclic(edges) {
40
42
  }
41
43
  for (const node of edges.keys()) visit(node);
42
44
  }
45
+ /** Ensures every client model can reach the authenticated root through owning relations. */
43
46
  function validateClientReachability(edges, clientModels, rootModel) {
44
47
  const reverseEdges = /* @__PURE__ */ new Map();
45
48
  for (const modelName of edges.keys()) reverseEdges.set(modelName, /* @__PURE__ */ new Set());
@@ -73,6 +76,7 @@ function resolveAuthorizationPaths(contract, rootModel, modelName) {
73
76
  if (modelName === rootModel) return [];
74
77
  const models = domainModelsAtDefaultNamespace(contract.domain);
75
78
  const paths = [];
79
+ /** Collects relation paths while excluding models already on the current path. */
76
80
  const dfs = (current, path, visited) => {
77
81
  const model = models[current];
78
82
  if (!model) return;
@@ -93,6 +97,40 @@ function resolveAuthorizationPaths(contract, rootModel, modelName) {
93
97
  dfs(modelName, [], /* @__PURE__ */ new Set([modelName]));
94
98
  return paths.sort((a, b) => a.length - b.length || a.join(".").localeCompare(b.join(".")));
95
99
  }
100
+ /**
101
+ * Groups root paths by their first parent. Every populated group must pass;
102
+ * alternate routes within a group retain any-path authorization.
103
+ * @throws {Error} If a checked path is not a single-field FK-to-key join.
104
+ */
105
+ function resolveParentReferenceChecks(contract, getKeyField, modelName, paths) {
106
+ const models = domainModelsAtDefaultNamespace(contract.domain);
107
+ const groups = /* @__PURE__ */ new Map();
108
+ for (const path of paths) {
109
+ if (path.length === 0) throw new Error(`Empty tenant parent path for "${modelName}".`);
110
+ let current = modelName;
111
+ for (const relationName of path) {
112
+ const model = models[current];
113
+ const relation = model?.relations[relationName];
114
+ if (!relation || relation.cardinality !== "N:1" || !("on" in relation) || relation.on.localFields.length !== 1 || relation.on.targetFields.length !== 1 || relation.on.targetFields[0] !== getKeyField(contract, relation.to.model) || !model?.fields[relation.on.localFields[0]]) throw new Error(`Unsupported tenant parent join "${current}.${relationName}": expected a single-field FK to the parent's key.`);
115
+ if (current === modelName) {
116
+ const localField = relation.on.localFields[0];
117
+ let group = groups.get(relationName);
118
+ if (!group) {
119
+ group = {
120
+ relation: relationName,
121
+ localField,
122
+ nullable: model.fields[localField].nullable === true,
123
+ paths: []
124
+ };
125
+ groups.set(relationName, group);
126
+ }
127
+ group.paths.push([...path]);
128
+ }
129
+ current = relation.to.model;
130
+ }
131
+ }
132
+ return [...groups.values()];
133
+ }
96
134
  //#endregion
97
135
  //#region src/core/validation-codecs.ts
98
136
  const sqlApplicationTypes = {};
@@ -142,6 +180,7 @@ sqlApplicationTypes["pg/text-array@1"] = ["string[]"];
142
180
  sqlApplicationTypes["pg/bytea@1"] = ["Uint8Array"];
143
181
  sqlApplicationTypes["pg/json@1"] = ["unknown"];
144
182
  sqlApplicationTypes["pg/jsonb@1"] = ["unknown"];
183
+ /** Resolves native application types for the IDB and SQL codecs supported by sync validation. */
145
184
  const defaultValidationCodecs = { targetTypesFor: (id) => idbCodecLookup.targetTypesFor(id) ?? sqlApplicationTypes[id] };
146
185
  //#endregion
147
186
  //#region src/core/sync-server.ts
@@ -158,6 +197,7 @@ const defaultGetKeyField = (contract, modelName) => {
158
197
  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."}`);
159
198
  return keyPath;
160
199
  };
200
+ /** Builds a root decision or the relation paths needed to authorize a scoped row. */
161
201
  function buildOwnershipCheck(dag, contract, getKeyField, modelName, keyField, key, scopeKey) {
162
202
  if (modelName === dag.rootModel) return {
163
203
  kind: "root",
@@ -175,10 +215,12 @@ function buildOwnershipCheck(dag, contract, getKeyField, modelName, keyField, ke
175
215
  paths: resolveAuthorizationPaths(contract, dag.rootModel, modelName)
176
216
  };
177
217
  }
218
+ /** Validates native payloads and keys before deriving ownership and candidate-parent checks. */
178
219
  function validatePush(dag, contract, clientContract, getKeyField, events, options) {
179
220
  const models = domainModelsAtDefaultNamespace(contract.domain);
180
221
  const clientModels = domainModelsAtDefaultNamespace(clientContract.domain);
181
222
  const codecLookup = options.codecLookup ?? defaultValidationCodecs;
223
+ /** Rejects unknown models and invalid fields before walking an event’s ownership paths (ADR 015). */
182
224
  function checkEvent(event) {
183
225
  if (!dag.clientModels.has(event.model) || !models[event.model] || !clientModels[event.model]) return { kind: "unknown-model" };
184
226
  const keyField = getKeyField(contract, event.model);
@@ -202,7 +244,11 @@ function validatePush(dag, contract, clientContract, getKeyField, events, option
202
244
  issues: result.issues
203
245
  };
204
246
  }
205
- return buildOwnershipCheck(dag, contract, getKeyField, event.model, keyField, event.wireKey, options.scopeKey);
247
+ const check = buildOwnershipCheck(dag, contract, getKeyField, event.model, keyField, event.wireKey, options.scopeKey);
248
+ return check.kind === "scoped" && event.operation !== "delete" ? {
249
+ ...check,
250
+ parentReferences: resolveParentReferenceChecks(contract, getKeyField, event.model, check.paths)
251
+ } : check;
206
252
  }
207
253
  return events.map((event) => ({
208
254
  eventId: event.id,
@@ -210,6 +256,7 @@ function validatePush(dag, contract, clientContract, getKeyField, events, option
210
256
  check: checkEvent(event)
211
257
  }));
212
258
  }
259
+ /** Builds live ownership checks for the supplied changelog rows. */
213
260
  function buildPullQueries(dag, contract, getKeyField, logs, options) {
214
261
  const models = domainModelsAtDefaultNamespace(contract.domain);
215
262
  return logs.map((log) => {
@@ -233,6 +280,7 @@ function buildPullQueries(dag, contract, getKeyField, logs, options) {
233
280
  */
234
281
  function createSyncServer(options) {
235
282
  const dag = buildOwnershipDag(options.contract, options.clientContract, options.rootModel);
283
+ const clientFields = new Map(Object.entries(domainModelsAtDefaultNamespace(options.clientContract.domain)).map(([name, model]) => [name, new Set(Object.keys(model.fields))]));
236
284
  const getKeyField = options.getKeyField ?? defaultGetKeyField;
237
285
  const codecLookup = options.codecLookup ?? defaultValidationCodecs;
238
286
  for (const model of dag.clientModels) try {
@@ -243,6 +291,12 @@ function createSyncServer(options) {
243
291
  }
244
292
  return {
245
293
  rootModel: dag.rootModel,
294
+ clientStorageHash: options.clientContract.storage.storageHash,
295
+ projectRecord: (model, row) => {
296
+ const fields = clientFields.get(model);
297
+ if (!fields) throw new Error(`projectRecord: model "${model}" is not defined in the client contract.`);
298
+ return Object.fromEntries(Object.entries(row).filter(([name]) => fields.has(name)));
299
+ },
246
300
  validatePush: (events, pushOptions) => validatePush(dag, options.contract, options.clientContract, getKeyField, events, {
247
301
  ...pushOptions,
248
302
  codecLookup
@@ -251,6 +305,6 @@ function createSyncServer(options) {
251
305
  };
252
306
  }
253
307
  //#endregion
254
- export { buildOwnershipDag, buildPullQueries, createSyncServer, defaultGetKeyField, defaultValidationCodecs, resolveAuthorizationPaths, validatePush };
308
+ export { buildOwnershipDag, buildPullQueries, createSyncServer, defaultGetKeyField, defaultValidationCodecs, resolveAuthorizationPaths, resolveParentReferenceChecks, validatePush };
255
309
 
256
310
  //# sourceMappingURL=server.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.mjs","names":[],"sources":["../src/core/ownership-dag.ts","../src/core/authorization-paths.ts","../src/core/validation-codecs.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 { idbCodecLookup } from \"@prisma-idb/target-idb/runtime\";\nimport type { ValidationCodecLookup } from \"@prisma-idb/target-idb/runtime\";\n\n// SQL descriptors' targetTypes are database type names, unlike IDB's JS names.\n// Map codec identities to their application representations, never storage types.\nconst sqlApplicationTypes: Record<string, readonly string[]> = {};\nfor (const name of [\"text\", \"char\", \"varchar\"]) sqlApplicationTypes[`sql/${name}@1`] = [\"string\"];\nfor (const name of [\"int\", \"float\"]) sqlApplicationTypes[`sql/${name}@1`] = [\"number\"];\nfor (const name of [\n \"text\",\n \"enum\",\n \"char\",\n \"varchar\",\n \"uuid\",\n \"numeric\",\n \"inet\",\n \"bit\",\n \"varbit\",\n \"time\",\n \"timetz\",\n \"date-string\",\n \"timestamp-string\",\n \"timestamptz-string\",\n \"time-string\",\n \"tsquery\",\n]) {\n sqlApplicationTypes[`pg/${name}@1`] = [\"string\"];\n}\nfor (const name of [\"int\", \"int2\", \"int4\", \"int8number\", \"float\", \"float4\", \"float8\"]) {\n sqlApplicationTypes[`pg/${name}@1`] = [\"number\"];\n}\nfor (const name of [\"int8\", \"unboundedint\"]) sqlApplicationTypes[`pg/${name}@1`] = [\"bigint\"];\nsqlApplicationTypes[\"pg/bool@1\"] = [\"boolean\"];\n// rc.5 date/timestamp codecs carry Date; pg/time carries string. rc.6 removed\n// these IDs, but historical contracts still need the same native validation.\nfor (const name of [\"date\", \"timestamp\", \"timestamptz\", \"timestamptz-date\"]) {\n sqlApplicationTypes[`pg/${name}@1`] = [\"Date\"];\n}\nsqlApplicationTypes[\"sql/timestamp@1\"] = [\"Date\"];\nsqlApplicationTypes[\"pg/text-array@1\"] = [\"string[]\"];\n// Temporal codecs and pg/interval@1 carry objects, not Date/string. They have\n// no default validator; createSyncServer rejects them before accepting pushes.\nsqlApplicationTypes[\"pg/bytea@1\"] = [\"Uint8Array\"];\nsqlApplicationTypes[\"pg/json@1\"] = [\"unknown\"];\nsqlApplicationTypes[\"pg/jsonb@1\"] = [\"unknown\"];\n\nexport const defaultValidationCodecs: ValidationCodecLookup = {\n targetTypesFor: (id) => idbCodecLookup.targetTypesFor(id) ?? sqlApplicationTypes[id],\n};\n","import { domainModelsAtDefaultNamespace } from \"@prisma/orm-framework/contract/types\";\nimport { assertRecordValidator, validateRecord, validateKeyFields } from \"@prisma-idb/target-idb/runtime\";\nimport type { ValidationCodecLookup } from \"@prisma-idb/target-idb/runtime\";\nimport { defaultValidationCodecs } from \"./validation-codecs\";\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 /** Native application values used for contract validation. */\n readonly payload: Record<string, unknown>;\n /**\n * The record's primary key in its JSON wire form, as the client sent it.\n * Changelog rows and the ownership check use this form, so\n * `check.key` returns it unchanged. It equals `payload[keyField]` unless the key's\n * native type (`bigint`, `Date`, `Uint8Array`) differs from its wire form.\n */\n readonly wireKey: 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 type PushCheck =\n | OwnershipCheck\n | {\n readonly kind: \"validation-failure\";\n readonly error: \"RECORD_VALIDATION_FAILURE\" | \"KEYPATH_VALIDATION_FAILURE\";\n readonly issues: readonly string[];\n };\n\nexport interface PushValidationResult {\n readonly eventId: string;\n readonly model: string;\n readonly check: PushCheck;\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 clientContract: SyncServerContract,\n getKeyField: GetKeyField,\n events: readonly SyncPushEvent[],\n options: { readonly scopeKey: string; readonly codecLookup?: ValidationCodecLookup }\n): readonly PushValidationResult[] {\n const models = domainModelsAtDefaultNamespace(contract.domain);\n const clientModels = domainModelsAtDefaultNamespace(clientContract.domain);\n const codecLookup = options.codecLookup ?? defaultValidationCodecs;\n\n // Shape checks run before the ownership walk (ADR 015), so a malformed\n // event never reaches an ownership query.\n function checkEvent(event: SyncPushEvent): PushCheck {\n if (!dag.clientModels.has(event.model) || !models[event.model] || !clientModels[event.model]) {\n return { kind: \"unknown-model\" };\n }\n const keyField = getKeyField(contract, event.model);\n const keyValue = event.payload?.[keyField];\n const keyResult = validateKeyFields(contract, event.model, { [keyField]: keyValue }, [keyField], { codecLookup });\n if (!keyResult.ok) {\n return { kind: \"validation-failure\", error: \"KEYPATH_VALIDATION_FAILURE\", issues: keyResult.issues };\n }\n if (event.operation !== \"delete\") {\n // SQL creates may omit nullable fields; patches validate only supplied fields.\n const optionalFields = Object.entries(clientModels[event.model]!.fields)\n .filter(([, field]) => field.nullable)\n .map(([name]) => name);\n const result = validateRecord(clientContract, event.model, event.payload, {\n codecLookup,\n partial: event.operation === \"update\",\n optionalFields,\n });\n if (!result.ok) {\n return { kind: \"validation-failure\", error: \"RECORD_VALIDATION_FAILURE\", issues: result.issues };\n }\n }\n return buildOwnershipCheck(dag, contract, getKeyField, event.model, keyField, event.wireKey, options.scopeKey);\n }\n\n return events.map((event) => ({ eventId: event.id, model: event.model, check: checkEvent(event) }));\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 the synced models and fields to validate. Any family. */\n readonly clientContract: SyncServerContract;\n readonly rootModel: string;\n /** @default defaultGetKeyField (IDB-shaped storage.keyPath) */\n readonly getKeyField?: GetKeyField;\n /** Native application types; unsupported validators throw at construction. */\n readonly codecLookup?: ValidationCodecLookup;\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 const codecLookup = options.codecLookup ?? defaultValidationCodecs;\n for (const model of dag.clientModels) {\n try {\n assertRecordValidator(options.clientContract, model, { codecLookup });\n } catch (error) {\n const reason = error instanceof Error ? error.message : \"Unable to build record validator\";\n throw new Error(\n `createSyncServer: cannot validate model \"${model}\": ${reason}. ` +\n \"Provide a codecLookup with supported application types or select a Date/string codec. \" +\n \"Temporal and interval objects have no default validator.\"\n );\n }\n }\n\n return {\n rootModel: dag.rootModel,\n validatePush: (events, pushOptions) =>\n validatePush(dag, options.contract, options.clientContract, getKeyField, events, {\n ...pushOptions,\n codecLookup,\n }),\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/validation-codecs.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\n/** Rejects relation cycles before ownership paths are used to authorize requests. */\nfunction validateAcyclic(edges: ReadonlyMap<string, ReadonlySet<string>>): void {\n const state = new Map<string, \"visiting\" | \"done\">();\n\n /** Visits each ownership node once and detects back edges on the active path. */\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\n/** Ensures every client model can reach the authenticated root through owning relations. */\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 { GetKeyField } from \"./sync-server\";\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 /** Collects relation paths while excluding models already on the current path. */\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\n/** One outgoing tenant parent, with alternate routes to the caller's root. */\nexport interface ParentReferenceCheck {\n readonly relation: string;\n readonly localField: string;\n readonly nullable: boolean;\n readonly paths: readonly (readonly string[])[];\n}\n\n/**\n * Groups root paths by their first parent. Every populated group must pass;\n * alternate routes within a group retain any-path authorization.\n * @throws {Error} If a checked path is not a single-field FK-to-key join.\n */\nexport function resolveParentReferenceChecks(\n contract: SyncServerContract,\n getKeyField: GetKeyField,\n modelName: string,\n paths: readonly (readonly string[])[]\n): readonly ParentReferenceCheck[] {\n const models = domainModelsAtDefaultNamespace(contract.domain);\n const groups = new Map<string, { relation: string; localField: string; nullable: boolean; paths: string[][] }>();\n for (const path of paths) {\n if (path.length === 0) throw new Error(`Empty tenant parent path for \"${modelName}\".`);\n let current = modelName;\n for (const relationName of path) {\n const model = models[current];\n const relation = model?.relations[relationName];\n if (\n !relation ||\n relation.cardinality !== \"N:1\" ||\n !(\"on\" in relation) ||\n relation.on.localFields.length !== 1 ||\n relation.on.targetFields.length !== 1 ||\n relation.on.targetFields[0] !== getKeyField(contract, relation.to.model) ||\n !model?.fields[relation.on.localFields[0]!]\n ) {\n throw new Error(\n `Unsupported tenant parent join \"${current}.${relationName}\": expected a single-field FK to the parent's key.`\n );\n }\n if (current === modelName) {\n const localField = relation.on.localFields[0]!;\n let group = groups.get(relationName);\n if (!group) {\n group = {\n relation: relationName,\n localField,\n nullable: model.fields[localField]!.nullable === true,\n paths: [],\n };\n groups.set(relationName, group);\n }\n group.paths.push([...path]);\n }\n current = relation.to.model;\n }\n }\n return [...groups.values()];\n}\n","import { idbCodecLookup } from \"@prisma-idb/target-idb/runtime\";\nimport type { ValidationCodecLookup } from \"@prisma-idb/target-idb/runtime\";\n\n// SQL descriptors' targetTypes are database type names, unlike IDB's JS names.\n// Map codec identities to their application representations, never storage types.\nconst sqlApplicationTypes: Record<string, readonly string[]> = {};\nfor (const name of [\"text\", \"char\", \"varchar\"]) sqlApplicationTypes[`sql/${name}@1`] = [\"string\"];\nfor (const name of [\"int\", \"float\"]) sqlApplicationTypes[`sql/${name}@1`] = [\"number\"];\nfor (const name of [\n \"text\",\n \"enum\",\n \"char\",\n \"varchar\",\n \"uuid\",\n \"numeric\",\n \"inet\",\n \"bit\",\n \"varbit\",\n \"time\",\n \"timetz\",\n \"date-string\",\n \"timestamp-string\",\n \"timestamptz-string\",\n \"time-string\",\n \"tsquery\",\n]) {\n sqlApplicationTypes[`pg/${name}@1`] = [\"string\"];\n}\nfor (const name of [\"int\", \"int2\", \"int4\", \"int8number\", \"float\", \"float4\", \"float8\"]) {\n sqlApplicationTypes[`pg/${name}@1`] = [\"number\"];\n}\nfor (const name of [\"int8\", \"unboundedint\"]) sqlApplicationTypes[`pg/${name}@1`] = [\"bigint\"];\nsqlApplicationTypes[\"pg/bool@1\"] = [\"boolean\"];\n// rc.5 date/timestamp codecs carry Date; pg/time carries string. rc.6 removed\n// these IDs, but historical contracts still need the same native validation.\nfor (const name of [\"date\", \"timestamp\", \"timestamptz\", \"timestamptz-date\"]) {\n sqlApplicationTypes[`pg/${name}@1`] = [\"Date\"];\n}\nsqlApplicationTypes[\"sql/timestamp@1\"] = [\"Date\"];\nsqlApplicationTypes[\"pg/text-array@1\"] = [\"string[]\"];\n// Temporal codecs and pg/interval@1 carry objects, not Date/string. They have\n// no default validator; createSyncServer rejects them before accepting pushes.\nsqlApplicationTypes[\"pg/bytea@1\"] = [\"Uint8Array\"];\nsqlApplicationTypes[\"pg/json@1\"] = [\"unknown\"];\nsqlApplicationTypes[\"pg/jsonb@1\"] = [\"unknown\"];\n\n/** Resolves native application types for the IDB and SQL codecs supported by sync validation. */\nexport const defaultValidationCodecs: ValidationCodecLookup = {\n targetTypesFor: (id) => idbCodecLookup.targetTypesFor(id) ?? sqlApplicationTypes[id],\n};\n","import { domainModelsAtDefaultNamespace } from \"@prisma/orm-framework/contract/types\";\nimport { assertRecordValidator, validateRecord, validateKeyFields } from \"@prisma-idb/target-idb/runtime\";\nimport type { ValidationCodecLookup } from \"@prisma-idb/target-idb/runtime\";\nimport { defaultValidationCodecs } from \"./validation-codecs\";\nimport { resolveAuthorizationPaths, resolveParentReferenceChecks } from \"./authorization-paths\";\nimport type { ParentReferenceCheck } 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 /** Native application values used for contract validation. */\n readonly payload: Record<string, unknown>;\n /**\n * The record's primary key in its JSON wire form, as the client sent it.\n * Changelog rows and the ownership check use this form, so\n * `check.key` returns it unchanged. It equals `payload[keyField]` unless the key's\n * native type (`bigint`, `Date`, `Uint8Array`) differs from its wire form.\n */\n readonly wireKey: 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\n/** An ownership check with candidate-parent integrity requirements, or a validation refusal. */\nexport type PushCheck =\n | Exclude<OwnershipCheck, { kind: \"scoped\" }>\n | (Extract<OwnershipCheck, { kind: \"scoped\" }> & {\n /** Additional candidate integrity checks for create/update. Adapters must derive these when absent. */\n readonly parentReferences?: readonly ParentReferenceCheck[];\n })\n | {\n readonly kind: \"validation-failure\";\n readonly error: \"RECORD_VALIDATION_FAILURE\" | \"KEYPATH_VALIDATION_FAILURE\";\n readonly issues: readonly string[];\n };\n\n/** Associates an event with its validation and ownership check. */\nexport interface PushValidationResult {\n readonly eventId: string;\n readonly model: string;\n readonly check: PushCheck;\n}\n\n/** Associates a changelog row with the ownership check its reader must execute. */\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\n/** Builds a root decision or the relation paths needed to authorize a scoped row. */\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\n/** Validates native payloads and keys before deriving ownership and candidate-parent checks. */\nexport function validatePush(\n dag: OwnershipDag,\n contract: SyncServerContract,\n clientContract: SyncServerContract,\n getKeyField: GetKeyField,\n events: readonly SyncPushEvent[],\n options: { readonly scopeKey: string; readonly codecLookup?: ValidationCodecLookup }\n): readonly PushValidationResult[] {\n const models = domainModelsAtDefaultNamespace(contract.domain);\n const clientModels = domainModelsAtDefaultNamespace(clientContract.domain);\n const codecLookup = options.codecLookup ?? defaultValidationCodecs;\n\n /** Rejects unknown models and invalid fields before walking an event’s ownership paths (ADR 015). */\n function checkEvent(event: SyncPushEvent): PushCheck {\n if (!dag.clientModels.has(event.model) || !models[event.model] || !clientModels[event.model]) {\n return { kind: \"unknown-model\" };\n }\n const keyField = getKeyField(contract, event.model);\n const keyValue = event.payload?.[keyField];\n const keyResult = validateKeyFields(contract, event.model, { [keyField]: keyValue }, [keyField], { codecLookup });\n if (!keyResult.ok) {\n return { kind: \"validation-failure\", error: \"KEYPATH_VALIDATION_FAILURE\", issues: keyResult.issues };\n }\n if (event.operation !== \"delete\") {\n // SQL creates may omit nullable fields; patches validate only supplied fields.\n const optionalFields = Object.entries(clientModels[event.model]!.fields)\n .filter(([, field]) => field.nullable)\n .map(([name]) => name);\n const result = validateRecord(clientContract, event.model, event.payload, {\n codecLookup,\n partial: event.operation === \"update\",\n optionalFields,\n });\n if (!result.ok) {\n return { kind: \"validation-failure\", error: \"RECORD_VALIDATION_FAILURE\", issues: result.issues };\n }\n }\n const check = buildOwnershipCheck(\n dag,\n contract,\n getKeyField,\n event.model,\n keyField,\n event.wireKey,\n options.scopeKey\n );\n return check.kind === \"scoped\" && event.operation !== \"delete\"\n ? { ...check, parentReferences: resolveParentReferenceChecks(contract, getKeyField, event.model, check.paths) }\n : check;\n }\n\n return events.map((event) => ({ eventId: event.id, model: event.model, check: checkEvent(event) }));\n}\n\n/** Builds live ownership checks for the supplied changelog rows. */\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\n/** Binds full and projected contracts to an authenticated ownership root. */\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 the synced models and fields to validate. Any family. */\n readonly clientContract: SyncServerContract;\n readonly rootModel: string;\n /** @default defaultGetKeyField (IDB-shaped storage.keyPath) */\n readonly getKeyField?: GetKeyField;\n /** Native application types; unsupported validators throw at construction. */\n readonly codecLookup?: ValidationCodecLookup;\n}\n\n/** Pure validation, ownership checks, and projection bound to one client contract. */\nexport interface SyncServer {\n readonly rootModel: string;\n /** The storage hash of the emitted client contract, including extension stores (ADR 015). */\n readonly clientStorageHash: string;\n /** Copies only top-level client-contract fields. Throws for an unknown client model. */\n projectRecord(model: string, row: Readonly<Record<string, unknown>>): Record<string, unknown>;\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 clientFields = new Map(\n Object.entries(domainModelsAtDefaultNamespace(options.clientContract.domain)).map(([name, model]) => [\n name,\n new Set(Object.keys(model.fields)),\n ])\n );\n const getKeyField = options.getKeyField ?? defaultGetKeyField;\n const codecLookup = options.codecLookup ?? defaultValidationCodecs;\n for (const model of dag.clientModels) {\n try {\n assertRecordValidator(options.clientContract, model, { codecLookup });\n } catch (error) {\n const reason = error instanceof Error ? error.message : \"Unable to build record validator\";\n throw new Error(\n `createSyncServer: cannot validate model \"${model}\": ${reason}. ` +\n \"Provide a codecLookup with supported application types or select a Date/string codec. \" +\n \"Temporal and interval objects have no default validator.\"\n );\n }\n }\n\n return {\n rootModel: dag.rootModel,\n clientStorageHash: options.clientContract.storage.storageHash,\n projectRecord: (model, row) => {\n const fields = clientFields.get(model);\n if (!fields) throw new Error(`projectRecord: model \"${model}\" is not defined in the client contract.`);\n return Object.fromEntries(Object.entries(row).filter(([name]) => fields.has(name)));\n },\n validatePush: (events, pushOptions) =>\n validatePush(dag, options.contract, options.clientContract, getKeyField, events, {\n ...pushOptions,\n codecLookup,\n }),\n buildPullQueries: (logs, pullOptions) => buildPullQueries(dag, options.contract, getKeyField, logs, pullOptions),\n };\n}\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import { stripIdbExcludeAttributes } from "@prisma-idb/family-idb/contract-psl";
2
2
  import { readFile } from "node:fs/promises";
3
- import { buildSymbolTable, mapPslDiagnostics } from "@prisma/orm-framework/psl-parser";
3
+ import { buildSymbolTable, createBinder, mapPslDiagnostics } from "@prisma/orm-framework/psl-parser";
4
4
  import { hasPslInterpreter, withSeedDiagnostics } from "@prisma/orm-framework/psl-parser/interpret";
5
5
  import { parse } from "@prisma/orm-framework/psl-parser/syntax";
6
6
  import { notOk, ok } from "@prisma/orm-framework/utils/result";
@@ -8,45 +8,13 @@ import { applySqlSpecifierControlPolicy } from "@prisma/orm-family-sql/contract-
8
8
  import { prismaContract } from "@prisma/orm-family-sql/contract-psl/provider";
9
9
  //#region src/core/changelog-schema.ts
10
10
  /**
11
- * Synthetic `Changelog` model, appended to the raw PSL text of a server
12
- * contract (never the client one) so a consumer never hand-authors it —
13
- * the old generator required an exact 6-field model + a matching enum and
14
- * threw one of a dozen validation errors if either drifted.
11
+ * Server-only changelog schema appended to the shared PSL source.
15
12
  *
16
- * `id` is a UUID v7 (`uuid(7)`), matching the old generator: fixed-width
17
- * and time-ordered, so it sorts correctly as a plain string — the pull
18
- * cursor stays an opaque string on the wire and never needs numeric
19
- * comparison or an int range check. The `uuid(7)` default is only a
20
- * fallback: `@prisma-idb/sync-server-sql` supplies each id itself, under a
21
- * per-scope lock, so ids and commit order agree (see `changelog-id.ts` and
22
- * `scope-lock.ts` there). `keyPath` is `String`, not `Json`: every
23
- * model this package's own consumers sync uses a single-field `String`
24
- * key today (no compound keys), so there's nothing to store as JSON, and
25
- * — confirmed directly against a real Postgres run — this target's
26
- * `pg/jsonb@1` codec doesn't decode correctly in the currently-published
27
- * `@prisma/orm-postgres` version (every other codec, including the enum,
28
- * decodes fine). `String` sidesteps a real gap rather than working around
29
- * it blindly. If a future consumer needs a genuinely compound/JSON-shaped
30
- * key, this is the function to fork, not patch — it'd change
31
- * `sync-server`'s own `key: unknown` handling too, not just this schema
32
- * fragment.
33
- *
34
- * The enum deliberately has no `@@type(...)` codec pragma — confirmed
35
- * empirically (a throwaway `contract emit` against `@prisma/orm-postgres`)
36
- * that omitting it still resolves to `pg/text@1`, because
37
- * `@prisma/orm-postgres/config`'s `defineConfig` already sets
38
- * `enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, ... }` — the *target*
39
- * supplies that default, not the schema text. Hardcoding `@@type("pg/text@1")`
40
- * here would have been redundant against Postgres and actively wrong
41
- * against a different SQL target (e.g. a future SQLite one, whose
42
- * own inference default isn't a `pg/*` codec) — this function is named
43
- * `*Sql`, not `*Postgres`, so it shouldn't assume one. This doesn't make it
44
- * Mongo-portable, to be clear — Mongo's contract-psl is a different PSL
45
- * dialect entirely (different scalar types, presumably no `enum`/
46
- * `@default(uuid(7))` the way SQL has them), so this function was
47
- * never going to apply there regardless; the fix here is about staying
48
- * agnostic *within* the SQL family (Postgres/SQLite/whatever comes next),
49
- * which is the scope its name actually promises.
13
+ * The SQL adapter supplies UUID v7 ids under a per-scope lock so cursor order
14
+ * agrees with commit order. UUID v7 is also the schema default.
15
+ * Keys use a single String column; compound keys need a separate sync design.
16
+ * The SQL target's enum inference selects the operation codec, so this schema
17
+ * does not hardcode a Postgres codec.
50
18
  */
51
19
  const CHANGELOG_MODEL_SQL_PSL = `
52
20
  enum ChangeOperation {
@@ -141,13 +109,23 @@ function sqlContractWithSync(schemaPath, options) {
141
109
  const { symbolTable, diagnostics: symbolTableDiagnostics } = buildSymbolTable({
142
110
  documents,
143
111
  sources,
144
- pslBlockDescriptors: context.authoringContributions.pslBlockDescriptors
112
+ defaultNamespaceId: context.defaultNamespaceId
113
+ });
114
+ const { binder, diagnostics: binderDiagnostics } = createBinder({
115
+ symbolTable,
116
+ sources,
117
+ context
145
118
  });
146
- const seedDiagnostics = mapPslDiagnostics([...parseDiagnostics, ...symbolTableDiagnostics], sources);
119
+ const seedDiagnostics = mapPslDiagnostics([
120
+ ...parseDiagnostics,
121
+ ...symbolTableDiagnostics,
122
+ ...binderDiagnostics
123
+ ], sources);
147
124
  const interpreted = withSeedDiagnostics(interpret({
148
125
  documents,
149
126
  sources,
150
- symbolTable
127
+ symbolTable,
128
+ binder
151
129
  }, context), seedDiagnostics);
152
130
  if (!interpreted.ok) return interpreted;
153
131
  return ok(applySqlSpecifierControlPolicy(interpreted.value, options.defaultControlPolicy, options.createNamespace));
@@ -158,4 +136,4 @@ function sqlContractWithSync(schemaPath, options) {
158
136
  //#endregion
159
137
  export { injectChangelogModelSql as n, prepareSqlSchemaWithSync as r, sqlContractWithSync as t };
160
138
 
161
- //# sourceMappingURL=sql-contract-CPS2Bh0c.mjs.map
139
+ //# sourceMappingURL=sql-contract-DC7961z-.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sql-contract-DC7961z-.mjs","names":[],"sources":["../src/core/changelog-schema.ts","../src/core/sql-contract.ts"],"sourcesContent":["import { stripIdbExcludeAttributes } from \"@prisma-idb/family-idb/contract-psl\";\n\n/**\n * Server-only changelog schema appended to the shared PSL source.\n *\n * The SQL adapter supplies UUID v7 ids under a per-scope lock so cursor order\n * agrees with commit order. UUID v7 is also the schema default.\n * Keys use a single String column; compound keys need a separate sync design.\n * The SQL target's enum inference selects the operation codec, so this schema\n * does not hardcode a Postgres codec.\n */\nconst CHANGELOG_MODEL_SQL_PSL = `\nenum ChangeOperation {\n create\n update\n delete\n}\n\nmodel Changelog {\n id String @id @default(uuid(7))\n model String\n keyPath String\n operation ChangeOperation\n scopeKey String\n outboxEventId String @unique\n createdAt DateTime @default(now())\n\n @@index([scopeKey, id])\n}\n`;\n\n/** Appends the synthetic `Changelog` model to raw PSL schema text. */\nexport function injectChangelogModelSql(schema: string): string {\n return `${schema}\\n${CHANGELOG_MODEL_SQL_PSL}`;\n}\n\n/**\n * The composed step a SQL-family config needs to turn a schema authored\n * for `family-idb` (browser client) into the real server schema: strip\n * `@idb.exclude`/`@@idb.exclude` (meaningless to a real server, and the\n * SQL parser hard-errors on the unrecognized `idb` namespace otherwise —\n * see `stripIdbExcludeAttributes`), then append `Changelog`. See\n * `sqlContractWithSync` for the version that also feeds this straight into\n * the SQL family's own parser, in memory.\n */\nexport function prepareSqlSchemaWithSync(schema: string): string {\n return injectChangelogModelSql(stripIdbExcludeAttributes(schema));\n}\n","import { readFile } from \"node:fs/promises\";\nimport type { ContractConfig } from \"@prisma/orm-framework/config/config-types\";\nimport { buildSymbolTable, createBinder, mapPslDiagnostics } from \"@prisma/orm-framework/psl-parser\";\nimport { hasPslInterpreter, withSeedDiagnostics } from \"@prisma/orm-framework/psl-parser/interpret\";\nimport { parse } from \"@prisma/orm-framework/psl-parser/syntax\";\nimport { notOk, ok } from \"@prisma/orm-framework/utils/result\";\nimport { applySqlSpecifierControlPolicy } from \"@prisma/orm-family-sql/contract-ts/contract-builder\";\nimport { prismaContract, type PrismaContractOptions } from \"@prisma/orm-family-sql/contract-psl/provider\";\nimport { prepareSqlSchemaWithSync } from \"./changelog-schema\";\n\n/**\n * Reads `schemaPath` once, runs it through `prepareSqlSchemaWithSync`, and\n * hands the *text* straight to the SQL family's own parse → interpret →\n * control-policy pipeline — no generated `.prisma` file lands on disk.\n *\n * `@prisma/orm-family-sql`'s own `prismaContract(schemaPath, options)`\n * always `readFile()`s its argument, with no hook to inject text instead\n * (github.com/prisma/orm#30115). This works around that today by\n * decomposing it: call the real `prismaContract` to get a `source` whose\n * `interpret` is the target-specific closure we don't want to reimplement\n * (`options.target`, `options.createNamespace`, etc. all live there), then\n * replace only `load` with a version that transforms the schema text\n * in-memory instead of pointing at a second file. The transformed text\n * still flows through the same parse → symbol-table → interpret →\n * `applySqlSpecifierControlPolicy` steps `prismaContract` itself runs, so\n * `storageHash` and diagnostics behave identically to the file-based path.\n *\n * Note: `@prisma/orm-postgres/config`'s `defineConfig` (and its per-target\n * siblings) only accept a schema *path* for `contract`, not a\n * `ContractConfig` — they build their own `prismaContract(...)` call\n * internally. Passing this function's return value to one of those still\n * requires wiring the family/target/adapter/driver through the core\n * `defineConfig` (`@prisma/orm-framework/config/config-types`) directly\n * instead of the target's convenience wrapper.\n */\nexport function sqlContractWithSync(schemaPath: string, options: PrismaContractOptions): ContractConfig {\n const { source, output } = prismaContract(schemaPath, options);\n if (!hasPslInterpreter(source)) {\n throw new Error(\"sqlContractWithSync: prismaContract() did not return a PSL-capable source.\");\n }\n const { interpret } = source;\n\n return {\n ...(output !== undefined ? { output } : {}),\n source: {\n ...source,\n async load(context) {\n const [absoluteSchemaPath] = context.resolvedInputs;\n if (absoluteSchemaPath === undefined) {\n throw new Error(\n \"sqlContractWithSync: context.resolvedInputs is empty. The CLI config loader should populate it positional-matched with source.inputs.\"\n );\n }\n\n let raw: string;\n try {\n raw = await readFile(absoluteSchemaPath, \"utf-8\");\n } catch (error) {\n const message = String(error);\n return notOk({\n summary: `Failed to read Prisma schema at \"${schemaPath}\"`,\n diagnostics: [\n {\n code: \"PSL_SCHEMA_READ_FAILED\",\n message,\n sourceId: schemaPath,\n },\n ],\n });\n }\n\n const schema = prepareSqlSchemaWithSync(raw);\n\n const { document, sources, diagnostics: parseDiagnostics } = parse(schema, schemaPath);\n const documents = [document];\n const { symbolTable, diagnostics: symbolTableDiagnostics } = buildSymbolTable({\n documents,\n sources,\n defaultNamespaceId: context.defaultNamespaceId,\n });\n const { binder, diagnostics: binderDiagnostics } = createBinder({ symbolTable, sources, context });\n\n const seedDiagnostics = mapPslDiagnostics(\n [...parseDiagnostics, ...symbolTableDiagnostics, ...binderDiagnostics],\n sources\n );\n\n const interpreted = withSeedDiagnostics(\n interpret({ documents, sources, symbolTable, binder }, context),\n seedDiagnostics\n );\n if (!interpreted.ok) {\n return interpreted;\n }\n\n return ok(\n applySqlSpecifierControlPolicy(interpreted.value, options.defaultControlPolicy, options.createNamespace)\n );\n },\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAWA,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;AAqBhC,SAAgB,wBAAwB,QAAwB;CAC9D,OAAO,GAAG,OAAO,IAAI;AACvB;;;;;;;;;;AAWA,SAAgB,yBAAyB,QAAwB;CAC/D,OAAO,wBAAwB,0BAA0B,MAAM,CAAC;AAClE;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACZA,SAAgB,oBAAoB,YAAoB,SAAgD;CACtG,MAAM,EAAE,QAAQ,WAAW,eAAe,YAAY,OAAO;CAC7D,IAAI,CAAC,kBAAkB,MAAM,GAC3B,MAAM,IAAI,MAAM,4EAA4E;CAE9F,MAAM,EAAE,cAAc;CAEtB,OAAO;EACL,GAAI,WAAW,KAAA,IAAY,EAAE,OAAO,IAAI,CAAC;EACzC,QAAQ;GACN,GAAG;GACH,MAAM,KAAK,SAAS;IAClB,MAAM,CAAC,sBAAsB,QAAQ;IACrC,IAAI,uBAAuB,KAAA,GACzB,MAAM,IAAI,MACR,uIACF;IAGF,IAAI;IACJ,IAAI;KACF,MAAM,MAAM,SAAS,oBAAoB,OAAO;IAClD,SAAS,OAAO;KACd,MAAM,UAAU,OAAO,KAAK;KAC5B,OAAO,MAAM;MACX,SAAS,oCAAoC,WAAW;MACxD,aAAa,CACX;OACE,MAAM;OACN;OACA,UAAU;MACZ,CACF;KACF,CAAC;IACH;IAEA,MAAM,SAAS,yBAAyB,GAAG;IAE3C,MAAM,EAAE,UAAU,SAAS,aAAa,qBAAqB,MAAM,QAAQ,UAAU;IACrF,MAAM,YAAY,CAAC,QAAQ;IAC3B,MAAM,EAAE,aAAa,aAAa,2BAA2B,iBAAiB;KAC5E;KACA;KACA,oBAAoB,QAAQ;IAC9B,CAAC;IACD,MAAM,EAAE,QAAQ,aAAa,sBAAsB,aAAa;KAAE;KAAa;KAAS;IAAQ,CAAC;IAEjG,MAAM,kBAAkB,kBACtB;KAAC,GAAG;KAAkB,GAAG;KAAwB,GAAG;IAAiB,GACrE,OACF;IAEA,MAAM,cAAc,oBAClB,UAAU;KAAE;KAAW;KAAS;KAAa;IAAO,GAAG,OAAO,GAC9D,eACF;IACA,IAAI,CAAC,YAAY,IACf,OAAO;IAGT,OAAO,GACL,+BAA+B,YAAY,OAAO,QAAQ,sBAAsB,QAAQ,eAAe,CACzG;GACF;EACF;CACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prisma-idb/sync-server",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
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,11 +41,11 @@
41
41
  "./package.json": "./package.json"
42
42
  },
43
43
  "dependencies": {
44
- "@prisma-idb/family-idb": "0.12.0",
45
- "@prisma/orm-family-sql": "8.0.0-rc.12",
46
- "@prisma/orm-framework": "8.0.0-rc.12",
47
- "@prisma/orm-postgres": "8.0.0-rc.12",
48
- "@prisma-idb/target-idb": "0.12.0"
44
+ "@prisma-idb/family-idb": "0.13.0",
45
+ "@prisma/orm-family-sql": "8.0.0-rc.18",
46
+ "@prisma/orm-framework": "8.0.0-rc.18",
47
+ "@prisma/orm-postgres": "8.0.0-rc.18",
48
+ "@prisma-idb/target-idb": "0.13.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^24.13.3",
@@ -1 +0,0 @@
1
- {"version":3,"file":"sql-contract-CPS2Bh0c.mjs","names":[],"sources":["../src/core/changelog-schema.ts","../src/core/sql-contract.ts"],"sourcesContent":["import { stripIdbExcludeAttributes } from \"@prisma-idb/family-idb/contract-psl\";\n\n/**\n * Synthetic `Changelog` model, appended to the raw PSL text of a server\n * contract (never the client one) so a consumer never hand-authors it —\n * the old generator required an exact 6-field model + a matching enum and\n * threw one of a dozen validation errors if either drifted.\n *\n * `id` is a UUID v7 (`uuid(7)`), matching the old generator: fixed-width\n * and time-ordered, so it sorts correctly as a plain string — the pull\n * cursor stays an opaque string on the wire and never needs numeric\n * comparison or an int range check. The `uuid(7)` default is only a\n * fallback: `@prisma-idb/sync-server-sql` supplies each id itself, under a\n * per-scope lock, so ids and commit order agree (see `changelog-id.ts` and\n * `scope-lock.ts` there). `keyPath` is `String`, not `Json`: every\n * model this package's own consumers sync uses a single-field `String`\n * key today (no compound keys), so there's nothing to store as JSON, and\n * — confirmed directly against a real Postgres run — this target's\n * `pg/jsonb@1` codec doesn't decode correctly in the currently-published\n * `@prisma/orm-postgres` version (every other codec, including the enum,\n * decodes fine). `String` sidesteps a real gap rather than working around\n * it blindly. If a future consumer needs a genuinely compound/JSON-shaped\n * key, this is the function to fork, not patch — it'd change\n * `sync-server`'s own `key: unknown` handling too, not just this schema\n * fragment.\n *\n * The enum deliberately has no `@@type(...)` codec pragma — confirmed\n * empirically (a throwaway `contract emit` against `@prisma/orm-postgres`)\n * that omitting it still resolves to `pg/text@1`, because\n * `@prisma/orm-postgres/config`'s `defineConfig` already sets\n * `enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, ... }` — the *target*\n * supplies that default, not the schema text. Hardcoding `@@type(\"pg/text@1\")`\n * here would have been redundant against Postgres and actively wrong\n * against a different SQL target (e.g. a future SQLite one, whose\n * own inference default isn't a `pg/*` codec) — this function is named\n * `*Sql`, not `*Postgres`, so it shouldn't assume one. This doesn't make it\n * Mongo-portable, to be clear — Mongo's contract-psl is a different PSL\n * dialect entirely (different scalar types, presumably no `enum`/\n * `@default(uuid(7))` the way SQL has them), so this function was\n * never going to apply there regardless; the fix here is about staying\n * agnostic *within* the SQL family (Postgres/SQLite/whatever comes next),\n * which is the scope its name actually promises.\n */\nconst CHANGELOG_MODEL_SQL_PSL = `\nenum ChangeOperation {\n create\n update\n delete\n}\n\nmodel Changelog {\n id String @id @default(uuid(7))\n model String\n keyPath String\n operation ChangeOperation\n scopeKey String\n outboxEventId String @unique\n createdAt DateTime @default(now())\n\n @@index([scopeKey, id])\n}\n`;\n\n/** Appends the synthetic `Changelog` model to raw PSL schema text. */\nexport function injectChangelogModelSql(schema: string): string {\n return `${schema}\\n${CHANGELOG_MODEL_SQL_PSL}`;\n}\n\n/**\n * The composed step a SQL-family config needs to turn a schema authored\n * for `family-idb` (browser client) into the real server schema: strip\n * `@idb.exclude`/`@@idb.exclude` (meaningless to a real server, and the\n * SQL parser hard-errors on the unrecognized `idb` namespace otherwise —\n * see `stripIdbExcludeAttributes`), then append `Changelog`. See\n * `sqlContractWithSync` for the version that also feeds this straight into\n * the SQL family's own parser, in memory.\n */\nexport function prepareSqlSchemaWithSync(schema: string): string {\n return injectChangelogModelSql(stripIdbExcludeAttributes(schema));\n}\n","import { readFile } from \"node:fs/promises\";\nimport type { ContractConfig } from \"@prisma/orm-framework/config/config-types\";\nimport { buildSymbolTable, mapPslDiagnostics } from \"@prisma/orm-framework/psl-parser\";\nimport { hasPslInterpreter, withSeedDiagnostics } from \"@prisma/orm-framework/psl-parser/interpret\";\nimport { parse } from \"@prisma/orm-framework/psl-parser/syntax\";\nimport { notOk, ok } from \"@prisma/orm-framework/utils/result\";\nimport { applySqlSpecifierControlPolicy } from \"@prisma/orm-family-sql/contract-ts/contract-builder\";\nimport { prismaContract, type PrismaContractOptions } from \"@prisma/orm-family-sql/contract-psl/provider\";\nimport { prepareSqlSchemaWithSync } from \"./changelog-schema\";\n\n/**\n * Reads `schemaPath` once, runs it through `prepareSqlSchemaWithSync`, and\n * hands the *text* straight to the SQL family's own parse → interpret →\n * control-policy pipeline — no generated `.prisma` file lands on disk.\n *\n * `@prisma/orm-family-sql`'s own `prismaContract(schemaPath, options)`\n * always `readFile()`s its argument, with no hook to inject text instead\n * (github.com/prisma/orm#30115). This works around that today by\n * decomposing it: call the real `prismaContract` to get a `source` whose\n * `interpret` is the target-specific closure we don't want to reimplement\n * (`options.target`, `options.createNamespace`, etc. all live there), then\n * replace only `load` with a version that transforms the schema text\n * in-memory instead of pointing at a second file. The transformed text\n * still flows through the same parse → symbol-table → interpret →\n * `applySqlSpecifierControlPolicy` steps `prismaContract` itself runs, so\n * `storageHash` and diagnostics behave identically to the file-based path.\n *\n * Note: `@prisma/orm-postgres/config`'s `defineConfig` (and its per-target\n * siblings) only accept a schema *path* for `contract`, not a\n * `ContractConfig` — they build their own `prismaContract(...)` call\n * internally. Passing this function's return value to one of those still\n * requires wiring the family/target/adapter/driver through the core\n * `defineConfig` (`@prisma/orm-framework/config/config-types`) directly\n * instead of the target's convenience wrapper.\n */\nexport function sqlContractWithSync(schemaPath: string, options: PrismaContractOptions): ContractConfig {\n const { source, output } = prismaContract(schemaPath, options);\n if (!hasPslInterpreter(source)) {\n throw new Error(\"sqlContractWithSync: prismaContract() did not return a PSL-capable source.\");\n }\n const { interpret } = source;\n\n return {\n ...(output !== undefined ? { output } : {}),\n source: {\n ...source,\n async load(context) {\n const [absoluteSchemaPath] = context.resolvedInputs;\n if (absoluteSchemaPath === undefined) {\n throw new Error(\n \"sqlContractWithSync: context.resolvedInputs is empty. The CLI config loader should populate it positional-matched with source.inputs.\"\n );\n }\n\n let raw: string;\n try {\n raw = await readFile(absoluteSchemaPath, \"utf-8\");\n } catch (error) {\n const message = String(error);\n return notOk({\n summary: `Failed to read Prisma schema at \"${schemaPath}\"`,\n diagnostics: [\n {\n code: \"PSL_SCHEMA_READ_FAILED\",\n message,\n sourceId: schemaPath,\n },\n ],\n });\n }\n\n const schema = prepareSqlSchemaWithSync(raw);\n\n const { document, sources, diagnostics: parseDiagnostics } = parse(schema, schemaPath);\n const documents = [document];\n const { symbolTable, diagnostics: symbolTableDiagnostics } = buildSymbolTable({\n documents,\n sources,\n pslBlockDescriptors: context.authoringContributions.pslBlockDescriptors,\n });\n\n const seedDiagnostics = mapPslDiagnostics([...parseDiagnostics, ...symbolTableDiagnostics], sources);\n\n const interpreted = withSeedDiagnostics(\n interpret({ documents, sources, symbolTable }, context),\n seedDiagnostics\n );\n if (!interpreted.ok) {\n return interpreted;\n }\n\n return ok(\n applySqlSpecifierControlPolicy(interpreted.value, options.defaultControlPolicy, options.createNamespace)\n );\n },\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2CA,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;AAqBhC,SAAgB,wBAAwB,QAAwB;CAC9D,OAAO,GAAG,OAAO,IAAI;AACvB;;;;;;;;;;AAWA,SAAgB,yBAAyB,QAAwB;CAC/D,OAAO,wBAAwB,0BAA0B,MAAM,CAAC;AAClE;;;;;;;;;;;;;;;;;;;;;;;;;;;;AC5CA,SAAgB,oBAAoB,YAAoB,SAAgD;CACtG,MAAM,EAAE,QAAQ,WAAW,eAAe,YAAY,OAAO;CAC7D,IAAI,CAAC,kBAAkB,MAAM,GAC3B,MAAM,IAAI,MAAM,4EAA4E;CAE9F,MAAM,EAAE,cAAc;CAEtB,OAAO;EACL,GAAI,WAAW,KAAA,IAAY,EAAE,OAAO,IAAI,CAAC;EACzC,QAAQ;GACN,GAAG;GACH,MAAM,KAAK,SAAS;IAClB,MAAM,CAAC,sBAAsB,QAAQ;IACrC,IAAI,uBAAuB,KAAA,GACzB,MAAM,IAAI,MACR,uIACF;IAGF,IAAI;IACJ,IAAI;KACF,MAAM,MAAM,SAAS,oBAAoB,OAAO;IAClD,SAAS,OAAO;KACd,MAAM,UAAU,OAAO,KAAK;KAC5B,OAAO,MAAM;MACX,SAAS,oCAAoC,WAAW;MACxD,aAAa,CACX;OACE,MAAM;OACN;OACA,UAAU;MACZ,CACF;KACF,CAAC;IACH;IAEA,MAAM,SAAS,yBAAyB,GAAG;IAE3C,MAAM,EAAE,UAAU,SAAS,aAAa,qBAAqB,MAAM,QAAQ,UAAU;IACrF,MAAM,YAAY,CAAC,QAAQ;IAC3B,MAAM,EAAE,aAAa,aAAa,2BAA2B,iBAAiB;KAC5E;KACA;KACA,qBAAqB,QAAQ,uBAAuB;IACtD,CAAC;IAED,MAAM,kBAAkB,kBAAkB,CAAC,GAAG,kBAAkB,GAAG,sBAAsB,GAAG,OAAO;IAEnG,MAAM,cAAc,oBAClB,UAAU;KAAE;KAAW;KAAS;IAAY,GAAG,OAAO,GACtD,eACF;IACA,IAAI,CAAC,YAAY,IACf,OAAO;IAGT,OAAO,GACL,+BAA+B,YAAY,OAAO,QAAQ,sBAAsB,QAAQ,eAAe,CACzG;GACF;EACF;CACF;AACF"}