@abloatai/humans 0.37.1 → 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.
- package/dist/core.d.ts +1 -0
- package/dist/core.js +4 -0
- package/dist/local/BaseSyncedStore.d.ts +4 -2
- package/dist/local/BaseSyncedStore.js +7 -1
- package/dist/local/Database.d.ts +20 -0
- package/dist/local/Database.js +83 -49
- package/dist/local/InstanceCache.d.ts +18 -8
- package/dist/local/InstanceCache.js +74 -74
- package/dist/local/Model.d.ts +18 -0
- package/dist/local/Model.js +83 -32
- package/dist/local/SyncClient.d.ts +1 -4
- package/dist/local/SyncClient.js +55 -60
- package/dist/local/client/createModelProxy.js +14 -12
- package/dist/local/client/options.d.ts +7 -0
- package/dist/local/client/reactiveEngine.js +23 -3
- package/dist/local/client/storeLifecycle.js +6 -3
- package/dist/local/stores/DatabaseManager.d.ts +2 -2
- package/dist/local/stores/DatabaseManager.js +2 -2
- package/dist/local/stores/persistenceIdentity.d.ts +7 -8
- package/dist/local/stores/persistenceIdentity.js +4 -5
- package/dist/local/sync/SyncWebSocket.d.ts +7 -0
- package/dist/local/sync/SyncWebSocket.js +21 -6
- package/dist/local/sync/deltaPipeline.js +31 -13
- package/dist/local/sync/drainProfile.d.ts +104 -0
- package/dist/local/sync/drainProfile.js +182 -0
- package/dist/local/sync/initialize.js +2 -2
- package/dist/local/transactions/mutations/MutationQueue.js +32 -12
- package/dist/local/transactions/mutations/pendingDrain.d.ts +1 -1
- package/dist/local/transactions/mutations/pendingDrain.js +2 -1
- package/package.json +2 -2
- package/src/core.ts +15 -0
- package/src/local/BaseSyncedStore.ts +11 -3
- package/src/local/Database.ts +87 -50
- package/src/local/InstanceCache.ts +77 -71
- package/src/local/Model.ts +98 -37
- package/src/local/SyncClient.ts +57 -61
- package/src/local/client/createModelProxy.ts +14 -12
- package/src/local/client/options.ts +9 -0
- package/src/local/client/reactiveEngine.ts +23 -2
- package/src/local/client/storeLifecycle.ts +10 -4
- package/src/local/stores/DatabaseManager.ts +4 -4
- package/src/local/stores/persistenceIdentity.ts +10 -12
- package/src/local/sync/SyncWebSocket.ts +20 -6
- package/src/local/sync/deltaPipeline.ts +51 -21
- package/src/local/sync/drainProfile.ts +257 -0
- package/src/local/sync/initialize.ts +2 -2
- package/src/local/transactions/mutations/MutationQueue.ts +31 -12
- package/src/local/transactions/mutations/pendingDrain.ts +7 -2
package/dist/local/Model.js
CHANGED
|
@@ -31,6 +31,7 @@ export class ValidationError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
/** Shared frozen default for {@link Model.getDerivedGetterNames}. */
|
|
33
33
|
const EMPTY_DERIVED_GETTERS = Object.freeze([]);
|
|
34
|
+
const fieldWritePlans = new WeakMap();
|
|
34
35
|
/**
|
|
35
36
|
* The abstract base class every domain model extends. It holds the model's id
|
|
36
37
|
* and timestamps, tracks in-place property changes for change detection and
|
|
@@ -54,6 +55,17 @@ export class Model {
|
|
|
54
55
|
_isNew = true;
|
|
55
56
|
/** Original data snapshot */
|
|
56
57
|
_originalData;
|
|
58
|
+
/**
|
|
59
|
+
* Whether the persisted baseline needs recapturing. Hydration and ack land
|
|
60
|
+
* far more often than anything reads the baseline — an observer-only client
|
|
61
|
+
* reads it never — so each of those sites marks the snapshot stale and
|
|
62
|
+
* {@link getOriginalSnapshot} materializes it on first read. Correctness
|
|
63
|
+
* rests on the tracking invariant: every non-hydration write is recorded in
|
|
64
|
+
* `modifiedProperties` (first-old-wins), so between the stale mark and the
|
|
65
|
+
* read, untracked fields still hold exactly the values an eager capture
|
|
66
|
+
* would have recorded.
|
|
67
|
+
*/
|
|
68
|
+
_originalDataStale = false;
|
|
57
69
|
/** Sync status */
|
|
58
70
|
syncStatus = 'pending';
|
|
59
71
|
/** Timestamps */
|
|
@@ -208,7 +220,8 @@ export class Model {
|
|
|
208
220
|
*/
|
|
209
221
|
markAsPersisted() {
|
|
210
222
|
this._isNew = false;
|
|
211
|
-
this._originalData =
|
|
223
|
+
this._originalData = undefined;
|
|
224
|
+
this._originalDataStale = true;
|
|
212
225
|
}
|
|
213
226
|
/**
|
|
214
227
|
* Check if this is a new model
|
|
@@ -230,6 +243,10 @@ export class Model {
|
|
|
230
243
|
* last acknowledged state would already be the authoritative baseline.
|
|
231
244
|
*/
|
|
232
245
|
getOriginalSnapshot() {
|
|
246
|
+
if (this._originalDataStale) {
|
|
247
|
+
this._originalData = this.captureSnapshot();
|
|
248
|
+
this._originalDataStale = false;
|
|
249
|
+
}
|
|
233
250
|
return this._originalData;
|
|
234
251
|
}
|
|
235
252
|
/**
|
|
@@ -238,7 +255,8 @@ export class Model {
|
|
|
238
255
|
clearChanges() {
|
|
239
256
|
runInAction(() => {
|
|
240
257
|
this.modifiedProperties.clear();
|
|
241
|
-
this._originalData =
|
|
258
|
+
this._originalData = undefined;
|
|
259
|
+
this._originalDataStale = true;
|
|
242
260
|
});
|
|
243
261
|
}
|
|
244
262
|
/**
|
|
@@ -471,43 +489,39 @@ export class Model {
|
|
|
471
489
|
* leaves that baseline intact.
|
|
472
490
|
*/
|
|
473
491
|
assignFieldsFromData(data, onWrite) {
|
|
474
|
-
|
|
475
|
-
|
|
492
|
+
let plan = fieldWritePlans.get(this.constructor);
|
|
493
|
+
if (!plan) {
|
|
494
|
+
plan = new Map();
|
|
495
|
+
fieldWritePlans.set(this.constructor, plan);
|
|
496
|
+
}
|
|
497
|
+
for (const key in data) {
|
|
476
498
|
if (key === 'id')
|
|
477
499
|
continue;
|
|
478
|
-
|
|
479
|
-
if (!(this.hasOwnProperty(key) || key in this))
|
|
500
|
+
if (!Object.prototype.hasOwnProperty.call(data, key))
|
|
480
501
|
continue;
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
catch {
|
|
488
|
-
// If MobX internals are unavailable for some reason, fall back to descriptor checks below
|
|
502
|
+
const raw = data[key];
|
|
503
|
+
let disposition = plan.get(key);
|
|
504
|
+
if (disposition === undefined) {
|
|
505
|
+
disposition = this.resolveFieldDisposition(key);
|
|
506
|
+
plan.set(key, disposition);
|
|
489
507
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
508
|
+
if (disposition === 'skip-readonly')
|
|
509
|
+
continue;
|
|
510
|
+
if (disposition === 'skip-absent') {
|
|
511
|
+
// The plan was built from an instance that lacked this key. Re-resolve
|
|
512
|
+
// for this instance without touching the cache, so heterogeneous
|
|
513
|
+
// shapes stay correct at the cost of the slow path.
|
|
514
|
+
if (!(key in this))
|
|
515
|
+
continue;
|
|
516
|
+
disposition = this.resolveFieldDisposition(key);
|
|
517
|
+
if (disposition === 'skip-readonly' || disposition === 'skip-absent')
|
|
518
|
+
continue;
|
|
499
519
|
}
|
|
500
|
-
|
|
501
|
-
const writable = desc
|
|
502
|
-
? ('writable' in desc && !!desc.writable) ||
|
|
503
|
-
('set' in desc && typeof desc.set === 'function')
|
|
504
|
-
: true;
|
|
505
|
-
if (!writable) {
|
|
506
|
-
// Skip read-only accessor properties (getter-only)
|
|
520
|
+
else if (!(key in this)) {
|
|
507
521
|
continue;
|
|
508
522
|
}
|
|
509
523
|
// Handle date conversions
|
|
510
|
-
const value =
|
|
524
|
+
const value = disposition === 'write-date' && raw
|
|
511
525
|
? new Date(raw)
|
|
512
526
|
: raw;
|
|
513
527
|
// Capture the pre-write value BEFORE assignment so trackers
|
|
@@ -518,6 +532,42 @@ export class Model {
|
|
|
518
532
|
onWrite?.(key, oldValue, value);
|
|
519
533
|
}
|
|
520
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* The uncached decision for one key: existence on instance or prototype,
|
|
537
|
+
* never a MobX computed (its setter may throw), and writability resolved
|
|
538
|
+
* through the descriptor chain — a data descriptor's `writable` or an
|
|
539
|
+
* accessor's setter.
|
|
540
|
+
*/
|
|
541
|
+
resolveFieldDisposition(key) {
|
|
542
|
+
if (!(key in this))
|
|
543
|
+
return 'skip-absent';
|
|
544
|
+
try {
|
|
545
|
+
if (isComputedProp(this, key)) {
|
|
546
|
+
return 'skip-readonly';
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
// If MobX internals are unavailable for some reason, fall back to descriptor checks below
|
|
551
|
+
}
|
|
552
|
+
const ownDesc = Object.getOwnPropertyDescriptor(this, key);
|
|
553
|
+
let desc = ownDesc;
|
|
554
|
+
if (!desc) {
|
|
555
|
+
let proto = Object.getPrototypeOf(this);
|
|
556
|
+
while (proto && proto !== Object.prototype && !desc) {
|
|
557
|
+
desc = Object.getOwnPropertyDescriptor(proto, key);
|
|
558
|
+
proto = Object.getPrototypeOf(proto);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const writable = desc
|
|
562
|
+
? ('writable' in desc && !!desc.writable) ||
|
|
563
|
+
('set' in desc && typeof desc.set === 'function')
|
|
564
|
+
: true;
|
|
565
|
+
if (!writable)
|
|
566
|
+
return 'skip-readonly';
|
|
567
|
+
return key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt'
|
|
568
|
+
? 'write-date'
|
|
569
|
+
: 'write';
|
|
570
|
+
}
|
|
521
571
|
/**
|
|
522
572
|
* Update from raw data (hydration)
|
|
523
573
|
*
|
|
@@ -550,7 +600,8 @@ export class Model {
|
|
|
550
600
|
});
|
|
551
601
|
// Mark as persisted if updating existing model
|
|
552
602
|
if (!this._isNew) {
|
|
553
|
-
this._originalData =
|
|
603
|
+
this._originalData = undefined;
|
|
604
|
+
this._originalDataStale = true;
|
|
554
605
|
}
|
|
555
606
|
this.didUpdate();
|
|
556
607
|
}
|
|
@@ -275,10 +275,6 @@ export declare class SyncClient extends EventEmitter {
|
|
|
275
275
|
* conflict resolver reads exactly these fields and no others.
|
|
276
276
|
*/
|
|
277
277
|
private extractCriticalState;
|
|
278
|
-
/**
|
|
279
|
-
* Check if critical state changes exist that require forcing server state
|
|
280
|
-
*/
|
|
281
|
-
private hasCriticalStateChange;
|
|
282
278
|
/**
|
|
283
279
|
* Handle network reconnection
|
|
284
280
|
*/
|
|
@@ -487,6 +483,7 @@ export declare class SyncClient extends EventEmitter {
|
|
|
487
483
|
*/
|
|
488
484
|
getMutationQueue(): MutationQueue;
|
|
489
485
|
applyDeltaBatchToPool(dbResults: readonly AppliedChange[], enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>): void;
|
|
486
|
+
private applyDeltaBatchToPoolInAction;
|
|
490
487
|
/**
|
|
491
488
|
* Apply bootstrap data to the InstanceCache with ghost removal.
|
|
492
489
|
* Owns: model creation, batch upsert, ghost detection + removal.
|
package/dist/local/SyncClient.js
CHANGED
|
@@ -1038,31 +1038,22 @@ export class SyncClient extends EventEmitter {
|
|
|
1038
1038
|
* local model has unsynced changes, so the two sides stay consistent.
|
|
1039
1039
|
*/
|
|
1040
1040
|
resolveConflicts(localModel, serverData) {
|
|
1041
|
-
|
|
1042
|
-
//
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
localUpdatedAt: localModel.updatedAt?.toString(),
|
|
1054
|
-
serverUpdatedAt: serverData.updatedAt,
|
|
1055
|
-
localChanges: localModel.getChanges(),
|
|
1056
|
-
serverState: this.extractCriticalState(serverData),
|
|
1057
|
-
});
|
|
1058
|
-
// PRIORITY 1: Check for critical server states that must be respected
|
|
1059
|
-
// These states override any local changes to maintain data consistency
|
|
1060
|
-
const criticalServerStates = this.extractCriticalState(serverData);
|
|
1061
|
-
const shouldForceAcceptServer = this.hasCriticalStateChange(criticalServerStates);
|
|
1041
|
+
// No entry-point debug here: this runs once per incoming update delta,
|
|
1042
|
+
// and a debug call's payload is BUILT even when the logger discards it —
|
|
1043
|
+
// a per-delta model scan on the apply hot path. The outcome branches
|
|
1044
|
+
// below log the cases worth reading.
|
|
1045
|
+
// PRIORITY 1: Check for critical server states that must be respected.
|
|
1046
|
+
// These states override any local changes to maintain data consistency.
|
|
1047
|
+
// Checked inline — the collected-object form (`extractCriticalState`)
|
|
1048
|
+
// only materializes on the rare force-accept branch, for its log line.
|
|
1049
|
+
const shouldForceAcceptServer = (serverData.deletedAt !== undefined && serverData.deletedAt !== null) ||
|
|
1050
|
+
(serverData.archivedAt !== undefined && serverData.archivedAt !== null) ||
|
|
1051
|
+
serverData.isActive === false ||
|
|
1052
|
+
(serverData.unassignedAt !== undefined && serverData.unassignedAt !== null);
|
|
1062
1053
|
if (shouldForceAcceptServer) {
|
|
1063
1054
|
this.runtime.logger.debug('Accepting server update - critical state change detected', {
|
|
1064
1055
|
modelId: localModel.id,
|
|
1065
|
-
criticalStates:
|
|
1056
|
+
criticalStates: this.extractCriticalState(serverData),
|
|
1066
1057
|
});
|
|
1067
1058
|
// Force accept server state for critical changes
|
|
1068
1059
|
localModel.updateFromData(serverData);
|
|
@@ -1072,7 +1063,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1072
1063
|
}
|
|
1073
1064
|
// Local-first: if we have local dirty fields, merge by field.
|
|
1074
1065
|
// Keep locally changed fields; apply server for the rest.
|
|
1075
|
-
if (
|
|
1066
|
+
if (localModel.hasChanges) {
|
|
1076
1067
|
const localChanges = localModel.getChanges();
|
|
1077
1068
|
this.runtime.logger.debug('Merging server update with local dirty fields', {
|
|
1078
1069
|
modelId: localModel.id,
|
|
@@ -1082,6 +1073,13 @@ export class SyncClient extends EventEmitter {
|
|
|
1082
1073
|
const merged = { ...serverData, ...(localChanges || {}) };
|
|
1083
1074
|
// Preserve the most recent updatedAt without clearing dirty flags
|
|
1084
1075
|
if (serverData.updatedAt || localModel.updatedAt) {
|
|
1076
|
+
// Safely get timestamp, handling both Date objects and strings
|
|
1077
|
+
const localUpdatedAt = localModel.updatedAt
|
|
1078
|
+
? localModel.updatedAt instanceof Date
|
|
1079
|
+
? localModel.updatedAt.getTime()
|
|
1080
|
+
: new Date(localModel.updatedAt).getTime()
|
|
1081
|
+
: 0;
|
|
1082
|
+
const serverUpdatedAt = toEpochMs(serverData.updatedAt);
|
|
1085
1083
|
const mergedUpdatedAt = new Date(Math.max(localUpdatedAt, serverUpdatedAt));
|
|
1086
1084
|
// updateFromData accepts Date or ISO string for dates
|
|
1087
1085
|
merged.updatedAt = mergedUpdatedAt;
|
|
@@ -1090,10 +1088,9 @@ export class SyncClient extends EventEmitter {
|
|
|
1090
1088
|
// Intentionally DO NOT clearChanges here; pending tx will confirm and clear
|
|
1091
1089
|
return localModel;
|
|
1092
1090
|
}
|
|
1093
|
-
// No local changes: fall back to LWW to converge
|
|
1094
|
-
//
|
|
1095
|
-
|
|
1096
|
-
this.runtime.logger.debug(`Accepting server update - ${acceptReason}`);
|
|
1091
|
+
// No local changes: fall back to LWW to converge. Accept server
|
|
1092
|
+
// regardless of timestamp equality to stay in sync. Not logged — this is
|
|
1093
|
+
// the common path for every collaborator update a client receives.
|
|
1097
1094
|
localModel.updateFromData(serverData);
|
|
1098
1095
|
localModel.clearChanges();
|
|
1099
1096
|
localModel.markAsSynced();
|
|
@@ -1125,14 +1122,6 @@ export class SyncClient extends EventEmitter {
|
|
|
1125
1122
|
}
|
|
1126
1123
|
return critical;
|
|
1127
1124
|
}
|
|
1128
|
-
/**
|
|
1129
|
-
* Check if critical state changes exist that require forcing server state
|
|
1130
|
-
*/
|
|
1131
|
-
hasCriticalStateChange(criticalStates) {
|
|
1132
|
-
// Any critical state present means we should force accept server
|
|
1133
|
-
return (Object.keys(criticalStates).length > 0 &&
|
|
1134
|
-
Object.values(criticalStates).some((v) => v !== null && v !== undefined));
|
|
1135
|
-
}
|
|
1136
1125
|
/**
|
|
1137
1126
|
* Handle network reconnection
|
|
1138
1127
|
*/
|
|
@@ -1536,6 +1525,18 @@ export class SyncClient extends EventEmitter {
|
|
|
1536
1525
|
return this.mutationQueue;
|
|
1537
1526
|
}
|
|
1538
1527
|
applyDeltaBatchToPool(dbResults, enrichRelations) {
|
|
1528
|
+
// The WHOLE batch — conflict resolution and model mutation included, not
|
|
1529
|
+
// just the pool bookkeeping at the end — runs in one MobX action.
|
|
1530
|
+
// `resolveConflicts` writes model fields via `updateFromData`; when those
|
|
1531
|
+
// writes ran before the action, every delta opened its own top-level
|
|
1532
|
+
// action and flushed reactions at its boundary, so a large frame paid one
|
|
1533
|
+
// reaction pass per delta and an observer could see a partially applied
|
|
1534
|
+
// frame between them.
|
|
1535
|
+
runInAction(() => {
|
|
1536
|
+
this.applyDeltaBatchToPoolInAction(dbResults, enrichRelations);
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
applyDeltaBatchToPoolInAction(dbResults, enrichRelations) {
|
|
1539
1540
|
const modelsToAdd = [];
|
|
1540
1541
|
const modelsToUpsert = [];
|
|
1541
1542
|
const idsToRemove = [];
|
|
@@ -1633,31 +1634,25 @@ export class SyncClient extends EventEmitter {
|
|
|
1633
1634
|
break;
|
|
1634
1635
|
}
|
|
1635
1636
|
}
|
|
1636
|
-
// Reveal the whole frame
|
|
1637
|
-
//
|
|
1638
|
-
//
|
|
1639
|
-
//
|
|
1640
|
-
//
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
// Kept inside the action so any observable query-cache state it
|
|
1656
|
-
// flips is part of the same atomic reveal.
|
|
1657
|
-
const changedTypes = new Set(dbResults.map(r => r.modelName));
|
|
1658
|
-
if (changedTypes.size > 0)
|
|
1659
|
-
this.emit('models:changed', changedTypes);
|
|
1660
|
-
});
|
|
1637
|
+
// Reveal the whole frame at one reaction boundary: the caller's single
|
|
1638
|
+
// action covers the collection loop above and these batch pool writes, so
|
|
1639
|
+
// dependents recompute exactly once regardless of how many models or
|
|
1640
|
+
// operation kinds the frame touched, and the app never observes a
|
|
1641
|
+
// partially applied frame.
|
|
1642
|
+
if (modelsToAdd.length > 0)
|
|
1643
|
+
this.objectPool.addBatch(modelsToAdd, ModelScope.live);
|
|
1644
|
+
if (modelsToUpsert.length > 0)
|
|
1645
|
+
this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
|
|
1646
|
+
if (idsToRemove.length > 0)
|
|
1647
|
+
this.objectPool.removeBatch(idsToRemove);
|
|
1648
|
+
for (const id of idsToArchive)
|
|
1649
|
+
this.objectPool.updateScope(id, ModelScope.archived);
|
|
1650
|
+
// Emit changed model types so QueryProcessor can auto-invalidate.
|
|
1651
|
+
// Kept inside the action so any observable query-cache state it
|
|
1652
|
+
// flips is part of the same atomic reveal.
|
|
1653
|
+
const changedTypes = new Set(dbResults.map(r => r.modelName));
|
|
1654
|
+
if (changedTypes.size > 0)
|
|
1655
|
+
this.emit('models:changed', changedTypes);
|
|
1661
1656
|
}
|
|
1662
1657
|
/**
|
|
1663
1658
|
* Apply bootstrap data to the InstanceCache with ghost removal.
|
|
@@ -142,15 +142,17 @@ defaultWait) {
|
|
|
142
142
|
typeof value.release === 'function';
|
|
143
143
|
const mutationOptions = (params) => {
|
|
144
144
|
const rest = {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
145
|
+
...(params.idempotencyKey !== undefined
|
|
146
|
+
? { idempotencyKey: params.idempotencyKey }
|
|
147
|
+
: {}),
|
|
148
|
+
...(params.label !== undefined ? { label: params.label } : {}),
|
|
149
|
+
...(params.wait !== undefined ? { wait: params.wait } : {}),
|
|
150
|
+
...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
|
|
151
|
+
...(params.onStale !== undefined ? { onStale: params.onStale } : {}),
|
|
152
|
+
...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
|
|
153
|
+
...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
|
|
154
|
+
...(params.reads !== undefined ? { reads: params.reads } : {}),
|
|
155
|
+
...(params.track !== undefined ? { track: params.track } : {}),
|
|
154
156
|
};
|
|
155
157
|
// The write-options schema — the runtime twin of the compile-time params.
|
|
156
158
|
// Catches plain-JavaScript callers (for example `onStale: 'rejct'`) at the
|
|
@@ -312,7 +314,7 @@ defaultWait) {
|
|
|
312
314
|
return {
|
|
313
315
|
object: 'claim',
|
|
314
316
|
id: lease.id,
|
|
315
|
-
readAt: snapshot.stamp,
|
|
317
|
+
readAt: lease.readAt ?? snapshot.stamp,
|
|
316
318
|
// The fencing token the server minted for this grant, forwarded from the
|
|
317
319
|
// lease so writes taken under this handle carry it (Option B).
|
|
318
320
|
...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
|
|
@@ -746,7 +748,7 @@ defaultWait) {
|
|
|
746
748
|
const effective = claimed
|
|
747
749
|
? {
|
|
748
750
|
wait: 'confirmed',
|
|
749
|
-
readAt: claimed.snapshot.stamp,
|
|
751
|
+
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
750
752
|
onStale: 'reject',
|
|
751
753
|
claimRef: { id: claimed.lease.id },
|
|
752
754
|
...opts,
|
|
@@ -816,7 +818,7 @@ defaultWait) {
|
|
|
816
818
|
const effective = claimed
|
|
817
819
|
? {
|
|
818
820
|
wait: 'confirmed',
|
|
819
|
-
readAt: claimed.snapshot.stamp,
|
|
821
|
+
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
820
822
|
onStale: 'reject',
|
|
821
823
|
claimRef: { id: claimed.lease.id },
|
|
822
824
|
...(claimed.lease.fenceToken !== undefined
|
|
@@ -434,6 +434,13 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
434
434
|
* identity from the token through the identity endpoint instead.
|
|
435
435
|
*/
|
|
436
436
|
organizationId?: string;
|
|
437
|
+
/**
|
|
438
|
+
* Immutable branch selected by a self-hosted credential. Hosted clients
|
|
439
|
+
* receive this from the credential exchange.
|
|
440
|
+
*/
|
|
441
|
+
branchId?: string;
|
|
442
|
+
/** Whether the selected self-hosted branch is the project's root branch. */
|
|
443
|
+
branchRoot?: boolean;
|
|
437
444
|
/** The client-wide write default — see {@link AbloOptions.wait}. Projected
|
|
438
445
|
* from the public option rather than restated, so the two cannot diverge. */
|
|
439
446
|
wait?: AbloOptions['wait'];
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* host-built core client with the per-model surface split — the cut's own
|
|
12
12
|
* design step (docs/plans/package-split.md).
|
|
13
13
|
*/
|
|
14
|
+
import { omittedModelError } from '@abloatai/transaction/schema/select';
|
|
14
15
|
import { durableCommitOperationSchema, } from '@abloatai/transaction/transactions/settlement/commitEnvelope';
|
|
15
16
|
import { AbloAuthenticationError, AbloConnectionError, AbloValidationError, claimedError } from '@abloatai/transaction/errors';
|
|
16
17
|
import { batchFence, fenceTokenFor, modelTarget, streamTarget, subTarget, } from '@abloatai/transaction/coordination';
|
|
@@ -250,7 +251,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
250
251
|
}
|
|
251
252
|
});
|
|
252
253
|
}
|
|
253
|
-
function wrapClaimHandle(claim, waited = false, fenceToken) {
|
|
254
|
+
function wrapClaimHandle(claim, waited = false, fenceToken, readAt) {
|
|
254
255
|
const release = () => {
|
|
255
256
|
claim.revoke?.();
|
|
256
257
|
return Promise.resolve();
|
|
@@ -265,6 +266,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
265
266
|
description: claim.description,
|
|
266
267
|
target: claim.target,
|
|
267
268
|
waited,
|
|
269
|
+
...(readAt !== undefined ? { readAt } : {}),
|
|
268
270
|
...(resolvedFenceToken !== undefined ? { fenceToken: resolvedFenceToken } : {}),
|
|
269
271
|
release,
|
|
270
272
|
revoke: claim.revoke,
|
|
@@ -293,9 +295,10 @@ export function buildReactiveEngine(inputs) {
|
|
|
293
295
|
// holds the lease, never a half-claimed one racing the queue.
|
|
294
296
|
let waited = false;
|
|
295
297
|
let fenceToken;
|
|
298
|
+
let readAt;
|
|
296
299
|
if (claimOptions.queue) {
|
|
297
300
|
try {
|
|
298
|
-
({ waited, fenceToken } = await awaitClaimGrant(transport, claim.id, {
|
|
301
|
+
({ waited, fenceToken, readAt } = await awaitClaimGrant(transport, claim.id, {
|
|
299
302
|
timeoutMs: claimOptions.waitTimeoutMs,
|
|
300
303
|
maxQueueDepth: claimOptions.maxQueueDepth,
|
|
301
304
|
signal: claimOptions.signal,
|
|
@@ -310,7 +313,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
310
313
|
throw err;
|
|
311
314
|
}
|
|
312
315
|
}
|
|
313
|
-
return wrapClaimHandle(claim, waited, fenceToken);
|
|
316
|
+
return wrapClaimHandle(claim, waited, fenceToken, readAt);
|
|
314
317
|
},
|
|
315
318
|
list(target) {
|
|
316
319
|
return listModelClaims(target);
|
|
@@ -701,5 +704,22 @@ export function buildReactiveEngine(inputs) {
|
|
|
701
704
|
});
|
|
702
705
|
},
|
|
703
706
|
};
|
|
707
|
+
// A model the schema projection left out answers with an error naming the
|
|
708
|
+
// model and the fix, not `undefined`. An app can compile against the full
|
|
709
|
+
// source schema while running a projection, so the type system never sees
|
|
710
|
+
// this gap; without the stub the caller crashes one property later with a
|
|
711
|
+
// bare TypeError ("reading 'local'") that names neither. Non-enumerable so
|
|
712
|
+
// spread, Object.keys, and JSON.stringify walk past the stubs untriggered.
|
|
713
|
+
for (const name of schema.omittedModels ?? []) {
|
|
714
|
+
if (name in engine)
|
|
715
|
+
continue;
|
|
716
|
+
Object.defineProperty(engine, name, {
|
|
717
|
+
get() {
|
|
718
|
+
throw omittedModelError(name);
|
|
719
|
+
},
|
|
720
|
+
enumerable: false,
|
|
721
|
+
configurable: true,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
704
724
|
return engine;
|
|
705
725
|
}
|
|
@@ -107,7 +107,7 @@ export function startStoreLifecycle(deps) {
|
|
|
107
107
|
auth: authCredentials,
|
|
108
108
|
logger,
|
|
109
109
|
});
|
|
110
|
-
const { userId, accountScope, projectId,
|
|
110
|
+
const { userId, accountScope, projectId, branchId, branchRoot, teamIds, capabilityToken, syncGroups, participantKind, } = resolved;
|
|
111
111
|
// Fail-loud guard: detect the degenerate "no real sync groups
|
|
112
112
|
// resolved" state before opening the socket. It is the same class of bug as
|
|
113
113
|
// a sensible-looking default that's functionally broken: the
|
|
@@ -155,12 +155,15 @@ export function startStoreLifecycle(deps) {
|
|
|
155
155
|
// agents default to 'none' (transactional participant — see
|
|
156
156
|
// option doc) and everyone else defaults to 'full'.
|
|
157
157
|
const resolvedBootstrapMode = internalOptions.bootstrapMode ?? (participantKind === 'agent' ? 'none' : 'full');
|
|
158
|
+
if (!branchId) {
|
|
159
|
+
throw new AbloConnectionError('The server did not resolve an Ablo branch for this credential.', { code: 'invalid_request' });
|
|
160
|
+
}
|
|
158
161
|
const gen = store.initialize({
|
|
159
162
|
userId,
|
|
160
163
|
organizationId: accountScope,
|
|
161
164
|
projectId,
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
branchId,
|
|
166
|
+
branchRoot,
|
|
164
167
|
teamIds,
|
|
165
168
|
kind: participantKind,
|
|
166
169
|
capabilityToken,
|
|
@@ -15,8 +15,8 @@ export interface DatabaseInfo {
|
|
|
15
15
|
workspaceId: string;
|
|
16
16
|
participantKind: string;
|
|
17
17
|
projectId: string | null;
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
branchId: string;
|
|
19
|
+
branchRoot: boolean;
|
|
20
20
|
schemaHash: string;
|
|
21
21
|
schemaVersion: number;
|
|
22
22
|
userVersion?: number;
|
|
@@ -114,8 +114,8 @@ export class DatabaseManager {
|
|
|
114
114
|
workspaceId: identity.organizationId,
|
|
115
115
|
participantKind: identity.participantKind,
|
|
116
116
|
projectId: identity.projectId,
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
branchId: identity.branchId,
|
|
118
|
+
branchRoot: identity.branchRoot,
|
|
119
119
|
schemaHash,
|
|
120
120
|
schemaVersion,
|
|
121
121
|
userVersion,
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The complete authenticated
|
|
3
|
-
*
|
|
4
|
-
* those replicas must never share a namespace.
|
|
2
|
+
* The complete authenticated branch that owns one local replica. A branch id
|
|
3
|
+
* is authoritative.
|
|
5
4
|
*/
|
|
6
5
|
export interface PersistenceIdentity {
|
|
7
6
|
readonly participantId: string;
|
|
8
7
|
readonly participantKind: string;
|
|
9
8
|
readonly organizationId: string;
|
|
10
9
|
readonly projectId: string | null;
|
|
11
|
-
readonly
|
|
12
|
-
readonly
|
|
10
|
+
readonly branchId: string;
|
|
11
|
+
readonly branchRoot: boolean;
|
|
13
12
|
}
|
|
14
13
|
export interface PersistedIdentityMetadata {
|
|
15
14
|
readonly namespaceVersion?: number;
|
|
@@ -17,10 +16,10 @@ export interface PersistedIdentityMetadata {
|
|
|
17
16
|
readonly workspaceId: string;
|
|
18
17
|
readonly participantKind?: string;
|
|
19
18
|
readonly projectId?: string | null;
|
|
20
|
-
readonly
|
|
21
|
-
readonly
|
|
19
|
+
readonly branchId?: string;
|
|
20
|
+
readonly branchRoot?: boolean;
|
|
22
21
|
}
|
|
23
|
-
export declare const PERSISTENCE_NAMESPACE_VERSION =
|
|
22
|
+
export declare const PERSISTENCE_NAMESPACE_VERSION = 4;
|
|
24
23
|
/** Collision-resistant IndexedDB name for one authenticated data plane. */
|
|
25
24
|
export declare function persistenceDatabaseName(identity: PersistenceIdentity, userVersion?: number): Promise<string>;
|
|
26
25
|
/** Defense-in-depth check after namespace lookup and before persisted reads. */
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { AbloConnectionError } from '@abloatai/transaction/errors';
|
|
2
|
-
export const PERSISTENCE_NAMESPACE_VERSION =
|
|
2
|
+
export const PERSISTENCE_NAMESPACE_VERSION = 4;
|
|
3
3
|
function canonicalIdentity(identity, userVersion) {
|
|
4
4
|
return JSON.stringify([
|
|
5
5
|
PERSISTENCE_NAMESPACE_VERSION,
|
|
6
6
|
identity.projectId,
|
|
7
|
-
identity.
|
|
8
|
-
identity.sandboxId,
|
|
7
|
+
['branch', identity.branchId, identity.branchRoot],
|
|
9
8
|
identity.organizationId,
|
|
10
9
|
identity.participantKind,
|
|
11
10
|
identity.participantId,
|
|
@@ -33,6 +32,6 @@ export function persistenceIdentityMatches(info, identity) {
|
|
|
33
32
|
info.workspaceId === identity.organizationId &&
|
|
34
33
|
info.participantKind === identity.participantKind &&
|
|
35
34
|
(info.projectId ?? null) === identity.projectId &&
|
|
36
|
-
|
|
37
|
-
(info.
|
|
35
|
+
info.branchId === identity.branchId &&
|
|
36
|
+
(info.branchRoot ?? false) === identity.branchRoot);
|
|
38
37
|
}
|
|
@@ -94,7 +94,14 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
94
94
|
* and an observability breadcrumb; it is never applied. There is one parse per
|
|
95
95
|
* delta — callers must not re-parse.
|
|
96
96
|
*/
|
|
97
|
+
/**
|
|
98
|
+
* Wire validation runs once per delta, so at drain scale it is a per-delta
|
|
99
|
+
* fixed cost rather than a payload-proportional one. The guard keeps the
|
|
100
|
+
* normal path free: when profiling is off this is a boolean test and a
|
|
101
|
+
* direct call, with no closure allocated per delta.
|
|
102
|
+
*/
|
|
97
103
|
private normalizeWireDelta;
|
|
104
|
+
private parseWireDelta;
|
|
98
105
|
/**
|
|
99
106
|
* Handle incoming sync delta (untrusted wire input — validated and
|
|
100
107
|
* normalized by {@link normalizeWireDelta}; malformed deltas are dropped).
|