@abloatai/humans 0.37.0 → 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 +38 -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 +48 -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/dist/core.d.ts CHANGED
@@ -33,3 +33,4 @@ export { createClaimStream, type AttachableClaimStream, type ClaimStreamConfig,
33
33
  export { awaitClaimGrant, type GrantTransport, } from '@abloatai/transaction/coordination/awaitClaimGrant';
34
34
  export { LoadStrategy } from '@abloatai/transaction/types';
35
35
  export type { InternalAbloOptions } from './local/client/options.js';
36
+ export { drainProfileSnapshot, resetDrainProfile, drainProfilingEnabled, type DrainProfile, type DrainStage, type DrainStageTotals, } from './local/sync/drainProfile.js';
package/dist/core.js CHANGED
@@ -46,3 +46,7 @@ export { awaitClaimGrant, } from '@abloatai/transaction/coordination/awaitClaimG
46
46
  // An enum naming the strategies for loading a model's data. Referenced when
47
47
  // registering models in extension code.
48
48
  export { LoadStrategy } from '@abloatai/transaction/types';
49
+ // Stage timings for the delta drain, so a benchmark harness can report where
50
+ // an observer's catch-up time went instead of inferring it. Inert unless
51
+ // `ABLO_PROFILE_DRAIN=true`, and read-only: the pipeline does the recording.
52
+ export { drainProfileSnapshot, resetDrainProfile, drainProfilingEnabled, } from './local/sync/drainProfile.js';
@@ -86,8 +86,10 @@ export interface UserContext {
86
86
  organizationId: string;
87
87
  /** Authenticated data-plane coordinates used to isolate local persistence. */
88
88
  projectId?: string | null;
89
- environment?: 'sandbox' | 'production' | null;
90
- sandboxId?: string | null;
89
+ /** Immutable branch target. Authoritative whenever present. */
90
+ branchId: string;
91
+ /** True only when branchId is the project's production root. */
92
+ branchRoot?: boolean;
91
93
  role?: string;
92
94
  teamIds?: string[];
93
95
  /** Participant kind on the wire. Default 'user' for browser
@@ -142,15 +142,17 @@ defaultWait) {
142
142
  typeof value.release === 'function';
143
143
  const mutationOptions = (params) => {
144
144
  const rest = {
145
- idempotencyKey: params.idempotencyKey,
146
- label: params.label,
147
- wait: params.wait,
148
- readAt: params.readAt,
149
- onStale: params.onStale,
150
- fenceToken: params.fenceToken,
151
- claimRef: params.claimRef,
152
- reads: params.reads,
153
- track: params.track,
145
+ ...(params.idempotencyKey !== undefined
146
+ ? { idempotencyKey: params.idempotencyKey }
147
+ : {}),
148
+ ...(params.label !== undefined ? { label: params.label } : {}),
149
+ ...(params.wait !== undefined ? { wait: params.wait } : {}),
150
+ ...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
151
+ ...(params.onStale !== undefined ? { onStale: params.onStale } : {}),
152
+ ...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
153
+ ...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
154
+ ...(params.reads !== undefined ? { reads: params.reads } : {}),
155
+ ...(params.track !== undefined ? { track: params.track } : {}),
154
156
  };
155
157
  // The write-options schema — the runtime twin of the compile-time params.
156
158
  // Catches plain-JavaScript callers (for example `onStale: 'rejct'`) at the
@@ -312,7 +314,7 @@ defaultWait) {
312
314
  return {
313
315
  object: 'claim',
314
316
  id: lease.id,
315
- readAt: snapshot.stamp,
317
+ readAt: lease.readAt ?? snapshot.stamp,
316
318
  // The fencing token the server minted for this grant, forwarded from the
317
319
  // lease so writes taken under this handle carry it (Option B).
318
320
  ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
@@ -746,7 +748,7 @@ defaultWait) {
746
748
  const effective = claimed
747
749
  ? {
748
750
  wait: 'confirmed',
749
- readAt: claimed.snapshot.stamp,
751
+ readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
750
752
  onStale: 'reject',
751
753
  claimRef: { id: claimed.lease.id },
752
754
  ...opts,
@@ -816,7 +818,7 @@ defaultWait) {
816
818
  const effective = claimed
817
819
  ? {
818
820
  wait: 'confirmed',
819
- readAt: claimed.snapshot.stamp,
821
+ readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
820
822
  onStale: 'reject',
821
823
  claimRef: { id: claimed.lease.id },
822
824
  ...(claimed.lease.fenceToken !== undefined
@@ -434,6 +434,13 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
434
434
  * identity from the token through the identity endpoint instead.
435
435
  */
436
436
  organizationId?: string;
437
+ /**
438
+ * Immutable branch selected by a self-hosted credential. Hosted clients
439
+ * receive this from the credential exchange.
440
+ */
441
+ branchId?: string;
442
+ /** Whether the selected self-hosted branch is the project's root branch. */
443
+ branchRoot?: boolean;
437
444
  /** The client-wide write default — see {@link AbloOptions.wait}. Projected
438
445
  * from the public option rather than restated, so the two cannot diverge. */
439
446
  wait?: AbloOptions['wait'];
@@ -250,7 +250,7 @@ export function buildReactiveEngine(inputs) {
250
250
  }
251
251
  });
252
252
  }
253
- function wrapClaimHandle(claim, waited = false, fenceToken) {
253
+ function wrapClaimHandle(claim, waited = false, fenceToken, readAt) {
254
254
  const release = () => {
255
255
  claim.revoke?.();
256
256
  return Promise.resolve();
@@ -265,6 +265,7 @@ export function buildReactiveEngine(inputs) {
265
265
  description: claim.description,
266
266
  target: claim.target,
267
267
  waited,
268
+ ...(readAt !== undefined ? { readAt } : {}),
268
269
  ...(resolvedFenceToken !== undefined ? { fenceToken: resolvedFenceToken } : {}),
269
270
  release,
270
271
  revoke: claim.revoke,
@@ -293,9 +294,10 @@ export function buildReactiveEngine(inputs) {
293
294
  // holds the lease, never a half-claimed one racing the queue.
294
295
  let waited = false;
295
296
  let fenceToken;
297
+ let readAt;
296
298
  if (claimOptions.queue) {
297
299
  try {
298
- ({ waited, fenceToken } = await awaitClaimGrant(transport, claim.id, {
300
+ ({ waited, fenceToken, readAt } = await awaitClaimGrant(transport, claim.id, {
299
301
  timeoutMs: claimOptions.waitTimeoutMs,
300
302
  maxQueueDepth: claimOptions.maxQueueDepth,
301
303
  signal: claimOptions.signal,
@@ -310,7 +312,7 @@ export function buildReactiveEngine(inputs) {
310
312
  throw err;
311
313
  }
312
314
  }
313
- return wrapClaimHandle(claim, waited, fenceToken);
315
+ return wrapClaimHandle(claim, waited, fenceToken, readAt);
314
316
  },
315
317
  list(target) {
316
318
  return listModelClaims(target);
@@ -107,7 +107,7 @@ export function startStoreLifecycle(deps) {
107
107
  auth: authCredentials,
108
108
  logger,
109
109
  });
110
- const { userId, accountScope, projectId, environment, sandboxId, teamIds, capabilityToken, syncGroups, participantKind, } = resolved;
110
+ const { userId, accountScope, projectId, branchId, branchRoot, teamIds, capabilityToken, syncGroups, participantKind, } = resolved;
111
111
  // Fail-loud guard: detect the degenerate "no real sync groups
112
112
  // resolved" state before opening the socket. It is the same class of bug as
113
113
  // a sensible-looking default that's functionally broken: the
@@ -155,12 +155,15 @@ export function startStoreLifecycle(deps) {
155
155
  // agents default to 'none' (transactional participant — see
156
156
  // option doc) and everyone else defaults to 'full'.
157
157
  const resolvedBootstrapMode = internalOptions.bootstrapMode ?? (participantKind === 'agent' ? 'none' : 'full');
158
+ if (!branchId) {
159
+ throw new AbloConnectionError('The server did not resolve an Ablo branch for this credential.', { code: 'invalid_request' });
160
+ }
158
161
  const gen = store.initialize({
159
162
  userId,
160
163
  organizationId: accountScope,
161
164
  projectId,
162
- environment,
163
- sandboxId,
165
+ branchId,
166
+ branchRoot,
164
167
  teamIds,
165
168
  kind: participantKind,
166
169
  capabilityToken,
@@ -15,8 +15,8 @@ export interface DatabaseInfo {
15
15
  workspaceId: string;
16
16
  participantKind: string;
17
17
  projectId: string | null;
18
- environment: 'sandbox' | 'production' | null;
19
- sandboxId: string | null;
18
+ branchId: string;
19
+ branchRoot: boolean;
20
20
  schemaHash: string;
21
21
  schemaVersion: number;
22
22
  userVersion?: number;
@@ -114,8 +114,8 @@ export class DatabaseManager {
114
114
  workspaceId: identity.organizationId,
115
115
  participantKind: identity.participantKind,
116
116
  projectId: identity.projectId,
117
- environment: identity.environment,
118
- sandboxId: identity.sandboxId,
117
+ branchId: identity.branchId,
118
+ branchRoot: identity.branchRoot,
119
119
  schemaHash,
120
120
  schemaVersion,
121
121
  userVersion,
@@ -1,15 +1,14 @@
1
1
  /**
2
- * The complete authenticated plane that owns one local replica. The same
3
- * participant can have different data in a project, sandbox, or environment;
4
- * those replicas must never share a namespace.
2
+ * The complete authenticated branch that owns one local replica. A branch id
3
+ * is authoritative.
5
4
  */
6
5
  export interface PersistenceIdentity {
7
6
  readonly participantId: string;
8
7
  readonly participantKind: string;
9
8
  readonly organizationId: string;
10
9
  readonly projectId: string | null;
11
- readonly environment: 'sandbox' | 'production' | null;
12
- readonly sandboxId: string | null;
10
+ readonly branchId: string;
11
+ readonly branchRoot: boolean;
13
12
  }
14
13
  export interface PersistedIdentityMetadata {
15
14
  readonly namespaceVersion?: number;
@@ -17,10 +16,10 @@ export interface PersistedIdentityMetadata {
17
16
  readonly workspaceId: string;
18
17
  readonly participantKind?: string;
19
18
  readonly projectId?: string | null;
20
- readonly environment?: 'sandbox' | 'production' | null;
21
- readonly sandboxId?: string | null;
19
+ readonly branchId?: string;
20
+ readonly branchRoot?: boolean;
22
21
  }
23
- export declare const PERSISTENCE_NAMESPACE_VERSION = 2;
22
+ export declare const PERSISTENCE_NAMESPACE_VERSION = 4;
24
23
  /** Collision-resistant IndexedDB name for one authenticated data plane. */
25
24
  export declare function persistenceDatabaseName(identity: PersistenceIdentity, userVersion?: number): Promise<string>;
26
25
  /** Defense-in-depth check after namespace lookup and before persisted reads. */
@@ -1,11 +1,10 @@
1
1
  import { AbloConnectionError } from '@abloatai/transaction/errors';
2
- export const PERSISTENCE_NAMESPACE_VERSION = 2;
2
+ export const PERSISTENCE_NAMESPACE_VERSION = 4;
3
3
  function canonicalIdentity(identity, userVersion) {
4
4
  return JSON.stringify([
5
5
  PERSISTENCE_NAMESPACE_VERSION,
6
6
  identity.projectId,
7
- identity.environment,
8
- identity.sandboxId,
7
+ ['branch', identity.branchId, identity.branchRoot],
9
8
  identity.organizationId,
10
9
  identity.participantKind,
11
10
  identity.participantId,
@@ -33,6 +32,6 @@ export function persistenceIdentityMatches(info, identity) {
33
32
  info.workspaceId === identity.organizationId &&
34
33
  info.participantKind === identity.participantKind &&
35
34
  (info.projectId ?? null) === identity.projectId &&
36
- (info.environment ?? null) === identity.environment &&
37
- (info.sandboxId ?? null) === identity.sandboxId);
35
+ info.branchId === identity.branchId &&
36
+ (info.branchRoot ?? false) === identity.branchRoot);
38
37
  }
@@ -94,7 +94,14 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
94
94
  * and an observability breadcrumb; it is never applied. There is one parse per
95
95
  * delta — callers must not re-parse.
96
96
  */
97
+ /**
98
+ * Wire validation runs once per delta, so at drain scale it is a per-delta
99
+ * fixed cost rather than a payload-proportional one. The guard keeps the
100
+ * normal path free: when profiling is off this is a boolean test and a
101
+ * direct call, with no closure allocated per delta.
102
+ */
97
103
  private normalizeWireDelta;
104
+ private parseWireDelta;
98
105
  /**
99
106
  * Handle incoming sync delta (untrusted wire input — validated and
100
107
  * normalized by {@link normalizeWireDelta}; malformed deltas are dropped).
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { getContext } from '../context.js';
13
13
  import { clientSyncDeltaSchema } from '@abloatai/transaction/wire/delta';
14
+ import { drainProfilingEnabled, observeDrainStage } from './drainProfile.js';
14
15
  import { WsTransport, } from '@abloatai/transaction/transport/wsTransport';
15
16
  import { isRecord } from './wsFrameHandlers.js';
16
17
  // Sync-position state (lastSyncId watermark, version vector, server cursor).
@@ -123,7 +124,24 @@ export class SyncWebSocket extends WsTransport {
123
124
  * and an observability breadcrumb; it is never applied. There is one parse per
124
125
  * delta — callers must not re-parse.
125
126
  */
127
+ /**
128
+ * Wire validation runs once per delta, so at drain scale it is a per-delta
129
+ * fixed cost rather than a payload-proportional one. The guard keeps the
130
+ * normal path free: when profiling is off this is a boolean test and a
131
+ * direct call, with no closure allocated per delta.
132
+ */
126
133
  normalizeWireDelta(raw) {
134
+ if (!drainProfilingEnabled())
135
+ return this.parseWireDelta(raw);
136
+ const startedAt = performance.now();
137
+ try {
138
+ return this.parseWireDelta(raw);
139
+ }
140
+ finally {
141
+ observeDrainStage('parse', performance.now() - startedAt);
142
+ }
143
+ }
144
+ parseWireDelta(raw) {
127
145
  let candidate = raw;
128
146
  if (isRecord(raw)) {
129
147
  const normalized = { ...raw };
@@ -14,6 +14,7 @@ import { runInAction } from 'mobx';
14
14
  import { globalRuntime } from '../context.js';
15
15
  import { ModelScope } from '../InstanceCache.js';
16
16
  import { runStage, pluginsForStage, } from '../../plugin.js';
17
+ import { observeDrainBatch, timeDrainStage, timeDrainStageAsync } from './drainProfile.js';
17
18
  /**
18
19
  * One drain per store. Incoming WebSocket frames may arrive while persistence
19
20
  * and pool application are awaiting. Without a single-flight guard every
@@ -80,6 +81,23 @@ function isSameState(a, b) {
80
81
  }
81
82
  /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
82
83
  export function deduplicateDeltas(ctx, deltas) {
84
+ // The dominant live-publication shape is a frame of independent entity
85
+ // creates. When every entity key occurs once, reconciliation cannot remove
86
+ // or reorder anything: preserve the already commit-ordered input directly
87
+ // and avoid allocating a bucket array, state signature, and two sorts per
88
+ // delta. The first duplicate falls through to the full transition logic.
89
+ const uniqueEntities = new Set();
90
+ let hasDuplicateEntity = false;
91
+ for (const delta of deltas) {
92
+ const key = `${delta.modelName}:${delta.modelId}`;
93
+ if (uniqueEntities.has(key)) {
94
+ hasDuplicateEntity = true;
95
+ break;
96
+ }
97
+ uniqueEntities.add(key);
98
+ }
99
+ if (!hasDuplicateEntity)
100
+ return deltas;
83
101
  const byEntity = new Map();
84
102
  for (const d of deltas) {
85
103
  const key = `${d.modelName}:${d.modelId}`;
@@ -278,7 +296,8 @@ function yieldToHost() {
278
296
  }
279
297
  async function flushDeltaBatch(ctx, queuedDeltas) {
280
298
  const stagePlugins = ctx.stagePlugins ?? [];
281
- const deduplicatedDeltas = ctx.deduplicateDeltas(queuedDeltas);
299
+ const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
300
+ observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
282
301
  runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
283
302
  // Custom entities → apply straight to the pool, skipping the local store.
284
303
  const customDeltas = deduplicatedDeltas.filter((d) => ctx.isCustomEntity(d.modelName));
@@ -315,7 +334,7 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
315
334
  // handleGroupRemoved) and never reach here, though the persistence
316
335
  // signature accepts them defensively.
317
336
  const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
318
- const batch = await ctx.processDeltaBatch(regularDeltas.map((d) => ({
337
+ const batch = await timeDrainStageAsync('persist', () => ctx.processDeltaBatch(regularDeltas.map((d) => ({
319
338
  syncId: d.id,
320
339
  actionType: d.actionType,
321
340
  modelName: d.modelName,
@@ -324,7 +343,7 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
324
343
  // Thread `transactionId` through so the receive layer can recognize
325
344
  // echoes of locally-applied transactions and skip the pool mutation.
326
345
  transactionId: d.transactionId,
327
- })));
346
+ }))));
328
347
  const dbResults = batch.results;
329
348
  runStage(stagePlugins, 'persist', { deltas: regularDeltas });
330
349
  // Apply the batch results to the in-memory graph. When a plugin has
@@ -332,12 +351,14 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
332
351
  // materialiser attached where it said it would. The direct call is the
333
352
  // bridge for stores constructed without plugins (subclasses, tests),
334
353
  // whose own apply is the whole pipeline.
335
- if (pluginsForStage(stagePlugins, 'apply').length > 0) {
336
- runStage(stagePlugins, 'apply', { changes: dbResults });
337
- }
338
- else {
339
- ctx.applyDeltaBatchToPool(dbResults);
340
- }
354
+ timeDrainStage('apply', () => {
355
+ if (pluginsForStage(stagePlugins, 'apply').length > 0) {
356
+ runStage(stagePlugins, 'apply', { changes: dbResults });
357
+ }
358
+ else {
359
+ ctx.applyDeltaBatchToPool(dbResults);
360
+ }
361
+ });
341
362
  // Acknowledge and advance the sync cursor, gated on persistence.
342
363
  //
343
364
  // We must acknowledge `persistedSyncId` — the high-water mark of deltas whose
@@ -348,10 +369,14 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
348
369
  // be lost. The cursor and the persisted state must move together.
349
370
  const persistedSyncId = batch.persistedSyncId;
350
371
  if (persistedSyncId > ctx.lastAckedId) {
351
- ctx.acknowledge(persistedSyncId);
352
- ctx.advancePersisted(persistedSyncId);
353
- runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
372
+ timeDrainStage('acknowledge', () => {
373
+ ctx.acknowledge(persistedSyncId);
374
+ ctx.advancePersisted(persistedSyncId);
375
+ runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
376
+ });
354
377
  }
355
378
  // Cache invalidation happens automatically via the 'models:changed' event.
356
- runStage(stagePlugins, 'notify', { changes: dbResults });
379
+ timeDrainStage('notify', () => {
380
+ runStage(stagePlugins, 'notify', { changes: dbResults });
381
+ });
357
382
  }
@@ -0,0 +1,59 @@
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
+ import type { PipelineStage } from '../../plugin.js';
23
+ /** The pipeline's own stages plus the transport-level wire validation ahead of them. */
24
+ export type DrainStage = 'parse' | PipelineStage;
25
+ export interface DrainStageTotals {
26
+ /** Accumulated wall time attributed to this stage. */
27
+ readonly totalMs: number;
28
+ /** How many times the stage ran. Per-delta for `parse`, per-batch for the rest. */
29
+ readonly calls: number;
30
+ }
31
+ export interface DrainProfile {
32
+ /** Flush batches drained. The per-batch fixed cost multiplies by this. */
33
+ readonly batches: number;
34
+ /** Deltas that reached the pipeline. The per-delta fixed cost multiplies by this. */
35
+ readonly deltas: number;
36
+ /** Deltas dropped by the dedupe stage before persistence. */
37
+ readonly deduplicated: number;
38
+ /** Wall time from the first observed stage to the last. */
39
+ readonly spanMs: number;
40
+ readonly stages: Readonly<Record<DrainStage, DrainStageTotals>>;
41
+ }
42
+ /** Whether drain profiling is on. Callers skip their own bookkeeping when it is not. */
43
+ export declare function drainProfilingEnabled(): boolean;
44
+ /** Attribute already-measured wall time to a stage. */
45
+ export declare function observeDrainStage(stage: DrainStage, elapsedMs: number): void;
46
+ /** Time a synchronous stage. Returns the callback's value untouched. */
47
+ export declare function timeDrainStage<T>(stage: DrainStage, run: () => T): T;
48
+ /** Time an asynchronous stage. Returns the callback's value untouched. */
49
+ export declare function timeDrainStageAsync<T>(stage: DrainStage, run: () => Promise<T>): Promise<T>;
50
+ /**
51
+ * Record one drained batch: how many deltas entered it and how many survived
52
+ * deduplication. Batch count is the multiplier on every per-batch cost, so it
53
+ * is reported alongside the timings rather than derived from them.
54
+ */
55
+ export declare function observeDrainBatch(received: number, survived: number): void;
56
+ /** The totals accumulated since the last reset. */
57
+ export declare function drainProfileSnapshot(): DrainProfile;
58
+ /** Clear the totals so a phase measures only its own traffic. */
59
+ export declare function resetDrainProfile(): void;
@@ -0,0 +1,127 @@
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
+ const DRAIN_STAGES = [
23
+ 'parse',
24
+ 'receive',
25
+ 'dedupe',
26
+ 'persist',
27
+ 'apply',
28
+ 'acknowledge',
29
+ 'notify',
30
+ ];
31
+ function emptyTotals() {
32
+ const totals = {};
33
+ for (const stage of DRAIN_STAGES)
34
+ totals[stage] = { totalMs: 0, calls: 0 };
35
+ return totals;
36
+ }
37
+ let totals = emptyTotals();
38
+ let batches = 0;
39
+ let deltas = 0;
40
+ let deduplicated = 0;
41
+ let firstMark;
42
+ let lastMark = 0;
43
+ /**
44
+ * Read once. A profiler that consults the environment on every delta would
45
+ * itself become a per-delta cost in the path it is measuring.
46
+ */
47
+ const enabled = (() => {
48
+ const host = globalThis;
49
+ return host.process?.env?.ABLO_PROFILE_DRAIN === 'true';
50
+ })();
51
+ /** Whether drain profiling is on. Callers skip their own bookkeeping when it is not. */
52
+ export function drainProfilingEnabled() {
53
+ return enabled;
54
+ }
55
+ function mark(elapsedMs) {
56
+ const now = performance.now();
57
+ firstMark ??= now - elapsedMs;
58
+ lastMark = now;
59
+ }
60
+ /** Attribute already-measured wall time to a stage. */
61
+ export function observeDrainStage(stage, elapsedMs) {
62
+ if (!enabled)
63
+ return;
64
+ const entry = totals[stage];
65
+ entry.totalMs += elapsedMs;
66
+ entry.calls += 1;
67
+ mark(elapsedMs);
68
+ }
69
+ /** Time a synchronous stage. Returns the callback's value untouched. */
70
+ export function timeDrainStage(stage, run) {
71
+ if (!enabled)
72
+ return run();
73
+ const startedAt = performance.now();
74
+ try {
75
+ return run();
76
+ }
77
+ finally {
78
+ observeDrainStage(stage, performance.now() - startedAt);
79
+ }
80
+ }
81
+ /** Time an asynchronous stage. Returns the callback's value untouched. */
82
+ export async function timeDrainStageAsync(stage, run) {
83
+ if (!enabled)
84
+ return run();
85
+ const startedAt = performance.now();
86
+ try {
87
+ return await run();
88
+ }
89
+ finally {
90
+ observeDrainStage(stage, performance.now() - startedAt);
91
+ }
92
+ }
93
+ /**
94
+ * Record one drained batch: how many deltas entered it and how many survived
95
+ * deduplication. Batch count is the multiplier on every per-batch cost, so it
96
+ * is reported alongside the timings rather than derived from them.
97
+ */
98
+ export function observeDrainBatch(received, survived) {
99
+ if (!enabled)
100
+ return;
101
+ batches += 1;
102
+ deltas += received;
103
+ deduplicated += Math.max(0, received - survived);
104
+ }
105
+ /** The totals accumulated since the last reset. */
106
+ export function drainProfileSnapshot() {
107
+ const stages = {};
108
+ for (const stage of DRAIN_STAGES) {
109
+ stages[stage] = { totalMs: totals[stage].totalMs, calls: totals[stage].calls };
110
+ }
111
+ return {
112
+ batches,
113
+ deltas,
114
+ deduplicated,
115
+ spanMs: firstMark === undefined ? 0 : lastMark - firstMark,
116
+ stages,
117
+ };
118
+ }
119
+ /** Clear the totals so a phase measures only its own traffic. */
120
+ export function resetDrainProfile() {
121
+ totals = emptyTotals();
122
+ batches = 0;
123
+ deltas = 0;
124
+ deduplicated = 0;
125
+ firstMark = undefined;
126
+ lastMark = 0;
127
+ }
@@ -13,8 +13,8 @@ export function* initialize(host, context, signal) {
13
13
  organizationId: context.organizationId,
14
14
  participantKind: context.kind ?? 'user',
15
15
  projectId: context.projectId ?? context.organizationId,
16
- environment: context.environment ?? null,
17
- sandboxId: context.sandboxId ?? null,
16
+ branchId: context.branchId,
17
+ branchRoot: context.branchRoot ?? false,
18
18
  });
19
19
  // Propagate identity only after storage is ready, then restore sealed
20
20
  // requests before accepting fresh mutations.
@@ -861,17 +861,18 @@ export class MutationQueue extends EventEmitter {
861
861
  * transaction.
862
862
  */
863
863
  confirmationFor(modelName, modelId) {
864
- const candidates = [
865
- ...this.store.getByStatus('pending'),
866
- ...this.store.getByStatus('executing'),
867
- ...this.store.getByStatus('awaiting_delta'),
868
- ].filter((tx) => tx.modelName === modelName && tx.modelId === modelId);
869
- if (candidates.length === 0)
870
- return Promise.resolve();
871
- const latest = candidates.sort((a, b) => b.createdAt - a.createdAt)[0];
872
- if (!latest)
873
- return Promise.resolve();
874
- return latest.confirmation ?? Promise.resolve();
864
+ const transactions = this.store.getAll();
865
+ for (let index = transactions.length - 1; index >= 0; index--) {
866
+ const transaction = transactions[index];
867
+ if (transaction?.modelName === modelName &&
868
+ transaction.modelId === modelId &&
869
+ (transaction.status === 'pending' ||
870
+ transaction.status === 'executing' ||
871
+ transaction.status === 'awaiting_delta')) {
872
+ return transaction.confirmation ?? Promise.resolve();
873
+ }
874
+ }
875
+ return Promise.resolve();
875
876
  }
876
877
  /**
877
878
  * Attaches a `confirmation` promise to a newly created transaction. Call this
@@ -1046,7 +1047,26 @@ export class MutationQueue extends EventEmitter {
1046
1047
  return this.pendingDrainPromise;
1047
1048
  }
1048
1049
  async drainPendingInternal() {
1049
- await drainPendingSettlements(this.pendingDrainContext);
1050
+ // The normal batch scheduler and the explicit/reconnect drain are two
1051
+ // ways to drive the same durable queue. They must never seal the same
1052
+ // staged source records concurrently: the first seal consumes those
1053
+ // records, so the second would correctly reject them as already claimed.
1054
+ //
1055
+ // `isProcessing` is acquired synchronously before either path awaits,
1056
+ // making it the queue-wide execution lock. If the normal lane already
1057
+ // owns it, that lane will finish the pending work; callers waiting on a
1058
+ // specific confirmation remain attached to the exact transaction.
1059
+ if (this.isProcessing)
1060
+ return;
1061
+ this.isProcessing = true;
1062
+ try {
1063
+ await drainPendingSettlements(this.pendingDrainContext);
1064
+ }
1065
+ finally {
1066
+ this.isProcessing = false;
1067
+ if (this.executionQueue.length > 0)
1068
+ this.scheduleProcessing(true);
1069
+ }
1050
1070
  }
1051
1071
  async create(model, context, writeOptions, sourceMutationId) {
1052
1072
  return createModel(this.modelMutationContext, model, context, writeOptions, sourceMutationId);