@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
@@ -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.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "directory": "packages/humans"
85
85
  },
86
86
  "dependencies": {
87
- "@abloatai/transaction": "^0.38.0",
87
+ "@abloatai/transaction": "^0.40.0",
88
88
  "mobx": "^6.13.7",
89
89
  "uuid": "^11.1.0",
90
90
  "zod": "^4.4.3"
package/src/core.ts CHANGED
@@ -110,7 +110,10 @@ export {
110
110
  drainProfileSnapshot,
111
111
  resetDrainProfile,
112
112
  drainProfilingEnabled,
113
+ drainAcknowledgeStamps,
114
+ type AcknowledgeStamp,
113
115
  type DrainProfile,
116
+ type DrainBatchRow,
114
117
  type DrainStage,
115
118
  type DrainStageTotals,
116
119
  } from './local/sync/drainProfile.js';
@@ -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
  *
@@ -644,8 +662,26 @@ export class BaseSyncedStore<
644
662
  this.smartSyncOptions = {
645
663
  maxDeltasBeforeBootstrap: 1000,
646
664
  maxBootstrapSize: 10 * 1024 * 1024,
647
- batchingDelay: 100,
665
+ // The inbound-delta flush debounce. Under sustained traffic the
666
+ // `maxBatchSize` force-flush governs batching, so this timer decides
667
+ // exactly one thing: how long the FINAL partial batch of a burst sits
668
+ // before it materializes. At 100 ms it was the largest single term in
669
+ // the observer's drain tail on the throughput bench; 10 ms coalesces a
670
+ // trickle just as well and keeps burst tails inside the drain budget.
671
+ batchingDelay: 10,
648
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,
649
685
  };
650
686
 
651
687
  // Create internal helpers
@@ -391,48 +391,85 @@ export class Database {
391
391
 
392
392
  const out: ModelData = {};
393
393
 
394
- for (const [key, value] of Object.entries(data)) {
395
- // Drop redundant or ephemeral markers
396
- if (key === '__typename' || key === '__class' || key === 'clientId' || key === 'syncStatus') {
397
- continue;
398
- }
394
+ for (const key in data) {
395
+ if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
396
+ this.compactAssign(out, key, data[key]);
397
+ }
399
398
 
400
- // Skip only `undefined`; preserve explicit `null`, which is a
401
- // meaningful value for a nullable column.
402
- if (value === undefined) {
403
- continue;
404
- }
399
+ // Always ensure id is present
400
+ if (!out.id && data.id) out.id = data.id;
405
401
 
406
- if (Array.isArray(value)) {
407
- if (value.length === 0) continue;
408
- out[key] = value;
409
- continue;
410
- }
402
+ return out;
403
+ }
411
404
 
412
- if (typeof value === 'object') {
413
- // Preserve explicit null values
414
- if (value === null) {
415
- out[key] = null;
416
- continue;
417
- }
405
+ /**
406
+ * The one definition of the per-key compaction rule: drops the redundant
407
+ * markers `__typename`, `__class`, `clientId`, and `syncStatus`, drops
408
+ * `undefined`, empty arrays, and empty plain objects, and preserves
409
+ * explicit `null` (a meaningful "clear this field" for a nullable column)
410
+ * and `Date` instances (IndexedDB can clone these). `compactRecord` applies
411
+ * it into a fresh object; the batched in-memory delta path applies it
412
+ * directly onto the merge target so a delta costs one object, not four.
413
+ */
414
+ private compactAssign(out: ModelData, key: string, value: unknown): void {
415
+ if (key === '__typename' || key === '__class' || key === 'clientId' || key === 'syncStatus') {
416
+ return;
417
+ }
418
418
 
419
- // Preserve Date objects (IndexedDB can clone these)
420
- if (value instanceof Date) {
421
- out[key] = value;
422
- continue;
423
- }
419
+ if (value === undefined) return;
420
+
421
+ if (Array.isArray(value)) {
422
+ if (value.length === 0) return;
423
+ out[key] = value;
424
+ return;
425
+ }
426
+
427
+ if (typeof value === 'object') {
428
+ if (value === null) {
429
+ out[key] = null;
430
+ return;
431
+ }
424
432
 
425
- // For plain objects, drop if empty
426
- if (Object.keys(value).length === 0) continue;
433
+ if (value instanceof Date) {
427
434
  out[key] = value;
428
- continue;
435
+ return;
429
436
  }
430
437
 
438
+ if (Object.keys(value).length === 0) return;
431
439
  out[key] = value;
440
+ return;
432
441
  }
433
442
 
434
- // Always ensure id is present
435
- if (!out.id && data.id) out.id = data.id;
443
+ out[key] = value;
444
+ }
445
+
446
+ /**
447
+ * Compact a wire delta's payload in one pass, mirroring
448
+ * `compactRecord({ id: modelId, ...data })` exactly: the id key is
449
+ * processed first with the payload's own `id` winning over the envelope's,
450
+ * then each payload key in order. Passing an existing record as `out`
451
+ * makes this the update merge — compacted keys override, dropped keys
452
+ * leave the existing values untouched — without the intermediate
453
+ * id-injected and compacted copies the spread form allocates.
454
+ */
455
+ private compactDeltaRecord(
456
+ modelId: string,
457
+ data: Record<string, unknown>,
458
+ out: ModelData = {},
459
+ ): ModelData {
460
+ const hasOwnId = Object.prototype.hasOwnProperty.call(data, 'id');
461
+ this.compactAssign(out, 'id', hasOwnId ? data.id : modelId);
462
+
463
+ for (const key in data) {
464
+ if (key === 'id') continue;
465
+ if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
466
+ this.compactAssign(out, key, data[key]);
467
+ }
468
+
469
+ if (!out.id) {
470
+ const idValue = hasOwnId ? data.id : modelId;
471
+ if (idValue) out.id = idValue;
472
+ }
436
473
 
437
474
  return out;
438
475
  }
@@ -1109,45 +1146,45 @@ export class Database {
1109
1146
  // catch-up frame. Apply the already-ordered batch directly, matching the
1110
1147
  // synchronous request scheduling used by the IndexedDB transaction path.
1111
1148
  for (const [index, delta] of deltas.entries()) {
1112
- const { actionType, modelName, modelId, data, syncId } = delta;
1149
+ const { actionType, modelName, modelId, data, syncId, transactionId } = delta;
1113
1150
  const store = this.getStore(modelName, 'processDeltaBatch');
1114
1151
  let single: AppliedChange;
1115
1152
 
1116
1153
  if (!store || (typeof syncId === 'number' && syncId <= lastApplied)) {
1117
- single = { action: 'verify', modelName, modelId };
1154
+ single = { action: 'verify', modelName, modelId, transactionId };
1118
1155
  } else {
1119
1156
  const memoryStore = store as InMemoryObjectStore;
1120
- const dataWithId =
1121
- data && typeof data === 'object'
1122
- ? { id: modelId, ...(data as Record<string, unknown>) }
1123
- : data;
1124
- const compacted =
1125
- dataWithId && typeof dataWithId === 'object'
1126
- ? this.compactRecord(modelName, dataWithId)
1127
- : dataWithId;
1128
1157
 
1129
1158
  switch (actionType) {
1130
1159
  case 'C':
1131
- case 'I':
1160
+ case 'I': {
1161
+ const compacted =
1162
+ data && typeof data === 'object'
1163
+ ? this.compactDeltaRecord(modelId, data)
1164
+ : data;
1132
1165
  if (compacted && typeof compacted === 'object') {
1133
1166
  memoryStore.putSync(compacted);
1134
1167
  }
1135
- single = { action: 'add', modelName, modelId, data: compacted };
1168
+ single = { action: 'add', modelName, modelId, data: compacted, transactionId };
1136
1169
  break;
1170
+ }
1137
1171
  case 'U': {
1138
1172
  const existing = memoryStore.getSync(modelId);
1139
1173
  if (!existing) {
1140
- single = { action: 'verify', modelName, modelId, data: null };
1174
+ single = { action: 'verify', modelName, modelId, data: null, transactionId };
1141
1175
  } else {
1142
- const merged = { ...existing, ...compacted };
1176
+ const merged: ModelData = { ...existing };
1177
+ if (data && typeof data === 'object') {
1178
+ this.compactDeltaRecord(modelId, data, merged);
1179
+ }
1143
1180
  memoryStore.putSync(merged);
1144
- single = { action: 'update', modelName, modelId, data: merged };
1181
+ single = { action: 'update', modelName, modelId, data: merged, transactionId };
1145
1182
  }
1146
1183
  break;
1147
1184
  }
1148
1185
  case 'D':
1149
1186
  memoryStore.deleteSync(modelId);
1150
- single = { action: 'remove', modelName, modelId };
1187
+ single = { action: 'remove', modelName, modelId, transactionId };
1151
1188
  break;
1152
1189
  case 'A': {
1153
1190
  const archivedData = this.compactRecord(modelName, {
@@ -1156,18 +1193,18 @@ export class Database {
1156
1193
  archivedAt: new Date(),
1157
1194
  });
1158
1195
  memoryStore.putSync(archivedData);
1159
- single = { action: 'archive', modelName, modelId, data: archivedData };
1196
+ single = { action: 'archive', modelName, modelId, data: archivedData, transactionId };
1160
1197
  break;
1161
1198
  }
1162
1199
  case 'V':
1163
1200
  case 'G':
1164
1201
  case 'S':
1165
- single = { action: 'verify', modelName, modelId, data };
1202
+ single = { action: 'verify', modelName, modelId, data, transactionId };
1166
1203
  break;
1167
1204
  }
1168
1205
  }
1169
1206
 
1170
- inMemResults[index] = { ...single, transactionId: delta.transactionId };
1207
+ inMemResults[index] = single;
1171
1208
  if (
1172
1209
  single.action !== 'verify' &&
1173
1210
  typeof syncId === 'number' &&