@abloatai/ablo 0.32.0 → 0.34.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 +35 -0
- package/README.md +24 -12
- package/dist/cli.cjs +148 -21
- package/dist/client/Ablo.js +3 -2
- package/dist/client/createModelProxy.d.ts +53 -10
- package/dist/client/createModelProxy.js +24 -5
- package/dist/client/httpTransport.js +1 -0
- package/dist/client/resourceTypes.d.ts +10 -1
- package/dist/client/wsMutationExecutor.d.ts +2 -2
- package/dist/client/wsMutationExecutor.js +1 -1
- package/dist/coordination/index.d.ts +2 -2
- package/dist/coordination/index.js +1 -1
- package/dist/coordination/schema.d.ts +20 -0
- package/dist/coordination/schema.js +22 -0
- package/dist/errorCodes.d.ts +1 -1
- package/dist/errorCodes.js +1 -1
- package/dist/interfaces/index.d.ts +13 -1
- package/dist/react/AbloProvider.d.ts +8 -8
- package/dist/react/AbloProvider.js +6 -6
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.js +2 -2
- 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/dist/server/commit.d.ts +8 -1
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -1
- package/dist/sync/SyncWebSocket.d.ts +3 -3
- package/dist/sync/SyncWebSocket.js +4 -4
- package/dist/sync/commitFrames.d.ts +2 -2
- package/dist/sync/commitFrames.js +5 -1
- package/dist/transactions/TransactionQueue.d.ts +4 -1
- package/dist/transactions/TransactionQueue.js +18 -0
- package/dist/transactions/commitEnvelope.d.ts +8 -0
- package/dist/transactions/commitEnvelope.js +2 -1
- package/dist/transactions/durableWriteStore.d.ts +8 -0
- package/dist/wire/frames.d.ts +17 -1
- package/dist/wire/frames.js +2 -1
- package/docs/client-behavior.md +1 -1
- package/docs/coordination.md +18 -6
- package/docs/groups.md +50 -0
- package/docs/identity.md +2 -2
- package/docs/migration.md +29 -5
- package/docs/react.md +9 -9
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.34.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
A long-running actor has a stale-context problem the per-commit read gate never reaches. The `reads` guard is a premise for the commit in hand: you declare what you looked at, the server checks it at commit, and the premise is gone. That fits an actor that reads and writes in one breath, not one that reads a row, works for minutes — an LLM call, a fetch, a human's turn — and only then writes. By the time it commits, the premise it would have declared is already old, and there was no commit in between on which to hear that the ground had shifted. This release adds `track`, the durable half of the same idea. `ablo.<model>.track({ id })` registers a read-dependency that persists on the server; the next time you commit anything, a change that landed on the tracked row since you registered rides back on the receipt's `notifications` — the same `StaleNotification` an `onStale: 'notify'` premise hands you, arriving on the write you were going to make anyway. You can also register one as part of a write, `track: [{ group: 'deck:abc' }]` alongside the batch, the standing-subscription companion to the single-commit `reads`. A track is idempotent — registering the same target again refreshes the one subscription rather than stacking duplicates — it re-baselines after it fires so a given change notifies once, and it never notifies you of your own writes, since the signal is about what others did. Delivery is on your next commit's receipt; a track does not yet push out of band between commits, so it sharpens the write-time freshness check rather than replacing a live subscription.
|
|
8
|
+
|
|
9
|
+
The model-level presence verb is renamed from `watch` to `join`. It read like a data subscription but delivered presence — who else is on a set of rows and what they hold — so it now says what it does. `ablo.<model>.join(ids, { ttl })` opens the participant handle, with `.peers`, the scoped claim stream, and `await using` disposal unchanged; the handle's `status` was already `'joined'` and the layer beneath always called itself join, so the verb now matches the thing it returns. `onChange` remains the way to hear a row's *values* change, and `track` is the durable read-dependency for actors — three distinct jobs that the one overloaded `watch` used to blur. The React hook follows: `useWatch` becomes `useJoin`, the `WatchOptions` / `UseWatchOptions` / `UseWatchReturn` types become `JoinOptions` / `UseJoinOptions` / `UseJoinReturn`, and the error code `model_watch_not_configured` is now `model_join_not_configured`. There is no compatibility alias — rename the call sites and the type imports. The migration guide carries the mechanical diff.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
`ablo connect --apply` now checks, before it runs any grants, that you can actually grant on the tables you're publishing — and stops with the one-line fix if you can't, instead of failing partway through. The setup grants the writer role access to each published table, an operation Postgres reserves for the table's owner, so a table left owned by an earlier integration's role used to abort the run midway with a bare `must be owner of table …` and no guidance. Apply now names the offending tables and their owner up front and prints the exact `ALTER TABLE … OWNER TO` to reassign them to your admin — metadata only, your rows and row-level-security policies untouched. The check understands inherited role membership, so an admin that inherits the owning role — the ordinary managed-Postgres case — is left to proceed rather than stopped needlessly; only a membership that can't act as the owner is flagged.
|
|
14
|
+
|
|
15
|
+
## 0.33.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
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:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
defineSchema({
|
|
23
|
+
models: { document: { /* … */ } },
|
|
24
|
+
sessionSettings: { 'app.current_org': 'orgId' },
|
|
25
|
+
})
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
### Patch Changes
|
|
33
|
+
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
3
38
|
## 0.32.0
|
|
4
39
|
|
|
5
40
|
### 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
|
@@ -909,7 +909,7 @@ var init_errorCodes = __esm({
|
|
|
909
909
|
model_claimed: wire("claim", 409, false, "Another participant holds a claim on this row. Read `claim.state` to see who holds it, or queue behind them with a claim of your own."),
|
|
910
910
|
model_claimed_timeout: wire("claim", 409, false, "Another participant held a claim on this row and did not release it in time. Retry, or read `claim.state` to see who holds it."),
|
|
911
911
|
model_claim_not_configured: client("claim", "Claiming requires the collaboration runtime, which the standard Ablo({ schema, apiKey }) client wires up for every model automatically \u2014 there is no per-model claim configuration to add. This appears only when a model proxy is constructed directly without that runtime (an internal/advanced path)."),
|
|
912
|
-
|
|
912
|
+
model_join_not_configured: client("claim", "join() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport)."),
|
|
913
913
|
// ── stale context / idempotency (409) ──────────────────────────────
|
|
914
914
|
// Not retryable at the transport: the rejected request carries its frozen
|
|
915
915
|
// `readAt`, so resending the identical payload can never succeed. Recovery
|
|
@@ -1199,7 +1199,7 @@ var init_roles = __esm({
|
|
|
1199
1199
|
});
|
|
1200
1200
|
|
|
1201
1201
|
// src/coordination/schema.ts
|
|
1202
|
-
var import_zod3, targetRangeSchema, participantKindSchema, wireParticipantKindSchema, targetRefSchema, onStaleModeSchema, writeGuardSchema, staleNotificationSchema, readDependencySchema, claimStatusSchema, grantStampFields, wireClaimBaseSchema, wireClaimSummarySchema, claimErrorSchema, wireClaimSchema, claimRejectionSchema, claimLostSchema, modelTargetSchema, modelClaimSchema, claimBeginPayloadSchema, claimAbandonPayloadSchema, claimReorderPayloadSchema, claimHeartbeatFieldsSchema, claimHeartbeatPayloadSchema, claimHeartbeatAckPayloadSchema, claimHeartbeatBatchPayloadSchema, claimHeartbeatBatchAckPayloadSchema, updateSubscriptionPayloadSchema, subscriptionAckPayloadSchema, commitOperationTypeSchema, commitOperationSchema, presenceKindSchema, presenceActivitySchema, presenceUpdateFrameSchema;
|
|
1202
|
+
var import_zod3, targetRangeSchema, participantKindSchema, wireParticipantKindSchema, targetRefSchema, onStaleModeSchema, writeGuardSchema, staleNotificationSchema, readDependencySchema, trackDependencySchema, claimStatusSchema, grantStampFields, wireClaimBaseSchema, wireClaimSummarySchema, claimErrorSchema, wireClaimSchema, claimRejectionSchema, claimLostSchema, modelTargetSchema, modelClaimSchema, claimBeginPayloadSchema, claimAbandonPayloadSchema, claimReorderPayloadSchema, claimHeartbeatFieldsSchema, claimHeartbeatPayloadSchema, claimHeartbeatAckPayloadSchema, claimHeartbeatBatchPayloadSchema, claimHeartbeatBatchAckPayloadSchema, updateSubscriptionPayloadSchema, subscriptionAckPayloadSchema, commitOperationTypeSchema, commitOperationSchema, presenceKindSchema, presenceActivitySchema, presenceUpdateFrameSchema;
|
|
1203
1203
|
var init_schema = __esm({
|
|
1204
1204
|
"src/coordination/schema.ts"() {
|
|
1205
1205
|
"use strict";
|
|
@@ -1284,6 +1284,17 @@ var init_schema = __esm({
|
|
|
1284
1284
|
onStale: onStaleModeSchema.optional()
|
|
1285
1285
|
})
|
|
1286
1286
|
]);
|
|
1287
|
+
trackDependencySchema = import_zod3.z.union([
|
|
1288
|
+
import_zod3.z.object({
|
|
1289
|
+
model: import_zod3.z.string(),
|
|
1290
|
+
id: import_zod3.z.string(),
|
|
1291
|
+
readAt: import_zod3.z.number().optional()
|
|
1292
|
+
}),
|
|
1293
|
+
import_zod3.z.object({
|
|
1294
|
+
group: import_zod3.z.string(),
|
|
1295
|
+
readAt: import_zod3.z.number().optional()
|
|
1296
|
+
})
|
|
1297
|
+
]);
|
|
1287
1298
|
claimStatusSchema = import_zod3.z.enum([
|
|
1288
1299
|
"active",
|
|
1289
1300
|
"committed",
|
|
@@ -5431,6 +5442,55 @@ BEGIN
|
|
|
5431
5442
|
END $$;`
|
|
5432
5443
|
];
|
|
5433
5444
|
}
|
|
5445
|
+
function reconcilePublicationPlan(current, desiredTables) {
|
|
5446
|
+
const pub = quoteIdent(ABLO_PUBLICATION);
|
|
5447
|
+
const desiredAll = desiredTables.length === 0;
|
|
5448
|
+
const target = desiredAll ? "FOR ALL TABLES" : `FOR TABLE ${desiredTables.map(quoteIdent).join(", ")}`;
|
|
5449
|
+
if (!current.exists) {
|
|
5450
|
+
return {
|
|
5451
|
+
sql: [`CREATE PUBLICATION ${pub} ${target};`],
|
|
5452
|
+
added: desiredAll ? [] : [...desiredTables],
|
|
5453
|
+
removed: [],
|
|
5454
|
+
recreated: false
|
|
5455
|
+
};
|
|
5456
|
+
}
|
|
5457
|
+
if (current.allTables !== desiredAll) {
|
|
5458
|
+
return {
|
|
5459
|
+
sql: [`DROP PUBLICATION IF EXISTS ${pub};`, `CREATE PUBLICATION ${pub} ${target};`],
|
|
5460
|
+
added: desiredAll ? [] : desiredTables.filter((t) => !current.tables.includes(t)),
|
|
5461
|
+
removed: current.allTables ? [] : current.tables.filter((t) => !desiredTables.includes(t)),
|
|
5462
|
+
recreated: true
|
|
5463
|
+
};
|
|
5464
|
+
}
|
|
5465
|
+
if (desiredAll) {
|
|
5466
|
+
return { sql: [], added: [], removed: [], recreated: false };
|
|
5467
|
+
}
|
|
5468
|
+
const added = desiredTables.filter((t) => !current.tables.includes(t));
|
|
5469
|
+
const removed = current.tables.filter((t) => !desiredTables.includes(t));
|
|
5470
|
+
if (added.length === 0 && removed.length === 0) {
|
|
5471
|
+
return { sql: [], added: [], removed: [], recreated: false };
|
|
5472
|
+
}
|
|
5473
|
+
return {
|
|
5474
|
+
sql: [`ALTER PUBLICATION ${pub} SET TABLE ${desiredTables.map(quoteIdent).join(", ")};`],
|
|
5475
|
+
added,
|
|
5476
|
+
removed,
|
|
5477
|
+
recreated: false
|
|
5478
|
+
};
|
|
5479
|
+
}
|
|
5480
|
+
async function readPublicationState(sql) {
|
|
5481
|
+
const pubRows = await sql.unsafe(
|
|
5482
|
+
`SELECT puballtables FROM pg_publication WHERE pubname = $1`,
|
|
5483
|
+
[ABLO_PUBLICATION]
|
|
5484
|
+
);
|
|
5485
|
+
const pubRow = pubRows[0];
|
|
5486
|
+
if (!pubRow) return { exists: false, allTables: false, tables: [] };
|
|
5487
|
+
if (pubRow.puballtables) return { exists: true, allTables: true, tables: [] };
|
|
5488
|
+
const tableRows = await sql.unsafe(
|
|
5489
|
+
`SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = 'public' ORDER BY tablename`,
|
|
5490
|
+
[ABLO_PUBLICATION]
|
|
5491
|
+
);
|
|
5492
|
+
return { exists: true, allTables: false, tables: tableRows.map((r2) => r2.tablename) };
|
|
5493
|
+
}
|
|
5434
5494
|
async function probeReadiness(sql, opts = {}) {
|
|
5435
5495
|
const publication = opts.publication ?? ABLO_PUBLICATION;
|
|
5436
5496
|
const items = [];
|
|
@@ -5568,7 +5628,7 @@ async function registerDirectDataSource(opts) {
|
|
|
5568
5628
|
)
|
|
5569
5629
|
);
|
|
5570
5630
|
} else if (code === "database_not_replication_ready" || code === "data_source_blocked") {
|
|
5571
|
-
for (const f of body.details?.failures ?? []) {
|
|
5631
|
+
for (const f of body.failures ?? body.details?.failures ?? []) {
|
|
5572
5632
|
console.error(
|
|
5573
5633
|
` ${import_picocolors6.default.red("\u2717")} ${import_picocolors6.default.bold(f.item ?? "item")}${f.actual ? import_picocolors6.default.dim(` (${f.actual})`) : ""}`
|
|
5574
5634
|
);
|
|
@@ -5580,7 +5640,8 @@ async function registerDirectDataSource(opts) {
|
|
|
5580
5640
|
Apply the fixes, verify with ${import_picocolors6.default.bold("ablo connect check")}, then re-run.`)
|
|
5581
5641
|
);
|
|
5582
5642
|
} else if (code === "database_unreachable" || code === "source_unreachable") {
|
|
5583
|
-
|
|
5643
|
+
const reason = body.reason ?? body.details?.reason;
|
|
5644
|
+
if (reason) console.error(import_picocolors6.default.dim(` ${reason}`));
|
|
5584
5645
|
console.error(
|
|
5585
5646
|
import_picocolors6.default.dim(
|
|
5586
5647
|
` Ablo's servers must be able to reach this database \u2014 a localhost or private-network
|
|
@@ -5592,7 +5653,7 @@ async function registerDirectDataSource(opts) {
|
|
|
5592
5653
|
console.error();
|
|
5593
5654
|
return false;
|
|
5594
5655
|
}
|
|
5595
|
-
var import_picocolors6, import_zod6, ABLO_PUBLICATION, ABLO_REPLICATION_ROLE, ABLO_WRITE_ROLE, DIRECT_DATA_SOURCE_ROUTES, DataSourceRegisterSuccess, DataSourceRegisterError;
|
|
5656
|
+
var import_picocolors6, import_zod6, ABLO_PUBLICATION, ABLO_REPLICATION_ROLE, ABLO_WRITE_ROLE, DIRECT_DATA_SOURCE_ROUTES, DataSourceRegisterSuccess, ReadinessFailure, DataSourceRegisterError;
|
|
5596
5657
|
var init_connectSetup = __esm({
|
|
5597
5658
|
"src/cli/connectSetup.ts"() {
|
|
5598
5659
|
"use strict";
|
|
@@ -5614,17 +5675,20 @@ var init_connectSetup = __esm({
|
|
|
5614
5675
|
host: import_zod6.z.string().optional(),
|
|
5615
5676
|
status: import_zod6.z.string().optional()
|
|
5616
5677
|
});
|
|
5678
|
+
ReadinessFailure = import_zod6.z.object({
|
|
5679
|
+
item: import_zod6.z.string().optional(),
|
|
5680
|
+
actual: import_zod6.z.string().optional(),
|
|
5681
|
+
fix: import_zod6.z.string().optional()
|
|
5682
|
+
});
|
|
5617
5683
|
DataSourceRegisterError = import_zod6.z.object({
|
|
5618
5684
|
code: import_zod6.z.string().optional(),
|
|
5619
5685
|
message: import_zod6.z.string().optional(),
|
|
5686
|
+
// Canonical: top-level, spread from the engine's `details`.
|
|
5687
|
+
failures: import_zod6.z.array(ReadinessFailure).optional(),
|
|
5688
|
+
reason: import_zod6.z.string().optional(),
|
|
5689
|
+
// Fallback: a wrapping proxy or older engine that nested the same payload.
|
|
5620
5690
|
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(),
|
|
5691
|
+
failures: import_zod6.z.array(ReadinessFailure).optional(),
|
|
5628
5692
|
reason: import_zod6.z.string().optional()
|
|
5629
5693
|
}).optional(),
|
|
5630
5694
|
error: import_zod6.z.object({ code: import_zod6.z.string().optional(), message: import_zod6.z.string().optional() }).optional()
|
|
@@ -5785,7 +5849,8 @@ __export(connectApply_exports, {
|
|
|
5785
5849
|
logicalReplicationGuidance: () => logicalReplicationGuidance,
|
|
5786
5850
|
passwordClause: () => passwordClause,
|
|
5787
5851
|
postRegistrationOutcome: () => postRegistrationOutcome,
|
|
5788
|
-
runConnectApply: () => runConnectApply
|
|
5852
|
+
runConnectApply: () => runConnectApply,
|
|
5853
|
+
tableOwnershipBlockers: () => tableOwnershipBlockers
|
|
5789
5854
|
});
|
|
5790
5855
|
function postRegistrationOutcome(input) {
|
|
5791
5856
|
if (input.registered) return { exitCode: 0, notice: null };
|
|
@@ -5829,18 +5894,21 @@ function connectApplyPlan(input) {
|
|
|
5829
5894
|
sql: []
|
|
5830
5895
|
}
|
|
5831
5896
|
];
|
|
5897
|
+
const reconcile2 = input.existingPublication ? reconcilePublicationPlan(input.existingPublication, tables) : null;
|
|
5898
|
+
const publicationSql = reconcile2 ? reconcile2.sql : [
|
|
5899
|
+
`DO $$ BEGIN
|
|
5900
|
+
CREATE PUBLICATION ${quoteIdent(ABLO_PUBLICATION)} ${publicationTarget};
|
|
5901
|
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
5902
|
+
END $$;`
|
|
5903
|
+
];
|
|
5904
|
+
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
5905
|
return [
|
|
5833
5906
|
...walStep,
|
|
5834
5907
|
{
|
|
5835
5908
|
key: "publication",
|
|
5836
5909
|
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
|
-
]
|
|
5910
|
+
detail: publicationDetail,
|
|
5911
|
+
sql: publicationSql
|
|
5844
5912
|
},
|
|
5845
5913
|
{
|
|
5846
5914
|
key: "replication-role",
|
|
@@ -5915,6 +5983,27 @@ async function unmanageableLedgerOwner(sql) {
|
|
|
5915
5983
|
);
|
|
5916
5984
|
return ledgerBlockedBy(rows[0]);
|
|
5917
5985
|
}
|
|
5986
|
+
function tableOwnershipBlockers(rows) {
|
|
5987
|
+
return rows.filter((row) => !row.can_manage && !row.is_superuser).map((row) => ({ relation: row.relation, owner: row.owner }));
|
|
5988
|
+
}
|
|
5989
|
+
async function unmanageablePublishedTableOwners(sql, tables) {
|
|
5990
|
+
const scoped = tables.length > 0;
|
|
5991
|
+
const rows = await sql.unsafe(
|
|
5992
|
+
`SELECT format('%I.%I', n.nspname, c.relname) AS relation,
|
|
5993
|
+
pg_get_userbyid(c.relowner) AS owner,
|
|
5994
|
+
pg_has_role(current_user, c.relowner, 'USAGE') AS can_manage,
|
|
5995
|
+
r.rolsuper AS is_superuser
|
|
5996
|
+
FROM pg_class c
|
|
5997
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
5998
|
+
JOIN pg_roles r ON r.rolname = current_user
|
|
5999
|
+
WHERE c.relkind = 'r'
|
|
6000
|
+
AND n.nspname = 'public'
|
|
6001
|
+
AND c.relname <> 'ablo_idempotency'
|
|
6002
|
+
${scoped ? "AND c.relname = ANY($1)" : ""}`,
|
|
6003
|
+
scoped ? [tables] : []
|
|
6004
|
+
);
|
|
6005
|
+
return tableOwnershipBlockers(rows);
|
|
6006
|
+
}
|
|
5918
6007
|
function detectProvider(hostOrTarget) {
|
|
5919
6008
|
const host = hostOrTarget.toLowerCase();
|
|
5920
6009
|
if (host.includes("neon.tech")) return "neon";
|
|
@@ -6040,12 +6129,38 @@ async function runConnectApply(args) {
|
|
|
6040
6129
|
or drop the existing ledger so Ablo recreates it under this admin \u2014 it holds only
|
|
6041
6130
|
idempotency replay records, safe to drop when no commit is in flight:
|
|
6042
6131
|
${import_picocolors8.default.cyan("DROP TABLE ablo_idempotency;")}
|
|
6132
|
+
`
|
|
6133
|
+
);
|
|
6134
|
+
process.exit(1);
|
|
6135
|
+
}
|
|
6136
|
+
const foreignTables = await unmanageablePublishedTableOwners(admin, args.tables).catch(() => []);
|
|
6137
|
+
if (foreignTables.length > 0) {
|
|
6138
|
+
await admin.end({ timeout: 2 });
|
|
6139
|
+
const list = foreignTables.map((t) => `${t.relation} (owned by ${t.owner})`).join("\n ");
|
|
6140
|
+
const alters = foreignTables.map((t) => `ALTER TABLE ${t.relation} OWNER TO ${quoteIdent(capability.rolname)};`).join(" ");
|
|
6141
|
+
const plural = foreignTables.length === 1;
|
|
6142
|
+
console.error(
|
|
6143
|
+
import_picocolors8.default.red(
|
|
6144
|
+
`
|
|
6145
|
+
${import_picocolors8.default.bold(String(foreignTables.length))} published table${plural ? "" : "s"} ${plural ? "is" : "are"} owned by another role, but you connected as ${import_picocolors8.default.bold(capability.rolname)}:`
|
|
6146
|
+
) + `
|
|
6147
|
+
${list}
|
|
6148
|
+
|
|
6149
|
+
Ablo grants the writer role access to your published tables, and Postgres reserves that
|
|
6150
|
+
for the table's owner. Reassign them to your admin \u2014 metadata only, your rows and RLS
|
|
6151
|
+
policies are untouched, and it works when your admin is a member of the owning role:
|
|
6152
|
+
${import_picocolors8.default.cyan(alters)}
|
|
6153
|
+
Or re-run pointing ${import_picocolors8.default.bold("--url")} at the owning role's connection.
|
|
6043
6154
|
`
|
|
6044
6155
|
);
|
|
6045
6156
|
process.exit(1);
|
|
6046
6157
|
}
|
|
6047
6158
|
const provider = detectProvider(target);
|
|
6048
6159
|
const walReady = await currentWalLevel(admin) === "logical";
|
|
6160
|
+
const existingPublication = await readPublicationState(admin).catch(
|
|
6161
|
+
() => ({ exists: false, allTables: false, tables: [] })
|
|
6162
|
+
);
|
|
6163
|
+
const pubReconcile = reconcilePublicationPlan(existingPublication, args.tables);
|
|
6049
6164
|
const replicationPassword = generateRolePassword();
|
|
6050
6165
|
const writePassword = generateRolePassword();
|
|
6051
6166
|
const buildPlan = (mode2) => connectApplyPlan({
|
|
@@ -6057,9 +6172,21 @@ async function runConnectApply(args) {
|
|
|
6057
6172
|
writeClause: passwordClause(writePassword, mode2)
|
|
6058
6173
|
},
|
|
6059
6174
|
walAlreadyLogical: walReady,
|
|
6060
|
-
provider
|
|
6175
|
+
provider,
|
|
6176
|
+
existingPublication
|
|
6061
6177
|
});
|
|
6062
6178
|
const steps = buildPlan("scram-verifier");
|
|
6179
|
+
if (pubReconcile.removed.length > 0 || pubReconcile.recreated) {
|
|
6180
|
+
console.log(
|
|
6181
|
+
` ${import_picocolors8.default.yellow("!")} ${import_picocolors8.default.bold(ABLO_PUBLICATION)} already publishes a different set; reconciling to your ${import_picocolors8.default.bold("--tables")}:`
|
|
6182
|
+
);
|
|
6183
|
+
for (const t of pubReconcile.added) console.log(` ${import_picocolors8.default.green("+")} ${t}`);
|
|
6184
|
+
for (const t of pubReconcile.removed)
|
|
6185
|
+
console.log(` ${import_picocolors8.default.red("-")} ${t} ${import_picocolors8.default.dim("(stops replicating to Ablo)")}`);
|
|
6186
|
+
if (pubReconcile.recreated && existingPublication.allTables)
|
|
6187
|
+
console.log(` ${import_picocolors8.default.red("-")} ${import_picocolors8.default.dim("every other table (was FOR ALL TABLES)")}`);
|
|
6188
|
+
console.log();
|
|
6189
|
+
}
|
|
6063
6190
|
printPlan2(steps, args.showSql);
|
|
6064
6191
|
if (!args.yes) {
|
|
6065
6192
|
if (!process.stdout.isTTY) {
|
package/dist/client/Ablo.js
CHANGED
|
@@ -715,10 +715,10 @@ export function Ablo(options) {
|
|
|
715
715
|
// reconcile errors so read interest never makes a read reject or stall.
|
|
716
716
|
enterScope: (scope) => store.enterScope(scope),
|
|
717
717
|
pinScope: (scope) => store.pinScope(scope),
|
|
718
|
-
// `ablo.<model>.
|
|
718
|
+
// `ablo.<model>.join(ids, { ttl })` performs a scoped participant join
|
|
719
719
|
// on this model's sync group(s). WebSocket only — `join` throws
|
|
720
720
|
// `AbloConnectionError` if the socket isn't ready.
|
|
721
|
-
|
|
721
|
+
createJoin: (modelKey, ids, options) => participantManager.join({
|
|
722
722
|
scope: { [modelKey]: ids },
|
|
723
723
|
...(options?.ttl !== undefined ? { ttlSeconds: options.ttl } : {}),
|
|
724
724
|
}),
|
|
@@ -758,6 +758,7 @@ export function Ablo(options) {
|
|
|
758
758
|
const queue = syncClient.getTransactionQueue();
|
|
759
759
|
await queue.enqueueCommit(clientTxId, operations, {
|
|
760
760
|
...(commitOptions.reads ? { reads: [...commitOptions.reads] } : {}),
|
|
761
|
+
...(commitOptions.track ? { track: [...commitOptions.track] } : {}),
|
|
761
762
|
});
|
|
762
763
|
if (wait === 'queued') {
|
|
763
764
|
return { id: clientTxId, status: 'queued' };
|
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
* `retrieve` and `list`, the synchronous local-graph snapshots `get`, `getAll`,
|
|
7
7
|
* and `getCount`, the writes `create`, `update`, and `delete`, the coordination
|
|
8
8
|
* namespace `claim` (callable as `claim({ id })`, plus `claim.state`,
|
|
9
|
-
* `claim.queue`, `claim.release`, and `claim.reorder`), `
|
|
9
|
+
* `claim.queue`, `claim.release`, and `claim.reorder`), `join`, and `onChange`.
|
|
10
10
|
* The factory returns a plain object; the client assembles the `ablo.<model>`
|
|
11
11
|
* lookup table from one of these per model.
|
|
12
12
|
*/
|
|
13
13
|
import { AbloClaimedError } from '../errors.js';
|
|
14
14
|
import { type ModelUpdater, type ContentionOptions } from './functionalUpdate.js';
|
|
15
15
|
import type { MutationOptions } from '../interfaces/index.js';
|
|
16
|
+
import type { StaleNotification } from '../coordination/schema.js';
|
|
16
17
|
import type { ModelRegistry } from '../ModelRegistry.js';
|
|
17
18
|
import type { InstanceCache } from '../InstanceCache.js';
|
|
18
19
|
import type { SyncClient } from '../SyncClient.js';
|
|
@@ -27,6 +28,27 @@ export interface ModelClientMeta {
|
|
|
27
28
|
}
|
|
28
29
|
export declare function getModelClientMeta(modelClient: unknown): ModelClientMeta | undefined;
|
|
29
30
|
export type ModelListScope = ModelScope | 'live' | 'archived' | 'all';
|
|
31
|
+
/** Options for `track({ id })` — register a durable read-dependency on a row. */
|
|
32
|
+
export interface ModelTrackParams {
|
|
33
|
+
/** The row to keep hearing about, by id. */
|
|
34
|
+
id: string;
|
|
35
|
+
/**
|
|
36
|
+
* The sync watermark this track is premised on. Omit to baseline at the
|
|
37
|
+
* current head — "tell me about anything from here on". Pass a known
|
|
38
|
+
* `lastSyncId` (e.g. the one you read the row at) to also catch a change that
|
|
39
|
+
* already landed between that read and this call.
|
|
40
|
+
*/
|
|
41
|
+
readAt?: number;
|
|
42
|
+
}
|
|
43
|
+
/** The result of `track({ id })`. */
|
|
44
|
+
export interface ModelTrackResult {
|
|
45
|
+
/**
|
|
46
|
+
* Tracks that had ALREADY fired at registration time — a change matching an
|
|
47
|
+
* open track that landed before this call. Present only when something was
|
|
48
|
+
* already stale; the ongoing signal arrives on the receipts of later commits.
|
|
49
|
+
*/
|
|
50
|
+
notifications?: StaleNotification[];
|
|
51
|
+
}
|
|
30
52
|
/** Options for the synchronous local-pool reads `get`, `getAll`, and
|
|
31
53
|
* `onChange` — a JavaScript `filter`, an equality `where`, and a lifecycle
|
|
32
54
|
* `state`. This is the local, reactive axis; contrast {@link ServerReadOptions},
|
|
@@ -172,11 +194,11 @@ export interface ModelCollaboration<T> {
|
|
|
172
194
|
pinScope?(scope: Record<string, string>): void | Promise<void>;
|
|
173
195
|
/**
|
|
174
196
|
* Opens a presence and claim subscription on this model's sync group(s) and
|
|
175
|
-
* returns the live participant handle. Backs `ablo.<model>.
|
|
197
|
+
* returns the live participant handle. Backs `ablo.<model>.join(ids)`.
|
|
176
198
|
* WebSocket only, since presence needs a live socket; it is absent on other
|
|
177
199
|
* client constructions, where the proxy throws a clear error.
|
|
178
200
|
*/
|
|
179
|
-
|
|
201
|
+
createJoin?(modelKey: string, ids: string | readonly string[], options?: JoinOptions): Promise<JoinedParticipant>;
|
|
180
202
|
}
|
|
181
203
|
export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
182
204
|
/** Peer-visible description of the work being performed — the sentence a
|
|
@@ -369,8 +391,8 @@ export interface ModelDeleteParams<T> extends MutationOptions {
|
|
|
369
391
|
readonly id: string;
|
|
370
392
|
readonly claim?: Claim<T> | ClaimTargetOptions<T> | null;
|
|
371
393
|
}
|
|
372
|
-
/** Options for the WebSocket-only `ablo.<model>.
|
|
373
|
-
export interface
|
|
394
|
+
/** Options for the WebSocket-only `ablo.<model>.join(ids, options?)`. */
|
|
395
|
+
export interface JoinOptions {
|
|
374
396
|
/**
|
|
375
397
|
* Lease TTL for the underlying presence claim — the participant
|
|
376
398
|
* auto-releases after this if the holder dies. Compact duration string
|
|
@@ -461,19 +483,40 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
461
483
|
*/
|
|
462
484
|
claim: ClaimApi<T>;
|
|
463
485
|
/**
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
486
|
+
* Register a durable read-dependency on a row of this model — keep hearing
|
|
487
|
+
* about it after this call returns. Where the per-write `reads` gate lives for
|
|
488
|
+
* exactly one commit, a track persists on the server: any change that lands on
|
|
489
|
+
* the tracked row rides back on the `notifications` of your next commit, so a
|
|
490
|
+
* long-running actor learns its context went stale without re-reading. A track
|
|
491
|
+
* you already have is refreshed, not duplicated (it is an idempotent upsert).
|
|
492
|
+
*
|
|
493
|
+
* ```ts
|
|
494
|
+
* await ablo.tasks.track({ id: 'task_42' });
|
|
495
|
+
* // …minutes of other work later, on your next write…
|
|
496
|
+
* const res = await ablo.tasks.update({ id: 'task_42', data: { done: true } });
|
|
497
|
+
* res.notifications; // populated if task_42 changed under you in the meantime
|
|
498
|
+
* ```
|
|
499
|
+
*
|
|
500
|
+
* The returned `notifications` are only the tracks that had ALREADY fired at
|
|
501
|
+
* registration time; the ongoing signal arrives on later receipts.
|
|
502
|
+
*/
|
|
503
|
+
track(params: ModelTrackParams): Promise<ModelTrackResult>;
|
|
504
|
+
/**
|
|
505
|
+
* Joins the sync group(s) for one or more rows of this model and returns a
|
|
506
|
+
* live participant handle — presence (`.peers`), the scoped claim stream
|
|
507
|
+
* (`.claims`), and `.leave()` / `await using` disposal. This is a presence
|
|
508
|
+
* subscription: it reports who else is here and what they hold, not row
|
|
509
|
+
* values changing — for the latter, use `onChange`.
|
|
467
510
|
*
|
|
468
511
|
* WebSocket only: presence needs a live socket, so this is absent on HTTP
|
|
469
512
|
* clients and throws on any non-WebSocket construction.
|
|
470
513
|
*
|
|
471
514
|
* ```ts
|
|
472
|
-
* await using participant = await ablo.slides.
|
|
515
|
+
* await using participant = await ablo.slides.join(slideIds, { ttl: '5m' });
|
|
473
516
|
* participant.peers; // who else is here
|
|
474
517
|
* ```
|
|
475
518
|
*/
|
|
476
|
-
|
|
519
|
+
join(ids: string | readonly string[], options?: JoinOptions): Promise<JoinedParticipant>;
|
|
477
520
|
/** Subscribe to changes; the callback runs on every change. */
|
|
478
521
|
onChange(callback: (entities: T[]) => void, options?: LocalReadOptions<T>): () => void;
|
|
479
522
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `retrieve` and `list`, the synchronous local-graph snapshots `get`, `getAll`,
|
|
7
7
|
* and `getCount`, the writes `create`, `update`, and `delete`, the coordination
|
|
8
8
|
* namespace `claim` (callable as `claim({ id })`, plus `claim.state`,
|
|
9
|
-
* `claim.queue`, `claim.release`, and `claim.reorder`), `
|
|
9
|
+
* `claim.queue`, `claim.release`, and `claim.reorder`), `join`, and `onChange`.
|
|
10
10
|
* The factory returns a plain object; the client assembles the `ablo.<model>`
|
|
11
11
|
* lookup table from one of these per model.
|
|
12
12
|
*/
|
|
@@ -750,11 +750,30 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
750
750
|
// `claim` is a callable namespace (take a claim) carrying the coordination
|
|
751
751
|
// readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
|
|
752
752
|
claim: claimApi,
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
753
|
+
track: guard(async (params) => {
|
|
754
|
+
const dep = {
|
|
755
|
+
model: wireModel,
|
|
756
|
+
id: params.id,
|
|
757
|
+
...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
|
|
758
|
+
};
|
|
759
|
+
// A track carries no write, so it rides the commit lane as a zero-operation
|
|
760
|
+
// commit: the queue tolerates disconnects and de-dupes replays, and the
|
|
761
|
+
// server's track-only path registers the dependency and reports anything
|
|
762
|
+
// that already fired. Reuse the same lane the batch `commits.create` door
|
|
763
|
+
// uses rather than opening a bespoke transport.
|
|
764
|
+
const clientTxId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
765
|
+
? crypto.randomUUID()
|
|
766
|
+
: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
767
|
+
const queue = syncClient.getTransactionQueue();
|
|
768
|
+
await queue.enqueueCommit(clientTxId, [], { track: [dep] });
|
|
769
|
+
const { notifications } = await queue.waitForCommitReceipt(clientTxId);
|
|
770
|
+
return notifications && notifications.length > 0 ? { notifications } : {};
|
|
771
|
+
}),
|
|
772
|
+
join: guard((ids, options) => {
|
|
773
|
+
if (!collaboration?.createJoin) {
|
|
774
|
+
throw new AbloValidationError(`Model "${schemaKey}" was built without a WebSocket runtime, so join() is unavailable here. Presence needs a live socket — use the standard Ablo({ schema, apiKey }) client (not the HTTP transport).`, { code: 'model_join_not_configured' });
|
|
756
775
|
}
|
|
757
|
-
return collaboration.
|
|
776
|
+
return collaboration.createJoin(schemaKey, ids, options);
|
|
758
777
|
}),
|
|
759
778
|
onChange(callback, options) {
|
|
760
779
|
return autorun(() => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* {@link HttpClaimApi} derivation. This module holds only types and has no runtime
|
|
5
5
|
* imports.
|
|
6
6
|
*/
|
|
7
|
-
import type { ReadDependency } from '../coordination/schema.js';
|
|
7
|
+
import type { ReadDependency, TrackDependency } from '../coordination/schema.js';
|
|
8
8
|
import type { ClientCommitReceipt, CommitStatus } from '../wire/commit.js';
|
|
9
9
|
import type { ModelTarget, ModelClaim } from '../coordination/schema.js';
|
|
10
10
|
export type { ModelTarget, ModelClaim };
|
|
@@ -96,6 +96,15 @@ export interface CommitCreateOptions {
|
|
|
96
96
|
* you read, not what you write.
|
|
97
97
|
*/
|
|
98
98
|
readonly reads?: readonly ReadDependency[] | null;
|
|
99
|
+
/**
|
|
100
|
+
* Durable read-dependencies to register as part of this batch — the persisted
|
|
101
|
+
* sibling of `reads`. Where `reads` guards only this commit, a `track` entry
|
|
102
|
+
* (`{ model, id, readAt? }` for a row or `{ group, readAt? }` for a sync group)
|
|
103
|
+
* lives on past it: a later matching change rides back on a future receipt's
|
|
104
|
+
* `notifications`. A track-only batch (just `track`, an empty `operations`) is
|
|
105
|
+
* the batch form of `ablo.<model>.track()`.
|
|
106
|
+
*/
|
|
107
|
+
readonly track?: readonly TrackDependency[] | null;
|
|
99
108
|
}
|
|
100
109
|
/** Public projection inferred from the canonical runtime schema. */
|
|
101
110
|
export type CommitReceipt = ClientCommitReceipt;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* commit time because it does not exist yet when the executor is created. A
|
|
5
5
|
* client wires this up automatically unless you supply your own executor.
|
|
6
6
|
*/
|
|
7
|
-
import type { ReadDependency } from '../coordination/schema.js';
|
|
7
|
+
import type { ReadDependency, TrackDependency } from '../coordination/schema.js';
|
|
8
8
|
import type { MutationExecutor, MutationOperation } from '../interfaces/index.js';
|
|
9
9
|
import type { CommitAck } from '../sync/commitFrames.js';
|
|
10
10
|
/**
|
|
@@ -23,5 +23,5 @@ import type { CommitAck } from '../sync/commitFrames.js';
|
|
|
23
23
|
* generation below exists only for direct, one-shot executor consumers.
|
|
24
24
|
*/
|
|
25
25
|
export declare function createDefaultMutationExecutor(getWs: () => {
|
|
26
|
-
sendCommit?: (operations: readonly MutationOperation[], clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null) => Promise<CommitAck>;
|
|
26
|
+
sendCommit?: (operations: readonly MutationOperation[], clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null, track?: readonly TrackDependency[] | null) => Promise<CommitAck>;
|
|
27
27
|
} | null): MutationExecutor;
|
|
@@ -34,7 +34,7 @@ export function createDefaultMutationExecutor(getWs) {
|
|
|
34
34
|
: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
|
|
35
35
|
try {
|
|
36
36
|
return await ws.sendCommit(operations, clientTxId, undefined, // use sendCommit's built-in 15s default; no per-call override
|
|
37
|
-
options?.causedByTaskId, options?.reads);
|
|
37
|
+
options?.causedByTaskId, options?.reads, options?.track);
|
|
38
38
|
}
|
|
39
39
|
catch (err) {
|
|
40
40
|
// Wrap transport-level failures as connection errors so the transaction
|