@abloatai/humans 0.39.0 → 0.41.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.
@@ -126,6 +126,15 @@ export interface SmartSyncOptions {
126
126
  maxBootstrapSize?: number;
127
127
  batchingDelay?: number;
128
128
  maxBatchSize?: number;
129
+ /**
130
+ * Upper bound on deltas revealed per apply slice. A large flush batch is
131
+ * split at TRANSACTION boundaries into slices of at most this many deltas,
132
+ * with the event loop yielded between slices, so a catch-up wave never
133
+ * holds the thread for one long synchronous apply. A transaction larger
134
+ * than the bound still applies whole — the commit stays the atomic unit of
135
+ * visibility. `Infinity` restores single-slice behavior.
136
+ */
137
+ applySliceDeltas?: number;
129
138
  }
130
139
  export type { RehydrationStats } from './sync/bootstrapApply.js';
131
140
  /**
@@ -36,6 +36,15 @@ import * as groupChange from './sync/groupChange.js';
36
36
  import * as bootstrapApply from './sync/bootstrapApply.js';
37
37
  import * as deltaPipeline from './sync/deltaPipeline.js';
38
38
  import { queryByClass as runQueryByClass, countModels } from './store/queryApi.js';
39
+ /** Bench-diagnostic slice-bound override; absent everywhere but the bench. */
40
+ function benchApplySliceOverride() {
41
+ const host = globalThis;
42
+ const raw = host.process?.env?.ABLO_APPLY_SLICE_DELTAS;
43
+ if (!raw)
44
+ return undefined;
45
+ const value = Number(raw);
46
+ return Number.isFinite(value) && value > 0 ? value : undefined;
47
+ }
39
48
  /**
40
49
  * Bootstrap retry configuration.
41
50
  *
@@ -378,6 +387,18 @@ export class BaseSyncedStore {
378
387
  // trickle just as well and keeps burst tails inside the drain budget.
379
388
  batchingDelay: 10,
380
389
  maxBatchSize: 50,
390
+ // ~600 deltas ≈ 9 to 14 ms of apply — inside a no-visible-stall
391
+ // budget, and a full 500-op commit reveals in one slice. The yield
392
+ // itself is TIME-budgeted in the pipeline (one or two yields per
393
+ // batch), because a host yield costs milliseconds under load.
394
+ // History: the "sliced-apply wedge" that briefly held this at
395
+ // Infinity was kernel memory limits against the bench's many-isolate
396
+ // process (semispace commits refused at stock max_map_count /
397
+ // CommitLimit), not this pipeline — with the limits raised, the
398
+ // sliced path ran the full certification load with zero errors and
399
+ // cut writer ack latency threefold. `ABLO_APPLY_SLICE_DELTAS` remains
400
+ // the bench-diagnostic override.
401
+ applySliceDeltas: benchApplySliceOverride() ?? 600,
381
402
  };
382
403
  // Create internal helpers
383
404
  this.queryProcessor = new QueryProcessor({
@@ -60,6 +60,18 @@ export declare class InstanceCache {
60
60
  constructor(config?: PoolConfig, modelRegistry?: ModelRegistry);
61
61
  private resolveModel;
62
62
  get<T extends Model = Model>(id: string): T | undefined;
63
+ /**
64
+ * Ingestion-side lookup: resolve a row WITHOUT crossing the consumer
65
+ * boundary. Same resolution semantics as {@link get} — weak-ref revival,
66
+ * disposed rows filtered, access recency stamped — but the model is NOT
67
+ * activated. The delta-apply loop reads every row it updates, and reading
68
+ * through `get()` made the stream itself install per-field MobX
69
+ * instrumentation on rows no consumer ever observes; that activation is
70
+ * the dominant term of apply cost (measured 12.9 vs 2.9 µs/delta in
71
+ * `applyPool.bench.test.ts`). Consumer reads must keep using `get()`,
72
+ * which is what activates a deferred model.
73
+ */
74
+ peek<T extends Model = Model>(id: string): T | undefined;
63
75
  /**
64
76
  * Look a row up **within one model**.
65
77
  *
@@ -226,6 +238,16 @@ export declare class InstanceCache {
226
238
  * on later evictions until it is no longer observed.
227
239
  */
228
240
  private evictOldestBatch;
241
+ /**
242
+ * Data-field keys to probe per model, resolved once per model name. Only
243
+ * stored-value fields can carry bulk; walking the whole instance also
244
+ * visited the base class's bookkeeping (`_mobxProperties`,
245
+ * `modifiedProperties`, `validationRules`, …) and JSON-stringified each of
246
+ * those empty containers on EVERY add — a first-order term of wire-ingest
247
+ * create apply. Reference/computed keys are deliberately excluded: reading
248
+ * them would execute their getters.
249
+ */
250
+ private sizeProbeKeys;
229
251
  private isLargeModel;
230
252
  /**
231
253
  * Register a foreign key field for indexing on a model type.
@@ -11,7 +11,7 @@ import { DEFER_MODEL_OBSERVABILITY, Model } from './Model.js';
11
11
  import { ModelRegistry } from './ModelRegistry.js';
12
12
  import { globalRuntime } from './context.js';
13
13
  import { AbloValidationError } from '@abloatai/transaction/errors';
14
- import { ModelScope } from '@abloatai/transaction/types';
14
+ import { ModelScope, PropertyType } from '@abloatai/transaction/types';
15
15
  import { ViewRegistry } from './views/ViewRegistry.js';
16
16
  import { QueryView } from './views/QueryView.js';
17
17
  // Re-exported so `import { ModelScope } from './InstanceCache.js'` resolves
@@ -21,9 +21,18 @@ export { ModelScope };
21
21
  * entity resolves to a single instance.
22
22
  */
23
23
  export class InstanceCache {
24
- // Single source of truth for all models (observable for reactivity)
25
- entries = observable.map();
26
- typeIndex = observable.map();
24
+ // Single source of truth for all models (observable for reactivity).
25
+ // Shallow on purpose, and the reactivity contract depends on it: readers
26
+ // track MEMBERSHIP (keys), and every entry-level change re-sets the whole
27
+ // entry (see `updateScope`) — in-place field writes on an entry do not
28
+ // notify. The deep default additionally converted every stored ModelEntry
29
+ // into an observable object, a per-add extendObservable/defineProperty
30
+ // pass that profiled as the single largest term of create-apply on the
31
+ // wire-ingestion path, bought nothing the contract uses, and meant
32
+ // `entries.get()` returned a converted wrapper rather than the object
33
+ // the insert stored.
34
+ entries = observable.map({}, { deep: false });
35
+ typeIndex = observable.map({}, { deep: false });
27
36
  // Non-observable access time tracking — kept outside observable.map so that
28
37
  // updating timestamps in get() during React render does NOT trigger MobX
29
38
  // reactions (which would cause infinite re-render loops).
@@ -205,6 +214,37 @@ export class InstanceCache {
205
214
  model?.ensureObservable();
206
215
  return model ?? undefined;
207
216
  }
217
+ /**
218
+ * Ingestion-side lookup: resolve a row WITHOUT crossing the consumer
219
+ * boundary. Same resolution semantics as {@link get} — weak-ref revival,
220
+ * disposed rows filtered, access recency stamped — but the model is NOT
221
+ * activated. The delta-apply loop reads every row it updates, and reading
222
+ * through `get()` made the stream itself install per-field MobX
223
+ * instrumentation on rows no consumer ever observes; that activation is
224
+ * the dominant term of apply cost (measured 12.9 vs 2.9 µs/delta in
225
+ * `applyPool.bench.test.ts`). Consumer reads must keep using `get()`,
226
+ * which is what activates a deferred model.
227
+ */
228
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
229
+ peek(id) {
230
+ const entry = this.entries.get(id);
231
+ if (!entry)
232
+ return undefined;
233
+ let model = entry.model;
234
+ if (!model && entry.weakRef) {
235
+ const restoredModel = entry.weakRef.deref();
236
+ if (!restoredModel)
237
+ return undefined;
238
+ model = restoredModel;
239
+ runInAction(() => {
240
+ entry.model = restoredModel;
241
+ });
242
+ }
243
+ if (model?.disposed)
244
+ return undefined;
245
+ this.touchAccess(id, Date.now());
246
+ return model ?? undefined;
247
+ }
208
248
  /**
209
249
  * Look a row up **within one model**.
210
250
  *
@@ -588,6 +628,11 @@ export class InstanceCache {
588
628
  continue;
589
629
  const model = this.resolveModel(entry, id);
590
630
  if (model && !model.disposed) {
631
+ // Bulk reads are a consumer boundary: rows land cold from the wire
632
+ // (delta ingestion applies with `peek` and never activates), so
633
+ // every read that hands models out must activate them, exactly as
634
+ // `get()` does. Idempotent — an active row pays one boolean check.
635
+ model.ensureObservable();
591
636
  result.push(model);
592
637
  }
593
638
  }
@@ -613,6 +658,8 @@ export class InstanceCache {
613
658
  continue;
614
659
  const model = this.resolveModel(entry, id);
615
660
  if (model && !model.disposed) {
661
+ // Consumer boundary — activate, as in getByType.
662
+ model.ensureObservable();
616
663
  result.push(model);
617
664
  }
618
665
  }
@@ -1030,14 +1077,37 @@ export class InstanceCache {
1030
1077
  }
1031
1078
  });
1032
1079
  }
1080
+ /**
1081
+ * Data-field keys to probe per model, resolved once per model name. Only
1082
+ * stored-value fields can carry bulk; walking the whole instance also
1083
+ * visited the base class's bookkeeping (`_mobxProperties`,
1084
+ * `modifiedProperties`, `validationRules`, …) and JSON-stringified each of
1085
+ * those empty containers on EVERY add — a first-order term of wire-ingest
1086
+ * create apply. Reference/computed keys are deliberately excluded: reading
1087
+ * them would execute their getters.
1088
+ */
1089
+ sizeProbeKeys = new Map();
1033
1090
  isLargeModel(model) {
1034
1091
  try {
1035
- // Most synchronized rows contain only short scalars. Serializing the
1036
- // entire model merely to discover that fact doubled wire-ingest object
1037
- // walking. Accumulate cheap scalar sizes and serialize only nested
1038
- // payloads that can plausibly cross the WeakRef threshold.
1092
+ const modelName = model.getModelName();
1093
+ let keys = this.sizeProbeKeys.get(modelName);
1094
+ if (!keys) {
1095
+ const props = this.registry.getProperties(modelName);
1096
+ keys = [...props.entries()]
1097
+ .filter(([, meta]) => meta.type === PropertyType.property || meta.type === PropertyType.ephemeralProperty)
1098
+ .map(([key]) => key);
1099
+ this.sizeProbeKeys.set(modelName, keys);
1100
+ }
1101
+ // A model with no registered stored fields (e.g. a custom entity) keeps
1102
+ // the whole-instance walk, since its payload lives outside the schema.
1103
+ const values = keys.length > 0
1104
+ ? keys.map((key) => Reflect.get(model, key))
1105
+ : Object.values(model);
1106
+ // Most synchronized rows contain only short scalars. Accumulate cheap
1107
+ // scalar sizes and serialize only nested payloads that can plausibly
1108
+ // cross the WeakRef threshold.
1039
1109
  let size = 0;
1040
- for (const value of Object.values(model)) {
1110
+ for (const value of values) {
1041
1111
  if (typeof value === 'string') {
1042
1112
  size += value.length;
1043
1113
  }
@@ -1134,6 +1204,9 @@ export class InstanceCache {
1134
1204
  }
1135
1205
  const model = this.resolveModel(entry, id);
1136
1206
  if (model && !model.disposed) {
1207
+ // Consumer boundary — activate, as in getByType. `hasMany` reads
1208
+ // (`section.blocks`) observe fields off these rows directly.
1209
+ model.ensureObservable();
1137
1210
  result.push(model);
1138
1211
  }
1139
1212
  else if (model?.disposed) {
@@ -81,6 +81,16 @@ export declare abstract class Model {
81
81
  clientId: string;
82
82
  /** MobX observable properties storage */
83
83
  _mobxProperties: ModelData;
84
+ /**
85
+ * True while {@link updateFromData} assigns inbound wire data. The
86
+ * `observe()` bridge (`mobxSetup.M1`) checks it and skips the forward to
87
+ * {@link propertyChanged} entirely: hydration writes are not user edits, and
88
+ * the bridge's work per changed field — a nested action, map bookkeeping,
89
+ * and a fabricated `updatedAt` stamp — was already being discarded by the
90
+ * `modifiedProperties` swap in `updateFromData`. The swap stays as the
91
+ * correctness backstop; this flag removes the cost.
92
+ */
93
+ _isHydrating: boolean;
84
94
  /** Referenced models cache */
85
95
  _referencedModels: Record<string, Model | null>;
86
96
  /** Track property changes */
@@ -47,6 +47,16 @@ export class Model {
47
47
  clientId;
48
48
  /** MobX observable properties storage */
49
49
  _mobxProperties = {};
50
+ /**
51
+ * True while {@link updateFromData} assigns inbound wire data. The
52
+ * `observe()` bridge (`mobxSetup.M1`) checks it and skips the forward to
53
+ * {@link propertyChanged} entirely: hydration writes are not user edits, and
54
+ * the bridge's work per changed field — a nested action, map bookkeeping,
55
+ * and a fabricated `updatedAt` stamp — was already being discarded by the
56
+ * `modifiedProperties` swap in `updateFromData`. The swap stays as the
57
+ * correctness backstop; this flag removes the cost.
58
+ */
59
+ _isHydrating = false;
50
60
  /** Referenced models cache */
51
61
  _referencedModels = {};
52
62
  /** Track property changes */
@@ -594,9 +604,15 @@ export class Model {
594
604
  runInAction(() => {
595
605
  const originalTracking = this.modifiedProperties;
596
606
  this.modifiedProperties = new Map();
597
- // No `onWrite` → this call records nothing itself.
598
- this.assignFieldsFromData(data);
599
- this.modifiedProperties = originalTracking;
607
+ this._isHydrating = true;
608
+ try {
609
+ // No `onWrite` → this call records nothing itself.
610
+ this.assignFieldsFromData(data);
611
+ }
612
+ finally {
613
+ this._isHydrating = false;
614
+ this.modifiedProperties = originalTracking;
615
+ }
600
616
  });
601
617
  // Mark as persisted if updating existing model
602
618
  if (!this._isNew) {
@@ -1579,7 +1579,13 @@ export class SyncClient extends EventEmitter {
1579
1579
  }
1580
1580
  switch (action) {
1581
1581
  case 'add': {
1582
- const existing = this.objectPool.get(modelId);
1582
+ // `peek`, not `get`: this loop is ingestion, not a consumer read.
1583
+ // `get()` activates deferred MobX instrumentation, so reading
1584
+ // through it here made the delta stream itself instrument every
1585
+ // row it touched — the dominant term of apply cost for rows no
1586
+ // consumer observes. Activation belongs to the consumer-facing
1587
+ // reads (`get`, views, subscribers), which are unchanged.
1588
+ const existing = this.objectPool.peek(modelId);
1583
1589
  if (existing) {
1584
1590
  existing.markAsSynced();
1585
1591
  }
@@ -1594,7 +1600,7 @@ export class SyncClient extends EventEmitter {
1594
1600
  break;
1595
1601
  }
1596
1602
  case 'update': {
1597
- const existing = this.objectPool.get(modelId);
1603
+ const existing = this.objectPool.peek(modelId);
1598
1604
  if (existing && !existing.disposed && result.data) {
1599
1605
  enrichRelations(modelName, result.data);
1600
1606
  const resolved = this.resolveConflicts(existing, result.data);
@@ -38,6 +38,7 @@ export interface DeltaPipelineContext {
38
38
  readonly smartSyncOptions: {
39
39
  readonly batchingDelay: number;
40
40
  readonly maxBatchSize: number;
41
+ readonly applySliceDeltas: number;
41
42
  };
42
43
  /** Pool-applied cursor (`syncClient.position.applied`). */
43
44
  readonly highestProcessedSyncId: number;
@@ -62,7 +63,8 @@ export interface DeltaPipelineContext {
62
63
  /** Acknowledges a sync id back to the server; a no-op when the socket is down. */
63
64
  acknowledge(syncId: number): void;
64
65
  readonly objectPool: {
65
- get(id: string): Model | undefined;
66
+ /** Ingestion-side lookup resolves without activating observability. */
67
+ peek(id: string): Model | undefined;
66
68
  add(model: Model, scope: ModelScope): void;
67
69
  remove(id: string): boolean;
68
70
  /** Full in-memory clear — the revocation-failure fallback (see
@@ -114,3 +116,32 @@ export declare function applyDeltaFrame(ctx: DeltaPipelineContext, deltas: SyncD
114
116
  * and advances the acknowledgement cursor once the store write has committed.
115
117
  */
116
118
  export declare function flushPendingDeltas(ctx: DeltaPipelineContext): Promise<void>;
119
+ /**
120
+ * Wedge forensics: where the pipeline currently is, updated synchronously at
121
+ * every stage boundary. A hang diagnoses itself by which counter pair
122
+ * diverged and which phase the active flush froze in. Mirrored onto
123
+ * `globalThis.__abloPipelineDebug` so a bench watchdog in the same thread
124
+ * can read it without an import path into SDK internals — diagnostics only,
125
+ * a handful of numbers, no payload data.
126
+ */
127
+ export declare const pipelineDebug: {
128
+ flushesStarted: number;
129
+ flushesSettled: number;
130
+ persistsStarted: number;
131
+ persistsSettled: number;
132
+ applySlices: number;
133
+ applyYields: number;
134
+ enqueued: number;
135
+ phase: string;
136
+ };
137
+ /**
138
+ * Split applied changes into slices of at most `maxDeltas`, never splitting a
139
+ * transaction: consecutive changes sharing a `transactionId` form one
140
+ * indivisible group (a commit reveals whole), while changes without one are
141
+ * individually splittable. A single transaction larger than the bound forms
142
+ * its own oversized slice, so apply always advances rather than stalling on
143
+ * an oversized commit — the same rule the server's publication chunking uses.
144
+ */
145
+ export declare function sliceApplyChanges<T extends {
146
+ readonly transactionId?: string;
147
+ }>(changes: readonly T[], maxDeltas: number): readonly T[][];
@@ -199,6 +199,7 @@ export function enqueueDelta(ctx, delta, options = {}) {
199
199
  // The delta is accepted and queued — the `receive` stage boundary.
200
200
  runStage(ctx.stagePlugins ?? [], 'receive', { delta });
201
201
  ctx.pendingDeltas.push(delta);
202
+ pipelineDebug.enqueued += 1;
202
203
  return true;
203
204
  }
204
205
  /** Debounce a flush for live single-delta traffic. */
@@ -285,6 +286,68 @@ async function drainPendingDeltas(ctx) {
285
286
  ctx.batchTimer = null;
286
287
  }
287
288
  }
289
+ /**
290
+ * Uninterrupted apply time allowed before the sliced loop yields — the
291
+ * "no visible stall" bound. Yields are amortized against it because one host
292
+ * yield costs milliseconds under load; at the measured per-delta apply cost
293
+ * this works out to roughly one yield per one to two 600-delta slices.
294
+ */
295
+ const APPLY_YIELD_BUDGET_MS = 12;
296
+ /**
297
+ * Wedge forensics: where the pipeline currently is, updated synchronously at
298
+ * every stage boundary. A hang diagnoses itself by which counter pair
299
+ * diverged and which phase the active flush froze in. Mirrored onto
300
+ * `globalThis.__abloPipelineDebug` so a bench watchdog in the same thread
301
+ * can read it without an import path into SDK internals — diagnostics only,
302
+ * a handful of numbers, no payload data.
303
+ */
304
+ export const pipelineDebug = {
305
+ flushesStarted: 0,
306
+ flushesSettled: 0,
307
+ persistsStarted: 0,
308
+ persistsSettled: 0,
309
+ applySlices: 0,
310
+ applyYields: 0,
311
+ enqueued: 0,
312
+ phase: 'idle',
313
+ };
314
+ globalThis.__abloPipelineDebug =
315
+ pipelineDebug;
316
+ /**
317
+ * Split applied changes into slices of at most `maxDeltas`, never splitting a
318
+ * transaction: consecutive changes sharing a `transactionId` form one
319
+ * indivisible group (a commit reveals whole), while changes without one are
320
+ * individually splittable. A single transaction larger than the bound forms
321
+ * its own oversized slice, so apply always advances rather than stalling on
322
+ * an oversized commit — the same rule the server's publication chunking uses.
323
+ */
324
+ export function sliceApplyChanges(changes, maxDeltas) {
325
+ if (changes.length <= maxDeltas)
326
+ return changes.length > 0 ? [[...changes]] : [];
327
+ const slices = [];
328
+ let current = [];
329
+ let index = 0;
330
+ while (index < changes.length) {
331
+ // The indivisible unit starting here: one transaction's run, or a single
332
+ // untransacted change.
333
+ const transactionId = changes[index].transactionId;
334
+ let end = index + 1;
335
+ if (transactionId !== undefined) {
336
+ while (end < changes.length && changes[end].transactionId === transactionId)
337
+ end += 1;
338
+ }
339
+ const groupSize = end - index;
340
+ if (current.length > 0 && current.length + groupSize > maxDeltas) {
341
+ slices.push(current);
342
+ current = [];
343
+ }
344
+ current.push(...changes.slice(index, end));
345
+ index = end;
346
+ }
347
+ if (current.length > 0)
348
+ slices.push(current);
349
+ return slices;
350
+ }
288
351
  function yieldToHost() {
289
352
  const immediate = globalThis.setImmediate;
290
353
  return new Promise((resolve) => {
@@ -296,15 +359,19 @@ function yieldToHost() {
296
359
  }
297
360
  async function flushDeltaBatch(ctx, queuedDeltas) {
298
361
  openDrainBatchRow(queuedDeltas.length);
362
+ pipelineDebug.flushesStarted += 1;
299
363
  try {
300
364
  await flushDeltaBatchInner(ctx, queuedDeltas);
301
365
  }
302
366
  finally {
367
+ pipelineDebug.flushesSettled += 1;
368
+ pipelineDebug.phase = 'idle';
303
369
  closeDrainBatchRow();
304
370
  }
305
371
  }
306
372
  async function flushDeltaBatchInner(ctx, queuedDeltas) {
307
373
  const stagePlugins = ctx.stagePlugins ?? [];
374
+ pipelineDebug.phase = 'dedupe';
308
375
  const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
309
376
  observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
310
377
  runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
@@ -320,7 +387,7 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
320
387
  // gained permission to see the entity, so we insert it into the
321
388
  // pool as if newly created.
322
389
  if (delta.actionType === 'I' || delta.actionType === 'U' || delta.actionType === 'C') {
323
- const existing = ctx.objectPool.get(delta.modelId);
390
+ const existing = ctx.objectPool.peek(delta.modelId);
324
391
  if (existing) {
325
392
  existing.updateFromData(data);
326
393
  }
@@ -343,6 +410,8 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
343
410
  // handleGroupRemoved) and never reach here, though the persistence
344
411
  // signature accepts them defensively.
345
412
  const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
413
+ pipelineDebug.phase = 'persist';
414
+ pipelineDebug.persistsStarted += 1;
346
415
  const batch = await timeDrainStageAsync('persist', () => ctx.processDeltaBatch(regularDeltas.map((d) => ({
347
416
  syncId: d.id,
348
417
  actionType: d.actionType,
@@ -353,6 +422,7 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
353
422
  // echoes of locally-applied transactions and skip the pool mutation.
354
423
  transactionId: d.transactionId,
355
424
  }))));
425
+ pipelineDebug.persistsSettled += 1;
356
426
  const dbResults = batch.results;
357
427
  runStage(stagePlugins, 'persist', { deltas: regularDeltas });
358
428
  // Apply the batch results to the in-memory graph. When a plugin has
@@ -360,14 +430,42 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
360
430
  // materialiser attached where it said it would. The direct call is the
361
431
  // bridge for stores constructed without plugins (subclasses, tests),
362
432
  // whose own apply is the whole pipeline.
363
- timeDrainStage('apply', () => {
364
- if (pluginsForStage(stagePlugins, 'apply').length > 0) {
365
- runStage(stagePlugins, 'apply', { changes: dbResults });
366
- }
367
- else {
368
- ctx.applyDeltaBatchToPool(dbResults);
433
+ //
434
+ // Large batches apply in TIME SLICES: split at transaction boundaries into
435
+ // bounded chunks with the event loop yielded between them, so a catch-up
436
+ // wave reveals commit-by-commit instead of holding the thread for one long
437
+ // synchronous block. Each slice is still one MobX action (reactions fire
438
+ // once per slice), and a transaction never splits across slices — the
439
+ // commit remains the atomic unit of visibility.
440
+ const slices = sliceApplyChanges(dbResults, ctx.smartSyncOptions.applySliceDeltas);
441
+ await timeDrainStageAsync('apply', async () => {
442
+ const hasApplyPlugins = pluginsForStage(stagePlugins, 'apply').length > 0;
443
+ // Yield on a TIME budget, not per slice: a host yield costs milliseconds
444
+ // under load (measured ~7x throughput collapse when yielding every few
445
+ // deltas), so the yield decision amortizes it — only after the budget of
446
+ // uninterrupted apply work has been spent, and never before the first
447
+ // slice. Slices stay the atomicity unit; the budget only decides where
448
+ // the loop breathes.
449
+ let sliceStartedAt = performance.now();
450
+ for (let index = 0; index < slices.length; index++) {
451
+ if (index > 0 && performance.now() - sliceStartedAt > APPLY_YIELD_BUDGET_MS) {
452
+ pipelineDebug.phase = `apply-yield-${index}`;
453
+ pipelineDebug.applyYields += 1;
454
+ await yieldToHost();
455
+ sliceStartedAt = performance.now();
456
+ }
457
+ pipelineDebug.phase = `apply-slice-${index}`;
458
+ pipelineDebug.applySlices += 1;
459
+ const slice = slices[index];
460
+ if (hasApplyPlugins) {
461
+ runStage(stagePlugins, 'apply', { changes: slice });
462
+ }
463
+ else {
464
+ ctx.applyDeltaBatchToPool(slice);
465
+ }
369
466
  }
370
467
  });
468
+ pipelineDebug.phase = 'acknowledge';
371
469
  // Acknowledge and advance the sync cursor, gated on persistence.
372
470
  //
373
471
  // We must acknowledge `persistedSyncId` — the high-water mark of deltas whose
@@ -419,11 +419,19 @@ export class MutationQueue extends EventEmitter {
419
419
  const notificationsByTarget = new Map();
420
420
  let holdsEntireBatch = false;
421
421
  for (const notification of notifications) {
422
- const candidates = targets.filter((target) => target.id === notification.id);
423
- const notificationKey = this.receiptTargetKey(notification.model, notification.id);
422
+ // Scope is decided before any target matching. A group premise fires over
423
+ // the WHOLE batch by convention, and its `target` now names the row that
424
+ // actually moved — which may well be a row this batch is writing, so
425
+ // matching first would misread a batch-wide hold as a per-row one.
426
+ if (notification.scope === 'group') {
427
+ holdsEntireBatch = true;
428
+ continue;
429
+ }
430
+ const candidates = targets.filter((target) => target.id === notification.target.id);
431
+ const notificationKey = this.receiptTargetKey(notification.target.model, notification.target.id);
424
432
  const exactTargets = candidates.filter((target) => target.key === notificationKey);
425
433
  const candidateKeys = new Set((exactTargets.length > 0 ? exactTargets : candidates).map((target) => target.key));
426
- if (notification.group || candidates.length === 0) {
434
+ if (candidates.length === 0) {
427
435
  holdsEntireBatch = true;
428
436
  continue;
429
437
  }
@@ -16,6 +16,7 @@ import { type PropertyMetadata, type ReferenceMetadata } from '@abloatai/transac
16
16
  interface M1Target {
17
17
  _hasCustomObservability?: boolean;
18
18
  _isConstructing?: boolean;
19
+ _isHydrating?: boolean;
19
20
  _extraMobxAnnotations?: Record<string, AnnotationMapEntry>;
20
21
  setupObservability?(): void;
21
22
  propertyChanged?(name: string, oldValue: unknown, newValue: unknown): void;
@@ -245,6 +245,11 @@ export function M1(target, propertyMetadata, referenceMetadata) {
245
245
  // would otherwise spuriously fill `modifiedProperties`.
246
246
  if (target._isConstructing)
247
247
  return;
248
+ // Hydration writes (`updateFromData`) are inbound wire data,
249
+ // not user edits: the forward's result is discarded by the
250
+ // `modifiedProperties` swap anyway, so skip the work.
251
+ if (target._isHydrating)
252
+ return;
248
253
  if (typeof target.propertyChanged === 'function') {
249
254
  target.propertyChanged(propName, change.oldValue, change.newValue);
250
255
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.39.0",
3
+ "version": "0.41.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.39.0",
87
+ "@abloatai/transaction": "^0.41.0",
88
88
  "mobx": "^6.13.7",
89
89
  "uuid": "^11.1.0",
90
90
  "zod": "^4.4.3"
@@ -187,12 +187,30 @@ export interface SmartSyncOptions {
187
187
  maxBootstrapSize?: number;
188
188
  batchingDelay?: number;
189
189
  maxBatchSize?: number;
190
+ /**
191
+ * Upper bound on deltas revealed per apply slice. A large flush batch is
192
+ * split at TRANSACTION boundaries into slices of at most this many deltas,
193
+ * with the event loop yielded between slices, so a catch-up wave never
194
+ * holds the thread for one long synchronous apply. A transaction larger
195
+ * than the bound still applies whole — the commit stays the atomic unit of
196
+ * visibility. `Infinity` restores single-slice behavior.
197
+ */
198
+ applySliceDeltas?: number;
190
199
  }
191
200
 
192
201
  // RehydrationStats is defined alongside the bootstrap-apply path and is
193
202
  // re-exported here.
194
203
  export type { RehydrationStats } from './sync/bootstrapApply.js';
195
204
 
205
+ /** Bench-diagnostic slice-bound override; absent everywhere but the bench. */
206
+ function benchApplySliceOverride(): number | undefined {
207
+ const host = globalThis as { process?: { env?: Record<string, string | undefined> } };
208
+ const raw = host.process?.env?.ABLO_APPLY_SLICE_DELTAS;
209
+ if (!raw) return undefined;
210
+ const value = Number(raw);
211
+ return Number.isFinite(value) && value > 0 ? value : undefined;
212
+ }
213
+
196
214
  /**
197
215
  * Bootstrap retry configuration.
198
216
  *
@@ -652,6 +670,18 @@ export class BaseSyncedStore<
652
670
  // trickle just as well and keeps burst tails inside the drain budget.
653
671
  batchingDelay: 10,
654
672
  maxBatchSize: 50,
673
+ // ~600 deltas ≈ 9 to 14 ms of apply — inside a no-visible-stall
674
+ // budget, and a full 500-op commit reveals in one slice. The yield
675
+ // itself is TIME-budgeted in the pipeline (one or two yields per
676
+ // batch), because a host yield costs milliseconds under load.
677
+ // History: the "sliced-apply wedge" that briefly held this at
678
+ // Infinity was kernel memory limits against the bench's many-isolate
679
+ // process (semispace commits refused at stock max_map_count /
680
+ // CommitLimit), not this pipeline — with the limits raised, the
681
+ // sliced path ran the full certification load with zero errors and
682
+ // cut writer ack latency threefold. `ABLO_APPLY_SLICE_DELTAS` remains
683
+ // the bench-diagnostic override.
684
+ applySliceDeltas: benchApplySliceOverride() ?? 600,
655
685
  };
656
686
 
657
687
  // Create internal helpers
@@ -13,7 +13,7 @@ import { ModelRegistry } from './ModelRegistry.js';
13
13
  import { globalRuntime } from './context.js';
14
14
  import type { RuntimeContext } from './RuntimeContext.js';
15
15
  import { AbloValidationError } from '@abloatai/transaction/errors';
16
- import { ModelScope } from '@abloatai/transaction/types';
16
+ import { ModelScope, PropertyType } from '@abloatai/transaction/types';
17
17
  import { ViewRegistry } from './views/ViewRegistry.js';
18
18
  import { QueryView, type QueryViewOptions } from './views/QueryView.js';
19
19
 
@@ -48,9 +48,18 @@ interface DeltaInfo {
48
48
  * entity resolves to a single instance.
49
49
  */
50
50
  export class InstanceCache {
51
- // Single source of truth for all models (observable for reactivity)
52
- private entries = observable.map<string, ModelEntry>();
53
- private typeIndex = observable.map<string, Set<string>>();
51
+ // Single source of truth for all models (observable for reactivity).
52
+ // Shallow on purpose, and the reactivity contract depends on it: readers
53
+ // track MEMBERSHIP (keys), and every entry-level change re-sets the whole
54
+ // entry (see `updateScope`) — in-place field writes on an entry do not
55
+ // notify. The deep default additionally converted every stored ModelEntry
56
+ // into an observable object, a per-add extendObservable/defineProperty
57
+ // pass that profiled as the single largest term of create-apply on the
58
+ // wire-ingestion path, bought nothing the contract uses, and meant
59
+ // `entries.get()` returned a converted wrapper rather than the object
60
+ // the insert stored.
61
+ private entries = observable.map<string, ModelEntry>({}, { deep: false });
62
+ private typeIndex = observable.map<string, Set<string>>({}, { deep: false });
54
63
 
55
64
  // Non-observable access time tracking — kept outside observable.map so that
56
65
  // updating timestamps in get() during React render does NOT trigger MobX
@@ -272,6 +281,38 @@ export class InstanceCache {
272
281
  return model ?? undefined;
273
282
  }
274
283
 
284
+ /**
285
+ * Ingestion-side lookup: resolve a row WITHOUT crossing the consumer
286
+ * boundary. Same resolution semantics as {@link get} — weak-ref revival,
287
+ * disposed rows filtered, access recency stamped — but the model is NOT
288
+ * activated. The delta-apply loop reads every row it updates, and reading
289
+ * through `get()` made the stream itself install per-field MobX
290
+ * instrumentation on rows no consumer ever observes; that activation is
291
+ * the dominant term of apply cost (measured 12.9 vs 2.9 µs/delta in
292
+ * `applyPool.bench.test.ts`). Consumer reads must keep using `get()`,
293
+ * which is what activates a deferred model.
294
+ */
295
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
296
+ peek<T extends Model = Model>(id: string): T | undefined {
297
+ const entry = this.entries.get(id);
298
+ if (!entry) return undefined;
299
+
300
+ let model = entry.model as T | undefined;
301
+ if (!model && entry.weakRef) {
302
+ const restoredModel = entry.weakRef.deref();
303
+ if (!restoredModel) return undefined;
304
+ model = restoredModel as T;
305
+ runInAction(() => {
306
+ entry.model = restoredModel;
307
+ });
308
+ }
309
+
310
+ if (model?.disposed) return undefined;
311
+
312
+ this.touchAccess(id, Date.now());
313
+ return model ?? undefined;
314
+ }
315
+
275
316
  /**
276
317
  * Look a row up **within one model**.
277
318
  *
@@ -720,6 +761,11 @@ export class InstanceCache {
720
761
 
721
762
  const model = this.resolveModel(entry, id);
722
763
  if (model && !model.disposed) {
764
+ // Bulk reads are a consumer boundary: rows land cold from the wire
765
+ // (delta ingestion applies with `peek` and never activates), so
766
+ // every read that hands models out must activate them, exactly as
767
+ // `get()` does. Idempotent — an active row pays one boolean check.
768
+ model.ensureObservable();
723
769
  result.push(model);
724
770
  }
725
771
  }
@@ -747,6 +793,8 @@ export class InstanceCache {
747
793
 
748
794
  const model = this.resolveModel(entry, id);
749
795
  if (model && !model.disposed) {
796
+ // Consumer boundary — activate, as in getByType.
797
+ model.ensureObservable();
750
798
  result.push(model);
751
799
  }
752
800
  }
@@ -1272,14 +1320,39 @@ export class InstanceCache {
1272
1320
  });
1273
1321
  }
1274
1322
 
1323
+ /**
1324
+ * Data-field keys to probe per model, resolved once per model name. Only
1325
+ * stored-value fields can carry bulk; walking the whole instance also
1326
+ * visited the base class's bookkeeping (`_mobxProperties`,
1327
+ * `modifiedProperties`, `validationRules`, …) and JSON-stringified each of
1328
+ * those empty containers on EVERY add — a first-order term of wire-ingest
1329
+ * create apply. Reference/computed keys are deliberately excluded: reading
1330
+ * them would execute their getters.
1331
+ */
1332
+ private sizeProbeKeys = new Map<string, readonly string[]>();
1333
+
1275
1334
  private isLargeModel(model: Model): boolean {
1276
1335
  try {
1277
- // Most synchronized rows contain only short scalars. Serializing the
1278
- // entire model merely to discover that fact doubled wire-ingest object
1279
- // walking. Accumulate cheap scalar sizes and serialize only nested
1280
- // payloads that can plausibly cross the WeakRef threshold.
1336
+ const modelName = model.getModelName();
1337
+ let keys = this.sizeProbeKeys.get(modelName);
1338
+ if (!keys) {
1339
+ const props = this.registry.getProperties(modelName);
1340
+ keys = [...props.entries()]
1341
+ .filter(([, meta]) => meta.type === PropertyType.property || meta.type === PropertyType.ephemeralProperty)
1342
+ .map(([key]) => key);
1343
+ this.sizeProbeKeys.set(modelName, keys);
1344
+ }
1345
+ // A model with no registered stored fields (e.g. a custom entity) keeps
1346
+ // the whole-instance walk, since its payload lives outside the schema.
1347
+ const values: Iterable<unknown> = keys.length > 0
1348
+ ? keys.map((key): unknown => Reflect.get(model, key))
1349
+ : Object.values(model);
1350
+
1351
+ // Most synchronized rows contain only short scalars. Accumulate cheap
1352
+ // scalar sizes and serialize only nested payloads that can plausibly
1353
+ // cross the WeakRef threshold.
1281
1354
  let size = 0;
1282
- for (const value of Object.values(model)) {
1355
+ for (const value of values) {
1283
1356
  if (typeof value === 'string') {
1284
1357
  size += value.length;
1285
1358
  } else if (
@@ -1375,6 +1448,9 @@ export class InstanceCache {
1375
1448
  if (!this.matchesScope(entry.scope, ModelScope.live)) { droppedScope++; continue; }
1376
1449
  const model = this.resolveModel(entry, id);
1377
1450
  if (model && !model.disposed) {
1451
+ // Consumer boundary — activate, as in getByType. `hasMany` reads
1452
+ // (`section.blocks`) observe fields off these rows directly.
1453
+ model.ensureObservable();
1378
1454
  result.push(model);
1379
1455
  } else if (model?.disposed) {
1380
1456
  droppedDisposed++;
@@ -121,6 +121,17 @@ export abstract class Model {
121
121
  /** MobX observable properties storage */
122
122
  _mobxProperties: ModelData = {};
123
123
 
124
+ /**
125
+ * True while {@link updateFromData} assigns inbound wire data. The
126
+ * `observe()` bridge (`mobxSetup.M1`) checks it and skips the forward to
127
+ * {@link propertyChanged} entirely: hydration writes are not user edits, and
128
+ * the bridge's work per changed field — a nested action, map bookkeeping,
129
+ * and a fabricated `updatedAt` stamp — was already being discarded by the
130
+ * `modifiedProperties` swap in `updateFromData`. The swap stays as the
131
+ * correctness backstop; this flag removes the cost.
132
+ */
133
+ _isHydrating = false;
134
+
124
135
  /** Referenced models cache */
125
136
  _referencedModels: Record<string, Model | null> = {};
126
137
 
@@ -721,11 +732,14 @@ export abstract class Model {
721
732
  runInAction(() => {
722
733
  const originalTracking = this.modifiedProperties;
723
734
  this.modifiedProperties = new Map();
724
-
725
- // No `onWrite` → this call records nothing itself.
726
- this.assignFieldsFromData(data);
727
-
728
- this.modifiedProperties = originalTracking;
735
+ this._isHydrating = true;
736
+ try {
737
+ // No `onWrite` → this call records nothing itself.
738
+ this.assignFieldsFromData(data);
739
+ } finally {
740
+ this._isHydrating = false;
741
+ this.modifiedProperties = originalTracking;
742
+ }
729
743
  });
730
744
 
731
745
  // Mark as persisted if updating existing model
@@ -1812,6 +1812,7 @@ export class SyncClient extends EventEmitter {
1812
1812
  return this.mutationQueue.confirmationFor(modelName, modelId);
1813
1813
  }
1814
1814
 
1815
+
1815
1816
  /**
1816
1817
  * Get detailed debug info for the sync debug page
1817
1818
  */
@@ -1950,7 +1951,13 @@ export class SyncClient extends EventEmitter {
1950
1951
 
1951
1952
  switch (action) {
1952
1953
  case 'add': {
1953
- 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);
1954
1961
  if (existing) {
1955
1962
  existing.markAsSynced();
1956
1963
  } else if (result.data) {
@@ -1963,7 +1970,7 @@ export class SyncClient extends EventEmitter {
1963
1970
  break;
1964
1971
  }
1965
1972
  case 'update': {
1966
- const existing = this.objectPool.get(modelId);
1973
+ const existing = this.objectPool.peek(modelId);
1967
1974
  if (existing && !existing.disposed && result.data) {
1968
1975
  enrichRelations(modelName, result.data);
1969
1976
  const resolved = this.resolveConflicts(existing, result.data);
@@ -53,7 +53,11 @@ export interface DeltaPipelineContext {
53
53
  batchTimer: ReturnType<typeof setTimeout> | null;
54
54
  /** Queue for deltas arriving during an active bootstrap; null when none. */
55
55
  readonly bootstrapDeltaQueue: SyncDelta[] | null;
56
- readonly smartSyncOptions: { readonly batchingDelay: number; readonly maxBatchSize: number };
56
+ readonly smartSyncOptions: {
57
+ readonly batchingDelay: number;
58
+ readonly maxBatchSize: number;
59
+ readonly applySliceDeltas: number;
60
+ };
57
61
  /** Pool-applied cursor (`syncClient.position.applied`). */
58
62
  readonly highestProcessedSyncId: number;
59
63
  /** Resume/ack cursor (`syncClient.position.persisted`). */
@@ -86,7 +90,8 @@ export interface DeltaPipelineContext {
86
90
 
87
91
  // ── Custom-entity pool ops (deltas that skip the local store) ──
88
92
  readonly objectPool: {
89
- get(id: string): Model | undefined;
93
+ /** Ingestion-side lookup resolves without activating observability. */
94
+ peek(id: string): Model | undefined;
90
95
  add(model: Model, scope: ModelScope): void;
91
96
  remove(id: string): boolean;
92
97
  /** Full in-memory clear — the revocation-failure fallback (see
@@ -312,6 +317,7 @@ export function enqueueDelta(
312
317
  // The delta is accepted and queued — the `receive` stage boundary.
313
318
  runStage(ctx.stagePlugins ?? [], 'receive', { delta });
314
319
  ctx.pendingDeltas.push(delta);
320
+ pipelineDebug.enqueued += 1;
315
321
  return true;
316
322
  }
317
323
 
@@ -400,6 +406,71 @@ async function drainPendingDeltas(ctx: DeltaPipelineContext): Promise<void> {
400
406
  }
401
407
  }
402
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
+
403
474
  function yieldToHost(): Promise<void> {
404
475
  const immediate = (
405
476
  globalThis as {
@@ -417,9 +488,12 @@ async function flushDeltaBatch(
417
488
  queuedDeltas: SyncDelta[],
418
489
  ): Promise<void> {
419
490
  openDrainBatchRow(queuedDeltas.length);
491
+ pipelineDebug.flushesStarted += 1;
420
492
  try {
421
493
  await flushDeltaBatchInner(ctx, queuedDeltas);
422
494
  } finally {
495
+ pipelineDebug.flushesSettled += 1;
496
+ pipelineDebug.phase = 'idle';
423
497
  closeDrainBatchRow();
424
498
  }
425
499
  }
@@ -429,6 +503,7 @@ async function flushDeltaBatchInner(
429
503
  queuedDeltas: SyncDelta[],
430
504
  ): Promise<void> {
431
505
  const stagePlugins = ctx.stagePlugins ?? [];
506
+ pipelineDebug.phase = 'dedupe';
432
507
  const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
433
508
  observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
434
509
  runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
@@ -446,7 +521,7 @@ async function flushDeltaBatchInner(
446
521
  // gained permission to see the entity, so we insert it into the
447
522
  // pool as if newly created.
448
523
  if (delta.actionType === 'I' || delta.actionType === 'U' || delta.actionType === 'C') {
449
- const existing = ctx.objectPool.get(delta.modelId);
524
+ const existing = ctx.objectPool.peek(delta.modelId);
450
525
  if (existing) {
451
526
  existing.updateFromData(data);
452
527
  } else {
@@ -465,6 +540,8 @@ async function flushDeltaBatchInner(
465
540
  // handleGroupRemoved) and never reach here, though the persistence
466
541
  // signature accepts them defensively.
467
542
  const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
543
+ pipelineDebug.phase = 'persist';
544
+ pipelineDebug.persistsStarted += 1;
468
545
  const batch = await timeDrainStageAsync('persist', () =>
469
546
  ctx.processDeltaBatch(
470
547
  regularDeltas.map((d) => ({
@@ -479,6 +556,7 @@ async function flushDeltaBatchInner(
479
556
  }))
480
557
  )
481
558
  );
559
+ pipelineDebug.persistsSettled += 1;
482
560
  const dbResults = batch.results;
483
561
  runStage(stagePlugins, 'persist', { deltas: regularDeltas });
484
562
 
@@ -487,13 +565,41 @@ async function flushDeltaBatchInner(
487
565
  // materialiser attached where it said it would. The direct call is the
488
566
  // bridge for stores constructed without plugins (subclasses, tests),
489
567
  // whose own apply is the whole pipeline.
490
- timeDrainStage('apply', () => {
491
- if (pluginsForStage(stagePlugins, 'apply').length > 0) {
492
- runStage(stagePlugins, 'apply', { changes: dbResults });
493
- } else {
494
- 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
+ }
495
600
  }
496
601
  });
602
+ pipelineDebug.phase = 'acknowledge';
497
603
 
498
604
  // Acknowledge and advance the sync cursor, gated on persistence.
499
605
  //
@@ -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,