@abloatai/ablo 0.29.3 → 0.30.1

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 (118) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +3 -2
  3. package/dist/BaseSyncedStore.js +3 -1
  4. package/dist/Database.js +29 -2
  5. package/dist/SyncClient.d.ts +5 -3
  6. package/dist/SyncClient.js +8 -4
  7. package/dist/ai-sdk/coordinatedTool.d.ts +0 -1
  8. package/dist/ai-sdk/coordinatedTool.js +0 -1
  9. package/dist/ai-sdk/coordinationContext.js +1 -5
  10. package/dist/cli.cjs +377 -84
  11. package/dist/client/Ablo.d.ts +1 -1
  12. package/dist/client/Ablo.js +24 -36
  13. package/dist/client/auth.d.ts +8 -18
  14. package/dist/client/auth.js +16 -96
  15. package/dist/client/createModelProxy.d.ts +16 -11
  16. package/dist/client/createModelProxy.js +162 -20
  17. package/dist/client/httpClient.d.ts +1 -1
  18. package/dist/client/httpTransport.js +263 -89
  19. package/dist/client/options.d.ts +0 -15
  20. package/dist/client/resourceTypes.d.ts +13 -26
  21. package/dist/client/schemaConfig.js +5 -0
  22. package/dist/client/writeOptionsSchema.d.ts +1 -0
  23. package/dist/client/writeOptionsSchema.js +11 -2
  24. package/dist/client/wsMutationExecutor.d.ts +3 -5
  25. package/dist/coordination/schema.d.ts +17 -13
  26. package/dist/coordination/schema.js +36 -10
  27. package/dist/errorCodes.d.ts +9 -4
  28. package/dist/errorCodes.js +10 -5
  29. package/dist/errors.js +4 -9
  30. package/dist/index.d.ts +4 -0
  31. package/dist/interfaces/index.d.ts +31 -19
  32. package/dist/schema/index.d.ts +1 -1
  33. package/dist/schema/index.js +4 -1
  34. package/dist/schema/schema.d.ts +13 -0
  35. package/dist/schema/select.js +7 -0
  36. package/dist/schema/syncDeltaRow.d.ts +31 -13
  37. package/dist/schema/syncDeltaRow.js +33 -15
  38. package/dist/schema/tenancy.d.ts +52 -16
  39. package/dist/schema/tenancy.js +86 -25
  40. package/dist/server/commit.d.ts +8 -24
  41. package/dist/server/index.d.ts +1 -0
  42. package/dist/server/index.js +1 -0
  43. package/dist/server/readConfig.d.ts +10 -0
  44. package/dist/source/adapter.d.ts +26 -15
  45. package/dist/source/adapter.js +5 -3
  46. package/dist/source/adapters/drizzle.js +35 -9
  47. package/dist/source/adapters/kysely.d.ts +33 -61
  48. package/dist/source/adapters/kysely.js +170 -177
  49. package/dist/source/adapters/kyselyMutationCore.d.ts +76 -0
  50. package/dist/source/adapters/kyselyMutationCore.js +125 -0
  51. package/dist/source/adapters/memory.d.ts +1 -1
  52. package/dist/source/adapters/memory.js +24 -10
  53. package/dist/source/adapters/prisma.js +35 -8
  54. package/dist/source/conformance.d.ts +26 -28
  55. package/dist/source/conformance.js +142 -69
  56. package/dist/source/contract.d.ts +46 -4
  57. package/dist/source/contract.js +66 -5
  58. package/dist/source/factory.d.ts +3 -3
  59. package/dist/source/factory.js +24 -3
  60. package/dist/source/idempotency.d.ts +46 -0
  61. package/dist/source/idempotency.js +129 -0
  62. package/dist/source/index.d.ts +6 -4
  63. package/dist/source/index.js +5 -3
  64. package/dist/source/migrations.d.ts +9 -5
  65. package/dist/source/migrations.js +39 -6
  66. package/dist/source/types.d.ts +88 -14
  67. package/dist/source/types.js +16 -0
  68. package/dist/surface.d.ts +1 -1
  69. package/dist/surface.js +0 -1
  70. package/dist/sync/BootstrapFetcher.js +7 -0
  71. package/dist/sync/SyncWebSocket.d.ts +5 -27
  72. package/dist/sync/awaitClaimGrant.d.ts +6 -0
  73. package/dist/sync/awaitClaimGrant.js +12 -2
  74. package/dist/sync/commitFrames.d.ts +2 -4
  75. package/dist/sync/commitFrames.js +1 -0
  76. package/dist/sync/createClaimStream.d.ts +1 -1
  77. package/dist/sync/createClaimStream.js +13 -15
  78. package/dist/sync/deltaPipeline.d.ts +1 -1
  79. package/dist/sync/deltaPipeline.js +1 -1
  80. package/dist/sync/participants.d.ts +2 -3
  81. package/dist/sync/participants.js +2 -2
  82. package/dist/sync/wsFrameHandlers.js +35 -15
  83. package/dist/testing/mocks/MockMutationExecutor.d.ts +21 -0
  84. package/dist/testing/mocks/MockMutationExecutor.js +53 -1
  85. package/dist/transactions/TransactionQueue.d.ts +61 -9
  86. package/dist/transactions/TransactionQueue.js +502 -114
  87. package/dist/transactions/commitEnvelope.d.ts +4 -0
  88. package/dist/transactions/commitEnvelope.js +25 -0
  89. package/dist/transactions/commitPayload.d.ts +23 -5
  90. package/dist/transactions/commitPayload.js +3 -0
  91. package/dist/transactions/deltaConfirmation.d.ts +2 -1
  92. package/dist/transactions/deltaConfirmation.js +22 -4
  93. package/dist/transactions/durableWriteStore.d.ts +8 -1
  94. package/dist/transactions/httpCommitEnvelope.d.ts +3 -1
  95. package/dist/transactions/httpCommitEnvelope.js +30 -0
  96. package/dist/transactions/replayValidation.d.ts +4 -0
  97. package/dist/transactions/replayValidation.js +6 -0
  98. package/dist/types/streams.d.ts +29 -17
  99. package/dist/wire/commit.d.ts +671 -0
  100. package/dist/wire/commit.js +300 -0
  101. package/dist/wire/delta.d.ts +4 -0
  102. package/dist/wire/delta.js +9 -0
  103. package/dist/wire/frames.d.ts +17 -45
  104. package/dist/wire/index.d.ts +2 -0
  105. package/dist/wire/index.js +4 -0
  106. package/docs/agent-messaging.md +4 -5
  107. package/docs/agents.md +1 -2
  108. package/docs/api-keys.md +1 -2
  109. package/docs/client-behavior.md +4 -6
  110. package/docs/coordination.md +64 -9
  111. package/docs/data-sources.md +4 -7
  112. package/docs/integration-guide.md +6 -8
  113. package/docs/migration.md +0 -3
  114. package/docs/quickstart.md +7 -8
  115. package/docs/schema-contract.md +7 -8
  116. package/package.json +4 -4
  117. package/dist/client/registerDataSource.d.ts +0 -19
  118. package/dist/client/registerDataSource.js +0 -66
@@ -6,7 +6,8 @@
6
6
  * session-error detection, online-status checks, and the transport that carries
7
7
  * mutations to your backend. The SDK ships sensible no-op defaults where it can.
8
8
  */
9
- import type { StaleNotification, ReadDependency, ParticipantKind } from '../coordination/schema.js';
9
+ import type { ReadDependency, ParticipantKind } from '../coordination/schema.js';
10
+ import type { CommitStatus, MutationCommitResultInput } from '../wire/commit.js';
10
11
  export interface SyncLogger {
11
12
  debug(message: string, ...args: unknown[]): void;
12
13
  info(message: string, ...args: unknown[]): void;
@@ -177,22 +178,12 @@ export interface ModelDebugLoggerContract {
177
178
  logCreation(modelName: string, data: unknown, constructor: unknown): void;
178
179
  logObservableSetup(modelName: string, observableProps: string[], computedProps: string[]): void;
179
180
  }
180
- /** Result of a successful `commit()` — server's sync cursor after the batch landed. */
181
- export interface CommitResult {
182
- lastSyncId: number;
183
- /**
184
- * Stale-context notifications. Present only when a write guarded with
185
- * `onStale: 'notify'` collided with a concurrent change: rather than throwing
186
- * an `AbloStaleContextError`, the commit succeeds and reports the collision
187
- * here so the caller can reconcile. See {@link StaleNotification}.
188
- */
189
- notifications?: StaleNotification[];
190
- /**
191
- * Ids of update or delete targets that matched no rows. Present, and non-empty,
192
- * only when a write missed the row it addressed.
193
- */
194
- missingIds?: string[];
195
- }
181
+ /**
182
+ * Result returned by an injected mutation executor. The input type is inferred
183
+ * from the runtime compatibility schema: legacy `{lastSyncId}` remains valid,
184
+ * while every explicit queued result requires a WAL correlation.
185
+ */
186
+ export type CommitResult = MutationCommitResultInput;
196
187
  /**
197
188
  * Per-call options accepted by any mutation, passed as the last argument.
198
189
  * Every field is optional; omitted fields fall back to sensible defaults.
@@ -208,9 +199,15 @@ export interface CommitResult {
208
199
  export interface MutationOptions {
209
200
  idempotencyKey?: string | null;
210
201
  label?: string;
211
- wait?: 'queued' | 'confirmed';
202
+ wait?: CommitStatus;
212
203
  readAt?: number | null;
213
204
  onStale?: 'reject' | 'overwrite' | 'notify' | null;
205
+ /**
206
+ * The fencing token (Option B) of the held claim this write belongs to. The
207
+ * server validates it against the entity's persisted high-water and rejects a
208
+ * stale token. Sourced from the claim handle, never set by hand.
209
+ */
210
+ fenceToken?: number | null;
214
211
  /** The id (or `{ id }`) of the claim this write belongs to. This is the
215
212
  * low-level reference the commit carries so the write is attributed to a claim
216
213
  * and can pass the holder's own lock. It is distinct from the `claim` handle on
@@ -243,7 +240,7 @@ export interface MutationOptions {
243
240
  * `claim` are deliberately absent: both are resolved on the client before a write
244
241
  * is staged, so neither reaches this layer.
245
242
  */
246
- export type WriteOptions = Pick<MutationOptions, 'readAt' | 'onStale' | 'idempotencyKey' | 'label'>;
243
+ export type WriteOptions = Pick<MutationOptions, 'readAt' | 'onStale' | 'idempotencyKey' | 'label' | 'fenceToken'>;
247
244
  /** A single mutation within a batch. Its `options` travel with it so the server
248
245
  * can cache and replay the operation for idempotent retries. */
249
246
  export interface MutationOperation {
@@ -265,6 +262,11 @@ export interface MutationOperation {
265
262
  transactionId?: string;
266
263
  readAt?: number | null;
267
264
  onStale?: 'reject' | 'overwrite' | 'notify' | null;
265
+ /**
266
+ * The fencing token (Option B) carried on the wire for this op — the held
267
+ * claim's token, validated against the entity's high-water at commit.
268
+ */
269
+ fenceToken?: number | null;
268
270
  /**
269
271
  * Per-operation idempotency and audit metadata. `idempotencyKey` is also the
270
272
  * cache key the server uses to de-duplicate retries; `label` is stored for
@@ -365,6 +367,16 @@ export interface SyncEngineConfig {
365
367
  * enforced.
366
368
  */
367
369
  expectedSchemaHash?: string;
370
+ /**
371
+ * Set only when the bound schema is a projection (`selectModels`/`omitModels`):
372
+ * the content hash of the full source schema the subset was cut from. A subset
373
+ * hashes differently from its full schema, so without this a projection-bound
374
+ * client always reports drift against a server running the full schema. The
375
+ * drift check treats the client as in-sync when the server's active hash
376
+ * matches either `expectedSchemaHash` or this source hash. Advisory, like the
377
+ * hash above.
378
+ */
379
+ expectedSourceSchemaHash?: string;
368
380
  }
369
381
  /**
370
382
  * Extends the WebSocket event map with your own collaboration events, such as
@@ -25,7 +25,7 @@ export { field, indexed, getFieldMeta, type FieldBuilder, type FieldMeta } from
25
25
  export { relation, type RelationDef, type RelationType } from './relation.js';
26
26
  export { tenancySchema, scopedViaRefSchema, policyInputSchema, resolvePolicy, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, type Tenancy, type ScopedViaRef, type PolicyInput, } from './tenancy.js';
27
27
  export { residencySchema, DEFAULT_RESIDENCY, type ModelResidency, planeSchema, DEFAULT_PLANE, type SchemaPlane, } from './residency.js';
28
- export { syncDeltaCoreSchema, deltaAttributionSchema, deltaProvenanceSchema, syncDeltaRowSchema, participantKindSchema, confirmationStateSchema, backfillProvenanceSchema, DELTA_RESIDENCY, type SyncDeltaCore, type DeltaAttribution, type DeltaProvenance, type SyncDeltaRow, type ParticipantKind, type ConfirmationState, type BackfillProvenance, } from './syncDeltaRow.js';
28
+ export { syncDeltaCoreSchema, deltaAttributionSchema, deltaProvenanceSchema, syncDeltaRowSchema, participantKindSchema, confirmationStateSchema, backfillProvenanceSchema, DELTA_DATA_CLASSIFICATION, DELTA_PHYSICAL_STORAGE, DELTA_RESIDENCY, type SyncDeltaCore, type DeltaAttribution, type DeltaProvenance, type SyncDeltaRow, type ParticipantKind, type ConfirmationState, type BackfillProvenance, } from './syncDeltaRow.js';
29
29
  export { syncDeltaActionSchema, wireDeltaDataSchema, participantRefSchema, syncDeltaWireCoreSchema, clientSyncDeltaSchema, serverSyncDeltaSchema, type SyncDeltaAction, type WireDeltaData, type ParticipantRef, type SyncDeltaWireCore, type ClientSyncDelta, type ServerSyncDelta, } from '../wire/delta.js';
30
30
  export { model, scopeKindOf, type ModelDef, type ModelOptions, type LoadStrategy, type PersistOptions, type RelationRecord, type GrantsRef, type ConflictAxis, } from './model.js';
31
31
  export { coordination, humansOverwrite, humansReject, humansNotify, agentsOverwrite, agentsReject, agentsNotify, systemOverwrite, systemReject, systemNotify, type ConflictRule, } from './coordination.js';
@@ -38,7 +38,10 @@ planeSchema,
38
38
  DEFAULT_PLANE, } from './residency.js';
39
39
  // The stored `sync_deltas` row, described as Zod schemas grouped by subsystem and by
40
40
  // which database the columns live in.
41
- export { syncDeltaCoreSchema, deltaAttributionSchema, deltaProvenanceSchema, syncDeltaRowSchema, participantKindSchema, confirmationStateSchema, backfillProvenanceSchema, DELTA_RESIDENCY, } from './syncDeltaRow.js';
41
+ export { syncDeltaCoreSchema, deltaAttributionSchema, deltaProvenanceSchema, syncDeltaRowSchema, participantKindSchema, confirmationStateSchema, backfillProvenanceSchema, DELTA_DATA_CLASSIFICATION, DELTA_PHYSICAL_STORAGE,
42
+ // Deliberate back-compat re-export; physical storage is the precise name.
43
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
44
+ DELTA_RESIDENCY, } from './syncDeltaRow.js';
42
45
  // The wire delta contract — the server-to-client projection of the stored row.
43
46
  // Both the client SDK and the server derive their `SyncDelta` type from these with
44
47
  // `z.infer`, so the two ends cannot drift apart.
@@ -114,6 +114,19 @@ export interface Schema<S extends SchemaRecord = SchemaRecord> {
114
114
  * to derive a participant's allowed sync-group set.
115
115
  */
116
116
  readonly identityRoles: readonly IdentityRole[];
117
+ /**
118
+ * Set only on a projection produced by `selectModels`/`omitModels`: the
119
+ * content hash of the FULL source schema the subset was cut from. A subset
120
+ * hashes differently from the full schema it belongs to, so a projection-bound
121
+ * client would otherwise always report drift against a server running the full
122
+ * schema. The drift check treats the client as in-sync when the server's active
123
+ * hash matches EITHER this client's own (subset) hash or this source hash — so
124
+ * a current projection stays quiet while a genuinely behind server still warns.
125
+ * Absent on a schema authored directly, where the client's own hash is the
126
+ * deployed hash and plain equality is correct. Excluded from `toSchemaJSON`, so
127
+ * stamping it never perturbs `schemaHash`.
128
+ */
129
+ readonly sourceSchemaHash?: string;
117
130
  }
118
131
  /**
119
132
  * Infer the full model type from a schema.
@@ -22,6 +22,7 @@
22
22
  * a record's fan-out — the selected set must be closed under `parent` edges.
23
23
  */
24
24
  import { AbloValidationError } from '../errors.js';
25
+ import { schemaHash } from './serialize.js';
25
26
  export function selectModels(schema, keys) {
26
27
  const keep = new Set(keys);
27
28
  const models = {};
@@ -51,6 +52,12 @@ export function selectModels(schema, keys) {
51
52
  models: models,
52
53
  validators: validators,
53
54
  identityRoles: schema.identityRoles,
55
+ // Record the full source's hash so the drift check can recognize this subset
56
+ // as current against a server running that full schema. Prefer the source's
57
+ // OWN `sourceSchemaHash` when it is itself a projection, so a subset-of-a-
58
+ // subset still points at the original full schema rather than an intermediate
59
+ // one. `schemaHash` ignores this field, so re-projecting stays deterministic.
60
+ sourceSchemaHash: schema.sourceSchemaHash ?? schemaHash(schema),
54
61
  };
55
62
  }
56
63
  /**
@@ -4,14 +4,17 @@
4
4
  * serves:
5
5
  *
6
6
  * - {@link syncDeltaCoreSchema} — the sync-protocol slice: everything a client
7
- * needs to reconstruct the change, plus the tenant key. This is the portable
8
- * part, and the only part written atomically with the application row.
7
+ * needs to reconstruct the change, plus the tenant key. This is a portable
8
+ * payload shape; it is not a statement that the row is physically stored in
9
+ * the customer's database.
9
10
  * - {@link deltaAttributionSchema} — who made the change, and on whose authority.
10
11
  * - {@link deltaProvenanceSchema} — which AI task, if any, produced it.
11
12
  *
12
13
  * {@link syncDeltaRowSchema} composes all three into the full stored row.
13
- * {@link DELTA_RESIDENCY} records which database each slice lives in, so
14
- * provisioning can derive that boundary from the schema instead of hand-coding it.
14
+ * {@link DELTA_DATA_CLASSIFICATION} states which slices contain customer data,
15
+ * while {@link DELTA_PHYSICAL_STORAGE} states where the runtime persists them.
16
+ * Keeping those axes separate prevents a portable schema slice from being cited
17
+ * incorrectly as a physical-residency guarantee.
15
18
  *
16
19
  * This is the stored shape. The delta broadcast to clients is a narrower projection
17
20
  * of it — see {@link import('../wire/delta.js').syncDeltaWireCoreSchema} — and
@@ -29,10 +32,11 @@ export declare const backfillProvenanceSchema: z.ZodEnum<{
29
32
  export type BackfillProvenance = z.infer<typeof backfillProvenanceSchema>;
30
33
  /**
31
34
  * Everything a client needs to materialize the change, plus the tenant key. This
32
- * is the portable slice: the only part written atomically with the application
33
- * row, and the shape an outbox marker in a customer's own database carries. `id`,
34
- * `createdAt`, and `syncGroups` are assigned by the server when the delta is
35
- * appended, so they are optional here an outbox marker does not have them yet.
35
+ * is the portable shape an authoritative WAL change or endpoint event carries.
36
+ * The runtime persists the resulting full row in Ablo's tenant-scoped ordered
37
+ * sync log; it is not written atomically with a direct application-row mutation.
38
+ * `id`, `createdAt`, and `syncGroups` are assigned when the source change is
39
+ * appended, so they are optional at the ingestion boundary.
36
40
  */
37
41
  export declare const syncDeltaCoreSchema: z.ZodObject<{
38
42
  id: z.ZodOptional<z.ZodUnion<readonly [z.ZodBigInt, z.ZodNumber]>>;
@@ -45,6 +49,7 @@ export declare const syncDeltaCoreSchema: z.ZodObject<{
45
49
  organizationId: z.ZodNullable<z.ZodString>;
46
50
  createdAt: z.ZodOptional<z.ZodString>;
47
51
  transactionId: z.ZodNullable<z.ZodString>;
52
+ sourceChangeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
48
53
  }, z.core.$strip>;
49
54
  export type SyncDeltaCore = z.infer<typeof syncDeltaCoreSchema>;
50
55
  export declare const deltaAttributionSchema: z.ZodObject<{
@@ -93,6 +98,7 @@ export declare const syncDeltaRowSchema: z.ZodObject<{
93
98
  organizationId: z.ZodNullable<z.ZodString>;
94
99
  createdAt: z.ZodOptional<z.ZodString>;
95
100
  transactionId: z.ZodNullable<z.ZodString>;
101
+ sourceChangeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
96
102
  createdBy: z.ZodNullable<z.ZodString>;
97
103
  actorId: z.ZodNullable<z.ZodString>;
98
104
  actorKind: z.ZodNullable<z.ZodEnum<{
@@ -123,14 +129,26 @@ export declare const syncDeltaRowSchema: z.ZodObject<{
123
129
  causedByTaskId: z.ZodNullable<z.ZodString>;
124
130
  }, z.core.$strip>;
125
131
  export type SyncDeltaRow = z.infer<typeof syncDeltaRowSchema>;
132
+ /** Which slices contain retained customer row data versus control metadata. */
133
+ export declare const DELTA_DATA_CLASSIFICATION: {
134
+ readonly core: "customer-data";
135
+ readonly attribution: "control-metadata";
136
+ readonly provenance: "control-metadata";
137
+ };
126
138
  /**
127
- * Maps each slice to the database it belongs in. A customer's own database holds
128
- * only the `tenant` slice; the `control` slices are enriched and stored in the
129
- * host's own database. Provisioning reads this map to decide which columns a given
130
- * database receives, rather than hand-coding the boundary.
139
+ * Where the current runtime physically persists each slice. All three live in
140
+ * Ablo's tenant-scoped `sync_deltas` log. `core` contains full post-change row
141
+ * payloads (and optional previous payloads), so `control` here must not be read
142
+ * as "metadata only."
131
143
  */
144
+ export declare const DELTA_PHYSICAL_STORAGE: {
145
+ readonly core: "control";
146
+ readonly attribution: "control";
147
+ readonly provenance: "control";
148
+ };
149
+ /** @deprecated Use `DELTA_PHYSICAL_STORAGE`; classification is a separate axis. */
132
150
  export declare const DELTA_RESIDENCY: {
133
- readonly core: "tenant";
151
+ readonly core: "control";
134
152
  readonly attribution: "control";
135
153
  readonly provenance: "control";
136
154
  };
@@ -4,14 +4,17 @@
4
4
  * serves:
5
5
  *
6
6
  * - {@link syncDeltaCoreSchema} — the sync-protocol slice: everything a client
7
- * needs to reconstruct the change, plus the tenant key. This is the portable
8
- * part, and the only part written atomically with the application row.
7
+ * needs to reconstruct the change, plus the tenant key. This is a portable
8
+ * payload shape; it is not a statement that the row is physically stored in
9
+ * the customer's database.
9
10
  * - {@link deltaAttributionSchema} — who made the change, and on whose authority.
10
11
  * - {@link deltaProvenanceSchema} — which AI task, if any, produced it.
11
12
  *
12
13
  * {@link syncDeltaRowSchema} composes all three into the full stored row.
13
- * {@link DELTA_RESIDENCY} records which database each slice lives in, so
14
- * provisioning can derive that boundary from the schema instead of hand-coding it.
14
+ * {@link DELTA_DATA_CLASSIFICATION} states which slices contain customer data,
15
+ * while {@link DELTA_PHYSICAL_STORAGE} states where the runtime persists them.
16
+ * Keeping those axes separate prevents a portable schema slice from being cited
17
+ * incorrectly as a physical-residency guarantee.
15
18
  *
16
19
  * This is the stored shape. The delta broadcast to clients is a narrower projection
17
20
  * of it — see {@link import('../wire/delta.js').syncDeltaWireCoreSchema} — and
@@ -31,10 +34,11 @@ const deltaDataSchema = z.record(z.string(), z.unknown()).nullable();
31
34
  // ── Core — the sync-protocol slice ────────────────────────────────────────────
32
35
  /**
33
36
  * Everything a client needs to materialize the change, plus the tenant key. This
34
- * is the portable slice: the only part written atomically with the application
35
- * row, and the shape an outbox marker in a customer's own database carries. `id`,
36
- * `createdAt`, and `syncGroups` are assigned by the server when the delta is
37
- * appended, so they are optional here an outbox marker does not have them yet.
37
+ * is the portable shape an authoritative WAL change or endpoint event carries.
38
+ * The runtime persists the resulting full row in Ablo's tenant-scoped ordered
39
+ * sync log; it is not written atomically with a direct application-row mutation.
40
+ * `id`, `createdAt`, and `syncGroups` are assigned when the source change is
41
+ * appended, so they are optional at the ingestion boundary.
38
42
  */
39
43
  export const syncDeltaCoreSchema = z.object({
40
44
  /** Monotonically increasing sync id, assigned by the server when the delta is appended; absent on an outbox marker. */
@@ -52,6 +56,12 @@ export const syncDeltaCoreSchema = z.object({
52
56
  /** ISO 8601 timestamp, assigned by the server when the delta is appended. */
53
57
  createdAt: z.string().optional(),
54
58
  transactionId: z.string().nullable(),
59
+ /**
60
+ * Stable identity of the authoritative WAL row or endpoint event. It is
61
+ * nullable for hosted/legacy rows and is never exposed as the optimistic
62
+ * client transaction id.
63
+ */
64
+ sourceChangeId: z.string().min(1).nullable().optional(),
55
65
  });
56
66
  // ── Attribution — who made the change, and on whose authority ─────────────────
57
67
  export const deltaAttributionSchema = z.object({
@@ -71,19 +81,27 @@ export const deltaProvenanceSchema = z.object({
71
81
  /** Foreign key to the task record for the AI turn that produced this commit; null when no task applies. */
72
82
  causedByTaskId: z.string().nullable(),
73
83
  });
74
- // ── Full stored row and its residency map ─────────────────────────────────────
84
+ // ── Full stored row, classification, and physical storage ─────────────────────
75
85
  /** The complete `sync_deltas` row: core, attribution, and provenance combined. */
76
86
  export const syncDeltaRowSchema = syncDeltaCoreSchema
77
87
  .extend(deltaAttributionSchema.shape)
78
88
  .extend(deltaProvenanceSchema.shape);
89
+ /** Which slices contain retained customer row data versus control metadata. */
90
+ export const DELTA_DATA_CLASSIFICATION = {
91
+ core: 'customer-data',
92
+ attribution: 'control-metadata',
93
+ provenance: 'control-metadata',
94
+ };
79
95
  /**
80
- * Maps each slice to the database it belongs in. A customer's own database holds
81
- * only the `tenant` slice; the `control` slices are enriched and stored in the
82
- * host's own database. Provisioning reads this map to decide which columns a given
83
- * database receives, rather than hand-coding the boundary.
96
+ * Where the current runtime physically persists each slice. All three live in
97
+ * Ablo's tenant-scoped `sync_deltas` log. `core` contains full post-change row
98
+ * payloads (and optional previous payloads), so `control` here must not be read
99
+ * as "metadata only."
84
100
  */
85
- export const DELTA_RESIDENCY = {
86
- core: 'tenant',
101
+ export const DELTA_PHYSICAL_STORAGE = {
102
+ core: 'control',
87
103
  attribution: 'control',
88
104
  provenance: 'control',
89
105
  };
106
+ /** @deprecated Use `DELTA_PHYSICAL_STORAGE`; classification is a separate axis. */
107
+ export const DELTA_RESIDENCY = DELTA_PHYSICAL_STORAGE;
@@ -14,9 +14,12 @@
14
14
  * model is built, so the authoring vocabulary never reaches the wire or any
15
15
  * consumer.
16
16
  *
17
- * Both forms discriminate on a single tag, which makes the opt-out from tenant
18
- * scoping (`{ by: 'none' }`) an explicit, named branch rather than a falsy flag —
19
- * important, because that branch makes an entire table readable across tenants.
17
+ * The canonical form is an exhaustively switchable discriminated union, so the type
18
+ * system enforces the isolation boundary at every consumer. The authoring form is a
19
+ * flat object whose `by` names the choice and whose per-branch rules are checked by a
20
+ * refinement — the opt-out from tenant scoping (`{ by: 'none' }`) and the
21
+ * source-derived case (`{ by: 'source' }`) are explicit, named values rather than
22
+ * falsy flags, important because `none` makes an entire table readable across tenants.
20
23
  */
21
24
  import { z } from 'zod';
22
25
  /** The default physical tenancy column. This is the single canonical definition of that column name. */
@@ -49,36 +52,69 @@ export declare const tenancySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
49
52
  }, z.core.$strip>;
50
53
  }, z.core.$strip>, z.ZodObject<{
51
54
  kind: z.ZodLiteral<"none">;
55
+ }, z.core.$strip>, z.ZodObject<{
56
+ kind: z.ZodLiteral<"source">;
52
57
  }, z.core.$strip>], "kind">;
53
58
  export type Tenancy = z.infer<typeof tenancySchema>;
54
59
  /**
55
60
  * The authoring form of tenancy — what a schema author writes as the model's
56
61
  * `policy` option. The name follows SQL row-level security, where a policy is the
57
- * rule that decides which rows a tenant may read. It is a discriminated union on
58
- * `by`, so the three branches are mutually exclusive and the opt-out
59
- * (`{ by: 'none' }`) is an explicit, named choice rather than a falsy flag.
62
+ * rule that decides which rows a tenant may read.
63
+ *
64
+ * The four choices `by` can take:
60
65
  *
61
66
  * - `{ by: 'column' }` — a row-local tenancy column (the default). `column`
62
67
  * overrides the name (default {@link DEFAULT_ORG_COLUMN}).
63
68
  * - `{ by: 'parent', fk, parent }` — inherit tenancy through a foreign key when
64
69
  * this table has no tenancy column of its own. `parentKey` (default `'id'`) and
65
70
  * `parentTenantColumn` (default {@link DEFAULT_ORG_COLUMN}) are optional overrides.
71
+ * - `{ by: 'source' }` — derive the organization from the connected source that
72
+ * registered the model, for a single-tenant connected database that carries no
73
+ * `organization_id` column. The registration is the tenant boundary.
66
74
  * - `{ by: 'none' }` — genuinely global or reference data. This makes the whole
67
75
  * table readable across tenants, so it is correct only for tables that have no tenant.
76
+ *
77
+ * The runtime schema is one flat object rather than a discriminated union: `by`
78
+ * selects the branch, and the {@link https://zod.dev | superRefine} pass enforces
79
+ * the per-branch field rules (`parent` requires `fk` and `parent`; the other three
80
+ * take no branch fields). The exported {@link PolicyInput} type is hand-written as a
81
+ * precise union so the compiler still requires the right fields per `by` — the flat
82
+ * schema validates at runtime, the union guides at author time, and the type guard
83
+ * below keeps the two from drifting.
68
84
  */
69
- export declare const policyInputSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
70
- by: z.ZodLiteral<"column">;
85
+ export declare const policyInputSchema: z.ZodObject<{
86
+ by: z.ZodEnum<{
87
+ none: "none";
88
+ source: "source";
89
+ column: "column";
90
+ parent: "parent";
91
+ }>;
71
92
  column: z.ZodOptional<z.ZodString>;
72
- }, z.core.$strip>, z.ZodObject<{
73
- by: z.ZodLiteral<"parent">;
74
- fk: z.ZodString;
75
- parent: z.ZodString;
93
+ fk: z.ZodOptional<z.ZodString>;
94
+ parent: z.ZodOptional<z.ZodString>;
76
95
  parentKey: z.ZodOptional<z.ZodString>;
77
96
  parentTenantColumn: z.ZodOptional<z.ZodString>;
78
- }, z.core.$strip>, z.ZodObject<{
79
- by: z.ZodLiteral<"none">;
80
- }, z.core.$strip>], "by">;
81
- export type PolicyInput = z.infer<typeof policyInputSchema>;
97
+ }, z.core.$strip>;
98
+ /**
99
+ * The precise authoring type. Hand-written rather than inferred from
100
+ * {@link policyInputSchema} so the compiler enforces the per-branch fields the flat
101
+ * runtime schema can only check at parse time — an author writing `{ by: 'parent' }`
102
+ * gets a type error for the missing `fk`/`parent`, not a runtime one.
103
+ */
104
+ export type PolicyInput = {
105
+ by: 'column';
106
+ column?: string;
107
+ } | {
108
+ by: 'parent';
109
+ fk: string;
110
+ parent: string;
111
+ parentKey?: string;
112
+ parentTenantColumn?: string;
113
+ } | {
114
+ by: 'source';
115
+ } | {
116
+ by: 'none';
117
+ };
82
118
  /**
83
119
  * Normalizes the authoring {@link PolicyInput} into the canonical {@link Tenancy}.
84
120
  * Called once, when the model is built, so that `ModelDef`, `ModelJSON`, and every
@@ -14,9 +14,12 @@
14
14
  * model is built, so the authoring vocabulary never reaches the wire or any
15
15
  * consumer.
16
16
  *
17
- * Both forms discriminate on a single tag, which makes the opt-out from tenant
18
- * scoping (`{ by: 'none' }`) an explicit, named branch rather than a falsy flag —
19
- * important, because that branch makes an entire table readable across tenants.
17
+ * The canonical form is an exhaustively switchable discriminated union, so the type
18
+ * system enforces the isolation boundary at every consumer. The authoring form is a
19
+ * flat object whose `by` names the choice and whose per-branch rules are checked by a
20
+ * refinement — the opt-out from tenant scoping (`{ by: 'none' }`) and the
21
+ * source-derived case (`{ by: 'source' }`) are explicit, named values rather than
22
+ * falsy flags, important because `none` makes an entire table readable across tenants.
20
23
  */
21
24
  import { z } from 'zod';
22
25
  /** The default physical tenancy column. This is the single canonical definition of that column name. */
@@ -46,41 +49,97 @@ export const tenancySchema = z.discriminatedUnion('kind', [
46
49
  z.object({ kind: z.literal('parent'), via: scopedViaRefSchema }),
47
50
  /** Not tenant-scoped (global / reference data). */
48
51
  z.object({ kind: z.literal('none') }),
52
+ /**
53
+ * Scoped by the data source itself: the model carries no tenancy column, and the
54
+ * owning organization is derived from the connected source that registered it — the
55
+ * registration is the tenant boundary. This is the natural default for a
56
+ * single-tenant connected database (`ablo connect`), which holds one org's rows and
57
+ * so has no per-row `organization_id` to read. Carries no payload for the same
58
+ * reason `none` does: the binding lives in the source registration, not the model.
59
+ */
60
+ z.object({ kind: z.literal('source') }),
49
61
  ]);
50
62
  /**
51
63
  * The authoring form of tenancy — what a schema author writes as the model's
52
64
  * `policy` option. The name follows SQL row-level security, where a policy is the
53
- * rule that decides which rows a tenant may read. It is a discriminated union on
54
- * `by`, so the three branches are mutually exclusive and the opt-out
55
- * (`{ by: 'none' }`) is an explicit, named choice rather than a falsy flag.
65
+ * rule that decides which rows a tenant may read.
66
+ *
67
+ * The four choices `by` can take:
56
68
  *
57
69
  * - `{ by: 'column' }` — a row-local tenancy column (the default). `column`
58
70
  * overrides the name (default {@link DEFAULT_ORG_COLUMN}).
59
71
  * - `{ by: 'parent', fk, parent }` — inherit tenancy through a foreign key when
60
72
  * this table has no tenancy column of its own. `parentKey` (default `'id'`) and
61
73
  * `parentTenantColumn` (default {@link DEFAULT_ORG_COLUMN}) are optional overrides.
74
+ * - `{ by: 'source' }` — derive the organization from the connected source that
75
+ * registered the model, for a single-tenant connected database that carries no
76
+ * `organization_id` column. The registration is the tenant boundary.
62
77
  * - `{ by: 'none' }` — genuinely global or reference data. This makes the whole
63
78
  * table readable across tenants, so it is correct only for tables that have no tenant.
79
+ *
80
+ * The runtime schema is one flat object rather than a discriminated union: `by`
81
+ * selects the branch, and the {@link https://zod.dev | superRefine} pass enforces
82
+ * the per-branch field rules (`parent` requires `fk` and `parent`; the other three
83
+ * take no branch fields). The exported {@link PolicyInput} type is hand-written as a
84
+ * precise union so the compiler still requires the right fields per `by` — the flat
85
+ * schema validates at runtime, the union guides at author time, and the type guard
86
+ * below keeps the two from drifting.
64
87
  */
65
- export const policyInputSchema = z.discriminatedUnion('by', [
66
- z.object({
67
- by: z.literal('column'),
68
- /** Override the physical tenancy column name. Default {@link DEFAULT_ORG_COLUMN}. */
69
- column: z.string().min(1).optional(),
70
- }),
71
- z.object({
72
- by: z.literal('parent'),
73
- /** Column on this table that points at the parent (for example, `'slideId'`). */
74
- fk: z.string().min(1),
75
- /** Parent table name (e.g. `'slides'`). */
76
- parent: z.string().min(1),
77
- /** Column on the parent that `fk` references. Default `'id'`. */
78
- parentKey: z.string().min(1).optional(),
79
- /** Column on the parent holding the tenant id. Default {@link DEFAULT_ORG_COLUMN}. */
80
- parentTenantColumn: z.string().min(1).optional(),
81
- }),
82
- z.object({ by: z.literal('none') }),
83
- ]);
88
+ export const policyInputSchema = z
89
+ .object({
90
+ by: z.enum(['column', 'parent', 'none', 'source']),
91
+ /** `by: 'column'` — override the physical tenancy column name. Default {@link DEFAULT_ORG_COLUMN}. */
92
+ column: z.string().min(1).optional(),
93
+ /** `by: 'parent'` — column on this table that points at the parent (for example, `'slideId'`). */
94
+ fk: z.string().min(1).optional(),
95
+ /** `by: 'parent'` — parent table name (e.g. `'slides'`). */
96
+ parent: z.string().min(1).optional(),
97
+ /** `by: 'parent'` — column on the parent that `fk` references. Default `'id'`. */
98
+ parentKey: z.string().min(1).optional(),
99
+ /** `by: 'parent'` — column on the parent holding the tenant id. Default {@link DEFAULT_ORG_COLUMN}. */
100
+ parentTenantColumn: z.string().min(1).optional(),
101
+ })
102
+ .superRefine((policy, ctx) => {
103
+ if (policy.by === 'parent') {
104
+ if (!policy.fk) {
105
+ ctx.addIssue({
106
+ code: z.ZodIssueCode.custom,
107
+ path: ['fk'],
108
+ message: "A `by: 'parent'` policy needs `fk` — the column on this table that points at the parent row whose tenancy it inherits.",
109
+ });
110
+ }
111
+ if (!policy.parent) {
112
+ ctx.addIssue({
113
+ code: z.ZodIssueCode.custom,
114
+ path: ['parent'],
115
+ message: "A `by: 'parent'` policy needs `parent` — the name of the parent table this table is scoped through.",
116
+ });
117
+ }
118
+ }
119
+ // A `column` policy may carry `column`; the parent fields belong only to `parent`.
120
+ // Flag a field that landed on the wrong branch so a typo doesn't silently no-op.
121
+ const parentOnly = ['fk', 'parent', 'parentKey', 'parentTenantColumn'];
122
+ if (policy.by !== 'parent') {
123
+ for (const field of parentOnly) {
124
+ if (policy[field] !== undefined) {
125
+ ctx.addIssue({
126
+ code: z.ZodIssueCode.custom,
127
+ path: [field],
128
+ message: `\`${field}\` only applies to a \`by: 'parent'\` policy; remove it or set \`by: 'parent'\`.`,
129
+ });
130
+ }
131
+ }
132
+ }
133
+ if (policy.by !== 'column' && policy.column !== undefined) {
134
+ ctx.addIssue({
135
+ code: z.ZodIssueCode.custom,
136
+ path: ['column'],
137
+ message: "`column` only applies to a `by: 'column'` policy; remove it or set `by: 'column'`.",
138
+ });
139
+ }
140
+ });
141
+ const _policyInputAssignable = true;
142
+ void _policyInputAssignable;
84
143
  /**
85
144
  * Normalizes the authoring {@link PolicyInput} into the canonical {@link Tenancy}.
86
145
  * Called once, when the model is built, so that `ModelDef`, `ModelJSON`, and every
@@ -103,6 +162,8 @@ export function resolvePolicy(input) {
103
162
  parentOrgColumn: input.parentTenantColumn,
104
163
  },
105
164
  };
165
+ case 'source':
166
+ return { kind: 'source' };
106
167
  case 'none':
107
168
  return { kind: 'none' };
108
169
  }
@@ -15,8 +15,9 @@
15
15
  */
16
16
  import type { ParticipantKind, ConfirmationState } from '../schema/syncDeltaRow.js';
17
17
  import type { ParticipantRef } from '../wire/delta.js';
18
+ import type { CommitExecutionResultInput } from '../wire/commit.js';
18
19
  import type { Environment } from '../environment.js';
19
- import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
20
+ import type { ReadDependency } from '../coordination/schema.js';
20
21
  export interface CommitContext {
21
22
  participantId: string;
22
23
  /**
@@ -30,6 +31,8 @@ export interface CommitContext {
30
31
  * targets the organization's default project.
31
32
  */
32
33
  projectId?: string;
34
+ /** Exact sandbox plane derived from the authenticated key, when present. */
35
+ sandboxId?: string;
33
36
  /** Optional external account scope forwarded to storage resolvers. */
34
37
  accountScope?: string;
35
38
  /**
@@ -96,27 +99,8 @@ export interface CommitContext {
96
99
  reads?: ReadDependency[] | null;
97
100
  }
98
101
  /**
99
- * The receipt returned when a commit finishes. It pins the exact range of
100
- * `sync_deltas` ids the batch produced, so a caller can broadcast just this batch's
101
- * deltas without racing concurrent commits that hold adjacent ids. `firstSyncId` is
102
- * 0 when the batch produced no deltas (no operations, or all of them were no-ops).
102
+ * The server execution receipt persisted in `mutation_log`. Its runtime schema
103
+ * lives with the HTTP/WS settlement contract so queued correlation cannot drift
104
+ * between cache, transport, and client.
103
105
  */
104
- export interface CommitResult {
105
- lastSyncId: number;
106
- firstSyncId: number;
107
- /**
108
- * Stale-context notifications for operations the committer guarded with
109
- * `onStale: 'notify'`. Non-empty only when a guarded write collided with a
110
- * concurrent change; the committer heals from these instead of receiving an
111
- * `AbloStaleContextError`. See {@link StaleNotification}.
112
- */
113
- notifications?: StaleNotification[];
114
- /**
115
- * Ids of update or delete targets that matched no rows — the row does not exist, or
116
- * lies outside the caller's organization. Surfacing them here lets a client turn a
117
- * silent no-op into an `AbloNotFoundError`. Non-empty only when at least one
118
- * operation missed. The ids are globally unique, so a caller can match its own
119
- * target id against this set without ambiguity.
120
- */
121
- missingIds?: string[];
122
- }
106
+ export type CommitResult = CommitExecutionResultInput;