@abloatai/ablo 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/README.md +95 -57
- package/dist/BaseSyncedStore.d.ts +1 -1
- package/dist/BaseSyncedStore.js +8 -4
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +3 -2
- package/dist/auth/index.js +39 -11
- package/dist/client/Ablo.d.ts +112 -3
- package/dist/client/Ablo.js +144 -10
- package/dist/client/ApiClient.d.ts +32 -0
- package/dist/client/ApiClient.js +76 -44
- package/dist/client/auth.d.ts +11 -1
- package/dist/client/auth.js +21 -2
- package/dist/client/createModelProxy.d.ts +120 -53
- package/dist/client/createModelProxy.js +66 -31
- package/dist/client/identity.js +14 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +57 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +286 -0
- package/dist/errorCodes.js +284 -0
- package/dist/errors.d.ts +103 -7
- package/dist/errors.js +192 -41
- package/dist/index.d.ts +11 -6
- package/dist/index.js +10 -6
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +37 -0
- package/dist/react/AbloProvider.js +107 -4
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +6 -0
- package/dist/schema/diff.js +21 -3
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/index.d.ts +7 -4
- package/dist/schema/index.js +9 -3
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +2 -112
- package/dist/schema/schema.js +50 -62
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +16 -12
- package/dist/schema/serialize.js +16 -12
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- package/dist/sync/BootstrapHelper.js +46 -27
- package/dist/sync/ConnectionManager.d.ts +3 -1
- package/dist/sync/ConnectionManager.js +37 -1
- package/dist/sync/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +26 -19
- package/dist/sync/NetworkProbe.d.ts +8 -0
- package/dist/sync/NetworkProbe.js +24 -2
- package/dist/sync/SyncWebSocket.d.ts +1 -1
- package/dist/sync/SyncWebSocket.js +43 -53
- package/dist/sync/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +46 -1
- package/dist/sync/participants.js +10 -16
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +53 -33
- package/docs/api-keys.md +47 -3
- package/docs/api.md +103 -57
- package/docs/audit.md +16 -9
- package/docs/cli.md +222 -0
- package/docs/client-behavior.md +35 -21
- package/docs/coordination.md +74 -36
- package/docs/data-sources.md +23 -21
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +30 -19
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +93 -0
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +29 -17
- package/docs/identity.md +198 -121
- package/docs/index.md +35 -18
- package/docs/integration-guide.md +79 -83
- package/docs/interaction-model.md +40 -25
- package/docs/mcp/claude-code.md +9 -17
- package/docs/mcp/cursor.md +6 -24
- package/docs/mcp/windsurf.md +6 -19
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +31 -39
- package/docs/react.md +18 -14
- package/docs/roadmap.md +15 -3
- package/docs/schema-contract.md +109 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +6 -2
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +27 -16
- package/package.json +13 -1
package/dist/client/auth.js
CHANGED
|
@@ -27,6 +27,15 @@ export function resolveApiKey(input) {
|
|
|
27
27
|
export function resolveAuthToken(input) {
|
|
28
28
|
return input.options.authToken ?? null;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the customer's own-Postgres connection string for write-back
|
|
32
|
+
* (dedicated/BYO tenant). Falls back to `DATABASE_URL` — the Prisma-style
|
|
33
|
+
* convention — so a server-side app that already exports it needs no extra
|
|
34
|
+
* config. Returns null for Ablo-managed storage (the hosted default).
|
|
35
|
+
*/
|
|
36
|
+
export function resolveDatabaseUrl(input) {
|
|
37
|
+
return input.options.databaseUrl ?? input.env.DATABASE_URL ?? null;
|
|
38
|
+
}
|
|
30
39
|
export const ABLO_DEFAULT_BASE_URL = 'wss://mesh.ablo.finance';
|
|
31
40
|
export function resolveBaseURL(input) {
|
|
32
41
|
return input.options.baseURL ?? ABLO_DEFAULT_BASE_URL;
|
|
@@ -35,11 +44,13 @@ export function resolveBaseURL(input) {
|
|
|
35
44
|
* Browser guard — apiKey is server-side-only by default. Same check
|
|
36
45
|
* Anthropic, OpenAI, and Stripe ship: shipping `sk_live_...` to a
|
|
37
46
|
* browser exposes it in every visitor's network tab. Consumers opt
|
|
38
|
-
* in explicitly when
|
|
47
|
+
* in explicitly when the browser holds a minted session token
|
|
48
|
+
* (`ek_`/`rk_`) or routes through a server proxy.
|
|
39
49
|
*/
|
|
40
50
|
export function assertBrowserSafety(input) {
|
|
51
|
+
const inBrowser = typeof window !== 'undefined';
|
|
41
52
|
if (!input.dangerouslyAllowBrowser &&
|
|
42
|
-
|
|
53
|
+
inBrowser &&
|
|
43
54
|
typeof input.apiKey === 'string' &&
|
|
44
55
|
input.apiKey.startsWith('sk_')) {
|
|
45
56
|
throw new AbloAuthenticationError("It looks like you're running in a browser-like environment.\n\n" +
|
|
@@ -49,6 +60,14 @@ export function assertBrowserSafety(input) {
|
|
|
49
60
|
'`dangerouslyAllowBrowser` option to `true`, e.g.,\n\n' +
|
|
50
61
|
' Ablo({ schema, apiKey, dangerouslyAllowBrowser: true });\n', { code: 'browser_apikey_blocked' });
|
|
51
62
|
}
|
|
63
|
+
// `databaseUrl` carries DB credentials and is NEVER browser-safe, so
|
|
64
|
+
// `dangerouslyAllowBrowser` does not override it. Register your database from
|
|
65
|
+
// a server-side runtime.
|
|
66
|
+
if (inBrowser && typeof input.databaseUrl === 'string' && input.databaseUrl.length > 0) {
|
|
67
|
+
throw new AbloAuthenticationError('Ablo `databaseUrl` cannot be used in a browser-like environment — it ' +
|
|
68
|
+
'carries your database credentials. Initialize the client with ' +
|
|
69
|
+
'`databaseUrl` from a server-side runtime only.', { code: 'browser_database_url_blocked' });
|
|
70
|
+
}
|
|
52
71
|
}
|
|
53
72
|
/**
|
|
54
73
|
* Resolve an `ApiKeySetter` callable to its current string value.
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
* testable in isolation and the constructor doesn't carry it.
|
|
8
8
|
*
|
|
9
9
|
* Each schema model gets one `ModelOperations<T, CreateInput>` —
|
|
10
|
-
* exposes `retrieve
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
10
|
+
* exposes the async server reads `retrieve` / `list`, the synchronous
|
|
11
|
+
* local-graph snapshots `get` / `getAll` / `getCount`, the writes
|
|
12
|
+
* `create` / `update` / `delete`, the coordination namespace `claim`
|
|
13
|
+
* (`claim(id, work)` plus `claim.state` / `claim.queue` / `claim.release` /
|
|
14
|
+
* `claim.reorder`), and `onChange`. The factory returns a plain object; the
|
|
15
|
+
* client assembles the `ablo.<model>` lookup table from these.
|
|
14
16
|
*/
|
|
15
17
|
import type { MutationOptions } from '../interfaces/index.js';
|
|
16
18
|
import type { ModelRegistry } from '../ModelRegistry.js';
|
|
@@ -35,10 +37,12 @@ export interface ModelListOptions<T> {
|
|
|
35
37
|
};
|
|
36
38
|
limit?: number;
|
|
37
39
|
offset?: number;
|
|
38
|
-
/** Lifecycle
|
|
39
|
-
|
|
40
|
+
/** Lifecycle filter — `live` (default), `archived`, or `all`. Named `state`
|
|
41
|
+
* (GitHub's open/closed/all precedent) so it doesn't collide with the
|
|
42
|
+
* sync-group `scope`. */
|
|
43
|
+
state?: ModelListScope;
|
|
40
44
|
}
|
|
41
|
-
export type ModelCountOptions<T> = Pick<ModelListOptions<T>, 'where' | 'filter' | '
|
|
45
|
+
export type ModelCountOptions<T> = Pick<ModelListOptions<T>, 'where' | 'filter' | 'state'>;
|
|
42
46
|
export interface ModelLoadOptions<T> {
|
|
43
47
|
/**
|
|
44
48
|
* Filter for the lookup. Accepts:
|
|
@@ -46,7 +50,7 @@ export interface ModelLoadOptions<T> {
|
|
|
46
50
|
* - tuple form: `[['name', 'ILIKE', '%Goldman%']]` for operators
|
|
47
51
|
*
|
|
48
52
|
* See `LoadWhere<T>` in `query/types.ts`. For OR semantics, run two
|
|
49
|
-
* `
|
|
53
|
+
* `list()` calls and union — the wire protocol is AND-only.
|
|
50
54
|
*/
|
|
51
55
|
where?: LoadWhere<T>;
|
|
52
56
|
orderBy?: {
|
|
@@ -66,6 +70,9 @@ export interface ModelLoadOptions<T> {
|
|
|
66
70
|
*/
|
|
67
71
|
expand?: readonly string[];
|
|
68
72
|
}
|
|
73
|
+
/** Options for the single-row async server read `retrieve(id)`. A subset of
|
|
74
|
+
* {@link ModelLoadOptions} — `where`/`limit`/`orderBy` are fixed by the id. */
|
|
75
|
+
export type ModelRetrieveOptions = Pick<ModelLoadOptions<unknown>, 'type' | 'expand'>;
|
|
69
76
|
export interface IntentLeaseHandle {
|
|
70
77
|
readonly id: string;
|
|
71
78
|
release(): Promise<void>;
|
|
@@ -109,6 +116,14 @@ export interface ModelCollaboration<T> {
|
|
|
109
116
|
model: string;
|
|
110
117
|
id: string;
|
|
111
118
|
}): readonly Intent[];
|
|
119
|
+
/**
|
|
120
|
+
* Re-rank the wait queue on a target (privileged — server-gated). `order` is
|
|
121
|
+
* the desired front-of-line ordering, taken from `queue(target)`.
|
|
122
|
+
*/
|
|
123
|
+
reorder(target: {
|
|
124
|
+
model: string;
|
|
125
|
+
id: string;
|
|
126
|
+
}, order: readonly Intent[]): void;
|
|
112
127
|
/**
|
|
113
128
|
* Resolve once no participant holds an active intent on the target.
|
|
114
129
|
* The contender's "wait until it's free" — delegates to the intent
|
|
@@ -163,24 +178,99 @@ export interface ClaimOptions {
|
|
|
163
178
|
* verb — there is no method chaining on the claim.
|
|
164
179
|
*/
|
|
165
180
|
export type ClaimedRow<T> = T & AsyncDisposable;
|
|
181
|
+
/**
|
|
182
|
+
* The coordination surface for a model, exposed as a callable namespace.
|
|
183
|
+
*
|
|
184
|
+
* Calling it takes a claim — either the callback form (`claim(id, work)`, which
|
|
185
|
+
* releases when `work` settles) or the bare lease form (`claim(id)`). Its
|
|
186
|
+
* members read and steer the same coordination plane: `state` (current holder),
|
|
187
|
+
* `queue` (the wait line), `release` (drop a held claim), `reorder` (re-rank the
|
|
188
|
+
* line). All four stay on the ephemeral coordination plane — never persisted,
|
|
189
|
+
* never a `SyncDelta`.
|
|
190
|
+
*/
|
|
191
|
+
export interface ClaimApi<T> {
|
|
192
|
+
/** Take a claim and get the held row back (bare lease form). */
|
|
193
|
+
(id: string, options?: ClaimOptions): Promise<ClaimedRow<T>>;
|
|
194
|
+
/**
|
|
195
|
+
* Take a claim, run `work` with the held row, release when it settles. The
|
|
196
|
+
* preferred form for ordinary held work.
|
|
197
|
+
*/
|
|
198
|
+
<R>(id: string, work: (row: ClaimedRow<T>) => Promise<R> | R, options?: ClaimOptions): Promise<R>;
|
|
199
|
+
/**
|
|
200
|
+
* Who's coordinating on a row — the current holder (who, phase, until when),
|
|
201
|
+
* or `null` when free. Synchronous and reactive; for observers/UI. Never
|
|
202
|
+
* blocks.
|
|
203
|
+
*/
|
|
204
|
+
state(id: string): Intent | null;
|
|
205
|
+
/**
|
|
206
|
+
* The wait queue on a row — who's lined up behind the holder and what each
|
|
207
|
+
* intends. Reactive snapshot (synced from the server, like `activity`);
|
|
208
|
+
* returns a Stripe-style list envelope, FIFO order, empty when no one waits.
|
|
209
|
+
*
|
|
210
|
+
* ```ts
|
|
211
|
+
* const { data } = ablo.decks.claim.queue('deck_1');
|
|
212
|
+
* // → [{ heldBy: 'agent:summarizer', action: 'editing', position: 0 }, …]
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
queue(id: string): {
|
|
216
|
+
readonly object: 'list';
|
|
217
|
+
readonly data: readonly Intent[];
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* Re-rank the wait queue on a record — move waiters to the front in the given
|
|
221
|
+
* order. Pass the `Intent[]` from `claim.queue(id).data`, reordered. A
|
|
222
|
+
* privileged operation: the server gates it (the caller needs the
|
|
223
|
+
* `intent.reorder` capability), so it's fire-and-forget — the new order
|
|
224
|
+
* arrives reactively through `claim.queue(id)`.
|
|
225
|
+
*
|
|
226
|
+
* ```ts
|
|
227
|
+
* const { data } = ablo.decks.claim.queue('deck_1');
|
|
228
|
+
* ablo.decks.claim.reorder('deck_1', [data[2], data[0], data[1]]); // promote #2
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
reorder(id: string, order: readonly Intent[]): void;
|
|
232
|
+
/** Release a claim you hold early. Usually implicit (scope exit). */
|
|
233
|
+
release(id: string): Promise<void>;
|
|
234
|
+
}
|
|
166
235
|
export interface ModelOperations<T, CreateInput> {
|
|
167
236
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
237
|
+
* Read a single entity by id from the **server** — async. Resolves through
|
|
238
|
+
* the 3-tier lookup (local pool → IndexedDB → network `POST /sync/query`)
|
|
239
|
+
* and lands the row in the local graph. Resolves to `undefined` when no
|
|
240
|
+
* such row exists.
|
|
171
241
|
*
|
|
172
|
-
*
|
|
242
|
+
* This is the default "get me this entity" read and the one hosted /
|
|
243
|
+
* stateless callers want, since their local graph starts empty. For a
|
|
244
|
+
* synchronous read of an already-warm graph (a React selector) use
|
|
245
|
+
* `get(id)`.
|
|
246
|
+
*
|
|
247
|
+
* Mirrors `stripe.customers.retrieve(id)` — network-backed.
|
|
173
248
|
*/
|
|
174
|
-
retrieve(id: string): T | undefined
|
|
249
|
+
retrieve(id: string, options?: ModelRetrieveOptions): Promise<T | undefined>;
|
|
175
250
|
/**
|
|
176
|
-
* List entities matching a filter from the
|
|
177
|
-
*
|
|
251
|
+
* List entities matching a filter from the **server** — async. Same 3-tier
|
|
252
|
+
* lookup + graph hydration as `retrieve`; single-flight deduped. Returns the
|
|
253
|
+
* matched rows.
|
|
178
254
|
*
|
|
179
|
-
* Mirrors `stripe.customers.list({...})
|
|
255
|
+
* Mirrors `stripe.customers.list({...})` — network-backed. For a synchronous
|
|
256
|
+
* read of the local graph use `getAll(...)`.
|
|
257
|
+
*/
|
|
258
|
+
list(options?: ModelLoadOptions<T>): Promise<T[]>;
|
|
259
|
+
/**
|
|
260
|
+
* Synchronous snapshot of a single entity from the **local graph** — no
|
|
261
|
+
* network. Returns `undefined` when the row isn't resident (cold hosted
|
|
262
|
+
* client, or a `lazy` model not yet loaded). Pairs with reactive selectors:
|
|
263
|
+
* `useAblo((ablo) => ablo.<model>.get(id))`.
|
|
180
264
|
*/
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
|
|
265
|
+
get(id: string): T | undefined;
|
|
266
|
+
/**
|
|
267
|
+
* Synchronous snapshot of a filtered collection from the **local graph** —
|
|
268
|
+
* no network round-trip. Empty until `retrieve`/`list`/bootstrap has warmed
|
|
269
|
+
* the graph.
|
|
270
|
+
*/
|
|
271
|
+
getAll(options?: ModelListOptions<T>): T[];
|
|
272
|
+
/** Count entities in the **local graph** (synchronous, no network). */
|
|
273
|
+
getCount(options?: ModelCountOptions<T>): number;
|
|
184
274
|
/**
|
|
185
275
|
* Create a new entity — **optimistic, offline-first**. Resolves once
|
|
186
276
|
* the mutation is queued locally, not when the server confirms.
|
|
@@ -192,50 +282,27 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
192
282
|
/** Delete an entity by id — optimistic, offline-first (see `create`). */
|
|
193
283
|
delete(id: string, options?: MutationOptions): Promise<void>;
|
|
194
284
|
/**
|
|
195
|
-
* Claim a row so other writers wait or are rejected until you're done
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
285
|
+
* Claim a row so other writers wait or are rejected until you're done, and
|
|
286
|
+
* inspect or manage that coordination through the same namespace. Call it to
|
|
287
|
+
* take a claim (the callback form releases when the callback returns or
|
|
288
|
+
* throws); reach for its members to observe and steer the wait line:
|
|
289
|
+
*
|
|
290
|
+
* - `claim.state(id)` — who holds the row now, or `null` when free
|
|
291
|
+
* - `claim.queue(id)` — who's lined up behind the holder
|
|
292
|
+
* - `claim.release(id)` — drop a claim early (usually implicit on scope exit)
|
|
293
|
+
* - `claim.reorder(id, order)` — re-rank the wait line
|
|
199
294
|
*
|
|
200
295
|
* ```ts
|
|
201
296
|
* await ablo.weatherReports.claim('report_stockholm', async (report) => {
|
|
202
297
|
* const weather = await getWeather(report.location);
|
|
203
298
|
* await ablo.weatherReports.update(report.id, { forecast: weather });
|
|
204
299
|
* });
|
|
205
|
-
* ```
|
|
206
|
-
*/
|
|
207
|
-
claim(id: string, options?: ClaimOptions): Promise<ClaimedRow<T>>;
|
|
208
|
-
claim<R>(id: string, work: (row: ClaimedRow<T>) => Promise<R> | R, options?: ClaimOptions): Promise<R>;
|
|
209
|
-
/**
|
|
210
|
-
* Read who's coordinating on a row — the current holder (who, phase,
|
|
211
|
-
* until when), or `null` when free. Synchronous and reactive; for
|
|
212
|
-
* observers/UI. Never blocks.
|
|
213
|
-
*/
|
|
214
|
-
claimState(id: string): Intent | null;
|
|
215
|
-
/**
|
|
216
|
-
* The wait queue on a row — who's lined up behind the holder and what each
|
|
217
|
-
* intends. Reactive snapshot (synced from the server, like `activity`);
|
|
218
|
-
* returns a Stripe-style list envelope, FIFO order, empty when no one waits.
|
|
219
300
|
*
|
|
220
|
-
*
|
|
221
|
-
* const { data } = ablo.decks.queue('deck_1');
|
|
222
|
-
* // → [{ heldBy: 'agent:summarizer', action: 'editing', position: 0 }, …]
|
|
301
|
+
* const holder = ablo.weatherReports.claim.state('report_stockholm');
|
|
223
302
|
* ```
|
|
224
303
|
*/
|
|
225
|
-
|
|
226
|
-
readonly object: 'list';
|
|
227
|
-
readonly data: readonly Intent[];
|
|
228
|
-
};
|
|
229
|
-
/** Release a claim you hold early. Usually implicit (scope exit). */
|
|
230
|
-
release(id: string): Promise<void>;
|
|
304
|
+
claim: ClaimApi<T>;
|
|
231
305
|
/** Listen for changes (callback called on every change). */
|
|
232
306
|
onChange(callback: (entities: T[]) => void, options?: ModelListOptions<T>): () => void;
|
|
233
|
-
/**
|
|
234
|
-
* Load matching rows into the local graph if they are not already
|
|
235
|
-
* present. Single-flight: concurrent calls with the same args share
|
|
236
|
-
* one in-flight request. Default `type: 'complete'` waits for the
|
|
237
|
-
* server; `type: 'unknown'` returns local + refreshes async.
|
|
238
|
-
*/
|
|
239
|
-
load(options?: ModelLoadOptions<T>): Promise<T[]>;
|
|
240
307
|
}
|
|
241
308
|
export declare function createModelProxy<T, C>(schemaKey: string, registeredModelName: string, objectPool: ObjectPool, syncClient: SyncClient, registry: ModelRegistry, hydration: HydrationCoordinator, collaboration?: ModelCollaboration<T>): ModelOperations<T, C>;
|
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
* testable in isolation and the constructor doesn't carry it.
|
|
8
8
|
*
|
|
9
9
|
* Each schema model gets one `ModelOperations<T, CreateInput>` —
|
|
10
|
-
* exposes `retrieve
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
10
|
+
* exposes the async server reads `retrieve` / `list`, the synchronous
|
|
11
|
+
* local-graph snapshots `get` / `getAll` / `getCount`, the writes
|
|
12
|
+
* `create` / `update` / `delete`, the coordination namespace `claim`
|
|
13
|
+
* (`claim(id, work)` plus `claim.state` / `claim.queue` / `claim.release` /
|
|
14
|
+
* `claim.reorder`), and `onChange`. The factory returns a plain object; the
|
|
15
|
+
* client assembles the `ablo.<model>` lookup table from these.
|
|
14
16
|
*/
|
|
15
17
|
import { autorun } from 'mobx';
|
|
16
|
-
import { AbloClaimedError, AbloValidationError } from '../errors.js';
|
|
18
|
+
import { AbloClaimedError, AbloValidationError, toAbloError, } from '../errors.js';
|
|
17
19
|
import { Model, modelAsRow } from '../Model.js';
|
|
18
20
|
import { ModelScope } from '../types/index.js';
|
|
19
21
|
const modelClientMeta = new WeakMap();
|
|
@@ -28,6 +30,22 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
28
30
|
throw new AbloValidationError(`Ablo: schema model "${schemaKey}" resolved to "${registeredModelName}", ` +
|
|
29
31
|
'but no matching constructor was registered.', { code: 'model_not_registered' });
|
|
30
32
|
}
|
|
33
|
+
// Last-line guarantee for the public surface: any rejection from a lower
|
|
34
|
+
// layer (transport timeout, IndexedDB failure, a third-party throw) is
|
|
35
|
+
// coerced to an AbloError before it reaches the consumer. The SDK's
|
|
36
|
+
// contract is that callers only ever catch tagged errors — `instanceof
|
|
37
|
+
// AbloError` / `e.type` always hold. Internal helpers stay unwrapped; only
|
|
38
|
+
// the methods exposed on `operations` are guarded.
|
|
39
|
+
const guard = (fn) => {
|
|
40
|
+
return async (...args) => {
|
|
41
|
+
try {
|
|
42
|
+
return await fn(...args);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
throw toAbloError(err);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
};
|
|
31
49
|
const load = async (options) => {
|
|
32
50
|
const rows = await hydration.fetch(schemaKey, options);
|
|
33
51
|
// The coordinator returns Model instances. ModelOperations is
|
|
@@ -129,12 +147,40 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
129
147
|
}
|
|
130
148
|
return takeClaim(id, a);
|
|
131
149
|
}
|
|
150
|
+
// `claim` is a callable namespace: invoke it to take a claim, reach its
|
|
151
|
+
// members to read/steer the coordination plane. Attach the readers to the
|
|
152
|
+
// guarded callable so `ablo.<model>.claim(...)` and `ablo.<model>.claim.state(...)`
|
|
153
|
+
// are the same object.
|
|
154
|
+
const claimApi = Object.assign(guard(claim), {
|
|
155
|
+
state(id) {
|
|
156
|
+
return collaboration?.observe({ model: schemaKey, id }) ?? null;
|
|
157
|
+
},
|
|
158
|
+
queue(id) {
|
|
159
|
+
return {
|
|
160
|
+
object: 'list',
|
|
161
|
+
data: collaboration?.queue({ model: schemaKey, id }) ?? [],
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
reorder(id, order) {
|
|
165
|
+
collaboration?.reorder({ model: schemaKey, id }, order);
|
|
166
|
+
},
|
|
167
|
+
release: guard((id) => releaseClaim(id)),
|
|
168
|
+
});
|
|
132
169
|
const operations = {
|
|
133
|
-
retrieve(id) {
|
|
170
|
+
retrieve: guard(async (id, options) => {
|
|
171
|
+
const rows = await load({
|
|
172
|
+
...options,
|
|
173
|
+
where: { id },
|
|
174
|
+
limit: 1,
|
|
175
|
+
});
|
|
176
|
+
return rows[0];
|
|
177
|
+
}),
|
|
178
|
+
list: guard(load),
|
|
179
|
+
get(id) {
|
|
134
180
|
return objectPool.get(id);
|
|
135
181
|
},
|
|
136
|
-
|
|
137
|
-
const all = objectPool.getByType(ModelClass, (options?.
|
|
182
|
+
getAll(options) {
|
|
183
|
+
const all = objectPool.getByType(ModelClass, (options?.state ?? ModelScope.live));
|
|
138
184
|
let result = all;
|
|
139
185
|
if (options?.where) {
|
|
140
186
|
const where = options.where;
|
|
@@ -166,10 +212,10 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
166
212
|
result = result.slice(0, options.limit);
|
|
167
213
|
return result;
|
|
168
214
|
},
|
|
169
|
-
|
|
170
|
-
return this.
|
|
215
|
+
getCount(options) {
|
|
216
|
+
return this.getAll(options).length;
|
|
171
217
|
},
|
|
172
|
-
async
|
|
218
|
+
create: guard(async (data, options) => {
|
|
173
219
|
// TODO(options-persistence): stash `options` alongside the
|
|
174
220
|
// queued transaction so idempotencyKey survives offline flush.
|
|
175
221
|
const model = new ModelClass({
|
|
@@ -181,8 +227,8 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
181
227
|
syncClient.add(model, options);
|
|
182
228
|
await waitForMutation(model, options);
|
|
183
229
|
return modelAsRow(model);
|
|
184
|
-
},
|
|
185
|
-
async
|
|
230
|
+
}),
|
|
231
|
+
update: guard(async (id, data, options) => {
|
|
186
232
|
const model = objectPool.get(id);
|
|
187
233
|
if (!model)
|
|
188
234
|
throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
|
|
@@ -202,34 +248,23 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
202
248
|
syncClient.update(model, effective);
|
|
203
249
|
await waitForMutation(model, effective);
|
|
204
250
|
return modelAsRow(model);
|
|
205
|
-
},
|
|
206
|
-
async
|
|
251
|
+
}),
|
|
252
|
+
delete: guard(async (id, options) => {
|
|
207
253
|
const model = objectPool.get(id);
|
|
208
254
|
if (!model)
|
|
209
255
|
throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
|
|
210
256
|
syncClient.delete(model, options);
|
|
211
257
|
await waitForMutation(model, options);
|
|
212
|
-
},
|
|
213
|
-
claim
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
},
|
|
217
|
-
queue(id) {
|
|
218
|
-
return {
|
|
219
|
-
object: 'list',
|
|
220
|
-
data: collaboration?.queue({ model: schemaKey, id }) ?? [],
|
|
221
|
-
};
|
|
222
|
-
},
|
|
223
|
-
release(id) {
|
|
224
|
-
return releaseClaim(id);
|
|
225
|
-
},
|
|
258
|
+
}),
|
|
259
|
+
// `claim` is a callable namespace (take a claim) carrying the coordination
|
|
260
|
+
// readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
|
|
261
|
+
claim: claimApi,
|
|
226
262
|
onChange(callback, options) {
|
|
227
263
|
return autorun(() => {
|
|
228
|
-
const entities = this.
|
|
264
|
+
const entities = this.getAll(options);
|
|
229
265
|
callback(entities);
|
|
230
266
|
});
|
|
231
267
|
},
|
|
232
|
-
load,
|
|
233
268
|
};
|
|
234
269
|
modelClientMeta.set(operations, {
|
|
235
270
|
key: schemaKey,
|
package/dist/client/identity.js
CHANGED
|
@@ -44,6 +44,20 @@ export async function resolveParticipantIdentity(input) {
|
|
|
44
44
|
// Branch 2: self-derived (capability token present, identity unknown)
|
|
45
45
|
if (!internalOptions.organizationId ||
|
|
46
46
|
(kind === 'agent' ? !options.agentId : !options.user?.id)) {
|
|
47
|
+
// Fail fast on the missing-credential case. We're here because there's no
|
|
48
|
+
// apiKey (Branch 1) and the identity isn't caller-supplied (Branch 3), so
|
|
49
|
+
// `initialCapToken` is the only thing that can authenticate the
|
|
50
|
+
// `/auth/identity` call. When it's absent — the common cause being
|
|
51
|
+
// `getToken()` resolving to `null` (no/expired session, see
|
|
52
|
+
// `getSyncCapabilityToken`) — the request can only come back as the server's
|
|
53
|
+
// opaque `identity_resolve_failed: no_matching_provider`. Surface the real
|
|
54
|
+
// condition locally instead: `session_expired` is the registered,
|
|
55
|
+
// re-authenticate-able code, and we never make a doomed round-trip.
|
|
56
|
+
if (!initialCapToken) {
|
|
57
|
+
throw new AbloAuthenticationError('No auth token available to resolve identity — the session token is ' +
|
|
58
|
+
'missing or expired. Ensure `getToken()` returns a valid token, or ' +
|
|
59
|
+
'pass `apiKey` / `capabilityToken`.', { code: 'session_expired' });
|
|
60
|
+
}
|
|
47
61
|
const baseUrl = options.bootstrapBaseUrl ?? `${url.replace(/^ws/, 'http')}/api`;
|
|
48
62
|
const identity = await resolveIdentity({
|
|
49
63
|
baseUrl,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface RegisterDataSourceInput {
|
|
2
|
+
/** HTTP API base, e.g. `https://api.abloatai.com/api` (from resolveBootstrapBaseUrl). */
|
|
3
|
+
readonly baseUrl: string;
|
|
4
|
+
/** Secret key (`sk_…`) used to authenticate + derive the org. */
|
|
5
|
+
readonly apiKey: string | null;
|
|
6
|
+
/** The customer's own Postgres connection string. */
|
|
7
|
+
readonly databaseUrl: string;
|
|
8
|
+
/** Optional Postgres schema (defaults server-side to `public`). */
|
|
9
|
+
readonly schema?: string;
|
|
10
|
+
/** Custom fetch (tests/proxies/odd runtimes). */
|
|
11
|
+
readonly fetchImpl?: typeof fetch;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* POST the connection string to the self-serve datasource route. Resolves on
|
|
15
|
+
* success (the org is now a dedicated tenant pointed at this DB); throws an
|
|
16
|
+
* `AbloError` with `datasource_registration_failed` otherwise so `ready()`
|
|
17
|
+
* surfaces it instead of silently bootstrapping against the wrong store.
|
|
18
|
+
*/
|
|
19
|
+
export declare function registerDataSource(input: RegisterDataSourceInput): Promise<void>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-serve data-source registration.
|
|
3
|
+
*
|
|
4
|
+
* When a client is constructed with `databaseUrl`, the SDK registers that
|
|
5
|
+
* connection string as the project's own-Postgres data store BEFORE bootstrap,
|
|
6
|
+
* so the server resolves the org's data plane to the customer's DB and writes
|
|
7
|
+
* synced rows back into it (dedicated/BYO tenant).
|
|
8
|
+
*
|
|
9
|
+
* The org is derived server-side from the API key — the caller never sends an
|
|
10
|
+
* organization id. The connection string is sent once over TLS and is never
|
|
11
|
+
* echoed back (the server stores it as a secret and returns only a safe
|
|
12
|
+
* `datasource` projection: host, database, schema).
|
|
13
|
+
*/
|
|
14
|
+
import { AbloError } from '../errors.js';
|
|
15
|
+
/**
|
|
16
|
+
* POST the connection string to the self-serve datasource route. Resolves on
|
|
17
|
+
* success (the org is now a dedicated tenant pointed at this DB); throws an
|
|
18
|
+
* `AbloError` with `datasource_registration_failed` otherwise so `ready()`
|
|
19
|
+
* surfaces it instead of silently bootstrapping against the wrong store.
|
|
20
|
+
*/
|
|
21
|
+
export async function registerDataSource(input) {
|
|
22
|
+
if (!input.apiKey) {
|
|
23
|
+
throw new AbloError('databaseUrl requires an apiKey to register the data source (the org is derived from the key).', { code: 'datasource_registration_failed' });
|
|
24
|
+
}
|
|
25
|
+
const doFetch = input.fetchImpl ?? fetch;
|
|
26
|
+
const endpoint = `${input.baseUrl.replace(/\/+$/, '')}/v1/datasource`;
|
|
27
|
+
let response;
|
|
28
|
+
try {
|
|
29
|
+
response = await doFetch(endpoint, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'content-type': 'application/json',
|
|
33
|
+
authorization: `Bearer ${input.apiKey}`,
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
connectionString: input.databaseUrl,
|
|
37
|
+
...(input.schema ? { schema: input.schema } : {}),
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (cause) {
|
|
42
|
+
throw new AbloError('Could not reach the Ablo API to register databaseUrl.', {
|
|
43
|
+
code: 'datasource_registration_failed',
|
|
44
|
+
cause,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
let detail = '';
|
|
49
|
+
try {
|
|
50
|
+
detail = (await response.text()).slice(0, 500);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// ignore body read failures — the status alone is enough to fail loud
|
|
54
|
+
}
|
|
55
|
+
throw new AbloError(`databaseUrl registration failed (HTTP ${response.status}). ${detail}`, { code: 'datasource_registration_failed', httpStatus: response.status });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* because the error messages reference URLs and would mislead if a
|
|
11
11
|
* URL was actually present.
|
|
12
12
|
*/
|
|
13
|
+
import { AbloError } from '../errors.js';
|
|
13
14
|
/**
|
|
14
15
|
* Minimal subset of `AbloOptions` the validator actually inspects.
|
|
15
16
|
* Defined here as its own interface so the validator doesn't pull
|
|
@@ -39,4 +40,4 @@ export interface ValidateAbloOptionsInput {
|
|
|
39
40
|
readonly configuredApiKey: unknown;
|
|
40
41
|
readonly configuredAuthToken: unknown;
|
|
41
42
|
}
|
|
42
|
-
export declare function validateAbloOptions(input: ValidateAbloOptionsInput):
|
|
43
|
+
export declare function validateAbloOptions(input: ValidateAbloOptionsInput): AbloError | null;
|
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
* because the error messages reference URLs and would mislead if a
|
|
11
11
|
* URL was actually present.
|
|
12
12
|
*/
|
|
13
|
+
import { AbloValidationError } from '../errors.js';
|
|
13
14
|
export function validateAbloOptions(input) {
|
|
14
15
|
const { options, url, configuredApiKey, configuredAuthToken } = input;
|
|
15
16
|
const kind = options.kind ?? 'user';
|
|
16
17
|
if (!url) {
|
|
17
|
-
return new
|
|
18
|
-
`Ablo({ baseURL: 'wss://sync.
|
|
18
|
+
return new AbloValidationError('Ablo: `url` is required. Pass the sync server URL, e.g. ' +
|
|
19
|
+
`Ablo({ baseURL: 'wss://sync.abloatai.com', schema, user })`, { code: 'base_url_missing' });
|
|
19
20
|
}
|
|
20
21
|
// Schema is optional for the model-first API:
|
|
21
22
|
// Ablo({ apiKey }).model('clauses').retrieve(...)
|
|
@@ -26,18 +27,18 @@ export function validateAbloOptions(input) {
|
|
|
26
27
|
kind === 'user' &&
|
|
27
28
|
options.user &&
|
|
28
29
|
!options.user.id) {
|
|
29
|
-
return new
|
|
30
|
+
return new AbloValidationError('Ablo: `user.id` must be a non-empty string when `user` is provided.', { code: 'invalid_options', param: 'user.id' });
|
|
30
31
|
}
|
|
31
32
|
if (!configuredApiKey && !configuredAuthToken && kind === 'agent' && !options.agentId) {
|
|
32
|
-
return new
|
|
33
|
+
return new AbloValidationError('Ablo: provide either `apiKey` or `agentId` for `kind: "agent"`. ' +
|
|
33
34
|
'Hosted-cloud consumers pass `apiKey` and the server derives the ' +
|
|
34
35
|
'agent identity from its scope; self-hosted passes `agentId` + ' +
|
|
35
|
-
'`capabilityToken` directly.');
|
|
36
|
+
'`capabilityToken` directly.', { code: 'invalid_options', param: 'agentId' });
|
|
36
37
|
}
|
|
37
38
|
if (!configuredApiKey && !configuredAuthToken && kind === 'agent' && !options.capabilityToken) {
|
|
38
|
-
return new
|
|
39
|
+
return new AbloValidationError('Ablo: provide either `apiKey` (hosted cloud — SDK exchanges internally) ' +
|
|
39
40
|
'or `capabilityToken` (self-hosted — your auth layer mints + hands in). ' +
|
|
40
|
-
'See https://
|
|
41
|
+
'See https://abloatai.com/docs/api-keys for the full pattern.', { code: 'invalid_options', param: 'capabilityToken' });
|
|
41
42
|
}
|
|
42
43
|
return null;
|
|
43
44
|
}
|