@abloatai/ablo 0.32.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.33.0
4
+
5
+ ### Minor Changes
6
+
7
+ The declarative seam introduced in 0.32.0 for carrying tenant identity into your row-level-security policies has a clearer name and a simpler shape. `tenantContext` is now `sessionSettings`, and instead of a list of `{ guc, from }` pairs it is a plain map from the Postgres session setting your policies read to the Ablo identity that fills it:
8
+
9
+ ```ts
10
+ defineSchema({
11
+ models: { document: { /* … */ } },
12
+ sessionSettings: { 'app.current_org': 'orgId' },
13
+ })
14
+ ```
15
+
16
+ The setting name is the key, so a setting takes exactly one source and a duplicate is unrepresentable rather than something to validate away. If you adopted `tenantContext` in 0.32.0, rename it to `sessionSettings` and turn each `{ guc: 'app.current_org', from: 'orgId' }` into `'app.current_org': 'orgId'`; the exported names followed the rename — `TenantContextMapping` and `TenantContextSource` became `SessionSettings` and `SessionSettingSource`, and `RESERVED_TENANT_CONTEXT_GUCS` became `RESERVED_SESSION_SETTINGS`. The meaning is unchanged: Ablo fills only settings it resolves from your authenticated identity, never from client-supplied data, so a mapping can forward the tenant Ablo already trusts but can never widen a writer's scope, and settings the engine reserves for itself — `row_security`, the timeouts — are still refused at definition time. Schemas pushed before the rename keep parsing.
17
+
18
+ Claims gain a fairness backstop, off by default. Claims coordinate long work by waiting rather than locking: when another participant holds a row, your `claim` joins a fair FIFO line behind it, and the holder keeps its lease alive for as long as the work runs by beating on a cadence. Nothing bounded a hold, so a holder that kept beating — and never read the `queueDepth` pressure signal each beat returns, the cue to checkpoint and release when others are waiting — could keep a contended row indefinitely, and the line behind it had no recourse but that holder's goodwill. A deployment can now configure, per model, how long a single holding may last while contenders are actually queued; a holder that runs past that fair share is preempted at the server on its next beat, which arrives as the `claim_lost` you already handle — abandon or re-claim, exactly as when a lease lapses. The guarantee is deliberately narrow: a holder with no one waiting is never preempted however long it runs, so open-ended solo work is untouched, and preemption is a fairness decision, never a correctness one — a preempted holder's next write is still checked at commit against the row's version and fencing token, so the worst it can cause is a clean re-read, never a lost update.
19
+
20
+ ### Patch Changes
21
+
22
+ Connecting a database a second time now behaves the way you'd expect. `ablo connect --apply` publishes exactly the tables you name with `--tables`, and until now it assumed that publication didn't exist yet — so on a database that had already been connected, the step to publish your tables quietly did nothing, and the writer role ended up granted on one set of tables while Ablo was still reading a different, older set. Registration then refused the writer as "not ready," which was correct but baffling: the two halves disagreed and nothing said so. Apply now reconciles the publication to match your `--tables` on every run — adding what's newly named, dropping what's no longer there, the same declarative model a CDC tool like Debezium uses — and prints what it's about to add or drop before it touches anything, so narrowing the set is a decision you see rather than a surprise.
23
+
24
+ When a readiness check does fail, the reason is finally visible. The direct-write preflight has always returned a precise checklist — which privilege is missing, on which table, and the exact grant to fix it — but the CLI was reading it from the wrong place in the error and rendered a blank message that sent you in circles. The checklist now prints under the failure, one line per unmet requirement, with the fix.
25
+
3
26
  ## 0.32.0
4
27
 
5
28
  ### Minor Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <strong>Let people and AI agents work on the same data without overwriting each other.</strong>
6
+ <strong>The coordination infrastructure for fleets of AI agents.</strong>
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -23,17 +23,28 @@
23
23
 
24
24
  ---
25
25
 
26
- When an agent and a person change the same thing at once, work gets lost: one
27
- edit silently clobbers another, or the agent acts on data that already moved.
28
- Ablo gives them one shared, typed write path so people, server actions, and
29
- agents can all work on the same rows without working blind.
26
+ Development stopped being the bottleneck; coordination is. The moment a *fleet*
27
+ of agents not one, and not just people edits the same rows, work gets lost:
28
+ one agent clobbers another, or acts on data that already moved. Ablo is the
29
+ infrastructure that lets the fleet work as one the load-bearing coordination
30
+ layer the way operational-transform and presence sit invisibly under a shared
31
+ document. You build the agents; Ablo is the substrate that lets them run together
32
+ on shared state without stepping on each other, with the people and server
33
+ actions alongside them on the exact same path.
30
34
 
31
35
  The core idea is a **claim**. An agent's work is rarely one instant write; it
32
- reads something, thinks, calls an LLM or tool, then writes back. While that is
33
- happening, the row can change underneath it. So before slow work starts, the
34
- agent claims the row. If someone else is already working on it, `claim` waits,
35
- re-reads the fresh row, then hands it over. No stale overwrite, no separate
36
- agent mutation path.
36
+ reads something, thinks, calls an LLM or a tool, then writes back and in that
37
+ gap the row can move under it. So before the slow work starts, the agent claims
38
+ the row. If another agent is already on it, `claim` waits its turn in a fair
39
+ line, re-reads the fresh row, then hands it over. No stale overwrite, no separate
40
+ agent mutation path. People are exempt by policy: a human edit is never made to
41
+ queue behind an agent, and by declared conflict rules always wins.
42
+
43
+ When the row moves anyway — say a record an agent generated against gets bumped
44
+ the moment before it commits — the commit is rejected and the agent is handed
45
+ back exactly the records that changed, so it re-reads only those, not the world.
46
+ The pattern we kept measuring: with real agents contending on shared state, the
47
+ win comes from the coordination layer, not a bigger model. Org beats intelligence.
37
48
 
38
49
  Under the hood, you define your data once with a Zod schema and get the same
39
50
  typed model client for every actor — people, server actions, and agents:
@@ -63,8 +74,9 @@ yet? A **sandbox** `sk_test` key holds throwaway **test data** — like Stripe t
63
74
  mode — so you can explore before pointing it at your Postgres. Test-mode only; in
64
75
  production every row lives in your database.)
65
76
 
66
- **Built for** collaborative editors, AI agent workflows, background workers on
67
- your own infrastructure, and internal tools anywhere people and agents change
77
+ **Built for** fleets of agents working a shared backlog, AI agent workflows on
78
+ your own infrastructure, collaborative editors where agents and people co-edit,
79
+ and internal tools — anywhere agents (and the people alongside them) change
68
80
  shared state and everyone has to see it live.
69
81
 
70
82
  ## Set up
package/dist/cli.cjs CHANGED
@@ -5431,6 +5431,55 @@ BEGIN
5431
5431
  END $$;`
5432
5432
  ];
5433
5433
  }
5434
+ function reconcilePublicationPlan(current, desiredTables) {
5435
+ const pub = quoteIdent(ABLO_PUBLICATION);
5436
+ const desiredAll = desiredTables.length === 0;
5437
+ const target = desiredAll ? "FOR ALL TABLES" : `FOR TABLE ${desiredTables.map(quoteIdent).join(", ")}`;
5438
+ if (!current.exists) {
5439
+ return {
5440
+ sql: [`CREATE PUBLICATION ${pub} ${target};`],
5441
+ added: desiredAll ? [] : [...desiredTables],
5442
+ removed: [],
5443
+ recreated: false
5444
+ };
5445
+ }
5446
+ if (current.allTables !== desiredAll) {
5447
+ return {
5448
+ sql: [`DROP PUBLICATION IF EXISTS ${pub};`, `CREATE PUBLICATION ${pub} ${target};`],
5449
+ added: desiredAll ? [] : desiredTables.filter((t) => !current.tables.includes(t)),
5450
+ removed: current.allTables ? [] : current.tables.filter((t) => !desiredTables.includes(t)),
5451
+ recreated: true
5452
+ };
5453
+ }
5454
+ if (desiredAll) {
5455
+ return { sql: [], added: [], removed: [], recreated: false };
5456
+ }
5457
+ const added = desiredTables.filter((t) => !current.tables.includes(t));
5458
+ const removed = current.tables.filter((t) => !desiredTables.includes(t));
5459
+ if (added.length === 0 && removed.length === 0) {
5460
+ return { sql: [], added: [], removed: [], recreated: false };
5461
+ }
5462
+ return {
5463
+ sql: [`ALTER PUBLICATION ${pub} SET TABLE ${desiredTables.map(quoteIdent).join(", ")};`],
5464
+ added,
5465
+ removed,
5466
+ recreated: false
5467
+ };
5468
+ }
5469
+ async function readPublicationState(sql) {
5470
+ const pubRows = await sql.unsafe(
5471
+ `SELECT puballtables FROM pg_publication WHERE pubname = $1`,
5472
+ [ABLO_PUBLICATION]
5473
+ );
5474
+ const pubRow = pubRows[0];
5475
+ if (!pubRow) return { exists: false, allTables: false, tables: [] };
5476
+ if (pubRow.puballtables) return { exists: true, allTables: true, tables: [] };
5477
+ const tableRows = await sql.unsafe(
5478
+ `SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = 'public' ORDER BY tablename`,
5479
+ [ABLO_PUBLICATION]
5480
+ );
5481
+ return { exists: true, allTables: false, tables: tableRows.map((r2) => r2.tablename) };
5482
+ }
5434
5483
  async function probeReadiness(sql, opts = {}) {
5435
5484
  const publication = opts.publication ?? ABLO_PUBLICATION;
5436
5485
  const items = [];
@@ -5568,7 +5617,7 @@ async function registerDirectDataSource(opts) {
5568
5617
  )
5569
5618
  );
5570
5619
  } else if (code === "database_not_replication_ready" || code === "data_source_blocked") {
5571
- for (const f of body.details?.failures ?? []) {
5620
+ for (const f of body.failures ?? body.details?.failures ?? []) {
5572
5621
  console.error(
5573
5622
  ` ${import_picocolors6.default.red("\u2717")} ${import_picocolors6.default.bold(f.item ?? "item")}${f.actual ? import_picocolors6.default.dim(` (${f.actual})`) : ""}`
5574
5623
  );
@@ -5580,7 +5629,8 @@ async function registerDirectDataSource(opts) {
5580
5629
  Apply the fixes, verify with ${import_picocolors6.default.bold("ablo connect check")}, then re-run.`)
5581
5630
  );
5582
5631
  } else if (code === "database_unreachable" || code === "source_unreachable") {
5583
- if (body.details?.reason) console.error(import_picocolors6.default.dim(` ${body.details.reason}`));
5632
+ const reason = body.reason ?? body.details?.reason;
5633
+ if (reason) console.error(import_picocolors6.default.dim(` ${reason}`));
5584
5634
  console.error(
5585
5635
  import_picocolors6.default.dim(
5586
5636
  ` Ablo's servers must be able to reach this database \u2014 a localhost or private-network
@@ -5592,7 +5642,7 @@ async function registerDirectDataSource(opts) {
5592
5642
  console.error();
5593
5643
  return false;
5594
5644
  }
5595
- var import_picocolors6, import_zod6, ABLO_PUBLICATION, ABLO_REPLICATION_ROLE, ABLO_WRITE_ROLE, DIRECT_DATA_SOURCE_ROUTES, DataSourceRegisterSuccess, DataSourceRegisterError;
5645
+ var import_picocolors6, import_zod6, ABLO_PUBLICATION, ABLO_REPLICATION_ROLE, ABLO_WRITE_ROLE, DIRECT_DATA_SOURCE_ROUTES, DataSourceRegisterSuccess, ReadinessFailure, DataSourceRegisterError;
5596
5646
  var init_connectSetup = __esm({
5597
5647
  "src/cli/connectSetup.ts"() {
5598
5648
  "use strict";
@@ -5614,17 +5664,20 @@ var init_connectSetup = __esm({
5614
5664
  host: import_zod6.z.string().optional(),
5615
5665
  status: import_zod6.z.string().optional()
5616
5666
  });
5667
+ ReadinessFailure = import_zod6.z.object({
5668
+ item: import_zod6.z.string().optional(),
5669
+ actual: import_zod6.z.string().optional(),
5670
+ fix: import_zod6.z.string().optional()
5671
+ });
5617
5672
  DataSourceRegisterError = import_zod6.z.object({
5618
5673
  code: import_zod6.z.string().optional(),
5619
5674
  message: import_zod6.z.string().optional(),
5675
+ // Canonical: top-level, spread from the engine's `details`.
5676
+ failures: import_zod6.z.array(ReadinessFailure).optional(),
5677
+ reason: import_zod6.z.string().optional(),
5678
+ // Fallback: a wrapping proxy or older engine that nested the same payload.
5620
5679
  details: import_zod6.z.object({
5621
- failures: import_zod6.z.array(
5622
- import_zod6.z.object({
5623
- item: import_zod6.z.string().optional(),
5624
- actual: import_zod6.z.string().optional(),
5625
- fix: import_zod6.z.string().optional()
5626
- })
5627
- ).optional(),
5680
+ failures: import_zod6.z.array(ReadinessFailure).optional(),
5628
5681
  reason: import_zod6.z.string().optional()
5629
5682
  }).optional(),
5630
5683
  error: import_zod6.z.object({ code: import_zod6.z.string().optional(), message: import_zod6.z.string().optional() }).optional()
@@ -5829,18 +5882,21 @@ function connectApplyPlan(input) {
5829
5882
  sql: []
5830
5883
  }
5831
5884
  ];
5885
+ const reconcile2 = input.existingPublication ? reconcilePublicationPlan(input.existingPublication, tables) : null;
5886
+ const publicationSql = reconcile2 ? reconcile2.sql : [
5887
+ `DO $$ BEGIN
5888
+ CREATE PUBLICATION ${quoteIdent(ABLO_PUBLICATION)} ${publicationTarget};
5889
+ EXCEPTION WHEN duplicate_object THEN NULL;
5890
+ END $$;`
5891
+ ];
5892
+ const publicationDetail = reconcile2?.sql.length === 0 ? "already publishing exactly these tables \u2014 nothing to change" : tables.length > 0 ? `a read stream of the ${tables.length} table${tables.length === 1 ? "" : "s"} you chose` : "a read stream of your tables";
5832
5893
  return [
5833
5894
  ...walStep,
5834
5895
  {
5835
5896
  key: "publication",
5836
5897
  title: "Publish your tables to Ablo",
5837
- detail: tables.length > 0 ? `a read stream of the ${tables.length} table${tables.length === 1 ? "" : "s"} you chose` : "a read stream of your tables",
5838
- sql: [
5839
- `DO $$ BEGIN
5840
- CREATE PUBLICATION ${quoteIdent(ABLO_PUBLICATION)} ${publicationTarget};
5841
- EXCEPTION WHEN duplicate_object THEN NULL;
5842
- END $$;`
5843
- ]
5898
+ detail: publicationDetail,
5899
+ sql: publicationSql
5844
5900
  },
5845
5901
  {
5846
5902
  key: "replication-role",
@@ -6046,6 +6102,10 @@ async function runConnectApply(args) {
6046
6102
  }
6047
6103
  const provider = detectProvider(target);
6048
6104
  const walReady = await currentWalLevel(admin) === "logical";
6105
+ const existingPublication = await readPublicationState(admin).catch(
6106
+ () => ({ exists: false, allTables: false, tables: [] })
6107
+ );
6108
+ const pubReconcile = reconcilePublicationPlan(existingPublication, args.tables);
6049
6109
  const replicationPassword = generateRolePassword();
6050
6110
  const writePassword = generateRolePassword();
6051
6111
  const buildPlan = (mode2) => connectApplyPlan({
@@ -6057,9 +6117,21 @@ async function runConnectApply(args) {
6057
6117
  writeClause: passwordClause(writePassword, mode2)
6058
6118
  },
6059
6119
  walAlreadyLogical: walReady,
6060
- provider
6120
+ provider,
6121
+ existingPublication
6061
6122
  });
6062
6123
  const steps = buildPlan("scram-verifier");
6124
+ if (pubReconcile.removed.length > 0 || pubReconcile.recreated) {
6125
+ console.log(
6126
+ ` ${import_picocolors8.default.yellow("!")} ${import_picocolors8.default.bold(ABLO_PUBLICATION)} already publishes a different set; reconciling to your ${import_picocolors8.default.bold("--tables")}:`
6127
+ );
6128
+ for (const t of pubReconcile.added) console.log(` ${import_picocolors8.default.green("+")} ${t}`);
6129
+ for (const t of pubReconcile.removed)
6130
+ console.log(` ${import_picocolors8.default.red("-")} ${t} ${import_picocolors8.default.dim("(stops replicating to Ablo)")}`);
6131
+ if (pubReconcile.recreated && existingPublication.allTables)
6132
+ console.log(` ${import_picocolors8.default.red("-")} ${import_picocolors8.default.dim("every other table (was FOR ALL TABLES)")}`);
6133
+ console.log();
6134
+ }
6063
6135
  printPlan2(steps, args.showSql);
6064
6136
  if (!args.yes) {
6065
6137
  if (!process.stdout.isTTY) {
@@ -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, type TenantContextMapping, type TenantContextSource, RESERVED_TENANT_CONTEXT_GUCS, } 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, RESERVED_TENANT_CONTEXT_GUCS, } 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,36 +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`. */
34
33
  /**
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
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
39
38
  * widen a writer's scope. Mirrors the fields the engine sets on the direct-write
40
39
  * connection (`app.current_org_id`, `app.current_project_id`, …).
41
40
  */
42
- export type TenantContextSource = 'orgId' | 'projectId' | 'environment' | 'sandboxId' | 'participantId' | 'participantKind';
41
+ export type SessionSettingSource = 'orgId' | 'projectId' | 'environment' | 'sandboxId' | 'participantId' | 'participantKind';
43
42
  /**
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.
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.
47
48
  */
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
- }
49
+ export type SessionSettings = Readonly<Record<string, SessionSettingSource>>;
54
50
  /**
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.
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.
61
57
  */
62
- export declare const RESERVED_TENANT_CONTEXT_GUCS: readonly string[];
58
+ export declare const RESERVED_SESSION_SETTINGS: readonly string[];
63
59
  export interface DefineSchemaOptions {
64
60
  /**
65
61
  * How to translate camelCase JS field names into database column
@@ -90,26 +86,27 @@ export interface DefineSchemaOptions {
90
86
  */
91
87
  readonly identityRoles?: readonly IdentityRole[];
92
88
  /**
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:
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:
101
96
  *
102
97
  * ```ts
103
98
  * defineSchema({ ... }, {
104
- * tenantContext: [{ guc: 'app.current_app_org_id', from: 'orgId' }],
99
+ * sessionSettings: { 'app.current_org': 'orgId' },
105
100
  * })
106
101
  * ```
107
102
  *
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.
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).
111
108
  */
112
- readonly tenantContext?: readonly TenantContextMapping[];
109
+ readonly sessionSettings?: SessionSettings;
113
110
  }
114
111
  /** A record of model names → model definitions */
115
112
  export type SchemaRecord = Record<string, ModelDef>;
@@ -165,12 +162,12 @@ export interface Schema<S extends SchemaRecord = SchemaRecord> {
165
162
  */
166
163
  readonly identityRoles: readonly IdentityRole[];
167
164
  /**
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.
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.
172
169
  */
173
- readonly tenantContext: readonly TenantContextMapping[];
170
+ readonly sessionSettings: SessionSettings;
174
171
  /**
175
172
  * Set only on a projection produced by `selectModels`/`omitModels`: the
176
173
  * content hash of the FULL source schema the subset was cut from. A subset
@@ -45,14 +45,14 @@ function camelToSnake(identifier) {
45
45
  return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
46
46
  }
47
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.
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
54
  */
55
- export const RESERVED_TENANT_CONTEXT_GUCS = [
55
+ export const RESERVED_SESSION_SETTINGS = [
56
56
  'statement_timeout',
57
57
  'lock_timeout',
58
58
  'row_security',
@@ -221,45 +221,39 @@ export function defineSchema(models, options) {
221
221
  resolvedModels[name] = { ...def, typename, tableName, persist };
222
222
  }
223
223
  validateSyncGroupSchema(resolvedModels);
224
- validateTenantContext(options?.tenantContext ?? []);
224
+ validateSessionSettings(options?.sessionSettings ?? {});
225
225
  return {
226
226
  // Cast back to S: we only added values to optional fields that were
227
227
  // already part of ModelDef, so the shape is structurally unchanged.
228
228
  models: resolvedModels,
229
229
  validators: validators,
230
230
  identityRoles: options?.identityRoles ?? [],
231
- tenantContext: options?.tenantContext ?? [],
231
+ sessionSettings: options?.sessionSettings ?? {},
232
232
  };
233
233
  }
234
234
  /**
235
- * Reject tenant-context mappings that couldn't do what the author intends —
235
+ * Reject session-setting mappings that couldn't do what the author intends —
236
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.
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.
239
240
  */
240
- function validateTenantContext(mappings) {
241
- const seen = new Set();
242
- for (const { guc } of mappings) {
243
- const name = guc.trim();
241
+ function validateSessionSettings(settings) {
242
+ for (const setting of Object.keys(settings)) {
243
+ const name = setting.trim();
244
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' });
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
248
  }
249
- if (RESERVED_TENANT_CONTEXT_GUCS.includes(name)) {
250
- throw new AbloValidationError(`[defineSchema] tenantContext: \`${name}\` is a setting Ablo already ` +
249
+ if (RESERVED_SESSION_SETTINGS.includes(name)) {
250
+ throw new AbloValidationError(`[defineSchema] sessionSettings: \`${name}\` is a setting Ablo already ` +
251
251
  `manages on its write connection, so a mapping can't reassign it — ` +
252
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}` });
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
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
257
  }
264
258
  }
265
259
  /**
@@ -52,7 +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
+ sessionSettings: schema.sessionSettings,
56
56
  // Record the full source's hash so the drift check can recognize this subset
57
57
  // as current against a server running that full schema. Prefer the source's
58
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, type TenantContextMapping } 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,8 +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
+ /** Optional so schemas pushed before ADR 0011 still parse (defaults to `{}`). */
79
+ readonly sessionSettings?: SessionSettings;
80
80
  }
81
81
  /**
82
82
  * Project a `Schema` to its JSON form. Drops the client-only closures
@@ -93,7 +93,9 @@ export function toSchemaJSON(schema) {
93
93
  v: SCHEMA_JSON_VERSION,
94
94
  models,
95
95
  identityRoles: schema.identityRoles,
96
- ...(schema.tenantContext.length > 0 ? { tenantContext: schema.tenantContext } : {}),
96
+ ...(Object.keys(schema.sessionSettings).length > 0
97
+ ? { sessionSettings: schema.sessionSettings }
98
+ : {}),
97
99
  };
98
100
  }
99
101
  /** Serialize a `Schema` to a JSON string (the `ablo push` payload). */
@@ -205,7 +207,7 @@ export function fromSchemaJSON(json) {
205
207
  models: models,
206
208
  validators: validators,
207
209
  identityRoles: json.identityRoles,
208
- tenantContext: json.tenantContext ?? [],
210
+ sessionSettings: json.sessionSettings ?? {},
209
211
  };
210
212
  }
211
213
  /** Parse a `Schema` from a JSON string (inverse of {@link serializeSchema}). */
@@ -473,6 +473,18 @@ Each beat's answer carries two more things:
473
473
  the lease. Durable progress belongs in the data itself — write a row, and
474
474
  every subscriber already sees it.
475
475
 
476
+ Cooperative yield has a server-side backstop your deployment can turn on: a
477
+ **cumulative-hold ceiling**. Left unset — the default — a holder that keeps
478
+ beating holds the row as long as it likes, and the line behind it depends on
479
+ that holder reading `queueDepth` and releasing of its own accord. With a ceiling
480
+ configured for a model, a holder that runs past its fair share *while contenders
481
+ are queued* is preempted at the server: its next beat comes back `claim_lost`
482
+ (reason `preempted`) — the same loss you already handle, so abandon or re-claim,
483
+ and any write attempted under the old lease is fenced regardless. A holder with
484
+ no one waiting is never preempted, however long it runs. It is the same idea as
485
+ an SQS message that cannot stay invisible past a hard cap however often its lock
486
+ is refreshed, narrowed here to bite only under real contention.
487
+
476
488
  Works identically on both transports: the realtime client sends a
477
489
  `claim_heartbeat` frame; the HTTP client posts
478
490
  `POST /api/v1/models/{model}/{id}/claim/heartbeat` (`{ ttl?, claimId?, details? }`).
@@ -603,7 +615,7 @@ inspect the `code`.
603
615
 
604
616
  | error | `code` | thrown when | carries |
605
617
  |---|---|---|---|
606
- | `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away — the holder disconnected (reaped on the keepalive cycle), went silent past its TTL, or was revoked — while you were holding or waiting. | `claims?` |
618
+ | `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away — the holder disconnected (reaped on the keepalive cycle), went silent past its TTL, was revoked, or was preempted (a privileged reorder, or a configured cumulative-hold ceiling reached while contenders waited) — while you were holding or waiting. | `claims?` |
607
619
  | `AbloClaimedError` | `claim_queued` | **HTTP transport only.** A contended `claim` (default `queue: true`) could not block-wait for the lease (no socket), so it rejected immediately instead of queueing. Retryable — re-attempt the claim. | `claims?` |
608
620
  | `AbloClaimedError` | `grant_timeout` | The optional `timeoutMs` elapsed while you were still queued for a grant. | `claims?` |
609
621
  | `AbloClaimedError` | `queue_too_deep` | `claim` was passed `maxQueueDepth` and the wait line was already that deep when you tried to join — fail-fast instead of waiting. | `claims?` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",