@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/src/local/SyncClient.ts
CHANGED
|
@@ -1290,34 +1290,25 @@ export class SyncClient extends EventEmitter {
|
|
|
1290
1290
|
* local model has unsynced changes, so the two sides stay consistent.
|
|
1291
1291
|
*/
|
|
1292
1292
|
resolveConflicts(localModel: Model, serverData: Record<string, unknown>): Model {
|
|
1293
|
-
|
|
1294
|
-
//
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
serverUpdatedAt: serverData.updatedAt,
|
|
1308
|
-
localChanges: localModel.getChanges(),
|
|
1309
|
-
serverState: this.extractCriticalState(serverData),
|
|
1310
|
-
});
|
|
1311
|
-
|
|
1312
|
-
// PRIORITY 1: Check for critical server states that must be respected
|
|
1313
|
-
// These states override any local changes to maintain data consistency
|
|
1314
|
-
const criticalServerStates = this.extractCriticalState(serverData);
|
|
1315
|
-
const shouldForceAcceptServer = this.hasCriticalStateChange(criticalServerStates);
|
|
1293
|
+
// No entry-point debug here: this runs once per incoming update delta,
|
|
1294
|
+
// and a debug call's payload is BUILT even when the logger discards it —
|
|
1295
|
+
// a per-delta model scan on the apply hot path. The outcome branches
|
|
1296
|
+
// below log the cases worth reading.
|
|
1297
|
+
|
|
1298
|
+
// PRIORITY 1: Check for critical server states that must be respected.
|
|
1299
|
+
// These states override any local changes to maintain data consistency.
|
|
1300
|
+
// Checked inline — the collected-object form (`extractCriticalState`)
|
|
1301
|
+
// only materializes on the rare force-accept branch, for its log line.
|
|
1302
|
+
const shouldForceAcceptServer =
|
|
1303
|
+
(serverData.deletedAt !== undefined && serverData.deletedAt !== null) ||
|
|
1304
|
+
(serverData.archivedAt !== undefined && serverData.archivedAt !== null) ||
|
|
1305
|
+
serverData.isActive === false ||
|
|
1306
|
+
(serverData.unassignedAt !== undefined && serverData.unassignedAt !== null);
|
|
1316
1307
|
|
|
1317
1308
|
if (shouldForceAcceptServer) {
|
|
1318
1309
|
this.runtime.logger.debug('Accepting server update - critical state change detected', {
|
|
1319
1310
|
modelId: localModel.id,
|
|
1320
|
-
criticalStates:
|
|
1311
|
+
criticalStates: this.extractCriticalState(serverData),
|
|
1321
1312
|
});
|
|
1322
1313
|
|
|
1323
1314
|
// Force accept server state for critical changes
|
|
@@ -1329,7 +1320,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1329
1320
|
|
|
1330
1321
|
// Local-first: if we have local dirty fields, merge by field.
|
|
1331
1322
|
// Keep locally changed fields; apply server for the rest.
|
|
1332
|
-
if (
|
|
1323
|
+
if (localModel.hasChanges) {
|
|
1333
1324
|
const localChanges = localModel.getChanges();
|
|
1334
1325
|
this.runtime.logger.debug('Merging server update with local dirty fields', {
|
|
1335
1326
|
modelId: localModel.id,
|
|
@@ -1341,6 +1332,13 @@ export class SyncClient extends EventEmitter {
|
|
|
1341
1332
|
|
|
1342
1333
|
// Preserve the most recent updatedAt without clearing dirty flags
|
|
1343
1334
|
if (serverData.updatedAt || localModel.updatedAt) {
|
|
1335
|
+
// Safely get timestamp, handling both Date objects and strings
|
|
1336
|
+
const localUpdatedAt = localModel.updatedAt
|
|
1337
|
+
? localModel.updatedAt instanceof Date
|
|
1338
|
+
? localModel.updatedAt.getTime()
|
|
1339
|
+
: new Date(localModel.updatedAt).getTime()
|
|
1340
|
+
: 0;
|
|
1341
|
+
const serverUpdatedAt = toEpochMs(serverData.updatedAt);
|
|
1344
1342
|
const mergedUpdatedAt = new Date(Math.max(localUpdatedAt, serverUpdatedAt));
|
|
1345
1343
|
// updateFromData accepts Date or ISO string for dates
|
|
1346
1344
|
merged.updatedAt = mergedUpdatedAt;
|
|
@@ -1351,10 +1349,9 @@ export class SyncClient extends EventEmitter {
|
|
|
1351
1349
|
return localModel;
|
|
1352
1350
|
}
|
|
1353
1351
|
|
|
1354
|
-
// No local changes: fall back to LWW to converge
|
|
1355
|
-
//
|
|
1356
|
-
|
|
1357
|
-
this.runtime.logger.debug(`Accepting server update - ${acceptReason}`);
|
|
1352
|
+
// No local changes: fall back to LWW to converge. Accept server
|
|
1353
|
+
// regardless of timestamp equality to stay in sync. Not logged — this is
|
|
1354
|
+
// the common path for every collaborator update a client receives.
|
|
1358
1355
|
localModel.updateFromData(serverData);
|
|
1359
1356
|
localModel.clearChanges();
|
|
1360
1357
|
localModel.markAsSynced();
|
|
@@ -1392,17 +1389,6 @@ export class SyncClient extends EventEmitter {
|
|
|
1392
1389
|
return critical;
|
|
1393
1390
|
}
|
|
1394
1391
|
|
|
1395
|
-
/**
|
|
1396
|
-
* Check if critical state changes exist that require forcing server state
|
|
1397
|
-
*/
|
|
1398
|
-
private hasCriticalStateChange(criticalStates: Record<string, unknown>): boolean {
|
|
1399
|
-
// Any critical state present means we should force accept server
|
|
1400
|
-
return (
|
|
1401
|
-
Object.keys(criticalStates).length > 0 &&
|
|
1402
|
-
Object.values(criticalStates).some((v) => v !== null && v !== undefined)
|
|
1403
|
-
);
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
1392
|
/**
|
|
1407
1393
|
* Handle network reconnection
|
|
1408
1394
|
*/
|
|
@@ -1901,6 +1887,22 @@ export class SyncClient extends EventEmitter {
|
|
|
1901
1887
|
applyDeltaBatchToPool(
|
|
1902
1888
|
dbResults: readonly AppliedChange[],
|
|
1903
1889
|
enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>,
|
|
1890
|
+
): void {
|
|
1891
|
+
// The WHOLE batch — conflict resolution and model mutation included, not
|
|
1892
|
+
// just the pool bookkeeping at the end — runs in one MobX action.
|
|
1893
|
+
// `resolveConflicts` writes model fields via `updateFromData`; when those
|
|
1894
|
+
// writes ran before the action, every delta opened its own top-level
|
|
1895
|
+
// action and flushed reactions at its boundary, so a large frame paid one
|
|
1896
|
+
// reaction pass per delta and an observer could see a partially applied
|
|
1897
|
+
// frame between them.
|
|
1898
|
+
runInAction(() => {
|
|
1899
|
+
this.applyDeltaBatchToPoolInAction(dbResults, enrichRelations);
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
private applyDeltaBatchToPoolInAction(
|
|
1904
|
+
dbResults: readonly AppliedChange[],
|
|
1905
|
+
enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>,
|
|
1904
1906
|
): void {
|
|
1905
1907
|
const modelsToAdd: Model[] = [];
|
|
1906
1908
|
const modelsToUpsert: Model[] = [];
|
|
@@ -2002,27 +2004,21 @@ export class SyncClient extends EventEmitter {
|
|
|
2002
2004
|
}
|
|
2003
2005
|
}
|
|
2004
2006
|
|
|
2005
|
-
// Reveal the whole frame
|
|
2006
|
-
//
|
|
2007
|
-
//
|
|
2008
|
-
//
|
|
2009
|
-
//
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
// Emit changed model types so QueryProcessor can auto-invalidate.
|
|
2021
|
-
// Kept inside the action so any observable query-cache state it
|
|
2022
|
-
// flips is part of the same atomic reveal.
|
|
2023
|
-
const changedTypes = new Set(dbResults.map(r => r.modelName));
|
|
2024
|
-
if (changedTypes.size > 0) this.emit('models:changed', changedTypes);
|
|
2025
|
-
});
|
|
2007
|
+
// Reveal the whole frame at one reaction boundary: the caller's single
|
|
2008
|
+
// action covers the collection loop above and these batch pool writes, so
|
|
2009
|
+
// dependents recompute exactly once regardless of how many models or
|
|
2010
|
+
// operation kinds the frame touched, and the app never observes a
|
|
2011
|
+
// partially applied frame.
|
|
2012
|
+
if (modelsToAdd.length > 0) this.objectPool.addBatch(modelsToAdd, ModelScope.live);
|
|
2013
|
+
if (modelsToUpsert.length > 0) this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
|
|
2014
|
+
if (idsToRemove.length > 0) this.objectPool.removeBatch(idsToRemove);
|
|
2015
|
+
for (const id of idsToArchive) this.objectPool.updateScope(id, ModelScope.archived);
|
|
2016
|
+
|
|
2017
|
+
// Emit changed model types so QueryProcessor can auto-invalidate.
|
|
2018
|
+
// Kept inside the action so any observable query-cache state it
|
|
2019
|
+
// flips is part of the same atomic reveal.
|
|
2020
|
+
const changedTypes = new Set(dbResults.map(r => r.modelName));
|
|
2021
|
+
if (changedTypes.size > 0) this.emit('models:changed', changedTypes);
|
|
2026
2022
|
}
|
|
2027
2023
|
|
|
2028
2024
|
/**
|
|
@@ -521,15 +521,17 @@ export function createModelProxy<T, C>(
|
|
|
521
521
|
| ModelDeleteParams<T, C>,
|
|
522
522
|
): MutationOptions => {
|
|
523
523
|
const rest: MutationOptions = {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
524
|
+
...(params.idempotencyKey !== undefined
|
|
525
|
+
? { idempotencyKey: params.idempotencyKey }
|
|
526
|
+
: {}),
|
|
527
|
+
...(params.label !== undefined ? { label: params.label } : {}),
|
|
528
|
+
...(params.wait !== undefined ? { wait: params.wait } : {}),
|
|
529
|
+
...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
|
|
530
|
+
...(params.onStale !== undefined ? { onStale: params.onStale } : {}),
|
|
531
|
+
...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
|
|
532
|
+
...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
|
|
533
|
+
...(params.reads !== undefined ? { reads: params.reads } : {}),
|
|
534
|
+
...(params.track !== undefined ? { track: params.track } : {}),
|
|
533
535
|
};
|
|
534
536
|
// The write-options schema — the runtime twin of the compile-time params.
|
|
535
537
|
// Catches plain-JavaScript callers (for example `onStale: 'rejct'`) at the
|
|
@@ -713,7 +715,7 @@ export function createModelProxy<T, C>(
|
|
|
713
715
|
return {
|
|
714
716
|
object: 'claim',
|
|
715
717
|
id: lease.id,
|
|
716
|
-
readAt: snapshot.stamp,
|
|
718
|
+
readAt: lease.readAt ?? snapshot.stamp,
|
|
717
719
|
// The fencing token the server minted for this grant, forwarded from the
|
|
718
720
|
// lease so writes taken under this handle carry it (Option B).
|
|
719
721
|
...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
|
|
@@ -1227,7 +1229,7 @@ export function createModelProxy<T, C>(
|
|
|
1227
1229
|
const effective: MutationOptions | undefined = claimed
|
|
1228
1230
|
? {
|
|
1229
1231
|
wait: 'confirmed',
|
|
1230
|
-
readAt: claimed.snapshot.stamp,
|
|
1232
|
+
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
1231
1233
|
onStale: 'reject',
|
|
1232
1234
|
claimRef: { id: claimed.lease.id },
|
|
1233
1235
|
...opts,
|
|
@@ -1311,7 +1313,7 @@ export function createModelProxy<T, C>(
|
|
|
1311
1313
|
const effective: MutationOptions | undefined = claimed
|
|
1312
1314
|
? {
|
|
1313
1315
|
wait: 'confirmed',
|
|
1314
|
-
readAt: claimed.snapshot.stamp,
|
|
1316
|
+
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
1315
1317
|
onStale: 'reject',
|
|
1316
1318
|
claimRef: { id: claimed.lease.id },
|
|
1317
1319
|
...(claimed.lease.fenceToken !== undefined
|
|
@@ -520,6 +520,15 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
520
520
|
*/
|
|
521
521
|
organizationId?: string;
|
|
522
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Immutable branch selected by a self-hosted credential. Hosted clients
|
|
525
|
+
* receive this from the credential exchange.
|
|
526
|
+
*/
|
|
527
|
+
branchId?: string;
|
|
528
|
+
|
|
529
|
+
/** Whether the selected self-hosted branch is the project's root branch. */
|
|
530
|
+
branchRoot?: boolean;
|
|
531
|
+
|
|
523
532
|
/** The client-wide write default — see {@link AbloOptions.wait}. Projected
|
|
524
533
|
* from the public option rather than restated, so the two cannot diverge. */
|
|
525
534
|
wait?: AbloOptions['wait'];
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
16
|
+
import { omittedModelError } from '@abloatai/transaction/schema/select';
|
|
16
17
|
import {
|
|
17
18
|
durableCommitOperationSchema,
|
|
18
19
|
type DurableCommitOperation,
|
|
@@ -401,6 +402,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
401
402
|
claim: Claim,
|
|
402
403
|
waited = false,
|
|
403
404
|
fenceToken?: number,
|
|
405
|
+
readAt?: number,
|
|
404
406
|
): Claim {
|
|
405
407
|
const release = (): Promise<void> => {
|
|
406
408
|
claim.revoke?.();
|
|
@@ -416,6 +418,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
416
418
|
description: claim.description,
|
|
417
419
|
target: claim.target,
|
|
418
420
|
waited,
|
|
421
|
+
...(readAt !== undefined ? { readAt } : {}),
|
|
419
422
|
...(resolvedFenceToken !== undefined ? { fenceToken: resolvedFenceToken } : {}),
|
|
420
423
|
release,
|
|
421
424
|
revoke: claim.revoke,
|
|
@@ -448,9 +451,10 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
448
451
|
// holds the lease, never a half-claimed one racing the queue.
|
|
449
452
|
let waited = false;
|
|
450
453
|
let fenceToken: number | undefined;
|
|
454
|
+
let readAt: number | undefined;
|
|
451
455
|
if (claimOptions.queue) {
|
|
452
456
|
try {
|
|
453
|
-
({ waited, fenceToken } = await awaitClaimGrant(transport, claim.id, {
|
|
457
|
+
({ waited, fenceToken, readAt } = await awaitClaimGrant(transport, claim.id, {
|
|
454
458
|
timeoutMs: claimOptions.waitTimeoutMs,
|
|
455
459
|
maxQueueDepth: claimOptions.maxQueueDepth,
|
|
456
460
|
signal: claimOptions.signal,
|
|
@@ -464,7 +468,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
464
468
|
throw err;
|
|
465
469
|
}
|
|
466
470
|
}
|
|
467
|
-
return wrapClaimHandle(claim, waited, fenceToken);
|
|
471
|
+
return wrapClaimHandle(claim, waited, fenceToken, readAt);
|
|
468
472
|
},
|
|
469
473
|
list(target?: Partial<ModelTarget>): readonly ModelClaim[] {
|
|
470
474
|
return listModelClaims(target);
|
|
@@ -931,5 +935,22 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
931
935
|
},
|
|
932
936
|
} as Ablo<S>;
|
|
933
937
|
|
|
938
|
+
// A model the schema projection left out answers with an error naming the
|
|
939
|
+
// model and the fix, not `undefined`. An app can compile against the full
|
|
940
|
+
// source schema while running a projection, so the type system never sees
|
|
941
|
+
// this gap; without the stub the caller crashes one property later with a
|
|
942
|
+
// bare TypeError ("reading 'local'") that names neither. Non-enumerable so
|
|
943
|
+
// spread, Object.keys, and JSON.stringify walk past the stubs untriggered.
|
|
944
|
+
for (const name of schema.omittedModels ?? []) {
|
|
945
|
+
if (name in engine) continue;
|
|
946
|
+
Object.defineProperty(engine, name, {
|
|
947
|
+
get() {
|
|
948
|
+
throw omittedModelError(name);
|
|
949
|
+
},
|
|
950
|
+
enumerable: false,
|
|
951
|
+
configurable: true,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
934
955
|
return engine;
|
|
935
956
|
}
|
|
@@ -194,8 +194,8 @@ export function startStoreLifecycle<S extends SchemaRecord>(
|
|
|
194
194
|
userId,
|
|
195
195
|
accountScope,
|
|
196
196
|
projectId,
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
branchId,
|
|
198
|
+
branchRoot,
|
|
199
199
|
teamIds,
|
|
200
200
|
capabilityToken,
|
|
201
201
|
syncGroups,
|
|
@@ -257,13 +257,19 @@ export function startStoreLifecycle<S extends SchemaRecord>(
|
|
|
257
257
|
// option doc) and everyone else defaults to 'full'.
|
|
258
258
|
const resolvedBootstrapMode: 'full' | 'none' =
|
|
259
259
|
internalOptions.bootstrapMode ?? (participantKind === 'agent' ? 'none' : 'full');
|
|
260
|
+
if (!branchId) {
|
|
261
|
+
throw new AbloConnectionError(
|
|
262
|
+
'The server did not resolve an Ablo branch for this credential.',
|
|
263
|
+
{ code: 'invalid_request' },
|
|
264
|
+
);
|
|
265
|
+
}
|
|
260
266
|
|
|
261
267
|
const gen = store.initialize({
|
|
262
268
|
userId,
|
|
263
269
|
organizationId: accountScope,
|
|
264
270
|
projectId,
|
|
265
|
-
|
|
266
|
-
|
|
271
|
+
branchId,
|
|
272
|
+
branchRoot,
|
|
267
273
|
teamIds,
|
|
268
274
|
kind: participantKind,
|
|
269
275
|
capabilityToken,
|
|
@@ -31,8 +31,8 @@ export interface DatabaseInfo {
|
|
|
31
31
|
workspaceId: string;
|
|
32
32
|
participantKind: string;
|
|
33
33
|
projectId: string | null;
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
branchId: string;
|
|
35
|
+
branchRoot: boolean;
|
|
36
36
|
schemaHash: string;
|
|
37
37
|
schemaVersion: number;
|
|
38
38
|
userVersion?: number;
|
|
@@ -173,8 +173,8 @@ export class DatabaseManager {
|
|
|
173
173
|
workspaceId: identity.organizationId,
|
|
174
174
|
participantKind: identity.participantKind,
|
|
175
175
|
projectId: identity.projectId,
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
branchId: identity.branchId,
|
|
177
|
+
branchRoot: identity.branchRoot,
|
|
178
178
|
schemaHash,
|
|
179
179
|
schemaVersion,
|
|
180
180
|
userVersion,
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { AbloConnectionError } from '@abloatai/transaction/errors';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The complete authenticated
|
|
5
|
-
*
|
|
6
|
-
* those replicas must never share a namespace.
|
|
4
|
+
* The complete authenticated branch that owns one local replica. A branch id
|
|
5
|
+
* is authoritative.
|
|
7
6
|
*/
|
|
8
7
|
export interface PersistenceIdentity {
|
|
9
8
|
readonly participantId: string;
|
|
10
9
|
readonly participantKind: string;
|
|
11
10
|
readonly organizationId: string;
|
|
12
11
|
readonly projectId: string | null;
|
|
13
|
-
readonly
|
|
14
|
-
readonly
|
|
12
|
+
readonly branchId: string;
|
|
13
|
+
readonly branchRoot: boolean;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
export interface PersistedIdentityMetadata {
|
|
@@ -20,11 +19,11 @@ export interface PersistedIdentityMetadata {
|
|
|
20
19
|
readonly workspaceId: string;
|
|
21
20
|
readonly participantKind?: string;
|
|
22
21
|
readonly projectId?: string | null;
|
|
23
|
-
readonly
|
|
24
|
-
readonly
|
|
22
|
+
readonly branchId?: string;
|
|
23
|
+
readonly branchRoot?: boolean;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
export const PERSISTENCE_NAMESPACE_VERSION =
|
|
26
|
+
export const PERSISTENCE_NAMESPACE_VERSION = 4;
|
|
28
27
|
|
|
29
28
|
function canonicalIdentity(
|
|
30
29
|
identity: PersistenceIdentity,
|
|
@@ -33,8 +32,7 @@ function canonicalIdentity(
|
|
|
33
32
|
return JSON.stringify([
|
|
34
33
|
PERSISTENCE_NAMESPACE_VERSION,
|
|
35
34
|
identity.projectId,
|
|
36
|
-
identity.
|
|
37
|
-
identity.sandboxId,
|
|
35
|
+
['branch', identity.branchId, identity.branchRoot],
|
|
38
36
|
identity.organizationId,
|
|
39
37
|
identity.participantKind,
|
|
40
38
|
identity.participantId,
|
|
@@ -77,7 +75,7 @@ export function persistenceIdentityMatches(
|
|
|
77
75
|
info.workspaceId === identity.organizationId &&
|
|
78
76
|
info.participantKind === identity.participantKind &&
|
|
79
77
|
(info.projectId ?? null) === identity.projectId &&
|
|
80
|
-
|
|
81
|
-
(info.
|
|
78
|
+
info.branchId === identity.branchId &&
|
|
79
|
+
(info.branchRoot ?? false) === identity.branchRoot
|
|
82
80
|
);
|
|
83
81
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { getContext } from '../context.js';
|
|
14
14
|
import { clientSyncDeltaSchema, type ClientSyncDelta } from '@abloatai/transaction/wire/delta';
|
|
15
|
+
import { drainProfilingEnabled, observeDrainStage } from './drainProfile.js';
|
|
15
16
|
import {
|
|
16
17
|
WsTransport,
|
|
17
18
|
type WsTransportOptions,
|
|
@@ -212,7 +213,23 @@ export class SyncWebSocket<
|
|
|
212
213
|
* and an observability breadcrumb; it is never applied. There is one parse per
|
|
213
214
|
* delta — callers must not re-parse.
|
|
214
215
|
*/
|
|
216
|
+
/**
|
|
217
|
+
* Wire validation runs once per delta, so at drain scale it is a per-delta
|
|
218
|
+
* fixed cost rather than a payload-proportional one. The guard keeps the
|
|
219
|
+
* normal path free: when profiling is off this is a boolean test and a
|
|
220
|
+
* direct call, with no closure allocated per delta.
|
|
221
|
+
*/
|
|
215
222
|
private normalizeWireDelta(raw: unknown): SyncDelta | null {
|
|
223
|
+
if (!drainProfilingEnabled()) return this.parseWireDelta(raw);
|
|
224
|
+
const startedAt = performance.now();
|
|
225
|
+
try {
|
|
226
|
+
return this.parseWireDelta(raw);
|
|
227
|
+
} finally {
|
|
228
|
+
observeDrainStage('parse', performance.now() - startedAt);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private parseWireDelta(raw: unknown): SyncDelta | null {
|
|
216
233
|
let candidate: unknown = raw;
|
|
217
234
|
if (isRecord(raw)) {
|
|
218
235
|
const normalized: Record<string, unknown> = { ...raw };
|
|
@@ -255,12 +272,9 @@ export class SyncWebSocket<
|
|
|
255
272
|
protected override handleDelta(rawDelta: unknown): void {
|
|
256
273
|
const delta = this.normalizeWireDelta(rawDelta);
|
|
257
274
|
if (!delta) return;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
id: delta.modelId,
|
|
262
|
-
syncId: delta.id,
|
|
263
|
-
});
|
|
275
|
+
// No per-delta debug here: the payload object is built even when the
|
|
276
|
+
// logger discards it, and this runs at the full live wire rate. Dropped
|
|
277
|
+
// malformed deltas are still logged by `normalizeWireDelta`.
|
|
264
278
|
|
|
265
279
|
// Do not advance `this.cursor.lastSyncId` on receipt. The runtime cursor
|
|
266
280
|
// must stay consistent with what has been persisted locally; otherwise the
|
|
@@ -24,6 +24,14 @@ import {
|
|
|
24
24
|
type AbloPlugin,
|
|
25
25
|
type AppliedChange,
|
|
26
26
|
} from '../../plugin.js';
|
|
27
|
+
import {
|
|
28
|
+
observeDrainBatch,
|
|
29
|
+
observeDrainAcknowledge,
|
|
30
|
+
timeDrainStage,
|
|
31
|
+
timeDrainStageAsync,
|
|
32
|
+
openDrainBatchRow,
|
|
33
|
+
closeDrainBatchRow,
|
|
34
|
+
} from './drainProfile.js';
|
|
27
35
|
|
|
28
36
|
/**
|
|
29
37
|
* What the pipeline needs back from the surrounding store: the shared mutable
|
|
@@ -407,9 +415,22 @@ function yieldToHost(): Promise<void> {
|
|
|
407
415
|
async function flushDeltaBatch(
|
|
408
416
|
ctx: DeltaPipelineContext,
|
|
409
417
|
queuedDeltas: SyncDelta[],
|
|
418
|
+
): Promise<void> {
|
|
419
|
+
openDrainBatchRow(queuedDeltas.length);
|
|
420
|
+
try {
|
|
421
|
+
await flushDeltaBatchInner(ctx, queuedDeltas);
|
|
422
|
+
} finally {
|
|
423
|
+
closeDrainBatchRow();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function flushDeltaBatchInner(
|
|
428
|
+
ctx: DeltaPipelineContext,
|
|
429
|
+
queuedDeltas: SyncDelta[],
|
|
410
430
|
): Promise<void> {
|
|
411
431
|
const stagePlugins = ctx.stagePlugins ?? [];
|
|
412
|
-
const deduplicatedDeltas = ctx.deduplicateDeltas(queuedDeltas);
|
|
432
|
+
const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
|
|
433
|
+
observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
|
|
413
434
|
runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
|
|
414
435
|
|
|
415
436
|
// Custom entities → apply straight to the pool, skipping the local store.
|
|
@@ -444,17 +465,19 @@ async function flushDeltaBatch(
|
|
|
444
465
|
// handleGroupRemoved) and never reach here, though the persistence
|
|
445
466
|
// signature accepts them defensively.
|
|
446
467
|
const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
|
|
447
|
-
const batch = await
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
468
|
+
const batch = await timeDrainStageAsync('persist', () =>
|
|
469
|
+
ctx.processDeltaBatch(
|
|
470
|
+
regularDeltas.map((d) => ({
|
|
471
|
+
syncId: d.id,
|
|
472
|
+
actionType: d.actionType,
|
|
473
|
+
modelName: d.modelName,
|
|
474
|
+
modelId: d.modelId,
|
|
475
|
+
data: typeof d.data === 'string' ? JSON.parse(d.data) : d.data,
|
|
476
|
+
// Thread `transactionId` through so the receive layer can recognize
|
|
477
|
+
// echoes of locally-applied transactions and skip the pool mutation.
|
|
478
|
+
transactionId: d.transactionId,
|
|
479
|
+
}))
|
|
480
|
+
)
|
|
458
481
|
);
|
|
459
482
|
const dbResults = batch.results;
|
|
460
483
|
runStage(stagePlugins, 'persist', { deltas: regularDeltas });
|
|
@@ -464,11 +487,13 @@ async function flushDeltaBatch(
|
|
|
464
487
|
// materialiser attached where it said it would. The direct call is the
|
|
465
488
|
// bridge for stores constructed without plugins (subclasses, tests),
|
|
466
489
|
// whose own apply is the whole pipeline.
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
490
|
+
timeDrainStage('apply', () => {
|
|
491
|
+
if (pluginsForStage(stagePlugins, 'apply').length > 0) {
|
|
492
|
+
runStage(stagePlugins, 'apply', { changes: dbResults });
|
|
493
|
+
} else {
|
|
494
|
+
ctx.applyDeltaBatchToPool(dbResults);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
472
497
|
|
|
473
498
|
// Acknowledge and advance the sync cursor, gated on persistence.
|
|
474
499
|
//
|
|
@@ -480,11 +505,16 @@ async function flushDeltaBatch(
|
|
|
480
505
|
// be lost. The cursor and the persisted state must move together.
|
|
481
506
|
const persistedSyncId = batch.persistedSyncId;
|
|
482
507
|
if (persistedSyncId > ctx.lastAckedId) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
508
|
+
timeDrainStage('acknowledge', () => {
|
|
509
|
+
ctx.acknowledge(persistedSyncId);
|
|
510
|
+
ctx.advancePersisted(persistedSyncId);
|
|
511
|
+
observeDrainAcknowledge(persistedSyncId);
|
|
512
|
+
runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
|
|
513
|
+
});
|
|
486
514
|
}
|
|
487
515
|
|
|
488
516
|
// Cache invalidation happens automatically via the 'models:changed' event.
|
|
489
|
-
|
|
517
|
+
timeDrainStage('notify', () => {
|
|
518
|
+
runStage(stagePlugins, 'notify', { changes: dbResults });
|
|
519
|
+
});
|
|
490
520
|
}
|