@abloatai/ablo 0.30.2 → 0.32.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 (42) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +53 -12
  3. package/README.md +12 -14
  4. package/dist/cli.cjs +1787 -1329
  5. package/dist/coordination/schema.d.ts +4 -2
  6. package/dist/coordination/schema.js +23 -9
  7. package/dist/errorCodes.d.ts +1 -0
  8. package/dist/errorCodes.js +2 -1
  9. package/dist/schema/index.d.ts +1 -1
  10. package/dist/schema/index.js +1 -1
  11. package/dist/schema/schema.d.ts +57 -0
  12. package/dist/schema/schema.js +53 -0
  13. package/dist/schema/select.js +1 -0
  14. package/dist/schema/serialize.d.ts +3 -1
  15. package/dist/schema/serialize.js +7 -1
  16. package/dist/source/adapters/drizzle.js +4 -3
  17. package/dist/source/adapters/kysely.js +4 -4
  18. package/dist/source/adapters/prisma.js +3 -3
  19. package/dist/source/idempotency.d.ts +19 -4
  20. package/dist/source/idempotency.js +19 -4
  21. package/dist/source/migrations.d.ts +4 -1
  22. package/dist/source/migrations.js +36 -5
  23. package/docs/agent-messaging.md +1 -2
  24. package/docs/api.md +4 -4
  25. package/docs/audit.md +35 -23
  26. package/docs/client-behavior.md +7 -4
  27. package/docs/concurrency-convention.md +3 -3
  28. package/docs/data-sources.md +141 -102
  29. package/docs/examples/agent-human.md +2 -2
  30. package/docs/examples/ai-sdk-tool.md +1 -1
  31. package/docs/examples/nextjs.md +1 -1
  32. package/docs/examples/server-agent.md +2 -2
  33. package/docs/groups.md +160 -0
  34. package/docs/guarantees.md +9 -7
  35. package/docs/how-it-works.md +110 -0
  36. package/docs/integration-guide.md +8 -7
  37. package/docs/interaction-model.md +3 -3
  38. package/docs/quickstart.md +38 -31
  39. package/docs/schema-contract.md +7 -5
  40. package/docs/webhooks.md +23 -22
  41. package/llms.txt +10 -4
  42. package/package.json +1 -1
@@ -245,6 +245,8 @@ export declare const wireClaimSchema: z.ZodObject<{
245
245
  }, z.core.$strip>>;
246
246
  field: z.ZodOptional<z.ZodString>;
247
247
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
248
+ fenceToken: z.ZodOptional<z.ZodNumber>;
249
+ acquiredAt: z.ZodOptional<z.ZodNumber>;
248
250
  claimId: z.ZodString;
249
251
  description: z.ZodOptional<z.ZodString>;
250
252
  declaredAt: z.ZodNumber;
@@ -255,7 +257,6 @@ export declare const wireClaimSchema: z.ZodObject<{
255
257
  expired: "expired";
256
258
  canceled: "canceled";
257
259
  }>>;
258
- fenceToken: z.ZodOptional<z.ZodNumber>;
259
260
  error: z.ZodOptional<z.ZodObject<{
260
261
  code: z.ZodString;
261
262
  message: z.ZodOptional<z.ZodString>;
@@ -665,6 +666,8 @@ export declare const presenceUpdateFrameSchema: z.ZodObject<{
665
666
  }, z.core.$strip>>;
666
667
  field: z.ZodOptional<z.ZodString>;
667
668
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
669
+ fenceToken: z.ZodOptional<z.ZodNumber>;
670
+ acquiredAt: z.ZodOptional<z.ZodNumber>;
668
671
  claimId: z.ZodString;
669
672
  description: z.ZodOptional<z.ZodString>;
670
673
  declaredAt: z.ZodNumber;
@@ -675,7 +678,6 @@ export declare const presenceUpdateFrameSchema: z.ZodObject<{
675
678
  expired: "expired";
676
679
  canceled: "canceled";
677
680
  }>>;
678
- fenceToken: z.ZodOptional<z.ZodNumber>;
679
681
  error: z.ZodOptional<z.ZodObject<{
680
682
  code: z.ZodString;
681
683
  message: z.ZodOptional<z.ZodString>;
@@ -198,6 +198,28 @@ export const claimStatusSchema = z.enum([
198
198
  'expired',
199
199
  'canceled',
200
200
  ]);
201
+ /**
202
+ * Server-owned grant stamps — minted once when a claim is first granted and
203
+ * preserved verbatim across a re-announce of the same `claimId`, so neither a
204
+ * reconnect nor a client-supplied value can move them (unlike `declaredAt`,
205
+ * which the client sends afresh each announce). Both optional: a frame without
206
+ * them stays valid, and the feature each backs simply does not engage.
207
+ */
208
+ const grantStampFields = {
209
+ /**
210
+ * The monotonic fencing token minted for this grant (Option B). Strictly
211
+ * increasing per entity across successive grants, so a write that carries it
212
+ * is rejected at commit if a later holder already advanced the entity's
213
+ * high-water. A token-less write is simply not fence-checked.
214
+ */
215
+ fenceToken: z.number().optional(),
216
+ /**
217
+ * Lease origin (epoch ms): when THIS holding was acquired. The cumulative-
218
+ * hold ceiling measures a holder's fair share from here — and because it
219
+ * survives a re-announce, a reconnect cannot rewind the clock.
220
+ */
221
+ acquiredAt: z.number().optional(),
222
+ };
201
223
  const wireClaimBaseSchema = targetRefSchema.extend({
202
224
  claimId: z.string(),
203
225
  /**
@@ -211,15 +233,7 @@ const wireClaimBaseSchema = targetRefSchema.extend({
211
233
  /** Server-computed TTL deadline (epoch ms). Readers treat as advisory. */
212
234
  expiresAt: z.number(),
213
235
  status: claimStatusSchema.optional(),
214
- /**
215
- * The monotonic fencing token the server minted for this grant (Option B).
216
- * Strictly increasing per entity across successive grants, so a write that
217
- * carries it can be rejected at commit if a later holder already advanced the
218
- * entity's high-water. Server-stamped on grant and never client-supplied;
219
- * optional so every existing frame stays valid and a token-less write is
220
- * simply not fence-checked.
221
- */
222
- fenceToken: z.number().optional(),
236
+ ...grantStampFields,
223
237
  });
224
238
  export const wireClaimSummarySchema = wireClaimBaseSchema.pick({
225
239
  claimId: true,
@@ -192,6 +192,7 @@ export declare const ERROR_CODES: {
192
192
  readonly entity_not_found: ErrorCodeSpec;
193
193
  readonly model_not_found: ErrorCodeSpec;
194
194
  readonly mutate_update_entity_not_found: ErrorCodeSpec;
195
+ readonly no_data_source_registered: ErrorCodeSpec;
195
196
  readonly task_id_missing: ErrorCodeSpec;
196
197
  readonly not_null_violation: ErrorCodeSpec;
197
198
  readonly foreign_key_violation: ErrorCodeSpec;
@@ -207,6 +207,7 @@ export const ERROR_CODES = {
207
207
  entity_not_found: wire('not_found', 404, false, 'No row exists with the requested id. It may have been deleted, or the id may belong to a different environment.'),
208
208
  model_not_found: wire('not_found', 404, false, 'No row of this model exists with the requested id. It may have been deleted, or the id may belong to a different environment.'),
209
209
  mutate_update_entity_not_found: wire('not_found', 404, false, 'The row targeted by this update does not exist — it may have been deleted since you read it. Re-read before retrying.'),
210
+ no_data_source_registered: wire('not_found', 404, false, 'No database is connected to this plane yet, so there is nothing to check. Connect one with `ablo connect apply`, then run the check again.'),
210
211
  task_id_missing: wire('server', 502, true, 'The task-create response arrived without a task id, so the result cannot be used. Retry the request.'),
211
212
  // ── data integrity / database constraints ──────────────────────────
212
213
  // Emitted when a database integrity constraint rejects a write. None are
@@ -365,7 +366,7 @@ export const ERROR_CODES = {
365
366
  query_invalid_boolean: wire('validation', 400, false, 'The query compared a boolean column against an invalid boolean literal.'),
366
367
  protocol_version_unsupported: wire('transport', 426, false, 'The client sync-protocol version is outside the range this server supports — upgrade the SDK (or the server was rolled back mid-fleet).'),
367
368
  database_unreachable: wire('validation', 400, false, "Ablo could not reach this database to check that it can stream replication. The connection string may be wrong, the host may not be reachable from Ablo's servers, or the credentials may not be accepted."),
368
- database_not_replication_ready: wire('validation', 400, false, 'This database is not set up for logical replication yet. Every failing item — wal_level, the publication, the replication grant, a replica identity — is listed in the error details with its exact fix. `ablo connect` prints the one-time setup; `ablo connect --check` verifies it.'),
369
+ database_not_replication_ready: wire('validation', 400, false, 'This database is not set up for logical replication yet. Every failing item — wal_level, the publication, the replication grant, a replica identity — is listed in the error details with its exact fix. `ablo connect` prints the one-time setup; `ablo connect check` verifies it.'),
369
370
  replication_publication_drift: wire('validation', 400, false, 'Your schema maps to tables that are not members of the replication publication, so their changes silently never stream and the source looks frozen. The missing tables and the exact `ALTER PUBLICATION … ADD TABLE …` to add them are in the error details — Ablo never alters your database for you.'),
370
371
  query_unknown_relation: wire('validation', 400, false, 'The query references a relation the model does not define. Check the relation name against the schema.'),
371
372
  query_relation_target_unknown: wire('schema', 500, false, 'A relation in the query targets a model the schema does not define.'),
@@ -30,7 +30,7 @@ export { syncDeltaActionSchema, wireDeltaDataSchema, participantRefSchema, syncD
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';
32
32
  export { mutable, readOnly, type SugarOptions } from './sugar.js';
33
- export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type Model, type Row, type InferModel, type InferCreate, type InferRow, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, composeEntitySyncGroups, intersectRequestedWithAllowed, type IdentityRole, type IdentityContext, type IdentityRoleSource, type EntityRole, type EntityContext, type EntityRoleSource, type RoleSource, type RoleContext, type SyncGroup, type SyncGroupInput, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, type GroupsInput, } from './schema.js';
33
+ export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type Model, type Row, type InferModel, type InferCreate, type InferRow, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, composeEntitySyncGroups, intersectRequestedWithAllowed, type IdentityRole, type IdentityContext, type IdentityRoleSource, type EntityRole, type EntityContext, type EntityRoleSource, type RoleSource, type RoleContext, type SyncGroup, type SyncGroupInput, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, type GroupsInput, type TenantContextMapping, type TenantContextSource, RESERVED_TENANT_CONTEXT_GUCS, } from './schema.js';
34
34
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, type SchemaJSON, type ModelJSON, type RelationJSON, } from './serialize.js';
35
35
  export { selectModels, omitModels } from './select.js';
36
36
  export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, type ProvisionPlan, type MigrationPlan, } from './ddl.js';
@@ -56,7 +56,7 @@ export { coordination, humansOverwrite, humansReject, humansNotify, agentsOverwr
56
56
  // sensible defaults for everything else.
57
57
  export { mutable, readOnly } from './sugar.js';
58
58
  // Schema definition + type inference
59
- export { defineSchema, composeIdentitySyncGroups, composeEntitySyncGroups, intersectRequestedWithAllowed, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, } from './schema.js';
59
+ export { defineSchema, composeIdentitySyncGroups, composeEntitySyncGroups, intersectRequestedWithAllowed, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, RESERVED_TENANT_CONTEXT_GUCS, } from './schema.js';
60
60
  // Schema ⇄ JSON — serialize a schema for transport and rebuild it on the far side.
61
61
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, } from './serialize.js';
62
62
  // Schema projection — derive an app's subset from one canonical schema.
@@ -31,6 +31,35 @@ export type CasingFn = (jsField: string) => string;
31
31
  /** `defineSchema`'s casing option. Identity when unset. */
32
32
  export type Casing = CasingConvention | CasingFn;
33
33
  /** Options for `defineSchema`. */
34
+ /**
35
+ * A server-authored context value that can fill a customer's row-level-security
36
+ * GUC. Deliberately a closed set — every member is resolved by Ablo from the
37
+ * authenticated `ek_` and the plane, never from client-supplied data — so a
38
+ * mapping can forward the tenant identity Ablo already trusts but can never
39
+ * widen a writer's scope. Mirrors the fields the engine sets on the direct-write
40
+ * connection (`app.current_org_id`, `app.current_project_id`, …).
41
+ */
42
+ export type TenantContextSource = 'orgId' | 'projectId' | 'environment' | 'sandboxId' | 'participantId' | 'participantKind';
43
+ /**
44
+ * Maps a Postgres session GUC the customer's RLS policies read to the Ablo
45
+ * context value that fills it. See {@link DefineSchemaOptions.tenantContext} and
46
+ * ADR 0011.
47
+ */
48
+ export interface TenantContextMapping {
49
+ /** The GUC name the customer's policies read, e.g. `app.current_app_org_id`. */
50
+ readonly guc: string;
51
+ /** Which server-authored context value fills it. */
52
+ readonly from: TenantContextSource;
53
+ }
54
+ /**
55
+ * GUCs Ablo already sets on its direct-write connection. A `tenantContext`
56
+ * mapping FORWARDS Ablo's trusted context into a customer's own policy GUC — it
57
+ * may never reassign one of these, which would let a schema push relax the
58
+ * engine's own scoping, timeouts, or `row_security`. Shared with the engine's
59
+ * direct-write seam so authoring-time validation and the runtime guard read one
60
+ * list. See ADR 0011.
61
+ */
62
+ export declare const RESERVED_TENANT_CONTEXT_GUCS: readonly string[];
34
63
  export interface DefineSchemaOptions {
35
64
  /**
36
65
  * How to translate camelCase JS field names into database column
@@ -60,6 +89,27 @@ export interface DefineSchemaOptions {
60
89
  * authentication provider attached to the identity.
61
90
  */
62
91
  readonly identityRoles?: readonly IdentityRole[];
92
+ /**
93
+ * Declares how Ablo's direct-write connection carries tenant context into the
94
+ * GUCs your row-level-security policies read (ADR 0011). Before every direct
95
+ * write, Ablo already sets a fixed bundle (`app.current_org_id`,
96
+ * `app.current_project_id`, …) with `SET LOCAL`. If your policies read a
97
+ * differently-named GUC that your own app sets per-connection — e.g. a
98
+ * restrictive policy on `current_setting('app.current_app_org_id')` — declare
99
+ * the mapping here so Ablo forwards its trusted context into that GUC too, and
100
+ * your RLS governs Ablo's writes:
101
+ *
102
+ * ```ts
103
+ * defineSchema({ ... }, {
104
+ * tenantContext: [{ guc: 'app.current_app_org_id', from: 'orgId' }],
105
+ * })
106
+ * ```
107
+ *
108
+ * Do NOT carve a policy exception for the writer role — that exempts exactly
109
+ * the writes you want governed. Leave unset when your policies read the GUCs
110
+ * Ablo already sets (or when a table has no RLS). Empty array when unset.
111
+ */
112
+ readonly tenantContext?: readonly TenantContextMapping[];
63
113
  }
64
114
  /** A record of model names → model definitions */
65
115
  export type SchemaRecord = Record<string, ModelDef>;
@@ -114,6 +164,13 @@ export interface Schema<S extends SchemaRecord = SchemaRecord> {
114
164
  * to derive a participant's allowed sync-group set.
115
165
  */
116
166
  readonly identityRoles: readonly IdentityRole[];
167
+ /**
168
+ * Tenant-context GUC mappings registered via `defineSchema({...},
169
+ * { tenantContext })` (ADR 0011). The engine's direct-write path reads this to
170
+ * `SET LOCAL` each declared GUC from server-authored context before applying
171
+ * DML, so the customer's RLS governs Ablo's writes. Empty array when unset.
172
+ */
173
+ readonly tenantContext: readonly TenantContextMapping[];
117
174
  /**
118
175
  * Set only on a projection produced by `selectModels`/`omitModels`: the
119
176
  * content hash of the FULL source schema the subset was cut from. A subset
@@ -44,6 +44,26 @@ function resolveCasing(fn) {
44
44
  function camelToSnake(identifier) {
45
45
  return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
46
46
  }
47
+ /**
48
+ * GUCs Ablo already sets on its direct-write connection. A `tenantContext`
49
+ * mapping FORWARDS Ablo's trusted context into a customer's own policy GUC — it
50
+ * may never reassign one of these, which would let a schema push relax the
51
+ * engine's own scoping, timeouts, or `row_security`. Shared with the engine's
52
+ * direct-write seam so authoring-time validation and the runtime guard read one
53
+ * list. See ADR 0011.
54
+ */
55
+ export const RESERVED_TENANT_CONTEXT_GUCS = [
56
+ 'statement_timeout',
57
+ 'lock_timeout',
58
+ 'row_security',
59
+ 'search_path',
60
+ 'app.current_org_id',
61
+ 'app.current_project_id',
62
+ 'app.current_environment',
63
+ 'app.current_sandbox_id',
64
+ 'app.current_participant_id',
65
+ 'app.current_participant_kind',
66
+ ];
47
67
  /**
48
68
  * Base fields every synced model gets automatically.
49
69
  *
@@ -201,14 +221,47 @@ export function defineSchema(models, options) {
201
221
  resolvedModels[name] = { ...def, typename, tableName, persist };
202
222
  }
203
223
  validateSyncGroupSchema(resolvedModels);
224
+ validateTenantContext(options?.tenantContext ?? []);
204
225
  return {
205
226
  // Cast back to S: we only added values to optional fields that were
206
227
  // already part of ModelDef, so the shape is structurally unchanged.
207
228
  models: resolvedModels,
208
229
  validators: validators,
209
230
  identityRoles: options?.identityRoles ?? [],
231
+ tenantContext: options?.tenantContext ?? [],
210
232
  };
211
233
  }
234
+ /**
235
+ * Reject tenant-context mappings that couldn't do what the author intends —
236
+ * caught here at definition time rather than silently dropped on the write path.
237
+ * A mapping must name a non-empty customer GUC, must not reassign one of the
238
+ * engine's reserved GUCs, and each GUC may be mapped at most once.
239
+ */
240
+ function validateTenantContext(mappings) {
241
+ const seen = new Set();
242
+ for (const { guc } of mappings) {
243
+ const name = guc.trim();
244
+ if (name === '') {
245
+ throw new AbloValidationError(`[defineSchema] tenantContext: a mapping has an empty \`guc\`. Name the ` +
246
+ `Postgres setting your row-level-security policies read, e.g. ` +
247
+ `\`{ guc: 'app.current_app_org_id', from: 'orgId' }\`.`, { code: 'schema_definition_invalid', param: 'tenantContext' });
248
+ }
249
+ if (RESERVED_TENANT_CONTEXT_GUCS.includes(name)) {
250
+ throw new AbloValidationError(`[defineSchema] tenantContext: \`${name}\` is a setting Ablo already ` +
251
+ `manages on its write connection, so a mapping can't reassign it — ` +
252
+ `that would let a schema relax the engine's own scoping. Point your ` +
253
+ `policies at a GUC your app owns (e.g. \`app.current_app_org_id\`) and ` +
254
+ `map Ablo's trusted context into that instead. Reserved: ` +
255
+ `${RESERVED_TENANT_CONTEXT_GUCS.join(', ')}.`, { code: 'schema_definition_invalid', param: `tenantContext.${name}` });
256
+ }
257
+ if (seen.has(name)) {
258
+ throw new AbloValidationError(`[defineSchema] tenantContext: \`${name}\` is mapped more than once. ` +
259
+ `A GUC takes exactly one source — keep the mapping you mean and drop ` +
260
+ `the duplicate.`, { code: 'schema_definition_invalid', param: `tenantContext.${name}` });
261
+ }
262
+ seen.add(name);
263
+ }
264
+ }
212
265
  /**
213
266
  * Validates the relation-driven sync-group declarations (`scope` and `grants`)
214
267
  * at schema-build time, so a mistyped membership edge fails here — with a
@@ -52,6 +52,7 @@ export function selectModels(schema, keys) {
52
52
  models: models,
53
53
  validators: validators,
54
54
  identityRoles: schema.identityRoles,
55
+ tenantContext: schema.tenantContext,
55
56
  // Record the full source's hash so the drift check can recognize this subset
56
57
  // as current against a server running that full schema. Prefer the source's
57
58
  // OWN `sourceSchemaHash` when it is itself a projection, so a subset-of-a-
@@ -31,7 +31,7 @@ import type { Tenancy } from './tenancy.js';
31
31
  import type { ModelResidency } from './residency.js';
32
32
  import type { GrantsRef, LoadStrategy, PersistOptions, AutoFillRule, ConflictAxis } from './model.js';
33
33
  import type { RelationType } from './relation.js';
34
- import { type Schema, type IdentityRole, type EntityRole } from './schema.js';
34
+ import { type Schema, type IdentityRole, type EntityRole, type TenantContextMapping } from './schema.js';
35
35
  /** Current schema-JSON envelope version. Bump this on a breaking change to the
36
36
  * JSON shape itself — not to a user's schema. */
37
37
  declare const SCHEMA_JSON_VERSION: 3;
@@ -75,6 +75,8 @@ export interface SchemaJSON {
75
75
  readonly v: typeof SCHEMA_JSON_VERSION;
76
76
  readonly models: Record<string, ModelJSON>;
77
77
  readonly identityRoles: readonly IdentityRole[];
78
+ /** Optional so schemas pushed before ADR 0011 still parse (defaults to `[]`). */
79
+ readonly tenantContext?: readonly TenantContextMapping[];
78
80
  }
79
81
  /**
80
82
  * Project a `Schema` to its JSON form. Drops the client-only closures
@@ -89,7 +89,12 @@ export function toSchemaJSON(schema) {
89
89
  models[key] = modelToJSON(def);
90
90
  }
91
91
  }
92
- return { v: SCHEMA_JSON_VERSION, models, identityRoles: schema.identityRoles };
92
+ return {
93
+ v: SCHEMA_JSON_VERSION,
94
+ models,
95
+ identityRoles: schema.identityRoles,
96
+ ...(schema.tenantContext.length > 0 ? { tenantContext: schema.tenantContext } : {}),
97
+ };
93
98
  }
94
99
  /** Serialize a `Schema` to a JSON string (the `ablo push` payload). */
95
100
  export function serializeSchema(schema) {
@@ -200,6 +205,7 @@ export function fromSchemaJSON(json) {
200
205
  models: models,
201
206
  validators: validators,
202
207
  identityRoles: json.identityRoles,
208
+ tenantContext: json.tenantContext ?? [],
203
209
  };
204
210
  }
205
211
  /** Parse a `Schema` from a JSON string (inverse of {@link serializeSchema}). */
@@ -37,7 +37,7 @@ import { AbloValidationError } from '../../errors.js';
37
37
  import { sql } from 'drizzle-orm';
38
38
  import { outboxEventSchema } from '../contract.js';
39
39
  import { adapterTableMigrations } from '../migrations.js';
40
- import { assertSourceIdempotencyIntent, assertSourceIdempotencyRetention, sourceChangeIntentHash, } from '../idempotency.js';
40
+ import { assertSourceIdempotencyIntent, assertSourceIdempotencyRetention, SOURCE_IDEMPOTENCY_RETENTION, sourceChangeIntentHash, } from '../idempotency.js';
41
41
  import { toSchemaJSON } from '../../schema/serialize.js';
42
42
  import { camelToSnake, snakeToCamel } from '../../schema/ddl.js';
43
43
  import { tenancyColumn } from '../../schema/tenancy.js';
@@ -183,9 +183,10 @@ export function drizzleDataSource(db, schema) {
183
183
  )`);
184
184
  }
185
185
  await tx.execute(sql `
186
- INSERT INTO ablo_idempotency (client_tx_id, response, request_hash)
186
+ INSERT INTO ablo_idempotency (client_tx_id, response, request_hash, expires_at)
187
187
  VALUES (
188
- ${change.correlationId}, ${JSON.stringify(rows)}::jsonb, ${requestHash}
188
+ ${change.correlationId}, ${JSON.stringify(rows)}::jsonb, ${requestHash},
189
+ now() + ${SOURCE_IDEMPOTENCY_RETENTION}::interval
189
190
  )`);
190
191
  if (change.echo?.kind === 'postgres-wal') {
191
192
  await tx.execute(sql `SELECT pg_logical_emit_message(true, ${ABLO_POSTGRES_COMMIT_ECHO_PREFIX}, ${change.echo.payload})`);
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { AbloValidationError } from '../../errors.js';
20
20
  import { changeSetSchema, outboxEventSchema, sourceCommitEchoMarkerSchema, } from '../contract.js';
21
- import { assertSourceIdempotencyIntent, assertSourceIdempotencyRetention, sourceChangeIntentHash, } from '../idempotency.js';
21
+ import { assertSourceIdempotencyIntent, assertSourceIdempotencyRetention, sourceChangeIntentHash, SOURCE_IDEMPOTENCY_RETENTION, } from '../idempotency.js';
22
22
  import { adapterTableMigrations, idempotencyLedgerMigrations, } from '../migrations.js';
23
23
  import { ABLO_POSTGRES_COMMIT_ECHO_PREFIX } from '../types.js';
24
24
  import { createKyselyMutationCore, kyselyOperationRowId, } from './kyselyMutationCore.js';
@@ -32,10 +32,10 @@ function rawQuery(queryId, sql, parameters) {
32
32
  };
33
33
  }
34
34
  function reserveLedgerQuery(correlationId, requestHash) {
35
- return rawQuery('ablo-idempotency-reserve', `INSERT INTO ablo_idempotency (client_tx_id, response, request_hash)
36
- VALUES ($1, $2::jsonb, $3)
35
+ return rawQuery('ablo-idempotency-reserve', `INSERT INTO ablo_idempotency (client_tx_id, response, request_hash, expires_at)
36
+ VALUES ($1, $2::jsonb, $3, now() + $4::interval)
37
37
  ON CONFLICT (client_tx_id) DO NOTHING
38
- RETURNING client_tx_id`, [correlationId, '[]', requestHash]);
38
+ RETURNING client_tx_id`, [correlationId, '[]', requestHash, SOURCE_IDEMPOTENCY_RETENTION]);
39
39
  }
40
40
  function completeLedgerQuery(correlationId, rows) {
41
41
  return rawQuery('ablo-idempotency-complete', `UPDATE ablo_idempotency
@@ -16,7 +16,7 @@
16
16
  import { AbloValidationError } from '../../errors.js';
17
17
  import { outboxEventSchema } from '../contract.js';
18
18
  import { adapterTableMigrations } from '../migrations.js';
19
- import { assertSourceIdempotencyIntent, assertSourceIdempotencyRetention, sourceChangeIntentHash, } from '../idempotency.js';
19
+ import { assertSourceIdempotencyIntent, assertSourceIdempotencyRetention, SOURCE_IDEMPOTENCY_RETENTION, sourceChangeIntentHash, } from '../idempotency.js';
20
20
  import { ABLO_POSTGRES_COMMIT_ECHO_PREFIX, } from '../types.js';
21
21
  const lowerFirst = (s) => (s ? s.charAt(0).toLowerCase() + s.slice(1) : s);
22
22
  /**
@@ -162,8 +162,8 @@ export function prismaDataSource(prisma, schema, options = {}) {
162
162
  correlation_id, transaction_id, occurred_at
163
163
  ) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8)`, `${change.correlationId}:${index}`, op.model, entityId, op.type, JSON.stringify(op.type === 'DELETE' ? null : row), change.correlationId, op.transactionId ?? null, Date.now());
164
164
  }
165
- await tx.$executeRawUnsafe(`INSERT INTO ablo_idempotency (client_tx_id, response, request_hash)
166
- VALUES ($1, $2::jsonb, $3)`, change.correlationId, JSON.stringify(rows), requestHash);
165
+ await tx.$executeRawUnsafe(`INSERT INTO ablo_idempotency (client_tx_id, response, request_hash, expires_at)
166
+ VALUES ($1, $2::jsonb, $3, now() + $4::interval)`, change.correlationId, JSON.stringify(rows), requestHash, SOURCE_IDEMPOTENCY_RETENTION);
167
167
  if (change.echo?.kind === 'postgres-wal') {
168
168
  // Cast the returned LSN to text. `pg_logical_emit_message` yields a
169
169
  // `pg_lsn`, and Prisma's driver adapter can't deserialize that OID
@@ -38,9 +38,24 @@ export declare function sourceChangeIntentHash(change: ChangeSet): string;
38
38
  /** Fail closed for changed intent and legacy rows that lack hash evidence. */
39
39
  export declare function assertSourceIdempotencyIntent(cachedHash: unknown, requestHash: string): void;
40
40
  /**
41
- * Enforce the initial permanent-retention contract. New rows use Postgres
42
- * `infinity`; this check also fails explicitly if a future bounded-retention
43
- * policy leaves an expired tombstone. Missing expiry evidence is accepted only
44
- * for adapters reading rows written before the column existed.
41
+ * How long a ledger row stays replayable, as a Postgres interval. New rows are
42
+ * written with `expires_at = now() + this`, so the customer can prune anything
43
+ * past it the writer can't (it holds no DELETE, by design), so pruning is a
44
+ * job for their own admin/cron: `DELETE FROM ablo_idempotency WHERE expires_at <
45
+ * now()`.
46
+ *
47
+ * This is an ops/UX window, not a safety one: {@link assertSourceIdempotencyRetention}
48
+ * already refuses to replay an expired key (fail-closed), so an expired row can
49
+ * never be safely re-applied whether or not it has been pruned. The window only
50
+ * governs how late a retry can still collect its cached response before it's
51
+ * told to start over. 30 days comfortably covers an offline client's queued
52
+ * commits while keeping the table bounded.
53
+ */
54
+ export declare const SOURCE_IDEMPOTENCY_RETENTION = "30 days";
55
+ /**
56
+ * Enforce the retention contract on replay. A row past its `expires_at` is
57
+ * refused (fail-closed) rather than re-applied, so pruning expired rows is
58
+ * always safe. Rows written before the column existed (`infinity`/null) are
59
+ * accepted — they predate bounded retention and never expire.
45
60
  */
46
61
  export declare function assertSourceIdempotencyRetention(expiresAt: unknown, now?: number): void;
@@ -102,10 +102,25 @@ export function assertSourceIdempotencyIntent(cachedHash, requestHash) {
102
102
  }
103
103
  }
104
104
  /**
105
- * Enforce the initial permanent-retention contract. New rows use Postgres
106
- * `infinity`; this check also fails explicitly if a future bounded-retention
107
- * policy leaves an expired tombstone. Missing expiry evidence is accepted only
108
- * for adapters reading rows written before the column existed.
105
+ * How long a ledger row stays replayable, as a Postgres interval. New rows are
106
+ * written with `expires_at = now() + this`, so the customer can prune anything
107
+ * past it the writer can't (it holds no DELETE, by design), so pruning is a
108
+ * job for their own admin/cron: `DELETE FROM ablo_idempotency WHERE expires_at <
109
+ * now()`.
110
+ *
111
+ * This is an ops/UX window, not a safety one: {@link assertSourceIdempotencyRetention}
112
+ * already refuses to replay an expired key (fail-closed), so an expired row can
113
+ * never be safely re-applied whether or not it has been pruned. The window only
114
+ * governs how late a retry can still collect its cached response before it's
115
+ * told to start over. 30 days comfortably covers an offline client's queued
116
+ * commits while keeping the table bounded.
117
+ */
118
+ export const SOURCE_IDEMPOTENCY_RETENTION = '30 days';
119
+ /**
120
+ * Enforce the retention contract on replay. A row past its `expires_at` is
121
+ * refused (fail-closed) rather than re-applied, so pruning expired rows is
122
+ * always safe. Rows written before the column existed (`infinity`/null) are
123
+ * accepted — they predate bounded retention and never expire.
109
124
  */
110
125
  export function assertSourceIdempotencyRetention(expiresAt, now = Date.now()) {
111
126
  if (expiresAt == null ||
@@ -10,7 +10,10 @@
10
10
  * it records changes in its own `sync_deltas` log instead.
11
11
  */
12
12
  import type { Migration } from './contract.js';
13
- /** The permanent ledger shared by direct and endpoint mutation wrappers. */
13
+ /** The idempotency ledger shared by direct and endpoint mutation wrappers. New
14
+ * rows carry a bounded `expires_at` (set by the adapter at write time) so the
15
+ * customer can prune them; the `infinity` column default is only a fallback for
16
+ * a row inserted without one. */
14
17
  export declare function idempotencyLedgerMigrations(): readonly Migration[];
15
18
  /** Endpoint-only transactional outbox and its correlation columns. */
16
19
  export declare function endpointOutboxMigrations(): readonly Migration[];
@@ -9,7 +9,32 @@
9
9
  * own database when you run a Data Source. Ablo's hosted storage does not use them;
10
10
  * it records changes in its own `sync_deltas` log instead.
11
11
  */
12
- /** The permanent ledger shared by direct and endpoint mutation wrappers. */
12
+ /**
13
+ * Add a column to `ablo_idempotency` only when it is genuinely missing.
14
+ *
15
+ * `ADD COLUMN IF NOT EXISTS` performs its ownership check before the existence
16
+ * short-circuit, so on a ledger owned by another role the no-op re-run still
17
+ * errors "must be owner of table ablo_idempotency" — the setup's re-run promise
18
+ * breaks. `pg_attribute` is readable regardless of table ownership, so this DO
19
+ * block resolves the ledger through the search path and runs the `ALTER` only
20
+ * when the column is absent — a true no-op on a table that already has it.
21
+ */
22
+ function addColumnIfAbsent(column, definition) {
23
+ return `DO $$
24
+ DECLARE ledger regclass := to_regclass('ablo_idempotency');
25
+ BEGIN
26
+ IF ledger IS NOT NULL AND NOT EXISTS (
27
+ SELECT 1 FROM pg_attribute
28
+ WHERE attrelid = ledger AND attname = '${column}' AND attnum > 0 AND NOT attisdropped
29
+ ) THEN
30
+ ALTER TABLE ablo_idempotency ADD COLUMN ${column} ${definition};
31
+ END IF;
32
+ END $$;`;
33
+ }
34
+ /** The idempotency ledger shared by direct and endpoint mutation wrappers. New
35
+ * rows carry a bounded `expires_at` (set by the adapter at write time) so the
36
+ * customer can prune them; the `infinity` column default is only a fallback for
37
+ * a row inserted without one. */
13
38
  export function idempotencyLedgerMigrations() {
14
39
  return [
15
40
  {
@@ -27,13 +52,19 @@ export function idempotencyLedgerMigrations() {
27
52
  // Nullable only for rows created by older adapter versions. New writes
28
53
  // always populate it; replaying a legacy NULL row fails closed because
29
54
  // the adapter cannot prove that the intent matches.
30
- up: `ALTER TABLE ablo_idempotency
31
- ADD COLUMN IF NOT EXISTS request_hash TEXT;`,
55
+ //
56
+ // The column already exists on any current table (the CREATE above
57
+ // includes it), so this only matters when upgrading a pre-existing
58
+ // ledger. `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` checks ownership
59
+ // BEFORE it evaluates IF NOT EXISTS, so on a ledger that predates the
60
+ // setup — owned by another role — the no-op still errors "must be owner".
61
+ // Guard on the catalog (readable regardless of ownership) so the ALTER
62
+ // runs only when the column is genuinely absent, keeping re-runs safe.
63
+ up: addColumnIfAbsent('request_hash', 'TEXT'),
32
64
  },
33
65
  {
34
66
  name: 'ablo_idempotency_permanent_retention',
35
- up: `ALTER TABLE ablo_idempotency
36
- ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ NOT NULL DEFAULT 'infinity';`,
67
+ up: addColumnIfAbsent('expires_at', `TIMESTAMPTZ NOT NULL DEFAULT 'infinity'`),
37
68
  },
38
69
  ];
39
70
  }
@@ -9,7 +9,7 @@ HTTP, or be replayed from the sync cursor.
9
9
 
10
10
  | Need | Use | Why |
11
11
  | --- | --- | --- |
12
- | "I am holding this row because..." | `claim({ reason, description, meta })` | Live and low-latency. Peers see it through presence while the claim exists. |
12
+ | "I am holding this row because..." | `claim({ description, meta })` | Live and low-latency. Peers see it through presence while the claim exists. |
13
13
  | "Remember this handoff/status/request" | A `messages` model | Durable row. Ordered in `sync_deltas`, replayed after reconnect, readable by HTTP agents. |
14
14
 
15
15
  Claim context is ephemeral. If a participant was offline, reconnected later, or
@@ -90,7 +90,6 @@ about work currently protected by a claim.
90
90
  ```ts
91
91
  const claim = await ablo.workItems.claim({
92
92
  id: workItemId,
93
- reason: "reformatting",
94
93
  description: "normalizing the pricing table",
95
94
  meta: { estimateSeconds: 120 },
96
95
  queue: false,
package/docs/api.md CHANGED
@@ -124,7 +124,7 @@ coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
124
124
  | `id` | string | Unique identifier for the claim. |
125
125
  | `status` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. `active` is the holder; `queued` is a waiter in the FIFO line behind it. |
126
126
  | `target` | `{ type, id, field? }` | What is being coordinated. |
127
- | `reason` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. Serialized on the wire as `action`. |
127
+ | `description` | string | Peer-visible phrase for the work in progress — `'editing'`, `'writing'`, `'reviewing the risk section'`. Defaults to `'editing'`, and rides back in the rejection a blocked writer receives. |
128
128
  | `heldBy` | string | Participant id holding the claim. |
129
129
  | `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it — a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
130
130
  | `createdAt` | number? | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
@@ -136,7 +136,7 @@ coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
136
136
  "id": "claim_3MtwBwLkdIwHu7ix",
137
137
  "status": "active",
138
138
  "target": { "type": "weatherReports", "id": "report_stockholm", "field": "status" },
139
- "reason": "editing",
139
+ "description": "editing",
140
140
  "heldBy": "agent:report-writer",
141
141
  "participantKind": "agent",
142
142
  "expiresAt": 1716580000000
@@ -176,12 +176,12 @@ Reads never block on a claim — to wait for a row to free up, `claim({ id })` i
176
176
  const claim = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
177
177
  if (claim) {
178
178
  claim.heldBy;
179
- claim.reason;
179
+ claim.description;
180
180
  }
181
181
 
182
182
  const handle = await ablo.weatherReports.claim({
183
183
  id: 'report_stockholm',
184
- reason: 'editing',
184
+ description: 'editing',
185
185
  ttl: '2m',
186
186
  });
187
187
  await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' } });