@abloatai/ablo 0.7.0 → 0.9.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.
Files changed (181) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +80 -66
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +179 -5
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/SyncEngineContext.d.ts +2 -1
  8. package/dist/SyncEngineContext.js +5 -3
  9. package/dist/agent/session.js +6 -5
  10. package/dist/ai-sdk/coordination-context.js +4 -0
  11. package/dist/ai-sdk/index.d.ts +56 -47
  12. package/dist/ai-sdk/index.js +56 -47
  13. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  14. package/dist/ai-sdk/intent-broadcast.js +11 -4
  15. package/dist/ai-sdk/wrap.d.ts +14 -11
  16. package/dist/ai-sdk/wrap.js +11 -13
  17. package/dist/auth/credentialSource.d.ts +34 -0
  18. package/dist/auth/credentialSource.js +63 -0
  19. package/dist/auth/index.d.ts +2 -22
  20. package/dist/auth/index.js +26 -36
  21. package/dist/auth/schemas.d.ts +35 -0
  22. package/dist/auth/schemas.js +53 -0
  23. package/dist/client/Ablo.d.ts +259 -33
  24. package/dist/client/Ablo.js +276 -73
  25. package/dist/client/ApiClient.d.ts +52 -4
  26. package/dist/client/ApiClient.js +236 -66
  27. package/dist/client/auth.d.ts +21 -2
  28. package/dist/client/auth.js +77 -5
  29. package/dist/client/createInternalComponents.d.ts +2 -0
  30. package/dist/client/createInternalComponents.js +8 -1
  31. package/dist/client/createModelProxy.d.ts +187 -79
  32. package/dist/client/createModelProxy.js +203 -68
  33. package/dist/client/httpClient.d.ts +71 -0
  34. package/dist/client/httpClient.js +69 -0
  35. package/dist/client/identity.d.ts +2 -6
  36. package/dist/client/identity.js +63 -11
  37. package/dist/client/index.d.ts +1 -0
  38. package/dist/client/index.js +1 -0
  39. package/dist/client/registerDataSource.d.ts +19 -0
  40. package/dist/client/registerDataSource.js +59 -0
  41. package/dist/client/validateAbloOptions.d.ts +2 -1
  42. package/dist/client/validateAbloOptions.js +8 -7
  43. package/dist/core/DatabaseManager.js +30 -2
  44. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  45. package/dist/core/openIDBWithTimeout.js +88 -1
  46. package/dist/errorCodes.d.ts +92 -1
  47. package/dist/errorCodes.js +139 -7
  48. package/dist/errors.d.ts +54 -3
  49. package/dist/errors.js +192 -44
  50. package/dist/index.d.ts +23 -10
  51. package/dist/index.js +21 -8
  52. package/dist/keys/index.d.ts +76 -0
  53. package/dist/keys/index.js +171 -0
  54. package/dist/mutators/UndoManager.d.ts +86 -50
  55. package/dist/mutators/UndoManager.js +129 -22
  56. package/dist/mutators/inverseOp.d.ts +129 -0
  57. package/dist/mutators/inverseOp.js +74 -0
  58. package/dist/mutators/readerActions.d.ts +1 -1
  59. package/dist/mutators/undoApply.d.ts +42 -0
  60. package/dist/mutators/undoApply.js +143 -0
  61. package/dist/query/client.d.ts +10 -9
  62. package/dist/query/client.js +22 -14
  63. package/dist/react/AbloProvider.d.ts +23 -101
  64. package/dist/react/AbloProvider.js +61 -103
  65. package/dist/react/ClientSideSuspense.d.ts +1 -1
  66. package/dist/react/DefaultFallback.d.ts +1 -1
  67. package/dist/react/SyncGroupProvider.d.ts +1 -1
  68. package/dist/react/index.d.ts +3 -2
  69. package/dist/react/index.js +3 -2
  70. package/dist/react/useAblo.d.ts +4 -4
  71. package/dist/react/useAblo.js +10 -5
  72. package/dist/react/useCurrentUserId.d.ts +1 -1
  73. package/dist/react/useCurrentUserId.js +1 -1
  74. package/dist/react/useMutators.js +19 -12
  75. package/dist/react/useReactive.js +16 -3
  76. package/dist/schema/ddl.d.ts +26 -3
  77. package/dist/schema/ddl.js +152 -4
  78. package/dist/schema/index.d.ts +4 -0
  79. package/dist/schema/index.js +12 -0
  80. package/dist/schema/model.d.ts +11 -0
  81. package/dist/schema/model.js +2 -0
  82. package/dist/schema/openapi.d.ts +28 -0
  83. package/dist/schema/openapi.js +118 -0
  84. package/dist/schema/plane.d.ts +23 -0
  85. package/dist/schema/plane.js +19 -0
  86. package/dist/schema/relation.d.ts +20 -0
  87. package/dist/schema/serialize.d.ts +7 -3
  88. package/dist/schema/serialize.js +6 -2
  89. package/dist/schema/sync-delta-row.d.ts +157 -0
  90. package/dist/schema/sync-delta-row.js +102 -0
  91. package/dist/schema/sync-delta-wire.d.ts +180 -0
  92. package/dist/schema/sync-delta-wire.js +102 -0
  93. package/dist/server/adapter.d.ts +156 -0
  94. package/dist/server/adapter.js +19 -0
  95. package/dist/server/commit.d.ts +82 -0
  96. package/dist/server/commit.js +1 -0
  97. package/dist/server/index.d.ts +14 -0
  98. package/dist/server/index.js +1 -0
  99. package/dist/server/next.d.ts +51 -0
  100. package/dist/server/next.js +47 -0
  101. package/dist/server/read-config.d.ts +60 -0
  102. package/dist/server/read-config.js +8 -0
  103. package/dist/server/storage-mode.d.ts +17 -0
  104. package/dist/server/storage-mode.js +12 -0
  105. package/dist/source/adapter.d.ts +59 -0
  106. package/dist/source/adapter.js +19 -0
  107. package/dist/source/adapters/drizzle.d.ts +34 -0
  108. package/dist/source/adapters/drizzle.js +147 -0
  109. package/dist/source/adapters/memory.d.ts +12 -0
  110. package/dist/source/adapters/memory.js +114 -0
  111. package/dist/source/adapters/prisma.d.ts +57 -0
  112. package/dist/source/adapters/prisma.js +199 -0
  113. package/dist/source/conformance.d.ts +32 -0
  114. package/dist/source/conformance.js +134 -0
  115. package/dist/source/contract.d.ts +143 -0
  116. package/dist/source/contract.js +98 -0
  117. package/dist/source/index.d.ts +61 -10
  118. package/dist/source/index.js +98 -0
  119. package/dist/source/next.d.ts +33 -0
  120. package/dist/source/next.js +26 -0
  121. package/dist/sync/BootstrapHelper.d.ts +10 -0
  122. package/dist/sync/BootstrapHelper.js +56 -42
  123. package/dist/sync/ConnectionManager.d.ts +57 -1
  124. package/dist/sync/ConnectionManager.js +186 -11
  125. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  126. package/dist/sync/HydrationCoordinator.js +241 -41
  127. package/dist/sync/NetworkProbe.d.ts +60 -18
  128. package/dist/sync/NetworkProbe.js +121 -23
  129. package/dist/sync/SyncWebSocket.d.ts +45 -70
  130. package/dist/sync/SyncWebSocket.js +113 -89
  131. package/dist/sync/createIntentStream.js +10 -1
  132. package/dist/sync/participants.js +5 -2
  133. package/dist/transactions/TransactionQueue.js +13 -1
  134. package/dist/types/streams.d.ts +9 -0
  135. package/dist/utils/mobx-setup.js +1 -0
  136. package/dist/webhooks/events.d.ts +38 -0
  137. package/dist/webhooks/events.js +40 -0
  138. package/dist/webhooks/index.d.ts +10 -0
  139. package/dist/webhooks/index.js +10 -0
  140. package/dist/wire/errorEnvelope.d.ts +34 -0
  141. package/dist/wire/errorEnvelope.js +86 -0
  142. package/dist/wire/frames.d.ts +119 -0
  143. package/dist/wire/frames.js +1 -0
  144. package/dist/wire/index.d.ts +24 -0
  145. package/dist/wire/index.js +21 -0
  146. package/dist/wire/listEnvelope.d.ts +45 -0
  147. package/dist/wire/listEnvelope.js +17 -0
  148. package/docs/api-keys.md +5 -5
  149. package/docs/api.md +125 -65
  150. package/docs/audit.md +16 -9
  151. package/docs/cli.md +57 -47
  152. package/docs/client-behavior.md +54 -40
  153. package/docs/coordination.md +66 -80
  154. package/docs/data-sources.md +56 -34
  155. package/docs/examples/agent-human.md +74 -28
  156. package/docs/examples/ai-sdk-tool.md +29 -22
  157. package/docs/examples/existing-python-backend.md +41 -26
  158. package/docs/examples/nextjs.md +32 -17
  159. package/docs/examples/scoped-agent.md +43 -28
  160. package/docs/examples/server-agent.md +40 -15
  161. package/docs/guarantees.md +38 -27
  162. package/docs/identity.md +65 -59
  163. package/docs/index.md +30 -19
  164. package/docs/integration-guide.md +78 -78
  165. package/docs/interaction-model.md +43 -35
  166. package/docs/mcp/claude-code.md +11 -19
  167. package/docs/mcp/cursor.md +7 -25
  168. package/docs/mcp/windsurf.md +7 -20
  169. package/docs/mcp.md +103 -26
  170. package/docs/quickstart.md +63 -61
  171. package/docs/react.md +24 -16
  172. package/docs/roadmap.md +13 -13
  173. package/docs/schema-contract.md +111 -0
  174. package/docs/the-loop.md +21 -0
  175. package/examples/README.md +8 -4
  176. package/examples/data-source/README.md +10 -7
  177. package/examples/data-source/customer-server.ts +27 -25
  178. package/examples/data-source/run.ts +4 -3
  179. package/examples/quickstart.ts +1 -1
  180. package/llms.txt +55 -21
  181. package/package.json +48 -3
@@ -55,6 +55,118 @@ export function sqlType(fieldType) {
55
55
  }
56
56
  }
57
57
  const BASE_COLUMNS = new Set(['id', 'organization_id', 'created_by', 'created_at', 'updated_at']);
58
+ // ── Foreign keys (relation-driven, sync-safe) ────────────────────────────────
59
+ /**
60
+ * A Postgres-identifier-safe constraint name ≤63 bytes. When the natural
61
+ * `<table>_<col>_<suffix>` exceeds the limit, fall back to a deterministic
62
+ * hashed form so the name stays stable AND matches what Postgres actually stores
63
+ * — a silently-truncated name would never match the DO-block existence guard,
64
+ * breaking idempotency (re-adds every push) and risking prefix collisions.
65
+ */
66
+ function constraintName(table, col, suffix) {
67
+ const full = `${table}_${col}_${suffix}`;
68
+ if (full.length <= 63)
69
+ return full;
70
+ let h = 5381;
71
+ for (let i = 0; i < full.length; i++)
72
+ h = ((h * 33) + full.charCodeAt(i)) >>> 0;
73
+ const hash = h.toString(36);
74
+ const prefix = full.slice(0, Math.max(1, 63 - suffix.length - hash.length - 2));
75
+ return `${prefix}_${hash}_${suffix}`;
76
+ }
77
+ /**
78
+ * Foreign-key constraints for a model's belongsTo relations marked `{ fk: true }`.
79
+ *
80
+ * Emission is driven by an explicit `fk` marker, DECOUPLED from `parent`
81
+ * (`parent` = sync-group fan-out / visibility, control plane; `fk` = physical
82
+ * referential integrity, data plane — orthogonal axes, per Drizzle's
83
+ * relations()-vs-references() split and the Zanzibar "parent is permission-only"
84
+ * rule). A relation sets `fk` only when its target is co-located in the same DB
85
+ * AND written in the same commit, and is a strong / contained entity. Soft
86
+ * references (provenance / template pointers, e.g. `sourceSlideId`, `templateId`)
87
+ * stay plain columns — a hard FK there would reject a write pointing cross-scope
88
+ * or at an absent row and break sync.
89
+ *
90
+ * LIVE / POPULATED tables: a plain ADD CONSTRAINT takes SHARE ROW EXCLUSIVE on
91
+ * both tables and scans the whole child table — freezing writes on a customer's
92
+ * production DB. So the constraint is added `NOT VALID` (instant, no scan, brief
93
+ * lock) INSIDE the transaction, and the existing-row check (`VALIDATE
94
+ * CONSTRAINT`, SHARE UPDATE EXCLUSIVE — allows writes) plus the child index
95
+ * (`CREATE INDEX CONCURRENTLY`) are returned SEPARATELY in {@link
96
+ * ForeignKeyDdl.concurrent}, run after commit, outside any transaction, and are
97
+ * best-effort: if existing data violates a freshly-added FK the VALIDATE is
98
+ * skipped (logged, never fatal), the constraint still enforces all new writes,
99
+ * and nothing is destroyed.
100
+ *
101
+ * The key is a pure `DEFERRABLE INITIALLY DEFERRED` **integrity guard** with
102
+ * `ON DELETE NO ACTION`: it NEVER mutates a child row itself. (SET NULL / CASCADE
103
+ * would change data server-side with NO sync_delta — invisible to other clients
104
+ * until re-bootstrap — and would override the app-layer ModelRegistry onDelete
105
+ * contract.) The app layer owns deletes + nullification and emits the deltas; the
106
+ * deferred check just verifies — at COMMIT, so same-batch child-before-parent and
107
+ * the app's own cascade both pass — that integrity holds, failing loudly only if
108
+ * the app left a dangling reference.
109
+ *
110
+ * Authoritative + idempotent: a same-named constraint that isn't deferrable or
111
+ * carries the wrong delete action (a hand-added or legacy FK) is dropped and
112
+ * recreated; an already-correct one is left untouched (no re-validation cost).
113
+ * Emitted in a final pass, after every referenced table exists.
114
+ *
115
+ * The FK column is resolved the SAME way the table loop names columns
116
+ * (`fieldMeta.column ?? camelToSnake(field)`), not from `rel.foreignKeyColumn` —
117
+ * the table loop ignores relation casing, so trusting `foreignKeyColumn` would
118
+ * mismatch the real column whenever `casing` is unset.
119
+ */
120
+ function foreignKeyStatements(table, model, models, qs) {
121
+ const qt = `${qs}.${q(table)}`;
122
+ // The model's provisioned column set — guard so a relation whose FK field
123
+ // isn't actually declared (no column) never produces a broken ALTER.
124
+ const orgCol = tenancyColumn(resolveTenancy(model));
125
+ const columns = new Set(['id', 'created_by', 'created_at', 'updated_at']);
126
+ if (orgCol)
127
+ columns.add(orgCol);
128
+ for (const [fieldName, meta] of Object.entries(model.fields)) {
129
+ columns.add(meta.column ?? camelToSnake(fieldName));
130
+ }
131
+ const statements = [];
132
+ const concurrent = [];
133
+ for (const rel of Object.values(model.relations)) {
134
+ if (rel.type !== 'belongsTo')
135
+ continue; // only relations whose FK column lives on THIS table
136
+ if (rel.options?.fk !== true)
137
+ continue; // explicit `fk` marker — decoupled from `parent` (visibility)
138
+ const targetModel = models[rel.target];
139
+ if (!targetModel)
140
+ continue; // target not provisioned into this DB → can't reference it
141
+ if ((targetModel.plane ?? 'tenant') === 'control')
142
+ continue; // control-plane table absent in a tenant DB
143
+ const col = model.fields[rel.foreignKey]?.column ?? camelToSnake(rel.foreignKey);
144
+ if (!columns.has(col))
145
+ continue; // FK field isn't a provisioned column
146
+ const targetTable = targetModel.tableName ?? rel.target;
147
+ const cname = constraintName(table, col, 'fkey');
148
+ const lit = cname.replace(/'/g, "''");
149
+ const iname = constraintName(table, col, 'idx');
150
+ const targetQt = `${qs}.${q(targetTable)}`;
151
+ // In-tx: authoritative create as NOT VALID — instant, no child-table scan,
152
+ // only a brief lock. confdeltype 'a' = NO ACTION; recreate only when absent /
153
+ // not deferrable / wrong delete action, so a correct constraint is untouched.
154
+ statements.push(`DO $$ BEGIN\n` +
155
+ ` IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '${lit}' AND (NOT condeferrable OR confdeltype <> 'a')) THEN\n` +
156
+ ` ALTER TABLE ${qt} DROP CONSTRAINT ${q(cname)};\n` +
157
+ ` END IF;\n` +
158
+ ` IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '${lit}') THEN\n` +
159
+ ` ALTER TABLE ${qt} ADD CONSTRAINT ${q(cname)} FOREIGN KEY (${q(col)}) ` +
160
+ `REFERENCES ${targetQt} (${q('id')}) ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED NOT VALID;\n` +
161
+ ` END IF;\nEND $$;`);
162
+ // Post-commit, non-blocking: validate existing rows (SHARE UPDATE EXCLUSIVE,
163
+ // allows concurrent writes) then index the child column (Postgres does NOT
164
+ // auto-index the referencing column → parent deletes would seq-scan it).
165
+ concurrent.push(`ALTER TABLE ${qt} VALIDATE CONSTRAINT ${q(cname)};`);
166
+ concurrent.push(`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${q(iname)} ON ${qt} (${q(col)});`);
167
+ }
168
+ return { statements, concurrent };
169
+ }
58
170
  // ── Provisioning (additive, idempotent) ─────────────────────────────────────
59
171
  /**
60
172
  * Build the additive, idempotent provisioning plan for an app. Pure — no DB
@@ -65,11 +177,18 @@ const BASE_COLUMNS = new Set(['id', 'organization_id', 'created_by', 'created_at
65
177
  * itself is the isolation boundary). For `public` the `CREATE SCHEMA` is
66
178
  * skipped (it always exists).
67
179
  */
68
- export function generateProvisionPlan(schema, targetSchema) {
180
+ export function generateProvisionPlan(schema, targetSchema, opts = {}) {
69
181
  const appSchema = targetSchema;
70
182
  const qs = q(appSchema);
71
183
  const statements = appSchema === 'public' ? [] : [`CREATE SCHEMA IF NOT EXISTS ${qs};`];
184
+ const concurrent = [];
72
185
  for (const [key, model] of Object.entries(schema.models)) {
186
+ // Control-plane models (Ablo's own sync log / attribution / audit) are never
187
+ // emitted into a tenant database — only `tenant`-plane models are. Absent
188
+ // plane = `tenant` (back-compat). This declared boundary is what makes "what
189
+ // a BYO customer DB gets" derivable instead of hand-coded.
190
+ if ((model.plane ?? 'tenant') === 'control')
191
+ continue;
73
192
  // Default the physical table to the model key when `tableName` is omitted —
74
193
  // same fallback the migration path uses (`tableOfModel: m.tableName ?? key`).
75
194
  // Without this, a schema that doesn't set `tableName` (e.g. the `ablo init`
@@ -121,7 +240,19 @@ export function generateProvisionPlan(schema, targetSchema) {
121
240
  statements.push(`CREATE POLICY ${q(policy)} ON ${qt}\n USING (${predicate})\n WITH CHECK (${predicate});`);
122
241
  }
123
242
  }
124
- return { appSchema, statements };
243
+ // Foreign keys (opt-in) — a final pass so every referenced table already
244
+ // exists when its constraint is added.
245
+ if (opts.foreignKeys) {
246
+ for (const [key, m] of Object.entries(schema.models)) {
247
+ if ((m.plane ?? 'tenant') === 'control')
248
+ continue;
249
+ const t = m.tableName ?? key;
250
+ const fk = foreignKeyStatements(t, m, schema.models, qs);
251
+ statements.push(...fk.statements);
252
+ concurrent.push(...fk.concurrent);
253
+ }
254
+ }
255
+ return { appSchema, statements, concurrent };
125
256
  }
126
257
  // ── Migration (destructive-aware, diff-driven) ──────────────────────────────
127
258
  function enumCheckStatements(table, col, qt, values) {
@@ -169,9 +300,10 @@ function sqlLiteral(value, fieldType) {
169
300
  * resolve the *old* table name on a model rename).
170
301
  */
171
302
  export function generateMigrationPlan(steps, opts) {
172
- const { prev, next, targetSchema, backfills = [] } = opts;
303
+ const { prev, next, targetSchema, backfills = [], foreignKeys = false } = opts;
173
304
  const qs = q(targetSchema);
174
305
  const statements = [];
306
+ const concurrent = [];
175
307
  const qtFor = (table) => `${qs}.${q(table)}`;
176
308
  const tableOfModel = (schema, key) => {
177
309
  const m = schema?.models[key];
@@ -313,5 +445,21 @@ export function generateMigrationPlan(steps, opts) {
313
445
  }
314
446
  }
315
447
  }
316
- return { appSchema: targetSchema, statements };
448
+ // Foreign keys (opt-in). Reconcile against the FULL `next` schema, not just
449
+ // create_model steps: a parent edge ADDED to an existing model surfaces only as
450
+ // an add_field (relation changes aren't diffed), so a create_model-only pass
451
+ // would never materialize its FK. The DO-block is authoritative + idempotent
452
+ // (a no-op when the constraint is already correct), so emitting the full set
453
+ // each push is cheap and self-healing. Appended after every table/column step.
454
+ if (foreignKeys) {
455
+ for (const [key, def] of Object.entries(next.models)) {
456
+ if ((def.plane ?? 'tenant') === 'control')
457
+ continue;
458
+ const table = def.tableName ?? key;
459
+ const fk = foreignKeyStatements(table, def, next.models, qs);
460
+ statements.push(...fk.statements);
461
+ concurrent.push(...fk.concurrent);
462
+ }
463
+ }
464
+ return { appSchema: targetSchema, statements, concurrent };
317
465
  }
@@ -24,6 +24,9 @@ export { z } from 'zod';
24
24
  export { field, indexed, getFieldMeta, type FieldBuilder, type FieldMeta } from './field.js';
25
25
  export { relation, type RelationDef, type RelationType } from './relation.js';
26
26
  export { tenancySchema, scopedViaRefSchema, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, type Tenancy, type ScopedViaRef, type TenancyInput, } from './tenancy.js';
27
+ export { planeSchema, DEFAULT_PLANE, type SchemaPlane } from './plane.js';
28
+ export { syncDeltaCoreSchema, deltaAttributionSchema, deltaProvenanceSchema, syncDeltaRowSchema, participantKindSchema, confirmationStateSchema, backfillProvenanceSchema, DELTA_PLANES, type SyncDeltaCore, type DeltaAttribution, type DeltaProvenance, type SyncDeltaRow, type ParticipantKind, type ConfirmationState, type BackfillProvenance, } from './sync-delta-row.js';
29
+ export { syncDeltaActionSchema, wireDeltaDataSchema, participantRefSchema, syncDeltaWireCoreSchema, clientSyncDeltaSchema, serverSyncDeltaSchema, type SyncDeltaAction, type WireDeltaData, type ParticipantRef, type SyncDeltaWireCore, type ClientSyncDelta, type ServerSyncDelta, } from './sync-delta-wire.js';
27
30
  export { model, scopeKindOf, type ModelDef, type ModelOptions, type LoadStrategy, type PersistOptions, type RelationRecord, type GrantsRef, } from './model.js';
28
31
  export { mutable, readOnly, type SugarOptions } from './sugar.js';
29
32
  export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type InferModel, type InferCreate, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, composeEntitySyncGroups, type IdentityRole, type IdentityContext, type IdentityRoleSource, type EntityRole, type EntityContext, type EntityRoleSource, type RoleSource, type RoleContext, type SyncGroup, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './schema.js';
@@ -33,3 +36,4 @@ export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSna
33
36
  export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, type BackfillValue, type MigrationStep, type FieldChanges, type FieldColumnChange, type FieldTypeChange, type NullabilityChange, type EnumValuesChange, type IndexChange, type CastSafety, type FieldType, type RenameHints, type MigrationSignal, type MigrationClassification, type WarningCode, type BlockerCode, } from './diff.js';
34
37
  export { generateTypes } from './generate.js';
35
38
  export { query, defineQueries, type QueryDef, type QueryRecord, type Queries, type InferQueryInput, type InferQueryResult, } from './queries.js';
39
+ export { schemaToOpenApi, type SchemaToOpenApiOptions } from './openapi.js';
@@ -28,6 +28,17 @@ export { field, indexed, getFieldMeta } from './field.js';
28
28
  export { relation } from './relation.js';
29
29
  // Tenancy — the single source of truth for how a model's rows are tenant-scoped.
30
30
  export { tenancySchema, scopedViaRefSchema, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, } from './tenancy.js';
31
+ // Database plane — which DB a model's rows live in (`tenant` portable to a BYO
32
+ // customer DB, `control` = Ablo's own). Sibling axis to `tenancy`.
33
+ export { planeSchema, DEFAULT_PLANE } from './plane.js';
34
+ // Decomposed sync-delta storage row (P0 of the control/tenant plane split —
35
+ // see docs/plans/sync-delta-zod-decomposition.md). Describes the existing
36
+ // `sync_deltas` columns as Zod schemas grouped by subsystem + database plane.
37
+ export { syncDeltaCoreSchema, deltaAttributionSchema, deltaProvenanceSchema, syncDeltaRowSchema, participantKindSchema, confirmationStateSchema, backfillProvenanceSchema, DELTA_PLANES, } from './sync-delta-row.js';
38
+ // Canonical WIRE delta contract — the broadcast (server→client) projection of
39
+ // the stored row. The SDK client and the sync-server both derive their
40
+ // `SyncDelta` type from these via `z.infer` so the contract cannot drift.
41
+ export { syncDeltaActionSchema, wireDeltaDataSchema, participantRefSchema, syncDeltaWireCoreSchema, clientSyncDeltaSchema, serverSyncDeltaSchema, } from './sync-delta-wire.js';
31
42
  // Model builder
32
43
  export { model, scopeKindOf, } from './model.js';
33
44
  // Intent-first shorthand: `mutable.lazy({...})` and friends. Read the
@@ -48,3 +59,4 @@ export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlocke
48
59
  export { generateTypes } from './generate.js';
49
60
  // Query definition DSL + type inference
50
61
  export { query, defineQueries, } from './queries.js';
62
+ export { schemaToOpenApi } from './openapi.js';
@@ -22,6 +22,7 @@ import type { EntityRole } from './roles.js';
22
22
  import { type FieldMeta } from './field.js';
23
23
  import { type Tenancy, type ScopedViaRef } from './tenancy.js';
24
24
  export type { ScopedViaRef, Tenancy } from './tenancy.js';
25
+ import { type SchemaPlane } from './plane.js';
25
26
  /**
26
27
  * Controls when model data is loaded from the server.
27
28
  *
@@ -130,6 +131,13 @@ export interface ModelOptions {
130
131
  * directly only to author the union form explicitly.
131
132
  */
132
133
  tenancy?: Tenancy;
134
+ /**
135
+ * Which database plane this model's rows live in. `tenant` (default) =
136
+ * the tenant data plane, emitted into a customer's BYO/dedicated DB by
137
+ * provisioning. `control` = Ablo's control plane (sync log, attribution,
138
+ * audit) — never emitted into a customer DB. See `./plane.ts`.
139
+ */
140
+ plane?: SchemaPlane;
133
141
  /**
134
142
  * Marks this model as a **scope root** — a model that forms a sync group of
135
143
  * its own. A scope-root record lives in the group `<kind>:<id>`, where `kind`
@@ -321,6 +329,9 @@ export interface ModelDef<Shape extends z.ZodRawShape = z.ZodRawShape, R extends
321
329
  /** Canonical tenancy descriptor — the single source of truth, normalized from
322
330
  * the `orgScoped`/`scopedVia`/`orgColumn` authoring sugar at build. */
323
331
  readonly tenancy: Tenancy;
332
+ /** Database plane — `tenant` (default) is portable to a customer DB; `control`
333
+ * is Ablo-only. See {@link ModelOptions.plane} and `./plane.ts`. */
334
+ readonly plane?: SchemaPlane;
324
335
  /** Scope-root marker. See {@link ModelOptions.scope}. */
325
336
  readonly scope?: boolean | string;
326
337
  /** Membership edge granting identity → scope-root access. See {@link ModelOptions.grants}. */
@@ -22,6 +22,7 @@ import { getFieldMeta, inferFieldMetaFromZod } from './field.js';
22
22
  // re-exported so existing `import { ScopedViaRef } from './model'` call sites
23
23
  // keep resolving while consumers migrate.
24
24
  import { resolveTenancy } from './tenancy.js';
25
+ import { DEFAULT_PLANE } from './plane.js';
25
26
  /** Normalize the `entityRoles` option (single | array | undefined) to an array. */
26
27
  function normalizeEntityRoles(input) {
27
28
  if (!input)
@@ -94,6 +95,7 @@ export function model(shape, relations, options) {
94
95
  scopedVia: options?.scopedVia,
95
96
  orgColumn: options?.orgColumn,
96
97
  }),
98
+ plane: options?.plane ?? DEFAULT_PLANE,
97
99
  scope: options?.scope,
98
100
  grants: options?.grants,
99
101
  entityRoles: normalizeEntityRoles(options?.entityRoles),
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `schemaToOpenApi(schema)` — generate an OpenAPI 3.1 spec FROM a pushed schema,
3
+ * so the API Reference reflects the customer's OWN models, not Ablo's.
4
+ *
5
+ * The API surface *is* the schema: a `task` model is what makes `/v1/models/task`
6
+ * exist. This walks `schema.models[*].fields` (the introspectable `FieldMeta`)
7
+ * and emits, per model, the CRUD + coordination routes the hosted API serves:
8
+ * GET/POST /v1/models/{model}
9
+ * GET/PATCH/DELETE /v1/models/{model}/{id}
10
+ * POST/DELETE /v1/models/{model}/{id}/claim
11
+ * POST /v1/models/{model}/{id}/claim/reorder
12
+ * plus POST /v1/commits. Auth is a single Bearer scheme (the API key).
13
+ *
14
+ * Wire it into `ablo` codegen (e.g. `ablo openapi > openapi.json`) or serve it
15
+ * per-org; the output is a plain JSON-able object.
16
+ */
17
+ import type { Schema, SchemaRecord } from './schema.js';
18
+ export interface SchemaToOpenApiOptions {
19
+ /** Spec title. Default `"Ablo API"`. */
20
+ readonly title?: string;
21
+ /** Spec version. Default `"1.0.0"`. */
22
+ readonly version?: string;
23
+ /** API base URL. Default `"https://api.abloatai.com/api"`. */
24
+ readonly serverUrl?: string;
25
+ }
26
+ type Json = Record<string, unknown>;
27
+ export declare function schemaToOpenApi<S extends SchemaRecord>(schema: Schema<S>, options?: SchemaToOpenApiOptions): Json;
28
+ export {};
@@ -0,0 +1,118 @@
1
+ function fieldSchema(f) {
2
+ switch (f.type) {
3
+ case 'number':
4
+ return { type: 'number' };
5
+ case 'boolean':
6
+ return { type: 'boolean' };
7
+ case 'date':
8
+ return { type: 'string', format: 'date-time' };
9
+ case 'enum':
10
+ return f.enumValues ? { type: 'string', enum: [...f.enumValues] } : { type: 'string' };
11
+ case 'json':
12
+ return { type: 'object', additionalProperties: true };
13
+ case 'string':
14
+ default:
15
+ return { type: 'string' };
16
+ }
17
+ }
18
+ const pascal = (s) => s.charAt(0).toUpperCase() + s.slice(1);
19
+ const idParam = () => ({ name: 'id', in: 'path', required: true, schema: { type: 'string' } });
20
+ const jsonBody = (schema) => ({
21
+ required: true,
22
+ content: { 'application/json': { schema } },
23
+ });
24
+ const jsonResp = (description, schema) => ({
25
+ description,
26
+ content: { 'application/json': { schema } },
27
+ });
28
+ const commitReceipt = () => jsonResp('Commit receipt', {
29
+ type: 'object',
30
+ properties: {
31
+ object: { type: 'string', enum: ['commit_receipt'] },
32
+ clientTxId: { type: 'string' },
33
+ serverTxId: { type: 'string' },
34
+ success: { type: 'boolean' },
35
+ lastSyncId: { type: 'integer' },
36
+ },
37
+ });
38
+ export function schemaToOpenApi(schema, options = {}) {
39
+ const models = schema.models;
40
+ const paths = {};
41
+ const schemas = {};
42
+ for (const [key, def] of Object.entries(models)) {
43
+ const ref = { $ref: `#/components/schemas/${pascal(key)}` };
44
+ const properties = { id: { type: 'string' } };
45
+ const required = ['id'];
46
+ const createProps = {};
47
+ for (const [fname, fmeta] of Object.entries(def.fields)) {
48
+ const fs = fieldSchema(fmeta);
49
+ properties[fname] = fs;
50
+ createProps[fname] = fs;
51
+ if (!fmeta.isOptional)
52
+ required.push(fname);
53
+ }
54
+ schemas[pascal(key)] = { type: 'object', properties, required };
55
+ const createBody = jsonBody({ type: 'object', properties: createProps });
56
+ paths[`/v1/models/${key}`] = {
57
+ get: {
58
+ tags: [key],
59
+ summary: `List ${key}`,
60
+ parameters: [
61
+ { name: 'limit', in: 'query', schema: { type: 'integer' } },
62
+ { name: 'order_by', in: 'query', schema: { type: 'string' } },
63
+ { name: 'order', in: 'query', schema: { type: 'string', enum: ['asc', 'desc'] } },
64
+ ],
65
+ responses: {
66
+ '200': jsonResp('List of rows', {
67
+ type: 'object',
68
+ properties: { object: { type: 'string', enum: ['list'] }, data: { type: 'array', items: ref } },
69
+ }),
70
+ },
71
+ },
72
+ post: { tags: [key], summary: `Create a ${key}`, requestBody: createBody, responses: { '200': commitReceipt() } },
73
+ };
74
+ paths[`/v1/models/${key}/{id}`] = {
75
+ get: {
76
+ tags: [key],
77
+ summary: `Retrieve a ${key}`,
78
+ parameters: [idParam()],
79
+ responses: {
80
+ '200': jsonResp('The row', {
81
+ type: 'object',
82
+ properties: { data: ref, stamp: { type: 'integer' } },
83
+ }),
84
+ },
85
+ },
86
+ patch: { tags: [key], summary: `Update a ${key}`, parameters: [idParam()], requestBody: createBody, responses: { '200': commitReceipt() } },
87
+ delete: { tags: [key], summary: `Delete a ${key}`, parameters: [idParam()], responses: { '200': commitReceipt() } },
88
+ };
89
+ paths[`/v1/models/${key}/{id}/claim`] = {
90
+ post: { tags: [key], summary: `Claim a ${key} (acquire lease)`, parameters: [idParam()], responses: { '200': jsonResp('Claim acquired', { type: 'object' }) } },
91
+ delete: { tags: [key], summary: `Release a ${key} claim`, parameters: [idParam()], responses: { '200': jsonResp('Released', { type: 'object' }) } },
92
+ };
93
+ paths[`/v1/models/${key}/{id}/claim/reorder`] = {
94
+ post: { tags: [key], summary: `Reorder the ${key} wait-line (privileged)`, parameters: [idParam()], responses: { '200': jsonResp('Reordered', { type: 'object' }) } },
95
+ };
96
+ }
97
+ paths['/v1/commits'] = {
98
+ post: { tags: ['commits'], summary: 'Commit a batch of operations atomically', responses: { '200': commitReceipt() } },
99
+ };
100
+ return {
101
+ openapi: '3.1.0',
102
+ info: {
103
+ title: options.title ?? 'Ablo API',
104
+ version: options.version ?? '1.0.0',
105
+ description: 'Generated from your pushed Ablo schema — these routes are your models. ' +
106
+ 'Authenticate every request with your API key as a Bearer token.',
107
+ },
108
+ servers: [{ url: options.serverUrl ?? 'https://api.abloatai.com/api' }],
109
+ security: [{ bearerAuth: [] }],
110
+ components: {
111
+ securitySchemes: {
112
+ bearerAuth: { type: 'http', scheme: 'bearer', description: 'Your Ablo API key (sk_… / rk_…).' },
113
+ },
114
+ schemas,
115
+ },
116
+ paths,
117
+ };
118
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Database PLANE — which database a model's rows live in. A sibling axis to
3
+ * `tenancy` (which says how rows are isolated *within* a database):
4
+ *
5
+ * - `tenant` — the tenant data plane. For a BYO/dedicated customer this is
6
+ * THEIR database; provisioning emits these tables there.
7
+ * - `control` — Ablo's control plane (the sync log, attribution, audit, …).
8
+ * Never emitted into a customer DB; lives only in Ablo's own DB.
9
+ *
10
+ * P1 of the sync-delta decomposition (`docs/plans/sync-delta-zod-decomposition.md`):
11
+ * declaring the boundary lets BYO provisioning *derive* "what a customer DB gets"
12
+ * (`plane === 'tenant'`) instead of hand-coding it. Defaults to `tenant` —
13
+ * today every `defineSchema` model is the customer's own data; only Ablo's
14
+ * internal tables (once modeled in P2) declare `control`.
15
+ */
16
+ import { z } from 'zod';
17
+ export declare const planeSchema: z.ZodEnum<{
18
+ tenant: "tenant";
19
+ control: "control";
20
+ }>;
21
+ export type SchemaPlane = z.infer<typeof planeSchema>;
22
+ /** Default plane for a model that doesn't declare one — the tenant data plane. */
23
+ export declare const DEFAULT_PLANE: SchemaPlane;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Database PLANE — which database a model's rows live in. A sibling axis to
3
+ * `tenancy` (which says how rows are isolated *within* a database):
4
+ *
5
+ * - `tenant` — the tenant data plane. For a BYO/dedicated customer this is
6
+ * THEIR database; provisioning emits these tables there.
7
+ * - `control` — Ablo's control plane (the sync log, attribution, audit, …).
8
+ * Never emitted into a customer DB; lives only in Ablo's own DB.
9
+ *
10
+ * P1 of the sync-delta decomposition (`docs/plans/sync-delta-zod-decomposition.md`):
11
+ * declaring the boundary lets BYO provisioning *derive* "what a customer DB gets"
12
+ * (`plane === 'tenant'`) instead of hand-coding it. Defaults to `tenant` —
13
+ * today every `defineSchema` model is the customer's own data; only Ablo's
14
+ * internal tables (once modeled in P2) declare `control`.
15
+ */
16
+ import { z } from 'zod';
17
+ export const planeSchema = z.enum(['tenant', 'control']);
18
+ /** Default plane for a model that doesn't declare one — the tenant data plane. */
19
+ export const DEFAULT_PLANE = 'tenant';
@@ -79,6 +79,26 @@ export interface BelongsToOptions {
79
79
  * "the deck is the parent."
80
80
  */
81
81
  readonly parent?: boolean;
82
+ /**
83
+ * Emit a real Postgres FOREIGN KEY for this relation when provisioning a
84
+ * customer-owned (BYO / dedicated) database. **Independent of `parent`:**
85
+ * `parent` governs sync-group fan-out / visibility (control plane); `fk`
86
+ * governs physical referential integrity (data plane). The two are orthogonal
87
+ * — a relation may set either, both, or neither (mirrors Drizzle's
88
+ * `relations()` vs `references()` split; Zanzibar's `parent` is permission-only
89
+ * and "says nothing about data ownership or lifecycle").
90
+ *
91
+ * Set `fk: true` ONLY when the target row is co-located in the SAME database
92
+ * AND written in the SAME commit as this row, and the reference is to a
93
+ * strong / contained entity. Do NOT set it on provenance / template pointers
94
+ * (`sourceSlideId`, `templateId`), cross-tenant refs, or anything that may be
95
+ * written in a different transaction than its target — a hard FK there would
96
+ * reject the write and break out-of-order sync. Emitted as `DEFERRABLE
97
+ * INITIALLY DEFERRED, ON DELETE NO ACTION` — a pure integrity guard; cascade /
98
+ * nullify is owned by the app-layer mutation pipeline, not the DB. See
99
+ * `foreignKeyStatements` in `ddl.ts`.
100
+ */
101
+ readonly fk?: boolean;
82
102
  }
83
103
  declare const __relationType: unique symbol;
84
104
  declare const __relationTarget: unique symbol;
@@ -8,7 +8,7 @@
8
8
  * This is the GraphQL `printSchema` / `buildSchema` model: one `Schema`
9
9
  * type, two representations. A hosted multi-tenant server obtains a tenant's
10
10
  * `Schema` by `parseSchema(json)` instead of an in-process import — the JSON
11
- * is what travels over the control plane (`ablo schema push`) and is stored
11
+ * is what travels over the control plane (`ablo push`) and is stored
12
12
  * per `(tenant, version)`.
13
13
  *
14
14
  * What round-trips:
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import type { FieldMeta } from './field.js';
28
28
  import type { Tenancy } from './tenancy.js';
29
+ import type { SchemaPlane } from './plane.js';
29
30
  import type { GrantsRef, LoadStrategy, PersistOptions, AutoFillRule } from './model.js';
30
31
  import type { RelationType } from './relation.js';
31
32
  import { type Schema, type SchemaRecord, type IdentityRole, type EntityRole } from './schema.js';
@@ -51,6 +52,9 @@ export interface ModelJSON {
51
52
  readonly typename: string;
52
53
  readonly tableName?: string;
53
54
  readonly tenancy: Tenancy;
55
+ /** Database plane. Optional for back-compat: absent in artifacts written before
56
+ * the plane axis → read as `tenant` (the default). See `./plane.ts`. */
57
+ readonly plane?: SchemaPlane;
54
58
  readonly scope?: boolean | string;
55
59
  readonly grants?: GrantsRef;
56
60
  readonly entityRoles?: readonly EntityRole[];
@@ -62,7 +66,7 @@ export interface ModelJSON {
62
66
  readonly autoFill?: readonly AutoFillRule[];
63
67
  readonly requiredFields?: readonly string[];
64
68
  }
65
- /** The JSON form of a {@link Schema}. The `@ablo schema push` payload. */
69
+ /** The JSON form of a {@link Schema}. The `ablo push` payload. */
66
70
  export interface SchemaJSON {
67
71
  readonly v: typeof SCHEMA_JSON_VERSION;
68
72
  readonly models: Record<string, ModelJSON>;
@@ -74,7 +78,7 @@ export interface SchemaJSON {
74
78
  * rebuild need. The result is plain data — `JSON.stringify`-safe.
75
79
  */
76
80
  export declare function toSchemaJSON(schema: Schema<SchemaRecord>): SchemaJSON;
77
- /** Serialize a `Schema` to a JSON string (the `ablo schema push` payload). */
81
+ /** Serialize a `Schema` to a JSON string (the `ablo push` payload). */
78
82
  export declare function serializeSchema(schema: Schema<SchemaRecord>): string;
79
83
  /**
80
84
  * Reconstruct a working `Schema` from its JSON form. Validators are rebuilt
@@ -8,7 +8,7 @@
8
8
  * This is the GraphQL `printSchema` / `buildSchema` model: one `Schema`
9
9
  * type, two representations. A hosted multi-tenant server obtains a tenant's
10
10
  * `Schema` by `parseSchema(json)` instead of an in-process import — the JSON
11
- * is what travels over the control plane (`ablo schema push`) and is stored
11
+ * is what travels over the control plane (`ablo push`) and is stored
12
12
  * per `(tenant, version)`.
13
13
  *
14
14
  * What round-trips:
@@ -57,6 +57,7 @@ function modelToJSON(def) {
57
57
  typename: def.typename ?? '',
58
58
  tableName: def.tableName,
59
59
  tenancy: def.tenancy,
60
+ plane: def.plane,
60
61
  scope: def.scope,
61
62
  grants: def.grants,
62
63
  entityRoles: def.entityRoles,
@@ -88,7 +89,7 @@ export function toSchemaJSON(schema) {
88
89
  }
89
90
  return { v: SCHEMA_JSON_VERSION, models, identityRoles: schema.identityRoles };
90
91
  }
91
- /** Serialize a `Schema` to a JSON string (the `ablo schema push` payload). */
92
+ /** Serialize a `Schema` to a JSON string (the `ablo push` payload). */
92
93
  export function serializeSchema(schema) {
93
94
  return JSON.stringify(toSchemaJSON(schema));
94
95
  }
@@ -161,6 +162,9 @@ function modelFromJSON(json) {
161
162
  persist: json.persist,
162
163
  tableName: json.tableName,
163
164
  tenancy: json.tenancy,
165
+ // Absent in pre-plane-axis artifacts → default `tenant` (matches the model
166
+ // builder default + the provisioning fallback), so the round-trip is stable.
167
+ plane: json.plane ?? 'tenant',
164
168
  scope: json.scope,
165
169
  grants: json.grants,
166
170
  entityRoles: json.entityRoles,