@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.
@@ -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
  */
@@ -1901,6 +1887,22 @@ export class SyncClient extends EventEmitter {
1901
1887
  applyDeltaBatchToPool(
1902
1888
  dbResults: readonly AppliedChange[],
1903
1889
  enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>,
1890
+ ): void {
1891
+ // The WHOLE batch — conflict resolution and model mutation included, not
1892
+ // just the pool bookkeeping at the end — runs in one MobX action.
1893
+ // `resolveConflicts` writes model fields via `updateFromData`; when those
1894
+ // writes ran before the action, every delta opened its own top-level
1895
+ // action and flushed reactions at its boundary, so a large frame paid one
1896
+ // reaction pass per delta and an observer could see a partially applied
1897
+ // frame between them.
1898
+ runInAction(() => {
1899
+ this.applyDeltaBatchToPoolInAction(dbResults, enrichRelations);
1900
+ });
1901
+ }
1902
+
1903
+ private applyDeltaBatchToPoolInAction(
1904
+ dbResults: readonly AppliedChange[],
1905
+ enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>,
1904
1906
  ): void {
1905
1907
  const modelsToAdd: Model[] = [];
1906
1908
  const modelsToUpsert: Model[] = [];
@@ -2002,27 +2004,21 @@ export class SyncClient extends EventEmitter {
2002
2004
  }
2003
2005
  }
2004
2006
 
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
- });
2007
+ // Reveal the whole frame at one reaction boundary: the caller's single
2008
+ // action covers the collection loop above and these batch pool writes, so
2009
+ // dependents recompute exactly once regardless of how many models or
2010
+ // operation kinds the frame touched, and the app never observes a
2011
+ // partially applied frame.
2012
+ if (modelsToAdd.length > 0) this.objectPool.addBatch(modelsToAdd, ModelScope.live);
2013
+ if (modelsToUpsert.length > 0) this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
2014
+ if (idsToRemove.length > 0) this.objectPool.removeBatch(idsToRemove);
2015
+ for (const id of idsToArchive) this.objectPool.updateScope(id, ModelScope.archived);
2016
+
2017
+ // Emit changed model types so QueryProcessor can auto-invalidate.
2018
+ // Kept inside the action so any observable query-cache state it
2019
+ // flips is part of the same atomic reveal.
2020
+ const changedTypes = new Set(dbResults.map(r => r.modelName));
2021
+ if (changedTypes.size > 0) this.emit('models:changed', changedTypes);
2026
2022
  }
2027
2023
 
2028
2024
  /**
@@ -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
@@ -408,6 +415,18 @@ function yieldToHost(): Promise<void> {
408
415
  async function flushDeltaBatch(
409
416
  ctx: DeltaPipelineContext,
410
417
  queuedDeltas: SyncDelta[],
418
+ ): Promise<void> {
419
+ openDrainBatchRow(queuedDeltas.length);
420
+ try {
421
+ await flushDeltaBatchInner(ctx, queuedDeltas);
422
+ } finally {
423
+ closeDrainBatchRow();
424
+ }
425
+ }
426
+
427
+ async function flushDeltaBatchInner(
428
+ ctx: DeltaPipelineContext,
429
+ queuedDeltas: SyncDelta[],
411
430
  ): Promise<void> {
412
431
  const stagePlugins = ctx.stagePlugins ?? [];
413
432
  const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
@@ -489,6 +508,7 @@ async function flushDeltaBatch(
489
508
  timeDrainStage('acknowledge', () => {
490
509
  ctx.acknowledge(persistedSyncId);
491
510
  ctx.advancePersisted(persistedSyncId);
511
+ observeDrainAcknowledge(persistedSyncId);
492
512
  runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
493
513
  });
494
514
  }
@@ -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
  }