@abloatai/ablo 0.31.0 → 0.33.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 +50 -0
  3. package/README.md +25 -13
  4. package/dist/cli.cjs +1686 -1219
  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 +55 -1
  12. package/dist/schema/schema.js +47 -0
  13. package/dist/schema/select.js +1 -0
  14. package/dist/schema/serialize.d.ts +3 -1
  15. package/dist/schema/serialize.js +9 -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/coordination.md +13 -1
  29. package/docs/data-sources.md +34 -32
  30. package/docs/examples/agent-human.md +2 -2
  31. package/docs/examples/ai-sdk-tool.md +1 -1
  32. package/docs/examples/nextjs.md +1 -1
  33. package/docs/examples/server-agent.md +2 -2
  34. package/docs/groups.md +160 -0
  35. package/docs/guarantees.md +9 -7
  36. package/docs/how-it-works.md +110 -0
  37. package/docs/integration-guide.md +2 -2
  38. package/docs/interaction-model.md +3 -3
  39. package/docs/quickstart.md +6 -6
  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 SessionSettings, type SessionSettingSource, RESERVED_SESSION_SETTINGS, } 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_SESSION_SETTINGS, } 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.
@@ -30,7 +30,32 @@ export type CasingConvention = 'snake_case' | 'camelCase';
30
30
  export type CasingFn = (jsField: string) => string;
31
31
  /** `defineSchema`'s casing option. Identity when unset. */
32
32
  export type Casing = CasingConvention | CasingFn;
33
- /** Options for `defineSchema`. */
33
+ /**
34
+ * A server-authored identity value that can fill one of a customer's Postgres
35
+ * session settings. Deliberately a closed set — every member is resolved by Ablo
36
+ * from the authenticated `ek_` and the plane, never from client-supplied data —
37
+ * so a mapping can forward the tenant identity Ablo already trusts but can never
38
+ * widen a writer's scope. Mirrors the fields the engine sets on the direct-write
39
+ * connection (`app.current_org_id`, `app.current_project_id`, …).
40
+ */
41
+ export type SessionSettingSource = 'orgId' | 'projectId' | 'environment' | 'sandboxId' | 'participantId' | 'participantKind';
42
+ /**
43
+ * A map from a Postgres session-setting name the customer's RLS policies read to
44
+ * the Ablo identity that fills it — e.g. `{ 'app.current_org': 'orgId' }`. The
45
+ * setting name is the key (it takes exactly one source), so a duplicate is
46
+ * unrepresentable rather than validated away. See
47
+ * {@link DefineSchemaOptions.sessionSettings} and ADR 0011.
48
+ */
49
+ export type SessionSettings = Readonly<Record<string, SessionSettingSource>>;
50
+ /**
51
+ * Session settings Ablo already sets on its direct-write connection. A
52
+ * `sessionSettings` entry FORWARDS Ablo's trusted context into a setting the
53
+ * customer's own policies read — it may never reassign one of these, which would
54
+ * let a schema push relax the engine's own scoping, timeouts, or `row_security`.
55
+ * Shared with the engine's direct-write seam so authoring-time validation and
56
+ * the runtime guard read one list. See ADR 0011.
57
+ */
58
+ export declare const RESERVED_SESSION_SETTINGS: readonly string[];
34
59
  export interface DefineSchemaOptions {
35
60
  /**
36
61
  * How to translate camelCase JS field names into database column
@@ -60,6 +85,28 @@ export interface DefineSchemaOptions {
60
85
  * authentication provider attached to the identity.
61
86
  */
62
87
  readonly identityRoles?: readonly IdentityRole[];
88
+ /**
89
+ * Extra Postgres session settings Ablo's direct-write connection applies from
90
+ * your authenticated identity, so your row-level-security policies read them
91
+ * (ADR 0011). Before every direct write, Ablo already `SET LOCAL`s a fixed
92
+ * bundle (`app.current_org_id`, `app.current_project_id`, …). If your policies
93
+ * read a differently-named setting your own app sets per-connection — e.g. a
94
+ * restrictive policy on `current_setting('app.current_org')` — map that
95
+ * setting to the identity that fills it, and Ablo sets it too:
96
+ *
97
+ * ```ts
98
+ * defineSchema({ ... }, {
99
+ * sessionSettings: { 'app.current_org': 'orgId' },
100
+ * })
101
+ * ```
102
+ *
103
+ * The key is the setting name your policies read; the value is a closed set of
104
+ * identities Ablo authenticates, so a mapping can narrow what the writer sees
105
+ * but never widen it. Do NOT carve a policy exception for the writer role —
106
+ * that exempts exactly the writes you want governed. Leave unset when your
107
+ * policies read the settings Ablo already applies (or when a table has no RLS).
108
+ */
109
+ readonly sessionSettings?: SessionSettings;
63
110
  }
64
111
  /** A record of model names → model definitions */
65
112
  export type SchemaRecord = Record<string, ModelDef>;
@@ -114,6 +161,13 @@ export interface Schema<S extends SchemaRecord = SchemaRecord> {
114
161
  * to derive a participant's allowed sync-group set.
115
162
  */
116
163
  readonly identityRoles: readonly IdentityRole[];
164
+ /**
165
+ * Session settings registered via `defineSchema({...}, { sessionSettings })`
166
+ * (ADR 0011). The engine's direct-write path reads this to `SET LOCAL` each
167
+ * named setting from server-authored context before applying DML, so the
168
+ * customer's RLS governs Ablo's writes. Empty object when unset.
169
+ */
170
+ readonly sessionSettings: SessionSettings;
117
171
  /**
118
172
  * Set only on a projection produced by `selectModels`/`omitModels`: the
119
173
  * 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
+ * Session settings Ablo already sets on its direct-write connection. A
49
+ * `sessionSettings` entry FORWARDS Ablo's trusted context into a setting the
50
+ * customer's own policies read — it may never reassign one of these, which would
51
+ * let a schema push relax the engine's own scoping, timeouts, or `row_security`.
52
+ * Shared with the engine's direct-write seam so authoring-time validation and
53
+ * the runtime guard read one list. See ADR 0011.
54
+ */
55
+ export const RESERVED_SESSION_SETTINGS = [
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,41 @@ export function defineSchema(models, options) {
201
221
  resolvedModels[name] = { ...def, typename, tableName, persist };
202
222
  }
203
223
  validateSyncGroupSchema(resolvedModels);
224
+ validateSessionSettings(options?.sessionSettings ?? {});
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
+ sessionSettings: options?.sessionSettings ?? {},
210
232
  };
211
233
  }
234
+ /**
235
+ * Reject session-setting mappings that couldn't do what the author intends —
236
+ * caught here at definition time rather than silently dropped on the write path.
237
+ * Each key must name a non-empty setting the customer's policies read, and must
238
+ * not reassign one Ablo already manages. Uniqueness needs no check: the map is
239
+ * keyed by the setting name, so a duplicate is unrepresentable.
240
+ */
241
+ function validateSessionSettings(settings) {
242
+ for (const setting of Object.keys(settings)) {
243
+ const name = setting.trim();
244
+ if (name === '') {
245
+ throw new AbloValidationError(`[defineSchema] sessionSettings: a key is empty. Name the Postgres ` +
246
+ `setting your row-level-security policies read, e.g. ` +
247
+ `\`{ 'app.current_org': 'orgId' }\`.`, { code: 'schema_definition_invalid', param: 'sessionSettings' });
248
+ }
249
+ if (RESERVED_SESSION_SETTINGS.includes(name)) {
250
+ throw new AbloValidationError(`[defineSchema] sessionSettings: \`${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 setting your app owns (e.g. \`app.current_org\`) and ` +
254
+ `map Ablo's trusted identity into that instead. Reserved: ` +
255
+ `${RESERVED_SESSION_SETTINGS.join(', ')}.`, { code: 'schema_definition_invalid', param: `sessionSettings.${name}` });
256
+ }
257
+ }
258
+ }
212
259
  /**
213
260
  * Validates the relation-driven sync-group declarations (`scope` and `grants`)
214
261
  * 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
+ sessionSettings: schema.sessionSettings,
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 SessionSettings } 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 sessionSettings?: SessionSettings;
78
80
  }
79
81
  /**
80
82
  * Project a `Schema` to its JSON form. Drops the client-only closures
@@ -89,7 +89,14 @@ 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
+ ...(Object.keys(schema.sessionSettings).length > 0
97
+ ? { sessionSettings: schema.sessionSettings }
98
+ : {}),
99
+ };
93
100
  }
94
101
  /** Serialize a `Schema` to a JSON string (the `ablo push` payload). */
95
102
  export function serializeSchema(schema) {
@@ -200,6 +207,7 @@ export function fromSchemaJSON(json) {
200
207
  models: models,
201
208
  validators: validators,
202
209
  identityRoles: json.identityRoles,
210
+ sessionSettings: json.sessionSettings ?? {},
203
211
  };
204
212
  }
205
213
  /** 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' } });