@abloatai/humans 0.37.1 → 0.39.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 (48) 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/BaseSyncedStore.js +7 -1
  5. package/dist/local/Database.d.ts +20 -0
  6. package/dist/local/Database.js +83 -49
  7. package/dist/local/InstanceCache.d.ts +18 -8
  8. package/dist/local/InstanceCache.js +74 -74
  9. package/dist/local/Model.d.ts +18 -0
  10. package/dist/local/Model.js +83 -32
  11. package/dist/local/SyncClient.d.ts +1 -4
  12. package/dist/local/SyncClient.js +55 -60
  13. package/dist/local/client/createModelProxy.js +14 -12
  14. package/dist/local/client/options.d.ts +7 -0
  15. package/dist/local/client/reactiveEngine.js +23 -3
  16. package/dist/local/client/storeLifecycle.js +6 -3
  17. package/dist/local/stores/DatabaseManager.d.ts +2 -2
  18. package/dist/local/stores/DatabaseManager.js +2 -2
  19. package/dist/local/stores/persistenceIdentity.d.ts +7 -8
  20. package/dist/local/stores/persistenceIdentity.js +4 -5
  21. package/dist/local/sync/SyncWebSocket.d.ts +7 -0
  22. package/dist/local/sync/SyncWebSocket.js +21 -6
  23. package/dist/local/sync/deltaPipeline.js +31 -13
  24. package/dist/local/sync/drainProfile.d.ts +104 -0
  25. package/dist/local/sync/drainProfile.js +182 -0
  26. package/dist/local/sync/initialize.js +2 -2
  27. package/dist/local/transactions/mutations/MutationQueue.js +32 -12
  28. package/dist/local/transactions/mutations/pendingDrain.d.ts +1 -1
  29. package/dist/local/transactions/mutations/pendingDrain.js +2 -1
  30. package/package.json +2 -2
  31. package/src/core.ts +15 -0
  32. package/src/local/BaseSyncedStore.ts +11 -3
  33. package/src/local/Database.ts +87 -50
  34. package/src/local/InstanceCache.ts +77 -71
  35. package/src/local/Model.ts +98 -37
  36. package/src/local/SyncClient.ts +57 -61
  37. package/src/local/client/createModelProxy.ts +14 -12
  38. package/src/local/client/options.ts +9 -0
  39. package/src/local/client/reactiveEngine.ts +23 -2
  40. package/src/local/client/storeLifecycle.ts +10 -4
  41. package/src/local/stores/DatabaseManager.ts +4 -4
  42. package/src/local/stores/persistenceIdentity.ts +10 -12
  43. package/src/local/sync/SyncWebSocket.ts +20 -6
  44. package/src/local/sync/deltaPipeline.ts +51 -21
  45. package/src/local/sync/drainProfile.ts +257 -0
  46. package/src/local/sync/initialize.ts +2 -2
  47. package/src/local/transactions/mutations/MutationQueue.ts +31 -12
  48. package/src/local/transactions/mutations/pendingDrain.ts +7 -2
@@ -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 };
@@ -160,12 +178,9 @@ export class SyncWebSocket extends WsTransport {
160
178
  const delta = this.normalizeWireDelta(rawDelta);
161
179
  if (!delta)
162
180
  return;
163
- getContext().logger.debug('Received delta', {
164
- action: delta.actionType,
165
- model: delta.modelName,
166
- id: delta.modelId,
167
- syncId: delta.id,
168
- });
181
+ // No per-delta debug here: the payload object is built even when the
182
+ // logger discards it, and this runs at the full live wire rate. Dropped
183
+ // malformed deltas are still logged by `normalizeWireDelta`.
169
184
  // Do not advance `this.cursor.lastSyncId` on receipt. The runtime cursor
170
185
  // must stay consistent with what has been persisted locally; otherwise the
171
186
  // next `requestIncrementalSync()` (and the connect-time handshake) would
@@ -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, observeDrainAcknowledge, timeDrainStage, timeDrainStageAsync, openDrainBatchRow, closeDrainBatchRow, } 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
@@ -294,8 +295,18 @@ function yieldToHost() {
294
295
  });
295
296
  }
296
297
  async function flushDeltaBatch(ctx, queuedDeltas) {
298
+ openDrainBatchRow(queuedDeltas.length);
299
+ try {
300
+ await flushDeltaBatchInner(ctx, queuedDeltas);
301
+ }
302
+ finally {
303
+ closeDrainBatchRow();
304
+ }
305
+ }
306
+ async function flushDeltaBatchInner(ctx, queuedDeltas) {
297
307
  const stagePlugins = ctx.stagePlugins ?? [];
298
- const deduplicatedDeltas = ctx.deduplicateDeltas(queuedDeltas);
308
+ const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
309
+ observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
299
310
  runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
300
311
  // Custom entities → apply straight to the pool, skipping the local store.
301
312
  const customDeltas = deduplicatedDeltas.filter((d) => ctx.isCustomEntity(d.modelName));
@@ -332,7 +343,7 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
332
343
  // handleGroupRemoved) and never reach here, though the persistence
333
344
  // signature accepts them defensively.
334
345
  const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
335
- const batch = await ctx.processDeltaBatch(regularDeltas.map((d) => ({
346
+ const batch = await timeDrainStageAsync('persist', () => ctx.processDeltaBatch(regularDeltas.map((d) => ({
336
347
  syncId: d.id,
337
348
  actionType: d.actionType,
338
349
  modelName: d.modelName,
@@ -341,7 +352,7 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
341
352
  // Thread `transactionId` through so the receive layer can recognize
342
353
  // echoes of locally-applied transactions and skip the pool mutation.
343
354
  transactionId: d.transactionId,
344
- })));
355
+ }))));
345
356
  const dbResults = batch.results;
346
357
  runStage(stagePlugins, 'persist', { deltas: regularDeltas });
347
358
  // Apply the batch results to the in-memory graph. When a plugin has
@@ -349,12 +360,14 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
349
360
  // materialiser attached where it said it would. The direct call is the
350
361
  // bridge for stores constructed without plugins (subclasses, tests),
351
362
  // whose own apply is the whole pipeline.
352
- if (pluginsForStage(stagePlugins, 'apply').length > 0) {
353
- runStage(stagePlugins, 'apply', { changes: dbResults });
354
- }
355
- else {
356
- ctx.applyDeltaBatchToPool(dbResults);
357
- }
363
+ timeDrainStage('apply', () => {
364
+ if (pluginsForStage(stagePlugins, 'apply').length > 0) {
365
+ runStage(stagePlugins, 'apply', { changes: dbResults });
366
+ }
367
+ else {
368
+ ctx.applyDeltaBatchToPool(dbResults);
369
+ }
370
+ });
358
371
  // Acknowledge and advance the sync cursor, gated on persistence.
359
372
  //
360
373
  // We must acknowledge `persistedSyncId` — the high-water mark of deltas whose
@@ -365,10 +378,15 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
365
378
  // be lost. The cursor and the persisted state must move together.
366
379
  const persistedSyncId = batch.persistedSyncId;
367
380
  if (persistedSyncId > ctx.lastAckedId) {
368
- ctx.acknowledge(persistedSyncId);
369
- ctx.advancePersisted(persistedSyncId);
370
- runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
381
+ timeDrainStage('acknowledge', () => {
382
+ ctx.acknowledge(persistedSyncId);
383
+ ctx.advancePersisted(persistedSyncId);
384
+ observeDrainAcknowledge(persistedSyncId);
385
+ runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
386
+ });
371
387
  }
372
388
  // Cache invalidation happens automatically via the 'models:changed' event.
373
- runStage(stagePlugins, 'notify', { changes: dbResults });
389
+ timeDrainStage('notify', () => {
390
+ runStage(stagePlugins, 'notify', { changes: dbResults });
391
+ });
374
392
  }
@@ -0,0 +1,104 @@
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
+ /**
32
+ * One flush batch on the wall clock. Wall time (`Date.now`) rather than
33
+ * `performance.now`, because rows cross the worker boundary and each thread
34
+ * has its own `performance` origin — the drain-tail stamps learned the same
35
+ * lesson. Stage entries are the batch's own share of each pipeline stage.
36
+ */
37
+ export interface DrainBatchRow {
38
+ readonly startedAtWallMs: number;
39
+ readonly endedAtWallMs: number;
40
+ readonly deltas: number;
41
+ readonly stages: Readonly<Partial<Record<DrainStage, number>>>;
42
+ }
43
+ export interface DrainProfile {
44
+ /** Flush batches drained. The per-batch fixed cost multiplies by this. */
45
+ readonly batches: number;
46
+ /** Deltas that reached the pipeline. The per-delta fixed cost multiplies by this. */
47
+ readonly deltas: number;
48
+ /** Deltas dropped by the dedupe stage before persistence. */
49
+ readonly deduplicated: number;
50
+ /** Wall time from the first observed stage to the last. */
51
+ readonly spanMs: number;
52
+ readonly stages: Readonly<Record<DrainStage, DrainStageTotals>>;
53
+ /**
54
+ * The most recent flush batches, oldest first, capped — enough to cover a
55
+ * drain tail. Optional because derived profiles (window subtraction, fleet
56
+ * merges) drop it; only a worker's own snapshot carries rows.
57
+ */
58
+ readonly recentBatches?: readonly DrainBatchRow[];
59
+ }
60
+ /**
61
+ * Wall-stamped persisted-cursor advances, oldest first. The benchmark's drain
62
+ * gate reads THESE rather than observing the cursor from a timer or a
63
+ * cross-thread poll: any observation that has to be scheduled onto the
64
+ * worker's event loop queues behind the very drain burst it is measuring and
65
+ * reports the queue's latency as drain. A stamp taken synchronously inside
66
+ * the acknowledge stage cannot be deferred by anything.
67
+ */
68
+ export interface AcknowledgeStamp {
69
+ readonly syncId: number;
70
+ readonly atWallMs: number;
71
+ }
72
+ /**
73
+ * Record a persisted-cursor advance. Called by the pipeline's acknowledge
74
+ * stage. Unlike every stage timer here, this is NOT gated on the profiler
75
+ * flag: it is one wall-clock read and one bounded push per flush batch —
76
+ * nothing against the batch's own work — and the certification benchmark
77
+ * runs unprofiled (the profiler costs ~15%), so the honest drain stamp must
78
+ * exist without it.
79
+ */
80
+ export declare function observeDrainAcknowledge(syncId: number): void;
81
+ /** The recorded persisted-advance stamps, oldest first. */
82
+ export declare function drainAcknowledgeStamps(): readonly AcknowledgeStamp[];
83
+ /** Begin a batch row. Called by the pipeline at flush entry when profiling. */
84
+ export declare function openDrainBatchRow(deltaCount: number): void;
85
+ /** Close the open batch row and commit it to the ring. */
86
+ export declare function closeDrainBatchRow(): void;
87
+ /** Whether drain profiling is on. Callers skip their own bookkeeping when it is not. */
88
+ export declare function drainProfilingEnabled(): boolean;
89
+ /** Attribute already-measured wall time to a stage. */
90
+ export declare function observeDrainStage(stage: DrainStage, elapsedMs: number): void;
91
+ /** Time a synchronous stage. Returns the callback's value untouched. */
92
+ export declare function timeDrainStage<T>(stage: DrainStage, run: () => T): T;
93
+ /** Time an asynchronous stage. Returns the callback's value untouched. */
94
+ export declare function timeDrainStageAsync<T>(stage: DrainStage, run: () => Promise<T>): Promise<T>;
95
+ /**
96
+ * Record one drained batch: how many deltas entered it and how many survived
97
+ * deduplication. Batch count is the multiplier on every per-batch cost, so it
98
+ * is reported alongside the timings rather than derived from them.
99
+ */
100
+ export declare function observeDrainBatch(received: number, survived: number): void;
101
+ /** The totals accumulated since the last reset. */
102
+ export declare function drainProfileSnapshot(): DrainProfile;
103
+ /** Clear the totals so a phase measures only its own traffic. */
104
+ export declare function resetDrainProfile(): void;
@@ -0,0 +1,182 @@
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
+ /** Ring of recent batch rows. ~50 batches/sec at benchmark rates, so this covers seconds of tail. */
44
+ const BATCH_ROW_CAP = 128;
45
+ let batchRows = [];
46
+ /**
47
+ * The batch currently being flushed. Module-global like the totals above, so
48
+ * an isolate hosting several stores attributes interleaved awaits to whichever
49
+ * batch is open — the same per-isolate approximation the totals already make.
50
+ */
51
+ let currentRow = null;
52
+ const ACK_STAMP_CAP = 512;
53
+ let ackStamps = [];
54
+ /**
55
+ * Record a persisted-cursor advance. Called by the pipeline's acknowledge
56
+ * stage. Unlike every stage timer here, this is NOT gated on the profiler
57
+ * flag: it is one wall-clock read and one bounded push per flush batch —
58
+ * nothing against the batch's own work — and the certification benchmark
59
+ * runs unprofiled (the profiler costs ~15%), so the honest drain stamp must
60
+ * exist without it.
61
+ */
62
+ export function observeDrainAcknowledge(syncId) {
63
+ ackStamps.push({ syncId, atWallMs: Date.now() });
64
+ if (ackStamps.length > ACK_STAMP_CAP)
65
+ ackStamps.shift();
66
+ }
67
+ /** The recorded persisted-advance stamps, oldest first. */
68
+ export function drainAcknowledgeStamps() {
69
+ return [...ackStamps];
70
+ }
71
+ /** Begin a batch row. Called by the pipeline at flush entry when profiling. */
72
+ export function openDrainBatchRow(deltaCount) {
73
+ if (!enabled)
74
+ return;
75
+ currentRow = { startedAtWallMs: Date.now(), deltas: deltaCount, stages: {} };
76
+ }
77
+ /** Close the open batch row and commit it to the ring. */
78
+ export function closeDrainBatchRow() {
79
+ if (!enabled || currentRow === null)
80
+ return;
81
+ batchRows.push({
82
+ startedAtWallMs: currentRow.startedAtWallMs,
83
+ endedAtWallMs: Date.now(),
84
+ deltas: currentRow.deltas,
85
+ stages: currentRow.stages,
86
+ });
87
+ if (batchRows.length > BATCH_ROW_CAP)
88
+ batchRows.shift();
89
+ currentRow = null;
90
+ }
91
+ /**
92
+ * Read once. A profiler that consults the environment on every delta would
93
+ * itself become a per-delta cost in the path it is measuring.
94
+ */
95
+ const enabled = (() => {
96
+ const host = globalThis;
97
+ return host.process?.env?.ABLO_PROFILE_DRAIN === 'true';
98
+ })();
99
+ /** Whether drain profiling is on. Callers skip their own bookkeeping when it is not. */
100
+ export function drainProfilingEnabled() {
101
+ return enabled;
102
+ }
103
+ function mark(elapsedMs) {
104
+ const now = performance.now();
105
+ firstMark ??= now - elapsedMs;
106
+ lastMark = now;
107
+ }
108
+ /** Attribute already-measured wall time to a stage. */
109
+ export function observeDrainStage(stage, elapsedMs) {
110
+ if (!enabled)
111
+ return;
112
+ const entry = totals[stage];
113
+ entry.totalMs += elapsedMs;
114
+ entry.calls += 1;
115
+ if (currentRow !== null) {
116
+ currentRow.stages[stage] = (currentRow.stages[stage] ?? 0) + elapsedMs;
117
+ }
118
+ mark(elapsedMs);
119
+ }
120
+ /** Time a synchronous stage. Returns the callback's value untouched. */
121
+ export function timeDrainStage(stage, run) {
122
+ if (!enabled)
123
+ return run();
124
+ const startedAt = performance.now();
125
+ try {
126
+ return run();
127
+ }
128
+ finally {
129
+ observeDrainStage(stage, performance.now() - startedAt);
130
+ }
131
+ }
132
+ /** Time an asynchronous stage. Returns the callback's value untouched. */
133
+ export async function timeDrainStageAsync(stage, run) {
134
+ if (!enabled)
135
+ return run();
136
+ const startedAt = performance.now();
137
+ try {
138
+ return await run();
139
+ }
140
+ finally {
141
+ observeDrainStage(stage, performance.now() - startedAt);
142
+ }
143
+ }
144
+ /**
145
+ * Record one drained batch: how many deltas entered it and how many survived
146
+ * deduplication. Batch count is the multiplier on every per-batch cost, so it
147
+ * is reported alongside the timings rather than derived from them.
148
+ */
149
+ export function observeDrainBatch(received, survived) {
150
+ if (!enabled)
151
+ return;
152
+ batches += 1;
153
+ deltas += received;
154
+ deduplicated += Math.max(0, received - survived);
155
+ }
156
+ /** The totals accumulated since the last reset. */
157
+ export function drainProfileSnapshot() {
158
+ const stages = {};
159
+ for (const stage of DRAIN_STAGES) {
160
+ stages[stage] = { totalMs: totals[stage].totalMs, calls: totals[stage].calls };
161
+ }
162
+ return {
163
+ batches,
164
+ deltas,
165
+ deduplicated,
166
+ spanMs: firstMark === undefined ? 0 : lastMark - firstMark,
167
+ stages,
168
+ recentBatches: [...batchRows],
169
+ };
170
+ }
171
+ /** Clear the totals so a phase measures only its own traffic. */
172
+ export function resetDrainProfile() {
173
+ totals = emptyTotals();
174
+ batches = 0;
175
+ deltas = 0;
176
+ deduplicated = 0;
177
+ firstMark = undefined;
178
+ lastMark = 0;
179
+ batchRows = [];
180
+ currentRow = null;
181
+ ackStamps = [];
182
+ }
@@ -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);
@@ -10,7 +10,7 @@ export interface PendingDrainContext {
10
10
  deltaConfirmationTimeout: number;
11
11
  };
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>;
@@ -13,7 +13,8 @@ export async function drainPendingSettlements(ctx) {
13
13
  // These rows may already be waiting behind the normal batch timer. The
14
14
  // reconnect fast path takes ownership of them for this attempt so the same
15
15
  // transaction cannot dispatch concurrently through both paths.
16
- ctx.executionQueue = ctx.executionQueue.filter((tx) => !pendingIds.has(tx.id));
16
+ const retainedQueue = ctx.executionQueue.filter((tx) => !pendingIds.has(tx.id));
17
+ ctx.executionQueue.splice(0, ctx.executionQueue.length, ...retainedQueue);
17
18
  const remaining = [...pending];
18
19
  while (remaining.length > 0) {
19
20
  const batch = ctx.takePendingDrainBatch(remaining);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.37.1",
3
+ "version": "0.39.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.39.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,18 @@ 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
+ drainAcknowledgeStamps,
114
+ type AcknowledgeStamp,
115
+ type DrainProfile,
116
+ type DrainBatchRow,
117
+ type DrainStage,
118
+ type DrainStageTotals,
119
+ } 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
@@ -642,7 +644,13 @@ export class BaseSyncedStore<
642
644
  this.smartSyncOptions = {
643
645
  maxDeltasBeforeBootstrap: 1000,
644
646
  maxBootstrapSize: 10 * 1024 * 1024,
645
- batchingDelay: 100,
647
+ // The inbound-delta flush debounce. Under sustained traffic the
648
+ // `maxBatchSize` force-flush governs batching, so this timer decides
649
+ // exactly one thing: how long the FINAL partial batch of a burst sits
650
+ // before it materializes. At 100 ms it was the largest single term in
651
+ // the observer's drain tail on the throughput bench; 10 ms coalesces a
652
+ // trickle just as well and keeps burst tails inside the drain budget.
653
+ batchingDelay: 10,
646
654
  maxBatchSize: 50,
647
655
  };
648
656