@abloatai/humans 0.51.0 → 0.52.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 +1 -57
- package/dist/local/Model.js +0 -18
- package/dist/local/SyncClient.d.ts +0 -2
- package/dist/local/SyncClient.js +2 -19
- package/dist/local/client/createModelProxy.js +1 -1
- package/dist/local/interfaces/index.d.ts +3 -3
- package/dist/local/sync/OnDemandLoader.js +1 -1
- 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/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 +5 -113
- package/src/local/Model.ts +0 -20
- package/src/local/SyncClient.ts +2 -28
- package/src/local/client/createModelProxy.ts +2 -2
- package/src/local/interfaces/index.ts +3 -3
- package/src/local/sync/OnDemandLoader.ts +1 -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/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,63 +15,7 @@ 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
|
-
|
|
19
|
-
* Request identity excludes local timing metadata for re-entrant seals: a
|
|
20
|
-
* retry rebuilds its envelope with a fresh `sequence`/seal clock, so comparing
|
|
21
|
-
* those volatile fields would reject every legitimate same-request re-seal as
|
|
22
|
-
* an idempotency conflict. Only the fields that define the wire request count.
|
|
23
|
-
*/
|
|
24
|
-
function isSameOutboxRecord(existing, candidate) {
|
|
25
|
-
if (existing.type === 'http_commit_envelope' &&
|
|
26
|
-
candidate.type === 'http_commit_envelope') {
|
|
27
|
-
const identity = (record) => ({
|
|
28
|
-
id: record.id,
|
|
29
|
-
type: record.type,
|
|
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));
|
|
64
|
-
}
|
|
65
|
-
return JSON.stringify(existing) === JSON.stringify(candidate);
|
|
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);
|
|
74
|
-
}
|
|
18
|
+
import { isAcceptedOutboxPromotion, isSameOutboxRecord, } from './transactions/persistedTransaction.js';
|
|
75
19
|
export class Database {
|
|
76
20
|
// Core database components
|
|
77
21
|
databaseManager;
|
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
|
}
|
|
@@ -451,8 +451,6 @@ export declare class SyncClient extends EventEmitter {
|
|
|
451
451
|
}[];
|
|
452
452
|
};
|
|
453
453
|
};
|
|
454
|
-
unassignEntity(entityType: string, entityId: string): Promise<void>;
|
|
455
|
-
reassignEntity(entityType: string, entityId: string, assigneeType: string, assigneeId: string, id?: string): Promise<void>;
|
|
456
454
|
/**
|
|
457
455
|
* Apply a batch of delta results from Database to the InstanceCache.
|
|
458
456
|
* Owns: model creation, upsert, remove, archive, conflict resolution.
|
package/dist/local/SyncClient.js
CHANGED
|
@@ -1048,8 +1048,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1048
1048
|
// only materializes on the rare force-accept branch, for its log line.
|
|
1049
1049
|
const shouldForceAcceptServer = (serverData.deletedAt !== undefined && serverData.deletedAt !== null) ||
|
|
1050
1050
|
(serverData.archivedAt !== undefined && serverData.archivedAt !== null) ||
|
|
1051
|
-
serverData.isActive === false
|
|
1052
|
-
(serverData.unassignedAt !== undefined && serverData.unassignedAt !== null);
|
|
1051
|
+
serverData.isActive === false;
|
|
1053
1052
|
if (shouldForceAcceptServer) {
|
|
1054
1053
|
this.runtime.logger.debug('Accepting server update - critical state change detected', {
|
|
1055
1054
|
modelId: localModel.id,
|
|
@@ -1113,13 +1112,10 @@ export class SyncClient extends EventEmitter {
|
|
|
1113
1112
|
if (serverData.archivedAt !== undefined) {
|
|
1114
1113
|
critical.archivedAt = serverData.archivedAt;
|
|
1115
1114
|
}
|
|
1116
|
-
// Deactivation states
|
|
1115
|
+
// Deactivation states are always critical.
|
|
1117
1116
|
if (serverData.isActive !== undefined && serverData.isActive === false) {
|
|
1118
1117
|
critical.isActive = false;
|
|
1119
1118
|
}
|
|
1120
|
-
if (serverData.unassignedAt !== undefined) {
|
|
1121
|
-
critical.unassignedAt = serverData.unassignedAt;
|
|
1122
|
-
}
|
|
1123
1119
|
return critical;
|
|
1124
1120
|
}
|
|
1125
1121
|
/**
|
|
@@ -1475,19 +1471,6 @@ export class SyncClient extends EventEmitter {
|
|
|
1475
1471
|
mutationQueue: this.mutationQueue.getDebugInfo(),
|
|
1476
1472
|
};
|
|
1477
1473
|
}
|
|
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
1474
|
// ── Delta + Bootstrap application (owns InstanceCache writes) ──────────────
|
|
1492
1475
|
/**
|
|
1493
1476
|
* Apply a batch of delta results from Database to the InstanceCache.
|
|
@@ -75,7 +75,7 @@ hydration, collaboration, readSetContext) {
|
|
|
75
75
|
'but no matching constructor was registered.', { code: 'model_not_registered' });
|
|
76
76
|
}
|
|
77
77
|
// The coordination plane must speak the same wire dialect as the commit
|
|
78
|
-
// plane: the lowercased typename (`
|
|
78
|
+
// plane: the lowercased typename (`item`), not the schema key (`items`). The
|
|
79
79
|
// server's commit-time claim guard probes the lease store with the commit
|
|
80
80
|
// operation's model name, so a lease recorded under the schema key never
|
|
81
81
|
// matches — which would silently disarm the guard for every model whose
|
|
@@ -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
|
|
@@ -415,7 +415,7 @@ export class OnDemandLoader {
|
|
|
415
415
|
// that disagrees with the schema's: these rows were returned FOR this
|
|
416
416
|
// model's query, so the schema typename is correct by construction — and
|
|
417
417
|
// without stripping it, the spread would put the row's variant (a server
|
|
418
|
-
// echoing the schema KEY `
|
|
418
|
+
// echoing the schema KEY `items` instead of the typename `Item`) back on
|
|
419
419
|
// top of the stamp, sending hydration to the strict unknown-model error.
|
|
420
420
|
const { _Typename: _dropMangled, __typename: _dropRowVariant, ...rest } = obj;
|
|
421
421
|
void _dropMangled;
|
|
@@ -276,7 +276,7 @@ async function drainPendingDeltas(ctx) {
|
|
|
276
276
|
// A sustained stream can refill the detached queue before every
|
|
277
277
|
// persistence promise settles. Promise-only looping then forms an
|
|
278
278
|
// unbounded microtask chain that starves WebSocket reads, timers and
|
|
279
|
-
// replication keepalives. Give the host one
|
|
279
|
+
// replication keepalives. Give the host one macroitem turn between
|
|
280
280
|
// owned batches; Node has setImmediate, browsers fall back to a timer.
|
|
281
281
|
await yieldToHost();
|
|
282
282
|
}
|
|
@@ -43,7 +43,7 @@ export function* initialize(host, context, signal) {
|
|
|
43
43
|
// Bootstrap from server if needed.
|
|
44
44
|
//
|
|
45
45
|
// `bootstrapMode: 'none'` participants (headless workers and
|
|
46
|
-
//
|
|
46
|
+
// item runners) skip baseline replication — they read via
|
|
47
47
|
// `model.get()` round-trips and rely on covering deltas
|
|
48
48
|
// from filtered subscriptions to populate the pool lazily. The
|
|
49
49
|
// WS is already open by `setupWebSocketSync` above, so live
|
|
@@ -55,7 +55,7 @@ export function* initialize(host, context, signal) {
|
|
|
55
55
|
// initiates the upgrade, but it does NOT await the 'connected'
|
|
56
56
|
// event — it returns synchronously after wiring listeners.
|
|
57
57
|
// For bootstrapMode='none' consumers (headless workers and
|
|
58
|
-
//
|
|
58
|
+
// item runners), this branch is the entire body of initialize()
|
|
59
59
|
// after the WS is set up, so `ready()` would otherwise resolve
|
|
60
60
|
// while the WS is still in 'connecting' state. The very next
|
|
61
61
|
// `commits.create` then throws "SyncWebSocket not connected".
|
|
@@ -167,7 +167,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
167
167
|
/**
|
|
168
168
|
* Relates stale notifications back to write targets without assuming the
|
|
169
169
|
* server's canonical model name uses the same spelling as the public schema
|
|
170
|
-
* key (`
|
|
170
|
+
* key (`Item` versus `items`). Exact `(model,id)` wins; a globally unique id
|
|
171
171
|
* is the compatibility fallback. An ambiguous same-id cross-model mismatch
|
|
172
172
|
* is deliberately left unclassified, so it cannot falsely settle a queued
|
|
173
173
|
* write. A notification with no write-target id (or an explicit group) is a
|
|
@@ -417,7 +417,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
417
417
|
/**
|
|
418
418
|
* Relates stale notifications back to write targets without assuming the
|
|
419
419
|
* server's canonical model name uses the same spelling as the public schema
|
|
420
|
-
* key (`
|
|
420
|
+
* key (`Item` versus `items`). Exact `(model,id)` wins; a globally unique id
|
|
421
421
|
* is the compatibility fallback. An ambiguous same-id cross-model mismatch
|
|
422
422
|
* is deliberately left unclassified, so it cannot falsely settle a queued
|
|
423
423
|
* write. A notification with no write-target id (or an explicit group) is a
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { OnStaleMode } from '@abloatai/transaction/coordination/schema';
|
|
2
|
+
type ModelData = Record<string, unknown>;
|
|
3
|
+
/** One mutation retained in the durable local transaction journal. */
|
|
4
|
+
interface PersistedMutation {
|
|
5
|
+
type: 'create' | 'update' | 'delete' | 'archive';
|
|
6
|
+
modelData: ModelData;
|
|
7
|
+
modelName: string;
|
|
8
|
+
timestamp: string;
|
|
9
|
+
writeOptions?: {
|
|
10
|
+
readAt?: number | null;
|
|
11
|
+
onStale?: OnStaleMode | null;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Persisted transaction for offline/retry support.
|
|
16
|
+
*
|
|
17
|
+
* The index signature is part of the contract: this targets the generic
|
|
18
|
+
* record-shaped storage layer (`InMemoryObjectStore.put` and its IndexedDB
|
|
19
|
+
* equivalent), both of which take `Record<string, unknown>`.
|
|
20
|
+
*/
|
|
21
|
+
export interface PersistedTransaction {
|
|
22
|
+
id: string;
|
|
23
|
+
type?: string;
|
|
24
|
+
timestamp?: number;
|
|
25
|
+
createdAt?: number;
|
|
26
|
+
mutations?: PersistedMutation[];
|
|
27
|
+
awaitingDelta?: {
|
|
28
|
+
syncIdNeeded: number;
|
|
29
|
+
modelName: string;
|
|
30
|
+
modelId: string;
|
|
31
|
+
operationType: string;
|
|
32
|
+
};
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
/** Compare the stable request identity while ignoring local seal timing. */
|
|
36
|
+
export declare function isSameOutboxRecord(existing: PersistedTransaction, candidate: PersistedTransaction): boolean;
|
|
37
|
+
/** An accepted envelope may replace the otherwise-identical pending envelope. */
|
|
38
|
+
export declare function isAcceptedOutboxPromotion(existing: PersistedTransaction | undefined, candidate: PersistedTransaction): boolean;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Compare the stable request identity while ignoring local seal timing. */
|
|
2
|
+
export function isSameOutboxRecord(existing, candidate) {
|
|
3
|
+
if (existing.type === 'http_commit_envelope' &&
|
|
4
|
+
candidate.type === 'http_commit_envelope') {
|
|
5
|
+
const identity = (record) => ({
|
|
6
|
+
id: record.id,
|
|
7
|
+
type: record.type,
|
|
8
|
+
storageVersion: record.storageVersion,
|
|
9
|
+
idempotencyKey: record.idempotencyKey,
|
|
10
|
+
// Pre-versioning HTTP outbox rows are v1. Normalizing them preserves
|
|
11
|
+
// idempotency when the same request is resealed after an upgrade.
|
|
12
|
+
protocolVersion: record.protocolVersion ?? 1,
|
|
13
|
+
request: record.request,
|
|
14
|
+
scopeNamespace: record.scopeNamespace,
|
|
15
|
+
});
|
|
16
|
+
if (existing.correlationId !== undefined &&
|
|
17
|
+
candidate.correlationId !== undefined &&
|
|
18
|
+
existing.correlationId !== candidate.correlationId) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
22
|
+
}
|
|
23
|
+
if (existing.type === 'commit_envelope' &&
|
|
24
|
+
candidate.type === 'commit_envelope') {
|
|
25
|
+
const identity = (record) => ({
|
|
26
|
+
id: record.id,
|
|
27
|
+
type: record.type,
|
|
28
|
+
storageVersion: record.storageVersion,
|
|
29
|
+
origin: record.origin,
|
|
30
|
+
idempotencyKey: record.idempotencyKey,
|
|
31
|
+
operations: record.operations,
|
|
32
|
+
sourceMutationIds: record.sourceMutationIds,
|
|
33
|
+
commitOptions: record.commitOptions,
|
|
34
|
+
scope: record.scope,
|
|
35
|
+
});
|
|
36
|
+
if (existing.correlationId !== undefined &&
|
|
37
|
+
candidate.correlationId !== undefined &&
|
|
38
|
+
existing.correlationId !== candidate.correlationId) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
42
|
+
}
|
|
43
|
+
return JSON.stringify(existing) === JSON.stringify(candidate);
|
|
44
|
+
}
|
|
45
|
+
/** An accepted envelope may replace the otherwise-identical pending envelope. */
|
|
46
|
+
export function isAcceptedOutboxPromotion(existing, candidate) {
|
|
47
|
+
return (existing !== undefined &&
|
|
48
|
+
(existing.type === 'commit_envelope' ||
|
|
49
|
+
existing.type === 'http_commit_envelope') &&
|
|
50
|
+
existing.type === candidate.type &&
|
|
51
|
+
existing.acceptedAt === undefined &&
|
|
52
|
+
candidate.acceptedAt !== undefined);
|
|
53
|
+
}
|
|
@@ -48,7 +48,7 @@ export function M1(target, propertyMetadata, referenceMetadata) {
|
|
|
48
48
|
return false;
|
|
49
49
|
};
|
|
50
50
|
// Skip if target has its own observability setup
|
|
51
|
-
// This allows models like
|
|
51
|
+
// This allows models like Item to handle their own MobX setup
|
|
52
52
|
if (target.setupObservability || target._hasCustomObservability) {
|
|
53
53
|
getContext().modelDebugLogger?.logDebug(`${target.constructor.name} has custom observability, skipping M1`);
|
|
54
54
|
return;
|
|
@@ -206,10 +206,10 @@ export declare function usePeers(scope?: ParticipantScope): readonly Peer[];
|
|
|
206
206
|
/**
|
|
207
207
|
* Returns the raw `SyncEngine` proxy. Typically you want the typed
|
|
208
208
|
* hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
|
|
209
|
-
* where you need direct access (e.g., `sync.
|
|
209
|
+
* where you need direct access (e.g., `sync.items.onChange(cb)`).
|
|
210
210
|
*
|
|
211
211
|
* The generic parameter narrows the return type to your schema's
|
|
212
|
-
* model record so call sites get typed `sync.
|
|
212
|
+
* model record so call sites get typed `sync.items.findMany()` /
|
|
213
213
|
* `sync.sections.create(...)` without a cast at the call site:
|
|
214
214
|
*
|
|
215
215
|
* ```ts
|
|
@@ -400,10 +400,10 @@ export function usePeers(scope) {
|
|
|
400
400
|
/**
|
|
401
401
|
* Returns the raw `SyncEngine` proxy. Typically you want the typed
|
|
402
402
|
* hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
|
|
403
|
-
* where you need direct access (e.g., `sync.
|
|
403
|
+
* where you need direct access (e.g., `sync.items.onChange(cb)`).
|
|
404
404
|
*
|
|
405
405
|
* The generic parameter narrows the return type to your schema's
|
|
406
|
-
* model record so call sites get typed `sync.
|
|
406
|
+
* model record so call sites get typed `sync.items.findMany()` /
|
|
407
407
|
* `sync.sections.create(...)` without a cast at the call site:
|
|
408
408
|
*
|
|
409
409
|
* ```ts
|
package/dist/react/context.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface SyncReactContext {
|
|
|
8
8
|
organizationId: string;
|
|
9
9
|
/**
|
|
10
10
|
* An optional schema. When provided, hooks that take a model by name (such as
|
|
11
|
-
* `useQuery('
|
|
11
|
+
* `useQuery('items')`) read that model's metadata from this schema, so
|
|
12
12
|
* callers don't pass a schema at every call site. When omitted, those hooks
|
|
13
13
|
* require the schema as an argument instead.
|
|
14
14
|
*
|
|
@@ -38,7 +38,7 @@ export interface SyncProviderProps {
|
|
|
38
38
|
organizationId: string;
|
|
39
39
|
/**
|
|
40
40
|
* An optional schema. Provide it to enable hooks that take a model by name
|
|
41
|
-
* (such as `useQuery('
|
|
41
|
+
* (such as `useQuery('items')`); the model types also narrow through your
|
|
42
42
|
* `Register` augmentation. Omit it to pass the schema to those hooks directly
|
|
43
43
|
* instead.
|
|
44
44
|
*/
|
package/dist/react/useAblo.d.ts
CHANGED
|
@@ -54,13 +54,13 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
|
|
|
54
54
|
* // With the Register augmentation (recommended):
|
|
55
55
|
* const ablo = useAblo();
|
|
56
56
|
* if (!ablo) return <Loading />;
|
|
57
|
-
* const doc = await ablo.
|
|
57
|
+
* const doc = await ablo.records.get({ id }); // async server read
|
|
58
58
|
*
|
|
59
59
|
* // Reactive selector (a synchronous local snapshot). The selector's reads
|
|
60
60
|
* // are typed as snapshot rows — data fields + computeds, no relation
|
|
61
61
|
* // accessors — matching what the hook actually returns:
|
|
62
|
-
* const doc = useAblo((ablo) => ablo.
|
|
63
|
-
* const active = useAblo((ablo) => ablo.
|
|
62
|
+
* const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
|
|
63
|
+
* const active = useAblo((ablo) => ablo.records.claim.state({ id }));
|
|
64
64
|
*
|
|
65
65
|
* // Without the augmentation, pass the schema as a type argument:
|
|
66
66
|
* const ablo = useAblo<(typeof schema)['models']>();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"directory": "packages/humans"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@abloatai/transaction": "^0.
|
|
87
|
+
"@abloatai/transaction": "^0.52.0",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.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,125 +23,17 @@ 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
|
-
/**
|
|
71
|
-
* Request identity excludes local timing metadata for re-entrant seals: a
|
|
72
|
-
* retry rebuilds its envelope with a fresh `sequence`/seal clock, so comparing
|
|
73
|
-
* those volatile fields would reject every legitimate same-request re-seal as
|
|
74
|
-
* an idempotency conflict. Only the fields that define the wire request count.
|
|
75
|
-
*/
|
|
76
|
-
function isSameOutboxRecord(
|
|
77
|
-
existing: PersistedTransaction,
|
|
78
|
-
candidate: PersistedTransaction,
|
|
79
|
-
): boolean {
|
|
80
|
-
if (
|
|
81
|
-
existing.type === 'http_commit_envelope' &&
|
|
82
|
-
candidate.type === 'http_commit_envelope'
|
|
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));
|
|
127
|
-
}
|
|
128
|
-
return JSON.stringify(existing) === JSON.stringify(candidate);
|
|
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
|
-
);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
37
|
// Re-exported, not redeclared. `@abloatai/transaction`'s `types` module owns this
|
|
146
38
|
// vocabulary and documents what each mode does; this package held a byte-identical
|
|
147
39
|
// second copy while its own test fixtures already imported the canonical one.
|
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
|
@@ -1302,8 +1302,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1302
1302
|
const shouldForceAcceptServer =
|
|
1303
1303
|
(serverData.deletedAt !== undefined && serverData.deletedAt !== null) ||
|
|
1304
1304
|
(serverData.archivedAt !== undefined && serverData.archivedAt !== null) ||
|
|
1305
|
-
serverData.isActive === false
|
|
1306
|
-
(serverData.unassignedAt !== undefined && serverData.unassignedAt !== null);
|
|
1305
|
+
serverData.isActive === false;
|
|
1307
1306
|
|
|
1308
1307
|
if (shouldForceAcceptServer) {
|
|
1309
1308
|
this.runtime.logger.debug('Accepting server update - critical state change detected', {
|
|
@@ -1378,14 +1377,10 @@ export class SyncClient extends EventEmitter {
|
|
|
1378
1377
|
critical.archivedAt = serverData.archivedAt;
|
|
1379
1378
|
}
|
|
1380
1379
|
|
|
1381
|
-
// Deactivation states
|
|
1380
|
+
// Deactivation states are always critical.
|
|
1382
1381
|
if (serverData.isActive !== undefined && serverData.isActive === false) {
|
|
1383
1382
|
critical.isActive = false;
|
|
1384
1383
|
}
|
|
1385
|
-
if (serverData.unassignedAt !== undefined) {
|
|
1386
|
-
critical.unassignedAt = serverData.unassignedAt;
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
1384
|
return critical;
|
|
1390
1385
|
}
|
|
1391
1386
|
|
|
@@ -1824,27 +1819,6 @@ export class SyncClient extends EventEmitter {
|
|
|
1824
1819
|
};
|
|
1825
1820
|
}
|
|
1826
1821
|
|
|
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
1822
|
// ── Delta + Bootstrap application (owns InstanceCache writes) ──────────────
|
|
1849
1823
|
|
|
1850
1824
|
/**
|
|
@@ -159,7 +159,7 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
|
|
|
159
159
|
// Model-agnostic by construction: every member below names a target by
|
|
160
160
|
// `{ model, id }` and answers in claim/snapshot terms, so the row type never
|
|
161
161
|
// appears. It carried a `<T>` that nothing in the body read, which made
|
|
162
|
-
// `ModelCollaboration<
|
|
162
|
+
// `ModelCollaboration<Item>` and `ModelCollaboration<Invoice>` the same type
|
|
163
163
|
// while reading as though they differed.
|
|
164
164
|
export interface ModelCollaboration {
|
|
165
165
|
/** Exact point evidence from the HTTP read boundary (stamp captured before data). */
|
|
@@ -458,7 +458,7 @@ export function createModelProxy<T, C>(
|
|
|
458
458
|
}
|
|
459
459
|
|
|
460
460
|
// The coordination plane must speak the same wire dialect as the commit
|
|
461
|
-
// plane: the lowercased typename (`
|
|
461
|
+
// plane: the lowercased typename (`item`), not the schema key (`items`). The
|
|
462
462
|
// server's commit-time claim guard probes the lease store with the commit
|
|
463
463
|
// operation's model name, so a lease recorded under the schema key never
|
|
464
464
|
// matches — which would silently disarm the guard for every model whose
|
|
@@ -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
|
|
@@ -556,7 +556,7 @@ export class OnDemandLoader {
|
|
|
556
556
|
// that disagrees with the schema's: these rows were returned FOR this
|
|
557
557
|
// model's query, so the schema typename is correct by construction — and
|
|
558
558
|
// without stripping it, the spread would put the row's variant (a server
|
|
559
|
-
// echoing the schema KEY `
|
|
559
|
+
// echoing the schema KEY `items` instead of the typename `Item`) back on
|
|
560
560
|
// top of the stamp, sending hydration to the strict unknown-model error.
|
|
561
561
|
const { _Typename: _dropMangled, __typename: _dropRowVariant, ...rest } = obj as Record<
|
|
562
562
|
string,
|
|
@@ -396,7 +396,7 @@ async function drainPendingDeltas(ctx: DeltaPipelineContext): Promise<void> {
|
|
|
396
396
|
// A sustained stream can refill the detached queue before every
|
|
397
397
|
// persistence promise settles. Promise-only looping then forms an
|
|
398
398
|
// unbounded microtask chain that starves WebSocket reads, timers and
|
|
399
|
-
// replication keepalives. Give the host one
|
|
399
|
+
// replication keepalives. Give the host one macroitem turn between
|
|
400
400
|
// owned batches; Node has setImmediate, browsers fall back to a timer.
|
|
401
401
|
await yieldToHost();
|
|
402
402
|
}
|
|
@@ -92,7 +92,7 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
|
|
|
92
92
|
// Bootstrap from server if needed.
|
|
93
93
|
//
|
|
94
94
|
// `bootstrapMode: 'none'` participants (headless workers and
|
|
95
|
-
//
|
|
95
|
+
// item runners) skip baseline replication — they read via
|
|
96
96
|
// `model.get()` round-trips and rely on covering deltas
|
|
97
97
|
// from filtered subscriptions to populate the pool lazily. The
|
|
98
98
|
// WS is already open by `setupWebSocketSync` above, so live
|
|
@@ -110,7 +110,7 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
|
|
|
110
110
|
// initiates the upgrade, but it does NOT await the 'connected'
|
|
111
111
|
// event — it returns synchronously after wiring listeners.
|
|
112
112
|
// For bootstrapMode='none' consumers (headless workers and
|
|
113
|
-
//
|
|
113
|
+
// item runners), this branch is the entire body of initialize()
|
|
114
114
|
// after the WS is set up, so `ready()` would otherwise resolve
|
|
115
115
|
// while the WS is still in 'connecting' state. The very next
|
|
116
116
|
// `commits.create` then throws "SyncWebSocket not connected".
|
|
@@ -645,7 +645,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
645
645
|
/**
|
|
646
646
|
* Relates stale notifications back to write targets without assuming the
|
|
647
647
|
* server's canonical model name uses the same spelling as the public schema
|
|
648
|
-
* key (`
|
|
648
|
+
* key (`Item` versus `items`). Exact `(model,id)` wins; a globally unique id
|
|
649
649
|
* is the compatibility fallback. An ambiguous same-id cross-model mismatch
|
|
650
650
|
* is deliberately left unclassified, so it cannot falsely settle a queued
|
|
651
651
|
* write. A notification with no write-target id (or an explicit group) is a
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { OnStaleMode } from '@abloatai/transaction/coordination/schema';
|
|
2
|
+
|
|
3
|
+
type ModelData = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
/** One mutation retained in the durable local transaction journal. */
|
|
6
|
+
interface PersistedMutation {
|
|
7
|
+
type: 'create' | 'update' | 'delete' | 'archive';
|
|
8
|
+
modelData: ModelData;
|
|
9
|
+
modelName: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
writeOptions?: {
|
|
12
|
+
readAt?: number | null;
|
|
13
|
+
onStale?: OnStaleMode | null;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Persisted transaction for offline/retry support.
|
|
19
|
+
*
|
|
20
|
+
* The index signature is part of the contract: this targets the generic
|
|
21
|
+
* record-shaped storage layer (`InMemoryObjectStore.put` and its IndexedDB
|
|
22
|
+
* equivalent), both of which take `Record<string, unknown>`.
|
|
23
|
+
*/
|
|
24
|
+
export interface PersistedTransaction {
|
|
25
|
+
id: string;
|
|
26
|
+
type?: string;
|
|
27
|
+
timestamp?: number;
|
|
28
|
+
createdAt?: number;
|
|
29
|
+
mutations?: PersistedMutation[];
|
|
30
|
+
// Awaiting-delta transactions survive a tab close. Reconnect and delta
|
|
31
|
+
// catch-up confirm them during the next session.
|
|
32
|
+
awaitingDelta?: {
|
|
33
|
+
syncIdNeeded: number;
|
|
34
|
+
modelName: string;
|
|
35
|
+
modelId: string;
|
|
36
|
+
operationType: string;
|
|
37
|
+
};
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Compare the stable request identity while ignoring local seal timing. */
|
|
42
|
+
export function isSameOutboxRecord(
|
|
43
|
+
existing: PersistedTransaction,
|
|
44
|
+
candidate: PersistedTransaction,
|
|
45
|
+
): boolean {
|
|
46
|
+
if (
|
|
47
|
+
existing.type === 'http_commit_envelope' &&
|
|
48
|
+
candidate.type === 'http_commit_envelope'
|
|
49
|
+
) {
|
|
50
|
+
const identity = (record: PersistedTransaction): unknown => ({
|
|
51
|
+
id: record.id,
|
|
52
|
+
type: record.type,
|
|
53
|
+
storageVersion: record.storageVersion,
|
|
54
|
+
idempotencyKey: record.idempotencyKey,
|
|
55
|
+
// Pre-versioning HTTP outbox rows are v1. Normalizing them preserves
|
|
56
|
+
// idempotency when the same request is resealed after an upgrade.
|
|
57
|
+
protocolVersion: record.protocolVersion ?? 1,
|
|
58
|
+
request: record.request,
|
|
59
|
+
scopeNamespace: record.scopeNamespace,
|
|
60
|
+
});
|
|
61
|
+
if (
|
|
62
|
+
existing.correlationId !== undefined &&
|
|
63
|
+
candidate.correlationId !== undefined &&
|
|
64
|
+
existing.correlationId !== candidate.correlationId
|
|
65
|
+
) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (
|
|
72
|
+
existing.type === 'commit_envelope' &&
|
|
73
|
+
candidate.type === 'commit_envelope'
|
|
74
|
+
) {
|
|
75
|
+
const identity = (record: PersistedTransaction): unknown => ({
|
|
76
|
+
id: record.id,
|
|
77
|
+
type: record.type,
|
|
78
|
+
storageVersion: record.storageVersion,
|
|
79
|
+
origin: record.origin,
|
|
80
|
+
idempotencyKey: record.idempotencyKey,
|
|
81
|
+
operations: record.operations,
|
|
82
|
+
sourceMutationIds: record.sourceMutationIds,
|
|
83
|
+
commitOptions: record.commitOptions,
|
|
84
|
+
scope: record.scope,
|
|
85
|
+
});
|
|
86
|
+
if (
|
|
87
|
+
existing.correlationId !== undefined &&
|
|
88
|
+
candidate.correlationId !== undefined &&
|
|
89
|
+
existing.correlationId !== candidate.correlationId
|
|
90
|
+
) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return JSON.stringify(existing) === JSON.stringify(candidate);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** An accepted envelope may replace the otherwise-identical pending envelope. */
|
|
100
|
+
export function isAcceptedOutboxPromotion(
|
|
101
|
+
existing: PersistedTransaction | undefined,
|
|
102
|
+
candidate: PersistedTransaction,
|
|
103
|
+
): boolean {
|
|
104
|
+
return (
|
|
105
|
+
existing !== undefined &&
|
|
106
|
+
(existing.type === 'commit_envelope' ||
|
|
107
|
+
existing.type === 'http_commit_envelope') &&
|
|
108
|
+
existing.type === candidate.type &&
|
|
109
|
+
existing.acceptedAt === undefined &&
|
|
110
|
+
candidate.acceptedAt !== undefined
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -77,7 +77,7 @@ export function M1<T extends M1Target>(
|
|
|
77
77
|
};
|
|
78
78
|
|
|
79
79
|
// Skip if target has its own observability setup
|
|
80
|
-
// This allows models like
|
|
80
|
+
// This allows models like Item to handle their own MobX setup
|
|
81
81
|
if (target.setupObservability || target._hasCustomObservability) {
|
|
82
82
|
getContext().modelDebugLogger?.logDebug(`${target.constructor.name} has custom observability, skipping M1`);
|
|
83
83
|
return;
|
|
@@ -675,10 +675,10 @@ export function usePeers(scope?: ParticipantScope): readonly Peer[] {
|
|
|
675
675
|
/**
|
|
676
676
|
* Returns the raw `SyncEngine` proxy. Typically you want the typed
|
|
677
677
|
* hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
|
|
678
|
-
* where you need direct access (e.g., `sync.
|
|
678
|
+
* where you need direct access (e.g., `sync.items.onChange(cb)`).
|
|
679
679
|
*
|
|
680
680
|
* The generic parameter narrows the return type to your schema's
|
|
681
|
-
* model record so call sites get typed `sync.
|
|
681
|
+
* model record so call sites get typed `sync.items.findMany()` /
|
|
682
682
|
* `sync.sections.create(...)` without a cast at the call site:
|
|
683
683
|
*
|
|
684
684
|
* ```ts
|
package/src/react/context.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface SyncReactContext {
|
|
|
19
19
|
organizationId: string;
|
|
20
20
|
/**
|
|
21
21
|
* An optional schema. When provided, hooks that take a model by name (such as
|
|
22
|
-
* `useQuery('
|
|
22
|
+
* `useQuery('items')`) read that model's metadata from this schema, so
|
|
23
23
|
* callers don't pass a schema at every call site. When omitted, those hooks
|
|
24
24
|
* require the schema as an argument instead.
|
|
25
25
|
*
|
|
@@ -60,7 +60,7 @@ export interface SyncProviderProps {
|
|
|
60
60
|
organizationId: string;
|
|
61
61
|
/**
|
|
62
62
|
* An optional schema. Provide it to enable hooks that take a model by name
|
|
63
|
-
* (such as `useQuery('
|
|
63
|
+
* (such as `useQuery('items')`); the model types also narrow through your
|
|
64
64
|
* `Register` augmentation. Omit it to pass the schema to those hooks directly
|
|
65
65
|
* instead.
|
|
66
66
|
*/
|
package/src/react/useAblo.ts
CHANGED
|
@@ -138,13 +138,13 @@ function snapshotValue<T>(value: T): T {
|
|
|
138
138
|
* // With the Register augmentation (recommended):
|
|
139
139
|
* const ablo = useAblo();
|
|
140
140
|
* if (!ablo) return <Loading />;
|
|
141
|
-
* const doc = await ablo.
|
|
141
|
+
* const doc = await ablo.records.get({ id }); // async server read
|
|
142
142
|
*
|
|
143
143
|
* // Reactive selector (a synchronous local snapshot). The selector's reads
|
|
144
144
|
* // are typed as snapshot rows — data fields + computeds, no relation
|
|
145
145
|
* // accessors — matching what the hook actually returns:
|
|
146
|
-
* const doc = useAblo((ablo) => ablo.
|
|
147
|
-
* const active = useAblo((ablo) => ablo.
|
|
146
|
+
* const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
|
|
147
|
+
* const active = useAblo((ablo) => ablo.records.claim.state({ id }));
|
|
148
148
|
*
|
|
149
149
|
* // Without the augmentation, pass the schema as a type argument:
|
|
150
150
|
* const ablo = useAblo<(typeof schema)['models']>();
|