@abloatai/humans 0.38.0 → 0.40.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 (34) hide show
  1. package/dist/core.d.ts +1 -1
  2. package/dist/core.js +1 -1
  3. package/dist/local/BaseSyncedStore.d.ts +9 -0
  4. package/dist/local/BaseSyncedStore.js +28 -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 +40 -8
  8. package/dist/local/InstanceCache.js +156 -83
  9. package/dist/local/Model.d.ts +28 -0
  10. package/dist/local/Model.js +102 -35
  11. package/dist/local/SyncClient.d.ts +1 -4
  12. package/dist/local/SyncClient.js +63 -62
  13. package/dist/local/client/reactiveEngine.js +18 -0
  14. package/dist/local/sync/SyncWebSocket.js +3 -6
  15. package/dist/local/sync/deltaPipeline.d.ts +32 -1
  16. package/dist/local/sync/deltaPipeline.js +116 -8
  17. package/dist/local/sync/drainProfile.d.ts +45 -0
  18. package/dist/local/sync/drainProfile.js +55 -0
  19. package/dist/local/transactions/mutations/MutationQueue.js +11 -3
  20. package/dist/local/utils/mobxSetup.d.ts +1 -0
  21. package/dist/local/utils/mobxSetup.js +5 -0
  22. package/package.json +2 -2
  23. package/src/core.ts +3 -0
  24. package/src/local/BaseSyncedStore.ts +37 -1
  25. package/src/local/Database.ts +87 -50
  26. package/src/local/InstanceCache.ts +162 -80
  27. package/src/local/Model.ts +117 -42
  28. package/src/local/SyncClient.ts +66 -63
  29. package/src/local/client/reactiveEngine.ts +18 -0
  30. package/src/local/sync/SyncWebSocket.ts +3 -6
  31. package/src/local/sync/deltaPipeline.ts +135 -9
  32. package/src/local/sync/drainProfile.ts +93 -0
  33. package/src/local/transactions/mutations/MutationQueue.ts +14 -4
  34. package/src/local/utils/mobxSetup.ts +5 -0
@@ -1290,34 +1290,25 @@ export class SyncClient extends EventEmitter {
1290
1290
  * local model has unsynced changes, so the two sides stay consistent.
1291
1291
  */
1292
1292
  resolveConflicts(localModel: Model, serverData: Record<string, unknown>): Model {
1293
- const hasLocalChanges = localModel.hasChanges;
1294
- // Safely get timestamp, handling both Date objects and strings
1295
- const localUpdatedAt = localModel.updatedAt
1296
- ? localModel.updatedAt instanceof Date
1297
- ? localModel.updatedAt.getTime()
1298
- : new Date(localModel.updatedAt).getTime()
1299
- : 0;
1300
- const serverUpdatedAt = toEpochMs(serverData.updatedAt);
1301
-
1302
- this.runtime.logger.debug('Conflict resolution', {
1303
- modelId: localModel.id,
1304
- modelType: localModel.getModelName(),
1305
- hasLocalChanges,
1306
- localUpdatedAt: localModel.updatedAt?.toString(),
1307
- serverUpdatedAt: serverData.updatedAt,
1308
- localChanges: localModel.getChanges(),
1309
- serverState: this.extractCriticalState(serverData),
1310
- });
1311
-
1312
- // PRIORITY 1: Check for critical server states that must be respected
1313
- // These states override any local changes to maintain data consistency
1314
- const criticalServerStates = this.extractCriticalState(serverData);
1315
- const shouldForceAcceptServer = this.hasCriticalStateChange(criticalServerStates);
1293
+ // No entry-point debug here: this runs once per incoming update delta,
1294
+ // and a debug call's payload is BUILT even when the logger discards it —
1295
+ // a per-delta model scan on the apply hot path. The outcome branches
1296
+ // below log the cases worth reading.
1297
+
1298
+ // PRIORITY 1: Check for critical server states that must be respected.
1299
+ // These states override any local changes to maintain data consistency.
1300
+ // Checked inline — the collected-object form (`extractCriticalState`)
1301
+ // only materializes on the rare force-accept branch, for its log line.
1302
+ const shouldForceAcceptServer =
1303
+ (serverData.deletedAt !== undefined && serverData.deletedAt !== null) ||
1304
+ (serverData.archivedAt !== undefined && serverData.archivedAt !== null) ||
1305
+ serverData.isActive === false ||
1306
+ (serverData.unassignedAt !== undefined && serverData.unassignedAt !== null);
1316
1307
 
1317
1308
  if (shouldForceAcceptServer) {
1318
1309
  this.runtime.logger.debug('Accepting server update - critical state change detected', {
1319
1310
  modelId: localModel.id,
1320
- criticalStates: criticalServerStates,
1311
+ criticalStates: this.extractCriticalState(serverData),
1321
1312
  });
1322
1313
 
1323
1314
  // Force accept server state for critical changes
@@ -1329,7 +1320,7 @@ export class SyncClient extends EventEmitter {
1329
1320
 
1330
1321
  // Local-first: if we have local dirty fields, merge by field.
1331
1322
  // Keep locally changed fields; apply server for the rest.
1332
- if (hasLocalChanges) {
1323
+ if (localModel.hasChanges) {
1333
1324
  const localChanges = localModel.getChanges();
1334
1325
  this.runtime.logger.debug('Merging server update with local dirty fields', {
1335
1326
  modelId: localModel.id,
@@ -1341,6 +1332,13 @@ export class SyncClient extends EventEmitter {
1341
1332
 
1342
1333
  // Preserve the most recent updatedAt without clearing dirty flags
1343
1334
  if (serverData.updatedAt || localModel.updatedAt) {
1335
+ // Safely get timestamp, handling both Date objects and strings
1336
+ const localUpdatedAt = localModel.updatedAt
1337
+ ? localModel.updatedAt instanceof Date
1338
+ ? localModel.updatedAt.getTime()
1339
+ : new Date(localModel.updatedAt).getTime()
1340
+ : 0;
1341
+ const serverUpdatedAt = toEpochMs(serverData.updatedAt);
1344
1342
  const mergedUpdatedAt = new Date(Math.max(localUpdatedAt, serverUpdatedAt));
1345
1343
  // updateFromData accepts Date or ISO string for dates
1346
1344
  merged.updatedAt = mergedUpdatedAt;
@@ -1351,10 +1349,9 @@ export class SyncClient extends EventEmitter {
1351
1349
  return localModel;
1352
1350
  }
1353
1351
 
1354
- // No local changes: fall back to LWW to converge
1355
- // Accept server regardless of timestamp equality to stay in sync
1356
- const acceptReason = serverUpdatedAt > localUpdatedAt ? 'server is newer' : 'no local changes';
1357
- this.runtime.logger.debug(`Accepting server update - ${acceptReason}`);
1352
+ // No local changes: fall back to LWW to converge. Accept server
1353
+ // regardless of timestamp equality to stay in sync. Not logged — this is
1354
+ // the common path for every collaborator update a client receives.
1358
1355
  localModel.updateFromData(serverData);
1359
1356
  localModel.clearChanges();
1360
1357
  localModel.markAsSynced();
@@ -1392,17 +1389,6 @@ export class SyncClient extends EventEmitter {
1392
1389
  return critical;
1393
1390
  }
1394
1391
 
1395
- /**
1396
- * Check if critical state changes exist that require forcing server state
1397
- */
1398
- private hasCriticalStateChange(criticalStates: Record<string, unknown>): boolean {
1399
- // Any critical state present means we should force accept server
1400
- return (
1401
- Object.keys(criticalStates).length > 0 &&
1402
- Object.values(criticalStates).some((v) => v !== null && v !== undefined)
1403
- );
1404
- }
1405
-
1406
1392
  /**
1407
1393
  * Handle network reconnection
1408
1394
  */
@@ -1826,6 +1812,7 @@ export class SyncClient extends EventEmitter {
1826
1812
  return this.mutationQueue.confirmationFor(modelName, modelId);
1827
1813
  }
1828
1814
 
1815
+
1829
1816
  /**
1830
1817
  * Get detailed debug info for the sync debug page
1831
1818
  */
@@ -1901,6 +1888,22 @@ export class SyncClient extends EventEmitter {
1901
1888
  applyDeltaBatchToPool(
1902
1889
  dbResults: readonly AppliedChange[],
1903
1890
  enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>,
1891
+ ): void {
1892
+ // The WHOLE batch — conflict resolution and model mutation included, not
1893
+ // just the pool bookkeeping at the end — runs in one MobX action.
1894
+ // `resolveConflicts` writes model fields via `updateFromData`; when those
1895
+ // writes ran before the action, every delta opened its own top-level
1896
+ // action and flushed reactions at its boundary, so a large frame paid one
1897
+ // reaction pass per delta and an observer could see a partially applied
1898
+ // frame between them.
1899
+ runInAction(() => {
1900
+ this.applyDeltaBatchToPoolInAction(dbResults, enrichRelations);
1901
+ });
1902
+ }
1903
+
1904
+ private applyDeltaBatchToPoolInAction(
1905
+ dbResults: readonly AppliedChange[],
1906
+ enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>,
1904
1907
  ): void {
1905
1908
  const modelsToAdd: Model[] = [];
1906
1909
  const modelsToUpsert: Model[] = [];
@@ -1948,7 +1951,13 @@ export class SyncClient extends EventEmitter {
1948
1951
 
1949
1952
  switch (action) {
1950
1953
  case 'add': {
1951
- const existing = this.objectPool.get(modelId);
1954
+ // `peek`, not `get`: this loop is ingestion, not a consumer read.
1955
+ // `get()` activates deferred MobX instrumentation, so reading
1956
+ // through it here made the delta stream itself instrument every
1957
+ // row it touched — the dominant term of apply cost for rows no
1958
+ // consumer observes. Activation belongs to the consumer-facing
1959
+ // reads (`get`, views, subscribers), which are unchanged.
1960
+ const existing = this.objectPool.peek(modelId);
1952
1961
  if (existing) {
1953
1962
  existing.markAsSynced();
1954
1963
  } else if (result.data) {
@@ -1961,7 +1970,7 @@ export class SyncClient extends EventEmitter {
1961
1970
  break;
1962
1971
  }
1963
1972
  case 'update': {
1964
- const existing = this.objectPool.get(modelId);
1973
+ const existing = this.objectPool.peek(modelId);
1965
1974
  if (existing && !existing.disposed && result.data) {
1966
1975
  enrichRelations(modelName, result.data);
1967
1976
  const resolved = this.resolveConflicts(existing, result.data);
@@ -2002,27 +2011,21 @@ export class SyncClient extends EventEmitter {
2002
2011
  }
2003
2012
  }
2004
2013
 
2005
- // Reveal the whole frame in a single MobX action. `addBatch`,
2006
- // `upsertBatch`, `removeBatch`, and `updateScope` are each individually
2007
- // wrapped in an action, so calling them in sequence flushes reactions at
2008
- // every action boundary a catch-up frame that adds, updates, and removes
2009
- // would fire every dependent reaction several times in a row, re-rendering
2010
- // and re-sorting on each. Wrapping them in one outer `runInAction` defers
2011
- // all reaction flushes to a single boundary, so dependents recompute
2012
- // exactly once regardless of how many models or operation kinds the frame
2013
- // touched. The app therefore never observes a partially applied frame.
2014
- runInAction(() => {
2015
- if (modelsToAdd.length > 0) this.objectPool.addBatch(modelsToAdd, ModelScope.live);
2016
- if (modelsToUpsert.length > 0) this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
2017
- if (idsToRemove.length > 0) this.objectPool.removeBatch(idsToRemove);
2018
- for (const id of idsToArchive) this.objectPool.updateScope(id, ModelScope.archived);
2019
-
2020
- // Emit changed model types so QueryProcessor can auto-invalidate.
2021
- // Kept inside the action so any observable query-cache state it
2022
- // flips is part of the same atomic reveal.
2023
- const changedTypes = new Set(dbResults.map(r => r.modelName));
2024
- if (changedTypes.size > 0) this.emit('models:changed', changedTypes);
2025
- });
2014
+ // Reveal the whole frame at one reaction boundary: the caller's single
2015
+ // action covers the collection loop above and these batch pool writes, so
2016
+ // dependents recompute exactly once regardless of how many models or
2017
+ // operation kinds the frame touched, and the app never observes a
2018
+ // partially applied frame.
2019
+ if (modelsToAdd.length > 0) this.objectPool.addBatch(modelsToAdd, ModelScope.live);
2020
+ if (modelsToUpsert.length > 0) this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
2021
+ if (idsToRemove.length > 0) this.objectPool.removeBatch(idsToRemove);
2022
+ for (const id of idsToArchive) this.objectPool.updateScope(id, ModelScope.archived);
2023
+
2024
+ // Emit changed model types so QueryProcessor can auto-invalidate.
2025
+ // Kept inside the action so any observable query-cache state it
2026
+ // flips is part of the same atomic reveal.
2027
+ const changedTypes = new Set(dbResults.map(r => r.modelName));
2028
+ if (changedTypes.size > 0) this.emit('models:changed', changedTypes);
2026
2029
  }
2027
2030
 
2028
2031
  /**
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
16
+ import { omittedModelError } from '@abloatai/transaction/schema/select';
16
17
  import {
17
18
  durableCommitOperationSchema,
18
19
  type DurableCommitOperation,
@@ -934,5 +935,22 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
934
935
  },
935
936
  } as Ablo<S>;
936
937
 
938
+ // A model the schema projection left out answers with an error naming the
939
+ // model and the fix, not `undefined`. An app can compile against the full
940
+ // source schema while running a projection, so the type system never sees
941
+ // this gap; without the stub the caller crashes one property later with a
942
+ // bare TypeError ("reading 'local'") that names neither. Non-enumerable so
943
+ // spread, Object.keys, and JSON.stringify walk past the stubs untriggered.
944
+ for (const name of schema.omittedModels ?? []) {
945
+ if (name in engine) continue;
946
+ Object.defineProperty(engine, name, {
947
+ get() {
948
+ throw omittedModelError(name);
949
+ },
950
+ enumerable: false,
951
+ configurable: true,
952
+ });
953
+ }
954
+
937
955
  return engine;
938
956
  }
@@ -272,12 +272,9 @@ export class SyncWebSocket<
272
272
  protected override handleDelta(rawDelta: unknown): void {
273
273
  const delta = this.normalizeWireDelta(rawDelta);
274
274
  if (!delta) return;
275
- getContext().logger.debug('Received delta', {
276
- action: delta.actionType,
277
- model: delta.modelName,
278
- id: delta.modelId,
279
- syncId: delta.id,
280
- });
275
+ // No per-delta debug here: the payload object is built even when the
276
+ // logger discards it, and this runs at the full live wire rate. Dropped
277
+ // malformed deltas are still logged by `normalizeWireDelta`.
281
278
 
282
279
  // Do not advance `this.cursor.lastSyncId` on receipt. The runtime cursor
283
280
  // must stay consistent with what has been persisted locally; otherwise the
@@ -24,7 +24,14 @@ import {
24
24
  type AbloPlugin,
25
25
  type AppliedChange,
26
26
  } from '../../plugin.js';
27
- import { observeDrainBatch, timeDrainStage, timeDrainStageAsync } from './drainProfile.js';
27
+ import {
28
+ observeDrainBatch,
29
+ observeDrainAcknowledge,
30
+ timeDrainStage,
31
+ timeDrainStageAsync,
32
+ openDrainBatchRow,
33
+ closeDrainBatchRow,
34
+ } from './drainProfile.js';
28
35
 
29
36
  /**
30
37
  * What the pipeline needs back from the surrounding store: the shared mutable
@@ -46,7 +53,11 @@ export interface DeltaPipelineContext {
46
53
  batchTimer: ReturnType<typeof setTimeout> | null;
47
54
  /** Queue for deltas arriving during an active bootstrap; null when none. */
48
55
  readonly bootstrapDeltaQueue: SyncDelta[] | null;
49
- readonly smartSyncOptions: { readonly batchingDelay: number; readonly maxBatchSize: number };
56
+ readonly smartSyncOptions: {
57
+ readonly batchingDelay: number;
58
+ readonly maxBatchSize: number;
59
+ readonly applySliceDeltas: number;
60
+ };
50
61
  /** Pool-applied cursor (`syncClient.position.applied`). */
51
62
  readonly highestProcessedSyncId: number;
52
63
  /** Resume/ack cursor (`syncClient.position.persisted`). */
@@ -79,7 +90,8 @@ export interface DeltaPipelineContext {
79
90
 
80
91
  // ── Custom-entity pool ops (deltas that skip the local store) ──
81
92
  readonly objectPool: {
82
- get(id: string): Model | undefined;
93
+ /** Ingestion-side lookup resolves without activating observability. */
94
+ peek(id: string): Model | undefined;
83
95
  add(model: Model, scope: ModelScope): void;
84
96
  remove(id: string): boolean;
85
97
  /** Full in-memory clear — the revocation-failure fallback (see
@@ -305,6 +317,7 @@ export function enqueueDelta(
305
317
  // The delta is accepted and queued — the `receive` stage boundary.
306
318
  runStage(ctx.stagePlugins ?? [], 'receive', { delta });
307
319
  ctx.pendingDeltas.push(delta);
320
+ pipelineDebug.enqueued += 1;
308
321
  return true;
309
322
  }
310
323
 
@@ -393,6 +406,71 @@ async function drainPendingDeltas(ctx: DeltaPipelineContext): Promise<void> {
393
406
  }
394
407
  }
395
408
 
409
+ /**
410
+ * Uninterrupted apply time allowed before the sliced loop yields — the
411
+ * "no visible stall" bound. Yields are amortized against it because one host
412
+ * yield costs milliseconds under load; at the measured per-delta apply cost
413
+ * this works out to roughly one yield per one to two 600-delta slices.
414
+ */
415
+ const APPLY_YIELD_BUDGET_MS = 12;
416
+
417
+ /**
418
+ * Wedge forensics: where the pipeline currently is, updated synchronously at
419
+ * every stage boundary. A hang diagnoses itself by which counter pair
420
+ * diverged and which phase the active flush froze in. Mirrored onto
421
+ * `globalThis.__abloPipelineDebug` so a bench watchdog in the same thread
422
+ * can read it without an import path into SDK internals — diagnostics only,
423
+ * a handful of numbers, no payload data.
424
+ */
425
+ export const pipelineDebug = {
426
+ flushesStarted: 0,
427
+ flushesSettled: 0,
428
+ persistsStarted: 0,
429
+ persistsSettled: 0,
430
+ applySlices: 0,
431
+ applyYields: 0,
432
+ enqueued: 0,
433
+ phase: 'idle' as string,
434
+ };
435
+ (globalThis as { __abloPipelineDebug?: typeof pipelineDebug }).__abloPipelineDebug =
436
+ pipelineDebug;
437
+
438
+ /**
439
+ * Split applied changes into slices of at most `maxDeltas`, never splitting a
440
+ * transaction: consecutive changes sharing a `transactionId` form one
441
+ * indivisible group (a commit reveals whole), while changes without one are
442
+ * individually splittable. A single transaction larger than the bound forms
443
+ * its own oversized slice, so apply always advances rather than stalling on
444
+ * an oversized commit — the same rule the server's publication chunking uses.
445
+ */
446
+ export function sliceApplyChanges<T extends { readonly transactionId?: string }>(
447
+ changes: readonly T[],
448
+ maxDeltas: number,
449
+ ): readonly T[][] {
450
+ if (changes.length <= maxDeltas) return changes.length > 0 ? [[...changes]] : [];
451
+ const slices: T[][] = [];
452
+ let current: T[] = [];
453
+ let index = 0;
454
+ while (index < changes.length) {
455
+ // The indivisible unit starting here: one transaction's run, or a single
456
+ // untransacted change.
457
+ const transactionId = changes[index]!.transactionId;
458
+ let end = index + 1;
459
+ if (transactionId !== undefined) {
460
+ while (end < changes.length && changes[end]!.transactionId === transactionId) end += 1;
461
+ }
462
+ const groupSize = end - index;
463
+ if (current.length > 0 && current.length + groupSize > maxDeltas) {
464
+ slices.push(current);
465
+ current = [];
466
+ }
467
+ current.push(...changes.slice(index, end));
468
+ index = end;
469
+ }
470
+ if (current.length > 0) slices.push(current);
471
+ return slices;
472
+ }
473
+
396
474
  function yieldToHost(): Promise<void> {
397
475
  const immediate = (
398
476
  globalThis as {
@@ -408,8 +486,24 @@ function yieldToHost(): Promise<void> {
408
486
  async function flushDeltaBatch(
409
487
  ctx: DeltaPipelineContext,
410
488
  queuedDeltas: SyncDelta[],
489
+ ): Promise<void> {
490
+ openDrainBatchRow(queuedDeltas.length);
491
+ pipelineDebug.flushesStarted += 1;
492
+ try {
493
+ await flushDeltaBatchInner(ctx, queuedDeltas);
494
+ } finally {
495
+ pipelineDebug.flushesSettled += 1;
496
+ pipelineDebug.phase = 'idle';
497
+ closeDrainBatchRow();
498
+ }
499
+ }
500
+
501
+ async function flushDeltaBatchInner(
502
+ ctx: DeltaPipelineContext,
503
+ queuedDeltas: SyncDelta[],
411
504
  ): Promise<void> {
412
505
  const stagePlugins = ctx.stagePlugins ?? [];
506
+ pipelineDebug.phase = 'dedupe';
413
507
  const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
414
508
  observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
415
509
  runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
@@ -427,7 +521,7 @@ async function flushDeltaBatch(
427
521
  // gained permission to see the entity, so we insert it into the
428
522
  // pool as if newly created.
429
523
  if (delta.actionType === 'I' || delta.actionType === 'U' || delta.actionType === 'C') {
430
- const existing = ctx.objectPool.get(delta.modelId);
524
+ const existing = ctx.objectPool.peek(delta.modelId);
431
525
  if (existing) {
432
526
  existing.updateFromData(data);
433
527
  } else {
@@ -446,6 +540,8 @@ async function flushDeltaBatch(
446
540
  // handleGroupRemoved) and never reach here, though the persistence
447
541
  // signature accepts them defensively.
448
542
  const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
543
+ pipelineDebug.phase = 'persist';
544
+ pipelineDebug.persistsStarted += 1;
449
545
  const batch = await timeDrainStageAsync('persist', () =>
450
546
  ctx.processDeltaBatch(
451
547
  regularDeltas.map((d) => ({
@@ -460,6 +556,7 @@ async function flushDeltaBatch(
460
556
  }))
461
557
  )
462
558
  );
559
+ pipelineDebug.persistsSettled += 1;
463
560
  const dbResults = batch.results;
464
561
  runStage(stagePlugins, 'persist', { deltas: regularDeltas });
465
562
 
@@ -468,13 +565,41 @@ async function flushDeltaBatch(
468
565
  // materialiser attached where it said it would. The direct call is the
469
566
  // bridge for stores constructed without plugins (subclasses, tests),
470
567
  // whose own apply is the whole pipeline.
471
- timeDrainStage('apply', () => {
472
- if (pluginsForStage(stagePlugins, 'apply').length > 0) {
473
- runStage(stagePlugins, 'apply', { changes: dbResults });
474
- } else {
475
- ctx.applyDeltaBatchToPool(dbResults);
568
+ //
569
+ // Large batches apply in TIME SLICES: split at transaction boundaries into
570
+ // bounded chunks with the event loop yielded between them, so a catch-up
571
+ // wave reveals commit-by-commit instead of holding the thread for one long
572
+ // synchronous block. Each slice is still one MobX action (reactions fire
573
+ // once per slice), and a transaction never splits across slices — the
574
+ // commit remains the atomic unit of visibility.
575
+ const slices = sliceApplyChanges(dbResults, ctx.smartSyncOptions.applySliceDeltas);
576
+ await timeDrainStageAsync('apply', async () => {
577
+ const hasApplyPlugins = pluginsForStage(stagePlugins, 'apply').length > 0;
578
+ // Yield on a TIME budget, not per slice: a host yield costs milliseconds
579
+ // under load (measured ~7x throughput collapse when yielding every few
580
+ // deltas), so the yield decision amortizes it — only after the budget of
581
+ // uninterrupted apply work has been spent, and never before the first
582
+ // slice. Slices stay the atomicity unit; the budget only decides where
583
+ // the loop breathes.
584
+ let sliceStartedAt = performance.now();
585
+ for (let index = 0; index < slices.length; index++) {
586
+ if (index > 0 && performance.now() - sliceStartedAt > APPLY_YIELD_BUDGET_MS) {
587
+ pipelineDebug.phase = `apply-yield-${index}`;
588
+ pipelineDebug.applyYields += 1;
589
+ await yieldToHost();
590
+ sliceStartedAt = performance.now();
591
+ }
592
+ pipelineDebug.phase = `apply-slice-${index}`;
593
+ pipelineDebug.applySlices += 1;
594
+ const slice = slices[index]!;
595
+ if (hasApplyPlugins) {
596
+ runStage(stagePlugins, 'apply', { changes: slice });
597
+ } else {
598
+ ctx.applyDeltaBatchToPool(slice);
599
+ }
476
600
  }
477
601
  });
602
+ pipelineDebug.phase = 'acknowledge';
478
603
 
479
604
  // Acknowledge and advance the sync cursor, gated on persistence.
480
605
  //
@@ -489,6 +614,7 @@ async function flushDeltaBatch(
489
614
  timeDrainStage('acknowledge', () => {
490
615
  ctx.acknowledge(persistedSyncId);
491
616
  ctx.advancePersisted(persistedSyncId);
617
+ observeDrainAcknowledge(persistedSyncId);
492
618
  runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
493
619
  });
494
620
  }
@@ -32,6 +32,19 @@ export interface DrainStageTotals {
32
32
  readonly calls: number;
33
33
  }
34
34
 
35
+ /**
36
+ * One flush batch on the wall clock. Wall time (`Date.now`) rather than
37
+ * `performance.now`, because rows cross the worker boundary and each thread
38
+ * has its own `performance` origin — the drain-tail stamps learned the same
39
+ * lesson. Stage entries are the batch's own share of each pipeline stage.
40
+ */
41
+ export interface DrainBatchRow {
42
+ readonly startedAtWallMs: number;
43
+ readonly endedAtWallMs: number;
44
+ readonly deltas: number;
45
+ readonly stages: Readonly<Partial<Record<DrainStage, number>>>;
46
+ }
47
+
35
48
  export interface DrainProfile {
36
49
  /** Flush batches drained. The per-batch fixed cost multiplies by this. */
37
50
  readonly batches: number;
@@ -42,6 +55,12 @@ export interface DrainProfile {
42
55
  /** Wall time from the first observed stage to the last. */
43
56
  readonly spanMs: number;
44
57
  readonly stages: Readonly<Record<DrainStage, DrainStageTotals>>;
58
+ /**
59
+ * The most recent flush batches, oldest first, capped — enough to cover a
60
+ * drain tail. Optional because derived profiles (window subtraction, fleet
61
+ * merges) drop it; only a worker's own snapshot carries rows.
62
+ */
63
+ readonly recentBatches?: readonly DrainBatchRow[];
45
64
  }
46
65
 
47
66
  const DRAIN_STAGES: readonly DrainStage[] = [
@@ -72,6 +91,73 @@ let deduplicated = 0;
72
91
  let firstMark: number | undefined;
73
92
  let lastMark = 0;
74
93
 
94
+ /** Ring of recent batch rows. ~50 batches/sec at benchmark rates, so this covers seconds of tail. */
95
+ const BATCH_ROW_CAP = 128;
96
+ let batchRows: DrainBatchRow[] = [];
97
+ interface OpenBatchRow {
98
+ startedAtWallMs: number;
99
+ deltas: number;
100
+ stages: Partial<Record<DrainStage, number>>;
101
+ }
102
+ /**
103
+ * The batch currently being flushed. Module-global like the totals above, so
104
+ * an isolate hosting several stores attributes interleaved awaits to whichever
105
+ * batch is open — the same per-isolate approximation the totals already make.
106
+ */
107
+ let currentRow: OpenBatchRow | null = null;
108
+
109
+ /**
110
+ * Wall-stamped persisted-cursor advances, oldest first. The benchmark's drain
111
+ * gate reads THESE rather than observing the cursor from a timer or a
112
+ * cross-thread poll: any observation that has to be scheduled onto the
113
+ * worker's event loop queues behind the very drain burst it is measuring and
114
+ * reports the queue's latency as drain. A stamp taken synchronously inside
115
+ * the acknowledge stage cannot be deferred by anything.
116
+ */
117
+ export interface AcknowledgeStamp {
118
+ readonly syncId: number;
119
+ readonly atWallMs: number;
120
+ }
121
+ const ACK_STAMP_CAP = 512;
122
+ let ackStamps: AcknowledgeStamp[] = [];
123
+
124
+ /**
125
+ * Record a persisted-cursor advance. Called by the pipeline's acknowledge
126
+ * stage. Unlike every stage timer here, this is NOT gated on the profiler
127
+ * flag: it is one wall-clock read and one bounded push per flush batch —
128
+ * nothing against the batch's own work — and the certification benchmark
129
+ * runs unprofiled (the profiler costs ~15%), so the honest drain stamp must
130
+ * exist without it.
131
+ */
132
+ export function observeDrainAcknowledge(syncId: number): void {
133
+ ackStamps.push({ syncId, atWallMs: Date.now() });
134
+ if (ackStamps.length > ACK_STAMP_CAP) ackStamps.shift();
135
+ }
136
+
137
+ /** The recorded persisted-advance stamps, oldest first. */
138
+ export function drainAcknowledgeStamps(): readonly AcknowledgeStamp[] {
139
+ return [...ackStamps];
140
+ }
141
+
142
+ /** Begin a batch row. Called by the pipeline at flush entry when profiling. */
143
+ export function openDrainBatchRow(deltaCount: number): void {
144
+ if (!enabled) return;
145
+ currentRow = { startedAtWallMs: Date.now(), deltas: deltaCount, stages: {} };
146
+ }
147
+
148
+ /** Close the open batch row and commit it to the ring. */
149
+ export function closeDrainBatchRow(): void {
150
+ if (!enabled || currentRow === null) return;
151
+ batchRows.push({
152
+ startedAtWallMs: currentRow.startedAtWallMs,
153
+ endedAtWallMs: Date.now(),
154
+ deltas: currentRow.deltas,
155
+ stages: currentRow.stages,
156
+ });
157
+ if (batchRows.length > BATCH_ROW_CAP) batchRows.shift();
158
+ currentRow = null;
159
+ }
160
+
75
161
  /**
76
162
  * Read once. A profiler that consults the environment on every delta would
77
163
  * itself become a per-delta cost in the path it is measuring.
@@ -98,6 +184,9 @@ export function observeDrainStage(stage: DrainStage, elapsedMs: number): void {
98
184
  const entry = totals[stage];
99
185
  entry.totalMs += elapsedMs;
100
186
  entry.calls += 1;
187
+ if (currentRow !== null) {
188
+ currentRow.stages[stage] = (currentRow.stages[stage] ?? 0) + elapsedMs;
189
+ }
101
190
  mark(elapsedMs);
102
191
  }
103
192
 
@@ -150,6 +239,7 @@ export function drainProfileSnapshot(): DrainProfile {
150
239
  deduplicated,
151
240
  spanMs: firstMark === undefined ? 0 : lastMark - firstMark,
152
241
  stages,
242
+ recentBatches: [...batchRows],
153
243
  };
154
244
  }
155
245
 
@@ -161,4 +251,7 @@ export function resetDrainProfile(): void {
161
251
  deduplicated = 0;
162
252
  firstMark = undefined;
163
253
  lastMark = 0;
254
+ batchRows = [];
255
+ currentRow = null;
256
+ ackStamps = [];
164
257
  }
@@ -647,10 +647,20 @@ export class MutationQueue extends EventEmitter {
647
647
  let holdsEntireBatch = false;
648
648
 
649
649
  for (const notification of notifications) {
650
- const candidates = targets.filter((target) => target.id === notification.id);
650
+ // Scope is decided before any target matching. A group premise fires over
651
+ // the WHOLE batch by convention, and its `target` now names the row that
652
+ // actually moved — which may well be a row this batch is writing, so
653
+ // matching first would misread a batch-wide hold as a per-row one.
654
+ if (notification.scope === 'group') {
655
+ holdsEntireBatch = true;
656
+ continue;
657
+ }
658
+ const candidates = targets.filter(
659
+ (target) => target.id === notification.target.id,
660
+ );
651
661
  const notificationKey = this.receiptTargetKey(
652
- notification.model,
653
- notification.id,
662
+ notification.target.model,
663
+ notification.target.id,
654
664
  );
655
665
  const exactTargets = candidates.filter(
656
666
  (target) => target.key === notificationKey,
@@ -661,7 +671,7 @@ export class MutationQueue extends EventEmitter {
661
671
  ),
662
672
  );
663
673
 
664
- if (notification.group || candidates.length === 0) {
674
+ if (candidates.length === 0) {
665
675
  holdsEntireBatch = true;
666
676
  continue;
667
677
  }
@@ -27,6 +27,7 @@ import { getContext } from '../context.js';
27
27
  interface M1Target {
28
28
  _hasCustomObservability?: boolean;
29
29
  _isConstructing?: boolean;
30
+ _isHydrating?: boolean;
30
31
  _extraMobxAnnotations?: Record<string, AnnotationMapEntry>;
31
32
  setupObservability?(): void;
32
33
  propertyChanged?(name: string, oldValue: unknown, newValue: unknown): void;
@@ -282,6 +283,10 @@ export function M1<T extends M1Target>(
282
283
  // pre-construct models with partial data then bulk-assign
283
284
  // would otherwise spuriously fill `modifiedProperties`.
284
285
  if (target._isConstructing) return;
286
+ // Hydration writes (`updateFromData`) are inbound wire data,
287
+ // not user edits: the forward's result is discarded by the
288
+ // `modifiedProperties` swap anyway, so skip the work.
289
+ if (target._isHydrating) return;
285
290
  if (typeof target.propertyChanged === 'function') {
286
291
  target.propertyChanged(
287
292
  propName,