@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.
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/local/BaseSyncedStore.d.ts +9 -0
- package/dist/local/BaseSyncedStore.js +28 -1
- package/dist/local/Database.d.ts +20 -0
- package/dist/local/Database.js +83 -49
- package/dist/local/InstanceCache.d.ts +40 -8
- package/dist/local/InstanceCache.js +156 -83
- package/dist/local/Model.d.ts +28 -0
- package/dist/local/Model.js +102 -35
- package/dist/local/SyncClient.d.ts +1 -4
- package/dist/local/SyncClient.js +63 -62
- package/dist/local/client/reactiveEngine.js +18 -0
- package/dist/local/sync/SyncWebSocket.js +3 -6
- package/dist/local/sync/deltaPipeline.d.ts +32 -1
- package/dist/local/sync/deltaPipeline.js +116 -8
- package/dist/local/sync/drainProfile.d.ts +45 -0
- package/dist/local/sync/drainProfile.js +55 -0
- package/dist/local/transactions/mutations/MutationQueue.js +11 -3
- package/dist/local/utils/mobxSetup.d.ts +1 -0
- package/dist/local/utils/mobxSetup.js +5 -0
- package/package.json +2 -2
- package/src/core.ts +3 -0
- package/src/local/BaseSyncedStore.ts +37 -1
- package/src/local/Database.ts +87 -50
- package/src/local/InstanceCache.ts +162 -80
- package/src/local/Model.ts +117 -42
- package/src/local/SyncClient.ts +66 -63
- package/src/local/client/reactiveEngine.ts +18 -0
- package/src/local/sync/SyncWebSocket.ts +3 -6
- package/src/local/sync/deltaPipeline.ts +135 -9
- package/src/local/sync/drainProfile.ts +93 -0
- package/src/local/transactions/mutations/MutationQueue.ts +14 -4
- 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
|
-
|
|
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({
|
package/dist/local/Database.d.ts
CHANGED
|
@@ -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
|
package/dist/local/Database.js
CHANGED
|
@@ -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
|
|
235
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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] =
|
|
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
|
|
226
|
+
* Free capacity for a whole incoming frame by taking the first eligible
|
|
227
|
+
* keys of the recency-ordered `accessTimes` map.
|
|
209
228
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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.
|