@abloatai/ablo 0.9.5 → 0.9.6
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 +6 -0
- package/README.md +3 -3
- package/dist/cli.cjs +421 -376
- package/dist/client/Ablo.d.ts +9 -0
- package/dist/client/Ablo.js +6 -4
- package/dist/client/createModelProxy.d.ts +8 -0
- package/dist/client/createModelProxy.js +31 -11
- package/dist/errorCodes.js +15 -6
- package/dist/sync/SyncWebSocket.d.ts +13 -0
- package/dist/sync/SyncWebSocket.js +28 -4
- package/dist/sync/awaitIntentGrant.d.ts +15 -1
- package/dist/sync/awaitIntentGrant.js +9 -7
- package/dist/transactions/TransactionQueue.js +39 -12
- package/docs/quickstart.md +29 -17
- package/llms-full.txt +1 -1
- package/llms.txt +3 -3
- package/package.json +1 -1
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -442,6 +442,15 @@ export interface IntentCreateOptions {
|
|
|
442
442
|
}
|
|
443
443
|
export interface IntentHandle extends AsyncDisposable {
|
|
444
444
|
readonly id: string;
|
|
445
|
+
/**
|
|
446
|
+
* True when the lease was granted AFTER waiting in the server's FIFO
|
|
447
|
+
* queue behind a holder (`intent_granted`), false/absent for an
|
|
448
|
+
* immediate grant (`intent_acquired`). Consumers re-read the target
|
|
449
|
+
* row when this is set — the row may have changed while we queued, and
|
|
450
|
+
* the local coordination snapshot can't detect that for org-scoped
|
|
451
|
+
* subscriptions (intent fan-out is entity-scoped).
|
|
452
|
+
*/
|
|
453
|
+
readonly waited?: boolean;
|
|
445
454
|
release(): Promise<void>;
|
|
446
455
|
revoke(): void;
|
|
447
456
|
}
|
package/dist/client/Ablo.js
CHANGED
|
@@ -1238,12 +1238,13 @@ export function Ablo(options) {
|
|
|
1238
1238
|
}
|
|
1239
1239
|
await waitForModelUnclaimed(target, { timeout: options?.claimedTimeout });
|
|
1240
1240
|
}
|
|
1241
|
-
function wrapIntentHandle(claim) {
|
|
1241
|
+
function wrapIntentHandle(claim, waited = false) {
|
|
1242
1242
|
const release = async () => {
|
|
1243
1243
|
claim.revoke();
|
|
1244
1244
|
};
|
|
1245
1245
|
return {
|
|
1246
1246
|
id: claim.id,
|
|
1247
|
+
waited,
|
|
1247
1248
|
release,
|
|
1248
1249
|
revoke: claim.revoke,
|
|
1249
1250
|
[Symbol.asyncDispose]: release,
|
|
@@ -1269,14 +1270,15 @@ export function Ablo(options) {
|
|
|
1269
1270
|
// we reach the head of the FIFO line). Block here on that grant so
|
|
1270
1271
|
// callers — chiefly `ablo.<model>.claim` — get a handle that already
|
|
1271
1272
|
// holds the lease, never a half-claimed one racing the queue.
|
|
1273
|
+
let waited = false;
|
|
1272
1274
|
if (intentOptions.queue) {
|
|
1273
1275
|
const ws = store.getSyncWebSocket();
|
|
1274
1276
|
if (ws) {
|
|
1275
1277
|
try {
|
|
1276
|
-
await awaitIntentGrant(ws, claim.id, {
|
|
1278
|
+
({ waited } = await awaitIntentGrant(ws, claim.id, {
|
|
1277
1279
|
timeoutMs: intentOptions.waitTimeoutMs,
|
|
1278
1280
|
maxQueueDepth: intentOptions.maxQueueDepth,
|
|
1279
|
-
});
|
|
1281
|
+
}));
|
|
1280
1282
|
}
|
|
1281
1283
|
catch (err) {
|
|
1282
1284
|
// Gave up waiting (queue too deep, timed out, or lost) — abandon
|
|
@@ -1287,7 +1289,7 @@ export function Ablo(options) {
|
|
|
1287
1289
|
}
|
|
1288
1290
|
}
|
|
1289
1291
|
}
|
|
1290
|
-
return wrapIntentHandle(claim);
|
|
1292
|
+
return wrapIntentHandle(claim, waited);
|
|
1291
1293
|
},
|
|
1292
1294
|
list(target) {
|
|
1293
1295
|
return listModelClaims(target);
|
|
@@ -75,6 +75,14 @@ export interface ModelLoadOptions<T> {
|
|
|
75
75
|
export type ModelRetrieveOptions = Pick<ModelLoadOptions<unknown>, 'type' | 'expand'>;
|
|
76
76
|
export interface IntentLeaseHandle {
|
|
77
77
|
readonly id: string;
|
|
78
|
+
/**
|
|
79
|
+
* True when the grant came AFTER waiting in the server's FIFO line
|
|
80
|
+
* (`intent_granted`) — the authoritative "the row may have changed
|
|
81
|
+
* underneath us" signal. The local `observe()` snapshot can't stand in
|
|
82
|
+
* for this: intent fan-out is entity-scoped, so org-wide subscriptions
|
|
83
|
+
* (the default hosted client) never see peers' claims at all.
|
|
84
|
+
*/
|
|
85
|
+
readonly waited?: boolean;
|
|
78
86
|
release(): Promise<void>;
|
|
79
87
|
revoke(): void;
|
|
80
88
|
}
|
|
@@ -31,6 +31,16 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
31
31
|
throw new AbloValidationError(`Ablo: schema model "${schemaKey}" resolved to "${registeredModelName}", ` +
|
|
32
32
|
'but no matching constructor was registered.', { code: 'model_not_registered' });
|
|
33
33
|
}
|
|
34
|
+
// The coordination plane (claims/intents) must speak the SAME wire dialect
|
|
35
|
+
// as the commit plane: the lowercased TYPENAME (`task`), not the schema key
|
|
36
|
+
// (`tasks`). The server's commit-time intent guard probes the lease store
|
|
37
|
+
// with the commit op's model name; a lease recorded under the schema key
|
|
38
|
+
// never collides with it — which silently disarmed the guard for every
|
|
39
|
+
// model whose schema key differs from its typename (i.e. nearly all of
|
|
40
|
+
// them, plural key vs singular typename). Public surfaces (ClaimHandle.
|
|
41
|
+
// target.model) keep the schema key; only the wire/coordination targets
|
|
42
|
+
// use this.
|
|
43
|
+
const wireModel = registeredModelName.toLowerCase();
|
|
34
44
|
// Last-line guarantee for the public surface: any rejection from a lower
|
|
35
45
|
// layer (transport timeout, IndexedDB failure, a third-party throw) is
|
|
36
46
|
// coerced to an AbloError before it reaches the consumer. The SDK's
|
|
@@ -98,7 +108,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
98
108
|
// Is someone ELSE already on this target? Read the local coordination
|
|
99
109
|
// snapshot up front — it decides whether we'll need to re-read after the
|
|
100
110
|
// claim (a free / already-mine target can't have changed under us).
|
|
101
|
-
const held = collaboration.observe({ model:
|
|
111
|
+
const held = collaboration.observe({ model: wireModel, id });
|
|
102
112
|
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
103
113
|
const failFast = options?.wait === false;
|
|
104
114
|
// Fail-fast (`wait: false`): if another participant already holds it,
|
|
@@ -113,7 +123,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
113
123
|
// Ensure the row exists locally before claiming.
|
|
114
124
|
let model = objectPool.get(id);
|
|
115
125
|
if (!model) {
|
|
116
|
-
await load({ where:
|
|
126
|
+
await load({ where: [['id', id]] });
|
|
117
127
|
model = objectPool.get(id);
|
|
118
128
|
}
|
|
119
129
|
if (!model) {
|
|
@@ -126,7 +136,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
126
136
|
// observed conflict above, so this just records our lease.
|
|
127
137
|
const lease = await collaboration.createIntent({
|
|
128
138
|
target: {
|
|
129
|
-
model:
|
|
139
|
+
model: wireModel,
|
|
130
140
|
id,
|
|
131
141
|
...(options?.field ? { field: options.field } : {}),
|
|
132
142
|
...(options?.path ? { path: options.path } : {}),
|
|
@@ -140,9 +150,19 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
140
150
|
});
|
|
141
151
|
// Only when we actually waited behind another holder can the row have
|
|
142
152
|
// changed underneath us — re-read so the claimed snapshot reflects what
|
|
143
|
-
// they committed before releasing.
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
// they committed before releasing. Two signals, either suffices:
|
|
154
|
+
// - `lease.waited` — the server granted via `intent_granted`, i.e. we
|
|
155
|
+
// provably queued behind a holder. Authoritative; works even when
|
|
156
|
+
// the local snapshot is blind (intent fan-out is entity-scoped, so
|
|
157
|
+
// org-wide-subscribed clients never observe peers' claims).
|
|
158
|
+
// - `contended` — the local snapshot saw a holder up front. Kept for
|
|
159
|
+
// the no-queue paths where no grant frame exists.
|
|
160
|
+
if ((contended || lease.waited === true) && !failFast) {
|
|
161
|
+
// `type: 'complete'` forces the round-trip: the hydration ledger
|
|
162
|
+
// otherwise serves the LOCAL row for an already-hydrated id, and the
|
|
163
|
+
// holder's final write may not have fanned out to us yet — the exact
|
|
164
|
+
// stale-snapshot race this re-read exists to close.
|
|
165
|
+
await load({ where: [['id', id]], type: 'complete' });
|
|
146
166
|
model = objectPool.get(id) ?? model;
|
|
147
167
|
}
|
|
148
168
|
const snapshot = collaboration.createSnapshot(schemaKey, id);
|
|
@@ -178,16 +198,16 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
178
198
|
// are the same object.
|
|
179
199
|
const claimApi = Object.assign(guard(claim), {
|
|
180
200
|
state(params) {
|
|
181
|
-
return collaboration?.observe({ model:
|
|
201
|
+
return collaboration?.observe({ model: wireModel, id: params.id }) ?? null;
|
|
182
202
|
},
|
|
183
203
|
queue(params) {
|
|
184
204
|
return {
|
|
185
205
|
object: 'list',
|
|
186
|
-
data: collaboration?.queue({ model:
|
|
206
|
+
data: collaboration?.queue({ model: wireModel, id: params.id }) ?? [],
|
|
187
207
|
};
|
|
188
208
|
},
|
|
189
209
|
reorder(params) {
|
|
190
|
-
collaboration?.reorder({ model:
|
|
210
|
+
collaboration?.reorder({ model: wireModel, id: params.id }, params.order);
|
|
191
211
|
},
|
|
192
212
|
release: guard((params) => releaseClaim(isClaimHandle(params) ? params.target.id : params.id)),
|
|
193
213
|
});
|
|
@@ -195,7 +215,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
195
215
|
retrieve: guard(async (params) => {
|
|
196
216
|
const rows = await load({
|
|
197
217
|
...params,
|
|
198
|
-
where:
|
|
218
|
+
where: [['id', params.id]],
|
|
199
219
|
limit: 1,
|
|
200
220
|
});
|
|
201
221
|
return rows[0];
|
|
@@ -251,7 +271,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
251
271
|
}
|
|
252
272
|
autoLease = await collaboration.createIntent({
|
|
253
273
|
target: {
|
|
254
|
-
model:
|
|
274
|
+
model: wireModel,
|
|
255
275
|
id,
|
|
256
276
|
...(claim.field ? { field: claim.field } : {}),
|
|
257
277
|
...(claim.path ? { path: claim.path } : {}),
|
package/dist/errorCodes.js
CHANGED
|
@@ -142,13 +142,22 @@ export const ERROR_CODES = {
|
|
|
142
142
|
byo_tenant_tables_unforced_rls: wire('permission', 403, false, 'Tenant tables do not have RLS forced under the direct Postgres connector role.'),
|
|
143
143
|
byo_host_not_allowed: wire('permission', 403, false, 'The direct Postgres connector host resolves to a private, loopback, or link-local address and cannot be used.'),
|
|
144
144
|
// ── claim / intent conflict (409) ──────────────────────────────────
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
// Held-claim rejections are NOT queue-retryable (gRPC FAILED_PRECONDITION /
|
|
146
|
+
// ABORTED semantics; Replicache/Zero SETTLE a rejected mutation — reject the
|
|
147
|
+
// caller, roll back the optimistic effect — instead of resending it).
|
|
148
|
+
// Blindly re-sending the same payload cannot succeed while the lease is
|
|
149
|
+
// held, and a lease can outlive any sane retry budget. The correct recovery
|
|
150
|
+
// lives at the CALLER: take a claim (`ablo.<model>.claim` queues fairly
|
|
151
|
+
// behind the holder) or re-read and rebase. `retryable: true` here turned
|
|
152
|
+
// every cross-client claim conflict into an infinite client resend loop
|
|
153
|
+
// (~150ms storm — found by the claims journey, 2026-06-10).
|
|
154
|
+
claim_conflict: wire('claim', 409, false, 'The target entity is claimed by another participant.'),
|
|
155
|
+
claim_lost: wire('claim', 409, false, 'A previously held claim was lost before the write applied.'),
|
|
156
|
+
entity_claimed: wire('claim', 409, false, 'The target entity is currently claimed; write was blocked.'),
|
|
157
|
+
intent_conflict: wire('claim', 409, false, 'An intent on the target conflicts with an active intent (server-internal alias of claim_conflict).'),
|
|
149
158
|
malformed_claim: wire('claim', 400, false, 'The claim payload was malformed.'),
|
|
150
|
-
model_claimed: wire('claim', 409,
|
|
151
|
-
model_claimed_timeout: wire('claim', 409,
|
|
159
|
+
model_claimed: wire('claim', 409, false, 'The model instance is claimed by another participant.'),
|
|
160
|
+
model_claimed_timeout: wire('claim', 409, false, 'Timed out waiting for a model claim to clear.'),
|
|
152
161
|
model_claim_not_configured: client('claim', 'Claiming was requested on a model that has no claim configuration.'),
|
|
153
162
|
// ── stale context / idempotency (409) ──────────────────────────────
|
|
154
163
|
stale_context: wire('conflict', 409, true, 'The write carried a readAt watermark that is now stale; re-read and retry.'),
|
|
@@ -385,6 +385,19 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
385
385
|
* Setup WebSocket event handlers
|
|
386
386
|
*/
|
|
387
387
|
private setupEventHandlers;
|
|
388
|
+
/**
|
|
389
|
+
* Normalize a wire delta at the receive boundary. The contract
|
|
390
|
+
* (`syncDeltaWireCoreSchema`) says `id: number`, but deployed servers
|
|
391
|
+
* have sent the raw Postgres BIGINT serialization — a STRING — and
|
|
392
|
+
* every downstream watermark gate (`typeof syncId === 'number'` in
|
|
393
|
+
* `Database.processDeltaBatch`, the metadata-cursor update, numeric
|
|
394
|
+
* `>=` threshold comparisons in TransactionQueue) silently breaks on
|
|
395
|
+
* strings: acks are withheld, the resume cursor never advances, and
|
|
396
|
+
* every reconnect replays from 0 (or force-bootstraps once the gap
|
|
397
|
+
* exceeds maxDeltaGapForPartial). Coerce ONCE here so the rest of the
|
|
398
|
+
* client can trust the declared type — and old servers stay compatible.
|
|
399
|
+
*/
|
|
400
|
+
private normalizeWireDelta;
|
|
388
401
|
/**
|
|
389
402
|
* Handle incoming sync delta
|
|
390
403
|
*/
|
|
@@ -318,8 +318,11 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
318
318
|
clearTimeout(pending.timeout);
|
|
319
319
|
this.pendingMutations.delete(clientTxId);
|
|
320
320
|
if (success) {
|
|
321
|
+
// Coerce defensively — bigint columns serialize as strings
|
|
322
|
+
// from older servers (see normalizeWireDelta).
|
|
323
|
+
const ackedSyncId = Number(lastSyncId);
|
|
321
324
|
pending.resolve({
|
|
322
|
-
lastSyncId:
|
|
325
|
+
lastSyncId: Number.isFinite(ackedSyncId) ? ackedSyncId : 0,
|
|
323
326
|
});
|
|
324
327
|
}
|
|
325
328
|
else {
|
|
@@ -631,10 +634,29 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
631
634
|
}
|
|
632
635
|
};
|
|
633
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* Normalize a wire delta at the receive boundary. The contract
|
|
639
|
+
* (`syncDeltaWireCoreSchema`) says `id: number`, but deployed servers
|
|
640
|
+
* have sent the raw Postgres BIGINT serialization — a STRING — and
|
|
641
|
+
* every downstream watermark gate (`typeof syncId === 'number'` in
|
|
642
|
+
* `Database.processDeltaBatch`, the metadata-cursor update, numeric
|
|
643
|
+
* `>=` threshold comparisons in TransactionQueue) silently breaks on
|
|
644
|
+
* strings: acks are withheld, the resume cursor never advances, and
|
|
645
|
+
* every reconnect replays from 0 (or force-bootstraps once the gap
|
|
646
|
+
* exceeds maxDeltaGapForPartial). Coerce ONCE here so the rest of the
|
|
647
|
+
* client can trust the declared type — and old servers stay compatible.
|
|
648
|
+
*/
|
|
649
|
+
normalizeWireDelta(delta) {
|
|
650
|
+
if (typeof delta.id === 'number')
|
|
651
|
+
return delta;
|
|
652
|
+
const coerced = Number(delta.id);
|
|
653
|
+
return { ...delta, id: Number.isFinite(coerced) ? coerced : 0 };
|
|
654
|
+
}
|
|
634
655
|
/**
|
|
635
656
|
* Handle incoming sync delta
|
|
636
657
|
*/
|
|
637
|
-
handleDelta(
|
|
658
|
+
handleDelta(rawDelta) {
|
|
659
|
+
const delta = this.normalizeWireDelta(rawDelta);
|
|
638
660
|
getContext().logger.debug('Received delta', {
|
|
639
661
|
action: delta.actionType,
|
|
640
662
|
model: delta.modelName,
|
|
@@ -1342,8 +1364,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1342
1364
|
}
|
|
1343
1365
|
// Process incremental deltas
|
|
1344
1366
|
if (payload.deltas && Array.isArray(payload.deltas)) {
|
|
1345
|
-
// Process all deltas from sync response - store handles idempotency
|
|
1346
|
-
|
|
1367
|
+
// Process all deltas from sync response - store handles idempotency.
|
|
1368
|
+
// Same receive-boundary normalization as handleDelta — catch-up
|
|
1369
|
+
// replays from older servers carry string ids too.
|
|
1370
|
+
const newDeltas = payload.deltas.map((d) => this.normalizeWireDelta(d));
|
|
1347
1371
|
if (newDeltas.length > 0) {
|
|
1348
1372
|
// DO NOT pre-advance `this.lastSyncId` here. Same reasoning as
|
|
1349
1373
|
// `handleDelta`: the runtime cursor must stay consistent with
|
|
@@ -15,6 +15,20 @@
|
|
|
15
15
|
export interface GrantTransport {
|
|
16
16
|
subscribe(event: 'intent_acquired' | 'intent_granted' | 'intent_lost' | 'intent_queued', handler: (payload: Record<string, unknown>) => void): () => void;
|
|
17
17
|
}
|
|
18
|
+
export interface IntentGrantInfo {
|
|
19
|
+
/**
|
|
20
|
+
* True when the grant arrived as `intent_granted` — i.e. the target was
|
|
21
|
+
* HELD when we asked and we waited in the FIFO line behind the holder.
|
|
22
|
+
* False for the immediate `intent_acquired` (target was free).
|
|
23
|
+
*
|
|
24
|
+
* Callers use this to know the row may have changed while we queued:
|
|
25
|
+
* intent VISIBILITY is entity-scoped (org-wide subscriptions receive no
|
|
26
|
+
* presence/intent fan-out — see Hub.broadcastPresenceChange), so the
|
|
27
|
+
* local coordination snapshot cannot be trusted to detect "we waited".
|
|
28
|
+
* The grant frame itself is the authoritative signal.
|
|
29
|
+
*/
|
|
30
|
+
readonly waited: boolean;
|
|
31
|
+
}
|
|
18
32
|
export declare function awaitIntentGrant(transport: GrantTransport, intentId: string, options?: {
|
|
19
33
|
timeoutMs?: number;
|
|
20
34
|
/**
|
|
@@ -23,4 +37,4 @@ export declare function awaitIntentGrant(transport: GrantTransport, intentId: st
|
|
|
23
37
|
* already ahead of us). Omit to wait however deep the queue is.
|
|
24
38
|
*/
|
|
25
39
|
maxQueueDepth?: number;
|
|
26
|
-
}): Promise<
|
|
40
|
+
}): Promise<IntentGrantInfo>;
|
|
@@ -24,15 +24,17 @@ export function awaitIntentGrant(transport, intentId, options) {
|
|
|
24
24
|
u();
|
|
25
25
|
fn();
|
|
26
26
|
};
|
|
27
|
-
const onGrant = (p) => {
|
|
28
|
-
if (p?.intentId === intentId)
|
|
29
|
-
settle(resolve);
|
|
30
|
-
};
|
|
31
27
|
// The target was free → `intent_acquired` (immediate); it was contended,
|
|
32
28
|
// we waited in line, and reached the head → `intent_granted`. Either frame
|
|
33
|
-
// means the lease is now ours
|
|
34
|
-
unsubs.push(transport.subscribe('intent_acquired',
|
|
35
|
-
|
|
29
|
+
// means the lease is now ours; `waited` records which path it was.
|
|
30
|
+
unsubs.push(transport.subscribe('intent_acquired', (p) => {
|
|
31
|
+
if (p?.intentId === intentId)
|
|
32
|
+
settle(() => resolve({ waited: false }));
|
|
33
|
+
}));
|
|
34
|
+
unsubs.push(transport.subscribe('intent_granted', (p) => {
|
|
35
|
+
if (p?.intentId === intentId)
|
|
36
|
+
settle(() => resolve({ waited: true }));
|
|
37
|
+
}));
|
|
36
38
|
if (options?.maxQueueDepth !== undefined) {
|
|
37
39
|
const max = options.maxQueueDepth;
|
|
38
40
|
unsubs.push(transport.subscribe('intent_queued', (p) => {
|
|
@@ -852,7 +852,17 @@ export class TransactionQueue extends EventEmitter {
|
|
|
852
852
|
// LINEAR PATTERN: Simplified coalescing for updates
|
|
853
853
|
// Staging already batches all transactions in same event loop tick
|
|
854
854
|
// We only need to handle: (1) in-flight merging, (2) same-entity merging
|
|
855
|
-
|
|
855
|
+
//
|
|
856
|
+
// RETRIES (attempts > 0) must NEVER take either merge path. A retry is
|
|
857
|
+
// re-enqueued from `handleFailure` while its own modelKey is still in
|
|
858
|
+
// `inFlightByModel` (the post-batch cleanup runs after the catch), so the
|
|
859
|
+
// in-flight merge mistook the retry for a concurrent user edit: it moved
|
|
860
|
+
// the data into `pendingMergeByModel`, REMOVED the transaction, and the
|
|
861
|
+
// post-batch block minted a fresh follow-up with `attempts: 0`. The
|
|
862
|
+
// attempt counter never accumulated and `maxRetries` never tripped — an
|
|
863
|
+
// infinite ~`batchDelay` resend storm of self-laundering transaction
|
|
864
|
+
// clones (found by the claims journey, 2026-06-10).
|
|
865
|
+
if (transaction.type === 'update' && transaction.attempts === 0) {
|
|
856
866
|
const preserveWatermark = hasStaleWriteOptions(transaction.writeOptions);
|
|
857
867
|
// If there is an in-flight update for this model, merge into post-flight buffer
|
|
858
868
|
if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
|
|
@@ -1606,25 +1616,40 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1606
1616
|
await this.rollbackOptimistic(transaction, 'permanent_error', error);
|
|
1607
1617
|
}
|
|
1608
1618
|
this.emit('transaction:failed', { transaction, error, permanent: true });
|
|
1619
|
+
// The id-suffixed event is what `waitForConfirmation` (the
|
|
1620
|
+
// `wait:'confirmed'` path) listens on — without it a permanently
|
|
1621
|
+
// rejected write left the caller's promise hanging forever.
|
|
1622
|
+
this.emit(`transaction:failed:${transaction.id}`, { error });
|
|
1609
1623
|
return;
|
|
1610
1624
|
}
|
|
1611
1625
|
if (transaction.attempts < this.config.maxRetries) {
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
1614
|
-
//
|
|
1626
|
+
// Exponential backoff with FULL jitter for EVERY transient retry
|
|
1627
|
+
// (AWS-standard shape: `sleep = random(0, min(cap, base × 2^attempt))`
|
|
1628
|
+
// — see the Exponential Backoff And Jitter analysis). Throttling
|
|
1629
|
+
// signals (429/503) get a longer base, mirroring the AWS SDK's
|
|
1630
|
+
// transient-vs-throttle split. Previously only 429/503 backed off
|
|
1631
|
+
// AND the sleep blocked the whole batch loop — every other failure
|
|
1632
|
+
// retried at raw `batchDelay` cadence. The re-enqueue is scheduled
|
|
1633
|
+
// (never awaited) so one backing-off transaction can't stall
|
|
1634
|
+
// unrelated commits.
|
|
1635
|
+
const { baseMs, capMs } = this.config.retryBackoff;
|
|
1636
|
+
let base = baseMs;
|
|
1615
1637
|
try {
|
|
1616
1638
|
const status = extractStatusCode(error);
|
|
1617
|
-
if (status === 429 || status === 503)
|
|
1618
|
-
|
|
1619
|
-
const delay = Math.min(capMs, Math.floor(baseMs * Math.pow(2, transaction.attempts - 1)));
|
|
1620
|
-
const jitter = Math.floor(Math.random() * 100);
|
|
1621
|
-
await new Promise((r) => setTimeout(r, delay + jitter));
|
|
1622
|
-
}
|
|
1639
|
+
if (status === 429 || status === 503)
|
|
1640
|
+
base = Math.max(baseMs, 1_000);
|
|
1623
1641
|
}
|
|
1624
1642
|
catch { }
|
|
1625
|
-
|
|
1643
|
+
const ceiling = Math.min(capMs, base * Math.pow(2, transaction.attempts - 1));
|
|
1644
|
+
const delay = Math.floor(Math.random() * ceiling);
|
|
1626
1645
|
this.store.updateStatus(transaction.id, 'pending');
|
|
1627
|
-
|
|
1646
|
+
setTimeout(() => {
|
|
1647
|
+
// The queue may have shut down or the tx may have been settled
|
|
1648
|
+
// (e.g. delta-confirmed) while we backed off.
|
|
1649
|
+
if (this.store.get(transaction.id)?.status !== 'pending')
|
|
1650
|
+
return;
|
|
1651
|
+
this.enqueue(transaction);
|
|
1652
|
+
}, delay);
|
|
1628
1653
|
}
|
|
1629
1654
|
else {
|
|
1630
1655
|
// Mark as failed and rollback
|
|
@@ -1633,6 +1658,8 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1633
1658
|
await this.rollbackOptimistic(transaction, 'max_retries_exhausted', error);
|
|
1634
1659
|
}
|
|
1635
1660
|
this.emit('transaction:failed', { transaction, error });
|
|
1661
|
+
// Settle `waitForConfirmation` waiters (see the permanent branch above).
|
|
1662
|
+
this.emit(`transaction:failed:${transaction.id}`, { error });
|
|
1636
1663
|
}
|
|
1637
1664
|
}
|
|
1638
1665
|
/**
|
package/docs/quickstart.md
CHANGED
|
@@ -7,17 +7,19 @@ is the system of record — Ablo never hosts your data. It is the transaction
|
|
|
7
7
|
layer on top: it registers your connection, commits every write there behind
|
|
8
8
|
row-level security, and fans the confirmed rows out to every connected client.
|
|
9
9
|
|
|
10
|
-
## 1. Install and
|
|
10
|
+
## 1. Install and initialize
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
13
|
npm install @abloatai/ablo
|
|
14
|
-
npx ablo
|
|
14
|
+
npx ablo init
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
`ablo
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
`ablo init` scaffolds your project (next step shows what it creates) and ends
|
|
18
|
+
by signing you in — one browser click, and a `sk_test_` key is saved locally
|
|
19
|
+
for the CLI. Later, `npx ablo push` (step 4) writes `ABLO_API_KEY` into your
|
|
20
|
+
`.env.local` so the SDK finds it too — no manual copy-paste. `npx ablo login`
|
|
21
|
+
also exists standalone. In CI, or to manage the key by hand, set it yourself
|
|
22
|
+
instead:
|
|
21
23
|
|
|
22
24
|
```bash
|
|
23
25
|
export ABLO_API_KEY=sk_test_...
|
|
@@ -28,7 +30,7 @@ except both point at databases *you* own: `sk_test_*` for your dev database,
|
|
|
28
30
|
`sk_live_*` for production. There is no keyless mode; the public `/sandbox` page
|
|
29
31
|
is a hosted demo, not your app.
|
|
30
32
|
|
|
31
|
-
## 2.
|
|
33
|
+
## 2. Your Ablo schema (init scaffolded it)
|
|
32
34
|
|
|
33
35
|
The schema is the contract — it generates `ablo.<model>` methods for app code,
|
|
34
36
|
server actions, agents, and React reads. Declare **only the synced models** Ablo
|
|
@@ -78,10 +80,11 @@ tenant isolation with row-level security, so the server rejects superuser or
|
|
|
78
80
|
|
|
79
81
|
> **Neon / Supabase note:** the connection string those dashboards hand you
|
|
80
82
|
> uses the database OWNER role (e.g. `neondb_owner`), which is `BYPASSRLS` —
|
|
81
|
-
> Ablo will reject it. You don't have to fix that by hand: `npx ablo
|
|
82
|
-
> detects the unsafe role and offers to create the scoped one for
|
|
83
|
-
> your machine, so the owner credential never reaches Ablo. It
|
|
84
|
-
> `DATABASE_URL` into your env file (the generated password is
|
|
83
|
+
> Ablo will reject it. You don't have to fix that by hand: `npx ablo dev`
|
|
84
|
+
> (next step) detects the unsafe role and offers to create the scoped one for
|
|
85
|
+
> you — from your machine, so the owner credential never reaches Ablo. It
|
|
86
|
+
> writes the new `DATABASE_URL` into your env file (the generated password is
|
|
87
|
+
> never printed).
|
|
85
88
|
>
|
|
86
89
|
> Prefer to do it manually? The equivalent SQL:
|
|
87
90
|
>
|
|
@@ -101,16 +104,25 @@ built from an ORM adapter instead — same product, same writes, see
|
|
|
101
104
|
[Connect Your Database](./data-sources.md). In that setup, omit `databaseUrl`
|
|
102
105
|
from `Ablo(...)`.
|
|
103
106
|
|
|
104
|
-
## 4.
|
|
107
|
+
## 4. Push — Ablo provisions your tables for you
|
|
105
108
|
|
|
106
109
|
```bash
|
|
107
|
-
npx ablo
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
# .env.local
|
|
110
|
+
npx ablo push # checks your DATABASE_URL role, pushes the schema (sandbox),
|
|
111
|
+
# provisions your synced-model tables (with row-level
|
|
112
|
+
# security) IN YOUR database, and writes ABLO_API_KEY to
|
|
113
|
+
# .env.local. Add --watch to re-push on every save.
|
|
111
114
|
```
|
|
112
115
|
|
|
113
|
-
|
|
116
|
+
Nothing runs locally — there is no dev server to start. Your app talks to
|
|
117
|
+
Ablo's hosted API with the sandbox key; the rows land in your database.
|
|
118
|
+
|
|
119
|
+
There is no separate migration step: the push provisions your synced-model
|
|
120
|
+
tables in the registered database server-side — your other tables are left
|
|
121
|
+
untouched. (`npx ablo migrate` still exists for the signed Data Source
|
|
122
|
+
endpoint mode, where Ablo never touches your database and DDL must run from
|
|
123
|
+
your side.)
|
|
124
|
+
|
|
125
|
+
`ablo push` uploads the schema *definition* —
|
|
114
126
|
model names, fields, types. That metadata is the only thing Ablo keeps; the
|
|
115
127
|
rows stay in your database. Skipping the push makes every write to a new or
|
|
116
128
|
changed model fail with `server_execute_unknown_model` — that error literally
|
package/llms-full.txt
CHANGED
|
@@ -201,7 +201,7 @@ Use these public environment names:
|
|
|
201
201
|
|
|
202
202
|
- `ABLO_API_KEY` — SDK authentication for app and agent code. Where it comes
|
|
203
203
|
from: the human runs `npx ablo login` once (browser; an agent must not run
|
|
204
|
-
it), and `npx ablo
|
|
204
|
+
it), and `npx ablo push` then writes `ABLO_API_KEY=sk_test_…` into
|
|
205
205
|
`.env.local` automatically (and gitignores it). Check the environment and
|
|
206
206
|
`.env.local` for PRESENCE only (`grep -cq '^ABLO_API_KEY=' .env.local`)
|
|
207
207
|
before asking the human for a key — never print or echo the key value; a
|
package/llms.txt
CHANGED
|
@@ -12,7 +12,7 @@ First action when integrating into an app: run `npx ablo init --yes --framework
|
|
|
12
12
|
|
|
13
13
|
Second: make sure a key exists — WITHOUT printing it. The key is a secret; it must never appear in your output, your reasoning, or a file you echo (it would live in the conversation history forever). Check PRESENCE only: `[ -n "$ABLO_API_KEY" ] && echo set` and `grep -cq '^ABLO_API_KEY=' .env.local && echo wired` — never `cat .env.local`, never `echo $ABLO_API_KEY`. If neither check passes, ask the HUMAN to run `npx ablo login` once — it opens a browser and saves a `sk_test_` key locally; an agent must NOT run it. You never copy the key by hand: the next step writes it into `.env.local` (and gitignores it) for you.
|
|
14
14
|
|
|
15
|
-
Then PUSH — this is the step everything depends on. The server keeps its OWN copy of the schema. Run `npx ablo
|
|
15
|
+
Then PUSH — this is the step everything depends on. The server keeps its OWN copy of the schema. Run `npx ablo push --no-watch`: it pushes `ablo/schema.ts` (sandbox) AND writes `ABLO_API_KEY` into `.env.local` from the stored login. Until the schema is pushed, EVERY write to a new or changed model fails with `server_execute_unknown_model`. Re-run it after schema changes (`npx ablo push` also works once the key is wired; bare `npx ablo push` watches forever — don't, you have no TTY).
|
|
16
16
|
|
|
17
17
|
## Use this API
|
|
18
18
|
|
|
@@ -179,8 +179,8 @@ Do not teach `/api`, `/agent`, `/ai-sdk`, `/core`, `/realtime`, or internal subp
|
|
|
179
179
|
`ablo init` and other prompts need a TTY; an agent/CI run has none and will HANG. Always:
|
|
180
180
|
|
|
181
181
|
- `npx ablo init --yes` (flags: `--framework`, `--auth`, `--storage`, `--no-agent`, `--no-pull`, `--no-install`, `--no-login`). Generates `ablo/schema.ts` + the `ablo/data-source.ts` endpoint above.
|
|
182
|
-
- Key: see "Start here" — env → `.env.local` → ask the human to `npx ablo login`; never run `login` yourself, never copy keys by hand (`ablo
|
|
182
|
+
- Key: see "Start here" — env → `.env.local` → ask the human to `npx ablo login`; never run `login` yourself, never copy keys by hand (`ablo push` writes `.env.local`).
|
|
183
183
|
- Adopt an existing DB: `npx ablo pull prisma [path]` / `npx ablo pull drizzle <module>`.
|
|
184
|
-
- `npx ablo
|
|
184
|
+
- `npx ablo push --no-watch` pushes the schema (sandbox) AND writes `ABLO_API_KEY` to `.env.local` (default watches forever); `npx ablo logs --no-follow` (default tails forever); `npx ablo mode sandbox|production` (always pass the arg). `npx ablo push`/`status`/`pull`/`check`/`generate` are one-shot.
|
|
185
185
|
|
|
186
186
|
Canonical docs to read before integrating: `quickstart`, `schema-contract`, `integration-guide`, `guarantees`, `client-behavior`, `data-sources`, `examples/existing-python-backend`, `api`, `examples/ai-sdk-tool`, and `examples/server-agent`.
|