@abloatai/ablo 0.11.1 → 0.12.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 +49 -0
- package/README.md +10 -2
- package/dist/Model.d.ts +39 -0
- package/dist/Model.js +68 -0
- package/dist/ai-sdk/claim-broadcast.d.ts +4 -3
- package/dist/ai-sdk/claim-broadcast.js +2 -2
- package/dist/ai-sdk/wrap.d.ts +5 -4
- package/dist/ai-sdk/wrap.js +3 -3
- package/dist/auth/credentialPolicy.d.ts +145 -0
- package/dist/auth/credentialPolicy.js +130 -0
- package/dist/cli.cjs +42 -7
- package/dist/client/Ablo.d.ts +64 -91
- package/dist/client/Ablo.js +43 -103
- package/dist/client/ApiClient.d.ts +10 -1
- package/dist/client/ApiClient.js +45 -22
- package/dist/client/auth.d.ts +12 -5
- package/dist/client/auth.js +2 -1
- package/dist/client/createModelProxy.d.ts +64 -17
- package/dist/client/createModelProxy.js +18 -12
- package/dist/client/httpClient.d.ts +17 -3
- package/dist/client/httpClient.js +1 -0
- package/dist/client/identity.js +134 -122
- package/dist/client/index.d.ts +1 -1
- package/dist/client/sessionMint.d.ts +15 -0
- package/dist/client/sessionMint.js +86 -0
- package/dist/coordination/schema.d.ts +1 -1
- package/dist/coordination/schema.js +3 -1
- package/dist/errorCodes.d.ts +2 -0
- package/dist/errorCodes.js +2 -0
- package/dist/errors.d.ts +6 -3
- package/dist/errors.js +9 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -7
- package/dist/mutators/RecordingTransaction.js +14 -42
- package/dist/react/AbloProvider.d.ts +12 -13
- package/dist/react/AbloProvider.js +10 -10
- package/dist/react/context.d.ts +10 -45
- package/dist/react/context.js +12 -17
- package/dist/react/index.d.ts +8 -10
- package/dist/react/index.js +8 -11
- package/dist/react/useMutators.js +3 -2
- package/dist/react/useSyncStatus.d.ts +1 -1
- package/dist/react/useUndoScope.js +3 -2
- package/dist/realtime/index.d.ts +1 -1
- package/dist/schema/generate.js +1 -2
- package/dist/schema/model.d.ts +10 -3
- package/dist/schema/schema.d.ts +13 -2
- package/dist/schema/schema.js +26 -0
- package/dist/surface.d.ts +29 -0
- package/dist/surface.js +60 -0
- package/dist/sync/ConnectionManager.d.ts +16 -5
- package/dist/sync/ConnectionManager.js +42 -7
- package/dist/sync/createClaimStream.js +5 -4
- package/dist/sync/participants.js +1 -1
- package/dist/transactions/TransactionQueue.d.ts +0 -11
- package/dist/transactions/TransactionQueue.js +12 -56
- package/dist/types/global.d.ts +3 -0
- package/dist/types/streams.d.ts +17 -29
- package/dist/utils/mobx-setup.js +1 -0
- package/docs/api-keys.md +49 -0
- package/docs/api.md +3 -2
- package/docs/client-behavior.md +1 -0
- package/docs/coordination.md +75 -21
- package/docs/examples/existing-python-backend.md +9 -5
- package/docs/examples/scoped-agent.md +1 -1
- package/docs/guarantees.md +4 -3
- package/docs/identity.md +89 -82
- package/docs/integration-guide.md +19 -10
- package/docs/migration.md +11 -3
- package/docs/quickstart.md +6 -2
- package/docs/react.md +3 -3
- package/docs/schema-contract.md +23 -5
- package/llms-full.txt +18 -16
- package/llms.txt +6 -6
- package/package.json +1 -1
- package/dist/api/index.d.ts +0 -10
- package/dist/api/index.js +0 -9
- package/dist/principal.d.ts +0 -44
- package/dist/principal.js +0 -49
- package/dist/react/SyncGroupProvider.d.ts +0 -19
- package/dist/react/SyncGroupProvider.js +0 -44
- package/dist/react/useClaim.d.ts +0 -29
- package/dist/react/useClaim.js +0 -42
- package/dist/react/usePresence.d.ts +0 -32
- package/dist/react/usePresence.js +0 -41
|
@@ -19,6 +19,7 @@ import type { ModelRegistry } from '../ModelRegistry.js';
|
|
|
19
19
|
import type { ObjectPool } from '../ObjectPool.js';
|
|
20
20
|
import type { SyncClient } from '../SyncClient.js';
|
|
21
21
|
import type { HydrationCoordinator } from '../sync/HydrationCoordinator.js';
|
|
22
|
+
import type { JoinedParticipant } from '../sync/participants.js';
|
|
22
23
|
import type { LoadWhere } from '../query/types.js';
|
|
23
24
|
import { ModelScope } from '../types/index.js';
|
|
24
25
|
import type { Duration, Claim, ClaimHandle, ClaimWaitOptions, Snapshot, TargetRange } from '../types/streams.js';
|
|
@@ -28,7 +29,10 @@ export interface ModelClientMeta {
|
|
|
28
29
|
}
|
|
29
30
|
export declare function getModelClientMeta(modelClient: unknown): ModelClientMeta | undefined;
|
|
30
31
|
export type ModelListScope = ModelScope | 'live' | 'archived' | 'all';
|
|
31
|
-
|
|
32
|
+
/** Options for the sync LOCAL-pool reads `get`/`getAll`/`onChange` (JS
|
|
33
|
+
* `filter`, equality `where`, lifecycle `state`). The local-reactive axis;
|
|
34
|
+
* contrast {@link ServerReadOptions} (the async server axis). */
|
|
35
|
+
export interface LocalReadOptions<T> {
|
|
32
36
|
where?: Partial<T>;
|
|
33
37
|
/** Arbitrary local predicate. Applied after `where`. */
|
|
34
38
|
filter?: (entity: T) => boolean;
|
|
@@ -42,8 +46,11 @@ export interface ModelListOptions<T> {
|
|
|
42
46
|
* sync-group `scope`. */
|
|
43
47
|
state?: ModelListScope;
|
|
44
48
|
}
|
|
45
|
-
export type
|
|
46
|
-
|
|
49
|
+
export type LocalCountOptions<T> = Pick<LocalReadOptions<T>, 'where' | 'filter' | 'state'>;
|
|
50
|
+
/** Options for the async SERVER reads `retrieve`/`list` (operator `where` DSL,
|
|
51
|
+
* `type`, `expand`). The server-async axis; contrast {@link LocalReadOptions}
|
|
52
|
+
* (the local-reactive axis). */
|
|
53
|
+
export interface ServerReadOptions<T> {
|
|
47
54
|
/**
|
|
48
55
|
* Filter for the lookup. Accepts:
|
|
49
56
|
* - object form: `{ name: 'foo' }` (equality, array values → `IN`)
|
|
@@ -71,8 +78,8 @@ export interface ModelLoadOptions<T> {
|
|
|
71
78
|
expand?: readonly string[];
|
|
72
79
|
}
|
|
73
80
|
/** Options for the single-row async server read `retrieve({ id })`. A subset of
|
|
74
|
-
* {@link
|
|
75
|
-
export type
|
|
81
|
+
* {@link ServerReadOptions} — `where`/`limit`/`orderBy` are fixed by the id. */
|
|
82
|
+
export type ServerRetrieveOptions = Pick<ServerReadOptions<unknown>, 'type' | 'expand'>;
|
|
76
83
|
export interface ModelCollaboration<T> {
|
|
77
84
|
createClaim(options: {
|
|
78
85
|
target: {
|
|
@@ -83,7 +90,8 @@ export interface ModelCollaboration<T> {
|
|
|
83
90
|
range?: TargetRange;
|
|
84
91
|
meta?: Record<string, unknown>;
|
|
85
92
|
};
|
|
86
|
-
action
|
|
93
|
+
/** Human-readable phase (`'editing'`); wire field is `action`. */
|
|
94
|
+
reason: string;
|
|
87
95
|
ttl?: Duration;
|
|
88
96
|
/**
|
|
89
97
|
* Block on the server's fair FIFO queue when the target is held, rather
|
|
@@ -161,10 +169,19 @@ export interface ModelCollaboration<T> {
|
|
|
161
169
|
* Forwards to `BaseSyncedStore.pinScope`.
|
|
162
170
|
*/
|
|
163
171
|
pinScope?(scope: Record<string, string>): void | Promise<void>;
|
|
172
|
+
/**
|
|
173
|
+
* Open a presence/claim subscription on this model's sync group(s) and
|
|
174
|
+
* return the live participant handle. Backs `ablo.<model>.watch(ids)` —
|
|
175
|
+
* the relocation of the old `ablo.participants.join({ scope })`. WebSocket
|
|
176
|
+
* only (presence needs a socket); absent on non-ws constructions, in which
|
|
177
|
+
* case the proxy throws a clear error. Forwards to `participantManager.join`.
|
|
178
|
+
*/
|
|
179
|
+
createWatch?(modelKey: string, ids: string | readonly string[], options?: WatchOptions): Promise<JoinedParticipant>;
|
|
164
180
|
}
|
|
165
181
|
export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
166
|
-
/**
|
|
167
|
-
|
|
182
|
+
/** Human-readable phase shown to observers while held. Defaults to
|
|
183
|
+
* `'editing'`. The same word on every claim surface; wire field is `action`. */
|
|
184
|
+
reason?: string;
|
|
168
185
|
/** Peer-visible explanation of the work being performed. */
|
|
169
186
|
description?: string;
|
|
170
187
|
/** Field-level target, for fine-grained claimed-state badges. */
|
|
@@ -184,8 +201,14 @@ export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
|
184
201
|
* `AbloClaimedError` instead of waiting (claim-or-skip). Use `false` for
|
|
185
202
|
* work-distribution dedup ("if someone else has this job, skip it") where
|
|
186
203
|
* waiting would mean double-processing.
|
|
204
|
+
*
|
|
205
|
+
* Named `queue` to match every other claim surface (low-level
|
|
206
|
+
* `claims.claim`, HTTP `claim.create`, and the wire). The high-level typed
|
|
207
|
+
* claim defaults it ON because it serializes writers; the low-level lease
|
|
208
|
+
* and HTTP default it OFF — they return/resolve immediately and can't
|
|
209
|
+
* transparently wait for a grant.
|
|
187
210
|
*/
|
|
188
|
-
|
|
211
|
+
queue?: boolean;
|
|
189
212
|
/**
|
|
190
213
|
* Backpressure: willing to queue, but not behind too many. If the server
|
|
191
214
|
* reports `position >= maxQueueDepth` when we join the line, reject with
|
|
@@ -211,7 +234,7 @@ export interface ClaimReorderParams<T = Record<string, unknown>> extends ClaimLo
|
|
|
211
234
|
* ```ts
|
|
212
235
|
* const claim = await ablo.weatherReports.claim({
|
|
213
236
|
* id: 'report_stockholm',
|
|
214
|
-
*
|
|
237
|
+
* reason: 'forecasting',
|
|
215
238
|
* description: 'Fetching current weather before writing the forecast.',
|
|
216
239
|
* });
|
|
217
240
|
* try {
|
|
@@ -243,7 +266,7 @@ export type ClaimOptions<T = Record<string, unknown>> = ClaimTargetOptions<T>;
|
|
|
243
266
|
* data: { title },
|
|
244
267
|
* claim: {
|
|
245
268
|
* field: 'title',
|
|
246
|
-
*
|
|
269
|
+
* reason: 'renaming',
|
|
247
270
|
* description: 'Renaming the task to match the project brief.',
|
|
248
271
|
* },
|
|
249
272
|
* });
|
|
@@ -276,7 +299,7 @@ export interface ClaimApi<T> {
|
|
|
276
299
|
/** Release a manual claim handle early. Single-write claims auto-release. */
|
|
277
300
|
release(params: ClaimLookupParams<T> | ClaimHandle<T>): Promise<void>;
|
|
278
301
|
}
|
|
279
|
-
export interface ModelRetrieveParams extends
|
|
302
|
+
export interface ModelRetrieveParams extends ServerRetrieveOptions {
|
|
280
303
|
readonly id: string;
|
|
281
304
|
}
|
|
282
305
|
export interface ModelCreateParams<T, CreateInput> extends MutationOptions {
|
|
@@ -293,6 +316,15 @@ export interface ModelDeleteParams<T> extends MutationOptions {
|
|
|
293
316
|
readonly id: string;
|
|
294
317
|
readonly claim?: ClaimHandle<T> | ClaimTargetOptions<T> | null;
|
|
295
318
|
}
|
|
319
|
+
/** Options for the WebSocket-only `ablo.<model>.watch(ids, options?)`. */
|
|
320
|
+
export interface WatchOptions {
|
|
321
|
+
/**
|
|
322
|
+
* Lease TTL for the underlying presence claim — the participant
|
|
323
|
+
* auto-releases after this if the holder dies. Compact duration string
|
|
324
|
+
* (`'5m'`) or ms number, mirroring the claim `ttl`.
|
|
325
|
+
*/
|
|
326
|
+
ttl?: Duration;
|
|
327
|
+
}
|
|
296
328
|
export interface ModelOperations<T, CreateInput> {
|
|
297
329
|
/**
|
|
298
330
|
* Read a single entity by id from the **server** — async. Resolves through
|
|
@@ -316,7 +348,7 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
316
348
|
* Mirrors `stripe.customers.list({...})` — network-backed. For a synchronous
|
|
317
349
|
* read of the local graph use `getAll(...)`.
|
|
318
350
|
*/
|
|
319
|
-
list(options?:
|
|
351
|
+
list(options?: ServerReadOptions<T>): Promise<T[]>;
|
|
320
352
|
/**
|
|
321
353
|
* Synchronous snapshot of a single entity from the **local graph** — no
|
|
322
354
|
* network. Returns `undefined` when the row isn't resident (cold hosted
|
|
@@ -329,9 +361,9 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
329
361
|
* no network round-trip. Empty until `retrieve`/`list`/bootstrap has warmed
|
|
330
362
|
* the graph.
|
|
331
363
|
*/
|
|
332
|
-
getAll(options?:
|
|
364
|
+
getAll(options?: LocalReadOptions<T>): T[];
|
|
333
365
|
/** Count entities in the **local graph** (synchronous, no network). */
|
|
334
|
-
getCount(options?:
|
|
366
|
+
getCount(options?: LocalCountOptions<T>): number;
|
|
335
367
|
/**
|
|
336
368
|
* Create a new entity — **optimistic, offline-first**. Resolves once
|
|
337
369
|
* the mutation is queued locally, not when the server confirms.
|
|
@@ -355,7 +387,7 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
355
387
|
* ```ts
|
|
356
388
|
* const claim = await ablo.weatherReports.claim({
|
|
357
389
|
* id: 'report_stockholm',
|
|
358
|
-
*
|
|
390
|
+
* reason: 'forecasting',
|
|
359
391
|
* description: 'Fetching fresh weather before updating the report.',
|
|
360
392
|
* });
|
|
361
393
|
* const weather = await getWeather(claim.data.location);
|
|
@@ -370,7 +402,22 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
370
402
|
* ```
|
|
371
403
|
*/
|
|
372
404
|
claim: ClaimApi<T>;
|
|
405
|
+
/**
|
|
406
|
+
* Subscribe this client to the sync group(s) for one or more rows of this
|
|
407
|
+
* model and get a live participant handle back — presence (`.peers`), the
|
|
408
|
+
* scoped claim stream (`.claims`), and `.leave()` / `await using` disposal.
|
|
409
|
+
*
|
|
410
|
+
* The model-scoped relocation of the former `ablo.participants.join({
|
|
411
|
+
* scope: { <model>: ids } })`. WebSocket only — presence needs a socket, so
|
|
412
|
+
* this is absent on HTTP clients and throws on any non-ws construction.
|
|
413
|
+
*
|
|
414
|
+
* ```ts
|
|
415
|
+
* await using participant = await ablo.slides.watch(slideIds, { ttl: '5m' });
|
|
416
|
+
* participant.peers; // who else is here
|
|
417
|
+
* ```
|
|
418
|
+
*/
|
|
419
|
+
watch(ids: string | readonly string[], options?: WatchOptions): Promise<JoinedParticipant>;
|
|
373
420
|
/** Listen for changes (callback called on every change). */
|
|
374
|
-
onChange(callback: (entities: T[]) => void, options?:
|
|
421
|
+
onChange(callback: (entities: T[]) => void, options?: LocalReadOptions<T>): () => void;
|
|
375
422
|
}
|
|
376
423
|
export declare function createModelProxy<T, C>(schemaKey: string, registeredModelName: string, objectPool: ObjectPool, syncClient: SyncClient, registry: ModelRegistry, hydration: HydrationCoordinator, collaboration?: ModelCollaboration<T>): ModelOperations<T, C>;
|
|
@@ -77,7 +77,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
77
77
|
// `release({ id })` and `update({ id, data })` find the lease + snapshot a `claim({ id })`
|
|
78
78
|
// took — no per-call handle. Released on dispose, explicit release, or TTL.
|
|
79
79
|
//
|
|
80
|
-
// `target` / `
|
|
80
|
+
// `target` / `reason` / `expiresAt` are kept alongside the lease so
|
|
81
81
|
// `claim.state` can synthesize a self-claim: the server excludes a holder's
|
|
82
82
|
// own presence frames, so the local proxy is the ONLY place that knows "I
|
|
83
83
|
// hold this." `expiresAt` is the client's best estimate from the requested
|
|
@@ -103,7 +103,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
103
103
|
id: claim.id,
|
|
104
104
|
actor: claim.heldBy,
|
|
105
105
|
participantKind: claim.participantKind,
|
|
106
|
-
|
|
106
|
+
reason: claim.reason,
|
|
107
107
|
...(description ? { description } : {}),
|
|
108
108
|
field: claim.target.field,
|
|
109
109
|
status: claim.status,
|
|
@@ -143,8 +143,8 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
143
143
|
// claim (a free / already-mine target can't have changed under us).
|
|
144
144
|
const held = collaboration.observe({ model: wireModel, id });
|
|
145
145
|
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
146
|
-
const failFast = options?.
|
|
147
|
-
// Fail-fast (`
|
|
146
|
+
const failFast = options?.queue === false;
|
|
147
|
+
// Fail-fast (`queue: false`): if another participant already holds it,
|
|
148
148
|
// reject now instead of queuing. Best-effort at the client (a racing
|
|
149
149
|
// claim not yet synced into our snapshot slips through here) — the
|
|
150
150
|
// commit-time claim guard is the authoritative backstop that rejects
|
|
@@ -176,7 +176,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
176
176
|
// in the group when `createClaim` lands. Awaited because the broadcast
|
|
177
177
|
// ordering depends on it; still soft (the store swallows reconcile errors).
|
|
178
178
|
await collaboration.pinScope?.({ [schemaKey]: id });
|
|
179
|
-
// Acquire the lease. Default (`
|
|
179
|
+
// Acquire the lease. Default (`queue` !== false) goes through the server's
|
|
180
180
|
// fair FIFO queue — `queue: true` resolves only once the lease is genuinely
|
|
181
181
|
// ours, blocking behind any current holder, with no TOCTOU gap (the server
|
|
182
182
|
// orders contenders). Fail-fast skips the queue: we already rejected an
|
|
@@ -190,7 +190,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
190
190
|
...(options?.range ? { range: options.range } : {}),
|
|
191
191
|
...(claimMeta(options) ? { meta: claimMeta(options) } : {}),
|
|
192
192
|
},
|
|
193
|
-
|
|
193
|
+
reason: options?.reason ?? 'editing',
|
|
194
194
|
ttl: options?.ttl,
|
|
195
195
|
queue: !failFast,
|
|
196
196
|
maxQueueDepth: options?.maxQueueDepth,
|
|
@@ -213,7 +213,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
213
213
|
model = objectPool.get(id) ?? model;
|
|
214
214
|
}
|
|
215
215
|
const snapshot = collaboration.createSnapshot(schemaKey, id);
|
|
216
|
-
const
|
|
216
|
+
const reason = options?.reason ?? 'editing';
|
|
217
217
|
// The self-claim's `EntityRef` mirrors what a peer's `claim.state` would
|
|
218
218
|
// report (`observe` maps `held.target.model` → `type`), so a holder and a
|
|
219
219
|
// peer see the SAME target.type for one row — the wire model token.
|
|
@@ -231,7 +231,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
231
231
|
lease,
|
|
232
232
|
snapshot,
|
|
233
233
|
target: selfTarget,
|
|
234
|
-
|
|
234
|
+
reason,
|
|
235
235
|
expiresAt,
|
|
236
236
|
});
|
|
237
237
|
const target = {
|
|
@@ -248,7 +248,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
248
248
|
claimId: lease.claimId,
|
|
249
249
|
readAt: snapshot.stamp,
|
|
250
250
|
target,
|
|
251
|
-
|
|
251
|
+
reason,
|
|
252
252
|
...(options?.description ? { description: options.description } : {}),
|
|
253
253
|
data: modelAsRow(model),
|
|
254
254
|
release,
|
|
@@ -281,7 +281,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
281
281
|
id: own.lease.claimId,
|
|
282
282
|
status: 'active',
|
|
283
283
|
target: own.target,
|
|
284
|
-
|
|
284
|
+
reason: own.reason,
|
|
285
285
|
heldBy: collaboration?.selfParticipantId ?? '',
|
|
286
286
|
participantKind: collaboration?.selfParticipantKind ?? 'user',
|
|
287
287
|
expiresAt: own.expiresAt,
|
|
@@ -381,9 +381,9 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
381
381
|
...(claim.range ? { range: claim.range } : {}),
|
|
382
382
|
...(claimMeta(claim) ? { meta: claimMeta(claim) } : {}),
|
|
383
383
|
},
|
|
384
|
-
|
|
384
|
+
reason: claim.reason ?? 'creating',
|
|
385
385
|
ttl: claim.ttl,
|
|
386
|
-
queue: claim.
|
|
386
|
+
queue: claim.queue !== false,
|
|
387
387
|
maxQueueDepth: claim.maxQueueDepth,
|
|
388
388
|
});
|
|
389
389
|
}
|
|
@@ -509,6 +509,12 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
509
509
|
// `claim` is a callable namespace (take a claim) carrying the coordination
|
|
510
510
|
// readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
|
|
511
511
|
claim: claimApi,
|
|
512
|
+
watch: guard((ids, options) => {
|
|
513
|
+
if (!collaboration?.createWatch) {
|
|
514
|
+
throw new AbloValidationError(`Model "${schemaKey}" was built without a WebSocket runtime, so watch() is unavailable here. Presence needs a live socket — use the standard Ablo({ schema, apiKey }) client (not the HTTP transport).`, { code: 'model_watch_not_configured' });
|
|
515
|
+
}
|
|
516
|
+
return collaboration.createWatch(schemaKey, ids, options);
|
|
517
|
+
}),
|
|
512
518
|
onChange(callback, options) {
|
|
513
519
|
return autorun(() => {
|
|
514
520
|
const entities = this.getAll(options);
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
* surface as the browser client — typed proxies, stateless transport.
|
|
24
24
|
*/
|
|
25
25
|
import { type AbloApiClientOptions } from './ApiClient.js';
|
|
26
|
-
import type { CommitReceipt, CommitResource, HttpClaimApi, ModelRead, ModelReadOptions } from './Ablo.js';
|
|
27
|
-
import type { ModelCreateParams, ModelDeleteParams,
|
|
26
|
+
import type { CommitReceipt, CommitResource, HttpClaimApi, ModelRead, ModelReadOptions, CreateSessionParams, AbloSession } from './Ablo.js';
|
|
27
|
+
import type { ModelCreateParams, ModelDeleteParams, ServerReadOptions, 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'> {
|
|
30
30
|
/** The schema — used for TYPING only (typed model proxies); never sent or used at runtime. */
|
|
@@ -36,10 +36,16 @@ export interface AbloHttpClientOptions<S extends SchemaRecord> extends Omit<Ablo
|
|
|
36
36
|
* and the durable-lease claim plane (`claim` — acquire/hold/release). It does NOT
|
|
37
37
|
* include `get`/`getAll`/`getCount` (local synced-pool reads) or `onChange` (live
|
|
38
38
|
* subscription); those need the stateful plane and are absent BY TYPE here.
|
|
39
|
+
*
|
|
40
|
+
* Read-shape asymmetry (by design, not a gap): `retrieve(...)` returns a
|
|
41
|
+
* `ModelRead<T>` envelope `{ data, stamp, claims }` — the stateless client has no
|
|
42
|
+
* local graph, so the watermark/claims the stateful client reads from its pool
|
|
43
|
+
* must ride inline on the read (an agent needs the `stamp` to do a stale-guarded
|
|
44
|
+
* write; there is no `snapshot()` to fetch it from). `list(...)` returns a bare `T[]`.
|
|
39
45
|
*/
|
|
40
46
|
export interface HttpModelClient<T, C = T> {
|
|
41
47
|
retrieve(params: ModelRetrieveParams & ModelReadOptions): Promise<ModelRead<T>>;
|
|
42
|
-
list(options?:
|
|
48
|
+
list(options?: ServerReadOptions<T>): Promise<T[]>;
|
|
43
49
|
create(params: ModelCreateParams<T, C>): Promise<CommitReceipt>;
|
|
44
50
|
update(params: ModelUpdateParams<C>): Promise<CommitReceipt>;
|
|
45
51
|
delete(params: ModelDeleteParams<T>): Promise<CommitReceipt>;
|
|
@@ -61,6 +67,14 @@ export type AbloHttpClient<S extends SchemaRecord> = {
|
|
|
61
67
|
dispose(): Promise<void>;
|
|
62
68
|
/** Resolve the bearer credential this client authenticates with (see `AbloApi.getAuthToken`). */
|
|
63
69
|
getAuthToken(): Promise<string | null>;
|
|
70
|
+
/**
|
|
71
|
+
* Mint a short-lived scoped session (Stripe ephemeral-key shape). Minting is a
|
|
72
|
+
* stateless control-plane call, so — unlike `get`/`getAll`/`onChange` — it IS
|
|
73
|
+
* available on the HTTP client. `{ user }` → `ek_`, `{ agent, can }` → `rk_`.
|
|
74
|
+
*/
|
|
75
|
+
readonly sessions: {
|
|
76
|
+
create(params: CreateSessionParams<S>): Promise<AbloSession>;
|
|
77
|
+
};
|
|
64
78
|
/** String-keyed model accessor (for dynamic model names). */
|
|
65
79
|
model<T = Record<string, unknown>>(name: string): HttpModelClient<T>;
|
|
66
80
|
};
|
package/dist/client/identity.js
CHANGED
|
@@ -21,137 +21,153 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { AbloAuthenticationError } from '../errors.js';
|
|
23
23
|
import { exchangeApiKey } from '../auth/index.js';
|
|
24
|
+
import { mintUserSessionKey } from '../auth/index.js';
|
|
24
25
|
import { resolveIdentity } from '../auth/index.js';
|
|
25
26
|
import { createRefreshScheduler, } from '../auth/index.js';
|
|
27
|
+
import { resolveCredential, } from '../auth/credentialPolicy.js';
|
|
26
28
|
import { resolveApiKeyValue, resolveBootstrapBaseUrl } from './auth.js';
|
|
27
29
|
export async function resolveParticipantIdentity(input) {
|
|
28
30
|
const { options, internalOptions, url, kind, configuredApiKey, configuredAuthToken, bootstrapHelper, auth, logger, } = input;
|
|
29
31
|
const apiKeyValue = await resolveApiKeyValue(configuredApiKey);
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
refreshScheduler: null,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
// Branch 1: hosted-cloud (apiKey only, no caller-supplied capability token)
|
|
64
|
-
if (apiKeyValue && !options.capabilityToken) {
|
|
65
|
-
return resolveHosted({
|
|
66
|
-
apiKeyValue,
|
|
67
|
-
configuredApiKey,
|
|
68
|
-
url,
|
|
69
|
-
kind,
|
|
70
|
-
options,
|
|
71
|
-
bootstrapHelper,
|
|
72
|
-
auth,
|
|
73
|
-
logger,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
// Branch 2: self-derived (capability token present, identity unknown)
|
|
77
|
-
if (!internalOptions.organizationId ||
|
|
78
|
-
(kind === 'agent' ? !options.agentId : !options.user?.id)) {
|
|
79
|
-
// Fail fast on the missing-credential case. We're here because there's no
|
|
80
|
-
// apiKey (Branch 1) and the identity isn't caller-supplied (Branch 3), so
|
|
81
|
-
// `initialCapToken` is the only thing that can authenticate the
|
|
82
|
-
// `/auth/identity` call. When it's absent — the common cause being
|
|
83
|
-
// `getToken()` resolving to `null` (no/expired session, see
|
|
84
|
-
// `getSyncCapabilityToken`) — the request can only come back as the server's
|
|
85
|
-
// opaque `identity_resolve_failed: no_matching_provider`. Surface the real
|
|
86
|
-
// condition locally instead: `session_expired` is the registered,
|
|
87
|
-
// re-authenticate-able code, and we never make a doomed round-trip.
|
|
88
|
-
if (!initialCapToken) {
|
|
89
|
-
throw new AbloAuthenticationError('No auth token available to resolve identity — the session token is ' +
|
|
90
|
-
'missing or expired. Ensure `getToken()` returns a valid token, or ' +
|
|
91
|
-
'pass `apiKey` / `capabilityToken`.', { code: 'session_expired' });
|
|
92
|
-
}
|
|
93
|
-
// Single source of truth for the http(s) base — coerces ws/wss → http/https
|
|
94
|
-
// even when `bootstrapBaseUrl` is an explicit override (see auth.ts).
|
|
95
|
-
const baseUrl = resolveBootstrapBaseUrl({
|
|
96
|
-
url,
|
|
97
|
-
bootstrapBaseUrl: options.bootstrapBaseUrl,
|
|
98
|
-
});
|
|
99
|
-
const identity = await resolveIdentity({
|
|
32
|
+
// Single source of truth for the http(s) base — coerces ws/wss → http/https
|
|
33
|
+
// even when `bootstrapBaseUrl` is an explicit override (see auth.ts).
|
|
34
|
+
const baseUrl = resolveBootstrapBaseUrl({
|
|
35
|
+
url,
|
|
36
|
+
bootstrapBaseUrl: options.bootstrapBaseUrl,
|
|
37
|
+
});
|
|
38
|
+
// `internalOptions.organizationId` + a caller-supplied participant id is the
|
|
39
|
+
// legacy explicit path: the caller already knows its own identity, so no
|
|
40
|
+
// server round-trip is needed.
|
|
41
|
+
const hasExplicitIdentity = internalOptions.organizationId != null &&
|
|
42
|
+
(kind === 'agent' ? options.agentId != null : options.user?.id != null);
|
|
43
|
+
// The connect-time credential ROUTING decision lives in `credentialPolicy`:
|
|
44
|
+
// classify the apiKey (sk_/ek_/rk_/pk_) and route. The hosted exchange is the
|
|
45
|
+
// one mint the policy performs (delegating to the injected `exchangeApiKey`);
|
|
46
|
+
// every other route just hands back the bearer to use. We then switch on the
|
|
47
|
+
// resolved `kind` below to wire up scope + the refresh scheduler.
|
|
48
|
+
const cred = await resolveCredential({
|
|
49
|
+
apiKeyValue,
|
|
50
|
+
configuredApiKey,
|
|
51
|
+
capabilityToken: options.capabilityToken,
|
|
52
|
+
authToken: configuredAuthToken,
|
|
53
|
+
hasExplicitIdentity,
|
|
54
|
+
}, {
|
|
55
|
+
primitives: {
|
|
56
|
+
exchangeApiKey,
|
|
57
|
+
mintUserSessionKey,
|
|
58
|
+
resolveIdentity,
|
|
59
|
+
resolveApiKeyValue,
|
|
60
|
+
},
|
|
61
|
+
exchangeArgs: {
|
|
100
62
|
baseUrl,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
63
|
+
participantKind: (kind === 'agent' ? 'agent' : 'system'),
|
|
64
|
+
participantId: options.agentId ?? options.user?.id,
|
|
65
|
+
wideScope: true,
|
|
66
|
+
ttlSeconds: 3600,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
switch (cred.kind) {
|
|
70
|
+
case 'publishable':
|
|
71
|
+
// `pk_` — a long-lived, browser-safe, READ-ONLY project key. Used DIRECTLY
|
|
72
|
+
// as the bearer and NEVER exchanged for a short-lived capability — so it
|
|
73
|
+
// never expires and there is nothing to refresh. The sync-server's
|
|
74
|
+
// `apiKeyProvider` resolves the org + read-only scope from the key itself;
|
|
75
|
+
// we still call `/auth/identity` (authenticated by the `pk_` bearer) to
|
|
76
|
+
// learn the account scope + syncGroups for the bootstrap cache.
|
|
77
|
+
return resolveViaIdentity({
|
|
78
|
+
bearer: cred.getBearer,
|
|
79
|
+
baseUrl,
|
|
80
|
+
options,
|
|
81
|
+
bootstrapHelper,
|
|
82
|
+
auth,
|
|
83
|
+
});
|
|
84
|
+
case 'exchange':
|
|
85
|
+
// Hosted-cloud (`sk_`): the policy exchanged the apiKey for a capability
|
|
86
|
+
// token; here we apply the returned scope and set up the refresh scheduler.
|
|
87
|
+
return resolveHosted({
|
|
88
|
+
cred,
|
|
89
|
+
configuredApiKey,
|
|
90
|
+
baseUrl,
|
|
91
|
+
kind,
|
|
92
|
+
options,
|
|
93
|
+
bootstrapHelper,
|
|
94
|
+
auth,
|
|
95
|
+
logger,
|
|
96
|
+
});
|
|
97
|
+
case 'pre-minted':
|
|
98
|
+
// Self-derived: a pre-minted `ek_`/`rk_` bearer or an explicit capability
|
|
99
|
+
// token authenticates `/auth/identity` directly (no exchange, no refresh).
|
|
100
|
+
return resolveViaIdentity({
|
|
101
|
+
bearer: cred.getBearer,
|
|
102
|
+
baseUrl,
|
|
103
|
+
options,
|
|
104
|
+
bootstrapHelper,
|
|
105
|
+
auth,
|
|
106
|
+
});
|
|
107
|
+
case 'explicit': {
|
|
108
|
+
// Legacy explicit (self-hosted, pre-Phase-3 — caller knows its own
|
|
109
|
+
// organizationId + user/agentId).
|
|
110
|
+
const userId = kind === 'agent' ? options.agentId : options.user.id;
|
|
111
|
+
const accountScope = internalOptions.organizationId;
|
|
112
|
+
bootstrapHelper.setCacheScope(accountScope);
|
|
113
|
+
bootstrapHelper.setSyncGroups(options.syncGroups);
|
|
114
|
+
auth.setAuthToken(cred.getBearer);
|
|
115
|
+
return {
|
|
116
|
+
userId,
|
|
117
|
+
accountScope,
|
|
118
|
+
teamIds: kind === 'user' ? options.user?.teamIds : undefined,
|
|
119
|
+
capabilityToken: cred.getBearer,
|
|
120
|
+
syncGroups: options.syncGroups,
|
|
121
|
+
participantKind: kind,
|
|
122
|
+
refreshScheduler: null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
130
125
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Shared `/auth/identity` resolution for the `pk_` (publishable) and pre-minted
|
|
129
|
+
* (`ek_`/`rk_` or explicit cap token) routes: the bearer is used as-is, the
|
|
130
|
+
* server resolves the identity, and caller-passed syncGroups are MERGED with the
|
|
131
|
+
* server-resolved set.
|
|
132
|
+
*/
|
|
133
|
+
async function resolveViaIdentity(input) {
|
|
134
|
+
const { bearer, baseUrl, options, bootstrapHelper, auth } = input;
|
|
135
|
+
const identity = await resolveIdentity({ baseUrl, authToken: bearer });
|
|
136
|
+
// Merge caller-passed syncGroups with server-resolved ones rather than letting
|
|
137
|
+
// the server's response silently overwrite. Browser consumers (apps/web's
|
|
138
|
+
// SyncEngineProvider) compose `['default', 'org:${orgId}', 'user:${userId}',
|
|
139
|
+
// ...team:]` from the resolved session and pass it via `<AbloProvider
|
|
140
|
+
// syncGroups>`; before this merge, the self-derived path dropped that set on
|
|
141
|
+
// the floor in favor of `/auth/identity`'s response, which is empty for
|
|
142
|
+
// cookie-auth users today (apps/sync-server/src/routes/auth.ts only populates
|
|
143
|
+
// from `effectiveSyncGroups`, the cap-narrowed list). Empty syncGroups →
|
|
144
|
+
// server bootstrap falls back to `['default']` → no deltas fan out → live
|
|
145
|
+
// updates appear only on hard reload.
|
|
146
|
+
const callerGroups = options.syncGroups ?? [];
|
|
147
|
+
const mergedSyncGroups = callerGroups.length > 0
|
|
148
|
+
? [...new Set([...callerGroups, ...identity.syncGroups])]
|
|
149
|
+
: identity.syncGroups;
|
|
150
|
+
bootstrapHelper.setCacheScope(identity.accountScope);
|
|
151
|
+
bootstrapHelper.setSyncGroups(mergedSyncGroups);
|
|
152
|
+
auth.setAuthToken(bearer);
|
|
138
153
|
return {
|
|
139
|
-
userId,
|
|
140
|
-
accountScope,
|
|
141
|
-
teamIds:
|
|
142
|
-
capabilityToken:
|
|
143
|
-
syncGroups:
|
|
144
|
-
participantKind:
|
|
154
|
+
userId: identity.participantId,
|
|
155
|
+
accountScope: identity.accountScope,
|
|
156
|
+
teamIds: undefined,
|
|
157
|
+
capabilityToken: bearer,
|
|
158
|
+
syncGroups: mergedSyncGroups,
|
|
159
|
+
participantKind: identity.participantKind,
|
|
145
160
|
refreshScheduler: null,
|
|
146
161
|
};
|
|
147
162
|
}
|
|
148
163
|
async function resolveHosted(input) {
|
|
149
|
-
// Pure managed-cloud shape: `Ablo({schema, apiKey})`.
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
// Pure managed-cloud shape: `Ablo({schema, apiKey})`. The credential policy
|
|
165
|
+
// already exchanged the apiKey (delegating to `exchangeApiKey`); here we apply
|
|
166
|
+
// the returned scope + userMeta and stand up the refresh scheduler.
|
|
167
|
+
const { exchange } = input.cred;
|
|
168
|
+
const baseUrl = input.baseUrl;
|
|
169
|
+
// The refresh path re-runs `exchangeApiKey` with a freshly-resolved apiKey, so
|
|
170
|
+
// it needs the same argument bag the policy used for the initial exchange.
|
|
155
171
|
const exchangeArgs = {
|
|
156
172
|
baseUrl,
|
|
157
173
|
participantKind: (input.kind === 'agent' ? 'agent' : 'system'),
|
|
@@ -159,10 +175,6 @@ async function resolveHosted(input) {
|
|
|
159
175
|
wideScope: true,
|
|
160
176
|
ttlSeconds: 3600,
|
|
161
177
|
};
|
|
162
|
-
const exchange = await exchangeApiKey({
|
|
163
|
-
...exchangeArgs,
|
|
164
|
-
apiKey: input.apiKeyValue,
|
|
165
|
-
});
|
|
166
178
|
input.bootstrapHelper.setCacheScope(exchange.scope.organizationId);
|
|
167
179
|
input.bootstrapHelper.setSyncGroups(exchange.scope.syncGroups);
|
|
168
180
|
input.auth.setAuthToken(exchange.token);
|
package/dist/client/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* });
|
|
30
30
|
* ```
|
|
31
31
|
*/
|
|
32
|
-
export { Ablo, computeFKDepthPriority, type AbloOptions, type InternalAbloOptions, type ClaimedOptions, type IfClaimedPolicy, type ClaimWaitOptions, type
|
|
32
|
+
export { Ablo, computeFKDepthPriority, type AbloOptions, type InternalAbloOptions, type ClaimedOptions, type IfClaimedPolicy, type ClaimWaitOptions, type LocalCountOptions, type LocalReadOptions, type ModelListScope, type ServerReadOptions, 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
35
|
export type { AbloApi, AbloApiClientOptions, AbloApiClaims, Capability, CapabilityCreateOptions, CapabilityParticipantKind, CapabilityRecord, CapabilityResource, CapabilityRevocation, CapabilityScope, } from './ApiClient.js';
|