@abloatai/ablo 0.9.2 → 0.9.4

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 (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +47 -27
  4. package/dist/BaseSyncedStore.d.ts +7 -38
  5. package/dist/BaseSyncedStore.js +20 -67
  6. package/dist/Database.js +7 -1
  7. package/dist/NetworkMonitor.js +4 -1
  8. package/dist/SyncClient.d.ts +18 -5
  9. package/dist/SyncClient.js +72 -1
  10. package/dist/SyncEngineContext.js +5 -1
  11. package/dist/auth/index.js +3 -1
  12. package/dist/cli.cjs +282241 -0
  13. package/dist/client/Ablo.d.ts +12 -3
  14. package/dist/client/Ablo.js +36 -3
  15. package/dist/client/ApiClient.js +39 -6
  16. package/dist/client/auth.d.ts +1 -1
  17. package/dist/client/auth.js +14 -5
  18. package/dist/client/createInternalComponents.js +1 -1
  19. package/dist/client/createModelProxy.d.ts +9 -0
  20. package/dist/client/createModelProxy.js +34 -10
  21. package/dist/client/persistence.d.ts +6 -1
  22. package/dist/client/persistence.js +1 -1
  23. package/dist/client/registerDataSource.d.ts +4 -4
  24. package/dist/client/registerDataSource.js +39 -31
  25. package/dist/client/writeOptionsSchema.d.ts +50 -0
  26. package/dist/client/writeOptionsSchema.js +57 -0
  27. package/dist/core/index.d.ts +18 -26
  28. package/dist/core/index.js +22 -46
  29. package/dist/errorCodes.d.ts +13 -0
  30. package/dist/errorCodes.js +16 -1
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +7 -0
  33. package/dist/interfaces/index.d.ts +10 -0
  34. package/dist/mutators/UndoManager.d.ts +31 -5
  35. package/dist/mutators/UndoManager.js +113 -1
  36. package/dist/schema/ddl.js +12 -3
  37. package/dist/schema/field.js +2 -1
  38. package/dist/schema/model.d.ts +9 -7
  39. package/dist/schema/model.js +1 -1
  40. package/dist/schema/schema.js +7 -1
  41. package/dist/schema/serialize.js +2 -1
  42. package/dist/server/storage-mode.d.ts +7 -0
  43. package/dist/server/storage-mode.js +6 -0
  44. package/dist/source/adapters/drizzle.js +3 -2
  45. package/dist/source/adapters/kysely.d.ts +68 -0
  46. package/dist/source/adapters/kysely.js +210 -0
  47. package/dist/source/adapters/memory.js +2 -1
  48. package/dist/source/adapters/prisma.js +3 -2
  49. package/dist/source/index.js +2 -1
  50. package/dist/sync/syncPosition.d.ts +78 -0
  51. package/dist/sync/syncPosition.js +111 -0
  52. package/dist/transactions/TransactionQueue.d.ts +22 -8
  53. package/dist/transactions/TransactionQueue.js +76 -34
  54. package/dist/utils/duration.js +3 -2
  55. package/docs/api-keys.md +4 -4
  56. package/docs/cli.md +6 -6
  57. package/docs/client-behavior.md +1 -1
  58. package/docs/data-sources.md +61 -42
  59. package/docs/guarantees.md +2 -2
  60. package/docs/index.md +2 -2
  61. package/docs/integration-guide.md +4 -7
  62. package/docs/mcp.md +1 -1
  63. package/docs/quickstart.md +84 -37
  64. package/docs/schema-contract.md +2 -4
  65. package/llms-full.txt +365 -0
  66. package/llms.txt +14 -9
  67. package/package.json +26 -4
@@ -121,7 +121,7 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
121
121
  * Local persistence mode. Pass `indexeddb` only when you want offline
122
122
  * queueing and a reload-surviving browser cache.
123
123
  *
124
- * @default 'volatile'
124
+ * @default 'memory'
125
125
  */
126
126
  persistence?: AbloPersistence;
127
127
  /**
@@ -250,7 +250,7 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
250
250
  /** ObjectPool size limit (default: 10000) */
251
251
  maxPoolSize?: number;
252
252
  /**
253
- * Local persistence mode. Defaults to `volatile` so Ablo behaves like a
253
+ * Local persistence mode. Defaults to `memory` so Ablo behaves like a
254
254
  * point solution for shared state instead of silently bolting IndexedDB
255
255
  * durability onto every browser consumer.
256
256
  *
@@ -262,7 +262,7 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
262
262
  offline?: boolean;
263
263
  /**
264
264
  * @deprecated Internal/testing escape hatch. Use `persistence` in
265
- * production code. `true` maps to `volatile`; `false` maps to
265
+ * production code. `true` maps to `memory`; `false` maps to
266
266
  * `indexeddb` in browsers.
267
267
  */
268
268
  inMemory?: boolean;
@@ -463,6 +463,15 @@ export interface CommitCreateOptions {
463
463
  readonly idempotencyKey?: string | null;
464
464
  readonly readAt?: number | null;
465
465
  readonly onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
466
+ /**
467
+ * A claim handle from `ablo.<model>.claim({ id })` (or the HTTP claim
468
+ * surface). Same vocabulary as the per-model writes: the handle's
469
+ * snapshot watermark becomes the batch `readAt` default and `onStale`
470
+ * defaults to `'reject'`, so a commit that follows a claim is guarded
471
+ * against concurrent edits without re-stating the watermark by hand.
472
+ * Explicit `readAt`/`onStale` on the options win.
473
+ */
474
+ readonly claim?: ClaimHandle<Record<string, unknown>> | null;
466
475
  readonly operation?: CommitOperationInput;
467
476
  readonly operations?: readonly CommitOperationInput[];
468
477
  readonly wait?: CommitWait;
@@ -41,6 +41,7 @@ import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue,
41
41
  import { registerDataSource } from './registerDataSource.js';
42
42
  import { shouldUseInMemoryPersistence, } from './persistence.js';
43
43
  import { createModelProxy } from './createModelProxy.js';
44
+ import { assertWriteOptions } from './writeOptionsSchema.js';
44
45
  // ── Config derivation from schema ─────────────────────────────────────────
45
46
  /**
46
47
  * Compute a create-priority map from schema `belongsTo` relations using
@@ -1065,6 +1066,12 @@ export function Ablo(options) {
1065
1066
  return intent;
1066
1067
  return intent?.id;
1067
1068
  }
1069
+ function isClaimHandleValue(value) {
1070
+ return (typeof value === 'object' &&
1071
+ value !== null &&
1072
+ value.object === 'claim' &&
1073
+ typeof value.claimId === 'string');
1074
+ }
1068
1075
  function normalizeCommitOperation(op, defaults) {
1069
1076
  const model = op.model ?? op.target?.model;
1070
1077
  if (!model) {
@@ -1300,7 +1307,14 @@ export function Ablo(options) {
1300
1307
  createSnapshot: (modelKey, id) => createSnapshot({
1301
1308
  pool: objectPool,
1302
1309
  transport: store.getSyncWebSocket(),
1303
- getLastSyncId: () => store.getSyncWebSocket()?.getLastSyncId() ?? store.lastSyncId ?? 0,
1310
+ // `position.readFloor` is THE value claims/snapshots stamp as
1311
+ // `readAt` (max of the pool-applied cursor and the acked
1312
+ // watermark for our own writes — see sync/syncPosition.ts).
1313
+ // Stamping a bare stream cursor made a claim taken right after
1314
+ // an ack-confirmed write stale against that write's own delta.
1315
+ // The socket/store cursors are persistence-gated and therefore
1316
+ // never ahead of `applied` — no extra max() needed here.
1317
+ getLastSyncId: () => syncClient.position.readFloor,
1304
1318
  entities: { [modelKey]: id },
1305
1319
  }),
1306
1320
  queue: (target) => publicIntents.queueFor({ type: target.model, id: target.id }),
@@ -1341,10 +1355,26 @@ export function Ablo(options) {
1341
1355
  const commits = {
1342
1356
  async create(commitOptions) {
1343
1357
  await ready();
1358
+ // Same runtime contract as the per-model writes — one schema.
1359
+ assertWriteOptions({
1360
+ idempotencyKey: commitOptions.idempotencyKey,
1361
+ readAt: commitOptions.readAt,
1362
+ onStale: commitOptions.onStale,
1363
+ wait: commitOptions.wait,
1364
+ intent: commitOptions.intent,
1365
+ }, 'commits.create');
1344
1366
  const clientTxId = createClientTxId(commitOptions.idempotencyKey);
1345
- const operations = normalizeCommitOperations(commitOptions);
1367
+ // A claim handle supplies the batch stale-guard defaults — same
1368
+ // semantics as `ablo.<model>.update({ id, data, claim })`, so the
1369
+ // two write doors speak one claim vocabulary. Explicit options win.
1370
+ const claim = commitOptions.claim ?? null;
1371
+ const operations = normalizeCommitOperations({
1372
+ ...commitOptions,
1373
+ readAt: commitOptions.readAt ?? claim?.readAt ?? null,
1374
+ onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
1375
+ });
1346
1376
  const wait = commitOptions.wait ?? 'confirmed';
1347
- const intentId = normalizeIntentId(commitOptions.intent);
1377
+ const intentId = normalizeIntentId(commitOptions.intent) ?? claim?.claimId;
1348
1378
  void intentId; // The current wire clears intents by entity after commit.
1349
1379
  // Route through the TransactionQueue's commit lane so the call
1350
1380
  // tolerates WS disconnects: the envelope stays in memory until
@@ -1422,6 +1452,7 @@ export function Ablo(options) {
1422
1452
  idempotencyKey: params.idempotencyKey,
1423
1453
  readAt: params.readAt,
1424
1454
  onStale: params.onStale,
1455
+ ...(isClaimHandleValue(params.claim) ? { claim: params.claim } : {}),
1425
1456
  wait: params.wait,
1426
1457
  operations: [
1427
1458
  {
@@ -1440,6 +1471,7 @@ export function Ablo(options) {
1440
1471
  idempotencyKey: params.idempotencyKey,
1441
1472
  readAt: params.readAt,
1442
1473
  onStale: params.onStale,
1474
+ ...(isClaimHandleValue(params.claim) ? { claim: params.claim } : {}),
1443
1475
  wait: params.wait,
1444
1476
  operations: [
1445
1477
  {
@@ -1458,6 +1490,7 @@ export function Ablo(options) {
1458
1490
  idempotencyKey: params.idempotencyKey,
1459
1491
  readAt: params.readAt,
1460
1492
  onStale: params.onStale,
1493
+ ...(isClaimHandleValue(params.claim) ? { claim: params.claim } : {}),
1461
1494
  wait: params.wait,
1462
1495
  operations: [
1463
1496
  {
@@ -8,6 +8,7 @@
8
8
  import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, translateHttpError, } from '../errors.js';
9
9
  import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, } from './auth.js';
10
10
  import { toSeconds } from '../utils/duration.js';
11
+ import { assertWriteOptions } from './writeOptionsSchema.js';
11
12
  const DEFAULT_AGENT_LEASE = '10m';
12
13
  export function createProtocolClient(options) {
13
14
  const env = readProcessEnv();
@@ -223,15 +224,30 @@ export function createProtocolClient(options) {
223
224
  }
224
225
  const commits = {
225
226
  async create(commitOptions) {
227
+ // Same runtime contract as every other write door — one schema.
228
+ assertWriteOptions({
229
+ idempotencyKey: commitOptions.idempotencyKey,
230
+ readAt: commitOptions.readAt,
231
+ onStale: commitOptions.onStale,
232
+ wait: commitOptions.wait,
233
+ intent: commitOptions.intent,
234
+ }, 'commits.create');
226
235
  const clientTxId = createClientTxId(commitOptions.idempotencyKey);
227
- const operations = normalizeCommitOperations(commitOptions);
236
+ // Same claim vocabulary as the WS client's `commits.create`: a handle
237
+ // supplies the batch stale-guard defaults; explicit options win.
238
+ const claim = commitOptions.claim ?? null;
239
+ const operations = normalizeCommitOperations({
240
+ ...commitOptions,
241
+ readAt: commitOptions.readAt ?? claim?.readAt ?? null,
242
+ onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
243
+ });
228
244
  const body = await requestJson('/v1/commits', {
229
245
  method: 'POST',
230
246
  idempotencyKey: clientTxId,
231
247
  body: JSON.stringify({
232
248
  clientTxId,
233
249
  idempotencyKey: clientTxId,
234
- intent: normalizeIntentId(commitOptions.intent),
250
+ intent: normalizeIntentId(commitOptions.intent) ?? claim?.claimId,
235
251
  operations,
236
252
  }),
237
253
  });
@@ -435,17 +451,33 @@ export function createProtocolClient(options) {
435
451
  * envelopes — this helper is the one-op, one-record path only.
436
452
  */
437
453
  async function mutateModel(action, modelName, id, data, options) {
454
+ assertWriteOptions(options && {
455
+ idempotencyKey: options.idempotencyKey,
456
+ readAt: options.readAt,
457
+ onStale: options.onStale,
458
+ wait: options.wait,
459
+ intent: options.intent,
460
+ }, `${modelName} ${action}`);
438
461
  const clientTxId = createClientTxId(options?.idempotencyKey);
439
462
  const encModel = encodeURIComponent(modelName);
440
463
  const path = action === 'create'
441
464
  ? `/v1/models/${encModel}`
442
465
  : `/v1/models/${encModel}/${encodeURIComponent(id)}`;
443
466
  const method = action === 'create' ? 'POST' : action === 'update' ? 'PATCH' : 'DELETE';
467
+ // A carried claim handle supplies the stale-guard defaults — one claim
468
+ // vocabulary across the WS proxy, `commits.create`, and these routes.
469
+ const claimHandle = typeof options?.claim === 'object' &&
470
+ options?.claim !== null &&
471
+ options.claim.object === 'claim' &&
472
+ typeof options.claim.claimId === 'string'
473
+ ? options.claim
474
+ : undefined;
475
+ const readAt = options?.readAt ?? claimHandle?.readAt;
444
476
  const requestBody = {
445
477
  idempotencyKey: clientTxId,
446
- intent: normalizeIntentId(options?.intent),
447
- onStale: options?.onStale,
448
- readAt: options?.readAt,
478
+ intent: normalizeIntentId(options?.intent) ?? claimHandle?.claimId,
479
+ onStale: options?.onStale ?? (claimHandle?.readAt !== undefined ? 'reject' : undefined),
480
+ readAt,
449
481
  };
450
482
  if (action === 'create')
451
483
  requestBody.id = id;
@@ -503,11 +535,12 @@ export function createProtocolClient(options) {
503
535
  const releaseClaim = (params) => requestJson(claimPath(isClaimHandle(params) ? params.target.id : params.id), { method: 'DELETE' }).then(() => undefined);
504
536
  async function claimImpl(params) {
505
537
  const claimId = await acquireClaim(params);
506
- const { data } = await retrieveModel(name, { id: params.id });
538
+ const { data, stamp } = await retrieveModel(name, { id: params.id });
507
539
  const release = () => releaseClaim(params);
508
540
  return {
509
541
  object: 'claim',
510
542
  claimId,
543
+ readAt: stamp,
511
544
  target: {
512
545
  model: name,
513
546
  id: params.id,
@@ -53,7 +53,7 @@ export declare function resolveAuthToken(input: AuthResolveInput): string | null
53
53
  export declare function resolveDatabaseUrl(input: AuthResolveInput): string | null;
54
54
  export declare const ABLO_HOSTED_API_DOMAIN = "api.abloatai.com";
55
55
  export declare const ABLO_HOSTED_HTTP_BASE_URL = "https://api.abloatai.com";
56
- export declare const ABLO_DEFAULT_BASE_URL = "wss://api.abloatai.com";
56
+ export declare const ABLO_DEFAULT_BASE_URL = "https://api.abloatai.com";
57
57
  /**
58
58
  * Normalize old hosted aliases to the public API domain. Self-hosted/custom
59
59
  * URLs pass through unchanged; only first-party legacy hosts are rewritten.
@@ -40,7 +40,7 @@ export function resolveDatabaseUrl(input) {
40
40
  }
41
41
  export const ABLO_HOSTED_API_DOMAIN = 'api.abloatai.com';
42
42
  export const ABLO_HOSTED_HTTP_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
43
- export const ABLO_DEFAULT_BASE_URL = `wss://${ABLO_HOSTED_API_DOMAIN}`;
43
+ export const ABLO_DEFAULT_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
44
44
  const LEGACY_HOSTED_API_HOSTS = new Set([
45
45
  'mesh.ablo.finance',
46
46
  'mesh-staging.ablo.finance',
@@ -65,13 +65,22 @@ export function normalizeAbloHostedBaseUrl(rawUrl) {
65
65
  const schemed = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
66
66
  try {
67
67
  const url = new URL(schemed);
68
- if (!LEGACY_HOSTED_API_HOSTS.has(url.hostname))
69
- return schemed.replace(/\/+$/, '');
68
+ // Canonicalize the scheme to the HTTP family — the WHATWG WebSocket
69
+ // model: accept all four schemes (`http`/`https`/`ws`/`wss`), normalize
70
+ // ONCE at the entry point, and let each layer derive its own protocol
71
+ // (the socket layer maps http→ws / https→wss; fetch uses it as-is).
72
+ // Before this, a `ws://` baseURL reached HTTP consumers un-normalized
73
+ // and the client wedged at startup instead of connecting.
74
+ if (url.protocol === 'ws:')
75
+ url.protocol = 'http:';
76
+ if (url.protocol === 'wss:')
77
+ url.protocol = 'https:';
78
+ if (!LEGACY_HOSTED_API_HOSTS.has(url.hostname)) {
79
+ return url.toString().replace(/\/+$/, '');
80
+ }
70
81
  url.hostname = ABLO_HOSTED_API_DOMAIN;
71
82
  if (url.protocol === 'http:')
72
83
  url.protocol = 'https:';
73
- if (url.protocol === 'ws:')
74
- url.protocol = 'wss:';
75
84
  return url.toString().replace(/\/+$/, '');
76
85
  }
77
86
  catch {
@@ -42,7 +42,7 @@ export function createInternalComponents(input) {
42
42
  const database = new Database(modelRegistry, bootstrapHelper, {
43
43
  // Point-solution default: no browser-local durable store unless the
44
44
  // caller explicitly asks for it. Node/edge runtimes always use the
45
- // volatile store because IndexedDB is unavailable there.
45
+ // in-memory store because IndexedDB is unavailable there.
46
46
  inMemory: shouldUseInMemoryPersistence(options),
47
47
  });
48
48
  const syncClient = new SyncClient(objectPool, database);
@@ -212,6 +212,15 @@ export interface ClaimReorderParams<T = Record<string, unknown>> extends ClaimLo
212
212
  export interface ClaimHandle<T = Record<string, unknown>> extends AsyncDisposable {
213
213
  readonly object: 'claim';
214
214
  readonly claimId: string;
215
+ /**
216
+ * Sync watermark of the held snapshot (`data` was read at this stamp).
217
+ * Writes that carry the handle — `update({ id, data, claim })` or
218
+ * `commits.create({ claim, ... })` — use it as the `readAt` stale guard,
219
+ * so a concurrent commit between snapshot and write is rejected instead
220
+ * of clobbered. Optional for wire/duck-type compat with externally
221
+ * constructed handles.
222
+ */
223
+ readonly readAt?: number;
215
224
  readonly target: {
216
225
  readonly model: string;
217
226
  readonly id: string;
@@ -17,6 +17,7 @@
17
17
  import { autorun } from 'mobx';
18
18
  import { AbloClaimedError, AbloValidationError, toAbloError, } from '../errors.js';
19
19
  import { Model, modelAsRow } from '../Model.js';
20
+ import { assertWriteOptions } from './writeOptionsSchema.js';
20
21
  import { ModelScope } from '../types/index.js';
21
22
  const modelClientMeta = new WeakMap();
22
23
  export function getModelClientMeta(modelClient) {
@@ -76,6 +77,10 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
76
77
  };
77
78
  const mutationOptions = (params) => {
78
79
  const { id: _id, data: _data, claim: _claim, ...rest } = params;
80
+ // THE write-options schema — runtime twin of the compile-time params.
81
+ // Catches plain-JS callers (`onStale: 'rejct'`) at the call site with
82
+ // a typed error instead of a silent no-op or a server 400.
83
+ assertWriteOptions(rest, `${schemaKey} write`);
79
84
  return rest;
80
85
  };
81
86
  const releaseClaim = async (id) => {
@@ -154,6 +159,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
154
159
  return {
155
160
  object: 'claim',
156
161
  claimId: lease.id,
162
+ readAt: snapshot.stamp,
157
163
  target,
158
164
  action: options?.action ?? 'editing',
159
165
  ...(options?.description ? { description: options.description } : {}),
@@ -235,8 +241,6 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
235
241
  return this.getAll(options).length;
236
242
  },
237
243
  create: guard(async (params) => {
238
- // TODO(options-persistence): stash `params` alongside the
239
- // queued transaction so idempotencyKey survives offline flush.
240
244
  const id = params.id ?? Model.generateId();
241
245
  const opts = mutationOptions(params);
242
246
  const claim = params.claim;
@@ -260,8 +264,15 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
260
264
  maxQueueDepth: claim.maxQueueDepth,
261
265
  });
262
266
  }
267
+ // Default `organizationId` from the client's identity exactly like the
268
+ // mutator path (`buildModelForCreate`) — without this, a caller that
269
+ // omits it creates an org-unscoped row on one write door but not the
270
+ // other. An explicit value in `data` still wins via the spread.
271
+ const orgDefault = params.data.organizationId ??
272
+ syncClient.getOrganizationId();
263
273
  const model = new ModelClass({
264
274
  id,
275
+ ...(orgDefault != null ? { organizationId: orgDefault } : {}),
265
276
  ...params.data,
266
277
  createdAt: new Date(),
267
278
  updatedAt: new Date(),
@@ -299,9 +310,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
299
310
  // watermark + lease so it's stale-rejected and attributed to the claim.
300
311
  const claimed = activeClaims.get(id);
301
312
  const opts = mutationOptions(params);
302
- const handleIntent = isClaimHandle(params.claim)
303
- ? { id: params.claim.claimId }
304
- : undefined;
313
+ const handle = isClaimHandle(params.claim) ? params.claim : undefined;
305
314
  const effective = claimed
306
315
  ? {
307
316
  wait: 'confirmed',
@@ -311,8 +320,18 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
311
320
  ...opts,
312
321
  }
313
322
  : {
323
+ // A carried handle engages the same stale guard as a claim this
324
+ // proxy took itself — the watermark rides on the handle, so it
325
+ // works across clients (HTTP-minted handles included).
326
+ ...(handle?.readAt !== undefined
327
+ ? {
328
+ wait: 'confirmed',
329
+ readAt: handle.readAt,
330
+ onStale: 'reject',
331
+ }
332
+ : {}),
314
333
  ...opts,
315
- ...(handleIntent ? { intent: handleIntent } : {}),
334
+ ...(handle ? { intent: { id: handle.claimId } } : {}),
316
335
  };
317
336
  // Local user update: `applyChanges` keeps change tracking ON so
318
337
  // the edited fields land in `modifiedProperties` and actually get
@@ -341,9 +360,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
341
360
  throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
342
361
  const claimed = activeClaims.get(id);
343
362
  const opts = mutationOptions(params);
344
- const handleIntent = isClaimHandle(params.claim)
345
- ? { id: params.claim.claimId }
346
- : undefined;
363
+ const handle = isClaimHandle(params.claim) ? params.claim : undefined;
347
364
  const effective = claimed
348
365
  ? {
349
366
  wait: 'confirmed',
@@ -353,8 +370,15 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
353
370
  ...opts,
354
371
  }
355
372
  : {
373
+ ...(handle?.readAt !== undefined
374
+ ? {
375
+ wait: 'confirmed',
376
+ readAt: handle.readAt,
377
+ onStale: 'reject',
378
+ }
379
+ : {}),
356
380
  ...opts,
357
- ...(handleIntent ? { intent: handleIntent } : {}),
381
+ ...(handle ? { intent: { id: handle.claimId } } : {}),
358
382
  };
359
383
  syncClient.delete(model, effective);
360
384
  await waitForMutation(model, effective);
@@ -1,4 +1,9 @@
1
- export type AbloPersistence = 'volatile' | 'indexeddb';
1
+ /**
2
+ * Local persistence modes. `'memory'` (the default everywhere outside the
3
+ * browser) keeps the local graph in process memory; `'indexeddb'` adds
4
+ * offline queueing and a reload-surviving cache in the browser.
5
+ */
6
+ export type AbloPersistence = 'memory' | 'indexeddb';
2
7
  export interface PersistenceOptions {
3
8
  readonly persistence?: AbloPersistence | undefined;
4
9
  readonly inMemory?: boolean | undefined;
@@ -2,7 +2,7 @@ export function shouldUseInMemoryPersistence(options) {
2
2
  if (typeof window === 'undefined')
3
3
  return true;
4
4
  if (options.persistence)
5
- return options.persistence === 'volatile';
5
+ return options.persistence === 'memory';
6
6
  if (typeof options.inMemory === 'boolean')
7
7
  return options.inMemory;
8
8
  if (options.offline === true)
@@ -11,9 +11,9 @@ export interface RegisterDataSourceInput {
11
11
  readonly fetchImpl?: typeof fetch;
12
12
  }
13
13
  /**
14
- * POST the connection string to the self-serve direct connector route. Resolves
15
- * on success (the org is now a dedicated tenant pointed at this DB); throws an
16
- * `AbloError` with `datasource_registration_failed` otherwise so `ready()`
17
- * surfaces it instead of silently bootstrapping against the wrong store.
14
+ * POST the connection string to the self-serve datasource route. Resolves on
15
+ * success (the org's data plane now points at this DB); throws an `AbloError`
16
+ * with `datasource_registration_failed` otherwise so `ready()` surfaces it
17
+ * instead of silently bootstrapping against the wrong store.
18
18
  */
19
19
  export declare function registerDataSource(input: RegisterDataSourceInput): Promise<void>;
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Self-serve direct-Postgres connector registration.
3
- *
4
- * Historical note: this module name says "DataSource", but this path registers
5
- * a direct database URL. It is not the signed `dataSource(...)` endpoint path.
2
+ * Self-serve direct-kind datasource registration.
6
3
  *
7
4
  * When a client is constructed with `databaseUrl`, the SDK registers that
8
5
  * connection string BEFORE bootstrap so the server resolves the org's data plane
9
- * to that direct connector.
6
+ * to that direct connection.
7
+ *
8
+ * Targets the unified `POST /v1/datasources` resource; on a 404 (an older
9
+ * server without the unified route) it falls back to the legacy
10
+ * `POST /v1/datasource` alias so an SDK upgrade never strands registration.
10
11
  *
11
12
  * The org is derived server-side from the API key — the caller never sends an
12
13
  * organization id. The connection string is sent once over TLS and is never
@@ -15,36 +16,43 @@
15
16
  */
16
17
  import { AbloError } from '../errors.js';
17
18
  /**
18
- * POST the connection string to the self-serve direct connector route. Resolves
19
- * on success (the org is now a dedicated tenant pointed at this DB); throws an
20
- * `AbloError` with `datasource_registration_failed` otherwise so `ready()`
21
- * surfaces it instead of silently bootstrapping against the wrong store.
19
+ * POST the connection string to the self-serve datasource route. Resolves on
20
+ * success (the org's data plane now points at this DB); throws an `AbloError`
21
+ * with `datasource_registration_failed` otherwise so `ready()` surfaces it
22
+ * instead of silently bootstrapping against the wrong store.
22
23
  */
23
24
  export async function registerDataSource(input) {
24
25
  if (!input.apiKey) {
25
- throw new AbloError('databaseUrl requires an apiKey to register the direct Postgres connector (the org is derived from the key).', { code: 'datasource_registration_failed' });
26
+ throw new AbloError('databaseUrl requires an apiKey to register the database connection (the org is derived from the key).', { code: 'datasource_registration_failed' });
26
27
  }
27
28
  const doFetch = input.fetchImpl ?? fetch;
28
- const endpoint = `${input.baseUrl.replace(/\/+$/, '')}/v1/datasource`;
29
- let response;
30
- try {
31
- response = await doFetch(endpoint, {
32
- method: 'POST',
33
- headers: {
34
- 'content-type': 'application/json',
35
- authorization: `Bearer ${input.apiKey}`,
36
- },
37
- body: JSON.stringify({
38
- connectionString: input.databaseUrl,
39
- ...(input.schema ? { schema: input.schema } : {}),
40
- }),
41
- });
42
- }
43
- catch (cause) {
44
- throw new AbloError('Could not reach the Ablo API to register the direct Postgres connector.', {
45
- code: 'datasource_registration_failed',
46
- cause,
47
- });
29
+ const base = input.baseUrl.replace(/\/+$/, '');
30
+ const body = JSON.stringify({
31
+ connectionString: input.databaseUrl,
32
+ ...(input.schema ? { schema: input.schema } : {}),
33
+ });
34
+ const post = async (endpoint) => {
35
+ try {
36
+ return await doFetch(endpoint, {
37
+ method: 'POST',
38
+ headers: {
39
+ 'content-type': 'application/json',
40
+ authorization: `Bearer ${input.apiKey}`,
41
+ },
42
+ body,
43
+ });
44
+ }
45
+ catch (cause) {
46
+ throw new AbloError('Could not reach the Ablo API to register the database connection.', {
47
+ code: 'datasource_registration_failed',
48
+ cause,
49
+ });
50
+ }
51
+ };
52
+ let response = await post(`${base}/v1/datasources`);
53
+ if (response.status === 404) {
54
+ // Older server without the unified resource — use the legacy alias.
55
+ response = await post(`${base}/v1/datasource`);
48
56
  }
49
57
  if (!response.ok) {
50
58
  let detail = '';
@@ -54,6 +62,6 @@ export async function registerDataSource(input) {
54
62
  catch {
55
63
  // ignore body read failures — the status alone is enough to fail loud
56
64
  }
57
- throw new AbloError(`Direct Postgres connector registration failed (HTTP ${response.status}). ${detail}`, { code: 'datasource_registration_failed', httpStatus: response.status });
65
+ throw new AbloError(`Database connection registration failed (HTTP ${response.status}). ${detail}`, { code: 'datasource_registration_failed', httpStatus: response.status });
58
66
  }
59
67
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * THE write-options schema — one Zod schema for the write dialect every
3
+ * door speaks (`ablo.<model>.create/update/delete`, `commits.create`, the
4
+ * HTTP model routes). Validated once at each public boundary so a plain-JS
5
+ * caller passing `onStale: 'rejct'` fails loudly at the call site with a
6
+ * typed `AbloValidationError`, not silently (or 400) at the server.
7
+ *
8
+ * Mirrors `source/contract.ts`: the schema is the runtime twin of the
9
+ * `MutationOptions` interface, with a compile-time drift guard at the
10
+ * bottom so the two can never silently diverge.
11
+ *
12
+ * Validation-only by design: callers keep their ORIGINAL options object.
13
+ * Zod's parse output strips unknown keys, and the `intent` slot legally
14
+ * carries live handles (`IntentLeaseHandle` / claim leases) whose
15
+ * `release`/`revoke` functions must survive — so we assert, never replace.
16
+ */
17
+ import { z } from 'zod';
18
+ export declare const onStaleModeSchema: z.ZodEnum<{
19
+ reject: "reject";
20
+ force: "force";
21
+ flag: "flag";
22
+ merge: "merge";
23
+ }>;
24
+ export declare const writeOptionsSchema: z.ZodObject<{
25
+ idempotencyKey: z.ZodOptional<z.ZodNullable<z.ZodString>>;
26
+ label: z.ZodOptional<z.ZodString>;
27
+ wait: z.ZodOptional<z.ZodEnum<{
28
+ confirmed: "confirmed";
29
+ queued: "queued";
30
+ }>>;
31
+ readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
32
+ onStale: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
33
+ reject: "reject";
34
+ force: "force";
35
+ flag: "flag";
36
+ merge: "merge";
37
+ }>>>;
38
+ intent: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
39
+ id: z.ZodString;
40
+ }, z.core.$loose>]>>>;
41
+ causedByTaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
42
+ }, z.core.$strip>;
43
+ export type WriteOptionsInput = z.infer<typeof writeOptionsSchema>;
44
+ /**
45
+ * Assert a write-options bag against THE schema. Throws a typed
46
+ * `AbloValidationError` (`code: 'write_options_invalid'`, Stripe-style
47
+ * `param` pointing at the offending field) and returns nothing — the
48
+ * caller keeps its original object.
49
+ */
50
+ export declare function assertWriteOptions(value: unknown, context?: string): void;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * THE write-options schema — one Zod schema for the write dialect every
3
+ * door speaks (`ablo.<model>.create/update/delete`, `commits.create`, the
4
+ * HTTP model routes). Validated once at each public boundary so a plain-JS
5
+ * caller passing `onStale: 'rejct'` fails loudly at the call site with a
6
+ * typed `AbloValidationError`, not silently (or 400) at the server.
7
+ *
8
+ * Mirrors `source/contract.ts`: the schema is the runtime twin of the
9
+ * `MutationOptions` interface, with a compile-time drift guard at the
10
+ * bottom so the two can never silently diverge.
11
+ *
12
+ * Validation-only by design: callers keep their ORIGINAL options object.
13
+ * Zod's parse output strips unknown keys, and the `intent` slot legally
14
+ * carries live handles (`IntentLeaseHandle` / claim leases) whose
15
+ * `release`/`revoke` functions must survive — so we assert, never replace.
16
+ */
17
+ import { z } from 'zod';
18
+ import { AbloValidationError } from '../errors.js';
19
+ export const onStaleModeSchema = z.enum(['reject', 'force', 'flag', 'merge']);
20
+ export const writeOptionsSchema = z.object({
21
+ /** Server-side mutation_log cache key; `null` opts out of retry-safety. */
22
+ idempotencyKey: z.string().min(1).max(255).nullish(),
23
+ /** Human-readable audit tag, persisted to `mutation_log.label`. */
24
+ label: z.string().max(255).optional(),
25
+ /** Resolve when queued locally (default) or once the server confirms. */
26
+ wait: z.enum(['queued', 'confirmed']).optional(),
27
+ /** Stale guard: the sync watermark the caller's reasoning was based on. */
28
+ readAt: z.number().int().nonnegative().nullish(),
29
+ /** What the server does when the target moved past `readAt`. */
30
+ onStale: onStaleModeSchema.nullish(),
31
+ /** Claim/intent attribution — an id, or a live lease handle (loose: the
32
+ * handle's `release`/`revoke` functions ride along untouched). */
33
+ intent: z.union([z.string(), z.looseObject({ id: z.string() })]).nullish(),
34
+ /** Dormant wire-compat field; always `null` from current clients. */
35
+ causedByTaskId: z.string().nullish(),
36
+ });
37
+ /**
38
+ * Assert a write-options bag against THE schema. Throws a typed
39
+ * `AbloValidationError` (`code: 'write_options_invalid'`, Stripe-style
40
+ * `param` pointing at the offending field) and returns nothing — the
41
+ * caller keeps its original object.
42
+ */
43
+ export function assertWriteOptions(value, context) {
44
+ if (value == null)
45
+ return;
46
+ const result = writeOptionsSchema.safeParse(value);
47
+ if (result.success)
48
+ return;
49
+ const issue = result.error.issues[0];
50
+ const path = issue?.path.map(String).join('.') ?? '';
51
+ throw new AbloValidationError(`Invalid write options${context ? ` on \`${context}\`` : ''}${path ? ` at \`${path}\`` : ''}: ${issue?.message ?? 'failed validation'}.`, {
52
+ code: 'write_options_invalid',
53
+ ...(path ? { param: path } : {}),
54
+ });
55
+ }
56
+ const _writeOptionsContractInSync = [true, true];
57
+ void _writeOptionsContractInSync;