@abloatai/ablo 0.9.1 → 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 +84 -0
- package/CHANGELOG.md +40 -0
- package/README.md +53 -27
- package/dist/BaseSyncedStore.d.ts +2 -36
- package/dist/BaseSyncedStore.js +11 -55
- package/dist/NetworkMonitor.js +4 -1
- package/dist/SyncClient.d.ts +22 -5
- package/dist/SyncClient.js +77 -0
- package/dist/SyncEngineContext.js +5 -1
- package/dist/agent/index.js +1 -1
- package/dist/api/index.d.ts +1 -1
- package/dist/auth/index.js +3 -1
- package/dist/cli.cjs +302645 -0
- package/dist/client/Ablo.d.ts +19 -52
- package/dist/client/Ablo.js +30 -106
- package/dist/client/ApiClient.d.ts +1 -113
- package/dist/client/ApiClient.js +39 -238
- package/dist/client/auth.js +32 -2
- 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/httpClient.d.ts +5 -6
- package/dist/client/httpClient.js +2 -3
- package/dist/client/index.d.ts +1 -1
- 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 +19 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -1
- package/dist/interfaces/index.d.ts +14 -4
- package/dist/mutators/UndoManager.d.ts +48 -5
- package/dist/mutators/UndoManager.js +166 -1
- package/dist/react/AbloProvider.d.ts +18 -8
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/useUndoScope.js +7 -0
- package/dist/schema/ddl.js +2 -1
- package/dist/schema/field.js +2 -1
- package/dist/schema/serialize.js +2 -1
- package/dist/server/commit.d.ts +4 -5
- 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/types/streams.d.ts +2 -1
- package/dist/utils/duration.js +3 -2
- package/dist/wire/frames.d.ts +6 -8
- package/docs/api.md +1 -1
- package/docs/cli.md +17 -4
- package/docs/client-behavior.md +1 -1
- package/docs/data-sources.md +129 -125
- package/docs/examples/ai-sdk-tool.md +11 -5
- package/docs/examples/existing-python-backend.md +26 -4
- package/docs/examples/nextjs.md +3 -2
- package/docs/examples/scoped-agent.md +38 -11
- package/docs/guarantees.md +2 -2
- package/docs/identity.md +86 -59
- package/docs/index.md +2 -2
- package/docs/integration-guide.md +89 -61
- package/docs/mcp.md +1 -1
- package/docs/quickstart.md +84 -37
- package/docs/react.md +39 -28
- package/docs/schema-contract.md +2 -4
- package/llms-full.txt +360 -0
- package/llms.txt +30 -18
- package/package.json +23 -3
package/dist/client/ApiClient.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
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
|
-
const DEFAULT_INTENT_LEASE = '2m';
|
|
13
13
|
export function createProtocolClient(options) {
|
|
14
14
|
const env = readProcessEnv();
|
|
15
15
|
const authInput = { options, env };
|
|
@@ -100,144 +100,6 @@ export function createProtocolClient(options) {
|
|
|
100
100
|
schema: null,
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
|
-
function createAgent(id, agentOptions) {
|
|
104
|
-
return {
|
|
105
|
-
id,
|
|
106
|
-
async run(runOptions, handler) {
|
|
107
|
-
if (runOptions.signal?.aborted) {
|
|
108
|
-
return { status: 'cancelled' };
|
|
109
|
-
}
|
|
110
|
-
let capability = null;
|
|
111
|
-
let task = null;
|
|
112
|
-
try {
|
|
113
|
-
const leaseOptions = agentOptions.leaseSeconds !== undefined
|
|
114
|
-
? { leaseSeconds: agentOptions.leaseSeconds }
|
|
115
|
-
: { lease: agentOptions.lease ?? DEFAULT_AGENT_LEASE };
|
|
116
|
-
capability = await capabilities.create({
|
|
117
|
-
participantKind: 'agent',
|
|
118
|
-
participantId: id,
|
|
119
|
-
syncGroups: agentOptions.syncGroups ?? ['default'],
|
|
120
|
-
operations: agentOptions.can,
|
|
121
|
-
label: agentOptions.label ?? id,
|
|
122
|
-
userMeta: agentOptions.userMeta,
|
|
123
|
-
...leaseOptions,
|
|
124
|
-
});
|
|
125
|
-
const agentClient = capability.client();
|
|
126
|
-
task = await agentClient.tasks.create({
|
|
127
|
-
prompt: runOptions.prompt,
|
|
128
|
-
parentTaskId: runOptions.parentTaskId,
|
|
129
|
-
surface: runOptions.surface ?? 'agent',
|
|
130
|
-
metadata: runOptions.metadata,
|
|
131
|
-
});
|
|
132
|
-
const context = createAgentRunContext(agentClient, task);
|
|
133
|
-
const value = await handler(context);
|
|
134
|
-
await task.close({
|
|
135
|
-
costInputTokens: runOptions.costInputTokens,
|
|
136
|
-
costOutputTokens: runOptions.costOutputTokens,
|
|
137
|
-
costComputeMs: runOptions.costComputeMs,
|
|
138
|
-
});
|
|
139
|
-
return { status: 'done', task, value };
|
|
140
|
-
}
|
|
141
|
-
catch (error) {
|
|
142
|
-
if (task) {
|
|
143
|
-
await task.close({
|
|
144
|
-
costInputTokens: runOptions.costInputTokens,
|
|
145
|
-
costOutputTokens: runOptions.costOutputTokens,
|
|
146
|
-
costComputeMs: runOptions.costComputeMs,
|
|
147
|
-
}).catch(() => { });
|
|
148
|
-
}
|
|
149
|
-
if (isAbortError(error) || runOptions.signal?.aborted) {
|
|
150
|
-
return { status: 'cancelled', task: task ?? undefined, error };
|
|
151
|
-
}
|
|
152
|
-
return { status: 'failed', task: task ?? undefined, error };
|
|
153
|
-
}
|
|
154
|
-
finally {
|
|
155
|
-
if (capability) {
|
|
156
|
-
await capabilities.revoke(capability.id).catch(() => { });
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
function createAgentRunContext(agentClient, task) {
|
|
163
|
-
return {
|
|
164
|
-
task,
|
|
165
|
-
ablo: agentClient,
|
|
166
|
-
model(name) {
|
|
167
|
-
return createAgentModelClient(agentClient, name);
|
|
168
|
-
},
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
function createAgentModelClient(agentClient, name) {
|
|
172
|
-
const base = agentClient.model(name);
|
|
173
|
-
return {
|
|
174
|
-
retrieve(params) {
|
|
175
|
-
// Reads are never blocked by a claim (coordination.md): a claim
|
|
176
|
-
// serializes WRITERS, not readers. So — unlike the create/update/
|
|
177
|
-
// delete paths below — retrieve does NOT apply the agent claimed
|
|
178
|
-
// default; options pass through and the read path's `'return'`
|
|
179
|
-
// default keeps a claimed row readable. A caller can still opt into
|
|
180
|
-
// gating with an explicit `ifClaimed` (developer's choice).
|
|
181
|
-
return base.retrieve(params);
|
|
182
|
-
},
|
|
183
|
-
create(params) {
|
|
184
|
-
const id = params.id ?? createModelId();
|
|
185
|
-
return withAgentIntent(agentClient, name, id, params, (commitIntent) => base.create({
|
|
186
|
-
...stripAgentRuntimeOptions(params),
|
|
187
|
-
id,
|
|
188
|
-
data: params.data,
|
|
189
|
-
intent: commitIntent,
|
|
190
|
-
}));
|
|
191
|
-
},
|
|
192
|
-
update(params) {
|
|
193
|
-
return withAgentIntent(agentClient, name, params.id, params, (commitIntent) => base.update({
|
|
194
|
-
...stripAgentRuntimeOptions(params),
|
|
195
|
-
id: params.id,
|
|
196
|
-
data: params.data,
|
|
197
|
-
intent: commitIntent,
|
|
198
|
-
}));
|
|
199
|
-
},
|
|
200
|
-
delete(params) {
|
|
201
|
-
return withAgentIntent(agentClient, name, params.id, params, (commitIntent) => base.delete({
|
|
202
|
-
...stripAgentRuntimeOptions(params),
|
|
203
|
-
id: params.id,
|
|
204
|
-
intent: commitIntent,
|
|
205
|
-
}));
|
|
206
|
-
},
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
async function withAgentIntent(agentClient, modelName, id, mutationOptions, commit) {
|
|
210
|
-
const intentInput = mutationOptions?.intent;
|
|
211
|
-
const targetOverride = intentInput != null && typeof intentInput === 'object' && !isIntentHandleRef(intentInput)
|
|
212
|
-
? intentInput.target ?? {}
|
|
213
|
-
: {};
|
|
214
|
-
const target = {
|
|
215
|
-
...targetOverride,
|
|
216
|
-
model: targetOverride.model ?? modelName,
|
|
217
|
-
id: targetOverride.id ?? id,
|
|
218
|
-
...(intentInput != null && typeof intentInput === 'object' && !isIntentHandleRef(intentInput) && intentInput.field
|
|
219
|
-
? { field: intentInput.field }
|
|
220
|
-
: {}),
|
|
221
|
-
};
|
|
222
|
-
await applyClaimedPolicy(target, withAgentClaimedDefault(mutationOptions), 'wait');
|
|
223
|
-
if (intentInput == null || isIntentHandleRef(intentInput)) {
|
|
224
|
-
return commit(intentInput);
|
|
225
|
-
}
|
|
226
|
-
const action = typeof intentInput === 'string' ? intentInput : intentInput.action;
|
|
227
|
-
const intent = await agentClient.intents.create({
|
|
228
|
-
target,
|
|
229
|
-
action,
|
|
230
|
-
ttl: typeof intentInput === 'object'
|
|
231
|
-
? intentInput.ttl ?? DEFAULT_INTENT_LEASE
|
|
232
|
-
: DEFAULT_INTENT_LEASE,
|
|
233
|
-
});
|
|
234
|
-
try {
|
|
235
|
-
return await commit(intent);
|
|
236
|
-
}
|
|
237
|
-
finally {
|
|
238
|
-
await intent.release().catch(() => { });
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
103
|
function normalizeCommitOperation(op, defaults) {
|
|
242
104
|
const model = op.model ?? op.target?.model;
|
|
243
105
|
if (!model) {
|
|
@@ -362,15 +224,30 @@ export function createProtocolClient(options) {
|
|
|
362
224
|
}
|
|
363
225
|
const commits = {
|
|
364
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');
|
|
365
235
|
const clientTxId = createClientTxId(commitOptions.idempotencyKey);
|
|
366
|
-
|
|
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
|
+
});
|
|
367
244
|
const body = await requestJson('/v1/commits', {
|
|
368
245
|
method: 'POST',
|
|
369
246
|
idempotencyKey: clientTxId,
|
|
370
247
|
body: JSON.stringify({
|
|
371
248
|
clientTxId,
|
|
372
249
|
idempotencyKey: clientTxId,
|
|
373
|
-
intent: normalizeIntentId(commitOptions.intent),
|
|
250
|
+
intent: normalizeIntentId(commitOptions.intent) ?? claim?.claimId,
|
|
374
251
|
operations,
|
|
375
252
|
}),
|
|
376
253
|
});
|
|
@@ -476,51 +353,6 @@ export function createProtocolClient(options) {
|
|
|
476
353
|
return capabilities.create(options);
|
|
477
354
|
},
|
|
478
355
|
};
|
|
479
|
-
const tasks = {
|
|
480
|
-
async create(taskOptions) {
|
|
481
|
-
const body = await requestJson('/v1/tasks', {
|
|
482
|
-
method: 'POST',
|
|
483
|
-
body: JSON.stringify({
|
|
484
|
-
prompt: taskOptions.prompt,
|
|
485
|
-
parentTaskId: taskOptions.parentTaskId,
|
|
486
|
-
surface: taskOptions.surface,
|
|
487
|
-
metadata: taskOptions.metadata,
|
|
488
|
-
}),
|
|
489
|
-
});
|
|
490
|
-
const id = body.id ?? body.taskId ?? body.turnId;
|
|
491
|
-
if (!id) {
|
|
492
|
-
throw new AbloValidationError('Task create response did not include an id.', { code: 'task_id_missing' });
|
|
493
|
-
}
|
|
494
|
-
return {
|
|
495
|
-
id,
|
|
496
|
-
turnId: id,
|
|
497
|
-
promptHash: body.promptHash,
|
|
498
|
-
openedAt: body.openedAt,
|
|
499
|
-
close: (stats) => tasks.close(id, stats),
|
|
500
|
-
};
|
|
501
|
-
},
|
|
502
|
-
async close(id, stats) {
|
|
503
|
-
const body = await requestJson(`/v1/tasks/${encodeURIComponent(id)}/close`, {
|
|
504
|
-
method: 'POST',
|
|
505
|
-
body: JSON.stringify({
|
|
506
|
-
costInputTokens: stats?.costInputTokens ?? 0,
|
|
507
|
-
costOutputTokens: stats?.costOutputTokens ?? 0,
|
|
508
|
-
costComputeMs: stats?.costComputeMs ?? 0,
|
|
509
|
-
}),
|
|
510
|
-
});
|
|
511
|
-
const closedId = body.id ?? body.taskId ?? body.turnId ?? id;
|
|
512
|
-
return {
|
|
513
|
-
id: closedId,
|
|
514
|
-
turnId: closedId,
|
|
515
|
-
closed: body.closed ?? body.alreadyClosed ?? true,
|
|
516
|
-
alreadyClosed: body.alreadyClosed,
|
|
517
|
-
endedAt: body.endedAt,
|
|
518
|
-
};
|
|
519
|
-
},
|
|
520
|
-
open(options) {
|
|
521
|
-
return tasks.create(options);
|
|
522
|
-
},
|
|
523
|
-
};
|
|
524
356
|
const intents = {
|
|
525
357
|
async create(intentOptions) {
|
|
526
358
|
const intentId = createIntentId();
|
|
@@ -619,17 +451,33 @@ export function createProtocolClient(options) {
|
|
|
619
451
|
* envelopes — this helper is the one-op, one-record path only.
|
|
620
452
|
*/
|
|
621
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}`);
|
|
622
461
|
const clientTxId = createClientTxId(options?.idempotencyKey);
|
|
623
462
|
const encModel = encodeURIComponent(modelName);
|
|
624
463
|
const path = action === 'create'
|
|
625
464
|
? `/v1/models/${encModel}`
|
|
626
465
|
: `/v1/models/${encModel}/${encodeURIComponent(id)}`;
|
|
627
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;
|
|
628
476
|
const requestBody = {
|
|
629
477
|
idempotencyKey: clientTxId,
|
|
630
|
-
intent: normalizeIntentId(options?.intent),
|
|
631
|
-
onStale: options?.onStale,
|
|
632
|
-
readAt
|
|
478
|
+
intent: normalizeIntentId(options?.intent) ?? claimHandle?.claimId,
|
|
479
|
+
onStale: options?.onStale ?? (claimHandle?.readAt !== undefined ? 'reject' : undefined),
|
|
480
|
+
readAt,
|
|
633
481
|
};
|
|
634
482
|
if (action === 'create')
|
|
635
483
|
requestBody.id = id;
|
|
@@ -687,11 +535,12 @@ export function createProtocolClient(options) {
|
|
|
687
535
|
const releaseClaim = (params) => requestJson(claimPath(isClaimHandle(params) ? params.target.id : params.id), { method: 'DELETE' }).then(() => undefined);
|
|
688
536
|
async function claimImpl(params) {
|
|
689
537
|
const claimId = await acquireClaim(params);
|
|
690
|
-
const { data } = await retrieveModel(name, { id: params.id });
|
|
538
|
+
const { data, stamp } = await retrieveModel(name, { id: params.id });
|
|
691
539
|
const release = () => releaseClaim(params);
|
|
692
540
|
return {
|
|
693
541
|
object: 'claim',
|
|
694
542
|
claimId,
|
|
543
|
+
readAt: stamp,
|
|
695
544
|
target: {
|
|
696
545
|
model: name,
|
|
697
546
|
id: params.id,
|
|
@@ -780,35 +629,14 @@ export function createProtocolClient(options) {
|
|
|
780
629
|
async dispose() { },
|
|
781
630
|
async purge() { },
|
|
782
631
|
capabilities,
|
|
783
|
-
tasks,
|
|
784
632
|
intents,
|
|
785
633
|
commits,
|
|
786
634
|
model,
|
|
787
|
-
agent: createAgent,
|
|
788
635
|
async getAuthToken() {
|
|
789
636
|
// Mirror `authHeaders()`: a configured API key wins, else the
|
|
790
637
|
// construction-time auth token. Resolve the (possibly async) key setter.
|
|
791
638
|
return (await resolveApiKeyValue(configuredApiKey)) ?? configuredAuthToken ?? null;
|
|
792
639
|
},
|
|
793
|
-
async beginTurn(turnOptions) {
|
|
794
|
-
const task = await tasks.create(turnOptions);
|
|
795
|
-
let closed = false;
|
|
796
|
-
const close = async (stats) => {
|
|
797
|
-
if (closed)
|
|
798
|
-
return;
|
|
799
|
-
closed = true;
|
|
800
|
-
await task.close(stats);
|
|
801
|
-
};
|
|
802
|
-
const dispose = () => {
|
|
803
|
-
closed = true;
|
|
804
|
-
};
|
|
805
|
-
return {
|
|
806
|
-
turnId: task.id,
|
|
807
|
-
close,
|
|
808
|
-
dispose,
|
|
809
|
-
[Symbol.asyncDispose]: close,
|
|
810
|
-
};
|
|
811
|
-
},
|
|
812
640
|
};
|
|
813
641
|
}
|
|
814
642
|
function normalizeIntentId(intent) {
|
|
@@ -816,33 +644,6 @@ function normalizeIntentId(intent) {
|
|
|
816
644
|
return intent;
|
|
817
645
|
return intent?.id;
|
|
818
646
|
}
|
|
819
|
-
function withAgentClaimedDefault(options) {
|
|
820
|
-
return {
|
|
821
|
-
ifClaimed: 'fail',
|
|
822
|
-
...(options ?? {}),
|
|
823
|
-
};
|
|
824
|
-
}
|
|
825
|
-
function stripAgentRuntimeOptions(options) {
|
|
826
|
-
if (!options)
|
|
827
|
-
return undefined;
|
|
828
|
-
const { intent: _intent, ifClaimed: _ifClaimed, claimedTimeout: _claimedTimeout, claimedPollInterval: _claimedPollInterval, maxQueueDepth: _maxQueueDepth, ...rest } = options;
|
|
829
|
-
return rest;
|
|
830
|
-
}
|
|
831
|
-
function isIntentHandleRef(input) {
|
|
832
|
-
return (typeof input === 'object' &&
|
|
833
|
-
input !== null &&
|
|
834
|
-
'id' in input &&
|
|
835
|
-
typeof input.id === 'string' &&
|
|
836
|
-
!('action' in input));
|
|
837
|
-
}
|
|
838
|
-
function isAbortError(error) {
|
|
839
|
-
return (typeof DOMException !== 'undefined' &&
|
|
840
|
-
error instanceof DOMException &&
|
|
841
|
-
error.name === 'AbortError') || (typeof error === 'object' &&
|
|
842
|
-
error !== null &&
|
|
843
|
-
'name' in error &&
|
|
844
|
-
error.name === 'AbortError');
|
|
845
|
-
}
|
|
846
647
|
function parseBody(bodyText) {
|
|
847
648
|
if (bodyText.length === 0)
|
|
848
649
|
return null;
|
package/dist/client/auth.js
CHANGED
|
@@ -146,8 +146,38 @@ export function resolveBootstrapBaseUrl(input) {
|
|
|
146
146
|
// legitimately arrive as `wss://…` — normalize it here rather than
|
|
147
147
|
// faceplanting at fetch time. The derive branch below already does this;
|
|
148
148
|
// the override branch silently skipped it.
|
|
149
|
-
return normalizeAbloHostedBaseUrl(input.bootstrapBaseUrl).replace(/^ws/, 'http');
|
|
149
|
+
return ensureApiSuffix(normalizeAbloHostedBaseUrl(input.bootstrapBaseUrl).replace(/^ws/, 'http'));
|
|
150
150
|
}
|
|
151
151
|
const url = normalizeAbloHostedBaseUrl(input.url);
|
|
152
|
-
return
|
|
152
|
+
return ensureApiSuffix(url.replace(/^ws/, 'http'));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Guarantee the HTTP base ends in the `/api` route segment the sync-server
|
|
156
|
+
* mounts every endpoint under (`apps/sync-server/src/index.ts` — `app.route('/api', …)`).
|
|
157
|
+
*
|
|
158
|
+
* The derive branch always appended `/api`; the override branch did NOT,
|
|
159
|
+
* trusting the caller (apps/web passes `${baseUrl}/api`). But a hosted
|
|
160
|
+
* customer setting a custom `baseURL`/`bootstrapBaseUrl` (their own subdomain,
|
|
161
|
+
* staging, etc.) without the suffix sent every credential exchange to
|
|
162
|
+
* `…/auth/capability` instead of `…/api/auth/capability` → a 404 surfaced as
|
|
163
|
+
* `exchange_failed`. Since the SDK hardcodes routes relative to this base and
|
|
164
|
+
* there is no valid Ablo deployment that serves them off the root, normalizing
|
|
165
|
+
* to a single trailing `/api` here is always correct — and idempotent for
|
|
166
|
+
* callers who already include it.
|
|
167
|
+
*/
|
|
168
|
+
function ensureApiSuffix(httpBase) {
|
|
169
|
+
const trimmed = httpBase.replace(/\/+$/, '');
|
|
170
|
+
try {
|
|
171
|
+
const u = new URL(trimmed);
|
|
172
|
+
const segments = u.pathname.split('/').filter(Boolean);
|
|
173
|
+
if (segments[segments.length - 1] === 'api')
|
|
174
|
+
return trimmed;
|
|
175
|
+
u.pathname = `${u.pathname.replace(/\/+$/, '')}/api`;
|
|
176
|
+
return u.toString().replace(/\/+$/, '');
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Should be unreachable post-`normalizeAbloHostedBaseUrl` (which yields an
|
|
180
|
+
// absolute URL), but fall back to a string check rather than throwing.
|
|
181
|
+
return /\/api$/.test(trimmed) ? trimmed : `${trimmed}/api`;
|
|
182
|
+
}
|
|
153
183
|
}
|
|
@@ -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);
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* wraps it in a typed proxy facade so server code gets the SAME `client.<model>`
|
|
23
23
|
* surface as the browser client — typed proxies, stateless transport.
|
|
24
24
|
*/
|
|
25
|
-
import { type AbloApiClientOptions
|
|
26
|
-
import type { CommitReceipt, CommitResource, HttpClaimApi, ModelRead, ModelReadOptions
|
|
25
|
+
import { type AbloApiClientOptions } from './ApiClient.js';
|
|
26
|
+
import type { CommitReceipt, CommitResource, HttpClaimApi, ModelRead, ModelReadOptions } from './Ablo.js';
|
|
27
27
|
import type { ModelCreateParams, ModelDeleteParams, ModelLoadOptions, ModelRetrieveParams, ModelUpdateParams } from './createModelProxy.js';
|
|
28
28
|
import type { Schema, SchemaRecord, InferModel, InferCreate } from '../schema/schema.js';
|
|
29
29
|
export interface AbloHttpClientOptions<S extends SchemaRecord> extends Omit<AbloApiClientOptions, 'schema'> {
|
|
@@ -47,7 +47,7 @@ export interface HttpModelClient<T, C = T> {
|
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
49
|
* The honest type of the stateless HTTP client: typed model proxies (the
|
|
50
|
-
* request/response subset) + `commits` + `
|
|
50
|
+
* request/response subset) + `commits` + `dispose`. Reaching for a
|
|
51
51
|
* stateful-only capability (`get`/`getAll`/`getCount`, `onChange`,
|
|
52
52
|
* `claim.state`/`queue`/`reorder`) is a COMPILE error here, not a latent runtime
|
|
53
53
|
* `undefined` — the type matches what the transport can actually do.
|
|
@@ -56,7 +56,6 @@ export type AbloHttpClient<S extends SchemaRecord> = {
|
|
|
56
56
|
readonly [K in keyof S & string]: HttpModelClient<InferModel<Schema<S>, K>, InferCreate<Schema<S>, K>>;
|
|
57
57
|
} & {
|
|
58
58
|
readonly commits: CommitResource;
|
|
59
|
-
beginTurn(options: TaskCreateOptions): Promise<Turn>;
|
|
60
59
|
dispose(): Promise<void>;
|
|
61
60
|
/** Resolve the bearer credential this client authenticates with (see `AbloApi.getAuthToken`). */
|
|
62
61
|
getAuthToken(): Promise<string | null>;
|
|
@@ -65,7 +64,7 @@ export type AbloHttpClient<S extends SchemaRecord> = {
|
|
|
65
64
|
};
|
|
66
65
|
/**
|
|
67
66
|
* Stateless, typed HTTP client. Each `client.<model>` resolves to the protocol
|
|
68
|
-
* client's `model(name)`; `commits`, `
|
|
69
|
-
*
|
|
67
|
+
* client's `model(name)`; `commits`, `dispose`, etc. pass through. No socket is
|
|
68
|
+
* ever opened; identity is the Bearer credential.
|
|
70
69
|
*/
|
|
71
70
|
export declare function createAbloHttpClient<S extends SchemaRecord>(options: AbloHttpClientOptions<S>): AbloHttpClient<S>;
|
|
@@ -36,14 +36,13 @@ const PROTOCOL_MEMBERS = new Set([
|
|
|
36
36
|
'dispose',
|
|
37
37
|
'purge',
|
|
38
38
|
'commits',
|
|
39
|
-
'beginTurn',
|
|
40
39
|
'model',
|
|
41
40
|
'getAuthToken',
|
|
42
41
|
]);
|
|
43
42
|
/**
|
|
44
43
|
* Stateless, typed HTTP client. Each `client.<model>` resolves to the protocol
|
|
45
|
-
* client's `model(name)`; `commits`, `
|
|
46
|
-
*
|
|
44
|
+
* client's `model(name)`; `commits`, `dispose`, etc. pass through. No socket is
|
|
45
|
+
* ever opened; identity is the Bearer credential.
|
|
47
46
|
*/
|
|
48
47
|
export function createAbloHttpClient(options) {
|
|
49
48
|
// The schema is type-level only; the protocol client is schema-agnostic.
|
package/dist/client/index.d.ts
CHANGED
|
@@ -32,5 +32,5 @@
|
|
|
32
32
|
export { Ablo, computeFKDepthPriority, type AbloOptions, type InternalAbloOptions, type ClaimedOptions, type IfClaimedPolicy, type IntentWaitOptions, type ModelCountOptions, type ModelListOptions, type ModelListScope, type ModelLoadOptions, type ModelOperations, type ModelReadOptions, } from './Ablo.js';
|
|
33
33
|
export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './auth.js';
|
|
34
34
|
export type { AbloPersistence } from './persistence.js';
|
|
35
|
-
export type { AbloApi, AbloApiClientOptions, AbloApiIntents,
|
|
35
|
+
export type { AbloApi, AbloApiClientOptions, AbloApiIntents, Capability, CapabilityCreateOptions, CapabilityParticipantKind, CapabilityRecord, CapabilityResource, CapabilityRevocation, CapabilityScope, } from './ApiClient.js';
|
|
36
36
|
export type { EngineParticipant, JoinedParticipant, ParticipantJoinOptions, ParticipantManager, ParticipantScope, ParticipantStatus, ScopedIntents, ScopedPresence, } from '../sync/participants.js';
|
|
@@ -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>;
|