@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 +23 -0
- package/README.md +24 -12
- package/dist/cli.cjs +90 -18
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/schema.d.ts +37 -40
- package/dist/schema/schema.js +24 -30
- package/dist/schema/select.js +1 -1
- package/dist/schema/serialize.d.ts +3 -3
- package/dist/schema/serialize.js +4 -2
- package/docs/coordination.md +13 -1
- package/package.json +1 -1
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>
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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**
|
|
67
|
-
your own infrastructure,
|
|
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
|
-
|
|
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:
|
|
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) {
|
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, type
|
|
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,
|
|
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,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
|
|
36
|
-
*
|
|
37
|
-
* authenticated `ek_` and the plane, never from client-supplied data —
|
|
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
|
|
41
|
+
export type SessionSettingSource = 'orgId' | 'projectId' | 'environment' | 'sandboxId' | 'participantId' | 'participantKind';
|
|
43
42
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* may never reassign one of these, which would
|
|
58
|
-
* engine's own scoping, timeouts, or `row_security`.
|
|
59
|
-
* direct-write seam so authoring-time validation and
|
|
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
|
|
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
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* write, Ablo already
|
|
96
|
-
* `app.current_project_id`, …)
|
|
97
|
-
* differently-named
|
|
98
|
-
* restrictive policy on `current_setting('app.
|
|
99
|
-
*
|
|
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
|
-
*
|
|
99
|
+
* sessionSettings: { 'app.current_org': 'orgId' },
|
|
105
100
|
* })
|
|
106
101
|
* ```
|
|
107
102
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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
|
|
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
|
package/dist/schema/schema.js
CHANGED
|
@@ -45,14 +45,14 @@ function camelToSnake(identifier) {
|
|
|
45
45
|
return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* may never reassign one of these, which would
|
|
51
|
-
* engine's own scoping, timeouts, or `row_security`.
|
|
52
|
-
* direct-write seam so authoring-time validation and
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
231
|
+
sessionSettings: options?.sessionSettings ?? {},
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
234
|
/**
|
|
235
|
-
* Reject
|
|
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
|
-
*
|
|
238
|
-
*
|
|
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
|
|
241
|
-
const
|
|
242
|
-
|
|
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]
|
|
246
|
-
`
|
|
247
|
-
`\`{
|
|
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 (
|
|
250
|
-
throw new AbloValidationError(`[defineSchema]
|
|
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
|
|
254
|
-
`map Ablo's trusted
|
|
255
|
-
`${
|
|
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
|
/**
|
package/dist/schema/select.js
CHANGED
|
@@ -52,7 +52,7 @@ export function selectModels(schema, keys) {
|
|
|
52
52
|
models: models,
|
|
53
53
|
validators: validators,
|
|
54
54
|
identityRoles: schema.identityRoles,
|
|
55
|
-
|
|
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
|
|
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
|
|
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
|
package/dist/schema/serialize.js
CHANGED
|
@@ -93,7 +93,9 @@ export function toSchemaJSON(schema) {
|
|
|
93
93
|
v: SCHEMA_JSON_VERSION,
|
|
94
94
|
models,
|
|
95
95
|
identityRoles: schema.identityRoles,
|
|
96
|
-
...(schema.
|
|
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
|
-
|
|
210
|
+
sessionSettings: json.sessionSettings ?? {},
|
|
209
211
|
};
|
|
210
212
|
}
|
|
211
213
|
/** Parse a `Schema` from a JSON string (inverse of {@link serializeSchema}). */
|
package/docs/coordination.md
CHANGED
|
@@ -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
|
|
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?` |
|