@abloatai/humans 0.55.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.
Files changed (37) hide show
  1. package/dist/local/Database.js +2 -2
  2. package/dist/local/Model.js +1 -1
  3. package/dist/local/SyncClient.d.ts +3 -34
  4. package/dist/local/SyncClient.js +1 -16
  5. package/dist/local/client/createModelProxy.d.ts +13 -1
  6. package/dist/local/client/createModelProxy.js +159 -80
  7. package/dist/local/client/options.d.ts +7 -4
  8. package/dist/local/client/reactiveEngine.js +5 -1
  9. package/dist/local/client/wsMutationExecutor.js +1 -0
  10. package/dist/local/logPosition.d.ts +13 -2
  11. package/dist/local/logPosition.js +17 -5
  12. package/dist/local/sync/SyncWebSocket.js +16 -0
  13. package/dist/local/syncClientTypes.d.ts +41 -0
  14. package/dist/local/syncClientTypes.js +11 -0
  15. package/dist/local/transactions/mutations/MutationQueue.d.ts +2 -0
  16. package/dist/local/transactions/mutations/MutationQueue.js +54 -69
  17. package/dist/local/transactions/mutations/commitLane.d.ts +4 -1
  18. package/dist/local/transactions/mutations/commitLane.js +12 -3
  19. package/dist/local/transactions/mutations/mutationInput.d.ts +40 -0
  20. package/dist/local/transactions/mutations/mutationInput.js +53 -0
  21. package/dist/surface.d.ts +1 -1
  22. package/dist/surface.js +1 -0
  23. package/package.json +2 -2
  24. package/src/local/Database.ts +2 -3
  25. package/src/local/Model.ts +1 -1
  26. package/src/local/SyncClient.ts +12 -72
  27. package/src/local/client/createModelProxy.ts +164 -13
  28. package/src/local/client/options.ts +7 -4
  29. package/src/local/client/reactiveEngine.ts +5 -1
  30. package/src/local/client/wsMutationExecutor.ts +1 -0
  31. package/src/local/logPosition.ts +19 -6
  32. package/src/local/sync/SyncWebSocket.ts +15 -0
  33. package/src/local/syncClientTypes.ts +59 -0
  34. package/src/local/transactions/mutations/MutationQueue.ts +61 -88
  35. package/src/local/transactions/mutations/commitLane.ts +23 -5
  36. package/src/local/transactions/mutations/mutationInput.ts +69 -0
  37. 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 { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
41
- import { modelList, type ModelList } from '@abloatai/transaction/resources/httpResources';
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
- const operations: ModelOperations<T, C> = {
1202
- local,
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
- get,
1205
- retrieve: get,
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
- // No automatic scope enrolment on bulk `list`: that would subscribe to an
1208
- // unbounded set of rows' entity groups.
1209
- list,
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
- create: guardWrite(async (params: ModelCreateParams<T, C>): Promise<T> => {
1212
- const id = params.id ?? Model.generateId();
1213
- const claim = params.claim;
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(params);
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) {
@@ -90,15 +90,18 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
90
90
  /**
91
91
  * Pins this client to one Ablo project. During `ready()` the server resolves
92
92
  * the API key's actual project and the client refuses to start when it differs.
93
- * Defaults to `ABLO_PROJECT_ID`; `ablo dev` writes that value beside the key.
94
- * This is an assertion, never a routing selector the key remains authoritative.
93
+ * Defaults to `ABLO_PROJECT_ID`. This is an assertion, never a routing
94
+ * selector the key remains authoritative and already names its own project,
95
+ * so leave this unset unless one deployment can be handed keys for more than
96
+ * one project and you want the mismatch to fail loudly.
95
97
  */
96
98
  projectId?: string | null | undefined;
97
99
 
98
100
  /**
99
101
  * Pins this client to one immutable Ablo branch. Defaults to
100
- * `ABLO_BRANCH_ID`; `ablo dev` writes it beside the branch key. Like
101
- * `projectId`, this is a startup assertion and never selects a branch.
102
+ * `ABLO_BRANCH_ID`. Like `projectId`, this is a startup assertion that never
103
+ * selects a branch, and is worth setting only where a key for the wrong
104
+ * environment could reach this process.
102
105
  */
103
106
  branchId?: string | null | undefined;
104
107
 
@@ -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) {
@@ -1,12 +1,25 @@
1
+ /**
2
+ * The three log positions a connected client owns.
3
+ *
4
+ * Each field is a {@link logPositionSchema}, the one position type, and the
5
+ * field name says who is claiming what: `applied` is what arrival processed,
6
+ * `persisted` is what local storage durably holds IN DELIVERED ORDER, and
7
+ * `acked` is what the server has been told. They are the same kind of number
8
+ * as the server's heads and cursors, and deliberately not comparable to them
9
+ * without saying which owner you mean. See the owner table on
10
+ * `@abloatai/transaction/syncLog/contract`.
11
+ */
12
+
1
13
  import { z } from 'zod';
14
+ import { logPositionSchema } from '@abloatai/transaction/syncLog/contract';
2
15
 
3
- export const logPositionSchema = z.object({
4
- persisted: z.number().int().nonnegative(),
5
- applied: z.number().int().nonnegative(),
6
- acked: z.number().int().nonnegative(),
16
+ export const logPositionSnapshotSchema = z.object({
17
+ persisted: logPositionSchema,
18
+ applied: logPositionSchema,
19
+ acked: logPositionSchema,
7
20
  });
8
21
 
9
- export type LogPositionSnapshot = z.infer<typeof logPositionSchema>;
22
+ export type LogPositionSnapshot = z.infer<typeof logPositionSnapshotSchema>;
10
23
 
11
24
  export interface LogPositionPort {
12
25
  readonly persisted: number;
@@ -21,7 +34,7 @@ export interface LogPositionPort {
21
34
  }
22
35
 
23
36
  export function parseLogPosition(value: unknown): LogPositionSnapshot | null {
24
- const result = logPositionSchema.safeParse(value);
37
+ const result = logPositionSnapshotSchema.safeParse(value);
25
38
  return result.success ? result.data : null;
26
39
  }
27
40
 
@@ -531,6 +531,21 @@ export class SyncWebSocket<
531
531
  }
532
532
  );
533
533
  });
534
+ } else if (serverHead > this.cursor.lastSyncId) {
535
+ // The other direction: we are behind the server head and the server
536
+ // sent nothing. That is not a stall, it is proof. An empty response
537
+ // means the server walked the log up to `currentSyncId` under this
538
+ // client's own project and capability scope and found nothing we are
539
+ // entitled to, and it measured that head through the settled barrier,
540
+ // so no lower id can still be in flight. Adopting it is therefore
541
+ // exact, not optimistic.
542
+ //
543
+ // Without this, a client on a plane whose head moves for reasons it
544
+ // cannot see — another project, another sync group, a model outside
545
+ // its allowlist — never converges. Its cursor sticks, every catch-up
546
+ // poll finds a gap, and each of those polls takes the plane's advisory
547
+ // lock to read the settled head. The cost lands on the write path.
548
+ this.cursor.lastSyncId = serverHead;
534
549
  }
535
550
  }
536
551
 
@@ -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
+ }