@abloatai/ablo 0.10.1 → 0.11.1
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/CHANGELOG.md +34 -0
- package/README.md +63 -23
- package/dist/BaseSyncedStore.d.ts +75 -0
- package/dist/BaseSyncedStore.js +193 -8
- package/dist/Database.d.ts +10 -2
- package/dist/Database.js +15 -1
- package/dist/SyncClient.d.ts +12 -1
- package/dist/SyncClient.js +110 -26
- package/dist/agent/Agent.d.ts +9 -9
- package/dist/agent/Agent.js +16 -16
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +2 -2
- package/dist/agent/types.d.ts +1 -1
- package/dist/agent/types.js +1 -1
- package/dist/ai-sdk/{intent-broadcast.d.ts → claim-broadcast.d.ts} +10 -10
- package/dist/ai-sdk/{intent-broadcast.js → claim-broadcast.js} +6 -6
- package/dist/ai-sdk/coordination-context.d.ts +9 -9
- package/dist/ai-sdk/coordination-context.js +8 -8
- package/dist/ai-sdk/index.d.ts +1 -1
- package/dist/ai-sdk/index.js +1 -1
- package/dist/ai-sdk/wrap.d.ts +4 -4
- package/dist/ai-sdk/wrap.js +4 -4
- package/dist/api/index.d.ts +2 -2
- package/dist/cli.cjs +369 -67
- package/dist/client/Ablo.d.ts +30 -63
- package/dist/client/Ablo.js +124 -103
- package/dist/client/ApiClient.d.ts +6 -5
- package/dist/client/ApiClient.js +86 -62
- package/dist/client/auth.d.ts +9 -4
- package/dist/client/auth.js +40 -5
- package/dist/client/createModelProxy.d.ts +41 -54
- package/dist/client/createModelProxy.js +123 -20
- package/dist/client/httpClient.d.ts +2 -0
- package/dist/client/httpClient.js +1 -1
- package/dist/client/index.d.ts +3 -3
- package/dist/client/writeOptionsSchema.d.ts +4 -4
- package/dist/client/writeOptionsSchema.js +4 -4
- package/dist/coordination/schema.d.ts +249 -38
- package/dist/coordination/schema.js +172 -39
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +4 -4
- package/dist/errorCodes.d.ts +9 -9
- package/dist/errorCodes.js +16 -16
- package/dist/errors.d.ts +51 -2
- package/dist/errors.js +94 -5
- package/dist/interfaces/index.d.ts +8 -4
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/types.d.ts +13 -13
- package/dist/policy/types.js +8 -8
- package/dist/react/AbloProvider.d.ts +51 -4
- package/dist/react/AbloProvider.js +95 -11
- package/dist/react/context.d.ts +26 -9
- package/dist/react/context.js +2 -2
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +4 -4
- package/dist/react/useAblo.js +5 -5
- package/dist/react/{useIntent.d.ts → useClaim.d.ts} +9 -9
- package/dist/react/useClaim.js +42 -0
- package/dist/schema/index.js +1 -1
- package/dist/schema/schema.d.ts +3 -3
- package/dist/schema/sugar.d.ts +3 -3
- package/dist/schema/sugar.js +3 -3
- package/dist/schema/sync-delta-wire.d.ts +8 -8
- package/dist/server/commit.d.ts +2 -2
- package/dist/sync/AreaOfInterestManager.d.ts +162 -0
- package/dist/sync/AreaOfInterestManager.js +233 -0
- package/dist/sync/BootstrapHelper.d.ts +9 -1
- package/dist/sync/BootstrapHelper.js +15 -5
- package/dist/sync/NetworkProbe.d.ts +1 -1
- package/dist/sync/NetworkProbe.js +1 -1
- package/dist/sync/SyncWebSocket.d.ts +59 -25
- package/dist/sync/SyncWebSocket.js +123 -26
- package/dist/sync/awaitClaimGrant.d.ts +40 -0
- package/dist/sync/awaitClaimGrant.js +86 -0
- package/dist/sync/createClaimStream.d.ts +34 -0
- package/dist/sync/{createIntentStream.js → createClaimStream.js} +92 -81
- package/dist/sync/createPresenceStream.js +3 -2
- package/dist/sync/participants.d.ts +10 -10
- package/dist/sync/participants.js +17 -10
- package/dist/sync/schemas.d.ts +8 -8
- package/dist/transactions/TransactionQueue.d.ts +23 -0
- package/dist/transactions/TransactionQueue.js +186 -12
- package/dist/types/global.d.ts +18 -13
- package/dist/types/global.js +11 -6
- package/dist/types/index.d.ts +9 -7
- package/dist/types/index.js +2 -2
- package/dist/types/streams.d.ts +114 -98
- package/dist/types/streams.js +1 -1
- package/dist/utils/asyncIterator.d.ts +1 -1
- package/dist/utils/asyncIterator.js +1 -1
- package/dist/wire/frames.d.ts +2 -2
- package/docs/api.md +3 -3
- package/docs/client-behavior.md +6 -3
- package/docs/coordination.md +13 -3
- package/docs/data-sources.md +29 -9
- package/docs/migration.md +40 -0
- package/docs/quickstart.md +61 -33
- package/docs/react.md +46 -0
- package/llms-full.txt +25 -8
- package/llms.txt +11 -9
- package/package.json +3 -2
- package/dist/react/useIntent.js +0 -42
- package/dist/sync/awaitIntentGrant.d.ts +0 -40
- package/dist/sync/awaitIntentGrant.js +0 -62
- package/dist/sync/createIntentStream.d.ts +0 -34
package/dist/client/ApiClient.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* IndexedDB, no WebSocket. It maps the public Model / Claim / Commit
|
|
6
6
|
* nouns directly to HTTP routes on sync-server.
|
|
7
7
|
*/
|
|
8
|
-
import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, translateHttpError, } from '../errors.js';
|
|
9
|
-
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, } from './auth.js';
|
|
8
|
+
import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, claimedError, translateHttpError, } from '../errors.js';
|
|
9
|
+
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfDatabaseUrlEnvIgnored, } from './auth.js';
|
|
10
|
+
import { registerDataSource } from './registerDataSource.js';
|
|
10
11
|
import { toSeconds } from '../utils/duration.js';
|
|
11
12
|
import { assertWriteOptions } from './writeOptionsSchema.js';
|
|
12
13
|
const DEFAULT_AGENT_LEASE = '10m';
|
|
@@ -15,8 +16,13 @@ export function createProtocolClient(options) {
|
|
|
15
16
|
const authInput = { options, env };
|
|
16
17
|
const configuredApiKey = resolveApiKey(authInput);
|
|
17
18
|
const configuredAuthToken = resolveAuthToken(authInput);
|
|
19
|
+
const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
|
|
20
|
+
// Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
|
|
21
|
+
// passed — no logger on this path, so the helper falls back to console.warn.
|
|
22
|
+
warnIfDatabaseUrlEnvIgnored(authInput);
|
|
18
23
|
assertBrowserSafety({
|
|
19
24
|
apiKey: configuredApiKey,
|
|
25
|
+
databaseUrl: configuredDatabaseUrl,
|
|
20
26
|
dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
|
|
21
27
|
});
|
|
22
28
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
@@ -28,6 +34,28 @@ export function createProtocolClient(options) {
|
|
|
28
34
|
url,
|
|
29
35
|
bootstrapBaseUrl: options.bootstrapBaseUrl,
|
|
30
36
|
}).replace(/\/+$/, '');
|
|
37
|
+
let readyPromise = null;
|
|
38
|
+
async function ready() {
|
|
39
|
+
if (readyPromise)
|
|
40
|
+
return readyPromise;
|
|
41
|
+
readyPromise = (async () => {
|
|
42
|
+
if (!configuredDatabaseUrl)
|
|
43
|
+
return;
|
|
44
|
+
await registerDataSource({
|
|
45
|
+
baseUrl: apiBaseUrl,
|
|
46
|
+
apiKey: await resolveApiKeyValue(configuredApiKey),
|
|
47
|
+
databaseUrl: configuredDatabaseUrl,
|
|
48
|
+
...(options.fetch ? { fetchImpl: options.fetch } : {}),
|
|
49
|
+
});
|
|
50
|
+
})();
|
|
51
|
+
try {
|
|
52
|
+
await readyPromise;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
readyPromise = null;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
31
59
|
async function authHeaders() {
|
|
32
60
|
const apiKey = await resolveApiKeyValue(configuredApiKey);
|
|
33
61
|
const token = apiKey ?? configuredAuthToken;
|
|
@@ -57,6 +85,7 @@ export function createProtocolClient(options) {
|
|
|
57
85
|
return target.toString();
|
|
58
86
|
}
|
|
59
87
|
async function requestJson(path, init) {
|
|
88
|
+
await ready();
|
|
60
89
|
const { idempotencyKey, ...requestInit } = init;
|
|
61
90
|
const headers = await authHeaders();
|
|
62
91
|
if (idempotencyKey)
|
|
@@ -82,7 +111,7 @@ export function createProtocolClient(options) {
|
|
|
82
111
|
? crypto.randomUUID()
|
|
83
112
|
: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
84
113
|
}
|
|
85
|
-
function
|
|
114
|
+
function createClaimId() {
|
|
86
115
|
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
87
116
|
? `int_${crypto.randomUUID()}`
|
|
88
117
|
: `int_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -128,7 +157,7 @@ export function createProtocolClient(options) {
|
|
|
128
157
|
}
|
|
129
158
|
return inputOperations.map((op) => normalizeCommitOperation(op, commitOptions));
|
|
130
159
|
}
|
|
131
|
-
async function
|
|
160
|
+
async function listClaims(target) {
|
|
132
161
|
const state = await listClaimState(target);
|
|
133
162
|
return state.active;
|
|
134
163
|
}
|
|
@@ -141,24 +170,16 @@ export function createProtocolClient(options) {
|
|
|
141
170
|
if (target?.field)
|
|
142
171
|
params.set('field', target.field);
|
|
143
172
|
const suffix = params.toString();
|
|
144
|
-
const body = await requestJson(`/v1/
|
|
173
|
+
const body = await requestJson(`/v1/claims${suffix ? `?${suffix}` : ''}`, { method: 'GET' });
|
|
145
174
|
return {
|
|
146
|
-
active: body.
|
|
175
|
+
active: body.claims ?? [],
|
|
147
176
|
queue: body.queue ?? [],
|
|
148
177
|
};
|
|
149
178
|
}
|
|
150
|
-
function claimedError(target, claims, code) {
|
|
151
|
-
const label = [target.model, target.id, target.field].filter(Boolean).join('/');
|
|
152
|
-
const holder = claims[0];
|
|
153
|
-
const suffix = holder
|
|
154
|
-
? ` held by ${holder.actor} (${holder.action})`
|
|
155
|
-
: ' held by another participant';
|
|
156
|
-
return new AbloClaimedError(`Model row is claimed: ${label || 'target'}${suffix}.`, { code, claims });
|
|
157
|
-
}
|
|
158
179
|
function delay(ms, signal) {
|
|
159
180
|
if (signal?.aborted) {
|
|
160
|
-
return Promise.reject(new AbloConnectionError('
|
|
161
|
-
code: '
|
|
181
|
+
return Promise.reject(new AbloConnectionError('Claim wait aborted.', {
|
|
182
|
+
code: 'claim_wait_aborted',
|
|
162
183
|
cause: signal.reason,
|
|
163
184
|
}));
|
|
164
185
|
}
|
|
@@ -174,28 +195,28 @@ export function createProtocolClient(options) {
|
|
|
174
195
|
}
|
|
175
196
|
function onAbort() {
|
|
176
197
|
cleanup();
|
|
177
|
-
reject(new AbloConnectionError('
|
|
178
|
-
code: '
|
|
198
|
+
reject(new AbloConnectionError('Claim wait aborted.', {
|
|
199
|
+
code: 'claim_wait_aborted',
|
|
179
200
|
cause: signal?.reason,
|
|
180
201
|
}));
|
|
181
202
|
}
|
|
182
203
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
183
204
|
});
|
|
184
205
|
}
|
|
185
|
-
async function
|
|
206
|
+
async function waitForNoClaims(target, options) {
|
|
186
207
|
const startedAt = Date.now();
|
|
187
208
|
const pollInterval = options?.pollInterval;
|
|
188
209
|
for (;;) {
|
|
189
|
-
const
|
|
190
|
-
if (
|
|
210
|
+
const claims = await listClaims(target);
|
|
211
|
+
if (claims.length === 0)
|
|
191
212
|
return;
|
|
192
213
|
if (pollInterval == null) {
|
|
193
214
|
throw new AbloValidationError('Cannot wait for claims over the HTTP client without `pollInterval`. ' +
|
|
194
215
|
'Use the schema client for event-driven claim waits, pass `ifClaimed: "return"`, ' +
|
|
195
|
-
'or provide an explicit poll interval for this runtime.', { code: '
|
|
216
|
+
'or provide an explicit poll interval for this runtime.', { code: 'claim_wait_poll_interval_required' });
|
|
196
217
|
}
|
|
197
218
|
if (options?.timeout != null && Date.now() - startedAt >= options.timeout) {
|
|
198
|
-
throw claimedError(target,
|
|
219
|
+
throw claimedError(target, claims, 'model_claimed_timeout');
|
|
199
220
|
}
|
|
200
221
|
const remaining = options?.timeout == null
|
|
201
222
|
? pollInterval
|
|
@@ -217,7 +238,7 @@ export function createProtocolClient(options) {
|
|
|
217
238
|
state.queue.length >= options.maxQueueDepth) {
|
|
218
239
|
throw claimedError(target, state.active, 'queue_too_deep');
|
|
219
240
|
}
|
|
220
|
-
await
|
|
241
|
+
await waitForNoClaims(target, {
|
|
221
242
|
timeout: options?.claimedTimeout,
|
|
222
243
|
pollInterval: options?.claimedPollInterval,
|
|
223
244
|
});
|
|
@@ -230,7 +251,7 @@ export function createProtocolClient(options) {
|
|
|
230
251
|
readAt: commitOptions.readAt,
|
|
231
252
|
onStale: commitOptions.onStale,
|
|
232
253
|
wait: commitOptions.wait,
|
|
233
|
-
|
|
254
|
+
claim: commitOptions.claim,
|
|
234
255
|
}, 'commits.create');
|
|
235
256
|
const clientTxId = createClientTxId(commitOptions.idempotencyKey);
|
|
236
257
|
// Same claim vocabulary as the WS client's `commits.create`: a handle
|
|
@@ -247,7 +268,7 @@ export function createProtocolClient(options) {
|
|
|
247
268
|
body: JSON.stringify({
|
|
248
269
|
clientTxId,
|
|
249
270
|
idempotencyKey: clientTxId,
|
|
250
|
-
|
|
271
|
+
claim: normalizeClaimId(commitOptions.claimRef) ?? claim?.claimId,
|
|
251
272
|
operations,
|
|
252
273
|
}),
|
|
253
274
|
});
|
|
@@ -353,39 +374,42 @@ export function createProtocolClient(options) {
|
|
|
353
374
|
return capabilities.create(options);
|
|
354
375
|
},
|
|
355
376
|
};
|
|
356
|
-
const
|
|
357
|
-
async create(
|
|
358
|
-
const
|
|
359
|
-
const body = await requestJson('/v1/
|
|
377
|
+
const claims = {
|
|
378
|
+
async create(claimOptions) {
|
|
379
|
+
const claimId = createClaimId();
|
|
380
|
+
const body = await requestJson('/v1/claims', {
|
|
360
381
|
method: 'POST',
|
|
361
382
|
body: JSON.stringify({
|
|
362
|
-
|
|
363
|
-
target:
|
|
364
|
-
action:
|
|
365
|
-
ttl:
|
|
366
|
-
queue:
|
|
383
|
+
claimId,
|
|
384
|
+
target: claimOptions.target,
|
|
385
|
+
action: claimOptions.action,
|
|
386
|
+
ttl: claimOptions.ttl,
|
|
387
|
+
queue: claimOptions.queue,
|
|
367
388
|
}),
|
|
368
389
|
});
|
|
369
|
-
// The fair-queue grant is PUSHED over a WebSocket (`
|
|
390
|
+
// The fair-queue grant is PUSHED over a WebSocket (`claim_granted`),
|
|
370
391
|
// which this stateless HTTP client doesn't hold. Returning a handle here
|
|
371
392
|
// would be a phantom holder — a lease we can't confirm is ours. So a
|
|
372
393
|
// queued response is surfaced as a typed claimed signal; callers that need
|
|
373
394
|
// to *wait* in line use the realtime (WS-backed) `ablo.<model>.claim`.
|
|
374
395
|
if (body.status === 'queued') {
|
|
375
|
-
throw new AbloClaimedError(`Target ${
|
|
396
|
+
throw new AbloClaimedError(`Target ${claimOptions.target.model}/${claimOptions.target.id} is held; ` +
|
|
376
397
|
`queued at position ${body.position ?? 0}. The HTTP client can't await ` +
|
|
377
|
-
`the grant (no socket) — use the realtime client to wait in line.`, { code: '
|
|
398
|
+
`the grant (no socket) — use the realtime client to wait in line.`, { code: 'claim_queued' });
|
|
378
399
|
}
|
|
379
|
-
const id = body.
|
|
400
|
+
const id = body.claim?.id ?? claimId;
|
|
380
401
|
let released = false;
|
|
381
402
|
const release = async () => {
|
|
382
403
|
if (released)
|
|
383
404
|
return;
|
|
384
405
|
released = true;
|
|
385
|
-
await requestJson(`/v1/
|
|
406
|
+
await requestJson(`/v1/claims/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
386
407
|
};
|
|
387
408
|
return {
|
|
388
|
-
|
|
409
|
+
object: 'claim',
|
|
410
|
+
claimId: id,
|
|
411
|
+
action: claimOptions.action,
|
|
412
|
+
target: claimOptions.target,
|
|
389
413
|
release,
|
|
390
414
|
revoke: () => {
|
|
391
415
|
void release().catch(() => { });
|
|
@@ -393,9 +417,9 @@ export function createProtocolClient(options) {
|
|
|
393
417
|
[Symbol.asyncDispose]: release,
|
|
394
418
|
};
|
|
395
419
|
},
|
|
396
|
-
list:
|
|
420
|
+
list: listClaims,
|
|
397
421
|
waitFor(target, options) {
|
|
398
|
-
return
|
|
422
|
+
return waitForNoClaims(target, options);
|
|
399
423
|
},
|
|
400
424
|
};
|
|
401
425
|
async function listModel(modelName, options) {
|
|
@@ -456,7 +480,7 @@ export function createProtocolClient(options) {
|
|
|
456
480
|
readAt: options.readAt,
|
|
457
481
|
onStale: options.onStale,
|
|
458
482
|
wait: options.wait,
|
|
459
|
-
|
|
483
|
+
claim: options.claim,
|
|
460
484
|
}, `${modelName} ${action}`);
|
|
461
485
|
const clientTxId = createClientTxId(options?.idempotencyKey);
|
|
462
486
|
const encModel = encodeURIComponent(modelName);
|
|
@@ -475,7 +499,7 @@ export function createProtocolClient(options) {
|
|
|
475
499
|
const readAt = options?.readAt ?? claimHandle?.readAt;
|
|
476
500
|
const requestBody = {
|
|
477
501
|
idempotencyKey: clientTxId,
|
|
478
|
-
|
|
502
|
+
claim: normalizeClaimId(options?.claimRef) ?? claimHandle?.claimId,
|
|
479
503
|
onStale: options?.onStale ?? (claimHandle?.readAt !== undefined ? 'reject' : undefined),
|
|
480
504
|
readAt,
|
|
481
505
|
};
|
|
@@ -528,9 +552,9 @@ export function createProtocolClient(options) {
|
|
|
528
552
|
});
|
|
529
553
|
if (body.status === 'queued') {
|
|
530
554
|
throw new AbloClaimedError(`Target ${name}/${params.id} is held; queued at position ${body.position ?? 0}. ` +
|
|
531
|
-
`The HTTP client cannot await the grant without a WebSocket.`, { code: '
|
|
555
|
+
`The HTTP client cannot await the grant without a WebSocket.`, { code: 'claim_queued' });
|
|
532
556
|
}
|
|
533
|
-
return body.
|
|
557
|
+
return body.claim?.id ?? body.id ?? body.claimId ?? createClaimId();
|
|
534
558
|
};
|
|
535
559
|
const releaseClaim = (params) => requestJson(claimPath(isClaimHandle(params) ? params.target.id : params.id), { method: 'DELETE' }).then(() => undefined);
|
|
536
560
|
async function claimImpl(params) {
|
|
@@ -559,23 +583,23 @@ export function createProtocolClient(options) {
|
|
|
559
583
|
[Symbol.asyncDispose]: release,
|
|
560
584
|
};
|
|
561
585
|
}
|
|
562
|
-
const
|
|
586
|
+
const claimsForEntity = async (params) => requestJson(`/v1/claims?model=${encodeURIComponent(name)}&id=${encodeURIComponent(params.id)}${params.field ? `&field=${encodeURIComponent(params.field)}` : ''}`, { method: 'GET' });
|
|
563
587
|
const claim = Object.assign(claimImpl, {
|
|
564
588
|
release: releaseClaim,
|
|
565
589
|
state: async (params) => {
|
|
566
|
-
const res = await
|
|
567
|
-
return res.
|
|
590
|
+
const res = await claimsForEntity(params);
|
|
591
|
+
return res.claims?.[0] ?? null;
|
|
568
592
|
},
|
|
569
593
|
queue: async (params) => {
|
|
570
|
-
const res = await
|
|
594
|
+
const res = await claimsForEntity(params);
|
|
571
595
|
return { object: 'list', data: res.queue ?? [] };
|
|
572
596
|
},
|
|
573
597
|
reorder: async (params) => {
|
|
574
598
|
await requestJson(`${claimPath(params.id)}/reorder`, {
|
|
575
599
|
method: 'POST',
|
|
576
|
-
// The reorder route's payload is `{ heldBy,
|
|
577
|
-
// IS the
|
|
578
|
-
body: JSON.stringify({ order: params.order.map((i) => ({ heldBy: i.heldBy,
|
|
600
|
+
// The reorder route's payload is `{ heldBy, claimId }[]` — Claim's id
|
|
601
|
+
// IS the claimId.
|
|
602
|
+
body: JSON.stringify({ order: params.order.map((i) => ({ heldBy: i.heldBy, claimId: i.id })) }),
|
|
579
603
|
});
|
|
580
604
|
},
|
|
581
605
|
});
|
|
@@ -584,11 +608,11 @@ export function createProtocolClient(options) {
|
|
|
584
608
|
if (!claimInput)
|
|
585
609
|
return run(input);
|
|
586
610
|
if (isClaimHandle(claimInput)) {
|
|
587
|
-
return run({ ...input,
|
|
611
|
+
return run({ ...input, claimRef: { id: claimInput.claimId }, claim: undefined });
|
|
588
612
|
}
|
|
589
613
|
const claimId = await acquireClaim({ id, ...claimInput });
|
|
590
614
|
try {
|
|
591
|
-
return await run({ ...input,
|
|
615
|
+
return await run({ ...input, claimRef: { id: claimId }, claim: undefined });
|
|
592
616
|
}
|
|
593
617
|
finally {
|
|
594
618
|
await releaseClaim({ id }).catch(() => { });
|
|
@@ -624,12 +648,12 @@ export function createProtocolClient(options) {
|
|
|
624
648
|
};
|
|
625
649
|
}
|
|
626
650
|
return {
|
|
627
|
-
|
|
651
|
+
ready,
|
|
628
652
|
async waitForFlush() { },
|
|
629
653
|
async dispose() { },
|
|
630
654
|
async purge() { },
|
|
631
655
|
capabilities,
|
|
632
|
-
|
|
656
|
+
claims,
|
|
633
657
|
commits,
|
|
634
658
|
model,
|
|
635
659
|
async getAuthToken() {
|
|
@@ -639,10 +663,10 @@ export function createProtocolClient(options) {
|
|
|
639
663
|
},
|
|
640
664
|
};
|
|
641
665
|
}
|
|
642
|
-
function
|
|
643
|
-
if (typeof
|
|
644
|
-
return
|
|
645
|
-
return
|
|
666
|
+
function normalizeClaimId(claim) {
|
|
667
|
+
if (typeof claim === 'string')
|
|
668
|
+
return claim;
|
|
669
|
+
return claim?.id;
|
|
646
670
|
}
|
|
647
671
|
function parseBody(bodyText) {
|
|
648
672
|
if (bodyText.length === 0)
|
package/dist/client/auth.d.ts
CHANGED
|
@@ -45,12 +45,17 @@ export declare function resolveAuthToken(input: AuthResolveInput): string | null
|
|
|
45
45
|
/**
|
|
46
46
|
* Resolve the direct-URL connector's Postgres connection string.
|
|
47
47
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
48
|
+
* `databaseUrl` is an EXPLICIT, opt-in option: Ablo registers a dedicated
|
|
49
|
+
* tenant database only when the caller passes it to `Ablo(...)`. It is NOT
|
|
50
|
+
* read from `process.env.DATABASE_URL` — per this module's invariant
|
|
51
|
+
* (`ABLO_API_KEY` is the only environment fallback), an app's `DATABASE_URL`
|
|
52
|
+
* (commonly set for Prisma/Drizzle/docker) must never silently flip the client
|
|
53
|
+
* into connection-string mode. The default Data Source path keeps `DATABASE_URL`
|
|
54
|
+
* in the app and exposes `dataSource(...)`; that path leaves this null.
|
|
55
|
+
* `warnIfDatabaseUrlEnvIgnored` nudges callers who set the env but omitted the option.
|
|
52
56
|
*/
|
|
53
57
|
export declare function resolveDatabaseUrl(input: AuthResolveInput): string | null;
|
|
58
|
+
export declare function warnIfDatabaseUrlEnvIgnored(input: AuthResolveInput, warn?: (message: string) => void): void;
|
|
54
59
|
export declare const ABLO_HOSTED_API_DOMAIN = "api.abloatai.com";
|
|
55
60
|
export declare const ABLO_HOSTED_HTTP_BASE_URL = "https://api.abloatai.com";
|
|
56
61
|
export declare const ABLO_DEFAULT_BASE_URL = "https://api.abloatai.com";
|
package/dist/client/auth.js
CHANGED
|
@@ -30,13 +30,48 @@ export function resolveAuthToken(input) {
|
|
|
30
30
|
/**
|
|
31
31
|
* Resolve the direct-URL connector's Postgres connection string.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* `databaseUrl` is an EXPLICIT, opt-in option: Ablo registers a dedicated
|
|
34
|
+
* tenant database only when the caller passes it to `Ablo(...)`. It is NOT
|
|
35
|
+
* read from `process.env.DATABASE_URL` — per this module's invariant
|
|
36
|
+
* (`ABLO_API_KEY` is the only environment fallback), an app's `DATABASE_URL`
|
|
37
|
+
* (commonly set for Prisma/Drizzle/docker) must never silently flip the client
|
|
38
|
+
* into connection-string mode. The default Data Source path keeps `DATABASE_URL`
|
|
39
|
+
* in the app and exposes `dataSource(...)`; that path leaves this null.
|
|
40
|
+
* `warnIfDatabaseUrlEnvIgnored` nudges callers who set the env but omitted the option.
|
|
37
41
|
*/
|
|
38
42
|
export function resolveDatabaseUrl(input) {
|
|
39
|
-
return input.options.databaseUrl ??
|
|
43
|
+
return input.options.databaseUrl ?? null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* One-time migration nudge for the dropped `DATABASE_URL` env fallback.
|
|
47
|
+
*
|
|
48
|
+
* Earlier versions silently adopted `process.env.DATABASE_URL` when `databaseUrl`
|
|
49
|
+
* was not passed, registering a direct connector behind the caller's back — which
|
|
50
|
+
* surprised any app that keeps `DATABASE_URL` for another tool (Prisma, Drizzle,
|
|
51
|
+
* docker-compose) and, on localhost, tried to register a database Ablo's cloud
|
|
52
|
+
* cannot reach. The env value is now ignored; this points the developer at the
|
|
53
|
+
* explicit option instead of flipping their mode for them. Warns once per process
|
|
54
|
+
* so it never spams, and falls back to `console.warn` when no logger is supplied
|
|
55
|
+
* (the `transport: 'api'` client has none).
|
|
56
|
+
*/
|
|
57
|
+
let warnedDatabaseUrlEnvIgnored = false;
|
|
58
|
+
export function warnIfDatabaseUrlEnvIgnored(input, warn) {
|
|
59
|
+
if (warnedDatabaseUrlEnvIgnored)
|
|
60
|
+
return;
|
|
61
|
+
if (input.options.databaseUrl != null)
|
|
62
|
+
return;
|
|
63
|
+
const envUrl = input.env.DATABASE_URL;
|
|
64
|
+
if (typeof envUrl !== 'string' || envUrl.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
warnedDatabaseUrlEnvIgnored = true;
|
|
67
|
+
const message = 'Found DATABASE_URL in the environment but `databaseUrl` was not passed to Ablo(...). ' +
|
|
68
|
+
'Ablo no longer auto-adopts DATABASE_URL — the environment value is ignored. ' +
|
|
69
|
+
'To register your Postgres directly, pass `databaseUrl: process.env.DATABASE_URL` explicitly; ' +
|
|
70
|
+
'otherwise ignore this (the hosted sandbox and signed Data Source endpoints need no databaseUrl).';
|
|
71
|
+
if (warn)
|
|
72
|
+
warn(message);
|
|
73
|
+
else if (typeof console !== 'undefined')
|
|
74
|
+
console.warn('[sync]', message);
|
|
40
75
|
}
|
|
41
76
|
export const ABLO_HOSTED_API_DOMAIN = 'api.abloatai.com';
|
|
42
77
|
export const ABLO_HOSTED_HTTP_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
|
|
@@ -21,7 +21,7 @@ import type { SyncClient } from '../SyncClient.js';
|
|
|
21
21
|
import type { HydrationCoordinator } from '../sync/HydrationCoordinator.js';
|
|
22
22
|
import type { LoadWhere } from '../query/types.js';
|
|
23
23
|
import { ModelScope } from '../types/index.js';
|
|
24
|
-
import type { Duration,
|
|
24
|
+
import type { Duration, Claim, ClaimHandle, ClaimWaitOptions, Snapshot, TargetRange } from '../types/streams.js';
|
|
25
25
|
export interface ModelClientMeta {
|
|
26
26
|
readonly key: string;
|
|
27
27
|
readonly typename: string;
|
|
@@ -73,21 +73,8 @@ export interface ModelLoadOptions<T> {
|
|
|
73
73
|
/** Options for the single-row async server read `retrieve({ id })`. A subset of
|
|
74
74
|
* {@link ModelLoadOptions} — `where`/`limit`/`orderBy` are fixed by the id. */
|
|
75
75
|
export type ModelRetrieveOptions = Pick<ModelLoadOptions<unknown>, 'type' | 'expand'>;
|
|
76
|
-
export interface IntentLeaseHandle {
|
|
77
|
-
readonly id: string;
|
|
78
|
-
/**
|
|
79
|
-
* True when the grant came AFTER waiting in the server's FIFO line
|
|
80
|
-
* (`intent_granted`) — the authoritative "the row may have changed
|
|
81
|
-
* underneath us" signal. The local `observe()` snapshot can't stand in
|
|
82
|
-
* for this: intent fan-out is entity-scoped, so org-wide subscriptions
|
|
83
|
-
* (the default hosted client) never see peers' claims at all.
|
|
84
|
-
*/
|
|
85
|
-
readonly waited?: boolean;
|
|
86
|
-
release(): Promise<void>;
|
|
87
|
-
revoke(): void;
|
|
88
|
-
}
|
|
89
76
|
export interface ModelCollaboration<T> {
|
|
90
|
-
|
|
77
|
+
createClaim(options: {
|
|
91
78
|
target: {
|
|
92
79
|
model: string;
|
|
93
80
|
id: string;
|
|
@@ -106,11 +93,11 @@ export interface ModelCollaboration<T> {
|
|
|
106
93
|
queue?: boolean;
|
|
107
94
|
/** Reject (don't wait) if the queue is already this deep when we join. */
|
|
108
95
|
maxQueueDepth?: number;
|
|
109
|
-
}): Promise<
|
|
96
|
+
}): Promise<ClaimHandle>;
|
|
110
97
|
createSnapshot(modelKey: string, id: string): Snapshot;
|
|
111
98
|
/**
|
|
112
99
|
* Current coordination state on a target — who (if anyone) holds it.
|
|
113
|
-
* Synchronous reactive snapshot read off the presence/
|
|
100
|
+
* Synchronous reactive snapshot read off the presence/claim stream;
|
|
114
101
|
* `null` when the target is free. The wiring site computes it because
|
|
115
102
|
* only it knows the local participant id (needed to distinguish "I
|
|
116
103
|
* hold it" from "someone else holds it").
|
|
@@ -118,15 +105,15 @@ export interface ModelCollaboration<T> {
|
|
|
118
105
|
observe(target: {
|
|
119
106
|
model: string;
|
|
120
107
|
id: string;
|
|
121
|
-
}):
|
|
108
|
+
}): Claim | null;
|
|
122
109
|
/**
|
|
123
|
-
* The reactive wait queue on a target — the FIFO line of queued
|
|
124
|
-
* behind the holder. Synchronous snapshot off the synced
|
|
110
|
+
* The reactive wait queue on a target — the FIFO line of queued claims
|
|
111
|
+
* behind the holder. Synchronous snapshot off the synced claim stream.
|
|
125
112
|
*/
|
|
126
113
|
queue(target: {
|
|
127
114
|
model: string;
|
|
128
115
|
id: string;
|
|
129
|
-
}): readonly
|
|
116
|
+
}): readonly Claim[];
|
|
130
117
|
/**
|
|
131
118
|
* Re-rank the wait queue on a target (privileged — server-gated). `order` is
|
|
132
119
|
* the desired front-of-line ordering, taken from `queue(target)`.
|
|
@@ -134,21 +121,46 @@ export interface ModelCollaboration<T> {
|
|
|
134
121
|
reorder(target: {
|
|
135
122
|
model: string;
|
|
136
123
|
id: string;
|
|
137
|
-
}, order: readonly
|
|
124
|
+
}, order: readonly Claim[]): void;
|
|
138
125
|
/**
|
|
139
|
-
* Resolve once no participant holds an active
|
|
140
|
-
* The contender's "wait until it's free" — delegates to the
|
|
126
|
+
* Resolve once no participant holds an active claim on the target.
|
|
127
|
+
* The contender's "wait until it's free" — delegates to the claim
|
|
141
128
|
* stream's `waitFor`.
|
|
142
129
|
*/
|
|
143
130
|
waitFor(target: {
|
|
144
131
|
model: string;
|
|
145
132
|
id: string;
|
|
146
|
-
}, options?:
|
|
133
|
+
}, options?: ClaimWaitOptions): Promise<void>;
|
|
147
134
|
/**
|
|
148
135
|
* The local participant's id. Used to distinguish "I already hold this"
|
|
149
136
|
* from "someone else holds it" in `claimOrWait`.
|
|
150
137
|
*/
|
|
151
138
|
readonly selfParticipantId: string;
|
|
139
|
+
/**
|
|
140
|
+
* The local participant's kind (`'user' | 'agent' | 'system'`). Used to
|
|
141
|
+
* stamp the synthesized self-claim returned from `claim.state` when the
|
|
142
|
+
* LOCAL proxy holds the lease (server presence frames exclude one's own
|
|
143
|
+
* claims, so the holder must build its own view).
|
|
144
|
+
*/
|
|
145
|
+
readonly selfParticipantKind?: 'user' | 'agent' | 'system';
|
|
146
|
+
/**
|
|
147
|
+
* Subscribe the connection to a scope's sync group(s) (read-interest).
|
|
148
|
+
* The typed surface calls this on single-entity reads/claim observation so
|
|
149
|
+
* a Node/agent client lands in the SAME entity-scoped group the holder's
|
|
150
|
+
* claim presence fans out on — otherwise a peer subscribed only to
|
|
151
|
+
* `org:`/`user:` groups never sees claim broadcasts. Fire-and-forget and
|
|
152
|
+
* SOFT: read interest is best-effort and must never make a read reject or
|
|
153
|
+
* stall (see `AreaOfInterestManager.reconcile`). Optional so minimal test
|
|
154
|
+
* doubles can omit it. Forwards to `BaseSyncedStore.enterScope`.
|
|
155
|
+
*/
|
|
156
|
+
enterScope?(scope: Record<string, string>): void | Promise<void>;
|
|
157
|
+
/**
|
|
158
|
+
* Pin a scope's sync group(s) (write-intent / prominence): a row this
|
|
159
|
+
* client holds an active claim on stays subscribed regardless of
|
|
160
|
+
* navigation. Same fire-and-forget, soft semantics as `enterScope`.
|
|
161
|
+
* Forwards to `BaseSyncedStore.pinScope`.
|
|
162
|
+
*/
|
|
163
|
+
pinScope?(scope: Record<string, string>): void | Promise<void>;
|
|
152
164
|
}
|
|
153
165
|
export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
154
166
|
/** Phase shown to observers while held. Defaults to `'editing'`. */
|
|
@@ -191,7 +203,7 @@ export interface ClaimLookupParams<T = Record<string, unknown>> {
|
|
|
191
203
|
readonly field?: string;
|
|
192
204
|
}
|
|
193
205
|
export interface ClaimReorderParams<T = Record<string, unknown>> extends ClaimLookupParams<T> {
|
|
194
|
-
readonly order: readonly
|
|
206
|
+
readonly order: readonly Claim[];
|
|
195
207
|
}
|
|
196
208
|
/**
|
|
197
209
|
* A claim handle: the held entity data plus an explicit release hook, so
|
|
@@ -217,32 +229,7 @@ export interface ClaimReorderParams<T = Record<string, unknown>> extends ClaimLo
|
|
|
217
229
|
* `ablo.<model>.update({ id, data, claim })` verb — the handle carries the
|
|
218
230
|
* lease id and snapshot watermark for attribution + stale protection.
|
|
219
231
|
*/
|
|
220
|
-
export
|
|
221
|
-
readonly object: 'claim';
|
|
222
|
-
readonly claimId: string;
|
|
223
|
-
/**
|
|
224
|
-
* Sync watermark of the held snapshot (`data` was read at this stamp).
|
|
225
|
-
* Writes that carry the handle — `update({ id, data, claim })` or
|
|
226
|
-
* `commits.create({ claim, ... })` — use it as the `readAt` stale guard,
|
|
227
|
-
* so a concurrent commit between snapshot and write is rejected instead
|
|
228
|
-
* of clobbered. Optional for wire/duck-type compat with externally
|
|
229
|
-
* constructed handles.
|
|
230
|
-
*/
|
|
231
|
-
readonly readAt?: number;
|
|
232
|
-
readonly target: {
|
|
233
|
-
readonly model: string;
|
|
234
|
-
readonly id: string;
|
|
235
|
-
readonly field?: string;
|
|
236
|
-
readonly path?: string;
|
|
237
|
-
readonly range?: TargetRange;
|
|
238
|
-
readonly meta?: Record<string, unknown>;
|
|
239
|
-
};
|
|
240
|
-
readonly action: string;
|
|
241
|
-
readonly description?: string;
|
|
242
|
-
readonly data: T;
|
|
243
|
-
release(): Promise<void>;
|
|
244
|
-
revoke(): void;
|
|
245
|
-
}
|
|
232
|
+
export type { ClaimHandle };
|
|
246
233
|
export type ClaimOptions<T = Record<string, unknown>> = ClaimTargetOptions<T>;
|
|
247
234
|
/**
|
|
248
235
|
* The coordination surface for a model, exposed as a callable namespace.
|
|
@@ -273,14 +260,14 @@ export interface ClaimApi<T> {
|
|
|
273
260
|
* Current holder for a row, or `null` when free. Use this for UI badges and
|
|
274
261
|
* preflight checks, not for the normal write path.
|
|
275
262
|
*/
|
|
276
|
-
state(params: ClaimLookupParams<T>):
|
|
263
|
+
state(params: ClaimLookupParams<T>): Claim | null;
|
|
277
264
|
/**
|
|
278
265
|
* FIFO wait line behind the current holder. Advanced: useful for operator
|
|
279
266
|
* UIs and schedulers.
|
|
280
267
|
*/
|
|
281
268
|
queue(params: ClaimLookupParams<T>): {
|
|
282
269
|
readonly object: 'list';
|
|
283
|
-
readonly data: readonly
|
|
270
|
+
readonly data: readonly Claim[];
|
|
284
271
|
};
|
|
285
272
|
/**
|
|
286
273
|
* Re-rank the wait line. Advanced and permission-gated.
|