@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
|
@@ -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
|
-
|
|
423
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
package/src/local/Database.ts
CHANGED
|
@@ -391,48 +391,85 @@ export class Database {
|
|
|
391
391
|
|
|
392
392
|
const out: ModelData = {};
|
|
393
393
|
|
|
394
|
-
for (const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
out[key] = value;
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
411
404
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
426
|
-
if (Object.keys(value).length === 0) continue;
|
|
433
|
+
if (value instanceof Date) {
|
|
427
434
|
out[key] = value;
|
|
428
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
|
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] =
|
|
1207
|
+
inMemResults[index] = single;
|
|
1171
1208
|
if (
|
|
1172
1209
|
single.action !== 'verify' &&
|
|
1173
1210
|
typeof syncId === 'number' &&
|