@abloatai/ablo 0.17.0 → 0.19.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 +70 -0
- package/dist/BaseSyncedStore.d.ts +5 -4
- package/dist/BaseSyncedStore.js +38 -14
- package/dist/NetworkMonitor.js +3 -1
- package/dist/ObjectPool.d.ts +14 -1
- package/dist/ObjectPool.js +8 -1
- package/dist/SyncClient.js +7 -1
- package/dist/SyncEngineContext.js +2 -0
- package/dist/ai-sdk/coordination-context.d.ts +2 -1
- package/dist/ai-sdk/coordination-context.js +3 -3
- package/dist/ai-sdk/index.d.ts +3 -4
- package/dist/ai-sdk/index.js +2 -3
- package/dist/ai-sdk/wrap.d.ts +13 -18
- package/dist/ai-sdk/wrap.js +2 -6
- package/dist/cli.cjs +459 -205
- package/dist/client/Ablo.d.ts +116 -23
- package/dist/client/Ablo.js +128 -38
- package/dist/client/ApiClient.d.ts +2 -2
- package/dist/client/ApiClient.js +27 -32
- package/dist/client/auth.d.ts +26 -0
- package/dist/client/auth.js +209 -1
- package/dist/client/createModelProxy.d.ts +16 -11
- package/dist/client/createModelProxy.js +15 -15
- package/dist/client/sessionMint.d.ts +10 -0
- package/dist/client/sessionMint.js +8 -1
- package/dist/coordination/schema.d.ts +10 -10
- package/dist/coordination/schema.js +7 -8
- package/dist/coordination/trace.d.ts +91 -0
- package/dist/coordination/trace.js +147 -0
- package/dist/core/StoreManager.js +3 -1
- package/dist/errorCodes.d.ts +2 -0
- package/dist/errorCodes.js +2 -0
- package/dist/errors.d.ts +10 -2
- package/dist/errors.js +20 -9
- package/dist/index.d.ts +7 -1
- package/dist/index.js +12 -0
- package/dist/interfaces/index.d.ts +45 -2
- package/dist/policy/types.d.ts +33 -9
- package/dist/policy/types.js +42 -11
- package/dist/react/AbloProvider.d.ts +2 -2
- package/dist/schema/ddl.d.ts +36 -0
- package/dist/schema/ddl.js +66 -0
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -0
- package/dist/sync/HydrationCoordinator.js +7 -3
- package/dist/sync/SyncWebSocket.d.ts +11 -2
- package/dist/sync/SyncWebSocket.js +68 -4
- package/dist/sync/awaitClaimGrant.js +9 -2
- package/dist/sync/createClaimStream.d.ts +9 -2
- package/dist/sync/createClaimStream.js +41 -7
- package/dist/sync/participants.d.ts +12 -11
- package/dist/sync/participants.js +5 -5
- package/dist/transactions/TransactionQueue.d.ts +1 -0
- package/dist/transactions/TransactionQueue.js +40 -3
- package/dist/types/streams.d.ts +57 -135
- package/dist/wire/errorEnvelope.d.ts +13 -0
- package/docs/coordination.md +16 -0
- package/docs/debugging.md +194 -0
- package/docs/index.md +1 -0
- package/package.json +1 -1
- package/dist/ai-sdk/claim-broadcast.d.ts +0 -83
- package/dist/ai-sdk/claim-broadcast.js +0 -79
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Wire contract (apps/sync-server/src/hub/types.ts):
|
|
12
12
|
* • Outbound: `{ type: 'claim_begin', payload: { claimId,
|
|
13
|
-
* entityType, entityId,
|
|
13
|
+
* entityType, entityId, reason, field?, estimatedMs? } }`
|
|
14
14
|
* • Outbound: `{ type: 'claim_abandon', payload: { claimId,
|
|
15
15
|
* entityType?, entityId? } }`
|
|
16
16
|
* • Inbound (via presence): `event.activeClaims: Claim[]`
|
|
@@ -22,12 +22,19 @@
|
|
|
22
22
|
* deletes.
|
|
23
23
|
*/
|
|
24
24
|
import type { SyncWebSocket } from './SyncWebSocket.js';
|
|
25
|
-
import type { ClaimStream } from '../types/streams.js';
|
|
25
|
+
import type { ClaimOptions, Claim, ClaimStream, PresenceTarget } from '../types/streams.js';
|
|
26
26
|
export interface ClaimStreamConfig {
|
|
27
27
|
/** Identity used to filter our own active claims out of `others`. */
|
|
28
28
|
participantId: string;
|
|
29
29
|
}
|
|
30
30
|
export interface AttachableClaimStream extends ClaimStream {
|
|
31
|
+
/**
|
|
32
|
+
* INTERNAL lease mint — sends the `claim_begin` frame and returns a held
|
|
33
|
+
* {@link Claim} (no row `data`; the model door reads the row and stamps it).
|
|
34
|
+
* Not part of the public `ClaimStream` surface: the only public way to take a
|
|
35
|
+
* claim is `ablo.<model>.claim({ id })`, which builds on this.
|
|
36
|
+
*/
|
|
37
|
+
claim(target: PresenceTarget, opts?: ClaimOptions): Claim;
|
|
31
38
|
attach(transport: SyncWebSocket): void;
|
|
32
39
|
dispose(): void;
|
|
33
40
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Wire contract (apps/sync-server/src/hub/types.ts):
|
|
12
12
|
* • Outbound: `{ type: 'claim_begin', payload: { claimId,
|
|
13
|
-
* entityType, entityId,
|
|
13
|
+
* entityType, entityId, reason, field?, estimatedMs? } }`
|
|
14
14
|
* • Outbound: `{ type: 'claim_abandon', payload: { claimId,
|
|
15
15
|
* entityType?, entityId? } }`
|
|
16
16
|
* • Inbound (via presence): `event.activeClaims: Claim[]`
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
import { asyncIteratorFrom } from '../utils/asyncIterator.js';
|
|
25
25
|
import { toMs } from '../utils/duration.js';
|
|
26
26
|
import { descriptionFromMeta, participantKindFromWire, } from '../coordination/schema.js';
|
|
27
|
+
import { getContext } from '../context.js';
|
|
28
|
+
/** Readable target for the coordination trace: `documents:abc` / `documents:abc.title`. */
|
|
29
|
+
function claimLabel(type, id, field) {
|
|
30
|
+
return field ? `${type}:${id}.${field}` : `${type}:${id}`;
|
|
31
|
+
}
|
|
27
32
|
export function createClaimStream(config, transport = null) {
|
|
28
33
|
const { participantId } = config;
|
|
29
34
|
// ── State: others' open claims, keyed by claimId ───────────────
|
|
@@ -37,6 +42,9 @@ export function createClaimStream(config, transport = null) {
|
|
|
37
42
|
const queueByEntity = new Map();
|
|
38
43
|
const entityKey = (type, id) => `${type}:${id}`;
|
|
39
44
|
const EMPTY_QUEUE = Object.freeze([]);
|
|
45
|
+
// Last queue position we logged per own-claim, so advancing in line is traced
|
|
46
|
+
// once per change (not re-logged on every server re-fan of the same line).
|
|
47
|
+
const lastLoggedQueuePos = new Map();
|
|
40
48
|
// ── Subscribers ──────────────────────────────────────────────────
|
|
41
49
|
const listeners = new Set();
|
|
42
50
|
const rejectionListeners = new Set();
|
|
@@ -96,7 +104,9 @@ export function createClaimStream(config, transport = null) {
|
|
|
96
104
|
continue;
|
|
97
105
|
const description = descriptionFromMeta(claim.meta);
|
|
98
106
|
activeByClaimId.set(claim.claimId, {
|
|
107
|
+
object: 'claim',
|
|
99
108
|
id: claim.claimId,
|
|
109
|
+
status: 'active',
|
|
100
110
|
heldBy: event.userId,
|
|
101
111
|
participantKind: participantKindFromWire(event.participantKind, event.isAgent),
|
|
102
112
|
target: {
|
|
@@ -107,10 +117,10 @@ export function createClaimStream(config, transport = null) {
|
|
|
107
117
|
field: claim.field,
|
|
108
118
|
meta: claim.meta,
|
|
109
119
|
},
|
|
110
|
-
reason: claim.
|
|
120
|
+
reason: claim.reason,
|
|
111
121
|
...(description ? { description } : {}),
|
|
112
122
|
ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
|
|
113
|
-
|
|
123
|
+
createdAt: claim.declaredAt,
|
|
114
124
|
expiresAt: claim.expiresAt,
|
|
115
125
|
});
|
|
116
126
|
mutated = true;
|
|
@@ -122,6 +132,12 @@ export function createClaimStream(config, transport = null) {
|
|
|
122
132
|
unsubs.push(t.subscribe('claim_rejected', (rejection) => {
|
|
123
133
|
if (!rejection.claimId)
|
|
124
134
|
return;
|
|
135
|
+
if (ownClaims.has(rejection.claimId)) {
|
|
136
|
+
const tgt = rejection.target
|
|
137
|
+
? claimLabel(rejection.target.entityType, rejection.target.entityId, rejection.target.field)
|
|
138
|
+
: rejection.claimId;
|
|
139
|
+
getContext().logger.info(`claim: rejected ${tgt}${rejection.heldBy ? ` — held by ${rejection.heldBy}` : ''}`, { claimId: rejection.claimId, reason: rejection.reason });
|
|
140
|
+
}
|
|
125
141
|
// Drop the rejected own-claim so reconnect doesn't re-announce
|
|
126
142
|
// a claim the server already rejected (would just spam both
|
|
127
143
|
// sides with conflicts).
|
|
@@ -141,6 +157,10 @@ export function createClaimStream(config, transport = null) {
|
|
|
141
157
|
const lost = payload;
|
|
142
158
|
if (!lost.claimId)
|
|
143
159
|
return;
|
|
160
|
+
if (ownClaims.has(lost.claimId)) {
|
|
161
|
+
const c = ownClaims.get(lost.claimId);
|
|
162
|
+
getContext().logger.info(`claim: lost ${c ? claimLabel(c.entityType, c.entityId, c.field) : lost.claimId} (preempted or expired)`, { claimId: lost.claimId });
|
|
163
|
+
}
|
|
144
164
|
// Drop the lost own-claim so reconnect doesn't re-announce a lease we
|
|
145
165
|
// no longer hold.
|
|
146
166
|
ownClaims.delete(lost.claimId);
|
|
@@ -166,6 +186,16 @@ export function createClaimStream(config, transport = null) {
|
|
|
166
186
|
queueByEntity.delete(key);
|
|
167
187
|
else
|
|
168
188
|
queueByEntity.set(key, Object.freeze([...line]));
|
|
189
|
+
// If WE are in this line, trace our position (the "agent queued behind a
|
|
190
|
+
// claim" moment) — once per position change, so advancing is visible.
|
|
191
|
+
const ourIndex = line.findIndex((c) => ownClaims.has(c.id));
|
|
192
|
+
if (ourIndex >= 0) {
|
|
193
|
+
const ourId = line[ourIndex].id;
|
|
194
|
+
if (lastLoggedQueuePos.get(ourId) !== ourIndex) {
|
|
195
|
+
lastLoggedQueuePos.set(ourId, ourIndex);
|
|
196
|
+
getContext().logger.info(`claim: queued for ${claimLabel(p.target.type, p.target.id)} — position ${ourIndex + 1} of ${line.length}, waiting`, { claimId: ourId });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
169
199
|
notifyListeners();
|
|
170
200
|
}));
|
|
171
201
|
// (3) On reconnect, re-announce every open self-claim — the
|
|
@@ -192,8 +222,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
192
222
|
entityId: claim.entityId,
|
|
193
223
|
path: claim.path,
|
|
194
224
|
range: claim.range,
|
|
195
|
-
|
|
196
|
-
action: claim.reason,
|
|
225
|
+
reason: claim.reason,
|
|
197
226
|
field: claim.field,
|
|
198
227
|
meta: claim.meta,
|
|
199
228
|
estimatedMs: claim.estimatedMs,
|
|
@@ -251,6 +280,9 @@ export function createClaimStream(config, transport = null) {
|
|
|
251
280
|
};
|
|
252
281
|
ownClaims.set(claimId, claim);
|
|
253
282
|
sendBegin(claimId, claim);
|
|
283
|
+
// Coordination trace (info): the creator can SEE their human/agent claims.
|
|
284
|
+
getContext().logger.info(`claim: requesting ${claimLabel(claim.entityType, claim.entityId, claim.field)} for "${claim.reason}"` +
|
|
285
|
+
(claim.queue ? ' (will queue if contended)' : ''), { claimId });
|
|
254
286
|
let revoked = false;
|
|
255
287
|
const revoke = () => {
|
|
256
288
|
if (revoked)
|
|
@@ -258,13 +290,15 @@ export function createClaimStream(config, transport = null) {
|
|
|
258
290
|
revoked = true;
|
|
259
291
|
ownClaims.delete(claimId);
|
|
260
292
|
sendAbandon(claimId, claim);
|
|
293
|
+
getContext().logger.info(`claim: released ${claimLabel(claim.entityType, claim.entityId, claim.field)}`, { claimId });
|
|
261
294
|
};
|
|
262
295
|
return {
|
|
263
296
|
object: 'claim',
|
|
264
|
-
claimId,
|
|
297
|
+
id: claimId,
|
|
298
|
+
status: 'active',
|
|
265
299
|
reason: args.reason,
|
|
266
300
|
target: {
|
|
267
|
-
|
|
301
|
+
type: args.entityType,
|
|
268
302
|
id: args.entityId,
|
|
269
303
|
path: args.path,
|
|
270
304
|
range: args.range,
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { SyncWebSocket } from './SyncWebSocket.js';
|
|
2
2
|
import type { Schema, SchemaRecord } from '../schema/schema.js';
|
|
3
|
-
import type {
|
|
3
|
+
import type { Claim, Activity, ClaimTarget, ClaimStream, Peer, PresenceStream, PresenceTarget } from '../types/streams.js';
|
|
4
|
+
import type { AttachableClaimStream } from './createClaimStream.js';
|
|
4
5
|
/**
|
|
5
6
|
* Scope accepted by participant APIs. The normal SDK shape is an
|
|
6
7
|
* entity target (`{ type, id }`). Raw sync-group strings remain an
|
|
7
8
|
* advanced transport escape hatch.
|
|
8
9
|
*/
|
|
9
|
-
export type ParticipantScope =
|
|
10
|
+
export type ParticipantScope = ClaimTarget | readonly ClaimTarget[] | string | readonly string[] | {
|
|
10
11
|
readonly syncGroup: string;
|
|
11
12
|
} | {
|
|
12
13
|
readonly syncGroups: readonly string[];
|
|
@@ -44,7 +45,7 @@ export interface ParticipantJoinOptions {
|
|
|
44
45
|
}
|
|
45
46
|
export interface ScopedPresence {
|
|
46
47
|
readonly self: Peer;
|
|
47
|
-
readonly focus:
|
|
48
|
+
readonly focus: ClaimTarget | null;
|
|
48
49
|
readonly others: ReadonlyArray<Peer>;
|
|
49
50
|
update(activity: Activity): void;
|
|
50
51
|
reading(detail?: string): void;
|
|
@@ -66,15 +67,15 @@ export interface ScopedClaimOptions {
|
|
|
66
67
|
readonly ttl?: import('../types/streams.js').Duration;
|
|
67
68
|
}
|
|
68
69
|
export interface ScopedClaims {
|
|
69
|
-
readonly focus:
|
|
70
|
-
readonly others: ReadonlyArray<
|
|
70
|
+
readonly focus: ClaimTarget | null;
|
|
71
|
+
readonly others: ReadonlyArray<Claim>;
|
|
71
72
|
/**
|
|
72
73
|
* Claim an exclusive claim on the participant's focus target (or
|
|
73
74
|
* an explicit override via `opts.target`). Single verb — the old
|
|
74
75
|
* `editing / writing / announce / claim(reason, opts)` overloads
|
|
75
76
|
* collapsed into this one method.
|
|
76
77
|
*/
|
|
77
|
-
claim(opts?: ScopedClaimOptions):
|
|
78
|
+
claim(opts?: ScopedClaimOptions): Claim;
|
|
78
79
|
onRejected(listener: Parameters<ClaimStream['onRejected']>[0]): () => void;
|
|
79
80
|
onChange(listener: () => void): () => void;
|
|
80
81
|
}
|
|
@@ -84,14 +85,14 @@ export interface ParticipantFocusOptions {
|
|
|
84
85
|
}
|
|
85
86
|
export interface JoinedParticipant {
|
|
86
87
|
/** Current exact thing this participant is reading/editing. */
|
|
87
|
-
readonly target:
|
|
88
|
-
readonly focusTarget:
|
|
88
|
+
readonly target: ClaimTarget | null;
|
|
89
|
+
readonly focusTarget: ClaimTarget | null;
|
|
89
90
|
/** Transport scopes this participant is joined to for visibility/fan-out. */
|
|
90
91
|
readonly syncGroups: readonly string[];
|
|
91
92
|
readonly presence: ScopedPresence;
|
|
92
93
|
readonly claims: ScopedClaims;
|
|
93
94
|
readonly peers: ReadonlyArray<Peer>;
|
|
94
|
-
readonly activeClaims: ReadonlyArray<
|
|
95
|
+
readonly activeClaims: ReadonlyArray<Claim>;
|
|
95
96
|
focus(target: PresenceTarget, options?: ParticipantFocusOptions): JoinedParticipant;
|
|
96
97
|
leave(): void;
|
|
97
98
|
[Symbol.asyncDispose](): Promise<void>;
|
|
@@ -104,11 +105,11 @@ export interface ParticipantManagerConfig {
|
|
|
104
105
|
readonly ready: () => Promise<void>;
|
|
105
106
|
readonly getTransport: () => SyncWebSocket | null;
|
|
106
107
|
readonly presence: PresenceStream;
|
|
107
|
-
readonly claims:
|
|
108
|
+
readonly claims: AttachableClaimStream;
|
|
108
109
|
readonly schema?: Schema<SchemaRecord>;
|
|
109
110
|
}
|
|
110
111
|
export declare function createParticipantManager(config: ParticipantManagerConfig): ParticipantManager;
|
|
111
112
|
export declare function resolveParticipantSyncGroups(scope: ParticipantScope | undefined, schema?: Schema<SchemaRecord>): string[];
|
|
112
|
-
export declare function syncGroupFromEntityRef(ref:
|
|
113
|
+
export declare function syncGroupFromEntityRef(ref: ClaimTarget, schema?: Schema<SchemaRecord>): string;
|
|
113
114
|
export declare function parseParticipantTtlSeconds(value: number | string | null | undefined): number | undefined;
|
|
114
115
|
export declare function createParticipantClaimId(): string;
|
|
@@ -220,20 +220,20 @@ function createJoinedParticipant(args) {
|
|
|
220
220
|
ownHandles.add(handle);
|
|
221
221
|
return {
|
|
222
222
|
object: 'claim',
|
|
223
|
-
|
|
223
|
+
id: handle.id,
|
|
224
224
|
reason: handle.reason,
|
|
225
225
|
target: handle.target,
|
|
226
226
|
async release() {
|
|
227
227
|
ownHandles.delete(handle);
|
|
228
|
-
await handle.release();
|
|
228
|
+
await handle.release?.();
|
|
229
229
|
},
|
|
230
230
|
revoke() {
|
|
231
231
|
ownHandles.delete(handle);
|
|
232
|
-
handle.revoke();
|
|
232
|
+
handle.revoke?.();
|
|
233
233
|
},
|
|
234
234
|
[Symbol.asyncDispose]: async () => {
|
|
235
235
|
ownHandles.delete(handle);
|
|
236
|
-
await handle[Symbol.asyncDispose]();
|
|
236
|
+
await handle[Symbol.asyncDispose]?.();
|
|
237
237
|
},
|
|
238
238
|
};
|
|
239
239
|
};
|
|
@@ -262,7 +262,7 @@ function createJoinedParticipant(args) {
|
|
|
262
262
|
return;
|
|
263
263
|
left = true;
|
|
264
264
|
for (const handle of Array.from(ownHandles)) {
|
|
265
|
-
handle.revoke();
|
|
265
|
+
handle.revoke?.();
|
|
266
266
|
ownHandles.delete(handle);
|
|
267
267
|
}
|
|
268
268
|
args.presence.idle();
|
|
@@ -125,6 +125,7 @@ interface TransactionQueueConfig {
|
|
|
125
125
|
}
|
|
126
126
|
export declare class TransactionQueue extends EventEmitter {
|
|
127
127
|
private store;
|
|
128
|
+
private lastPermanentErrorSig?;
|
|
128
129
|
private _mutationExecutor;
|
|
129
130
|
private get mutationExecutor();
|
|
130
131
|
private executionQueue;
|
|
@@ -199,6 +199,12 @@ class TransactionStore {
|
|
|
199
199
|
}
|
|
200
200
|
export class TransactionQueue extends EventEmitter {
|
|
201
201
|
store = new TransactionStore();
|
|
202
|
+
// Signature of the last permanent-error we logged at `warn`. A `create`
|
|
203
|
+
// whose id already exists (`unique_violation`) is a permanent rejection
|
|
204
|
+
// that the offline queue re-drives on every reconnect/bootstrap — without
|
|
205
|
+
// this, the identical cause prints on a loop. We log the first occurrence
|
|
206
|
+
// and demote exact repeats to `debug`.
|
|
207
|
+
lastPermanentErrorSig;
|
|
202
208
|
// Per-instance executor binding. Set by `setMutationExecutor(...)` from the
|
|
203
209
|
// owning Ablo right after construction. Falls back to `getContext()` only
|
|
204
210
|
// when unset (preserves legacy tests / SDK consumers that haven't migrated).
|
|
@@ -470,6 +476,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
470
476
|
// promise always settles, regardless of which path produced the
|
|
471
477
|
// terminal state.
|
|
472
478
|
this.on('transaction:completed', (tx) => {
|
|
479
|
+
// Any successful write clears the permanent-error dedup, so a genuine
|
|
480
|
+
// recurrence after recovery warns again instead of staying demoted.
|
|
481
|
+
this.lastPermanentErrorSig = undefined;
|
|
473
482
|
const r = this.confirmationResolvers.get(tx.id);
|
|
474
483
|
if (r) {
|
|
475
484
|
this.confirmationResolvers.delete(tx.id);
|
|
@@ -1254,7 +1263,12 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1254
1263
|
return undefined;
|
|
1255
1264
|
};
|
|
1256
1265
|
const diagnostics = readDiagnostics(error);
|
|
1257
|
-
|
|
1266
|
+
// Mechanic-level breadcrumb. Every batch rejection — transient
|
|
1267
|
+
// (reconnect retries it) or permanent (`handleFailure` logs the
|
|
1268
|
+
// authoritative `warn` with the same typed cause) — passes
|
|
1269
|
+
// through here. Logging it at `warn` made one rejected write
|
|
1270
|
+
// surface three identical dumps; keep it at `debug`.
|
|
1271
|
+
getContext().logger.debug('[TransactionQueue] Batch commit rejected', {
|
|
1258
1272
|
batchSize: batchOps.length,
|
|
1259
1273
|
models: batchOps.map(({ op }) => `${op.type}:${op.model}`),
|
|
1260
1274
|
errorType: abloErr?.type ?? error?.name,
|
|
@@ -1768,7 +1782,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1768
1782
|
// expiry (AbloAuthenticationError).
|
|
1769
1783
|
try {
|
|
1770
1784
|
const abloErr = error instanceof AbloError ? error : undefined;
|
|
1771
|
-
|
|
1785
|
+
const details = {
|
|
1772
1786
|
txId: transaction.id.slice(0, 8),
|
|
1773
1787
|
type: transaction.type,
|
|
1774
1788
|
model: transaction.modelName,
|
|
@@ -1779,7 +1793,30 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1779
1793
|
requestId: abloErr?.requestId,
|
|
1780
1794
|
message: error?.message,
|
|
1781
1795
|
inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
|
|
1782
|
-
}
|
|
1796
|
+
};
|
|
1797
|
+
// A `create` whose id already exists is the benign idempotency case:
|
|
1798
|
+
// "this row is already there." It's the least alarming permanent
|
|
1799
|
+
// error, so it doesn't warrant a `warn` — `info` keeps it visible
|
|
1800
|
+
// without crying wolf. Everything else (FK violation, auth expiry,
|
|
1801
|
+
// server 500) stays at `warn`.
|
|
1802
|
+
const isBenignIdempotent = transaction.type === 'create' &&
|
|
1803
|
+
(abloErr?.code === 'unique_violation' ||
|
|
1804
|
+
abloErr?.type === 'AbloIdempotencyError');
|
|
1805
|
+
// Demote exact repeats (same write rejected for the same reason on
|
|
1806
|
+
// each reconnect replay) to `debug` so the loop logs once.
|
|
1807
|
+
const sig = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
|
|
1808
|
+
const isRepeat = sig === this.lastPermanentErrorSig;
|
|
1809
|
+
this.lastPermanentErrorSig = sig;
|
|
1810
|
+
const logger = getContext().logger;
|
|
1811
|
+
if (isRepeat) {
|
|
1812
|
+
logger.debug('[TransactionQueue] Permanent error - rolling back (repeat)', details);
|
|
1813
|
+
}
|
|
1814
|
+
else if (isBenignIdempotent) {
|
|
1815
|
+
logger.info('[TransactionQueue] Write skipped — row already exists', details);
|
|
1816
|
+
}
|
|
1817
|
+
else {
|
|
1818
|
+
logger.warn('[TransactionQueue] Permanent error - rolling back', details);
|
|
1819
|
+
}
|
|
1783
1820
|
}
|
|
1784
1821
|
catch { }
|
|
1785
1822
|
// Mark as failed immediately and rollback
|
package/dist/types/streams.d.ts
CHANGED
|
@@ -100,7 +100,7 @@ export interface ContextChange {
|
|
|
100
100
|
* `range`, `field`, and `meta` are generic coordination hints for
|
|
101
101
|
* products like code editors, document editors, and design tools.
|
|
102
102
|
*/
|
|
103
|
-
export interface
|
|
103
|
+
export interface ClaimTarget {
|
|
104
104
|
readonly type: string;
|
|
105
105
|
readonly id: string;
|
|
106
106
|
readonly path?: string;
|
|
@@ -110,11 +110,11 @@ export interface EntityRef {
|
|
|
110
110
|
}
|
|
111
111
|
/**
|
|
112
112
|
* A pointer to one entity the participant is acting on. Either a
|
|
113
|
-
* typed `
|
|
113
|
+
* typed `ClaimTarget` (`{ type, id, ... }`), or a tuple
|
|
114
114
|
* `['Clause', 'cl_3']` for ergonomic inline use. The verb methods
|
|
115
115
|
* below accept both.
|
|
116
116
|
*/
|
|
117
|
-
export type PresenceTarget =
|
|
117
|
+
export type PresenceTarget = ClaimTarget | readonly [type: string, id: string];
|
|
118
118
|
/**
|
|
119
119
|
* Reactive livestream of what every multiplayer participant is doing.
|
|
120
120
|
* Every participant gets one; it's always on, always current.
|
|
@@ -336,27 +336,12 @@ export interface ClaimOptions extends ClaimLeaseOptions {
|
|
|
336
336
|
readonly queue?: boolean;
|
|
337
337
|
}
|
|
338
338
|
export interface ClaimStream {
|
|
339
|
-
/**
|
|
340
|
-
* Claim an exclusive claim on a target. Returns a handle — call
|
|
341
|
-
* `.revoke()` to cancel, let it expire via TTL, or use `await using`
|
|
342
|
-
* (TC39 explicit resource management) to auto-revoke on scope exit.
|
|
343
|
-
*
|
|
344
|
-
* Server rejects via `claim_rejected` when another participant
|
|
345
|
-
* already holds a claim on the same target. Default `reason` is
|
|
346
|
-
* `'editing'`; pass `{reason: 'writing'}` (or any string) to override.
|
|
347
|
-
*
|
|
348
|
-
* The frame ships on the open WS immediately. One method, one shape —
|
|
349
|
-
* the verb shortcuts (`editing`, `writing`, `announce`) and the
|
|
350
|
-
* scoped `claim(reason, opts)` overload were collapsed into this
|
|
351
|
-
* single primitive.
|
|
352
|
-
*/
|
|
353
|
-
claim(target: PresenceTarget, opts?: ClaimOptions): ClaimHandle;
|
|
354
339
|
/**
|
|
355
340
|
* Reactive view of every other participant's active claims.
|
|
356
341
|
* Reads return the current snapshot; pair with `subscribe(...)`
|
|
357
342
|
* below to get notified on change.
|
|
358
343
|
*/
|
|
359
|
-
readonly others: ReadonlyArray<
|
|
344
|
+
readonly others: ReadonlyArray<Claim>;
|
|
360
345
|
/**
|
|
361
346
|
* Reactive view of the wait queue on one target — the FIFO line of
|
|
362
347
|
* `status: 'queued'` claims behind the current holder, each with its
|
|
@@ -425,7 +410,7 @@ export interface ClaimStream {
|
|
|
425
410
|
* }
|
|
426
411
|
* ```
|
|
427
412
|
*/
|
|
428
|
-
[Symbol.asyncIterator](): AsyncIterableIterator<ReadonlyArray<
|
|
413
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<ReadonlyArray<Claim>>;
|
|
429
414
|
}
|
|
430
415
|
/**
|
|
431
416
|
* You LOST an claim you were HOLDING — distinct from `ClaimRejection` (a
|
|
@@ -452,95 +437,6 @@ export interface ClaimLost {
|
|
|
452
437
|
readonly meta?: Record<string, unknown>;
|
|
453
438
|
};
|
|
454
439
|
}
|
|
455
|
-
export interface ClaimDeclaration {
|
|
456
|
-
readonly target: EntityRef;
|
|
457
|
-
/** Human-readable reason — "rewriting title" / "restyling chart". */
|
|
458
|
-
readonly reason: string;
|
|
459
|
-
/**
|
|
460
|
-
* Seconds remaining until the server auto-expires this claim. An OUTPUT
|
|
461
|
-
* field carrying a concrete countdown, so it's a plain `number` — distinct
|
|
462
|
-
* from the input `ttl: Duration` (`'3m'`) you pass when announcing. Computed
|
|
463
|
-
* from `expiresAt - now`.
|
|
464
|
-
*/
|
|
465
|
-
readonly ttlSeconds?: number;
|
|
466
|
-
}
|
|
467
|
-
/**
|
|
468
|
-
* Handle returned from `announce(...)` / `analyzing(...)` / etc.
|
|
469
|
-
*
|
|
470
|
-
* Implements `Symbol.asyncDispose` so callers can write:
|
|
471
|
-
*
|
|
472
|
-
* ```ts
|
|
473
|
-
* {
|
|
474
|
-
* await using work = participant.claims.analyzing(clause, { ttl: '3m' });
|
|
475
|
-
* // ... do the work; claim auto-revokes when the block exits
|
|
476
|
-
* }
|
|
477
|
-
* ```
|
|
478
|
-
*/
|
|
479
|
-
/**
|
|
480
|
-
* THE one claim handle. Returned by every claim door — the typed
|
|
481
|
-
* `ablo.<model>.claim({ id })` (rich: `data`/`readAt`/`target` populated) and
|
|
482
|
-
* the low-level `participant.claims.claim()` lease (minimal: `claimId` +
|
|
483
|
-
* `revoke`/`release`). Row-level fields are optional precisely because the
|
|
484
|
-
* low-level lease has no row snapshot; the model door fills them in.
|
|
485
|
-
*
|
|
486
|
-
* Implements `Symbol.asyncDispose` so callers can `await using claim = ...`
|
|
487
|
-
* and have it auto-release on scope exit.
|
|
488
|
-
*/
|
|
489
|
-
export interface ClaimHandle<T = Record<string, unknown>> extends AsyncDisposable {
|
|
490
|
-
readonly object: 'claim';
|
|
491
|
-
readonly claimId: string;
|
|
492
|
-
/**
|
|
493
|
-
* True when the grant came AFTER waiting in the server's FIFO line
|
|
494
|
-
* (`claim_granted`) — the authoritative "the row may have changed under us"
|
|
495
|
-
* signal. Absent for an immediate grant or a non-queued lease.
|
|
496
|
-
*/
|
|
497
|
-
readonly waited?: boolean;
|
|
498
|
-
/**
|
|
499
|
-
* Sync watermark of the held snapshot (`data` was read at this stamp). Writes
|
|
500
|
-
* carrying the handle use it as the `readAt` stale guard. Present for
|
|
501
|
-
* model-scoped claims; absent for low-level leases.
|
|
502
|
-
*/
|
|
503
|
-
readonly readAt?: number;
|
|
504
|
-
readonly target: {
|
|
505
|
-
readonly model: string;
|
|
506
|
-
readonly id: string;
|
|
507
|
-
readonly field?: string;
|
|
508
|
-
readonly path?: string;
|
|
509
|
-
readonly range?: TargetRange;
|
|
510
|
-
readonly meta?: Record<string, unknown>;
|
|
511
|
-
};
|
|
512
|
-
/**
|
|
513
|
-
* The human-readable phase this claim represents — `'editing'`, `'writing'`,
|
|
514
|
-
* `'forecasting'`. The SAME word on every claim surface (inputs and outputs);
|
|
515
|
-
* distinct from the CRUD operation (`CommitOperationInput.action`). Defaults
|
|
516
|
-
* to `'editing'`. Serialized on the wire as `action`.
|
|
517
|
-
*/
|
|
518
|
-
readonly reason: string;
|
|
519
|
-
readonly description?: string;
|
|
520
|
-
/** Row snapshot — populated by `ablo.<model>.claim`; absent on low-level leases. */
|
|
521
|
-
readonly data?: T;
|
|
522
|
-
release(): Promise<void>;
|
|
523
|
-
revoke(): void;
|
|
524
|
-
}
|
|
525
|
-
export interface ActiveClaim extends ClaimDeclaration {
|
|
526
|
-
readonly id: string;
|
|
527
|
-
readonly heldBy: string;
|
|
528
|
-
/**
|
|
529
|
-
* Whether the holding participant is a user (session), an agent, or a
|
|
530
|
-
* system actor. First-class field so UIs can style "agent editing X"
|
|
531
|
-
* differently from "user editing X" without string-parsing `heldBy`.
|
|
532
|
-
* Canonical `'user' | 'agent' | 'system'` — the presence/claim stream
|
|
533
|
-
* derives the value from the boolean `isAgent` wire flag (so it produces
|
|
534
|
-
* only `'user'`/`'agent'`), but the type stays the full union it shares
|
|
535
|
-
* with the HTTP claim surface and lease store.
|
|
536
|
-
*/
|
|
537
|
-
readonly participantKind: ParticipantKind;
|
|
538
|
-
readonly description?: string;
|
|
539
|
-
/** Epoch-ms the claim was announced. */
|
|
540
|
-
readonly announcedAt: number;
|
|
541
|
-
/** Epoch-ms the server auto-expires it. */
|
|
542
|
-
readonly expiresAt: number;
|
|
543
|
-
}
|
|
544
440
|
/**
|
|
545
441
|
* Every lifecycle state of a coordination claim, in one enum.
|
|
546
442
|
* `active` = the current holder (the lock). `queued` = waiting in the FIFO
|
|
@@ -554,44 +450,70 @@ export interface ClaimWaitOptions {
|
|
|
554
450
|
readonly pollInterval?: number;
|
|
555
451
|
readonly signal?: AbortSignal;
|
|
556
452
|
}
|
|
557
|
-
|
|
558
|
-
* The coordination state of one entity. Self-describing on the wire via
|
|
559
|
-
* `object: 'claim'`. Existence with `status: 'active'` *is* the lock;
|
|
560
|
-
* the fields *are* the awareness ("agent X is editing this until Y").
|
|
561
|
-
*
|
|
562
|
-
* Deliberately omits a Stripe-style `next_action`: a contender's only
|
|
563
|
-
* response is "wait until free, then re-read", and the runtime performs
|
|
564
|
-
* that uniformly — `claim` serializes behind the holder via the server
|
|
565
|
-
* FIFO queue (or low-level `claims.waitFor` to wait without claiming), and the
|
|
566
|
-
* stale-context guard forces the re-read. Encoding a constant instruction
|
|
567
|
-
* the engine always takes would be the kind of ceremony this object exists
|
|
568
|
-
* to remove.
|
|
569
|
-
*/
|
|
570
|
-
export interface Claim {
|
|
453
|
+
export interface Claim<T = Record<string, unknown>> {
|
|
571
454
|
readonly object: 'claim';
|
|
455
|
+
/** This claim's id. */
|
|
572
456
|
readonly id: string;
|
|
573
|
-
|
|
457
|
+
/**
|
|
458
|
+
* Lifecycle state. `active` = the holder (the lock); `queued` = waiting in
|
|
459
|
+
* the FIFO line (carries `position`). Optional on shapes derived from a
|
|
460
|
+
* presence frame that don't carry it — a present entry is `active` by
|
|
461
|
+
* construction.
|
|
462
|
+
*/
|
|
463
|
+
readonly status?: ClaimStatus;
|
|
574
464
|
/** What is being coordinated. */
|
|
575
|
-
readonly target:
|
|
576
|
-
/**
|
|
577
|
-
*
|
|
578
|
-
*
|
|
465
|
+
readonly target: ClaimTarget;
|
|
466
|
+
/**
|
|
467
|
+
* Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. The same
|
|
468
|
+
* field on every claim surface; distinct from the CRUD operation. Defaults to
|
|
469
|
+
* `'editing'`.
|
|
470
|
+
*/
|
|
579
471
|
readonly reason: string;
|
|
580
472
|
/** Peer-visible explanation of the work being performed. */
|
|
581
473
|
readonly description?: string;
|
|
582
|
-
/** Participant holding it. */
|
|
583
|
-
readonly heldBy
|
|
584
|
-
readonly participantKind: ParticipantKind;
|
|
474
|
+
/** Participant holding it. Absent on a handle you hold for yourself. */
|
|
475
|
+
readonly heldBy?: string;
|
|
585
476
|
/**
|
|
586
|
-
*
|
|
587
|
-
*
|
|
477
|
+
* Whether the holder is a user (session), agent, or system actor — so UIs
|
|
478
|
+
* style "agent editing X" vs "user editing X" without string-parsing
|
|
479
|
+
* `heldBy`. Present on observed claims; absent on your own fresh handle.
|
|
588
480
|
*/
|
|
481
|
+
readonly participantKind?: ParticipantKind;
|
|
482
|
+
/** Epoch-ms the holder opened it. */
|
|
589
483
|
readonly createdAt?: number;
|
|
590
484
|
/** Epoch-ms the server auto-expires it if the holder doesn't finish. */
|
|
591
|
-
readonly expiresAt
|
|
485
|
+
readonly expiresAt?: number;
|
|
486
|
+
/**
|
|
487
|
+
* Seconds remaining until auto-expiry — an OUTPUT countdown (plain `number`),
|
|
488
|
+
* distinct from the input `ttl: Duration` (`'3m'`) you pass when claiming.
|
|
489
|
+
*/
|
|
490
|
+
readonly ttlSeconds?: number;
|
|
592
491
|
/**
|
|
593
492
|
* 0-based place in the FIFO line — present only when `status: 'queued'`
|
|
594
|
-
* (`0` = next
|
|
493
|
+
* (`0` = next behind the holder). Absent for the active holder.
|
|
595
494
|
*/
|
|
596
495
|
readonly position?: number;
|
|
496
|
+
/**
|
|
497
|
+
* True when the grant came AFTER waiting in the server's FIFO line
|
|
498
|
+
* (`claim_granted`) — the authoritative "the row may have changed under us"
|
|
499
|
+
* signal. Absent for an immediate (uncontended) grant.
|
|
500
|
+
*/
|
|
501
|
+
readonly waited?: boolean;
|
|
502
|
+
/**
|
|
503
|
+
* Sync watermark of the held snapshot (`data` was read at this stamp). Writes
|
|
504
|
+
* carrying the claim use it as the `readAt` stale guard. Present on a claim
|
|
505
|
+
* you hold.
|
|
506
|
+
*/
|
|
507
|
+
readonly readAt?: number;
|
|
508
|
+
/** Row snapshot under the lease — present on a claim you hold. */
|
|
509
|
+
readonly data?: T;
|
|
510
|
+
/**
|
|
511
|
+
* Release the lease. Present only on a claim you HOLD (returned by
|
|
512
|
+
* `ablo.<model>.claim`); absent on observed peer claims.
|
|
513
|
+
*/
|
|
514
|
+
release?(): Promise<void>;
|
|
515
|
+
/** Synchronously abandon the lease. Present only on a claim you hold. */
|
|
516
|
+
revoke?(): void;
|
|
517
|
+
/** Auto-release on `await using` scope exit. Present only on a held claim. */
|
|
518
|
+
[Symbol.asyncDispose]?(): PromiseLike<void>;
|
|
597
519
|
}
|
|
@@ -8,6 +8,19 @@ export interface ErrorEnvelope {
|
|
|
8
8
|
readonly message: string;
|
|
9
9
|
readonly doc_url?: string;
|
|
10
10
|
readonly request_id?: string;
|
|
11
|
+
/** Aggregate field-level failures — so one 4xx can report EVERY invalid input
|
|
12
|
+
* at once (schema push, batch commit, CLI-arg validation) instead of failing
|
|
13
|
+
* on the first. `param` stays the single-field convenience case. (RFC 9457
|
|
14
|
+
* `errors[]` / JSON:API `errors[]` / Google `BadRequest.fieldViolations[]`.) */
|
|
15
|
+
readonly errors?: ReadonlyArray<{
|
|
16
|
+
readonly code?: string;
|
|
17
|
+
readonly message: string;
|
|
18
|
+
readonly param?: string;
|
|
19
|
+
}>;
|
|
20
|
+
/** Typed-details slot: `AbloError.toJSON()` spreads its `details` (e.g.
|
|
21
|
+
* `missingIds`, `conflicts`, `retryAfterSeconds`) as top-level members.
|
|
22
|
+
* Consumers MUST ignore members they don't recognize (forward-compat). */
|
|
23
|
+
readonly [key: string]: unknown;
|
|
11
24
|
}
|
|
12
25
|
/** {@link AbloError} subclass → default HTTP status. The subclass is chosen to
|
|
13
26
|
* match status semantics (a validation error is a 400, a permission error a
|
package/docs/coordination.md
CHANGED
|
@@ -25,6 +25,22 @@ one answer to "how do two agents not clobber each other" — then covers the
|
|
|
25
25
|
(`claim` · `claim.state` · `claim.queue` · `claim.release` · [writing under a
|
|
26
26
|
claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
|
|
27
27
|
|
|
28
|
+
> **Before anything else: one identity per agent.** Coordination excludes
|
|
29
|
+
> *participants*, and a participant **is the key**, not the client object. The
|
|
30
|
+
> server derives identity from the credential's scope — so **N clients sharing
|
|
31
|
+
> one `sk_`/`ek_` are one participant**: they all see the same `heldBy`, never
|
|
32
|
+
> queue behind each other, and a "second" claimer silently re-takes the lease it
|
|
33
|
+
> already holds (no mutual exclusion). To get real per-agent exclusion, mint a
|
|
34
|
+
> **distinct scoped `rk_` per agent** and bind a client to it:
|
|
35
|
+
>
|
|
36
|
+
> ```ts
|
|
37
|
+
> const { token } = await ablo.sessions.create({ agent: { id: `agent-${i}` } }); // rk_
|
|
38
|
+
> const agent = Ablo({ schema, apiKey: token }); // this agent's own participant
|
|
39
|
+
> ```
|
|
40
|
+
>
|
|
41
|
+
> Now `agent-0` holds while `agent-1`/`agent-2` queue in FIFO order and drain in
|
|
42
|
+
> turn. See [sessions](./sessions.md#agent-sessions-rk_) for the minting flow.
|
|
43
|
+
|
|
28
44
|
---
|
|
29
45
|
|
|
30
46
|
## The model — three layers, one decision
|