@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/src/client.ts
CHANGED
|
@@ -131,10 +131,10 @@ export type AbloClient<S extends SchemaRecord> = {
|
|
|
131
131
|
* server verifies it. The browser must never see the `sk_` key, only the
|
|
132
132
|
* per-user session token.
|
|
133
133
|
*
|
|
134
|
-
* Pass `{ user: { id }, can: {
|
|
134
|
+
* Pass `{ user: { id }, can: { items: ['read', 'update'] } }` for an end-user
|
|
135
135
|
* session. It mints an `ek_` and attributes writes to a user (recorded as
|
|
136
136
|
* `actor_kind` on the delta row). Pass `{ agent: { id }, can: {
|
|
137
|
-
*
|
|
137
|
+
* items: ['update'] } }` for a scoped agent session, which mints an `rk_`.
|
|
138
138
|
* Both kinds require `can`, typed against your schema's model names. This
|
|
139
139
|
* always authenticates with the original `sk_`, never the client's exchanged
|
|
140
140
|
* sync credential.
|
|
@@ -150,10 +150,10 @@ export type AbloClient<S extends SchemaRecord> = {
|
|
|
150
150
|
* ```ts
|
|
151
151
|
* const agent = await ablo.agents.create({
|
|
152
152
|
* name: 'researcher', // readable label (optional)
|
|
153
|
-
* can: {
|
|
153
|
+
* can: { records: ['read', 'update'] },
|
|
154
154
|
* // id omitted → a fresh uuid: a distinct, independent participant
|
|
155
155
|
* });
|
|
156
|
-
* await agent.
|
|
156
|
+
* await agent.records.update({ id, data, claim });
|
|
157
157
|
* await agent.dispose(); // when the agent is done
|
|
158
158
|
* ```
|
|
159
159
|
*
|
|
@@ -84,7 +84,7 @@ import type { CommitLatencySample } from './transactions/mutations/commitLatency
|
|
|
84
84
|
export type ModelConstructor<T extends Model> = abstract new (...args: never[]) => T;
|
|
85
85
|
|
|
86
86
|
/** Concrete constructor type for instantiation */
|
|
87
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Constructor args vary per model (
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Constructor args vary per model (PrismaItem, Record<string, unknown>, etc.)
|
|
88
88
|
export type ConcreteModelConstructor<T extends Model> = new (data?: any) => T;
|
|
89
89
|
|
|
90
90
|
// ModelData is defined in a separate module to break the type cycle between
|
package/src/local/Database.ts
CHANGED
|
@@ -23,123 +23,32 @@ import type { BootstrapFetcher, BootstrapData } from './sync/BootstrapFetcher.js
|
|
|
23
23
|
import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
|
|
24
24
|
import { logPositionSchema } from './logPosition.js';
|
|
25
25
|
import type { SyncDeltaAction } from '@abloatai/transaction/wire/delta';
|
|
26
|
-
import type { OnStaleMode } from '@abloatai/transaction/coordination/schema';
|
|
27
26
|
import type { BootstrapType } from '@abloatai/transaction/types';
|
|
28
27
|
import { highestPersistedPrefixSyncId } from './sync/persistedPrefix.js';
|
|
28
|
+
import {
|
|
29
|
+
isAcceptedOutboxPromotion,
|
|
30
|
+
isSameOutboxRecord,
|
|
31
|
+
type PersistedTransaction,
|
|
32
|
+
} from './transactions/persistedTransaction.js';
|
|
29
33
|
|
|
30
34
|
/** Generic record type for model data */
|
|
31
35
|
type ModelData = Record<string, unknown>;
|
|
32
36
|
|
|
33
|
-
/** Persisted mutation in a transaction */
|
|
34
|
-
interface PersistedMutation {
|
|
35
|
-
type: 'create' | 'update' | 'delete' | 'archive';
|
|
36
|
-
modelData: ModelData;
|
|
37
|
-
modelName: string;
|
|
38
|
-
timestamp: string;
|
|
39
|
-
writeOptions?: {
|
|
40
|
-
readAt?: number | null;
|
|
41
|
-
onStale?: OnStaleMode | null;
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Persisted transaction for offline/retry support.
|
|
46
|
-
*
|
|
47
|
-
* Index signature is part of the contract: this interface targets
|
|
48
|
-
* the generic record-shaped storage layer (`InMemoryObjectStore.put`
|
|
49
|
-
* + the IDB ObjectStore equivalent), both of which take
|
|
50
|
-
* `Record<string, unknown>`. Every declared field below already
|
|
51
|
-
* satisfies `unknown`; the index signature just makes the
|
|
52
|
-
* interface assignable to the storage parameter without a cast. */
|
|
53
|
-
interface PersistedTransaction {
|
|
54
|
-
id: string;
|
|
55
|
-
type?: string;
|
|
56
|
-
timestamp?: number;
|
|
57
|
-
createdAt?: number;
|
|
58
|
-
mutations?: PersistedMutation[];
|
|
59
|
-
// Persist awaiting-delta transactions so they survive a tab close. On the
|
|
60
|
-
// next session, WebSocket reconnect plus delta catch-up confirms them.
|
|
61
|
-
awaitingDelta?: {
|
|
62
|
-
syncIdNeeded: number;
|
|
63
|
-
modelName: string;
|
|
64
|
-
modelId: string;
|
|
65
|
-
operationType: string;
|
|
66
|
-
};
|
|
67
|
-
[key: string]: unknown;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
37
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* an idempotency conflict. Only the fields that define the wire request count.
|
|
38
|
+
* Carry each input delta's log position onto the change that answers it.
|
|
39
|
+
* `processDeltaBatch` builds its results index-aligned with its input, so the
|
|
40
|
+
* position is stamped once here rather than at every construction site.
|
|
75
41
|
*/
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
):
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const identity = (record: PersistedTransaction): unknown => ({
|
|
85
|
-
id: record.id,
|
|
86
|
-
type: record.type,
|
|
87
|
-
storageVersion: record.storageVersion,
|
|
88
|
-
idempotencyKey: record.idempotencyKey,
|
|
89
|
-
// HTTP outbox rows written before protocol versioning are v1. Normalize
|
|
90
|
-
// them so a same-request re-seal remains idempotent after an upgrade.
|
|
91
|
-
protocolVersion: record.protocolVersion ?? 1,
|
|
92
|
-
request: record.request,
|
|
93
|
-
scopeNamespace: record.scopeNamespace,
|
|
94
|
-
});
|
|
95
|
-
if (
|
|
96
|
-
existing.correlationId !== undefined &&
|
|
97
|
-
candidate.correlationId !== undefined &&
|
|
98
|
-
existing.correlationId !== candidate.correlationId
|
|
99
|
-
) {
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
103
|
-
}
|
|
104
|
-
if (
|
|
105
|
-
existing.type === 'commit_envelope' &&
|
|
106
|
-
candidate.type === 'commit_envelope'
|
|
107
|
-
) {
|
|
108
|
-
const identity = (record: PersistedTransaction): unknown => ({
|
|
109
|
-
id: record.id,
|
|
110
|
-
type: record.type,
|
|
111
|
-
storageVersion: record.storageVersion,
|
|
112
|
-
origin: record.origin,
|
|
113
|
-
idempotencyKey: record.idempotencyKey,
|
|
114
|
-
operations: record.operations,
|
|
115
|
-
sourceMutationIds: record.sourceMutationIds,
|
|
116
|
-
commitOptions: record.commitOptions,
|
|
117
|
-
scope: record.scope,
|
|
118
|
-
});
|
|
119
|
-
if (
|
|
120
|
-
existing.correlationId !== undefined &&
|
|
121
|
-
candidate.correlationId !== undefined &&
|
|
122
|
-
existing.correlationId !== candidate.correlationId
|
|
123
|
-
) {
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
|
-
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
42
|
+
function stampSyncIds(
|
|
43
|
+
results: AppliedChange[],
|
|
44
|
+
deltas: readonly { syncId?: number }[],
|
|
45
|
+
): AppliedChange[] {
|
|
46
|
+
for (let index = 0; index < results.length; index++) {
|
|
47
|
+
const change = results[index];
|
|
48
|
+
const syncId = deltas[index]?.syncId;
|
|
49
|
+
if (change && typeof syncId === 'number') change.syncId = syncId;
|
|
127
50
|
}
|
|
128
|
-
return
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function isAcceptedOutboxPromotion(
|
|
132
|
-
existing: PersistedTransaction | undefined,
|
|
133
|
-
candidate: PersistedTransaction,
|
|
134
|
-
): boolean {
|
|
135
|
-
return (
|
|
136
|
-
existing !== undefined &&
|
|
137
|
-
(existing.type === 'commit_envelope' ||
|
|
138
|
-
existing.type === 'http_commit_envelope') &&
|
|
139
|
-
existing.type === candidate.type &&
|
|
140
|
-
existing.acceptedAt === undefined &&
|
|
141
|
-
candidate.acceptedAt !== undefined
|
|
142
|
-
);
|
|
51
|
+
return results;
|
|
143
52
|
}
|
|
144
53
|
|
|
145
54
|
// Re-exported, not redeclared. `@abloatai/transaction`'s `types` module owns this
|
|
@@ -1227,7 +1136,10 @@ export class Database {
|
|
|
1227
1136
|
updatedAt: new Date(),
|
|
1228
1137
|
};
|
|
1229
1138
|
}
|
|
1230
|
-
return {
|
|
1139
|
+
return {
|
|
1140
|
+
results: stampSyncIds(inMemResults, deltas),
|
|
1141
|
+
persistedSyncId: inMemPersistedSyncId,
|
|
1142
|
+
};
|
|
1231
1143
|
}
|
|
1232
1144
|
|
|
1233
1145
|
// Prepare results aligned with input order
|
|
@@ -1648,7 +1560,7 @@ export class Database {
|
|
|
1648
1560
|
});
|
|
1649
1561
|
}
|
|
1650
1562
|
|
|
1651
|
-
return { results, persistedSyncId: highestPersistedSyncId };
|
|
1563
|
+
return { results: stampSyncIds(results, deltas), persistedSyncId: highestPersistedSyncId };
|
|
1652
1564
|
}
|
|
1653
1565
|
|
|
1654
1566
|
/** Get raw data for hydration */
|
|
@@ -16,6 +16,7 @@ import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
|
16
16
|
import { ModelScope, PropertyType } from '@abloatai/transaction/types';
|
|
17
17
|
import { ViewRegistry } from './views/ViewRegistry.js';
|
|
18
18
|
import { QueryView, type QueryViewOptions } from './views/QueryView.js';
|
|
19
|
+
import { RowWatermarks } from './rowWatermarks.js';
|
|
19
20
|
|
|
20
21
|
/** Constructor type for Model subclasses - uses abstract to handle variance */
|
|
21
22
|
type ModelConstructor<T extends Model> = abstract new (...args: never[]) => T;
|
|
@@ -124,6 +125,15 @@ export class InstanceCache {
|
|
|
124
125
|
// ViewRegistry — tracks active QueryViews for incremental view maintenance
|
|
125
126
|
readonly viewRegistry: ViewRegistry = new ViewRegistry();
|
|
126
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The log position each pooled row is known to reflect. Every door a row
|
|
130
|
+
* enters through (delta, own ack, bootstrap, server read) advances it, and
|
|
131
|
+
* every snapshot that would overwrite a resident row is judged against it —
|
|
132
|
+
* see {@link RowWatermarks}. Keyed by instance so it lives and dies with the
|
|
133
|
+
* pooled model.
|
|
134
|
+
*/
|
|
135
|
+
readonly watermarks = new RowWatermarks();
|
|
136
|
+
|
|
127
137
|
// Subscription registry
|
|
128
138
|
private subscriptions = new Map<string, Set<(model: Model) => void>>();
|
|
129
139
|
|
package/src/local/Model.ts
CHANGED
|
@@ -1108,26 +1108,6 @@ export abstract class Model {
|
|
|
1108
1108
|
// Try to get model class by identifier
|
|
1109
1109
|
let ModelClass = getActiveRegistry().getModelByName(modelIdentifier);
|
|
1110
1110
|
|
|
1111
|
-
// If not found by registered name, try mapping to the class name
|
|
1112
|
-
if (!ModelClass) {
|
|
1113
|
-
const classNameMap: Record<string, string> = {
|
|
1114
|
-
Task: 'TaskModel',
|
|
1115
|
-
Project: 'Project',
|
|
1116
|
-
Comment: 'CommentModel',
|
|
1117
|
-
User: 'UserModel',
|
|
1118
|
-
Organization: 'OrganizationModel',
|
|
1119
|
-
StatusGroup: 'StatusGroupModel',
|
|
1120
|
-
Team: 'TeamModel',
|
|
1121
|
-
Member: 'MemberModel',
|
|
1122
|
-
Role: 'RoleModel',
|
|
1123
|
-
};
|
|
1124
|
-
|
|
1125
|
-
const className = classNameMap[modelIdentifier];
|
|
1126
|
-
if (className) {
|
|
1127
|
-
ModelClass = getActiveRegistry().getModelByName(className);
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
1111
|
if (!ModelClass) {
|
|
1132
1112
|
throw new AbloValidationError(
|
|
1133
1113
|
`Model class not found for: ${modelIdentifier}`,
|
package/src/local/SyncClient.ts
CHANGED
|
@@ -34,7 +34,9 @@ import {
|
|
|
34
34
|
type UnconfirmedWritesMetrics,
|
|
35
35
|
} from './transactions/mutations/UnconfirmedWrites.js';
|
|
36
36
|
import type { DurableWriteStore } from './transactions/mutations/durableWriteStore.js';
|
|
37
|
+
import type { CommitTransaction } from './transactions/mutations/commitLane.js';
|
|
37
38
|
import type { Database } from './Database.js';
|
|
39
|
+
import type { BootstrapData } from './sync/BootstrapFetcher.js';
|
|
38
40
|
import type { MutationPersistencePort } from './mutationPersistence.js';
|
|
39
41
|
import type { WriteOptions } from './interfaces/index.js';
|
|
40
42
|
import { LogPosition } from './logPosition.js';
|
|
@@ -73,30 +75,29 @@ export interface RehydrationStats {
|
|
|
73
75
|
type EventHandler = () => void;
|
|
74
76
|
|
|
75
77
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* undefined existing timestamp means the pooled row is unversioned, so the
|
|
82
|
-
* incoming record wins. The scoped hydrate-on-enter path uses this to drop
|
|
83
|
-
* snapshot rows that a live delta has already advanced past.
|
|
78
|
+
* The slice of a bootstrap answer the pool applies: its rows, the models whose
|
|
79
|
+
* server query failed, and the log position the snapshot was taken at — the
|
|
80
|
+
* position every row in it reflects. `lastSyncId` is optional only for callers
|
|
81
|
+
* applying rows with no snapshot position to speak of; the fetcher always
|
|
82
|
+
* names one.
|
|
84
83
|
*/
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
84
|
+
export type BootstrapSnapshot = Pick<BootstrapData, 'models' | 'failedModels'> &
|
|
85
|
+
Partial<Pick<BootstrapData, 'lastSyncId'>>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What `transaction:completed` carries: a model mutation (one row, confirmed
|
|
89
|
+
* at `syncIdNeededForCompletion`) or an explicit commit (one row per operation,
|
|
90
|
+
* confirmed at `lastSyncId`). Each arm projects its own queue record.
|
|
91
|
+
*/
|
|
92
|
+
type CompletedTransaction =
|
|
93
|
+
| (Pick<QueuedMutation, 'id' | 'modelId' | 'syncIdNeededForCompletion'> & {
|
|
94
|
+
lastSyncId?: undefined;
|
|
95
|
+
operations?: undefined;
|
|
96
|
+
})
|
|
97
|
+
| (Pick<CommitTransaction, 'id' | 'lastSyncId' | 'operations'> & {
|
|
98
|
+
modelId?: undefined;
|
|
99
|
+
syncIdNeededForCompletion?: undefined;
|
|
100
|
+
});
|
|
100
101
|
|
|
101
102
|
/**
|
|
102
103
|
* Converts an untyped server `updatedAt` value — an ISO string, epoch number,
|
|
@@ -464,12 +465,18 @@ export class SyncClient extends EventEmitter {
|
|
|
464
465
|
}
|
|
465
466
|
);
|
|
466
467
|
|
|
467
|
-
// Clean up persisted awaiting transactions when they're finally confirmed
|
|
468
|
+
// Clean up persisted awaiting transactions when they're finally confirmed,
|
|
469
|
+
// and record the confirmed position on every row the transaction wrote.
|
|
470
|
+
// The acknowledgement is the earliest proof of where this client's own
|
|
471
|
+
// write landed in the log — earlier than its delta echo, which the pool
|
|
472
|
+
// suppresses on apply — so a snapshot read before the write cannot regress
|
|
473
|
+
// the row in the window between the two.
|
|
468
474
|
this.mutationQueue.on(
|
|
469
475
|
'transaction:completed',
|
|
470
|
-
(tx:
|
|
476
|
+
(tx: CompletedTransaction) => {
|
|
471
477
|
// void is safe: the handler's body is fully try/catch'd.
|
|
472
478
|
void this.removeAwaitingTransaction(tx.id);
|
|
479
|
+
this.noteOwnWritePositions(tx);
|
|
473
480
|
}
|
|
474
481
|
);
|
|
475
482
|
|
|
@@ -495,6 +502,23 @@ export class SyncClient extends EventEmitter {
|
|
|
495
502
|
);
|
|
496
503
|
}
|
|
497
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Advance the pooled rows a completed transaction wrote to the log position
|
|
507
|
+
* its acknowledgement named. A model mutation names one row; an explicit
|
|
508
|
+
* commit names one per operation. Rows no longer pooled have nothing to
|
|
509
|
+
* advance — a fresh instance starts without evidence.
|
|
510
|
+
*/
|
|
511
|
+
private noteOwnWritePositions(tx: CompletedTransaction): void {
|
|
512
|
+
const position = tx.lastSyncId ?? tx.syncIdNeededForCompletion;
|
|
513
|
+
if (position === undefined) return;
|
|
514
|
+
const rowIds =
|
|
515
|
+
tx.operations !== undefined ? tx.operations.map((op) => op.id) : [tx.modelId];
|
|
516
|
+
for (const rowId of rowIds) {
|
|
517
|
+
const row = this.objectPool.peek(rowId);
|
|
518
|
+
if (row) this.objectPool.watermarks.advance(row, position);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
498
522
|
/** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
|
|
499
523
|
private async persistAwaitingTransaction(event: {
|
|
500
524
|
txId: string;
|
|
@@ -1302,8 +1326,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1302
1326
|
const shouldForceAcceptServer =
|
|
1303
1327
|
(serverData.deletedAt !== undefined && serverData.deletedAt !== null) ||
|
|
1304
1328
|
(serverData.archivedAt !== undefined && serverData.archivedAt !== null) ||
|
|
1305
|
-
serverData.isActive === false
|
|
1306
|
-
(serverData.unassignedAt !== undefined && serverData.unassignedAt !== null);
|
|
1329
|
+
serverData.isActive === false;
|
|
1307
1330
|
|
|
1308
1331
|
if (shouldForceAcceptServer) {
|
|
1309
1332
|
this.runtime.logger.debug('Accepting server update - critical state change detected', {
|
|
@@ -1378,14 +1401,10 @@ export class SyncClient extends EventEmitter {
|
|
|
1378
1401
|
critical.archivedAt = serverData.archivedAt;
|
|
1379
1402
|
}
|
|
1380
1403
|
|
|
1381
|
-
// Deactivation states
|
|
1404
|
+
// Deactivation states are always critical.
|
|
1382
1405
|
if (serverData.isActive !== undefined && serverData.isActive === false) {
|
|
1383
1406
|
critical.isActive = false;
|
|
1384
1407
|
}
|
|
1385
|
-
if (serverData.unassignedAt !== undefined) {
|
|
1386
|
-
critical.unassignedAt = serverData.unassignedAt;
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
1408
|
return critical;
|
|
1390
1409
|
}
|
|
1391
1410
|
|
|
@@ -1824,27 +1843,6 @@ export class SyncClient extends EventEmitter {
|
|
|
1824
1843
|
};
|
|
1825
1844
|
}
|
|
1826
1845
|
|
|
1827
|
-
// --- Best-practice assignment ops ---
|
|
1828
|
-
async unassignEntity(entityType: string, entityId: string): Promise<void> {
|
|
1829
|
-
// Call server-side unassign to avoid per-id races
|
|
1830
|
-
await this.mutationExecutor.executeDelete('Assignment', entityId);
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
async reassignEntity(
|
|
1834
|
-
entityType: string,
|
|
1835
|
-
entityId: string,
|
|
1836
|
-
assigneeType: string,
|
|
1837
|
-
assigneeId: string,
|
|
1838
|
-
id?: string
|
|
1839
|
-
): Promise<void> {
|
|
1840
|
-
await this.mutationExecutor.executeCreate('Assignment', id || '', {
|
|
1841
|
-
entityType,
|
|
1842
|
-
entityId,
|
|
1843
|
-
assigneeType,
|
|
1844
|
-
assigneeId,
|
|
1845
|
-
});
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
1846
|
// ── Delta + Bootstrap application (owns InstanceCache writes) ──────────────
|
|
1849
1847
|
|
|
1850
1848
|
/**
|
|
@@ -1978,7 +1976,13 @@ export class SyncClient extends EventEmitter {
|
|
|
1978
1976
|
}
|
|
1979
1977
|
|
|
1980
1978
|
for (const result of dbResults) {
|
|
1981
|
-
const { modelName, modelId, action, transactionId } = result;
|
|
1979
|
+
const { modelName, modelId, action, transactionId, syncId } = result;
|
|
1980
|
+
|
|
1981
|
+
// Every delta names the log position the row now reflects — recorded
|
|
1982
|
+
// before echo detection, because an own echo is exactly a position the
|
|
1983
|
+
// pooled row has reached even though its fields are not re-applied.
|
|
1984
|
+
const resident = this.objectPool.peek(modelId);
|
|
1985
|
+
if (resident) this.objectPool.watermarks.advance(resident, syncId);
|
|
1982
1986
|
|
|
1983
1987
|
// Echo detection: if this delta carries a transaction id that matches
|
|
1984
1988
|
// one already applied optimistically, the pool already reflects the
|
|
@@ -2016,7 +2020,10 @@ export class SyncClient extends EventEmitter {
|
|
|
2016
2020
|
const model = this.objectPool.createFromData(data, undefined, {
|
|
2017
2021
|
deferObservability: true,
|
|
2018
2022
|
});
|
|
2019
|
-
if (model)
|
|
2023
|
+
if (model) {
|
|
2024
|
+
this.objectPool.watermarks.advance(model, syncId);
|
|
2025
|
+
modelsToAdd.push(model);
|
|
2026
|
+
}
|
|
2020
2027
|
}
|
|
2021
2028
|
break;
|
|
2022
2029
|
}
|
|
@@ -2084,17 +2091,18 @@ export class SyncClient extends EventEmitter {
|
|
|
2084
2091
|
* Owns: model creation, batch upsert, ghost detection + removal.
|
|
2085
2092
|
*/
|
|
2086
2093
|
applyBootstrapDataToPool(
|
|
2087
|
-
bootstrapData:
|
|
2094
|
+
bootstrapData: BootstrapSnapshot,
|
|
2088
2095
|
protectedIds?: ReadonlySet<string>,
|
|
2089
2096
|
options?: {
|
|
2090
2097
|
/**
|
|
2091
2098
|
* Scoped backfill for the hydrate-on-enter path: the snapshot covers only
|
|
2092
2099
|
* the groups just entered, not the whole model type. Two behaviors change
|
|
2093
|
-
* so the subset cannot corrupt the pool. First, the
|
|
2094
|
-
*
|
|
2095
|
-
* delta is not clobbered back to the
|
|
2096
|
-
* removal is skipped, because a subset
|
|
2097
|
-
* the same type that belong to other,
|
|
2100
|
+
* so the subset cannot corrupt the pool. First, a row the pool already
|
|
2101
|
+
* knows to reflect a position beyond the snapshot's `lastSyncId` is
|
|
2102
|
+
* skipped, so a concurrent live delta is not clobbered back to the
|
|
2103
|
+
* snapshot version. Second, ghost removal is skipped, because a subset
|
|
2104
|
+
* snapshot must never evict rows of the same type that belong to other,
|
|
2105
|
+
* unhydrated groups.
|
|
2098
2106
|
*/
|
|
2099
2107
|
scoped?: boolean;
|
|
2100
2108
|
},
|
|
@@ -2102,6 +2110,7 @@ export class SyncClient extends EventEmitter {
|
|
|
2102
2110
|
if (!bootstrapData.models) {
|
|
2103
2111
|
return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0 };
|
|
2104
2112
|
}
|
|
2113
|
+
const snapshotPosition = bootstrapData.lastSyncId;
|
|
2105
2114
|
|
|
2106
2115
|
const allModels: Model[] = [];
|
|
2107
2116
|
const serverIdsByType = new Map<string, Set<string>>();
|
|
@@ -2136,16 +2145,22 @@ export class SyncClient extends EventEmitter {
|
|
|
2136
2145
|
// taken at a server watermark. If a concurrent live delta already
|
|
2137
2146
|
// advanced this row past the snapshot, skip it. `createFromData`
|
|
2138
2147
|
// mutates the pooled model in place to keep instances alive, so this
|
|
2139
|
-
//
|
|
2140
|
-
//
|
|
2148
|
+
// guard has to run before it; a guard at the upsert layer would be too
|
|
2149
|
+
// late, because the row would already be clobbered.
|
|
2141
2150
|
if (options?.scoped && recordId) {
|
|
2142
|
-
const existing = this.objectPool.
|
|
2143
|
-
if (existing &&
|
|
2151
|
+
const existing = this.objectPool.peek(recordId);
|
|
2152
|
+
if (existing && this.objectPool.watermarks.isAheadOf(existing, snapshotPosition)) {
|
|
2153
|
+
skippedCount++;
|
|
2154
|
+
continue;
|
|
2155
|
+
}
|
|
2144
2156
|
}
|
|
2145
2157
|
|
|
2146
2158
|
try {
|
|
2147
2159
|
const model = this.objectPool.createFromData(data);
|
|
2148
|
-
if (model)
|
|
2160
|
+
if (model) {
|
|
2161
|
+
this.objectPool.watermarks.advance(model, snapshotPosition);
|
|
2162
|
+
allModels.push(model);
|
|
2163
|
+
}
|
|
2149
2164
|
} catch {
|
|
2150
2165
|
skippedCount++;
|
|
2151
2166
|
}
|
|
@@ -121,6 +121,10 @@ export function createInternalComponents<S extends SchemaRecord>(
|
|
|
121
121
|
baseUrl: bootstrapBaseUrl,
|
|
122
122
|
getAuthToken: auth?.getAuthToken,
|
|
123
123
|
runtime,
|
|
124
|
+
// The one canonical log position; the loader reads its floor when a query
|
|
125
|
+
// leaves so a late answer cannot overwrite a row the pool already knows to
|
|
126
|
+
// be further along.
|
|
127
|
+
position: syncClient.position,
|
|
124
128
|
});
|
|
125
129
|
|
|
126
130
|
// Drop the lazy-lane hydration ledger on reconnect. While connected, the
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
startClaimHeartbeatLoop,
|
|
39
39
|
} from '@abloatai/transaction/coordination/claimHeartbeatLoop';
|
|
40
40
|
import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
|
|
41
|
+
import { modelList, type ModelList } from '@abloatai/transaction/resources/httpResources';
|
|
41
42
|
import { subTarget } from '@abloatai/transaction/coordination';
|
|
42
43
|
// A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
|
|
43
44
|
// .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
|
|
@@ -159,7 +160,7 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
|
|
|
159
160
|
// Model-agnostic by construction: every member below names a target by
|
|
160
161
|
// `{ model, id }` and answers in claim/snapshot terms, so the row type never
|
|
161
162
|
// appears. It carried a `<T>` that nothing in the body read, which made
|
|
162
|
-
// `ModelCollaboration<
|
|
163
|
+
// `ModelCollaboration<Item>` and `ModelCollaboration<Invoice>` the same type
|
|
163
164
|
// while reading as though they differed.
|
|
164
165
|
export interface ModelCollaboration {
|
|
165
166
|
/** Exact point evidence from the HTTP read boundary (stamp captured before data). */
|
|
@@ -458,7 +459,7 @@ export function createModelProxy<T, C>(
|
|
|
458
459
|
}
|
|
459
460
|
|
|
460
461
|
// The coordination plane must speak the same wire dialect as the commit
|
|
461
|
-
// plane: the lowercased typename (`
|
|
462
|
+
// plane: the lowercased typename (`item`), not the schema key (`items`). The
|
|
462
463
|
// server's commit-time claim guard probes the lease store with the commit
|
|
463
464
|
// operation's model name, so a lease recorded under the schema key never
|
|
464
465
|
// matches — which would silently disarm the guard for every model whose
|
|
@@ -504,6 +505,18 @@ export function createModelProxy<T, C>(
|
|
|
504
505
|
};
|
|
505
506
|
|
|
506
507
|
const load = async (options?: ServerReadOptions<T>): Promise<T[]> => {
|
|
508
|
+
if (options?.cursor !== undefined) {
|
|
509
|
+
// The live client hydrates a working set into the local graph rather than
|
|
510
|
+
// handing back pages, so there is no cursor for this read to resume from.
|
|
511
|
+
// Accepting the option and ignoring it would return page one every time
|
|
512
|
+
// while the caller believed it was advancing.
|
|
513
|
+
throw new AbloValidationError(
|
|
514
|
+
'`cursor` resumes a page of the stateless read. This client keeps a ' +
|
|
515
|
+
'local graph and loads a working set instead of pages: narrow the ' +
|
|
516
|
+
'`where`, or construct the client with `transport: \'http\'` to page.',
|
|
517
|
+
{ code: 'invalid_options', param: 'cursor' },
|
|
518
|
+
);
|
|
519
|
+
}
|
|
507
520
|
const rows = await hydration.fetch<T>(schemaKey, options);
|
|
508
521
|
return rows.map((row) => modelAsRow<T>(row));
|
|
509
522
|
};
|
|
@@ -1146,10 +1159,18 @@ export function createModelProxy<T, C>(
|
|
|
1146
1159
|
|
|
1147
1160
|
const list = guard(async (
|
|
1148
1161
|
options?: ServerReadOptions<T>,
|
|
1149
|
-
): Promise<CapturedRow<T
|
|
1162
|
+
): Promise<ModelList<CapturedRow<T>>> => {
|
|
1150
1163
|
const registry = readSetContext?.getStore();
|
|
1151
1164
|
const rows = await load(options);
|
|
1152
|
-
|
|
1165
|
+
// This transport loads a working set rather than pages, so there is no
|
|
1166
|
+
// cursor to hand back. `limit` can still cut the set short, and a full
|
|
1167
|
+
// count is exactly the case where the caller cannot tell: report it rather
|
|
1168
|
+
// than claim completeness this read cannot vouch for.
|
|
1169
|
+
const page = modelList<CapturedRow<T>>(rows as CapturedRow<T>[], {
|
|
1170
|
+
hasMore: options?.limit !== undefined && rows.length >= options.limit,
|
|
1171
|
+
nextCursor: null,
|
|
1172
|
+
});
|
|
1173
|
+
if (!registry) return page;
|
|
1153
1174
|
for (const row of rows) {
|
|
1154
1175
|
const stamp = hydration.getReadEvidence?.(row as object);
|
|
1155
1176
|
if (stamp === undefined) {
|
|
@@ -1174,7 +1195,7 @@ export function createModelProxy<T, C>(
|
|
|
1174
1195
|
stamp,
|
|
1175
1196
|
);
|
|
1176
1197
|
}
|
|
1177
|
-
return
|
|
1198
|
+
return page;
|
|
1178
1199
|
});
|
|
1179
1200
|
|
|
1180
1201
|
const operations: ModelOperations<T, C> = {
|
|
@@ -396,14 +396,14 @@ export interface RuntimeConfig {
|
|
|
396
396
|
* Fields to preserve when merging a partial update into the local store. A
|
|
397
397
|
* change usually carries only the fields that changed; listing a model's
|
|
398
398
|
* essential fields here keeps them from being dropped during that merge.
|
|
399
|
-
* For example: `{
|
|
399
|
+
* For example: `{ Item: ['title', 'projectId'], Section: ['reportId', 'order'] }`.
|
|
400
400
|
*/
|
|
401
401
|
essentialFields: Readonly<Record<string, readonly string[]>>;
|
|
402
402
|
|
|
403
403
|
/**
|
|
404
404
|
* A fallback map from class name to model name, used to resolve a model's name
|
|
405
405
|
* when the usual lookup fails — for instance, when a bundler has minified the
|
|
406
|
-
* class names. For example: `{
|
|
406
|
+
* class names. For example: `{ ItemModel: 'Item', ProjectModel: 'Project' }`.
|
|
407
407
|
*/
|
|
408
408
|
classNameFallbackMap: Readonly<Record<string, string>>;
|
|
409
409
|
|
|
@@ -430,7 +430,7 @@ export interface RuntimeConfig {
|
|
|
430
430
|
|
|
431
431
|
/**
|
|
432
432
|
* Per-model content hashes of the schema this client was built against,
|
|
433
|
-
* keyed by schema key (`
|
|
433
|
+
* keyed by schema key (`items` → hash of that model's serialized JSON). The
|
|
434
434
|
* semantic layer of the drift check: on a whole-schema mismatch the client
|
|
435
435
|
* compares only the models IT declares against the server's per-model
|
|
436
436
|
* surface, so a purely additive server-side change (new models this build
|