@abloatai/humans 0.51.0 → 0.53.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/client.d.ts +4 -4
- package/dist/local/Database.d.ts +1 -34
- package/dist/local/Database.js +16 -55
- package/dist/local/InstanceCache.d.ts +9 -0
- package/dist/local/InstanceCache.js +9 -0
- package/dist/local/Model.js +0 -18
- package/dist/local/SyncClient.d.ts +23 -11
- package/dist/local/SyncClient.js +44 -53
- package/dist/local/client/createInternalComponents.js +4 -0
- package/dist/local/client/createModelProxy.js +21 -3
- package/dist/local/interfaces/index.d.ts +3 -3
- package/dist/local/rowWatermarks.d.ts +40 -0
- package/dist/local/rowWatermarks.js +53 -0
- package/dist/local/sync/OnDemandLoader.d.ts +22 -13
- package/dist/local/sync/OnDemandLoader.js +58 -73
- package/dist/local/sync/bootstrapApply.d.ts +2 -4
- package/dist/local/sync/deltaPipeline.js +1 -1
- package/dist/local/sync/initialize.js +2 -2
- package/dist/local/transactions/mutations/MutationQueue.d.ts +1 -1
- package/dist/local/transactions/mutations/MutationQueue.js +1 -1
- package/dist/local/transactions/persistedTransaction.d.ts +39 -0
- package/dist/local/transactions/persistedTransaction.js +53 -0
- package/dist/local/utils/mobxSetup.js +1 -1
- package/dist/plugin.d.ts +7 -0
- package/dist/react/AbloProvider.d.ts +2 -2
- package/dist/react/AbloProvider.js +2 -2
- package/dist/react/context.d.ts +2 -2
- package/dist/react/useAblo.d.ts +3 -3
- package/package.json +2 -2
- package/src/client.ts +4 -4
- package/src/local/BaseSyncedStore.ts +1 -1
- package/src/local/Database.ts +22 -110
- package/src/local/InstanceCache.ts +10 -0
- package/src/local/Model.ts +0 -20
- package/src/local/SyncClient.ts +81 -66
- package/src/local/client/createInternalComponents.ts +4 -0
- package/src/local/client/createModelProxy.ts +26 -5
- package/src/local/interfaces/index.ts +3 -3
- package/src/local/rowWatermarks.ts +54 -0
- package/src/local/sync/OnDemandLoader.ts +99 -70
- package/src/local/sync/bootstrapApply.ts +2 -1
- package/src/local/sync/deltaPipeline.ts +1 -1
- package/src/local/sync/initialize.ts +2 -2
- package/src/local/transactions/mutations/MutationQueue.ts +1 -1
- package/src/local/transactions/persistedTransaction.ts +112 -0
- package/src/local/utils/mobxSetup.ts +1 -1
- package/src/plugin.ts +7 -0
- package/src/react/AbloProvider.tsx +2 -2
- package/src/react/context.ts +2 -2
- package/src/react/useAblo.ts +3 -3
package/dist/client.d.ts
CHANGED
|
@@ -108,10 +108,10 @@ export type AbloClient<S extends SchemaRecord> = {
|
|
|
108
108
|
* server verifies it. The browser must never see the `sk_` key, only the
|
|
109
109
|
* per-user session token.
|
|
110
110
|
*
|
|
111
|
-
* Pass `{ user: { id }, can: {
|
|
111
|
+
* Pass `{ user: { id }, can: { items: ['read', 'update'] } }` for an end-user
|
|
112
112
|
* session. It mints an `ek_` and attributes writes to a user (recorded as
|
|
113
113
|
* `actor_kind` on the delta row). Pass `{ agent: { id }, can: {
|
|
114
|
-
*
|
|
114
|
+
* items: ['update'] } }` for a scoped agent session, which mints an `rk_`.
|
|
115
115
|
* Both kinds require `can`, typed against your schema's model names. This
|
|
116
116
|
* always authenticates with the original `sk_`, never the client's exchanged
|
|
117
117
|
* sync credential.
|
|
@@ -126,10 +126,10 @@ export type AbloClient<S extends SchemaRecord> = {
|
|
|
126
126
|
* ```ts
|
|
127
127
|
* const agent = await ablo.agents.create({
|
|
128
128
|
* name: 'researcher', // readable label (optional)
|
|
129
|
-
* can: {
|
|
129
|
+
* can: { records: ['read', 'update'] },
|
|
130
130
|
* // id omitted → a fresh uuid: a distinct, independent participant
|
|
131
131
|
* });
|
|
132
|
-
* await agent.
|
|
132
|
+
* await agent.records.update({ id, data, claim });
|
|
133
133
|
* await agent.dispose(); // when the agent is done
|
|
134
134
|
* ```
|
|
135
135
|
*
|
package/dist/local/Database.d.ts
CHANGED
|
@@ -14,43 +14,10 @@ import type { AppliedChange } from '../plugin.js';
|
|
|
14
14
|
import type { BootstrapFetcher, BootstrapData } from './sync/BootstrapFetcher.js';
|
|
15
15
|
import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
|
|
16
16
|
import type { SyncDeltaAction } from '@abloatai/transaction/wire/delta';
|
|
17
|
-
import type { OnStaleMode } from '@abloatai/transaction/coordination/schema';
|
|
18
17
|
import type { BootstrapType } from '@abloatai/transaction/types';
|
|
18
|
+
import { type PersistedTransaction } from './transactions/persistedTransaction.js';
|
|
19
19
|
/** Generic record type for model data */
|
|
20
20
|
type ModelData = Record<string, unknown>;
|
|
21
|
-
/** Persisted mutation in a transaction */
|
|
22
|
-
interface PersistedMutation {
|
|
23
|
-
type: 'create' | 'update' | 'delete' | 'archive';
|
|
24
|
-
modelData: ModelData;
|
|
25
|
-
modelName: string;
|
|
26
|
-
timestamp: string;
|
|
27
|
-
writeOptions?: {
|
|
28
|
-
readAt?: number | null;
|
|
29
|
-
onStale?: OnStaleMode | null;
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
/** Persisted transaction for offline/retry support.
|
|
33
|
-
*
|
|
34
|
-
* Index signature is part of the contract: this interface targets
|
|
35
|
-
* the generic record-shaped storage layer (`InMemoryObjectStore.put`
|
|
36
|
-
* + the IDB ObjectStore equivalent), both of which take
|
|
37
|
-
* `Record<string, unknown>`. Every declared field below already
|
|
38
|
-
* satisfies `unknown`; the index signature just makes the
|
|
39
|
-
* interface assignable to the storage parameter without a cast. */
|
|
40
|
-
interface PersistedTransaction {
|
|
41
|
-
id: string;
|
|
42
|
-
type?: string;
|
|
43
|
-
timestamp?: number;
|
|
44
|
-
createdAt?: number;
|
|
45
|
-
mutations?: PersistedMutation[];
|
|
46
|
-
awaitingDelta?: {
|
|
47
|
-
syncIdNeeded: number;
|
|
48
|
-
modelName: string;
|
|
49
|
-
modelId: string;
|
|
50
|
-
operationType: string;
|
|
51
|
-
};
|
|
52
|
-
[key: string]: unknown;
|
|
53
|
-
}
|
|
54
21
|
export type { BootstrapType };
|
|
55
22
|
export interface BootstrapRequirements {
|
|
56
23
|
type: BootstrapType;
|
package/dist/local/Database.js
CHANGED
|
@@ -15,62 +15,20 @@ import { persistenceDatabaseNamesForDeletion, purgeIndexedDbPersistence, } from
|
|
|
15
15
|
import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
|
|
16
16
|
import { logPositionSchema } from './logPosition.js';
|
|
17
17
|
import { highestPersistedPrefixSyncId } from './sync/persistedPrefix.js';
|
|
18
|
+
import { isAcceptedOutboxPromotion, isSameOutboxRecord, } from './transactions/persistedTransaction.js';
|
|
18
19
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* an idempotency conflict. Only the fields that define the wire request count.
|
|
20
|
+
* Carry each input delta's log position onto the change that answers it.
|
|
21
|
+
* `processDeltaBatch` builds its results index-aligned with its input, so the
|
|
22
|
+
* position is stamped once here rather than at every construction site.
|
|
23
23
|
*/
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
storageVersion: record.storageVersion,
|
|
31
|
-
idempotencyKey: record.idempotencyKey,
|
|
32
|
-
// HTTP outbox rows written before protocol versioning are v1. Normalize
|
|
33
|
-
// them so a same-request re-seal remains idempotent after an upgrade.
|
|
34
|
-
protocolVersion: record.protocolVersion ?? 1,
|
|
35
|
-
request: record.request,
|
|
36
|
-
scopeNamespace: record.scopeNamespace,
|
|
37
|
-
});
|
|
38
|
-
if (existing.correlationId !== undefined &&
|
|
39
|
-
candidate.correlationId !== undefined &&
|
|
40
|
-
existing.correlationId !== candidate.correlationId) {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
44
|
-
}
|
|
45
|
-
if (existing.type === 'commit_envelope' &&
|
|
46
|
-
candidate.type === 'commit_envelope') {
|
|
47
|
-
const identity = (record) => ({
|
|
48
|
-
id: record.id,
|
|
49
|
-
type: record.type,
|
|
50
|
-
storageVersion: record.storageVersion,
|
|
51
|
-
origin: record.origin,
|
|
52
|
-
idempotencyKey: record.idempotencyKey,
|
|
53
|
-
operations: record.operations,
|
|
54
|
-
sourceMutationIds: record.sourceMutationIds,
|
|
55
|
-
commitOptions: record.commitOptions,
|
|
56
|
-
scope: record.scope,
|
|
57
|
-
});
|
|
58
|
-
if (existing.correlationId !== undefined &&
|
|
59
|
-
candidate.correlationId !== undefined &&
|
|
60
|
-
existing.correlationId !== candidate.correlationId) {
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
24
|
+
function stampSyncIds(results, deltas) {
|
|
25
|
+
for (let index = 0; index < results.length; index++) {
|
|
26
|
+
const change = results[index];
|
|
27
|
+
const syncId = deltas[index]?.syncId;
|
|
28
|
+
if (change && typeof syncId === 'number')
|
|
29
|
+
change.syncId = syncId;
|
|
64
30
|
}
|
|
65
|
-
return
|
|
66
|
-
}
|
|
67
|
-
function isAcceptedOutboxPromotion(existing, candidate) {
|
|
68
|
-
return (existing !== undefined &&
|
|
69
|
-
(existing.type === 'commit_envelope' ||
|
|
70
|
-
existing.type === 'http_commit_envelope') &&
|
|
71
|
-
existing.type === candidate.type &&
|
|
72
|
-
existing.acceptedAt === undefined &&
|
|
73
|
-
candidate.acceptedAt !== undefined);
|
|
31
|
+
return results;
|
|
74
32
|
}
|
|
75
33
|
export class Database {
|
|
76
34
|
// Core database components
|
|
@@ -887,7 +845,10 @@ export class Database {
|
|
|
887
845
|
updatedAt: new Date(),
|
|
888
846
|
};
|
|
889
847
|
}
|
|
890
|
-
return {
|
|
848
|
+
return {
|
|
849
|
+
results: stampSyncIds(inMemResults, deltas),
|
|
850
|
+
persistedSyncId: inMemPersistedSyncId,
|
|
851
|
+
};
|
|
891
852
|
}
|
|
892
853
|
// Prepare results aligned with input order
|
|
893
854
|
const results = new Array(deltas.length);
|
|
@@ -1242,7 +1203,7 @@ export class Database {
|
|
|
1242
1203
|
gap: highestSyncId - highestPersistedSyncId,
|
|
1243
1204
|
});
|
|
1244
1205
|
}
|
|
1245
|
-
return { results, persistedSyncId: highestPersistedSyncId };
|
|
1206
|
+
return { results: stampSyncIds(results, deltas), persistedSyncId: highestPersistedSyncId };
|
|
1246
1207
|
}
|
|
1247
1208
|
/** Get raw data for hydration */
|
|
1248
1209
|
async hydrateModels(modelName) {
|
|
@@ -12,6 +12,7 @@ import type { RuntimeContext } from './RuntimeContext.js';
|
|
|
12
12
|
import { ModelScope } from '@abloatai/transaction/types';
|
|
13
13
|
import { ViewRegistry } from './views/ViewRegistry.js';
|
|
14
14
|
import { QueryView, type QueryViewOptions } from './views/QueryView.js';
|
|
15
|
+
import { RowWatermarks } from './rowWatermarks.js';
|
|
15
16
|
/** Constructor type for Model subclasses - uses abstract to handle variance */
|
|
16
17
|
type ModelConstructor<T extends Model> = abstract new (...args: never[]) => T;
|
|
17
18
|
export { ModelScope };
|
|
@@ -51,6 +52,14 @@ export declare class InstanceCache {
|
|
|
51
52
|
private gcTimer?;
|
|
52
53
|
readonly registry: ModelRegistry;
|
|
53
54
|
readonly viewRegistry: ViewRegistry;
|
|
55
|
+
/**
|
|
56
|
+
* The log position each pooled row is known to reflect. Every door a row
|
|
57
|
+
* enters through (delta, own ack, bootstrap, server read) advances it, and
|
|
58
|
+
* every snapshot that would overwrite a resident row is judged against it —
|
|
59
|
+
* see {@link RowWatermarks}. Keyed by instance so it lives and dies with the
|
|
60
|
+
* pooled model.
|
|
61
|
+
*/
|
|
62
|
+
readonly watermarks: RowWatermarks;
|
|
54
63
|
private subscriptions;
|
|
55
64
|
/**
|
|
56
65
|
* Subscribe to updates for a specific model type.
|
|
@@ -14,6 +14,7 @@ import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
|
14
14
|
import { ModelScope, PropertyType } from '@abloatai/transaction/types';
|
|
15
15
|
import { ViewRegistry } from './views/ViewRegistry.js';
|
|
16
16
|
import { QueryView } from './views/QueryView.js';
|
|
17
|
+
import { RowWatermarks } from './rowWatermarks.js';
|
|
17
18
|
// Re-exported so `import { ModelScope } from './InstanceCache.js'` resolves
|
|
18
19
|
export { ModelScope };
|
|
19
20
|
/**
|
|
@@ -80,6 +81,14 @@ export class InstanceCache {
|
|
|
80
81
|
registry;
|
|
81
82
|
// ViewRegistry — tracks active QueryViews for incremental view maintenance
|
|
82
83
|
viewRegistry = new ViewRegistry();
|
|
84
|
+
/**
|
|
85
|
+
* The log position each pooled row is known to reflect. Every door a row
|
|
86
|
+
* enters through (delta, own ack, bootstrap, server read) advances it, and
|
|
87
|
+
* every snapshot that would overwrite a resident row is judged against it —
|
|
88
|
+
* see {@link RowWatermarks}. Keyed by instance so it lives and dies with the
|
|
89
|
+
* pooled model.
|
|
90
|
+
*/
|
|
91
|
+
watermarks = new RowWatermarks();
|
|
83
92
|
// Subscription registry
|
|
84
93
|
subscriptions = new Map();
|
|
85
94
|
/**
|
package/dist/local/Model.js
CHANGED
|
@@ -937,24 +937,6 @@ export class Model {
|
|
|
937
937
|
}
|
|
938
938
|
// Try to get model class by identifier
|
|
939
939
|
let ModelClass = getActiveRegistry().getModelByName(modelIdentifier);
|
|
940
|
-
// If not found by registered name, try mapping to the class name
|
|
941
|
-
if (!ModelClass) {
|
|
942
|
-
const classNameMap = {
|
|
943
|
-
Task: 'TaskModel',
|
|
944
|
-
Project: 'Project',
|
|
945
|
-
Comment: 'CommentModel',
|
|
946
|
-
User: 'UserModel',
|
|
947
|
-
Organization: 'OrganizationModel',
|
|
948
|
-
StatusGroup: 'StatusGroupModel',
|
|
949
|
-
Team: 'TeamModel',
|
|
950
|
-
Member: 'MemberModel',
|
|
951
|
-
Role: 'RoleModel',
|
|
952
|
-
};
|
|
953
|
-
const className = classNameMap[modelIdentifier];
|
|
954
|
-
if (className) {
|
|
955
|
-
ModelClass = getActiveRegistry().getModelByName(className);
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
940
|
if (!ModelClass) {
|
|
959
941
|
throw new AbloValidationError(`Model class not found for: ${modelIdentifier}`, { code: 'model_class_not_registered' });
|
|
960
942
|
}
|
|
@@ -17,6 +17,7 @@ import { type CommitLatencySample } from './transactions/mutations/commitLatency
|
|
|
17
17
|
import { type UnconfirmedWritesMetrics } from './transactions/mutations/UnconfirmedWrites.js';
|
|
18
18
|
import type { DurableWriteStore } from './transactions/mutations/durableWriteStore.js';
|
|
19
19
|
import type { Database } from './Database.js';
|
|
20
|
+
import type { BootstrapData } from './sync/BootstrapFetcher.js';
|
|
20
21
|
import type { WriteOptions } from './interfaces/index.js';
|
|
21
22
|
import { LogPosition } from './logPosition.js';
|
|
22
23
|
interface SyncObserver {
|
|
@@ -43,6 +44,14 @@ export interface RehydrationStats {
|
|
|
43
44
|
healed: number;
|
|
44
45
|
elapsedMs: number;
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The slice of a bootstrap answer the pool applies: its rows, the models whose
|
|
49
|
+
* server query failed, and the log position the snapshot was taken at — the
|
|
50
|
+
* position every row in it reflects. `lastSyncId` is optional only for callers
|
|
51
|
+
* applying rows with no snapshot position to speak of; the fetcher always
|
|
52
|
+
* names one.
|
|
53
|
+
*/
|
|
54
|
+
export type BootstrapSnapshot = Pick<BootstrapData, 'models' | 'failedModels'> & Partial<Pick<BootstrapData, 'lastSyncId'>>;
|
|
46
55
|
export declare class SyncClient extends EventEmitter {
|
|
47
56
|
private readonly runtime;
|
|
48
57
|
private objectPool;
|
|
@@ -127,6 +136,13 @@ export declare class SyncClient extends EventEmitter {
|
|
|
127
136
|
* deliver the missing deltas and confirm the transaction.
|
|
128
137
|
*/
|
|
129
138
|
private setupAwaitingTransactionPersistence;
|
|
139
|
+
/**
|
|
140
|
+
* Advance the pooled rows a completed transaction wrote to the log position
|
|
141
|
+
* its acknowledgement named. A model mutation names one row; an explicit
|
|
142
|
+
* commit names one per operation. Rows no longer pooled have nothing to
|
|
143
|
+
* advance — a fresh instance starts without evidence.
|
|
144
|
+
*/
|
|
145
|
+
private noteOwnWritePositions;
|
|
130
146
|
/** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
|
|
131
147
|
private persistAwaitingTransaction;
|
|
132
148
|
/** Drop the persisted awaiting-row once confirmed (never rejects). */
|
|
@@ -451,8 +467,6 @@ export declare class SyncClient extends EventEmitter {
|
|
|
451
467
|
}[];
|
|
452
468
|
};
|
|
453
469
|
};
|
|
454
|
-
unassignEntity(entityType: string, entityId: string): Promise<void>;
|
|
455
|
-
reassignEntity(entityType: string, entityId: string, assigneeType: string, assigneeId: string, id?: string): Promise<void>;
|
|
456
470
|
/**
|
|
457
471
|
* Apply a batch of delta results from Database to the InstanceCache.
|
|
458
472
|
* Owns: model creation, upsert, remove, archive, conflict resolution.
|
|
@@ -499,18 +513,16 @@ export declare class SyncClient extends EventEmitter {
|
|
|
499
513
|
* Apply bootstrap data to the InstanceCache with ghost removal.
|
|
500
514
|
* Owns: model creation, batch upsert, ghost detection + removal.
|
|
501
515
|
*/
|
|
502
|
-
applyBootstrapDataToPool(bootstrapData: {
|
|
503
|
-
models?: Record<string, unknown[]>;
|
|
504
|
-
failedModels?: string[];
|
|
505
|
-
}, protectedIds?: ReadonlySet<string>, options?: {
|
|
516
|
+
applyBootstrapDataToPool(bootstrapData: BootstrapSnapshot, protectedIds?: ReadonlySet<string>, options?: {
|
|
506
517
|
/**
|
|
507
518
|
* Scoped backfill for the hydrate-on-enter path: the snapshot covers only
|
|
508
519
|
* the groups just entered, not the whole model type. Two behaviors change
|
|
509
|
-
* so the subset cannot corrupt the pool. First, the
|
|
510
|
-
*
|
|
511
|
-
* delta is not clobbered back to the
|
|
512
|
-
* removal is skipped, because a subset
|
|
513
|
-
* the same type that belong to other,
|
|
520
|
+
* so the subset cannot corrupt the pool. First, a row the pool already
|
|
521
|
+
* knows to reflect a position beyond the snapshot's `lastSyncId` is
|
|
522
|
+
* skipped, so a concurrent live delta is not clobbered back to the
|
|
523
|
+
* snapshot version. Second, ghost removal is skipped, because a subset
|
|
524
|
+
* snapshot must never evict rows of the same type that belong to other,
|
|
525
|
+
* unhydrated groups.
|
|
514
526
|
*/
|
|
515
527
|
scoped?: boolean;
|
|
516
528
|
}): {
|
package/dist/local/SyncClient.js
CHANGED
|
@@ -24,32 +24,6 @@ import { LogPosition } from './logPosition.js';
|
|
|
24
24
|
import { createLocalMutationPort } from './transactions/localMutation.js';
|
|
25
25
|
import { createReconnectDrain } from './transactions/reconnectDrain.js';
|
|
26
26
|
import { DatabaseCommitOutboxStore } from './transactions/databaseCommitOutbox.js';
|
|
27
|
-
/**
|
|
28
|
-
* Reports whether an incoming snapshot record is strictly newer than the
|
|
29
|
-
* model already in the pool. The comparison uses the server-stamped
|
|
30
|
-
* `updatedAt` timestamp, since rows carry no numeric version and the delta
|
|
31
|
-
* pipeline resolves order by arrival (last write wins). An undefined incoming
|
|
32
|
-
* timestamp counts as not newer, so a known row is never clobbered; an
|
|
33
|
-
* undefined existing timestamp means the pooled row is unversioned, so the
|
|
34
|
-
* incoming record wins. The scoped hydrate-on-enter path uses this to drop
|
|
35
|
-
* snapshot rows that a live delta has already advanced past.
|
|
36
|
-
*/
|
|
37
|
-
function rawRecordIsNewer(data, existing) {
|
|
38
|
-
const raw = data.updatedAt;
|
|
39
|
-
const inMs = raw instanceof Date
|
|
40
|
-
? raw.getTime()
|
|
41
|
-
: typeof raw === 'string'
|
|
42
|
-
? (Number.isNaN(Date.parse(raw)) ? undefined : Date.parse(raw))
|
|
43
|
-
: typeof raw === 'number'
|
|
44
|
-
? raw
|
|
45
|
-
: undefined;
|
|
46
|
-
const exMs = existing.updatedAt instanceof Date ? existing.updatedAt.getTime() : undefined;
|
|
47
|
-
if (inMs === undefined)
|
|
48
|
-
return false;
|
|
49
|
-
if (exMs === undefined)
|
|
50
|
-
return true;
|
|
51
|
-
return inMs > exMs;
|
|
52
|
-
}
|
|
53
27
|
/**
|
|
54
28
|
* Converts an untyped server `updatedAt` value — an ISO string, epoch number,
|
|
55
29
|
* or Date read off an untyped row — into epoch milliseconds for
|
|
@@ -352,10 +326,16 @@ export class SyncClient extends EventEmitter {
|
|
|
352
326
|
// void is safe: the handler's body is fully try/catch'd.
|
|
353
327
|
void this.persistAwaitingTransaction(event);
|
|
354
328
|
});
|
|
355
|
-
// Clean up persisted awaiting transactions when they're finally confirmed
|
|
329
|
+
// Clean up persisted awaiting transactions when they're finally confirmed,
|
|
330
|
+
// and record the confirmed position on every row the transaction wrote.
|
|
331
|
+
// The acknowledgement is the earliest proof of where this client's own
|
|
332
|
+
// write landed in the log — earlier than its delta echo, which the pool
|
|
333
|
+
// suppresses on apply — so a snapshot read before the write cannot regress
|
|
334
|
+
// the row in the window between the two.
|
|
356
335
|
this.mutationQueue.on('transaction:completed', (tx) => {
|
|
357
336
|
// void is safe: the handler's body is fully try/catch'd.
|
|
358
337
|
void this.removeAwaitingTransaction(tx.id);
|
|
338
|
+
this.noteOwnWritePositions(tx);
|
|
359
339
|
});
|
|
360
340
|
// Echo detection bridge. When the queue stages a transaction, the
|
|
361
341
|
// client has already optimistically applied the change to the
|
|
@@ -373,6 +353,23 @@ export class SyncClient extends EventEmitter {
|
|
|
373
353
|
this.echoTracker.drainOnRollback(event.transaction.id);
|
|
374
354
|
});
|
|
375
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* Advance the pooled rows a completed transaction wrote to the log position
|
|
358
|
+
* its acknowledgement named. A model mutation names one row; an explicit
|
|
359
|
+
* commit names one per operation. Rows no longer pooled have nothing to
|
|
360
|
+
* advance — a fresh instance starts without evidence.
|
|
361
|
+
*/
|
|
362
|
+
noteOwnWritePositions(tx) {
|
|
363
|
+
const position = tx.lastSyncId ?? tx.syncIdNeededForCompletion;
|
|
364
|
+
if (position === undefined)
|
|
365
|
+
return;
|
|
366
|
+
const rowIds = tx.operations !== undefined ? tx.operations.map((op) => op.id) : [tx.modelId];
|
|
367
|
+
for (const rowId of rowIds) {
|
|
368
|
+
const row = this.objectPool.peek(rowId);
|
|
369
|
+
if (row)
|
|
370
|
+
this.objectPool.watermarks.advance(row, position);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
376
373
|
/** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
|
|
377
374
|
async persistAwaitingTransaction(event) {
|
|
378
375
|
if (!this.database)
|
|
@@ -1048,8 +1045,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1048
1045
|
// only materializes on the rare force-accept branch, for its log line.
|
|
1049
1046
|
const shouldForceAcceptServer = (serverData.deletedAt !== undefined && serverData.deletedAt !== null) ||
|
|
1050
1047
|
(serverData.archivedAt !== undefined && serverData.archivedAt !== null) ||
|
|
1051
|
-
serverData.isActive === false
|
|
1052
|
-
(serverData.unassignedAt !== undefined && serverData.unassignedAt !== null);
|
|
1048
|
+
serverData.isActive === false;
|
|
1053
1049
|
if (shouldForceAcceptServer) {
|
|
1054
1050
|
this.runtime.logger.debug('Accepting server update - critical state change detected', {
|
|
1055
1051
|
modelId: localModel.id,
|
|
@@ -1113,13 +1109,10 @@ export class SyncClient extends EventEmitter {
|
|
|
1113
1109
|
if (serverData.archivedAt !== undefined) {
|
|
1114
1110
|
critical.archivedAt = serverData.archivedAt;
|
|
1115
1111
|
}
|
|
1116
|
-
// Deactivation states
|
|
1112
|
+
// Deactivation states are always critical.
|
|
1117
1113
|
if (serverData.isActive !== undefined && serverData.isActive === false) {
|
|
1118
1114
|
critical.isActive = false;
|
|
1119
1115
|
}
|
|
1120
|
-
if (serverData.unassignedAt !== undefined) {
|
|
1121
|
-
critical.unassignedAt = serverData.unassignedAt;
|
|
1122
|
-
}
|
|
1123
1116
|
return critical;
|
|
1124
1117
|
}
|
|
1125
1118
|
/**
|
|
@@ -1475,19 +1468,6 @@ export class SyncClient extends EventEmitter {
|
|
|
1475
1468
|
mutationQueue: this.mutationQueue.getDebugInfo(),
|
|
1476
1469
|
};
|
|
1477
1470
|
}
|
|
1478
|
-
// --- Best-practice assignment ops ---
|
|
1479
|
-
async unassignEntity(entityType, entityId) {
|
|
1480
|
-
// Call server-side unassign to avoid per-id races
|
|
1481
|
-
await this.mutationExecutor.executeDelete('Assignment', entityId);
|
|
1482
|
-
}
|
|
1483
|
-
async reassignEntity(entityType, entityId, assigneeType, assigneeId, id) {
|
|
1484
|
-
await this.mutationExecutor.executeCreate('Assignment', id || '', {
|
|
1485
|
-
entityType,
|
|
1486
|
-
entityId,
|
|
1487
|
-
assigneeType,
|
|
1488
|
-
assigneeId,
|
|
1489
|
-
});
|
|
1490
|
-
}
|
|
1491
1471
|
// ── Delta + Bootstrap application (owns InstanceCache writes) ──────────────
|
|
1492
1472
|
/**
|
|
1493
1473
|
* Apply a batch of delta results from Database to the InstanceCache.
|
|
@@ -1602,7 +1582,13 @@ export class SyncClient extends EventEmitter {
|
|
|
1602
1582
|
idsBeingRemoved.add(r.modelId);
|
|
1603
1583
|
}
|
|
1604
1584
|
for (const result of dbResults) {
|
|
1605
|
-
const { modelName, modelId, action, transactionId } = result;
|
|
1585
|
+
const { modelName, modelId, action, transactionId, syncId } = result;
|
|
1586
|
+
// Every delta names the log position the row now reflects — recorded
|
|
1587
|
+
// before echo detection, because an own echo is exactly a position the
|
|
1588
|
+
// pooled row has reached even though its fields are not re-applied.
|
|
1589
|
+
const resident = this.objectPool.peek(modelId);
|
|
1590
|
+
if (resident)
|
|
1591
|
+
this.objectPool.watermarks.advance(resident, syncId);
|
|
1606
1592
|
// Echo detection: if this delta carries a transaction id that matches
|
|
1607
1593
|
// one already applied optimistically, the pool already reflects the
|
|
1608
1594
|
// mutation, so the pool operation is skipped. The IndexedDB write in
|
|
@@ -1638,8 +1624,10 @@ export class SyncClient extends EventEmitter {
|
|
|
1638
1624
|
const model = this.objectPool.createFromData(data, undefined, {
|
|
1639
1625
|
deferObservability: true,
|
|
1640
1626
|
});
|
|
1641
|
-
if (model)
|
|
1627
|
+
if (model) {
|
|
1628
|
+
this.objectPool.watermarks.advance(model, syncId);
|
|
1642
1629
|
modelsToAdd.push(model);
|
|
1630
|
+
}
|
|
1643
1631
|
}
|
|
1644
1632
|
break;
|
|
1645
1633
|
}
|
|
@@ -1712,6 +1700,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1712
1700
|
if (!bootstrapData.models) {
|
|
1713
1701
|
return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0 };
|
|
1714
1702
|
}
|
|
1703
|
+
const snapshotPosition = bootstrapData.lastSyncId;
|
|
1715
1704
|
const allModels = [];
|
|
1716
1705
|
const serverIdsByType = new Map();
|
|
1717
1706
|
let healedCount = 0;
|
|
@@ -1747,19 +1736,21 @@ export class SyncClient extends EventEmitter {
|
|
|
1747
1736
|
// taken at a server watermark. If a concurrent live delta already
|
|
1748
1737
|
// advanced this row past the snapshot, skip it. `createFromData`
|
|
1749
1738
|
// mutates the pooled model in place to keep instances alive, so this
|
|
1750
|
-
//
|
|
1751
|
-
//
|
|
1739
|
+
// guard has to run before it; a guard at the upsert layer would be too
|
|
1740
|
+
// late, because the row would already be clobbered.
|
|
1752
1741
|
if (options?.scoped && recordId) {
|
|
1753
|
-
const existing = this.objectPool.
|
|
1754
|
-
if (existing &&
|
|
1742
|
+
const existing = this.objectPool.peek(recordId);
|
|
1743
|
+
if (existing && this.objectPool.watermarks.isAheadOf(existing, snapshotPosition)) {
|
|
1755
1744
|
skippedCount++;
|
|
1756
1745
|
continue;
|
|
1757
1746
|
}
|
|
1758
1747
|
}
|
|
1759
1748
|
try {
|
|
1760
1749
|
const model = this.objectPool.createFromData(data);
|
|
1761
|
-
if (model)
|
|
1750
|
+
if (model) {
|
|
1751
|
+
this.objectPool.watermarks.advance(model, snapshotPosition);
|
|
1762
1752
|
allModels.push(model);
|
|
1753
|
+
}
|
|
1763
1754
|
}
|
|
1764
1755
|
catch {
|
|
1765
1756
|
skippedCount++;
|
|
@@ -62,6 +62,10 @@ export function createInternalComponents(input) {
|
|
|
62
62
|
baseUrl: bootstrapBaseUrl,
|
|
63
63
|
getAuthToken: auth?.getAuthToken,
|
|
64
64
|
runtime,
|
|
65
|
+
// The one canonical log position; the loader reads its floor when a query
|
|
66
|
+
// leaves so a late answer cannot overwrite a row the pool already knows to
|
|
67
|
+
// be further along.
|
|
68
|
+
position: syncClient.position,
|
|
65
69
|
});
|
|
66
70
|
// Drop the lazy-lane hydration ledger on reconnect. While connected, the
|
|
67
71
|
// WebSocket delta stream keeps hydrated rows fresh so repeat reads serve
|
|
@@ -19,6 +19,7 @@ import { toMs } from '@abloatai/transaction/utils/duration';
|
|
|
19
19
|
import { LEASE_TTL_MS } from '@abloatai/transaction/wire/protocol';
|
|
20
20
|
import { heartbeatCadenceMs, resolveHeartbeatOptions, resolveHeartbeatPlan, startClaimHeartbeatLoop, } from '@abloatai/transaction/coordination/claimHeartbeatLoop';
|
|
21
21
|
import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
|
|
22
|
+
import { modelList } from '@abloatai/transaction/resources/httpResources';
|
|
22
23
|
import { subTarget } from '@abloatai/transaction/coordination';
|
|
23
24
|
// A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
|
|
24
25
|
// .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
|
|
@@ -75,7 +76,7 @@ hydration, collaboration, readSetContext) {
|
|
|
75
76
|
'but no matching constructor was registered.', { code: 'model_not_registered' });
|
|
76
77
|
}
|
|
77
78
|
// The coordination plane must speak the same wire dialect as the commit
|
|
78
|
-
// plane: the lowercased typename (`
|
|
79
|
+
// plane: the lowercased typename (`item`), not the schema key (`items`). The
|
|
79
80
|
// server's commit-time claim guard probes the lease store with the commit
|
|
80
81
|
// operation's model name, so a lease recorded under the schema key never
|
|
81
82
|
// matches — which would silently disarm the guard for every model whose
|
|
@@ -115,6 +116,15 @@ hydration, collaboration, readSetContext) {
|
|
|
115
116
|
};
|
|
116
117
|
};
|
|
117
118
|
const load = async (options) => {
|
|
119
|
+
if (options?.cursor !== undefined) {
|
|
120
|
+
// The live client hydrates a working set into the local graph rather than
|
|
121
|
+
// handing back pages, so there is no cursor for this read to resume from.
|
|
122
|
+
// Accepting the option and ignoring it would return page one every time
|
|
123
|
+
// while the caller believed it was advancing.
|
|
124
|
+
throw new AbloValidationError('`cursor` resumes a page of the stateless read. This client keeps a ' +
|
|
125
|
+
'local graph and loads a working set instead of pages: narrow the ' +
|
|
126
|
+
'`where`, or construct the client with `transport: \'http\'` to page.', { code: 'invalid_options', param: 'cursor' });
|
|
127
|
+
}
|
|
118
128
|
const rows = await hydration.fetch(schemaKey, options);
|
|
119
129
|
return rows.map((row) => modelAsRow(row));
|
|
120
130
|
};
|
|
@@ -649,8 +659,16 @@ hydration, collaboration, readSetContext) {
|
|
|
649
659
|
const list = guard(async (options) => {
|
|
650
660
|
const registry = readSetContext?.getStore();
|
|
651
661
|
const rows = await load(options);
|
|
662
|
+
// This transport loads a working set rather than pages, so there is no
|
|
663
|
+
// cursor to hand back. `limit` can still cut the set short, and a full
|
|
664
|
+
// count is exactly the case where the caller cannot tell: report it rather
|
|
665
|
+
// than claim completeness this read cannot vouch for.
|
|
666
|
+
const page = modelList(rows, {
|
|
667
|
+
hasMore: options?.limit !== undefined && rows.length >= options.limit,
|
|
668
|
+
nextCursor: null,
|
|
669
|
+
});
|
|
652
670
|
if (!registry)
|
|
653
|
-
return
|
|
671
|
+
return page;
|
|
654
672
|
for (const row of rows) {
|
|
655
673
|
const stamp = hydration.getReadEvidence?.(row);
|
|
656
674
|
if (stamp === undefined) {
|
|
@@ -665,7 +683,7 @@ hydration, collaboration, readSetContext) {
|
|
|
665
683
|
}
|
|
666
684
|
capturePointRead(readSetContext, readSetClientIdentity, wireModel, id, row, stamp);
|
|
667
685
|
}
|
|
668
|
-
return
|
|
686
|
+
return page;
|
|
669
687
|
});
|
|
670
688
|
const operations = {
|
|
671
689
|
local,
|
|
@@ -253,13 +253,13 @@ export interface RuntimeConfig {
|
|
|
253
253
|
* Fields to preserve when merging a partial update into the local store. A
|
|
254
254
|
* change usually carries only the fields that changed; listing a model's
|
|
255
255
|
* essential fields here keeps them from being dropped during that merge.
|
|
256
|
-
* For example: `{
|
|
256
|
+
* For example: `{ Item: ['title', 'projectId'], Section: ['reportId', 'order'] }`.
|
|
257
257
|
*/
|
|
258
258
|
essentialFields: Readonly<Record<string, readonly string[]>>;
|
|
259
259
|
/**
|
|
260
260
|
* A fallback map from class name to model name, used to resolve a model's name
|
|
261
261
|
* when the usual lookup fails — for instance, when a bundler has minified the
|
|
262
|
-
* class names. For example: `{
|
|
262
|
+
* class names. For example: `{ ItemModel: 'Item', ProjectModel: 'Project' }`.
|
|
263
263
|
*/
|
|
264
264
|
classNameFallbackMap: Readonly<Record<string, string>>;
|
|
265
265
|
/**
|
|
@@ -283,7 +283,7 @@ export interface RuntimeConfig {
|
|
|
283
283
|
expectedSourceSchemaHash?: string;
|
|
284
284
|
/**
|
|
285
285
|
* Per-model content hashes of the schema this client was built against,
|
|
286
|
-
* keyed by schema key (`
|
|
286
|
+
* keyed by schema key (`items` → hash of that model's serialized JSON). The
|
|
287
287
|
* semantic layer of the drift check: on a whole-schema mismatch the client
|
|
288
288
|
* compares only the models IT declares against the server's per-model
|
|
289
289
|
* surface, so a purely additive server-side change (new models this build
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The log position each pooled row is known to reflect — the client-side
|
|
3
|
+
* companion of the server's per-row watermark (`ModelListEvidence.stamp`).
|
|
4
|
+
*
|
|
5
|
+
* A row's copy in the pool moves through four doors, and every one of them
|
|
6
|
+
* names the log position it delivers: the ordered delta stream (the delta's
|
|
7
|
+
* id), the acknowledgement of this client's own commit (`lastSyncId`), a
|
|
8
|
+
* bootstrap snapshot (its `lastSyncId`), and a server read (the row's evidence
|
|
9
|
+
* stamp). Recording that position per row is what lets a later snapshot be
|
|
10
|
+
* judged. A snapshot taken at position P cannot carry anything the log did not
|
|
11
|
+
* hold at P, so when the pooled copy already reflects a position beyond P the
|
|
12
|
+
* snapshot is stale for that row and is left unapplied. Deltas repair every
|
|
13
|
+
* peer change a skipped snapshot would have carried; nothing repairs a
|
|
14
|
+
* snapshot that regresses this client's own confirmed write, because own
|
|
15
|
+
* echoes are suppressed on apply — which is why the rule errs toward keeping
|
|
16
|
+
* the resident copy.
|
|
17
|
+
*
|
|
18
|
+
* The row's `updatedAt` is not consulted. It is an application field the
|
|
19
|
+
* server never stamps and the client fabricates when a row arrives without one,
|
|
20
|
+
* so it orders nothing; the log does.
|
|
21
|
+
*
|
|
22
|
+
* Positions are `sync_deltas` ids, the same space as {@link LogPosition}. Zero
|
|
23
|
+
* and `undefined` mean "no evidence" and never advance a row.
|
|
24
|
+
*/
|
|
25
|
+
export declare class RowWatermarks {
|
|
26
|
+
#private;
|
|
27
|
+
/** Record that `row`'s pooled copy reflects the log at least through `position`. */
|
|
28
|
+
advance(row: object, position: number | undefined): void;
|
|
29
|
+
/** The highest log position `row` is known to reflect, if the client has any evidence. */
|
|
30
|
+
of(row: object): number | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the pooled copy of `row` is known to be ahead of a snapshot that
|
|
33
|
+
* reflects the log through `snapshotPosition`. `snapshotPosition` is a lower
|
|
34
|
+
* bound: the position the snapshot provably includes (a row's evidence stamp,
|
|
35
|
+
* a bootstrap's `lastSyncId`, or the client's own read floor at the moment
|
|
36
|
+
* the read was issued — the server had at least that much when it answered).
|
|
37
|
+
* A snapshot with no known position is never judged stale.
|
|
38
|
+
*/
|
|
39
|
+
isAheadOf(row: object, snapshotPosition: number | undefined): boolean;
|
|
40
|
+
}
|