@abloatai/ablo 0.9.2 → 0.9.3
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/AGENTS.md +1 -1
- package/CHANGELOG.md +6 -0
- package/README.md +40 -22
- package/dist/BaseSyncedStore.d.ts +2 -36
- package/dist/BaseSyncedStore.js +5 -53
- package/dist/NetworkMonitor.js +4 -1
- package/dist/SyncClient.d.ts +10 -5
- package/dist/SyncClient.js +63 -1
- package/dist/SyncEngineContext.js +5 -1
- package/dist/auth/index.js +3 -1
- package/dist/cli.cjs +302645 -0
- package/dist/client/Ablo.d.ts +12 -3
- package/dist/client/Ablo.js +28 -2
- package/dist/client/ApiClient.js +39 -6
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/createModelProxy.d.ts +9 -0
- package/dist/client/createModelProxy.js +34 -10
- package/dist/client/persistence.d.ts +6 -1
- package/dist/client/persistence.js +1 -1
- package/dist/client/registerDataSource.d.ts +4 -4
- package/dist/client/registerDataSource.js +39 -31
- package/dist/client/writeOptionsSchema.d.ts +50 -0
- package/dist/client/writeOptionsSchema.js +57 -0
- package/dist/core/index.d.ts +18 -26
- package/dist/core/index.js +22 -46
- package/dist/errorCodes.d.ts +13 -0
- package/dist/errorCodes.js +16 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/interfaces/index.d.ts +10 -0
- package/dist/mutators/UndoManager.d.ts +31 -5
- package/dist/mutators/UndoManager.js +113 -1
- package/dist/schema/ddl.js +2 -1
- package/dist/schema/field.js +2 -1
- package/dist/schema/serialize.js +2 -1
- package/dist/server/storage-mode.d.ts +7 -0
- package/dist/server/storage-mode.js +6 -0
- package/dist/source/adapters/drizzle.js +3 -2
- package/dist/source/adapters/kysely.d.ts +68 -0
- package/dist/source/adapters/kysely.js +210 -0
- package/dist/source/adapters/memory.js +2 -1
- package/dist/source/adapters/prisma.js +3 -2
- package/dist/source/index.js +2 -1
- package/dist/transactions/TransactionQueue.d.ts +6 -7
- package/dist/transactions/TransactionQueue.js +33 -9
- package/dist/utils/duration.js +3 -2
- package/docs/client-behavior.md +1 -1
- package/docs/data-sources.md +61 -42
- package/docs/guarantees.md +2 -2
- package/docs/index.md +2 -2
- package/docs/integration-guide.md +4 -7
- package/docs/mcp.md +1 -1
- package/docs/quickstart.md +84 -37
- package/docs/schema-contract.md +2 -4
- package/llms-full.txt +360 -0
- package/llms.txt +14 -9
- package/package.json +22 -2
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -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 '
|
|
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 `
|
|
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 `
|
|
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;
|
package/dist/client/Ablo.js
CHANGED
|
@@ -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) {
|
|
@@ -1341,10 +1348,26 @@ export function Ablo(options) {
|
|
|
1341
1348
|
const commits = {
|
|
1342
1349
|
async create(commitOptions) {
|
|
1343
1350
|
await ready();
|
|
1351
|
+
// Same runtime contract as the per-model writes — one schema.
|
|
1352
|
+
assertWriteOptions({
|
|
1353
|
+
idempotencyKey: commitOptions.idempotencyKey,
|
|
1354
|
+
readAt: commitOptions.readAt,
|
|
1355
|
+
onStale: commitOptions.onStale,
|
|
1356
|
+
wait: commitOptions.wait,
|
|
1357
|
+
intent: commitOptions.intent,
|
|
1358
|
+
}, 'commits.create');
|
|
1344
1359
|
const clientTxId = createClientTxId(commitOptions.idempotencyKey);
|
|
1345
|
-
|
|
1360
|
+
// A claim handle supplies the batch stale-guard defaults — same
|
|
1361
|
+
// semantics as `ablo.<model>.update({ id, data, claim })`, so the
|
|
1362
|
+
// two write doors speak one claim vocabulary. Explicit options win.
|
|
1363
|
+
const claim = commitOptions.claim ?? null;
|
|
1364
|
+
const operations = normalizeCommitOperations({
|
|
1365
|
+
...commitOptions,
|
|
1366
|
+
readAt: commitOptions.readAt ?? claim?.readAt ?? null,
|
|
1367
|
+
onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
|
|
1368
|
+
});
|
|
1346
1369
|
const wait = commitOptions.wait ?? 'confirmed';
|
|
1347
|
-
const intentId = normalizeIntentId(commitOptions.intent);
|
|
1370
|
+
const intentId = normalizeIntentId(commitOptions.intent) ?? claim?.claimId;
|
|
1348
1371
|
void intentId; // The current wire clears intents by entity after commit.
|
|
1349
1372
|
// Route through the TransactionQueue's commit lane so the call
|
|
1350
1373
|
// tolerates WS disconnects: the envelope stays in memory until
|
|
@@ -1422,6 +1445,7 @@ export function Ablo(options) {
|
|
|
1422
1445
|
idempotencyKey: params.idempotencyKey,
|
|
1423
1446
|
readAt: params.readAt,
|
|
1424
1447
|
onStale: params.onStale,
|
|
1448
|
+
...(isClaimHandleValue(params.claim) ? { claim: params.claim } : {}),
|
|
1425
1449
|
wait: params.wait,
|
|
1426
1450
|
operations: [
|
|
1427
1451
|
{
|
|
@@ -1440,6 +1464,7 @@ export function Ablo(options) {
|
|
|
1440
1464
|
idempotencyKey: params.idempotencyKey,
|
|
1441
1465
|
readAt: params.readAt,
|
|
1442
1466
|
onStale: params.onStale,
|
|
1467
|
+
...(isClaimHandleValue(params.claim) ? { claim: params.claim } : {}),
|
|
1443
1468
|
wait: params.wait,
|
|
1444
1469
|
operations: [
|
|
1445
1470
|
{
|
|
@@ -1458,6 +1483,7 @@ export function Ablo(options) {
|
|
|
1458
1483
|
idempotencyKey: params.idempotencyKey,
|
|
1459
1484
|
readAt: params.readAt,
|
|
1460
1485
|
onStale: params.onStale,
|
|
1486
|
+
...(isClaimHandleValue(params.claim) ? { claim: params.claim } : {}),
|
|
1461
1487
|
wait: params.wait,
|
|
1462
1488
|
operations: [
|
|
1463
1489
|
{
|
package/dist/client/ApiClient.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
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 === '
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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-
|
|
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
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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(`
|
|
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;
|
package/dist/core/index.d.ts
CHANGED
|
@@ -1,36 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @abloatai/ablo/core — Framework extension
|
|
3
3
|
*
|
|
4
|
-
* Only imported by
|
|
5
|
-
* the
|
|
6
|
-
* Regular model files and components should NOT import from
|
|
4
|
+
* Only imported by the handful of files that extend or orchestrate the
|
|
5
|
+
* sync engine (the app-shell store/provider stack, sync adapters, demo
|
|
6
|
+
* harnesses). Regular model files and components should NOT import from
|
|
7
|
+
* here — the consumer surface is `Ablo({ schema })` on the root.
|
|
8
|
+
*
|
|
9
|
+
* TRIMMED to what framework-level consumers actually import (verified by
|
|
10
|
+
* a monorepo-wide import scan). Everything else the engine defines stays
|
|
11
|
+
* module-private: if a new framework concern genuinely needs another
|
|
12
|
+
* primitive, add the export deliberately — don't re-widen the barrel.
|
|
7
13
|
*/
|
|
8
|
-
export { BaseSyncedStore, type
|
|
9
|
-
export { SyncClient
|
|
10
|
-
export { Database
|
|
14
|
+
export { BaseSyncedStore, type ModelConstructor, type ConcreteModelConstructor, } from '../BaseSyncedStore.js';
|
|
15
|
+
export { SyncClient } from '../SyncClient.js';
|
|
16
|
+
export { Database } from '../Database.js';
|
|
11
17
|
export { ObjectPool, ModelScope } from '../ObjectPool.js';
|
|
12
18
|
export { Model } from '../Model.js';
|
|
13
|
-
export { LazyReferenceCollection, type LazyCollectionOptions } from '../LazyReferenceCollection.js';
|
|
19
|
+
export { LazyReferenceCollection, type LazyCollectionOptions, } from '../LazyReferenceCollection.js';
|
|
20
|
+
export { ModelRegistry, getActiveRegistry, } from '../ModelRegistry.js';
|
|
14
21
|
export { postQuery, type PostQueryOptions } from '../query/client.js';
|
|
15
|
-
export { probeNetwork, type ProbeResult } from '../sync/NetworkProbe.js';
|
|
16
|
-
export { ConnectionManager, type ConnectionState, type ConnectionEvent, type ConnectionCallbacks, type ConnectionManagerOptions, } from '../sync/ConnectionManager.js';
|
|
17
|
-
export { ModelRegistry, getActiveRegistry, type ExtendedReferenceMetadata, type BackReferenceMetadata } from '../ModelRegistry.js';
|
|
18
22
|
export { computeFKDepthPriority, type InternalAbloOptions } from '../client/Ablo.js';
|
|
19
|
-
export {
|
|
20
|
-
export {
|
|
21
|
-
export {
|
|
22
|
-
export type { SyncEngineConfig, SyncLogger, SyncObservabilityProvider, SyncAnalytics, MutationExecutor, MutationDispatcher, SessionErrorDetector, OnlineStatusProvider, CommitResult, MutationOperation, BreadcrumbLevel, SyncBreadcrumbCategory, TransactionFailureDetails, BootstrapFailureDetails, WebSocketErrorDetails, RollbackDetails, SpanAttributes, } from '../interfaces/index.js';
|
|
23
|
-
export { SyncSessionError } from '../errors.js';
|
|
24
|
-
export { QueryProcessor } from './QueryProcessor.js';
|
|
25
|
-
export { QueryView, type QueryViewOptions } from './QueryView.js';
|
|
26
|
-
export { ViewRegistry } from './ViewRegistry.js';
|
|
27
|
-
export { ObjectStore } from '../stores/ObjectStore.js';
|
|
28
|
-
export { NetworkMonitor } from '../NetworkMonitor.js';
|
|
29
|
-
export { SyncWebSocket, type SyncDelta, type VersionVector, type BootstrapHint, type SyncGroupChangePayload, type BootstrapDataEvent, type PresenceUpdateEvent, type SyncWebSocketOptions, } from '../sync/SyncWebSocket.js';
|
|
30
|
-
export { BootstrapHelper, type BootstrapData, type BootstrapOptions, type BootstrapFetchResult } from '../sync/BootstrapHelper.js';
|
|
23
|
+
export type { SyncLogger, SyncObservabilityProvider, MutationExecutor, MutationDispatcher, SessionErrorDetector, OnlineStatusProvider, CommitResult, MutationOperation, } from '../interfaces/index.js';
|
|
24
|
+
export { SyncWebSocket, type SyncDelta, type SyncWebSocketOptions, } from '../sync/SyncWebSocket.js';
|
|
25
|
+
export { BootstrapHelper } from '../sync/BootstrapHelper.js';
|
|
31
26
|
export { createIntentStream, type AttachableIntentStream, type IntentStreamConfig, } from '../sync/createIntentStream.js';
|
|
32
27
|
export { awaitIntentGrant, type GrantTransport, } from '../sync/awaitIntentGrant.js';
|
|
33
|
-
export {
|
|
34
|
-
export { PropertyType, LoadStrategy, MutationOperationType } from '../types/index.js';
|
|
35
|
-
export type { PropertyMetadata, ReferenceMetadata, ModelMetadata, SyncAction, DeltaPacket, BootstrapMetadata, DatabaseMetadata, } from '../types/index.js';
|
|
36
|
-
export type { ModelData } from '../BaseSyncedStore.js';
|
|
28
|
+
export { LoadStrategy } from '../types/index.js';
|