@abloatai/humans 0.56.0 → 0.57.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/local/Model.js +1 -1
- package/dist/local/SyncClient.d.ts +3 -34
- package/dist/local/SyncClient.js +1 -16
- package/dist/local/client/createModelProxy.d.ts +13 -1
- package/dist/local/client/createModelProxy.js +159 -80
- package/dist/local/client/reactiveEngine.js +5 -1
- package/dist/local/client/wsMutationExecutor.js +1 -0
- package/dist/local/syncClientTypes.d.ts +41 -0
- package/dist/local/syncClientTypes.js +11 -0
- package/dist/local/transactions/mutations/MutationQueue.d.ts +2 -0
- package/dist/local/transactions/mutations/MutationQueue.js +54 -69
- package/dist/local/transactions/mutations/commitLane.d.ts +4 -1
- package/dist/local/transactions/mutations/commitLane.js +12 -3
- package/dist/local/transactions/mutations/mutationInput.d.ts +40 -0
- package/dist/local/transactions/mutations/mutationInput.js +53 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -0
- package/package.json +2 -2
- package/src/local/Database.ts +0 -1
- package/src/local/Model.ts +1 -1
- package/src/local/SyncClient.ts +12 -72
- package/src/local/client/createModelProxy.ts +164 -13
- package/src/local/client/reactiveEngine.ts +5 -1
- package/src/local/client/wsMutationExecutor.ts +1 -0
- package/src/local/syncClientTypes.ts +59 -0
- package/src/local/transactions/mutations/MutationQueue.ts +61 -88
- package/src/local/transactions/mutations/commitLane.ts +23 -5
- package/src/local/transactions/mutations/mutationInput.ts +69 -0
- package/src/surface.ts +1 -0
|
@@ -37,8 +37,25 @@ import {
|
|
|
37
37
|
resolveHeartbeatPlan,
|
|
38
38
|
startClaimHeartbeatLoop,
|
|
39
39
|
} from '@abloatai/transaction/coordination/claimHeartbeatLoop';
|
|
40
|
-
import {
|
|
41
|
-
|
|
40
|
+
import {
|
|
41
|
+
assertWriteOptions,
|
|
42
|
+
assertWriteTarget,
|
|
43
|
+
} from '@abloatai/transaction/resources/writeOptionsSchema';
|
|
44
|
+
import {
|
|
45
|
+
createModelId,
|
|
46
|
+
resolveCreatedRows,
|
|
47
|
+
resolveCreateId,
|
|
48
|
+
} from '@abloatai/transaction/resources/modelCreate';
|
|
49
|
+
import type {
|
|
50
|
+
CommitCreateOptions,
|
|
51
|
+
CommitReceipt,
|
|
52
|
+
} from '@abloatai/transaction/resources/httpResources';
|
|
53
|
+
import type { HttpModelMutationParams } from '@abloatai/transaction/transport/httpClient';
|
|
54
|
+
import {
|
|
55
|
+
collectModelList,
|
|
56
|
+
modelList,
|
|
57
|
+
type ModelList,
|
|
58
|
+
} from '@abloatai/transaction/resources/httpResources';
|
|
42
59
|
import { subTarget } from '@abloatai/transaction/coordination';
|
|
43
60
|
// A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
|
|
44
61
|
// .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
|
|
@@ -76,6 +93,7 @@ export type {
|
|
|
76
93
|
LocalReadOptions,
|
|
77
94
|
LocalCountOptions,
|
|
78
95
|
ServerReadOptions,
|
|
96
|
+
ListAllOptions,
|
|
79
97
|
ServerGetOptions,
|
|
80
98
|
ServerRetrieveOptions,
|
|
81
99
|
ClaimTargetOptions,
|
|
@@ -112,6 +130,7 @@ import type {
|
|
|
112
130
|
JoinOptions,
|
|
113
131
|
LocalCountOptions,
|
|
114
132
|
LocalReadOptions,
|
|
133
|
+
ModelCreateManyParams,
|
|
115
134
|
ModelCreateParams,
|
|
116
135
|
ModelDeleteParams,
|
|
117
136
|
ModelRetrieveParams,
|
|
@@ -119,6 +138,7 @@ import type {
|
|
|
119
138
|
ModelTrackResult,
|
|
120
139
|
ModelUpdateParams,
|
|
121
140
|
ServerReadOptions,
|
|
141
|
+
ListAllOptions,
|
|
122
142
|
} from '@abloatai/transaction/resources/modelOperations';
|
|
123
143
|
import {
|
|
124
144
|
claimQueueView,
|
|
@@ -165,6 +185,17 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
|
|
|
165
185
|
export interface ModelCollaboration {
|
|
166
186
|
/** Exact point evidence from the HTTP read boundary (stamp captured before data). */
|
|
167
187
|
readPoint(model: string, id: string): Promise<{ data: unknown; stamp: number }>;
|
|
188
|
+
/**
|
|
189
|
+
* The batch commit lane, for a create handed a list of rows.
|
|
190
|
+
*
|
|
191
|
+
* A reactive client's single writes go through the mutation queue and land
|
|
192
|
+
* optimistically, because a rejected write rolls one row back. A batch
|
|
193
|
+
* cannot: it is atomic, so applying the rows one at a time would paint a
|
|
194
|
+
* half-written state the server may then decline whole. It goes down the
|
|
195
|
+
* same commit door the stateless client uses and the rows arrive on the
|
|
196
|
+
* ordinary stream, the way a teammate's would.
|
|
197
|
+
*/
|
|
198
|
+
commitBatch(options: CommitCreateOptions): Promise<CommitReceipt>;
|
|
168
199
|
createClaim(options: {
|
|
169
200
|
/**
|
|
170
201
|
* The locator, in the spelling the SDK surface and the HTTP routes use.
|
|
@@ -1198,19 +1229,98 @@ export function createModelProxy<T, C>(
|
|
|
1198
1229
|
return page;
|
|
1199
1230
|
});
|
|
1200
1231
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1232
|
+
/**
|
|
1233
|
+
* Creates many rows as one atomic commit, and returns them in caller order.
|
|
1234
|
+
*/
|
|
1235
|
+
const createManyRows = async (
|
|
1236
|
+
params: HttpModelMutationParams<ModelCreateManyParams<C>>,
|
|
1237
|
+
): Promise<T[]> => {
|
|
1238
|
+
if (params.data.length === 0) return [];
|
|
1239
|
+
if (!collaboration) {
|
|
1240
|
+
throw new AbloValidationError(
|
|
1241
|
+
`Model "${schemaKey}" was built without the collaboration runtime, so a batch ` +
|
|
1242
|
+
`create is unavailable here. Use the standard Ablo({ schema, apiKey }) client.`,
|
|
1243
|
+
{ code: 'model_claim_not_configured' },
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1203
1246
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1247
|
+
const prepared = prepareReadSet(
|
|
1248
|
+
readSetContext,
|
|
1249
|
+
readSetClientIdentity,
|
|
1250
|
+
undefined,
|
|
1251
|
+
undefined,
|
|
1252
|
+
params.idempotencyKey,
|
|
1253
|
+
params.reads,
|
|
1254
|
+
);
|
|
1255
|
+
try {
|
|
1256
|
+
const organizationId = syncClient.getOrganizationId() ?? undefined;
|
|
1257
|
+
const ids: string[] = [];
|
|
1258
|
+
const operations = params.data.map((row) => {
|
|
1259
|
+
const fields = row as Record<string, unknown>;
|
|
1260
|
+
const id =
|
|
1261
|
+
resolveCreateId(undefined, fields) ??
|
|
1262
|
+
createModelId(
|
|
1263
|
+
registeredModelName,
|
|
1264
|
+
params.idempotencyKey ? `${params.idempotencyKey}:${ids.length}` : null,
|
|
1265
|
+
);
|
|
1266
|
+
ids.push(id);
|
|
1267
|
+
return {
|
|
1268
|
+
action: 'create' as const,
|
|
1269
|
+
model: registeredModelName,
|
|
1270
|
+
data: { organizationId: fields.organizationId ?? organizationId, ...fields, id },
|
|
1271
|
+
id,
|
|
1272
|
+
};
|
|
1273
|
+
});
|
|
1206
1274
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1275
|
+
const receipt = await collaboration.commitBatch({
|
|
1276
|
+
operations,
|
|
1277
|
+
wait: 'confirmed',
|
|
1278
|
+
...(prepared.idempotencyKey
|
|
1279
|
+
? { idempotencyKey: prepared.idempotencyKey }
|
|
1280
|
+
: params.idempotencyKey
|
|
1281
|
+
? { idempotencyKey: params.idempotencyKey }
|
|
1282
|
+
: {}),
|
|
1283
|
+
...(prepared.reads
|
|
1284
|
+
? { reads: [...prepared.reads] }
|
|
1285
|
+
: {}),
|
|
1286
|
+
...(params.track ? { track: [...params.track] } : {}),
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
const rows = await resolveCreatedRows<T>({
|
|
1290
|
+
modelName: registeredModelName,
|
|
1291
|
+
ids,
|
|
1292
|
+
operationResults: receipt.operationResults,
|
|
1293
|
+
readRow: async (id) => {
|
|
1294
|
+
const read = await collaboration.readPoint(registeredModelName, id);
|
|
1295
|
+
return read.data as T | undefined;
|
|
1296
|
+
},
|
|
1297
|
+
});
|
|
1298
|
+
consumeReadSet(
|
|
1299
|
+
readSetContext,
|
|
1300
|
+
readSetClientIdentity,
|
|
1301
|
+
prepared.consumed,
|
|
1302
|
+
prepared.automaticCommit,
|
|
1303
|
+
);
|
|
1304
|
+
return rows;
|
|
1305
|
+
} catch (error) {
|
|
1306
|
+
abortReadSetCommit(readSetContext, prepared.automaticCommit);
|
|
1307
|
+
throw error;
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1210
1310
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1311
|
+
// `create` takes one row or a list of them. The list form is atomic and
|
|
1312
|
+
// is therefore NOT applied optimistically: see `createManyRows`.
|
|
1313
|
+
const createImpl = guardWrite(async (
|
|
1314
|
+
params:
|
|
1315
|
+
| HttpModelMutationParams<ModelCreateParams<T, C>>
|
|
1316
|
+
| HttpModelMutationParams<ModelCreateManyParams<C>>,
|
|
1317
|
+
): Promise<T | T[]> => {
|
|
1318
|
+
if (Array.isArray(params.data)) {
|
|
1319
|
+
return createManyRows(params as HttpModelMutationParams<ModelCreateManyParams<C>>);
|
|
1320
|
+
}
|
|
1321
|
+
const single = params as ModelCreateParams<T, C>;
|
|
1322
|
+
const id = resolveCreateId(single.id, single.data) ?? Model.generateId();
|
|
1323
|
+
const claim = single.claim;
|
|
1214
1324
|
let autoLease: Claim | undefined;
|
|
1215
1325
|
if (claim && !isClaimHandle(claim)) {
|
|
1216
1326
|
if (!collaboration) {
|
|
@@ -1258,7 +1368,7 @@ export function createModelProxy<T, C>(
|
|
|
1258
1368
|
});
|
|
1259
1369
|
let prepared: PreparedReadSet | undefined;
|
|
1260
1370
|
try {
|
|
1261
|
-
const resolved = preparedMutation(
|
|
1371
|
+
const resolved = preparedMutation(single);
|
|
1262
1372
|
prepared = resolved.prepared;
|
|
1263
1373
|
const effective: MutationOptions = {
|
|
1264
1374
|
...resolved.options,
|
|
@@ -1294,8 +1404,42 @@ export function createModelProxy<T, C>(
|
|
|
1294
1404
|
} finally {
|
|
1295
1405
|
await autoLease?.release?.().catch(() => {});
|
|
1296
1406
|
}
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
// Two public signatures over one implementation. A property arrow would
|
|
1410
|
+
// collapse them to their union, and a single create would start
|
|
1411
|
+
// resolving to `T | T[]` for every caller.
|
|
1412
|
+
function createRows(
|
|
1413
|
+
params: HttpModelMutationParams<ModelCreateParams<T, C>>,
|
|
1414
|
+
): Promise<T>;
|
|
1415
|
+
function createRows(
|
|
1416
|
+
params: HttpModelMutationParams<ModelCreateManyParams<C>>,
|
|
1417
|
+
): Promise<T[]>;
|
|
1418
|
+
function createRows(
|
|
1419
|
+
params:
|
|
1420
|
+
| HttpModelMutationParams<ModelCreateParams<T, C>>
|
|
1421
|
+
| HttpModelMutationParams<ModelCreateManyParams<C>>,
|
|
1422
|
+
): Promise<T | T[]> {
|
|
1423
|
+
return createImpl(params);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const operations: ModelOperations<T, C> = {
|
|
1427
|
+
local,
|
|
1428
|
+
|
|
1429
|
+
get,
|
|
1430
|
+
retrieve: get,
|
|
1431
|
+
|
|
1432
|
+
// No automatic scope enrolment on bulk `list`: that would subscribe to an
|
|
1433
|
+
// unbounded set of rows' entity groups.
|
|
1434
|
+
list,
|
|
1435
|
+
listAll: guard(async (options: ListAllOptions<T> = {}) => {
|
|
1436
|
+
const { maxPages, signal, ...readOptions } = options;
|
|
1437
|
+
signal?.throwIfAborted();
|
|
1438
|
+
return collectModelList(await list(readOptions), { maxPages, signal });
|
|
1297
1439
|
}),
|
|
1298
1440
|
|
|
1441
|
+
create: createRows,
|
|
1442
|
+
|
|
1299
1443
|
// `update` is overloaded — classic `update({ id, data })` + functional
|
|
1300
1444
|
// `update(id, current => next)`. The IIFE keeps the shared error-guard
|
|
1301
1445
|
// wrapping while exposing the two public signatures (a plain `guard(...)`
|
|
@@ -1400,6 +1544,10 @@ export function createModelProxy<T, C>(
|
|
|
1400
1544
|
});
|
|
1401
1545
|
}
|
|
1402
1546
|
const params = arg;
|
|
1547
|
+
// Named before anything reads it. Without this the row lookup below
|
|
1548
|
+
// reports `Entity not found: Model/undefined`, which sends the reader
|
|
1549
|
+
// looking for a missing row rather than at the unaddressed write.
|
|
1550
|
+
assertWriteTarget('update', registeredModelName, params.id);
|
|
1403
1551
|
const autoClaim =
|
|
1404
1552
|
params.claim && !isClaimHandle(params.claim) ? params.claim : null;
|
|
1405
1553
|
if (autoClaim) {
|
|
@@ -1495,6 +1643,9 @@ export function createModelProxy<T, C>(
|
|
|
1495
1643
|
})(),
|
|
1496
1644
|
|
|
1497
1645
|
delete: guardWrite(async (params: ModelDeleteParams<T, C>): Promise<void> => {
|
|
1646
|
+
// Before the idempotent "ensure absent" below can read this as a row that
|
|
1647
|
+
// is simply not here. An unaddressed delete is a mistake, not an absence.
|
|
1648
|
+
assertWriteTarget('delete', registeredModelName, params.id);
|
|
1498
1649
|
const autoClaim =
|
|
1499
1650
|
params.claim && !isClaimHandle(params.claim) ? params.claim : null;
|
|
1500
1651
|
if (autoClaim) {
|
|
@@ -589,6 +589,9 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
589
589
|
hydration,
|
|
590
590
|
{
|
|
591
591
|
createClaim: (claimOptions) => publicClaims.create(claimOptions),
|
|
592
|
+
// Lazily referenced: `commits` is declared below this loop, and this
|
|
593
|
+
// only runs when someone actually writes a batch.
|
|
594
|
+
commitBatch: (commitOptions) => commits.create(commitOptions),
|
|
592
595
|
readPoint,
|
|
593
596
|
createSnapshot: (modelKey, id) =>
|
|
594
597
|
createSnapshot({
|
|
@@ -701,7 +704,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
701
704
|
return { id: clientTxId, status: 'queued' };
|
|
702
705
|
}
|
|
703
706
|
|
|
704
|
-
const { lastSyncId, notifications, missingIds } =
|
|
707
|
+
const { lastSyncId, notifications, missingIds, operationResults } =
|
|
705
708
|
await queue.waitForCommitReceipt(clientTxId);
|
|
706
709
|
return {
|
|
707
710
|
id: clientTxId,
|
|
@@ -709,6 +712,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
709
712
|
lastSyncId,
|
|
710
713
|
...(notifications && notifications.length > 0 ? { notifications } : {}),
|
|
711
714
|
...(missingIds && missingIds.length > 0 ? { missingIds } : {}),
|
|
715
|
+
...(operationResults && operationResults.length > 0 ? { operationResults } : {}),
|
|
712
716
|
};
|
|
713
717
|
},
|
|
714
718
|
async get({ id }) {
|
|
@@ -95,6 +95,7 @@ import { AbloError, AbloConnectionError } from '@abloatai/transaction/errors';
|
|
|
95
95
|
...(receipt.correlationId ? { correlationId: receipt.correlationId } : {}),
|
|
96
96
|
...(receipt.notifications ? { notifications: receipt.notifications } : {}),
|
|
97
97
|
...(receipt.missingIds ? { missingIds: receipt.missingIds } : {}),
|
|
98
|
+
...(receipt.operationResults ? { operationResults: receipt.operationResults } : {}),
|
|
98
99
|
});
|
|
99
100
|
}
|
|
100
101
|
if (!ws.sendCommit) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Model } from './Model.js';
|
|
2
|
+
import type { BootstrapData } from './sync/BootstrapFetcher.js';
|
|
3
|
+
import type { QueuedMutation } from './transactions/mutations/MutationQueue.js';
|
|
4
|
+
import type { CommitTransaction } from './transactions/mutations/commitLane.js';
|
|
5
|
+
|
|
6
|
+
export interface SyncObserver {
|
|
7
|
+
onSync?: (event: SyncEvent) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SyncEvent {
|
|
11
|
+
type: 'create' | 'update' | 'delete' | 'archive' | 'rollback';
|
|
12
|
+
modelType: string;
|
|
13
|
+
model?: Model;
|
|
14
|
+
modelId?: string;
|
|
15
|
+
transactionType?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SyncState {
|
|
19
|
+
connectionState: 'connected' | 'disconnected' | 'connecting';
|
|
20
|
+
pendingMutations: number;
|
|
21
|
+
lastSyncAt?: Date;
|
|
22
|
+
error?: Error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RehydrationStats {
|
|
26
|
+
added: number;
|
|
27
|
+
updated: number;
|
|
28
|
+
removed: number;
|
|
29
|
+
skipped: number;
|
|
30
|
+
healed: number;
|
|
31
|
+
elapsedMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type EventHandler = () => void;
|
|
35
|
+
|
|
36
|
+
/** The bootstrap fields applied to the local object pool. */
|
|
37
|
+
export type BootstrapSnapshot = Pick<BootstrapData, 'models' | 'failedModels'> &
|
|
38
|
+
Partial<Pick<BootstrapData, 'lastSyncId'>>;
|
|
39
|
+
|
|
40
|
+
/** A completed queued mutation or explicit commit. */
|
|
41
|
+
export type CompletedTransaction =
|
|
42
|
+
| (Pick<QueuedMutation, 'id' | 'modelId' | 'syncIdNeededForCompletion'> & {
|
|
43
|
+
lastSyncId?: undefined;
|
|
44
|
+
operations?: undefined;
|
|
45
|
+
})
|
|
46
|
+
| (Pick<CommitTransaction, 'id' | 'lastSyncId' | 'operations'> & {
|
|
47
|
+
modelId?: undefined;
|
|
48
|
+
syncIdNeededForCompletion?: undefined;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Normalize an untyped server timestamp for last-write-wins comparison. */
|
|
52
|
+
export function toEpochMs(value: unknown): number {
|
|
53
|
+
if (!value) return 0;
|
|
54
|
+
if (value instanceof Date) return value.getTime();
|
|
55
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
56
|
+
return new Date(value).getTime();
|
|
57
|
+
}
|
|
58
|
+
return 0;
|
|
59
|
+
}
|