@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/Model.d.ts +22 -0
  3. package/dist/Model.js +45 -0
  4. package/dist/ObjectPool.d.ts +14 -1
  5. package/dist/ObjectPool.js +8 -1
  6. package/dist/SyncClient.js +7 -1
  7. package/dist/SyncEngineContext.js +2 -0
  8. package/dist/ai-sdk/coordination-context.d.ts +2 -1
  9. package/dist/ai-sdk/coordination-context.js +3 -3
  10. package/dist/ai-sdk/index.d.ts +3 -4
  11. package/dist/ai-sdk/index.js +2 -3
  12. package/dist/ai-sdk/wrap.d.ts +13 -18
  13. package/dist/ai-sdk/wrap.js +2 -6
  14. package/dist/cli.cjs +253 -160
  15. package/dist/client/Ablo.d.ts +83 -22
  16. package/dist/client/Ablo.js +116 -32
  17. package/dist/client/ApiClient.d.ts +2 -2
  18. package/dist/client/ApiClient.js +27 -32
  19. package/dist/client/auth.d.ts +26 -0
  20. package/dist/client/auth.js +208 -0
  21. package/dist/client/createModelProxy.d.ts +16 -11
  22. package/dist/client/createModelProxy.js +15 -15
  23. package/dist/client/sessionMint.d.ts +10 -0
  24. package/dist/client/sessionMint.js +8 -1
  25. package/dist/coordination/schema.d.ts +10 -10
  26. package/dist/coordination/schema.js +7 -8
  27. package/dist/coordination/trace.d.ts +91 -0
  28. package/dist/coordination/trace.js +147 -0
  29. package/dist/errorCodes.d.ts +2 -0
  30. package/dist/errorCodes.js +2 -0
  31. package/dist/errors.d.ts +1 -2
  32. package/dist/errors.js +6 -9
  33. package/dist/index.d.ts +6 -1
  34. package/dist/index.js +13 -0
  35. package/dist/interfaces/index.d.ts +45 -2
  36. package/dist/mutators/undoApply.d.ts +7 -2
  37. package/dist/mutators/undoApply.js +7 -35
  38. package/dist/policy/types.d.ts +18 -9
  39. package/dist/policy/types.js +26 -16
  40. package/dist/react/AbloProvider.d.ts +2 -2
  41. package/dist/react/useAblo.js +12 -2
  42. package/dist/schema/sugar.js +10 -2
  43. package/dist/server/read-config.d.ts +7 -0
  44. package/dist/sync/HydrationCoordinator.js +7 -3
  45. package/dist/sync/SyncWebSocket.d.ts +11 -2
  46. package/dist/sync/SyncWebSocket.js +67 -3
  47. package/dist/sync/createClaimStream.d.ts +9 -2
  48. package/dist/sync/createClaimStream.js +9 -7
  49. package/dist/sync/participants.d.ts +12 -11
  50. package/dist/sync/participants.js +5 -5
  51. package/dist/transactions/TransactionQueue.d.ts +1 -0
  52. package/dist/transactions/TransactionQueue.js +40 -3
  53. package/dist/types/streams.d.ts +57 -135
  54. package/dist/utils/json.d.ts +39 -0
  55. package/dist/utils/json.js +88 -0
  56. package/docs/coordination.md +16 -0
  57. package/docs/debugging.md +194 -0
  58. package/docs/index.md +1 -0
  59. package/package.json +1 -1
  60. package/dist/ai-sdk/claim-broadcast.d.ts +0 -83
  61. 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 { ActiveClaim, Peer } from '../types/streams.js';
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<ActiveClaim>;
157
+ readonly claims: ReadonlyArray<Claim>;
158
158
  readonly status: ParticipantStatus;
159
159
  readonly error: Error | null;
160
160
  }
@@ -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, modelAsRow } from '../Model.js';
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 modelAsRow(value);
31
+ return value.toReactiveSnapshot();
22
32
  }
23
33
  if (Array.isArray(value)) {
24
34
  return value.map((item) => snapshotValue(item));
@@ -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) => build(shape, opts, { mutable: true, load: 'instant', lazyObservable: false }),
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) => build(shape, opts, { mutable: false, load: 'instant', lazyObservable: false }),
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
- .map((raw) => this.hydrateOne(raw, typename))
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
- * claim claims so peers see them without a separate channel. Wire
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
- action: string;
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: typeof clientTxId === 'string' ? 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, action, field?, estimatedMs? } }`
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, action, field?, estimatedMs? } }`
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.action,
120
+ reason: claim.reason,
119
121
  ...(description ? { description } : {}),
120
122
  ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
121
- announcedAt: claim.declaredAt,
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
- // Wire field stays `action` (coordination schema); source is `reason`.
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
- model: args.entityType,
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 { ActiveClaim, Activity, EntityRef, ClaimHandle, ClaimStream, Peer, PresenceStream, PresenceTarget } from '../types/streams.js';
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 = EntityRef | readonly EntityRef[] | string | readonly string[] | {
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: EntityRef | null;
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: EntityRef | null;
70
- readonly others: ReadonlyArray<ActiveClaim>;
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): ClaimHandle;
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: EntityRef | null;
88
- readonly focusTarget: EntityRef | null;
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<ActiveClaim>;
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: ClaimStream;
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: EntityRef, schema?: Schema<SchemaRecord>): string;
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
- claimId: handle.claimId,
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
- getContext().logger.warn('[TransactionQueue] Batch commit rejected', {
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
- getContext().logger.warn('[TransactionQueue] Permanent error - rolling back', {
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