@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.
@@ -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) {
@@ -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
+ }