@abloatai/ablo 0.18.0 → 0.20.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 +49 -0
- package/dist/Model.d.ts +22 -0
- package/dist/Model.js +45 -0
- 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 +253 -160
- package/dist/client/Ablo.d.ts +83 -22
- package/dist/client/Ablo.js +116 -32
- 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 +208 -0
- 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/errorCodes.d.ts +2 -0
- package/dist/errorCodes.js +2 -0
- package/dist/errors.d.ts +1 -2
- package/dist/errors.js +6 -9
- package/dist/index.d.ts +6 -1
- package/dist/index.js +13 -0
- package/dist/interfaces/index.d.ts +45 -2
- package/dist/mutators/undoApply.d.ts +7 -2
- package/dist/mutators/undoApply.js +7 -35
- package/dist/policy/types.d.ts +18 -9
- package/dist/policy/types.js +26 -16
- package/dist/react/AbloProvider.d.ts +2 -2
- package/dist/react/useAblo.js +12 -2
- package/dist/schema/sugar.js +10 -2
- package/dist/server/read-config.d.ts +7 -0
- package/dist/sync/HydrationCoordinator.js +7 -3
- package/dist/sync/SyncWebSocket.d.ts +11 -2
- package/dist/sync/SyncWebSocket.js +67 -3
- package/dist/sync/createClaimStream.d.ts +9 -2
- package/dist/sync/createClaimStream.js +9 -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/utils/json.d.ts +39 -0
- package/dist/utils/json.js +88 -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import type { SchemaRecord } from '../schema/schema.js';
|
|
3
3
|
import { Ablo } from '../client/Ablo.js';
|
|
4
|
-
import type {
|
|
4
|
+
import type { Claim, Peer } from '../types/streams.js';
|
|
5
5
|
import type { EngineParticipant, ParticipantScope, ParticipantStatus } from '../sync/participants.js';
|
|
6
6
|
import { type SyncStoreContract } from './context.js';
|
|
7
7
|
/**
|
|
@@ -154,7 +154,7 @@ export interface UseWatchReturn {
|
|
|
154
154
|
/** Everyone else on the engine's sync groups (`participant.presence.others`), bridged to React. */
|
|
155
155
|
readonly peers: ReadonlyArray<Peer>;
|
|
156
156
|
/** Active claim claims by peers (`participant.claims.others`), bridged to React. */
|
|
157
|
-
readonly claims: ReadonlyArray<
|
|
157
|
+
readonly claims: ReadonlyArray<Claim>;
|
|
158
158
|
readonly status: ParticipantStatus;
|
|
159
159
|
readonly error: Error | null;
|
|
160
160
|
}
|
package/dist/react/useAblo.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { useContext, useEffect, useState } from 'react';
|
|
3
3
|
import { AbloInternalContext } from './internalContext.js';
|
|
4
4
|
import { getModelClientMeta } from '../client/createModelProxy.js';
|
|
5
|
-
import { Model
|
|
5
|
+
import { Model } from '../Model.js';
|
|
6
6
|
import { useReactive } from './useReactive.js';
|
|
7
7
|
const EMPTY_CLAIMS = Object.freeze([]);
|
|
8
8
|
function readModelResult(engine, modelClient, id, initial) {
|
|
@@ -16,9 +16,19 @@ function readModelResult(engine, modelClient, id, initial) {
|
|
|
16
16
|
: EMPTY_CLAIMS;
|
|
17
17
|
return { data, claims, claimed: claims.length > 0 };
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Project a reactive read into the value `useReactive` caches and returns.
|
|
21
|
+
*
|
|
22
|
+
* For a `Model`, this MUST read the row's fields (via `toReactiveSnapshot`),
|
|
23
|
+
* not return the bare instance: MobX tracks property access, so reading the
|
|
24
|
+
* fields inside this tracked function is what subscribes the reaction to them —
|
|
25
|
+
* and the fresh object identity lets `useReactive`'s equality detect an
|
|
26
|
+
* in-place delta update. Returning `modelAsRow(value)` (the live instance, no
|
|
27
|
+
* field read) is why `useAblo(a => a.x.get(id))` used to ignore remote edits.
|
|
28
|
+
*/
|
|
19
29
|
function snapshotValue(value) {
|
|
20
30
|
if (value instanceof Model) {
|
|
21
|
-
return
|
|
31
|
+
return value.toReactiveSnapshot();
|
|
22
32
|
}
|
|
23
33
|
if (Array.isArray(value)) {
|
|
24
34
|
return value.map((item) => snapshotValue(item));
|
package/dist/schema/sugar.js
CHANGED
|
@@ -66,7 +66,10 @@ function build(shape, opts, baseline) {
|
|
|
66
66
|
* - `.manual` — never auto-loaded; explicit queries only
|
|
67
67
|
*/
|
|
68
68
|
export const mutable = {
|
|
69
|
-
instant: (shape, opts) =>
|
|
69
|
+
instant: (shape, opts) =>
|
|
70
|
+
// Reactive by default (see readonly.instant note): opt out with
|
|
71
|
+
// `lazyObservable: false` only for very large read-only list models.
|
|
72
|
+
build(shape, opts, { mutable: true, load: 'instant', lazyObservable: true }),
|
|
70
73
|
lazy: (shape, opts) => build(shape, opts, { mutable: true, load: 'lazy', lazyObservable: true }),
|
|
71
74
|
manual: (shape, opts) => build(shape, opts, { mutable: true, load: 'manual', lazyObservable: true }),
|
|
72
75
|
};
|
|
@@ -82,7 +85,12 @@ export const mutable = {
|
|
|
82
85
|
* writes
|
|
83
86
|
*/
|
|
84
87
|
export const readOnly = {
|
|
85
|
-
instant: (shape, opts) =>
|
|
88
|
+
instant: (shape, opts) =>
|
|
89
|
+
// Reactive by default (like every variant now): a remote delta that mutates
|
|
90
|
+
// a row in place must re-render reactive reads. Opt out per-model with
|
|
91
|
+
// `lazyObservable: false` for very large read-only lists where per-field
|
|
92
|
+
// atoms cost more than the QueryView's entry-replaced reactivity.
|
|
93
|
+
build(shape, opts, { mutable: false, load: 'instant', lazyObservable: true }),
|
|
86
94
|
lazy: (shape, opts) => build(shape, opts, { mutable: false, load: 'lazy', lazyObservable: true }),
|
|
87
95
|
/**
|
|
88
96
|
* Internal-only: never auto-loaded, never written by clients. The
|
|
@@ -57,4 +57,11 @@ export interface BootstrapModel {
|
|
|
57
57
|
fieldColumns?: Record<string, string>;
|
|
58
58
|
/** Physical-column aliases needed after SELECT * for `.from(...)` fields. */
|
|
59
59
|
columnOverrides?: readonly ColumnOverride[];
|
|
60
|
+
/**
|
|
61
|
+
* Physical columns the schema declares as `json` (`field.json()`). A json
|
|
62
|
+
* field stored in a TEXT column comes back from `row_to_json` as a serialized
|
|
63
|
+
* string; the bootstrap re-parses these so the wire is the canonical object
|
|
64
|
+
* regardless of physical column type (jsonb returns objects natively → no-op).
|
|
65
|
+
*/
|
|
66
|
+
jsonColumns?: readonly string[];
|
|
60
67
|
}
|
|
@@ -171,7 +171,11 @@ export class HydrationCoordinator {
|
|
|
171
171
|
async fetchFromNetwork(modelName, typename, clauses, options) {
|
|
172
172
|
const networkRows = await this.queryNetwork(modelName, clauses, options);
|
|
173
173
|
const networkModels = networkRows
|
|
174
|
-
|
|
174
|
+
// Strict: a row the SERVER returned whose typename this client never
|
|
175
|
+
// registered is a genuine schema collision (the org's pushed schema
|
|
176
|
+
// differs from local) — throw it here, naming the cause, rather than
|
|
177
|
+
// silently dropping the row and failing downstream as `entity_not_found`.
|
|
178
|
+
.map((raw) => this.hydrateOne(raw, typename, { strict: true }))
|
|
175
179
|
.filter((m) => m !== null);
|
|
176
180
|
if (networkModels.length > 0) {
|
|
177
181
|
this.opts.objectPool.addBatch(networkModels, ModelScope.live);
|
|
@@ -288,7 +292,7 @@ export class HydrationCoordinator {
|
|
|
288
292
|
getModelDef(modelName) {
|
|
289
293
|
return this.opts.schema.models?.[modelName];
|
|
290
294
|
}
|
|
291
|
-
hydrateOne(raw, typename) {
|
|
295
|
+
hydrateOne(raw, typename, opts) {
|
|
292
296
|
if (!raw || typeof raw !== 'object')
|
|
293
297
|
return null;
|
|
294
298
|
const obj = raw;
|
|
@@ -321,7 +325,7 @@ export class HydrationCoordinator {
|
|
|
321
325
|
// re-populate it). The typename comes from the schema relation
|
|
322
326
|
// (`'SlideLayer'`, `'SlideLayoutLayer'`, etc.) so no guessing involved.
|
|
323
327
|
const stamped = this.stampTypename(obj, typename);
|
|
324
|
-
return this.opts.objectPool.createFromData(stamped);
|
|
328
|
+
return this.opts.objectPool.createFromData(stamped, undefined, opts);
|
|
325
329
|
}
|
|
326
330
|
/**
|
|
327
331
|
* Stamp `__typename` onto a row when it's known (from the schema's
|
|
@@ -179,7 +179,7 @@ export interface PresenceUpdateEvent {
|
|
|
179
179
|
participantKind?: string;
|
|
180
180
|
timestamp?: number;
|
|
181
181
|
/** Server stamps every presence frame with this participant's open
|
|
182
|
-
*
|
|
182
|
+
* claims so peers see them without a separate channel. Wire
|
|
183
183
|
* shape mirrors `apps/sync-server/src/hub/types.ts Claim`. */
|
|
184
184
|
activeClaims?: Array<{
|
|
185
185
|
claimId: string;
|
|
@@ -192,7 +192,7 @@ export interface PresenceUpdateEvent {
|
|
|
192
192
|
startColumn?: number;
|
|
193
193
|
endColumn?: number;
|
|
194
194
|
};
|
|
195
|
-
|
|
195
|
+
reason: string;
|
|
196
196
|
field?: string;
|
|
197
197
|
meta?: Record<string, unknown>;
|
|
198
198
|
declaredAt: number;
|
|
@@ -490,6 +490,15 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
490
490
|
* so a bad notification never sinks an otherwise-successful commit.
|
|
491
491
|
*/
|
|
492
492
|
private parseNotifications;
|
|
493
|
+
/**
|
|
494
|
+
* Single instrumentation point for claim events. Every `claim_*` frame routes
|
|
495
|
+
* through here so a developer debugging a collision gets one consistent trace
|
|
496
|
+
* — a console line AND a structured capture — without each dispatch case
|
|
497
|
+
* re-deriving the row/holder shape. The wire payload is loosely typed
|
|
498
|
+
* (`Record<string, unknown>`), so this is the one place that narrows it into
|
|
499
|
+
* a {@link ClaimEvent}.
|
|
500
|
+
*/
|
|
501
|
+
private recordClaim;
|
|
493
502
|
sendCommit(operations: ReadonlyArray<MutationOperation>, clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null): Promise<CommitAck>;
|
|
494
503
|
/**
|
|
495
504
|
* Send a commit frame without waiting for `mutation_result`.
|
|
@@ -11,7 +11,8 @@ import { EventEmitter } from 'events';
|
|
|
11
11
|
import { getContext } from '../context.js';
|
|
12
12
|
import { flushOfflineQueueOnce } from './OfflineFlush.js';
|
|
13
13
|
import { AbloConnectionError, AbloError, CapabilityError, SyncSessionError, errorFromWire, toAbloError, } from '../errors.js';
|
|
14
|
-
import { subscriptionAckPayloadSchema, staleNotificationSchema, } from '../coordination/schema.js';
|
|
14
|
+
import { subscriptionAckPayloadSchema, staleNotificationSchema, wireParticipantKindSchema, } from '../coordination/schema.js';
|
|
15
|
+
import { formatClaim, formatConflict } from '../coordination/trace.js';
|
|
15
16
|
import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/credentialSource.js';
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
|
|
@@ -338,8 +339,23 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
338
339
|
// the advisory signal so an agent loop can self-heal, AND resolve
|
|
339
340
|
// the receipt with it (the commit still succeeded).
|
|
340
341
|
if (notifications && notifications.length > 0) {
|
|
342
|
+
const txId = typeof clientTxId === 'string' ? clientTxId : '';
|
|
343
|
+
const event = {
|
|
344
|
+
clientTxId: txId,
|
|
345
|
+
rows: notifications.map((n) => ({
|
|
346
|
+
model: n.model,
|
|
347
|
+
id: n.id,
|
|
348
|
+
fields: n.conflictingFields,
|
|
349
|
+
writtenBy: n.writtenBy?.kind,
|
|
350
|
+
})),
|
|
351
|
+
};
|
|
352
|
+
const message = formatConflict(event);
|
|
353
|
+
const ctx = getContext();
|
|
354
|
+
ctx.logger.warn(message);
|
|
355
|
+
ctx.observability.breadcrumb(message, 'sync.coordination', 'warning');
|
|
356
|
+
ctx.observability.captureConflict(event);
|
|
341
357
|
this.emit('conflict:notified', {
|
|
342
|
-
clientTxId:
|
|
358
|
+
clientTxId: txId,
|
|
343
359
|
notifications,
|
|
344
360
|
});
|
|
345
361
|
}
|
|
@@ -493,6 +509,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
493
509
|
// inactive server-side by the time this arrives.
|
|
494
510
|
const p = message.payload ?? {};
|
|
495
511
|
if (typeof p.claimId === 'string') {
|
|
512
|
+
this.recordClaim('expired', p);
|
|
496
513
|
this.emit('claim_expired', { claimId: p.claimId });
|
|
497
514
|
}
|
|
498
515
|
break;
|
|
@@ -502,33 +519,40 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
502
519
|
// already claimed by another participant. Forward the
|
|
503
520
|
// payload as-is — the ClaimStream consumer interprets
|
|
504
521
|
// the conflict shape (peerId, target, etc.).
|
|
522
|
+
this.recordClaim('rejected', message.payload ?? {});
|
|
505
523
|
this.emit('claim_rejected', message.payload ?? {});
|
|
506
524
|
break;
|
|
507
525
|
}
|
|
508
526
|
case 'claim_acquired': {
|
|
509
527
|
// Opt-in fair queue: the target was free, so the lease is ours
|
|
510
528
|
// immediately (no waiting). Payload carries { claimId, target }.
|
|
529
|
+
this.recordClaim('acquired', message.payload ?? {});
|
|
511
530
|
this.emit('claim_acquired', message.payload ?? {});
|
|
512
531
|
break;
|
|
513
532
|
}
|
|
514
533
|
case 'claim_queue': {
|
|
515
|
-
// Per-entity wait-queue snapshot for reactive `queue(id)`.
|
|
534
|
+
// Per-entity wait-queue snapshot for reactive `queue(id)`. Not a
|
|
535
|
+
// single claim's state change, so it isn't logged — the per-claim
|
|
536
|
+
// `queued`/`granted` events already tell that story.
|
|
516
537
|
this.emit('claim_queue', message.payload ?? {});
|
|
517
538
|
break;
|
|
518
539
|
}
|
|
519
540
|
case 'claim_queued': {
|
|
520
541
|
// Opt-in fair queue: our claim is waiting in line. Payload
|
|
521
542
|
// carries { claimId, target, position }.
|
|
543
|
+
this.recordClaim('queued', message.payload ?? {});
|
|
522
544
|
this.emit('claim_queued', message.payload ?? {});
|
|
523
545
|
break;
|
|
524
546
|
}
|
|
525
547
|
case 'claim_granted': {
|
|
526
548
|
// Our queued claim reached the head — the lease is now ours.
|
|
549
|
+
this.recordClaim('granted', message.payload ?? {});
|
|
527
550
|
this.emit('claim_granted', message.payload ?? {});
|
|
528
551
|
break;
|
|
529
552
|
}
|
|
530
553
|
case 'claim_lost': {
|
|
531
554
|
// A held/granted claim was taken from us (TTL lapse, revoke).
|
|
555
|
+
this.recordClaim('lost', message.payload ?? {});
|
|
532
556
|
this.emit('claim_lost', message.payload ?? {});
|
|
533
557
|
break;
|
|
534
558
|
}
|
|
@@ -872,6 +896,46 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
872
896
|
}
|
|
873
897
|
return out.length > 0 ? out : undefined;
|
|
874
898
|
}
|
|
899
|
+
/**
|
|
900
|
+
* Single instrumentation point for claim events. Every `claim_*` frame routes
|
|
901
|
+
* through here so a developer debugging a collision gets one consistent trace
|
|
902
|
+
* — a console line AND a structured capture — without each dispatch case
|
|
903
|
+
* re-deriving the row/holder shape. The wire payload is loosely typed
|
|
904
|
+
* (`Record<string, unknown>`), so this is the one place that narrows it into
|
|
905
|
+
* a {@link ClaimEvent}.
|
|
906
|
+
*/
|
|
907
|
+
recordClaim(phase, payload) {
|
|
908
|
+
const str = (v) => typeof v === 'string' ? v : undefined;
|
|
909
|
+
// Targets arrive flat ({ entityType, entityId }) or nested under `target`.
|
|
910
|
+
const target = payload.target && typeof payload.target === 'object'
|
|
911
|
+
? payload.target
|
|
912
|
+
: payload;
|
|
913
|
+
const kind = wireParticipantKindSchema.safeParse(payload.participantKind);
|
|
914
|
+
const event = {
|
|
915
|
+
phase,
|
|
916
|
+
claimId: str(payload.claimId),
|
|
917
|
+
model: str(target.entityType) ?? str(target.model),
|
|
918
|
+
id: str(target.entityId) ?? str(target.id),
|
|
919
|
+
field: str(target.field),
|
|
920
|
+
actor: str(payload.actor) ?? str(payload.heldBy),
|
|
921
|
+
participantKind: kind.success ? kind.data : undefined,
|
|
922
|
+
position: typeof payload.position === 'number' ? payload.position : undefined,
|
|
923
|
+
reason: str(payload.policyReason) ?? str(payload.reason),
|
|
924
|
+
};
|
|
925
|
+
const message = formatClaim(event);
|
|
926
|
+
// A rejection or lost lease is the collision a developer is actively
|
|
927
|
+
// debugging → warn (shows at the default log level). The routine events
|
|
928
|
+
// (acquired/queued/granted/expired) are debug-only so they never drown the
|
|
929
|
+
// console until you opt in with `new Ablo({ debug: true })`.
|
|
930
|
+
const isCollision = phase === 'rejected' || phase === 'lost';
|
|
931
|
+
const ctx = getContext();
|
|
932
|
+
if (isCollision)
|
|
933
|
+
ctx.logger.warn(message);
|
|
934
|
+
else
|
|
935
|
+
ctx.logger.debug(message);
|
|
936
|
+
ctx.observability.breadcrumb(message, 'sync.coordination', isCollision ? 'warning' : 'info');
|
|
937
|
+
ctx.observability.captureClaim(event);
|
|
938
|
+
}
|
|
875
939
|
sendCommit(operations, clientTxId, timeoutMs = 15_000, causedByTaskId, reads) {
|
|
876
940
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
877
941
|
return Promise.reject(this.notConnectedError('commit'));
|
|
@@ -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[]`
|
|
@@ -104,7 +104,9 @@ export function createClaimStream(config, transport = null) {
|
|
|
104
104
|
continue;
|
|
105
105
|
const description = descriptionFromMeta(claim.meta);
|
|
106
106
|
activeByClaimId.set(claim.claimId, {
|
|
107
|
+
object: 'claim',
|
|
107
108
|
id: claim.claimId,
|
|
109
|
+
status: 'active',
|
|
108
110
|
heldBy: event.userId,
|
|
109
111
|
participantKind: participantKindFromWire(event.participantKind, event.isAgent),
|
|
110
112
|
target: {
|
|
@@ -115,10 +117,10 @@ export function createClaimStream(config, transport = null) {
|
|
|
115
117
|
field: claim.field,
|
|
116
118
|
meta: claim.meta,
|
|
117
119
|
},
|
|
118
|
-
reason: claim.
|
|
120
|
+
reason: claim.reason,
|
|
119
121
|
...(description ? { description } : {}),
|
|
120
122
|
ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
|
|
121
|
-
|
|
123
|
+
createdAt: claim.declaredAt,
|
|
122
124
|
expiresAt: claim.expiresAt,
|
|
123
125
|
});
|
|
124
126
|
mutated = true;
|
|
@@ -220,8 +222,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
220
222
|
entityId: claim.entityId,
|
|
221
223
|
path: claim.path,
|
|
222
224
|
range: claim.range,
|
|
223
|
-
|
|
224
|
-
action: claim.reason,
|
|
225
|
+
reason: claim.reason,
|
|
225
226
|
field: claim.field,
|
|
226
227
|
meta: claim.meta,
|
|
227
228
|
estimatedMs: claim.estimatedMs,
|
|
@@ -293,10 +294,11 @@ export function createClaimStream(config, transport = null) {
|
|
|
293
294
|
};
|
|
294
295
|
return {
|
|
295
296
|
object: 'claim',
|
|
296
|
-
claimId,
|
|
297
|
+
id: claimId,
|
|
298
|
+
status: 'active',
|
|
297
299
|
reason: args.reason,
|
|
298
300
|
target: {
|
|
299
|
-
|
|
301
|
+
type: args.entityType,
|
|
300
302
|
id: args.entityId,
|
|
301
303
|
path: args.path,
|
|
302
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
|