@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.
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +50 -0
- package/README.md +25 -13
- package/dist/cli.cjs +1686 -1219
- package/dist/coordination/schema.d.ts +4 -2
- package/dist/coordination/schema.js +23 -9
- package/dist/errorCodes.d.ts +1 -0
- package/dist/errorCodes.js +2 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/schema.d.ts +55 -1
- package/dist/schema/schema.js +47 -0
- package/dist/schema/select.js +1 -0
- package/dist/schema/serialize.d.ts +3 -1
- package/dist/schema/serialize.js +9 -1
- package/dist/source/adapters/drizzle.js +4 -3
- package/dist/source/adapters/kysely.js +4 -4
- package/dist/source/adapters/prisma.js +3 -3
- package/dist/source/idempotency.d.ts +19 -4
- package/dist/source/idempotency.js +19 -4
- package/dist/source/migrations.d.ts +4 -1
- package/dist/source/migrations.js +36 -5
- package/docs/agent-messaging.md +1 -2
- package/docs/api.md +4 -4
- package/docs/audit.md +35 -23
- package/docs/client-behavior.md +7 -4
- package/docs/concurrency-convention.md +3 -3
- package/docs/coordination.md +13 -1
- package/docs/data-sources.md +34 -32
- package/docs/examples/agent-human.md +2 -2
- package/docs/examples/ai-sdk-tool.md +1 -1
- package/docs/examples/nextjs.md +1 -1
- package/docs/examples/server-agent.md +2 -2
- package/docs/groups.md +160 -0
- package/docs/guarantees.md +9 -7
- package/docs/how-it-works.md +110 -0
- package/docs/integration-guide.md +2 -2
- package/docs/interaction-model.md +3 -3
- package/docs/quickstart.md +6 -6
- package/docs/webhooks.md +23 -22
- package/llms.txt +10 -4
- 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,
|
package/dist/errorCodes.d.ts
CHANGED
|
@@ -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;
|
package/dist/errorCodes.js
CHANGED
|
@@ -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
|
|
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.'),
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/schema/index.js
CHANGED
|
@@ -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.
|
package/dist/schema/schema.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
package/dist/schema/schema.js
CHANGED
|
@@ -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
|
package/dist/schema/select.js
CHANGED
|
@@ -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
|
package/dist/schema/serialize.js
CHANGED
|
@@ -89,7 +89,14 @@ export function toSchemaJSON(schema) {
|
|
|
89
89
|
models[key] = modelToJSON(def);
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
-
return {
|
|
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
|
-
*
|
|
42
|
-
* `
|
|
43
|
-
*
|
|
44
|
-
* for
|
|
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
|
-
*
|
|
106
|
-
* `
|
|
107
|
-
*
|
|
108
|
-
* for
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
31
|
-
|
|
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: `
|
|
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
|
}
|
package/docs/agent-messaging.md
CHANGED
|
@@ -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({
|
|
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
|
-
| `
|
|
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
|
-
"
|
|
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.
|
|
179
|
+
claim.description;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
const handle = await ablo.weatherReports.claim({
|
|
183
183
|
id: 'report_stockholm',
|
|
184
|
-
|
|
184
|
+
description: 'editing',
|
|
185
185
|
ttl: '2m',
|
|
186
186
|
});
|
|
187
187
|
await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' } });
|