@abloatai/humans 0.38.0 → 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.
@@ -31,6 +31,7 @@ export class ValidationError extends Error {
31
31
  }
32
32
  /** Shared frozen default for {@link Model.getDerivedGetterNames}. */
33
33
  const EMPTY_DERIVED_GETTERS = Object.freeze([]);
34
+ const fieldWritePlans = new WeakMap();
34
35
  /**
35
36
  * The abstract base class every domain model extends. It holds the model's id
36
37
  * and timestamps, tracks in-place property changes for change detection and
@@ -54,6 +55,17 @@ export class Model {
54
55
  _isNew = true;
55
56
  /** Original data snapshot */
56
57
  _originalData;
58
+ /**
59
+ * Whether the persisted baseline needs recapturing. Hydration and ack land
60
+ * far more often than anything reads the baseline — an observer-only client
61
+ * reads it never — so each of those sites marks the snapshot stale and
62
+ * {@link getOriginalSnapshot} materializes it on first read. Correctness
63
+ * rests on the tracking invariant: every non-hydration write is recorded in
64
+ * `modifiedProperties` (first-old-wins), so between the stale mark and the
65
+ * read, untracked fields still hold exactly the values an eager capture
66
+ * would have recorded.
67
+ */
68
+ _originalDataStale = false;
57
69
  /** Sync status */
58
70
  syncStatus = 'pending';
59
71
  /** Timestamps */
@@ -208,7 +220,8 @@ export class Model {
208
220
  */
209
221
  markAsPersisted() {
210
222
  this._isNew = false;
211
- this._originalData = this.captureSnapshot();
223
+ this._originalData = undefined;
224
+ this._originalDataStale = true;
212
225
  }
213
226
  /**
214
227
  * Check if this is a new model
@@ -230,6 +243,10 @@ export class Model {
230
243
  * last acknowledged state would already be the authoritative baseline.
231
244
  */
232
245
  getOriginalSnapshot() {
246
+ if (this._originalDataStale) {
247
+ this._originalData = this.captureSnapshot();
248
+ this._originalDataStale = false;
249
+ }
233
250
  return this._originalData;
234
251
  }
235
252
  /**
@@ -238,7 +255,8 @@ export class Model {
238
255
  clearChanges() {
239
256
  runInAction(() => {
240
257
  this.modifiedProperties.clear();
241
- this._originalData = this.captureSnapshot();
258
+ this._originalData = undefined;
259
+ this._originalDataStale = true;
242
260
  });
243
261
  }
244
262
  /**
@@ -471,43 +489,39 @@ export class Model {
471
489
  * leaves that baseline intact.
472
490
  */
473
491
  assignFieldsFromData(data, onWrite) {
474
- // Update properties with safety checks for read-only/computed accessors
475
- for (const [key, raw] of Object.entries(data)) {
492
+ let plan = fieldWritePlans.get(this.constructor);
493
+ if (!plan) {
494
+ plan = new Map();
495
+ fieldWritePlans.set(this.constructor, plan);
496
+ }
497
+ for (const key in data) {
476
498
  if (key === 'id')
477
499
  continue;
478
- // Only attempt to set if the property exists on instance or prototype
479
- if (!(this.hasOwnProperty(key) || key in this))
500
+ if (!Object.prototype.hasOwnProperty.call(data, key))
480
501
  continue;
481
- // Never assign to MobX computed properties (they may expose a setter that throws)
482
- try {
483
- if (isComputedProp(this, key)) {
484
- continue;
485
- }
486
- }
487
- catch {
488
- // If MobX internals are unavailable for some reason, fall back to descriptor checks below
502
+ const raw = data[key];
503
+ let disposition = plan.get(key);
504
+ if (disposition === undefined) {
505
+ disposition = this.resolveFieldDisposition(key);
506
+ plan.set(key, disposition);
489
507
  }
490
- // Resolve property descriptor from own or prototype chain
491
- const ownDesc = Object.getOwnPropertyDescriptor(this, key);
492
- let desc = ownDesc;
493
- if (!desc) {
494
- let proto = Object.getPrototypeOf(this);
495
- while (proto && proto !== Object.prototype && !desc) {
496
- desc = Object.getOwnPropertyDescriptor(proto, key);
497
- proto = Object.getPrototypeOf(proto);
498
- }
508
+ if (disposition === 'skip-readonly')
509
+ continue;
510
+ if (disposition === 'skip-absent') {
511
+ // The plan was built from an instance that lacked this key. Re-resolve
512
+ // for this instance without touching the cache, so heterogeneous
513
+ // shapes stay correct at the cost of the slow path.
514
+ if (!(key in this))
515
+ continue;
516
+ disposition = this.resolveFieldDisposition(key);
517
+ if (disposition === 'skip-readonly' || disposition === 'skip-absent')
518
+ continue;
499
519
  }
500
- // Determine writability: allow if data descriptor writable, or accessor with setter
501
- const writable = desc
502
- ? ('writable' in desc && !!desc.writable) ||
503
- ('set' in desc && typeof desc.set === 'function')
504
- : true;
505
- if (!writable) {
506
- // Skip read-only accessor properties (getter-only)
520
+ else if (!(key in this)) {
507
521
  continue;
508
522
  }
509
523
  // Handle date conversions
510
- const value = (key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt') && raw
524
+ const value = disposition === 'write-date' && raw
511
525
  ? new Date(raw)
512
526
  : raw;
513
527
  // Capture the pre-write value BEFORE assignment so trackers
@@ -518,6 +532,42 @@ export class Model {
518
532
  onWrite?.(key, oldValue, value);
519
533
  }
520
534
  }
535
+ /**
536
+ * The uncached decision for one key: existence on instance or prototype,
537
+ * never a MobX computed (its setter may throw), and writability resolved
538
+ * through the descriptor chain — a data descriptor's `writable` or an
539
+ * accessor's setter.
540
+ */
541
+ resolveFieldDisposition(key) {
542
+ if (!(key in this))
543
+ return 'skip-absent';
544
+ try {
545
+ if (isComputedProp(this, key)) {
546
+ return 'skip-readonly';
547
+ }
548
+ }
549
+ catch {
550
+ // If MobX internals are unavailable for some reason, fall back to descriptor checks below
551
+ }
552
+ const ownDesc = Object.getOwnPropertyDescriptor(this, key);
553
+ let desc = ownDesc;
554
+ if (!desc) {
555
+ let proto = Object.getPrototypeOf(this);
556
+ while (proto && proto !== Object.prototype && !desc) {
557
+ desc = Object.getOwnPropertyDescriptor(proto, key);
558
+ proto = Object.getPrototypeOf(proto);
559
+ }
560
+ }
561
+ const writable = desc
562
+ ? ('writable' in desc && !!desc.writable) ||
563
+ ('set' in desc && typeof desc.set === 'function')
564
+ : true;
565
+ if (!writable)
566
+ return 'skip-readonly';
567
+ return key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt'
568
+ ? 'write-date'
569
+ : 'write';
570
+ }
521
571
  /**
522
572
  * Update from raw data (hydration)
523
573
  *
@@ -550,7 +600,8 @@ export class Model {
550
600
  });
551
601
  // Mark as persisted if updating existing model
552
602
  if (!this._isNew) {
553
- this._originalData = this.captureSnapshot();
603
+ this._originalData = undefined;
604
+ this._originalDataStale = true;
554
605
  }
555
606
  this.didUpdate();
556
607
  }
@@ -275,10 +275,6 @@ export declare class SyncClient extends EventEmitter {
275
275
  * conflict resolver reads exactly these fields and no others.
276
276
  */
277
277
  private extractCriticalState;
278
- /**
279
- * Check if critical state changes exist that require forcing server state
280
- */
281
- private hasCriticalStateChange;
282
278
  /**
283
279
  * Handle network reconnection
284
280
  */
@@ -487,6 +483,7 @@ export declare class SyncClient extends EventEmitter {
487
483
  */
488
484
  getMutationQueue(): MutationQueue;
489
485
  applyDeltaBatchToPool(dbResults: readonly AppliedChange[], enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>): void;
486
+ private applyDeltaBatchToPoolInAction;
490
487
  /**
491
488
  * Apply bootstrap data to the InstanceCache with ghost removal.
492
489
  * Owns: model creation, batch upsert, ghost detection + removal.
@@ -1038,31 +1038,22 @@ export class SyncClient extends EventEmitter {
1038
1038
  * local model has unsynced changes, so the two sides stay consistent.
1039
1039
  */
1040
1040
  resolveConflicts(localModel, serverData) {
1041
- const hasLocalChanges = localModel.hasChanges;
1042
- // Safely get timestamp, handling both Date objects and strings
1043
- const localUpdatedAt = localModel.updatedAt
1044
- ? localModel.updatedAt instanceof Date
1045
- ? localModel.updatedAt.getTime()
1046
- : new Date(localModel.updatedAt).getTime()
1047
- : 0;
1048
- const serverUpdatedAt = toEpochMs(serverData.updatedAt);
1049
- this.runtime.logger.debug('Conflict resolution', {
1050
- modelId: localModel.id,
1051
- modelType: localModel.getModelName(),
1052
- hasLocalChanges,
1053
- localUpdatedAt: localModel.updatedAt?.toString(),
1054
- serverUpdatedAt: serverData.updatedAt,
1055
- localChanges: localModel.getChanges(),
1056
- serverState: this.extractCriticalState(serverData),
1057
- });
1058
- // PRIORITY 1: Check for critical server states that must be respected
1059
- // These states override any local changes to maintain data consistency
1060
- const criticalServerStates = this.extractCriticalState(serverData);
1061
- const shouldForceAcceptServer = this.hasCriticalStateChange(criticalServerStates);
1041
+ // No entry-point debug here: this runs once per incoming update delta,
1042
+ // and a debug call's payload is BUILT even when the logger discards it —
1043
+ // a per-delta model scan on the apply hot path. The outcome branches
1044
+ // below log the cases worth reading.
1045
+ // PRIORITY 1: Check for critical server states that must be respected.
1046
+ // These states override any local changes to maintain data consistency.
1047
+ // Checked inline — the collected-object form (`extractCriticalState`)
1048
+ // only materializes on the rare force-accept branch, for its log line.
1049
+ const shouldForceAcceptServer = (serverData.deletedAt !== undefined && serverData.deletedAt !== null) ||
1050
+ (serverData.archivedAt !== undefined && serverData.archivedAt !== null) ||
1051
+ serverData.isActive === false ||
1052
+ (serverData.unassignedAt !== undefined && serverData.unassignedAt !== null);
1062
1053
  if (shouldForceAcceptServer) {
1063
1054
  this.runtime.logger.debug('Accepting server update - critical state change detected', {
1064
1055
  modelId: localModel.id,
1065
- criticalStates: criticalServerStates,
1056
+ criticalStates: this.extractCriticalState(serverData),
1066
1057
  });
1067
1058
  // Force accept server state for critical changes
1068
1059
  localModel.updateFromData(serverData);
@@ -1072,7 +1063,7 @@ export class SyncClient extends EventEmitter {
1072
1063
  }
1073
1064
  // Local-first: if we have local dirty fields, merge by field.
1074
1065
  // Keep locally changed fields; apply server for the rest.
1075
- if (hasLocalChanges) {
1066
+ if (localModel.hasChanges) {
1076
1067
  const localChanges = localModel.getChanges();
1077
1068
  this.runtime.logger.debug('Merging server update with local dirty fields', {
1078
1069
  modelId: localModel.id,
@@ -1082,6 +1073,13 @@ export class SyncClient extends EventEmitter {
1082
1073
  const merged = { ...serverData, ...(localChanges || {}) };
1083
1074
  // Preserve the most recent updatedAt without clearing dirty flags
1084
1075
  if (serverData.updatedAt || localModel.updatedAt) {
1076
+ // Safely get timestamp, handling both Date objects and strings
1077
+ const localUpdatedAt = localModel.updatedAt
1078
+ ? localModel.updatedAt instanceof Date
1079
+ ? localModel.updatedAt.getTime()
1080
+ : new Date(localModel.updatedAt).getTime()
1081
+ : 0;
1082
+ const serverUpdatedAt = toEpochMs(serverData.updatedAt);
1085
1083
  const mergedUpdatedAt = new Date(Math.max(localUpdatedAt, serverUpdatedAt));
1086
1084
  // updateFromData accepts Date or ISO string for dates
1087
1085
  merged.updatedAt = mergedUpdatedAt;
@@ -1090,10 +1088,9 @@ export class SyncClient extends EventEmitter {
1090
1088
  // Intentionally DO NOT clearChanges here; pending tx will confirm and clear
1091
1089
  return localModel;
1092
1090
  }
1093
- // No local changes: fall back to LWW to converge
1094
- // Accept server regardless of timestamp equality to stay in sync
1095
- const acceptReason = serverUpdatedAt > localUpdatedAt ? 'server is newer' : 'no local changes';
1096
- this.runtime.logger.debug(`Accepting server update - ${acceptReason}`);
1091
+ // No local changes: fall back to LWW to converge. Accept server
1092
+ // regardless of timestamp equality to stay in sync. Not logged — this is
1093
+ // the common path for every collaborator update a client receives.
1097
1094
  localModel.updateFromData(serverData);
1098
1095
  localModel.clearChanges();
1099
1096
  localModel.markAsSynced();
@@ -1125,14 +1122,6 @@ export class SyncClient extends EventEmitter {
1125
1122
  }
1126
1123
  return critical;
1127
1124
  }
1128
- /**
1129
- * Check if critical state changes exist that require forcing server state
1130
- */
1131
- hasCriticalStateChange(criticalStates) {
1132
- // Any critical state present means we should force accept server
1133
- return (Object.keys(criticalStates).length > 0 &&
1134
- Object.values(criticalStates).some((v) => v !== null && v !== undefined));
1135
- }
1136
1125
  /**
1137
1126
  * Handle network reconnection
1138
1127
  */
@@ -1536,6 +1525,18 @@ export class SyncClient extends EventEmitter {
1536
1525
  return this.mutationQueue;
1537
1526
  }
1538
1527
  applyDeltaBatchToPool(dbResults, enrichRelations) {
1528
+ // The WHOLE batch — conflict resolution and model mutation included, not
1529
+ // just the pool bookkeeping at the end — runs in one MobX action.
1530
+ // `resolveConflicts` writes model fields via `updateFromData`; when those
1531
+ // writes ran before the action, every delta opened its own top-level
1532
+ // action and flushed reactions at its boundary, so a large frame paid one
1533
+ // reaction pass per delta and an observer could see a partially applied
1534
+ // frame between them.
1535
+ runInAction(() => {
1536
+ this.applyDeltaBatchToPoolInAction(dbResults, enrichRelations);
1537
+ });
1538
+ }
1539
+ applyDeltaBatchToPoolInAction(dbResults, enrichRelations) {
1539
1540
  const modelsToAdd = [];
1540
1541
  const modelsToUpsert = [];
1541
1542
  const idsToRemove = [];
@@ -1633,31 +1634,25 @@ export class SyncClient extends EventEmitter {
1633
1634
  break;
1634
1635
  }
1635
1636
  }
1636
- // Reveal the whole frame in a single MobX action. `addBatch`,
1637
- // `upsertBatch`, `removeBatch`, and `updateScope` are each individually
1638
- // wrapped in an action, so calling them in sequence flushes reactions at
1639
- // every action boundary a catch-up frame that adds, updates, and removes
1640
- // would fire every dependent reaction several times in a row, re-rendering
1641
- // and re-sorting on each. Wrapping them in one outer `runInAction` defers
1642
- // all reaction flushes to a single boundary, so dependents recompute
1643
- // exactly once regardless of how many models or operation kinds the frame
1644
- // touched. The app therefore never observes a partially applied frame.
1645
- runInAction(() => {
1646
- if (modelsToAdd.length > 0)
1647
- this.objectPool.addBatch(modelsToAdd, ModelScope.live);
1648
- if (modelsToUpsert.length > 0)
1649
- this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
1650
- if (idsToRemove.length > 0)
1651
- this.objectPool.removeBatch(idsToRemove);
1652
- for (const id of idsToArchive)
1653
- this.objectPool.updateScope(id, ModelScope.archived);
1654
- // Emit changed model types so QueryProcessor can auto-invalidate.
1655
- // Kept inside the action so any observable query-cache state it
1656
- // flips is part of the same atomic reveal.
1657
- const changedTypes = new Set(dbResults.map(r => r.modelName));
1658
- if (changedTypes.size > 0)
1659
- this.emit('models:changed', changedTypes);
1660
- });
1637
+ // Reveal the whole frame at one reaction boundary: the caller's single
1638
+ // action covers the collection loop above and these batch pool writes, so
1639
+ // dependents recompute exactly once regardless of how many models or
1640
+ // operation kinds the frame touched, and the app never observes a
1641
+ // partially applied frame.
1642
+ if (modelsToAdd.length > 0)
1643
+ this.objectPool.addBatch(modelsToAdd, ModelScope.live);
1644
+ if (modelsToUpsert.length > 0)
1645
+ this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
1646
+ if (idsToRemove.length > 0)
1647
+ this.objectPool.removeBatch(idsToRemove);
1648
+ for (const id of idsToArchive)
1649
+ this.objectPool.updateScope(id, ModelScope.archived);
1650
+ // Emit changed model types so QueryProcessor can auto-invalidate.
1651
+ // Kept inside the action so any observable query-cache state it
1652
+ // flips is part of the same atomic reveal.
1653
+ const changedTypes = new Set(dbResults.map(r => r.modelName));
1654
+ if (changedTypes.size > 0)
1655
+ this.emit('models:changed', changedTypes);
1661
1656
  }
1662
1657
  /**
1663
1658
  * Apply bootstrap data to the InstanceCache with ghost removal.
@@ -11,6 +11,7 @@
11
11
  * host-built core client with the per-model surface split — the cut's own
12
12
  * design step (docs/plans/package-split.md).
13
13
  */
14
+ import { omittedModelError } from '@abloatai/transaction/schema/select';
14
15
  import { durableCommitOperationSchema, } from '@abloatai/transaction/transactions/settlement/commitEnvelope';
15
16
  import { AbloAuthenticationError, AbloConnectionError, AbloValidationError, claimedError } from '@abloatai/transaction/errors';
16
17
  import { batchFence, fenceTokenFor, modelTarget, streamTarget, subTarget, } from '@abloatai/transaction/coordination';
@@ -703,5 +704,22 @@ export function buildReactiveEngine(inputs) {
703
704
  });
704
705
  },
705
706
  };
707
+ // A model the schema projection left out answers with an error naming the
708
+ // model and the fix, not `undefined`. An app can compile against the full
709
+ // source schema while running a projection, so the type system never sees
710
+ // this gap; without the stub the caller crashes one property later with a
711
+ // bare TypeError ("reading 'local'") that names neither. Non-enumerable so
712
+ // spread, Object.keys, and JSON.stringify walk past the stubs untriggered.
713
+ for (const name of schema.omittedModels ?? []) {
714
+ if (name in engine)
715
+ continue;
716
+ Object.defineProperty(engine, name, {
717
+ get() {
718
+ throw omittedModelError(name);
719
+ },
720
+ enumerable: false,
721
+ configurable: true,
722
+ });
723
+ }
706
724
  return engine;
707
725
  }
@@ -178,12 +178,9 @@ export class SyncWebSocket extends WsTransport {
178
178
  const delta = this.normalizeWireDelta(rawDelta);
179
179
  if (!delta)
180
180
  return;
181
- getContext().logger.debug('Received delta', {
182
- action: delta.actionType,
183
- model: delta.modelName,
184
- id: delta.modelId,
185
- syncId: delta.id,
186
- });
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`.
187
184
  // Do not advance `this.cursor.lastSyncId` on receipt. The runtime cursor
188
185
  // must stay consistent with what has been persisted locally; otherwise the
189
186
  // next `requestIncrementalSync()` (and the connect-time handshake) would
@@ -14,7 +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
+ import { observeDrainBatch, observeDrainAcknowledge, timeDrainStage, timeDrainStageAsync, openDrainBatchRow, closeDrainBatchRow, } from './drainProfile.js';
18
18
  /**
19
19
  * One drain per store. Incoming WebSocket frames may arrive while persistence
20
20
  * and pool application are awaiting. Without a single-flight guard every
@@ -295,6 +295,15 @@ function yieldToHost() {
295
295
  });
296
296
  }
297
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) {
298
307
  const stagePlugins = ctx.stagePlugins ?? [];
299
308
  const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
300
309
  observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
@@ -372,6 +381,7 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
372
381
  timeDrainStage('acknowledge', () => {
373
382
  ctx.acknowledge(persistedSyncId);
374
383
  ctx.advancePersisted(persistedSyncId);
384
+ observeDrainAcknowledge(persistedSyncId);
375
385
  runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
376
386
  });
377
387
  }
@@ -28,6 +28,18 @@ export interface DrainStageTotals {
28
28
  /** How many times the stage ran. Per-delta for `parse`, per-batch for the rest. */
29
29
  readonly calls: number;
30
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
+ }
31
43
  export interface DrainProfile {
32
44
  /** Flush batches drained. The per-batch fixed cost multiplies by this. */
33
45
  readonly batches: number;
@@ -38,7 +50,40 @@ export interface DrainProfile {
38
50
  /** Wall time from the first observed stage to the last. */
39
51
  readonly spanMs: number;
40
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[];
41
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;
42
87
  /** Whether drain profiling is on. Callers skip their own bookkeeping when it is not. */
43
88
  export declare function drainProfilingEnabled(): boolean;
44
89
  /** Attribute already-measured wall time to a stage. */
@@ -40,6 +40,54 @@ let deltas = 0;
40
40
  let deduplicated = 0;
41
41
  let firstMark;
42
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
+ }
43
91
  /**
44
92
  * Read once. A profiler that consults the environment on every delta would
45
93
  * itself become a per-delta cost in the path it is measuring.
@@ -64,6 +112,9 @@ export function observeDrainStage(stage, elapsedMs) {
64
112
  const entry = totals[stage];
65
113
  entry.totalMs += elapsedMs;
66
114
  entry.calls += 1;
115
+ if (currentRow !== null) {
116
+ currentRow.stages[stage] = (currentRow.stages[stage] ?? 0) + elapsedMs;
117
+ }
67
118
  mark(elapsedMs);
68
119
  }
69
120
  /** Time a synchronous stage. Returns the callback's value untouched. */
@@ -114,6 +165,7 @@ export function drainProfileSnapshot() {
114
165
  deduplicated,
115
166
  spanMs: firstMark === undefined ? 0 : lastMark - firstMark,
116
167
  stages,
168
+ recentBatches: [...batchRows],
117
169
  };
118
170
  }
119
171
  /** Clear the totals so a phase measures only its own traffic. */
@@ -124,4 +176,7 @@ export function resetDrainProfile() {
124
176
  deduplicated = 0;
125
177
  firstMark = undefined;
126
178
  lastMark = 0;
179
+ batchRows = [];
180
+ currentRow = null;
181
+ ackStamps = [];
127
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.38.0",
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.38.0",
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
@@ -110,7 +110,10 @@ export {
110
110
  drainProfileSnapshot,
111
111
  resetDrainProfile,
112
112
  drainProfilingEnabled,
113
+ drainAcknowledgeStamps,
114
+ type AcknowledgeStamp,
113
115
  type DrainProfile,
116
+ type DrainBatchRow,
114
117
  type DrainStage,
115
118
  type DrainStageTotals,
116
119
  } from './local/sync/drainProfile.js';
@@ -644,7 +644,13 @@ export class BaseSyncedStore<
644
644
  this.smartSyncOptions = {
645
645
  maxDeltasBeforeBootstrap: 1000,
646
646
  maxBootstrapSize: 10 * 1024 * 1024,
647
- 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,
648
654
  maxBatchSize: 50,
649
655
  };
650
656