@abloatai/humans 0.37.1 → 0.38.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 (35) hide show
  1. package/dist/core.d.ts +1 -0
  2. package/dist/core.js +4 -0
  3. package/dist/local/BaseSyncedStore.d.ts +4 -2
  4. package/dist/local/client/createModelProxy.js +14 -12
  5. package/dist/local/client/options.d.ts +7 -0
  6. package/dist/local/client/reactiveEngine.js +5 -3
  7. package/dist/local/client/storeLifecycle.js +6 -3
  8. package/dist/local/stores/DatabaseManager.d.ts +2 -2
  9. package/dist/local/stores/DatabaseManager.js +2 -2
  10. package/dist/local/stores/persistenceIdentity.d.ts +7 -8
  11. package/dist/local/stores/persistenceIdentity.js +4 -5
  12. package/dist/local/sync/SyncWebSocket.d.ts +7 -0
  13. package/dist/local/sync/SyncWebSocket.js +18 -0
  14. package/dist/local/sync/deltaPipeline.js +21 -13
  15. package/dist/local/sync/drainProfile.d.ts +59 -0
  16. package/dist/local/sync/drainProfile.js +127 -0
  17. package/dist/local/sync/initialize.js +2 -2
  18. package/dist/local/transactions/mutations/MutationQueue.js +32 -12
  19. package/dist/local/transactions/mutations/pendingDrain.d.ts +1 -1
  20. package/dist/local/transactions/mutations/pendingDrain.js +2 -1
  21. package/package.json +2 -2
  22. package/src/core.ts +12 -0
  23. package/src/local/BaseSyncedStore.ts +4 -2
  24. package/src/local/client/createModelProxy.ts +14 -12
  25. package/src/local/client/options.ts +9 -0
  26. package/src/local/client/reactiveEngine.ts +5 -2
  27. package/src/local/client/storeLifecycle.ts +10 -4
  28. package/src/local/stores/DatabaseManager.ts +4 -4
  29. package/src/local/stores/persistenceIdentity.ts +10 -12
  30. package/src/local/sync/SyncWebSocket.ts +17 -0
  31. package/src/local/sync/deltaPipeline.ts +31 -21
  32. package/src/local/sync/drainProfile.ts +164 -0
  33. package/src/local/sync/initialize.ts +2 -2
  34. package/src/local/transactions/mutations/MutationQueue.ts +31 -12
  35. package/src/local/transactions/mutations/pendingDrain.ts +7 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "directory": "packages/humans"
85
85
  },
86
86
  "dependencies": {
87
- "@abloatai/transaction": "^0.37.1",
87
+ "@abloatai/transaction": "^0.38.0",
88
88
  "mobx": "^6.13.7",
89
89
  "uuid": "^11.1.0",
90
90
  "zod": "^4.4.3"
package/src/core.ts CHANGED
@@ -102,3 +102,15 @@ export { LoadStrategy } from '@abloatai/transaction/types';
102
102
  // client around an existing store/provider. Kept on the explicit extension
103
103
  // surface so those packages do not need the private alias package.
104
104
  export type { InternalAbloOptions } from './local/client/options.js';
105
+
106
+ // Stage timings for the delta drain, so a benchmark harness can report where
107
+ // an observer's catch-up time went instead of inferring it. Inert unless
108
+ // `ABLO_PROFILE_DRAIN=true`, and read-only: the pipeline does the recording.
109
+ export {
110
+ drainProfileSnapshot,
111
+ resetDrainProfile,
112
+ drainProfilingEnabled,
113
+ type DrainProfile,
114
+ type DrainStage,
115
+ type DrainStageTotals,
116
+ } from './local/sync/drainProfile.js';
@@ -146,8 +146,10 @@ export interface UserContext {
146
146
  organizationId: string;
147
147
  /** Authenticated data-plane coordinates used to isolate local persistence. */
148
148
  projectId?: string | null;
149
- environment?: 'sandbox' | 'production' | null;
150
- sandboxId?: string | null;
149
+ /** Immutable branch target. Authoritative whenever present. */
150
+ branchId: string;
151
+ /** True only when branchId is the project's production root. */
152
+ branchRoot?: boolean;
151
153
  role?: string;
152
154
  teamIds?: string[];
153
155
  /** Participant kind on the wire. Default 'user' for browser
@@ -521,15 +521,17 @@ export function createModelProxy<T, C>(
521
521
  | ModelDeleteParams<T, C>,
522
522
  ): MutationOptions => {
523
523
  const rest: MutationOptions = {
524
- idempotencyKey: params.idempotencyKey,
525
- label: params.label,
526
- wait: params.wait,
527
- readAt: params.readAt,
528
- onStale: params.onStale,
529
- fenceToken: params.fenceToken,
530
- claimRef: params.claimRef,
531
- reads: params.reads,
532
- track: params.track,
524
+ ...(params.idempotencyKey !== undefined
525
+ ? { idempotencyKey: params.idempotencyKey }
526
+ : {}),
527
+ ...(params.label !== undefined ? { label: params.label } : {}),
528
+ ...(params.wait !== undefined ? { wait: params.wait } : {}),
529
+ ...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
530
+ ...(params.onStale !== undefined ? { onStale: params.onStale } : {}),
531
+ ...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
532
+ ...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
533
+ ...(params.reads !== undefined ? { reads: params.reads } : {}),
534
+ ...(params.track !== undefined ? { track: params.track } : {}),
533
535
  };
534
536
  // The write-options schema — the runtime twin of the compile-time params.
535
537
  // Catches plain-JavaScript callers (for example `onStale: 'rejct'`) at the
@@ -713,7 +715,7 @@ export function createModelProxy<T, C>(
713
715
  return {
714
716
  object: 'claim',
715
717
  id: lease.id,
716
- readAt: snapshot.stamp,
718
+ readAt: lease.readAt ?? snapshot.stamp,
717
719
  // The fencing token the server minted for this grant, forwarded from the
718
720
  // lease so writes taken under this handle carry it (Option B).
719
721
  ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
@@ -1227,7 +1229,7 @@ export function createModelProxy<T, C>(
1227
1229
  const effective: MutationOptions | undefined = claimed
1228
1230
  ? {
1229
1231
  wait: 'confirmed',
1230
- readAt: claimed.snapshot.stamp,
1232
+ readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
1231
1233
  onStale: 'reject',
1232
1234
  claimRef: { id: claimed.lease.id },
1233
1235
  ...opts,
@@ -1311,7 +1313,7 @@ export function createModelProxy<T, C>(
1311
1313
  const effective: MutationOptions | undefined = claimed
1312
1314
  ? {
1313
1315
  wait: 'confirmed',
1314
- readAt: claimed.snapshot.stamp,
1316
+ readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
1315
1317
  onStale: 'reject',
1316
1318
  claimRef: { id: claimed.lease.id },
1317
1319
  ...(claimed.lease.fenceToken !== undefined
@@ -520,6 +520,15 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
520
520
  */
521
521
  organizationId?: string;
522
522
 
523
+ /**
524
+ * Immutable branch selected by a self-hosted credential. Hosted clients
525
+ * receive this from the credential exchange.
526
+ */
527
+ branchId?: string;
528
+
529
+ /** Whether the selected self-hosted branch is the project's root branch. */
530
+ branchRoot?: boolean;
531
+
523
532
  /** The client-wide write default — see {@link AbloOptions.wait}. Projected
524
533
  * from the public option rather than restated, so the two cannot diverge. */
525
534
  wait?: AbloOptions['wait'];
@@ -401,6 +401,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
401
401
  claim: Claim,
402
402
  waited = false,
403
403
  fenceToken?: number,
404
+ readAt?: number,
404
405
  ): Claim {
405
406
  const release = (): Promise<void> => {
406
407
  claim.revoke?.();
@@ -416,6 +417,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
416
417
  description: claim.description,
417
418
  target: claim.target,
418
419
  waited,
420
+ ...(readAt !== undefined ? { readAt } : {}),
419
421
  ...(resolvedFenceToken !== undefined ? { fenceToken: resolvedFenceToken } : {}),
420
422
  release,
421
423
  revoke: claim.revoke,
@@ -448,9 +450,10 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
448
450
  // holds the lease, never a half-claimed one racing the queue.
449
451
  let waited = false;
450
452
  let fenceToken: number | undefined;
453
+ let readAt: number | undefined;
451
454
  if (claimOptions.queue) {
452
455
  try {
453
- ({ waited, fenceToken } = await awaitClaimGrant(transport, claim.id, {
456
+ ({ waited, fenceToken, readAt } = await awaitClaimGrant(transport, claim.id, {
454
457
  timeoutMs: claimOptions.waitTimeoutMs,
455
458
  maxQueueDepth: claimOptions.maxQueueDepth,
456
459
  signal: claimOptions.signal,
@@ -464,7 +467,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
464
467
  throw err;
465
468
  }
466
469
  }
467
- return wrapClaimHandle(claim, waited, fenceToken);
470
+ return wrapClaimHandle(claim, waited, fenceToken, readAt);
468
471
  },
469
472
  list(target?: Partial<ModelTarget>): readonly ModelClaim[] {
470
473
  return listModelClaims(target);
@@ -194,8 +194,8 @@ export function startStoreLifecycle<S extends SchemaRecord>(
194
194
  userId,
195
195
  accountScope,
196
196
  projectId,
197
- environment,
198
- sandboxId,
197
+ branchId,
198
+ branchRoot,
199
199
  teamIds,
200
200
  capabilityToken,
201
201
  syncGroups,
@@ -257,13 +257,19 @@ export function startStoreLifecycle<S extends SchemaRecord>(
257
257
  // option doc) and everyone else defaults to 'full'.
258
258
  const resolvedBootstrapMode: 'full' | 'none' =
259
259
  internalOptions.bootstrapMode ?? (participantKind === 'agent' ? 'none' : 'full');
260
+ if (!branchId) {
261
+ throw new AbloConnectionError(
262
+ 'The server did not resolve an Ablo branch for this credential.',
263
+ { code: 'invalid_request' },
264
+ );
265
+ }
260
266
 
261
267
  const gen = store.initialize({
262
268
  userId,
263
269
  organizationId: accountScope,
264
270
  projectId,
265
- environment,
266
- sandboxId,
271
+ branchId,
272
+ branchRoot,
267
273
  teamIds,
268
274
  kind: participantKind,
269
275
  capabilityToken,
@@ -31,8 +31,8 @@ export interface DatabaseInfo {
31
31
  workspaceId: string;
32
32
  participantKind: string;
33
33
  projectId: string | null;
34
- environment: 'sandbox' | 'production' | null;
35
- sandboxId: string | null;
34
+ branchId: string;
35
+ branchRoot: boolean;
36
36
  schemaHash: string;
37
37
  schemaVersion: number;
38
38
  userVersion?: number;
@@ -173,8 +173,8 @@ export class DatabaseManager {
173
173
  workspaceId: identity.organizationId,
174
174
  participantKind: identity.participantKind,
175
175
  projectId: identity.projectId,
176
- environment: identity.environment,
177
- sandboxId: identity.sandboxId,
176
+ branchId: identity.branchId,
177
+ branchRoot: identity.branchRoot,
178
178
  schemaHash,
179
179
  schemaVersion,
180
180
  userVersion,
@@ -1,17 +1,16 @@
1
1
  import { AbloConnectionError } from '@abloatai/transaction/errors';
2
2
 
3
3
  /**
4
- * The complete authenticated plane that owns one local replica. The same
5
- * participant can have different data in a project, sandbox, or environment;
6
- * those replicas must never share a namespace.
4
+ * The complete authenticated branch that owns one local replica. A branch id
5
+ * is authoritative.
7
6
  */
8
7
  export interface PersistenceIdentity {
9
8
  readonly participantId: string;
10
9
  readonly participantKind: string;
11
10
  readonly organizationId: string;
12
11
  readonly projectId: string | null;
13
- readonly environment: 'sandbox' | 'production' | null;
14
- readonly sandboxId: string | null;
12
+ readonly branchId: string;
13
+ readonly branchRoot: boolean;
15
14
  }
16
15
 
17
16
  export interface PersistedIdentityMetadata {
@@ -20,11 +19,11 @@ export interface PersistedIdentityMetadata {
20
19
  readonly workspaceId: string;
21
20
  readonly participantKind?: string;
22
21
  readonly projectId?: string | null;
23
- readonly environment?: 'sandbox' | 'production' | null;
24
- readonly sandboxId?: string | null;
22
+ readonly branchId?: string;
23
+ readonly branchRoot?: boolean;
25
24
  }
26
25
 
27
- export const PERSISTENCE_NAMESPACE_VERSION = 2;
26
+ export const PERSISTENCE_NAMESPACE_VERSION = 4;
28
27
 
29
28
  function canonicalIdentity(
30
29
  identity: PersistenceIdentity,
@@ -33,8 +32,7 @@ function canonicalIdentity(
33
32
  return JSON.stringify([
34
33
  PERSISTENCE_NAMESPACE_VERSION,
35
34
  identity.projectId,
36
- identity.environment,
37
- identity.sandboxId,
35
+ ['branch', identity.branchId, identity.branchRoot],
38
36
  identity.organizationId,
39
37
  identity.participantKind,
40
38
  identity.participantId,
@@ -77,7 +75,7 @@ export function persistenceIdentityMatches(
77
75
  info.workspaceId === identity.organizationId &&
78
76
  info.participantKind === identity.participantKind &&
79
77
  (info.projectId ?? null) === identity.projectId &&
80
- (info.environment ?? null) === identity.environment &&
81
- (info.sandboxId ?? null) === identity.sandboxId
78
+ info.branchId === identity.branchId &&
79
+ (info.branchRoot ?? false) === identity.branchRoot
82
80
  );
83
81
  }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { getContext } from '../context.js';
14
14
  import { clientSyncDeltaSchema, type ClientSyncDelta } from '@abloatai/transaction/wire/delta';
15
+ import { drainProfilingEnabled, observeDrainStage } from './drainProfile.js';
15
16
  import {
16
17
  WsTransport,
17
18
  type WsTransportOptions,
@@ -212,7 +213,23 @@ export class SyncWebSocket<
212
213
  * and an observability breadcrumb; it is never applied. There is one parse per
213
214
  * delta — callers must not re-parse.
214
215
  */
216
+ /**
217
+ * Wire validation runs once per delta, so at drain scale it is a per-delta
218
+ * fixed cost rather than a payload-proportional one. The guard keeps the
219
+ * normal path free: when profiling is off this is a boolean test and a
220
+ * direct call, with no closure allocated per delta.
221
+ */
215
222
  private normalizeWireDelta(raw: unknown): SyncDelta | null {
223
+ if (!drainProfilingEnabled()) return this.parseWireDelta(raw);
224
+ const startedAt = performance.now();
225
+ try {
226
+ return this.parseWireDelta(raw);
227
+ } finally {
228
+ observeDrainStage('parse', performance.now() - startedAt);
229
+ }
230
+ }
231
+
232
+ private parseWireDelta(raw: unknown): SyncDelta | null {
216
233
  let candidate: unknown = raw;
217
234
  if (isRecord(raw)) {
218
235
  const normalized: Record<string, unknown> = { ...raw };
@@ -24,6 +24,7 @@ import {
24
24
  type AbloPlugin,
25
25
  type AppliedChange,
26
26
  } from '../../plugin.js';
27
+ import { observeDrainBatch, timeDrainStage, timeDrainStageAsync } from './drainProfile.js';
27
28
 
28
29
  /**
29
30
  * What the pipeline needs back from the surrounding store: the shared mutable
@@ -409,7 +410,8 @@ async function flushDeltaBatch(
409
410
  queuedDeltas: SyncDelta[],
410
411
  ): Promise<void> {
411
412
  const stagePlugins = ctx.stagePlugins ?? [];
412
- const deduplicatedDeltas = ctx.deduplicateDeltas(queuedDeltas);
413
+ const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
414
+ observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
413
415
  runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
414
416
 
415
417
  // Custom entities → apply straight to the pool, skipping the local store.
@@ -444,17 +446,19 @@ async function flushDeltaBatch(
444
446
  // handleGroupRemoved) and never reach here, though the persistence
445
447
  // signature accepts them defensively.
446
448
  const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
447
- const batch = await ctx.processDeltaBatch(
448
- regularDeltas.map((d) => ({
449
- syncId: d.id,
450
- actionType: d.actionType,
451
- modelName: d.modelName,
452
- modelId: d.modelId,
453
- data: typeof d.data === 'string' ? JSON.parse(d.data) : d.data,
454
- // Thread `transactionId` through so the receive layer can recognize
455
- // echoes of locally-applied transactions and skip the pool mutation.
456
- transactionId: d.transactionId,
457
- }))
449
+ const batch = await timeDrainStageAsync('persist', () =>
450
+ ctx.processDeltaBatch(
451
+ regularDeltas.map((d) => ({
452
+ syncId: d.id,
453
+ actionType: d.actionType,
454
+ modelName: d.modelName,
455
+ modelId: d.modelId,
456
+ data: typeof d.data === 'string' ? JSON.parse(d.data) : d.data,
457
+ // Thread `transactionId` through so the receive layer can recognize
458
+ // echoes of locally-applied transactions and skip the pool mutation.
459
+ transactionId: d.transactionId,
460
+ }))
461
+ )
458
462
  );
459
463
  const dbResults = batch.results;
460
464
  runStage(stagePlugins, 'persist', { deltas: regularDeltas });
@@ -464,11 +468,13 @@ async function flushDeltaBatch(
464
468
  // materialiser attached where it said it would. The direct call is the
465
469
  // bridge for stores constructed without plugins (subclasses, tests),
466
470
  // whose own apply is the whole pipeline.
467
- if (pluginsForStage(stagePlugins, 'apply').length > 0) {
468
- runStage(stagePlugins, 'apply', { changes: dbResults });
469
- } else {
470
- ctx.applyDeltaBatchToPool(dbResults);
471
- }
471
+ timeDrainStage('apply', () => {
472
+ if (pluginsForStage(stagePlugins, 'apply').length > 0) {
473
+ runStage(stagePlugins, 'apply', { changes: dbResults });
474
+ } else {
475
+ ctx.applyDeltaBatchToPool(dbResults);
476
+ }
477
+ });
472
478
 
473
479
  // Acknowledge and advance the sync cursor, gated on persistence.
474
480
  //
@@ -480,11 +486,15 @@ async function flushDeltaBatch(
480
486
  // be lost. The cursor and the persisted state must move together.
481
487
  const persistedSyncId = batch.persistedSyncId;
482
488
  if (persistedSyncId > ctx.lastAckedId) {
483
- ctx.acknowledge(persistedSyncId);
484
- ctx.advancePersisted(persistedSyncId);
485
- runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
489
+ timeDrainStage('acknowledge', () => {
490
+ ctx.acknowledge(persistedSyncId);
491
+ ctx.advancePersisted(persistedSyncId);
492
+ runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
493
+ });
486
494
  }
487
495
 
488
496
  // Cache invalidation happens automatically via the 'models:changed' event.
489
- runStage(stagePlugins, 'notify', { changes: dbResults });
497
+ timeDrainStage('notify', () => {
498
+ runStage(stagePlugins, 'notify', { changes: dbResults });
499
+ });
490
500
  }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Where the drain's seconds go.
3
+ *
4
+ * A commit's receipt is confirmed the moment its PostgreSQL transaction
5
+ * commits, but an observer is not caught up until it has applied the last
6
+ * delta. Server publication p95 is single-digit milliseconds while the final
7
+ * observer takes seconds, so the gap is client-side and has never been
8
+ * attributed to a stage. Two fixes aimed at wire bytes (project filtering,
9
+ * patch-only UPDATE delivery) each returned well under a third, which is
10
+ * evidence the dominant term is a per-delta or per-batch fixed cost rather
11
+ * than payload size.
12
+ *
13
+ * This times the stages a delta actually passes through so a benchmark run can
14
+ * state the attribution instead of inferring it. It is off unless
15
+ * `ABLO_PROFILE_DRAIN=true`, and every entry point returns before doing work
16
+ * when off, mirroring the server's commit profiler.
17
+ *
18
+ * The stage vocabulary derives from {@link PipelineStage}; `parse` is the one
19
+ * addition, because wire validation happens in the transport before a delta
20
+ * reaches the pipeline at all.
21
+ */
22
+
23
+ import type { PipelineStage } from '../../plugin.js';
24
+
25
+ /** The pipeline's own stages plus the transport-level wire validation ahead of them. */
26
+ export type DrainStage = 'parse' | PipelineStage;
27
+
28
+ export interface DrainStageTotals {
29
+ /** Accumulated wall time attributed to this stage. */
30
+ readonly totalMs: number;
31
+ /** How many times the stage ran. Per-delta for `parse`, per-batch for the rest. */
32
+ readonly calls: number;
33
+ }
34
+
35
+ export interface DrainProfile {
36
+ /** Flush batches drained. The per-batch fixed cost multiplies by this. */
37
+ readonly batches: number;
38
+ /** Deltas that reached the pipeline. The per-delta fixed cost multiplies by this. */
39
+ readonly deltas: number;
40
+ /** Deltas dropped by the dedupe stage before persistence. */
41
+ readonly deduplicated: number;
42
+ /** Wall time from the first observed stage to the last. */
43
+ readonly spanMs: number;
44
+ readonly stages: Readonly<Record<DrainStage, DrainStageTotals>>;
45
+ }
46
+
47
+ const DRAIN_STAGES: readonly DrainStage[] = [
48
+ 'parse',
49
+ 'receive',
50
+ 'dedupe',
51
+ 'persist',
52
+ 'apply',
53
+ 'acknowledge',
54
+ 'notify',
55
+ ];
56
+
57
+ interface MutableTotals {
58
+ totalMs: number;
59
+ calls: number;
60
+ }
61
+
62
+ function emptyTotals(): Record<DrainStage, MutableTotals> {
63
+ const totals = {} as Record<DrainStage, MutableTotals>;
64
+ for (const stage of DRAIN_STAGES) totals[stage] = { totalMs: 0, calls: 0 };
65
+ return totals;
66
+ }
67
+
68
+ let totals = emptyTotals();
69
+ let batches = 0;
70
+ let deltas = 0;
71
+ let deduplicated = 0;
72
+ let firstMark: number | undefined;
73
+ let lastMark = 0;
74
+
75
+ /**
76
+ * Read once. A profiler that consults the environment on every delta would
77
+ * itself become a per-delta cost in the path it is measuring.
78
+ */
79
+ const enabled: boolean = (() => {
80
+ const host = globalThis as { process?: { env?: Record<string, string | undefined> } };
81
+ return host.process?.env?.ABLO_PROFILE_DRAIN === 'true';
82
+ })();
83
+
84
+ /** Whether drain profiling is on. Callers skip their own bookkeeping when it is not. */
85
+ export function drainProfilingEnabled(): boolean {
86
+ return enabled;
87
+ }
88
+
89
+ function mark(elapsedMs: number): void {
90
+ const now = performance.now();
91
+ firstMark ??= now - elapsedMs;
92
+ lastMark = now;
93
+ }
94
+
95
+ /** Attribute already-measured wall time to a stage. */
96
+ export function observeDrainStage(stage: DrainStage, elapsedMs: number): void {
97
+ if (!enabled) return;
98
+ const entry = totals[stage];
99
+ entry.totalMs += elapsedMs;
100
+ entry.calls += 1;
101
+ mark(elapsedMs);
102
+ }
103
+
104
+ /** Time a synchronous stage. Returns the callback's value untouched. */
105
+ export function timeDrainStage<T>(stage: DrainStage, run: () => T): T {
106
+ if (!enabled) return run();
107
+ const startedAt = performance.now();
108
+ try {
109
+ return run();
110
+ } finally {
111
+ observeDrainStage(stage, performance.now() - startedAt);
112
+ }
113
+ }
114
+
115
+ /** Time an asynchronous stage. Returns the callback's value untouched. */
116
+ export async function timeDrainStageAsync<T>(
117
+ stage: DrainStage,
118
+ run: () => Promise<T>,
119
+ ): Promise<T> {
120
+ if (!enabled) return run();
121
+ const startedAt = performance.now();
122
+ try {
123
+ return await run();
124
+ } finally {
125
+ observeDrainStage(stage, performance.now() - startedAt);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Record one drained batch: how many deltas entered it and how many survived
131
+ * deduplication. Batch count is the multiplier on every per-batch cost, so it
132
+ * is reported alongside the timings rather than derived from them.
133
+ */
134
+ export function observeDrainBatch(received: number, survived: number): void {
135
+ if (!enabled) return;
136
+ batches += 1;
137
+ deltas += received;
138
+ deduplicated += Math.max(0, received - survived);
139
+ }
140
+
141
+ /** The totals accumulated since the last reset. */
142
+ export function drainProfileSnapshot(): DrainProfile {
143
+ const stages = {} as Record<DrainStage, DrainStageTotals>;
144
+ for (const stage of DRAIN_STAGES) {
145
+ stages[stage] = { totalMs: totals[stage].totalMs, calls: totals[stage].calls };
146
+ }
147
+ return {
148
+ batches,
149
+ deltas,
150
+ deduplicated,
151
+ spanMs: firstMark === undefined ? 0 : lastMark - firstMark,
152
+ stages,
153
+ };
154
+ }
155
+
156
+ /** Clear the totals so a phase measures only its own traffic. */
157
+ export function resetDrainProfile(): void {
158
+ totals = emptyTotals();
159
+ batches = 0;
160
+ deltas = 0;
161
+ deduplicated = 0;
162
+ firstMark = undefined;
163
+ lastMark = 0;
164
+ }
@@ -54,8 +54,8 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
54
54
  organizationId: context.organizationId,
55
55
  participantKind: context.kind ?? 'user',
56
56
  projectId: context.projectId ?? context.organizationId,
57
- environment: context.environment ?? null,
58
- sandboxId: context.sandboxId ?? null,
57
+ branchId: context.branchId,
58
+ branchRoot: context.branchRoot ?? false,
59
59
  });
60
60
 
61
61
  // Propagate identity only after storage is ready, then restore sealed
@@ -1222,17 +1222,20 @@ export class MutationQueue extends EventEmitter {
1222
1222
  * transaction.
1223
1223
  */
1224
1224
  confirmationFor(modelName: string, modelId: string): Promise<void> {
1225
- const candidates = [
1226
- ...this.store.getByStatus('pending'),
1227
- ...this.store.getByStatus('executing'),
1228
- ...this.store.getByStatus('awaiting_delta'),
1229
- ].filter(
1230
- (tx) => tx.modelName === modelName && tx.modelId === modelId,
1231
- );
1232
- if (candidates.length === 0) return Promise.resolve();
1233
- const latest = candidates.sort((a, b) => b.createdAt - a.createdAt)[0];
1234
- if (!latest) return Promise.resolve();
1235
- return latest.confirmation ?? Promise.resolve();
1225
+ const transactions = this.store.getAll();
1226
+ for (let index = transactions.length - 1; index >= 0; index--) {
1227
+ const transaction = transactions[index];
1228
+ if (
1229
+ transaction?.modelName === modelName &&
1230
+ transaction.modelId === modelId &&
1231
+ (transaction.status === 'pending' ||
1232
+ transaction.status === 'executing' ||
1233
+ transaction.status === 'awaiting_delta')
1234
+ ) {
1235
+ return transaction.confirmation ?? Promise.resolve();
1236
+ }
1237
+ }
1238
+ return Promise.resolve();
1236
1239
  }
1237
1240
 
1238
1241
  /**
@@ -1425,7 +1428,23 @@ export class MutationQueue extends EventEmitter {
1425
1428
  }
1426
1429
 
1427
1430
  private async drainPendingInternal(): Promise<void> {
1428
- await drainPendingSettlements(this.pendingDrainContext);
1431
+ // The normal batch scheduler and the explicit/reconnect drain are two
1432
+ // ways to drive the same durable queue. They must never seal the same
1433
+ // staged source records concurrently: the first seal consumes those
1434
+ // records, so the second would correctly reject them as already claimed.
1435
+ //
1436
+ // `isProcessing` is acquired synchronously before either path awaits,
1437
+ // making it the queue-wide execution lock. If the normal lane already
1438
+ // owns it, that lane will finish the pending work; callers waiting on a
1439
+ // specific confirmation remain attached to the exact transaction.
1440
+ if (this.isProcessing) return;
1441
+ this.isProcessing = true;
1442
+ try {
1443
+ await drainPendingSettlements(this.pendingDrainContext);
1444
+ } finally {
1445
+ this.isProcessing = false;
1446
+ if (this.executionQueue.length > 0) this.scheduleProcessing(true);
1447
+ }
1429
1448
  }
1430
1449
  async create(
1431
1450
  model: LocalModel,
@@ -10,7 +10,7 @@ export interface PendingDrainContext {
10
10
  readonly runtime: RuntimeContext;
11
11
  readonly config: { deltaConfirmationTimeout: number };
12
12
  readonly store: MutationStore;
13
- executionQueue: QueuedMutation[];
13
+ readonly executionQueue: QueuedMutation[];
14
14
  readonly optimisticUpdates: Map<string, OptimisticUpdateEntry>;
15
15
  readonly assertDurableReplayOpen: () => void;
16
16
  readonly processCommitLane: () => Promise<void>;
@@ -45,9 +45,14 @@ export async function drainPendingSettlements(ctx: PendingDrainContext): Promise
45
45
  // These rows may already be waiting behind the normal batch timer. The
46
46
  // reconnect fast path takes ownership of them for this attempt so the same
47
47
  // transaction cannot dispatch concurrently through both paths.
48
- ctx.executionQueue = ctx.executionQueue.filter(
48
+ const retainedQueue = ctx.executionQueue.filter(
49
49
  (tx) => !pendingIds.has(tx.id),
50
50
  );
51
+ ctx.executionQueue.splice(
52
+ 0,
53
+ ctx.executionQueue.length,
54
+ ...retainedQueue,
55
+ );
51
56
 
52
57
  const remaining = [...pending];
53
58
  while (remaining.length > 0) {