@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
package/dist/core.d.ts CHANGED
@@ -33,4 +33,4 @@ export { createClaimStream, type AttachableClaimStream, type ClaimStreamConfig,
33
33
  export { awaitClaimGrant, type GrantTransport, } from '@abloatai/transaction/coordination/awaitClaimGrant';
34
34
  export { LoadStrategy } from '@abloatai/transaction/types';
35
35
  export type { InternalAbloOptions } from './local/client/options.js';
36
- export { drainProfileSnapshot, resetDrainProfile, drainProfilingEnabled, type DrainProfile, type DrainStage, type DrainStageTotals, } from './local/sync/drainProfile.js';
36
+ export { drainProfileSnapshot, resetDrainProfile, drainProfilingEnabled, drainAcknowledgeStamps, type AcknowledgeStamp, type DrainProfile, type DrainBatchRow, type DrainStage, type DrainStageTotals, } from './local/sync/drainProfile.js';
package/dist/core.js CHANGED
@@ -49,4 +49,4 @@ export { LoadStrategy } from '@abloatai/transaction/types';
49
49
  // Stage timings for the delta drain, so a benchmark harness can report where
50
50
  // an observer's catch-up time went instead of inferring it. Inert unless
51
51
  // `ABLO_PROFILE_DRAIN=true`, and read-only: the pipeline does the recording.
52
- export { drainProfileSnapshot, resetDrainProfile, drainProfilingEnabled, } from './local/sync/drainProfile.js';
52
+ export { drainProfileSnapshot, resetDrainProfile, drainProfilingEnabled, drainAcknowledgeStamps, } from './local/sync/drainProfile.js';
@@ -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
  *
@@ -370,8 +379,26 @@ export class BaseSyncedStore {
370
379
  this.smartSyncOptions = {
371
380
  maxDeltasBeforeBootstrap: 1000,
372
381
  maxBootstrapSize: 10 * 1024 * 1024,
373
- batchingDelay: 100,
382
+ // The inbound-delta flush debounce. Under sustained traffic the
383
+ // `maxBatchSize` force-flush governs batching, so this timer decides
384
+ // exactly one thing: how long the FINAL partial batch of a burst sits
385
+ // before it materializes. At 100 ms it was the largest single term in
386
+ // the observer's drain tail on the throughput bench; 10 ms coalesces a
387
+ // trickle just as well and keeps burst tails inside the drain budget.
388
+ batchingDelay: 10,
374
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,
375
402
  };
376
403
  // Create internal helpers
377
404
  this.queryProcessor = new QueryProcessor({
@@ -151,6 +151,26 @@ export declare class Database {
151
151
  * and bootstrap data is plain JSON from the server.
152
152
  */
153
153
  private compactRecord;
154
+ /**
155
+ * The one definition of the per-key compaction rule: drops the redundant
156
+ * markers `__typename`, `__class`, `clientId`, and `syncStatus`, drops
157
+ * `undefined`, empty arrays, and empty plain objects, and preserves
158
+ * explicit `null` (a meaningful "clear this field" for a nullable column)
159
+ * and `Date` instances (IndexedDB can clone these). `compactRecord` applies
160
+ * it into a fresh object; the batched in-memory delta path applies it
161
+ * directly onto the merge target so a delta costs one object, not four.
162
+ */
163
+ private compactAssign;
164
+ /**
165
+ * Compact a wire delta's payload in one pass, mirroring
166
+ * `compactRecord({ id: modelId, ...data })` exactly: the id key is
167
+ * processed first with the payload's own `id` winning over the envelope's,
168
+ * then each payload key in order. Passing an existing record as `out`
169
+ * makes this the update merge — compacted keys override, dropped keys
170
+ * leave the existing values untouched — without the intermediate
171
+ * id-injected and compacted copies the spread form allocates.
172
+ */
173
+ private compactDeltaRecord;
154
174
  /**
155
175
  * Mark that the next bootstrap must be a full bootstrap.
156
176
  * Called when a sync group change ("G" delta) is received — the client must
@@ -231,44 +231,77 @@ export class Database {
231
231
  if (!data || typeof data !== 'object')
232
232
  return data;
233
233
  const out = {};
234
- for (const [key, value] of Object.entries(data)) {
235
- // Drop redundant or ephemeral markers
236
- if (key === '__typename' || key === '__class' || key === 'clientId' || key === 'syncStatus') {
237
- continue;
238
- }
239
- // Skip only `undefined`; preserve explicit `null`, which is a
240
- // meaningful value for a nullable column.
241
- if (value === undefined) {
242
- continue;
243
- }
244
- if (Array.isArray(value)) {
245
- if (value.length === 0)
246
- continue;
247
- out[key] = value;
234
+ for (const key in data) {
235
+ if (!Object.prototype.hasOwnProperty.call(data, key))
248
236
  continue;
237
+ this.compactAssign(out, key, data[key]);
238
+ }
239
+ // Always ensure id is present
240
+ if (!out.id && data.id)
241
+ out.id = data.id;
242
+ return out;
243
+ }
244
+ /**
245
+ * The one definition of the per-key compaction rule: drops the redundant
246
+ * markers `__typename`, `__class`, `clientId`, and `syncStatus`, drops
247
+ * `undefined`, empty arrays, and empty plain objects, and preserves
248
+ * explicit `null` (a meaningful "clear this field" for a nullable column)
249
+ * and `Date` instances (IndexedDB can clone these). `compactRecord` applies
250
+ * it into a fresh object; the batched in-memory delta path applies it
251
+ * directly onto the merge target so a delta costs one object, not four.
252
+ */
253
+ compactAssign(out, key, value) {
254
+ if (key === '__typename' || key === '__class' || key === 'clientId' || key === 'syncStatus') {
255
+ return;
256
+ }
257
+ if (value === undefined)
258
+ return;
259
+ if (Array.isArray(value)) {
260
+ if (value.length === 0)
261
+ return;
262
+ out[key] = value;
263
+ return;
264
+ }
265
+ if (typeof value === 'object') {
266
+ if (value === null) {
267
+ out[key] = null;
268
+ return;
249
269
  }
250
- if (typeof value === 'object') {
251
- // Preserve explicit null values
252
- if (value === null) {
253
- out[key] = null;
254
- continue;
255
- }
256
- // Preserve Date objects (IndexedDB can clone these)
257
- if (value instanceof Date) {
258
- out[key] = value;
259
- continue;
260
- }
261
- // For plain objects, drop if empty
262
- if (Object.keys(value).length === 0)
263
- continue;
270
+ if (value instanceof Date) {
264
271
  out[key] = value;
265
- continue;
272
+ return;
266
273
  }
274
+ if (Object.keys(value).length === 0)
275
+ return;
267
276
  out[key] = value;
277
+ return;
278
+ }
279
+ out[key] = value;
280
+ }
281
+ /**
282
+ * Compact a wire delta's payload in one pass, mirroring
283
+ * `compactRecord({ id: modelId, ...data })` exactly: the id key is
284
+ * processed first with the payload's own `id` winning over the envelope's,
285
+ * then each payload key in order. Passing an existing record as `out`
286
+ * makes this the update merge — compacted keys override, dropped keys
287
+ * leave the existing values untouched — without the intermediate
288
+ * id-injected and compacted copies the spread form allocates.
289
+ */
290
+ compactDeltaRecord(modelId, data, out = {}) {
291
+ const hasOwnId = Object.prototype.hasOwnProperty.call(data, 'id');
292
+ this.compactAssign(out, 'id', hasOwnId ? data.id : modelId);
293
+ for (const key in data) {
294
+ if (key === 'id')
295
+ continue;
296
+ if (!Object.prototype.hasOwnProperty.call(data, key))
297
+ continue;
298
+ this.compactAssign(out, key, data[key]);
299
+ }
300
+ if (!out.id) {
301
+ const idValue = hasOwnId ? data.id : modelId;
302
+ if (idValue)
303
+ out.id = idValue;
268
304
  }
269
- // Always ensure id is present
270
- if (!out.id && data.id)
271
- out.id = data.id;
272
305
  return out;
273
306
  }
274
307
  /**
@@ -778,43 +811,44 @@ export class Database {
778
811
  // catch-up frame. Apply the already-ordered batch directly, matching the
779
812
  // synchronous request scheduling used by the IndexedDB transaction path.
780
813
  for (const [index, delta] of deltas.entries()) {
781
- const { actionType, modelName, modelId, data, syncId } = delta;
814
+ const { actionType, modelName, modelId, data, syncId, transactionId } = delta;
782
815
  const store = this.getStore(modelName, 'processDeltaBatch');
783
816
  let single;
784
817
  if (!store || (typeof syncId === 'number' && syncId <= lastApplied)) {
785
- single = { action: 'verify', modelName, modelId };
818
+ single = { action: 'verify', modelName, modelId, transactionId };
786
819
  }
787
820
  else {
788
821
  const memoryStore = store;
789
- const dataWithId = data && typeof data === 'object'
790
- ? { id: modelId, ...data }
791
- : data;
792
- const compacted = dataWithId && typeof dataWithId === 'object'
793
- ? this.compactRecord(modelName, dataWithId)
794
- : dataWithId;
795
822
  switch (actionType) {
796
823
  case 'C':
797
- case 'I':
824
+ case 'I': {
825
+ const compacted = data && typeof data === 'object'
826
+ ? this.compactDeltaRecord(modelId, data)
827
+ : data;
798
828
  if (compacted && typeof compacted === 'object') {
799
829
  memoryStore.putSync(compacted);
800
830
  }
801
- single = { action: 'add', modelName, modelId, data: compacted };
831
+ single = { action: 'add', modelName, modelId, data: compacted, transactionId };
802
832
  break;
833
+ }
803
834
  case 'U': {
804
835
  const existing = memoryStore.getSync(modelId);
805
836
  if (!existing) {
806
- single = { action: 'verify', modelName, modelId, data: null };
837
+ single = { action: 'verify', modelName, modelId, data: null, transactionId };
807
838
  }
808
839
  else {
809
- const merged = { ...existing, ...compacted };
840
+ const merged = { ...existing };
841
+ if (data && typeof data === 'object') {
842
+ this.compactDeltaRecord(modelId, data, merged);
843
+ }
810
844
  memoryStore.putSync(merged);
811
- single = { action: 'update', modelName, modelId, data: merged };
845
+ single = { action: 'update', modelName, modelId, data: merged, transactionId };
812
846
  }
813
847
  break;
814
848
  }
815
849
  case 'D':
816
850
  memoryStore.deleteSync(modelId);
817
- single = { action: 'remove', modelName, modelId };
851
+ single = { action: 'remove', modelName, modelId, transactionId };
818
852
  break;
819
853
  case 'A': {
820
854
  const archivedData = this.compactRecord(modelName, {
@@ -823,17 +857,17 @@ export class Database {
823
857
  archivedAt: new Date(),
824
858
  });
825
859
  memoryStore.putSync(archivedData);
826
- single = { action: 'archive', modelName, modelId, data: archivedData };
860
+ single = { action: 'archive', modelName, modelId, data: archivedData, transactionId };
827
861
  break;
828
862
  }
829
863
  case 'V':
830
864
  case 'G':
831
865
  case 'S':
832
- single = { action: 'verify', modelName, modelId, data };
866
+ single = { action: 'verify', modelName, modelId, data, transactionId };
833
867
  break;
834
868
  }
835
869
  }
836
- inMemResults[index] = { ...single, transactionId: delta.transactionId };
870
+ inMemResults[index] = single;
837
871
  if (single.action !== 'verify' &&
838
872
  typeof syncId === 'number' &&
839
873
  syncId > inMemPersistedSyncId) {
@@ -35,6 +35,12 @@ export declare class InstanceCache {
35
35
  private entries;
36
36
  private typeIndex;
37
37
  private accessTimes;
38
+ /**
39
+ * Record an access. The delete-then-set moves an existing key to the back
40
+ * of the map's insertion order, which is what keeps `accessTimes` iterable
41
+ * oldest-first for eviction.
42
+ */
43
+ private touchAccess;
38
44
  private recentAdditions;
39
45
  private deltaHistory;
40
46
  private foreignKeyIndexes;
@@ -54,6 +60,18 @@ export declare class InstanceCache {
54
60
  constructor(config?: PoolConfig, modelRegistry?: ModelRegistry);
55
61
  private resolveModel;
56
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;
57
75
  /**
58
76
  * Look a row up **within one model**.
59
77
  *
@@ -205,17 +223,31 @@ export declare class InstanceCache {
205
223
  stopGC(): void;
206
224
  private evictOldest;
207
225
  /**
208
- * Free capacity for a whole incoming frame in one scan.
226
+ * Free capacity for a whole incoming frame by taking the first eligible
227
+ * keys of the recency-ordered `accessTimes` map.
209
228
  *
210
- * Calling evictOldest once per model made a full 10k-entry cache scan
211
- * `incomingCount` times. A 400-row delta frame therefore performed four
212
- * million entry visits before constructing models. Keep only the `count`
213
- * oldest candidates in a bounded max-heap: a full sort paid
214
- * O(cache log cache) for every sustained publication frame even though it
215
- * consumed only the first few hundred entries. The heap preserves the same
216
- * LRU/observed-model contract at O(cache log count) time and O(count) space.
229
+ * Every access moves its key to the back of that map (`touchAccess`), so
230
+ * iterating from the front visits entries oldest-first the eviction
231
+ * order at O(count) for a sustained publication frame. The previous
232
+ * shape kept a bounded max-heap but still walked EVERY cache entry per
233
+ * incoming frame, which at a 10k cap and ~1.3k-delta frames made the scan
234
+ * itself a first-order term of the observer's apply cost.
235
+ *
236
+ * The observed-model contract is unchanged: a model React is observing is
237
+ * skipped and stays in place, so it is reconsidered (and skipped again)
238
+ * on later evictions until it is no longer observed.
217
239
  */
218
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;
219
251
  private isLargeModel;
220
252
  /**
221
253
  * Register a foreign key field for indexing on a model type.