@abloatai/ablo 0.3.1 → 0.5.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 +54 -1
- package/NOTICE +2 -2
- package/README.md +99 -78
- package/dist/BaseSyncedStore.d.ts +3 -2
- package/dist/agent/Agent.d.ts +1 -1
- package/dist/agent/Agent.js +1 -1
- package/dist/agent/index.d.ts +4 -4
- package/dist/agent/index.js +6 -6
- package/dist/agent/types.d.ts +1 -1
- package/dist/ai-sdk/index.d.ts +3 -3
- package/dist/ai-sdk/index.js +3 -3
- package/dist/ai-sdk/intent-broadcast.d.ts +1 -1
- package/dist/ai-sdk/intent-broadcast.js +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/client/Ablo.d.ts +53 -27
- package/dist/client/Ablo.js +32 -1
- package/dist/client/auth.d.ts +3 -3
- package/dist/client/auth.js +5 -5
- package/dist/client/createModelProxy.d.ts +118 -32
- package/dist/client/createModelProxy.js +87 -44
- package/dist/client/index.d.ts +3 -3
- package/dist/client/index.js +3 -3
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +2 -2
- package/dist/errors.d.ts +9 -7
- package/dist/errors.js +9 -7
- package/dist/index.d.ts +20 -6
- package/dist/index.js +41 -22
- package/dist/interfaces/headless.d.ts +1 -1
- package/dist/interfaces/headless.js +2 -2
- package/dist/policy/index.d.ts +2 -2
- package/dist/policy/index.js +2 -2
- package/dist/policy/types.d.ts +10 -0
- package/dist/principal.d.ts +3 -3
- package/dist/principal.js +3 -3
- package/dist/query/client.d.ts +7 -6
- package/dist/react/AbloProvider.d.ts +44 -1
- package/dist/react/AbloProvider.js +3 -1
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/SyncGroupProvider.js +1 -1
- package/dist/react/context.d.ts +1 -1
- package/dist/react/context.js +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useErrorListener.js +1 -1
- package/dist/react/useMutate.d.ts +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/dist/react/useReader.d.ts +1 -1
- package/dist/schema/field.d.ts +1 -1
- package/dist/schema/field.js +1 -1
- package/dist/schema/index.d.ts +2 -2
- package/dist/schema/index.js +2 -2
- package/dist/schema/model.d.ts +2 -2
- package/dist/schema/model.js +2 -2
- package/dist/schema/queries.d.ts +1 -1
- package/dist/schema/queries.js +1 -1
- package/dist/schema/relation.d.ts +1 -1
- package/dist/schema/relation.js +1 -1
- package/dist/schema/schema.d.ts +1 -1
- package/dist/schema/schema.js +1 -1
- package/dist/source/index.d.ts +22 -28
- package/dist/source/index.js +23 -20
- package/dist/source/pushQueue.d.ts +1 -1
- package/dist/source/pushQueue.js +2 -2
- package/dist/sync/SyncWebSocket.d.ts +20 -5
- package/dist/sync/createIntentStream.js +7 -0
- package/dist/testing/fixtures/models.d.ts +1 -1
- package/dist/testing/fixtures/models.js +1 -1
- package/dist/testing/helpers/react-wrapper.d.ts +2 -2
- package/dist/testing/helpers/react-wrapper.js +2 -2
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/types/streams.d.ts +41 -1
- package/docs/api.md +78 -20
- package/docs/data-sources.md +50 -16
- package/docs/examples/ai-sdk-tool.md +14 -31
- package/docs/examples/existing-python-backend.md +6 -6
- package/docs/integration-guide.md +8 -7
- package/docs/interaction-model.md +16 -4
- package/docs/mcp.md +1 -1
- package/docs/quickstart.md +20 -18
- package/examples/data-source/README.md +1 -1
- package/examples/data-source/ablo-driver.ts +5 -5
- package/examples/data-source/customer-server.ts +10 -10
- package/examples/data-source/run.ts +9 -11
- package/examples/data-source/schema.ts +1 -1
- package/examples/quickstart.ts +2 -2
- package/llms.txt +1 -1
- package/package.json +1 -1
package/dist/schema/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @ablo/
|
|
2
|
+
* @abloatai/ablo/schema — Schema Definition DSL
|
|
3
3
|
*
|
|
4
4
|
* Define your data models with Zod. Types are inferred automatically.
|
|
5
5
|
*
|
|
6
6
|
* ```ts
|
|
7
7
|
* import { z } from 'zod';
|
|
8
|
-
* import { defineSchema, model, relation } from '@ablo/
|
|
8
|
+
* import { defineSchema, model, relation } from '@abloatai/ablo/schema';
|
|
9
9
|
*
|
|
10
10
|
* export const schema = defineSchema({
|
|
11
11
|
* tasks: model({
|
package/dist/schema/model.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
8
|
* import { z } from 'zod';
|
|
9
|
-
* import { model, relation } from '@ablo/
|
|
9
|
+
* import { model, relation } from '@abloatai/ablo/schema';
|
|
10
10
|
*
|
|
11
11
|
* const tasks = model({
|
|
12
12
|
* title: z.string(),
|
|
@@ -293,7 +293,7 @@ export interface ModelDef<Shape extends z.ZodRawShape = z.ZodRawShape, R extends
|
|
|
293
293
|
*
|
|
294
294
|
* ```ts
|
|
295
295
|
* import { z } from 'zod';
|
|
296
|
-
* import { model, relation } from '@ablo/
|
|
296
|
+
* import { model, relation } from '@abloatai/ablo/schema';
|
|
297
297
|
*
|
|
298
298
|
* const tasks = model({
|
|
299
299
|
* title: z.string(),
|
package/dist/schema/model.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
8
|
* import { z } from 'zod';
|
|
9
|
-
* import { model, relation } from '@ablo/
|
|
9
|
+
* import { model, relation } from '@abloatai/ablo/schema';
|
|
10
10
|
*
|
|
11
11
|
* const tasks = model({
|
|
12
12
|
* title: z.string(),
|
|
@@ -24,7 +24,7 @@ import { getFieldMeta, inferFieldMetaFromZod } from './field.js';
|
|
|
24
24
|
*
|
|
25
25
|
* ```ts
|
|
26
26
|
* import { z } from 'zod';
|
|
27
|
-
* import { model, relation } from '@ablo/
|
|
27
|
+
* import { model, relation } from '@abloatai/ablo/schema';
|
|
28
28
|
*
|
|
29
29
|
* const tasks = model({
|
|
30
30
|
* title: z.string(),
|
package/dist/schema/queries.d.ts
CHANGED
package/dist/schema/queries.js
CHANGED
package/dist/schema/relation.js
CHANGED
package/dist/schema/schema.d.ts
CHANGED
package/dist/schema/schema.js
CHANGED
package/dist/source/index.d.ts
CHANGED
|
@@ -141,7 +141,7 @@ export interface SourceCommitParams<TAuth = unknown> {
|
|
|
141
141
|
}
|
|
142
142
|
/**
|
|
143
143
|
* Operation-level permission tag used by `resolveScopes`. Mirrors the
|
|
144
|
-
* four wire request types:
|
|
144
|
+
* four wire request types: an API key carries the set of operations
|
|
145
145
|
* it's allowed to invoke. Stripe's restricted-key model at the
|
|
146
146
|
* operation granularity — model-level scoping is a future addition.
|
|
147
147
|
*/
|
|
@@ -191,8 +191,8 @@ export interface SourceHandlerContext<TAuth = unknown> {
|
|
|
191
191
|
* can use it in `authorize` (to reject out-of-scope calls) and in
|
|
192
192
|
* `list` / `load` (to filter rows the participant is allowed to see).
|
|
193
193
|
*
|
|
194
|
-
* Absent for calls made without scope context
|
|
195
|
-
*
|
|
194
|
+
* Absent for calls made without scope context, such as tests or
|
|
195
|
+
* single-tenant deployments that do not need scoped fan-out yet.
|
|
196
196
|
*/
|
|
197
197
|
readonly scope?: SourceRequestContext;
|
|
198
198
|
}
|
|
@@ -221,9 +221,9 @@ type SourceModels<S extends SchemaRecord, TAuth> = Partial<{
|
|
|
221
221
|
readonly id: string;
|
|
222
222
|
} & Record<string, unknown>, InferCreate<Schema<S>, K>, TAuth>;
|
|
223
223
|
}>;
|
|
224
|
-
export type
|
|
224
|
+
export type SourceApiKey = string | ((context: SourceAuthorizeContext) => Promise<string> | string);
|
|
225
225
|
export interface SourceSignatureOptions {
|
|
226
|
-
readonly
|
|
226
|
+
readonly apiKey: string;
|
|
227
227
|
readonly body: string;
|
|
228
228
|
/**
|
|
229
229
|
* Unique message id (`webhook-id` per the Standard Webhooks spec).
|
|
@@ -231,12 +231,15 @@ export interface SourceSignatureOptions {
|
|
|
231
231
|
* receivers may dedupe by it.
|
|
232
232
|
*/
|
|
233
233
|
readonly messageId: string;
|
|
234
|
+
/**
|
|
235
|
+
* Unix timestamp in seconds. Defaults to the current time.
|
|
236
|
+
*/
|
|
234
237
|
readonly timestamp?: number;
|
|
235
238
|
}
|
|
236
239
|
export interface SourceSignatureVerificationOptions {
|
|
237
240
|
readonly request: Request;
|
|
238
241
|
readonly body: string;
|
|
239
|
-
readonly
|
|
242
|
+
readonly apiKey: string;
|
|
240
243
|
readonly toleranceMs?: number;
|
|
241
244
|
}
|
|
242
245
|
export interface SourceSignatureVerificationResult {
|
|
@@ -263,21 +266,13 @@ export declare class SourceSignatureError extends Error {
|
|
|
263
266
|
export type AbloSourceOptions<S extends SchemaRecord, TAuth = unknown> = {
|
|
264
267
|
readonly schema: Schema<S>;
|
|
265
268
|
/**
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
* do not break during the Data Source naming cleanup.
|
|
272
|
-
*/
|
|
273
|
-
readonly secret?: SourceSecret;
|
|
274
|
-
/**
|
|
275
|
-
* Signing secret for Ablo -> customer Data Source calls. The value is
|
|
276
|
-
* created for the Data Source endpoint in Ablo and stored in the
|
|
277
|
-
* customer's app environment. Used to verify Standard Webhooks
|
|
278
|
-
* headers before any handler runs.
|
|
269
|
+
* Customer-visible Ablo credential. In the API-key-only onboarding
|
|
270
|
+
* path, Ablo signs Data Source calls with the same project API key
|
|
271
|
+
* that the customer's server-side SDK uses. This keeps the customer
|
|
272
|
+
* env surface to one Ablo credential while preserving signed request
|
|
273
|
+
* verification before any handler runs.
|
|
279
274
|
*/
|
|
280
|
-
readonly
|
|
275
|
+
readonly apiKey: SourceApiKey;
|
|
281
276
|
/**
|
|
282
277
|
* Clock-skew window for signed source requests. Default: 5 minutes.
|
|
283
278
|
*/
|
|
@@ -287,7 +282,7 @@ export type AbloSourceOptions<S extends SchemaRecord, TAuth = unknown> = {
|
|
|
287
282
|
* a database handle, account scope, or current actor. Keep database
|
|
288
283
|
* credentials in this function's environment; never send them to Ablo.
|
|
289
284
|
*
|
|
290
|
-
* Signature verification is handled by `
|
|
285
|
+
* Signature verification is handled by `apiKey` before this function
|
|
291
286
|
* runs. `authorize` should only attach business context.
|
|
292
287
|
*/
|
|
293
288
|
readonly authorize?: (context: SourceAuthorizeContext) => Promise<TAuth> | TAuth;
|
|
@@ -299,12 +294,11 @@ export type AbloSourceOptions<S extends SchemaRecord, TAuth = unknown> = {
|
|
|
299
294
|
* runs.
|
|
300
295
|
*
|
|
301
296
|
* Customers typically extract a key id from the request (e.g.
|
|
302
|
-
* `webhook-id` prefix, a custom header, or the
|
|
303
|
-
* look up the scopes for that key in their store.
|
|
304
|
-
* restricted keys: one secret can read, another can read + write.
|
|
297
|
+
* `webhook-id` prefix, a custom header, or the API key itself) and
|
|
298
|
+
* look up the scopes for that key in their store.
|
|
305
299
|
*
|
|
306
|
-
* When omitted, all operations are allowed
|
|
307
|
-
*
|
|
300
|
+
* When omitted, all operations are allowed. Returning an empty set
|
|
301
|
+
* denies all operations.
|
|
308
302
|
*/
|
|
309
303
|
readonly resolveScopes?: (params: {
|
|
310
304
|
readonly auth: TAuth;
|
|
@@ -351,7 +345,7 @@ export type SourceListRequest = {
|
|
|
351
345
|
export type SourceCommitRequest = {
|
|
352
346
|
readonly type: 'commit';
|
|
353
347
|
/**
|
|
354
|
-
*
|
|
348
|
+
* Optional single-model hint. Omit for cross-model commits; top-level
|
|
355
349
|
* `commit` receives the whole operation array unchanged.
|
|
356
350
|
*/
|
|
357
351
|
readonly model?: string;
|
|
@@ -407,7 +401,7 @@ export type DataSourceAuthorizeContext = SourceAuthorizeContext;
|
|
|
407
401
|
export type DataSourceHandlerContext<TAuth = unknown> = SourceHandlerContext<TAuth>;
|
|
408
402
|
export type DataSourceModelHandlers<Row, CreateInput, TAuth = unknown> = SourceModelHandlers<Row, CreateInput, TAuth>;
|
|
409
403
|
export type DataSourceCommitHandler<TAuth = unknown> = SourceCommitHandler<TAuth>;
|
|
410
|
-
export type
|
|
404
|
+
export type DataSourceApiKey = SourceApiKey;
|
|
411
405
|
export type DataSourceSignatureOptions = SourceSignatureOptions;
|
|
412
406
|
export type DataSourceSignatureVerificationOptions = SourceSignatureVerificationOptions;
|
|
413
407
|
export type DataSourceSignatureVerificationResult = SourceSignatureVerificationResult;
|
package/dist/source/index.js
CHANGED
|
@@ -79,13 +79,13 @@ function bufferToBase64(buffer) {
|
|
|
79
79
|
}
|
|
80
80
|
return btoa(binary);
|
|
81
81
|
}
|
|
82
|
-
async function hmacSha256Base64(
|
|
82
|
+
async function hmacSha256Base64(apiKey, payload) {
|
|
83
83
|
const crypto = globalThis.crypto?.subtle;
|
|
84
84
|
if (!crypto) {
|
|
85
85
|
throw new SourceSignatureError('source_signature_invalid', 'WebCrypto HMAC support is unavailable in this runtime');
|
|
86
86
|
}
|
|
87
87
|
const encoder = new TextEncoder();
|
|
88
|
-
const key = await crypto.importKey('raw', encoder.encode(
|
|
88
|
+
const key = await crypto.importKey('raw', encoder.encode(apiKey), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
89
89
|
return bufferToBase64(await crypto.sign('HMAC', key, encoder.encode(payload)));
|
|
90
90
|
}
|
|
91
91
|
/**
|
|
@@ -101,12 +101,9 @@ function timingSafeEqual(expected, actual) {
|
|
|
101
101
|
return diff === 0;
|
|
102
102
|
}
|
|
103
103
|
export async function signAbloSourceRequest(options) {
|
|
104
|
-
const signedAt = options.timestamp ?? Date.now();
|
|
104
|
+
const signedAt = options.timestamp ?? Math.floor(Date.now() / 1000);
|
|
105
105
|
// Standard Webhooks signing input: `${msg_id}.${timestamp}.${payload}`
|
|
106
|
-
|
|
107
|
-
// the wire for backwards compatibility with our existing tolerance
|
|
108
|
-
// window — the receiver compares them millis-to-millis.
|
|
109
|
-
const signature = await hmacSha256Base64(options.secret, `${options.messageId}.${signedAt}.${options.body}`);
|
|
106
|
+
const signature = await hmacSha256Base64(options.apiKey, `${options.messageId}.${signedAt}.${options.body}`);
|
|
110
107
|
return {
|
|
111
108
|
signedAt,
|
|
112
109
|
signature,
|
|
@@ -131,14 +128,16 @@ export async function verifyAbloSourceRequest(options) {
|
|
|
131
128
|
throw new SourceSignatureError('source_timestamp_invalid', 'Invalid webhook-timestamp header');
|
|
132
129
|
}
|
|
133
130
|
const toleranceMs = options.toleranceMs ?? DEFAULT_SIGNATURE_TOLERANCE_MS;
|
|
134
|
-
|
|
131
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
132
|
+
const toleranceSeconds = Math.ceil(toleranceMs / 1000);
|
|
133
|
+
if (Math.abs(nowSeconds - signedAt) > toleranceSeconds) {
|
|
135
134
|
throw new SourceSignatureError('source_timestamp_expired', 'webhook-timestamp is outside the allowed clock-skew window');
|
|
136
135
|
}
|
|
137
136
|
const presented = parseSignatureHeader(getHeader(options.request, ABLO_SOURCE_HEADERS.signature));
|
|
138
137
|
if (presented.length === 0) {
|
|
139
138
|
throw new SourceSignatureError('source_signature_missing', 'Missing webhook-signature header');
|
|
140
139
|
}
|
|
141
|
-
const expected = await hmacSha256Base64(options.
|
|
140
|
+
const expected = await hmacSha256Base64(options.apiKey, `${messageId}.${signedAt}.${options.body}`);
|
|
142
141
|
// Accept any presented signature that matches — supports key
|
|
143
142
|
// rotation per the Standard Webhooks spec.
|
|
144
143
|
const ok = presented.some((sig) => timingSafeEqual(expected, sig));
|
|
@@ -147,10 +146,10 @@ export async function verifyAbloSourceRequest(options) {
|
|
|
147
146
|
}
|
|
148
147
|
return { messageId, signedAt };
|
|
149
148
|
}
|
|
150
|
-
async function
|
|
151
|
-
if (!
|
|
149
|
+
async function resolveApiKey(apiKey, context) {
|
|
150
|
+
if (!apiKey)
|
|
152
151
|
return null;
|
|
153
|
-
return typeof
|
|
152
|
+
return typeof apiKey === 'function' ? apiKey(context) : apiKey;
|
|
154
153
|
}
|
|
155
154
|
/**
|
|
156
155
|
* Map a wire request to its scope tag. Each request type corresponds
|
|
@@ -209,19 +208,23 @@ export function abloSource(options) {
|
|
|
209
208
|
}
|
|
210
209
|
let signature = null;
|
|
211
210
|
try {
|
|
212
|
-
const
|
|
211
|
+
const apiKey = await resolveApiKey(options.apiKey, {
|
|
213
212
|
request,
|
|
214
213
|
body,
|
|
215
214
|
rawBody,
|
|
216
215
|
});
|
|
217
|
-
if (
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
toleranceMs: options.signatureToleranceMs,
|
|
223
|
-
});
|
|
216
|
+
if (!apiKey) {
|
|
217
|
+
return json({
|
|
218
|
+
error: 'source_api_key_missing',
|
|
219
|
+
message: 'Data Source apiKey is required',
|
|
220
|
+
}, 401);
|
|
224
221
|
}
|
|
222
|
+
signature = await verifyAbloSourceRequest({
|
|
223
|
+
request,
|
|
224
|
+
body: rawBody,
|
|
225
|
+
apiKey,
|
|
226
|
+
toleranceMs: options.signatureToleranceMs,
|
|
227
|
+
});
|
|
225
228
|
}
|
|
226
229
|
catch (err) {
|
|
227
230
|
if (err instanceof SourceSignatureError) {
|
|
@@ -60,7 +60,7 @@ export interface PushQueueStorage {
|
|
|
60
60
|
export declare const STANDARD_WEBHOOKS_RETRY_SCHEDULE: readonly number[];
|
|
61
61
|
export interface PushQueueOptions {
|
|
62
62
|
readonly endpoint: string;
|
|
63
|
-
readonly
|
|
63
|
+
readonly apiKey: string;
|
|
64
64
|
readonly storage: PushQueueStorage;
|
|
65
65
|
/**
|
|
66
66
|
* Override the retry delays. Default: Standard Webhooks schedule.
|
package/dist/source/pushQueue.js
CHANGED
|
@@ -84,9 +84,9 @@ export function createPushQueue(options) {
|
|
|
84
84
|
let signed;
|
|
85
85
|
try {
|
|
86
86
|
signed = await signAbloSourceRequest({
|
|
87
|
-
|
|
87
|
+
apiKey: options.apiKey,
|
|
88
88
|
body: rawBody,
|
|
89
|
-
timestamp: now(),
|
|
89
|
+
timestamp: Math.floor(now() / 1000),
|
|
90
90
|
// Reuse the queue id as the webhook-id across all retry
|
|
91
91
|
// attempts so the receiver can dedupe replays per spec.
|
|
92
92
|
messageId: item.id,
|
|
@@ -113,11 +113,12 @@ export interface SyncWebSocketOptions {
|
|
|
113
113
|
*/
|
|
114
114
|
kind?: 'user' | 'agent' | 'system';
|
|
115
115
|
/**
|
|
116
|
-
*
|
|
117
|
-
* `?authorization=Bearer+<token>` on the WS upgrade —
|
|
118
|
-
* form so it works in both Node (no header support) and
|
|
119
|
-
* The server's auth path accepts either form. Required for
|
|
120
|
-
* `kind: 'agent'`; ignored for `kind: 'user'`.
|
|
116
|
+
* The agent's bearer credential — a restricted (`rk_`) API key. When
|
|
117
|
+
* set, sent as `?authorization=Bearer+<token>` on the WS upgrade —
|
|
118
|
+
* query-param form so it works in both Node (no header support) and
|
|
119
|
+
* browsers. The server's auth path accepts either form. Required for
|
|
120
|
+
* `kind: 'agent'`; ignored for `kind: 'user'`. (Field name predates
|
|
121
|
+
* the Biscuit→opaque-key migration.)
|
|
121
122
|
*/
|
|
122
123
|
capabilityToken?: string;
|
|
123
124
|
}
|
|
@@ -193,6 +194,20 @@ export interface PresenceUpdateEvent {
|
|
|
193
194
|
meta?: Record<string, unknown>;
|
|
194
195
|
declaredAt: number;
|
|
195
196
|
expiresAt: number;
|
|
197
|
+
/**
|
|
198
|
+
* Lifecycle state. Additive — older servers omit it and the reader
|
|
199
|
+
* treats absence as `'active'`. Terminal states (`committed` /
|
|
200
|
+
* `expired` / `canceled`) ride one frame as the claim ends so peers
|
|
201
|
+
* learn *how* it resolved before it drops from the active set.
|
|
202
|
+
*/
|
|
203
|
+
status?: 'active' | 'committed' | 'expired' | 'canceled';
|
|
204
|
+
error?: {
|
|
205
|
+
code: string;
|
|
206
|
+
message?: string;
|
|
207
|
+
heldBy?: string;
|
|
208
|
+
heldByIntentId?: string;
|
|
209
|
+
heldByExpiresAt?: number;
|
|
210
|
+
};
|
|
196
211
|
}>;
|
|
197
212
|
localTime?: string;
|
|
198
213
|
type?: string;
|
|
@@ -78,6 +78,13 @@ export function createIntentStream(config, transport = null) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
for (const claim of event.activeIntents ?? []) {
|
|
81
|
+
// Terminal-status entries (committed / expired / canceled) are
|
|
82
|
+
// one-shot "this claim ended" signals. The holder sweep above
|
|
83
|
+
// already removed the prior active entry; skipping the re-add
|
|
84
|
+
// drops it from `others`, which is what resolves a contender's
|
|
85
|
+
// `settled()`. Absent status means active (wire back-compat).
|
|
86
|
+
if (claim.status && claim.status !== 'active')
|
|
87
|
+
continue;
|
|
81
88
|
activeByIntentId.set(claim.intentId, {
|
|
82
89
|
id: claim.intentId,
|
|
83
90
|
heldBy: event.userId,
|
|
@@ -19,7 +19,7 @@ export interface TestWrapperOptions {
|
|
|
19
19
|
*
|
|
20
20
|
* @example
|
|
21
21
|
* import { renderHook } from '@testing-library/react';
|
|
22
|
-
* import { createReactTestWrapper, createMockSyncStore } from '@ablo/
|
|
22
|
+
* import { createReactTestWrapper, createMockSyncStore } from '@abloatai/ablo/testing';
|
|
23
23
|
*
|
|
24
24
|
* const mockStore = createMockSyncStore();
|
|
25
25
|
* mockStore.setModels(Task, [task1, task2]);
|
|
@@ -40,7 +40,7 @@ export declare function createReactTestWrapper(options?: TestWrapperOptions): Re
|
|
|
40
40
|
* consumers without React tests to install it.
|
|
41
41
|
*
|
|
42
42
|
* @example
|
|
43
|
-
* import { renderSyncHook, createMockSyncStore } from '@ablo/
|
|
43
|
+
* import { renderSyncHook, createMockSyncStore } from '@abloatai/ablo/testing';
|
|
44
44
|
*
|
|
45
45
|
* const mockStore = createMockSyncStore();
|
|
46
46
|
* mockStore.addModel(Task, myTask);
|
|
@@ -13,7 +13,7 @@ import { MockSyncStore, createMockSyncStore } from '../mocks/MockSyncStore.js';
|
|
|
13
13
|
*
|
|
14
14
|
* @example
|
|
15
15
|
* import { renderHook } from '@testing-library/react';
|
|
16
|
-
* import { createReactTestWrapper, createMockSyncStore } from '@ablo/
|
|
16
|
+
* import { createReactTestWrapper, createMockSyncStore } from '@abloatai/ablo/testing';
|
|
17
17
|
*
|
|
18
18
|
* const mockStore = createMockSyncStore();
|
|
19
19
|
* mockStore.setModels(Task, [task1, task2]);
|
|
@@ -37,7 +37,7 @@ export function createReactTestWrapper(options = {}) {
|
|
|
37
37
|
* consumers without React tests to install it.
|
|
38
38
|
*
|
|
39
39
|
* @example
|
|
40
|
-
* import { renderSyncHook, createMockSyncStore } from '@ablo/
|
|
40
|
+
* import { renderSyncHook, createMockSyncStore } from '@abloatai/ablo/testing';
|
|
41
41
|
*
|
|
42
42
|
* const mockStore = createMockSyncStore();
|
|
43
43
|
* mockStore.addModel(Task, myTask);
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @ablo/
|
|
2
|
+
* @abloatai/ablo/testing — Public test utilities for SDK consumers.
|
|
3
3
|
*
|
|
4
4
|
* Provides mock implementations, fixture factories, and test harnesses
|
|
5
5
|
* for writing integration tests against the sync engine.
|
package/dist/testing/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @ablo/
|
|
2
|
+
* @abloatai/ablo/testing — Public test utilities for SDK consumers.
|
|
3
3
|
*
|
|
4
4
|
* Provides mock implementations, fixture factories, and test harnesses
|
|
5
5
|
* for writing integration tests against the sync engine.
|
package/dist/types/streams.d.ts
CHANGED
|
@@ -57,7 +57,8 @@ export interface AgentDelta {
|
|
|
57
57
|
/**
|
|
58
58
|
* A reference to whoever's authority bounds a joined participant.
|
|
59
59
|
* The spawned participant can never see or do more than this principal.
|
|
60
|
-
* Enforced
|
|
60
|
+
* Enforced server-side: the spawned agent gets its own restricted
|
|
61
|
+
* (`rk_`) key whose scope is a subset of the parent's.
|
|
61
62
|
*
|
|
62
63
|
* • `SessionRef` — human is joining an agent (chat assistant flow)
|
|
63
64
|
* • `AgentRef` — agent spawning a sub-agent (attenuation chain)
|
|
@@ -493,3 +494,42 @@ export interface ActiveIntent extends IntentDeclaration {
|
|
|
493
494
|
readonly announcedAt: string;
|
|
494
495
|
readonly expiresAt: string;
|
|
495
496
|
}
|
|
497
|
+
/** Every lifecycle state of a coordination intent, in one enum. */
|
|
498
|
+
export type IntentStatus = 'active' | 'committed' | 'expired' | 'canceled';
|
|
499
|
+
/** Options for waiting on a target to become free. */
|
|
500
|
+
export interface IntentWaitOptions {
|
|
501
|
+
readonly timeout?: number;
|
|
502
|
+
readonly pollInterval?: number;
|
|
503
|
+
readonly signal?: AbortSignal;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* The coordination state of one entity. Self-describing on the wire via
|
|
507
|
+
* `object: 'intent'`. Existence with `status: 'active'` *is* the lock;
|
|
508
|
+
* the fields *are* the awareness ("agent X is editing this until Y").
|
|
509
|
+
*
|
|
510
|
+
* Deliberately omits a Stripe-style `next_action`: a contender's only
|
|
511
|
+
* response is "wait until free, then re-read", and the runtime performs
|
|
512
|
+
* that uniformly at the tool boundary (`IntentHandle.whenFree()` + the
|
|
513
|
+
* stale-context guard that forces a re-read). Encoding a constant
|
|
514
|
+
* instruction the engine always takes would be the kind of ceremony this
|
|
515
|
+
* object exists to remove.
|
|
516
|
+
*/
|
|
517
|
+
export interface Intent {
|
|
518
|
+
readonly object: 'intent';
|
|
519
|
+
readonly id: string;
|
|
520
|
+
readonly status: IntentStatus;
|
|
521
|
+
/** What is being coordinated. */
|
|
522
|
+
readonly target: EntityRef;
|
|
523
|
+
/** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. */
|
|
524
|
+
readonly action: string;
|
|
525
|
+
/** Participant holding it. */
|
|
526
|
+
readonly heldBy: string;
|
|
527
|
+
readonly participantKind: 'human' | 'agent';
|
|
528
|
+
/**
|
|
529
|
+
* Ms-epoch the holder opened it. Optional until the lease wire carries
|
|
530
|
+
* it — derived shapes (e.g. mapped from a presence frame) may omit it.
|
|
531
|
+
*/
|
|
532
|
+
readonly createdAt?: string;
|
|
533
|
+
/** Ms-epoch the server auto-expires it if the holder doesn't finish. */
|
|
534
|
+
readonly expiresAt: string;
|
|
535
|
+
}
|
package/docs/api.md
CHANGED
|
@@ -107,31 +107,89 @@ that work to clear, or `ifBusy: 'fail'` to throw `AbloBusyError`.
|
|
|
107
107
|
|
|
108
108
|
## Intent
|
|
109
109
|
|
|
110
|
-
Intent is the coordination
|
|
111
|
-
a target before the write lands.
|
|
110
|
+
Intent is the coordination object — it tells humans and agents who is working on
|
|
111
|
+
a target before the write lands. Like Stripe's `PaymentIntent`, one
|
|
112
|
+
self-describing object carries the whole lifecycle in a single `status` field.
|
|
113
|
+
It lives on the **coordination plane**: ephemeral, TTL'd, broadcast to peers in
|
|
114
|
+
real time, and never persisted as a row.
|
|
115
|
+
|
|
116
|
+
Read or open one through the model accessor — `ablo.<model>.intent(id)` — which
|
|
117
|
+
sits beside `create`/`update`/`retrieve` and returns a handle **synchronously**,
|
|
118
|
+
so you can inspect who holds a target without awaiting.
|
|
119
|
+
|
|
120
|
+
### The Intent object
|
|
121
|
+
|
|
122
|
+
| Field | Type | Description |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `object` | `'intent'` | String representing the object's type. Always `'intent'`. |
|
|
125
|
+
| `id` | string | Unique identifier for the intent. |
|
|
126
|
+
| `status` | `'active' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. |
|
|
127
|
+
| `target` | `{ type, id, field? }` | What is being coordinated. |
|
|
128
|
+
| `action` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
|
|
129
|
+
| `heldBy` | string | Participant id holding the intent. |
|
|
130
|
+
| `participantKind` | `'human' \| 'agent'` | Whether a human session or an agent holds it. |
|
|
131
|
+
| `expiresAt` | string | Ms-epoch at which the server auto-expires it if the holder doesn't finish. |
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"object": "intent",
|
|
136
|
+
"id": "int_3MtwBwLkdIwHu7ix",
|
|
137
|
+
"status": "active",
|
|
138
|
+
"target": { "type": "tasks", "id": "task_123", "field": "status" },
|
|
139
|
+
"action": "editing",
|
|
140
|
+
"heldBy": "agent:task-writer",
|
|
141
|
+
"participantKind": "agent",
|
|
142
|
+
"expiresAt": "1716580000000"
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Lifecycle
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
claim() update() lands
|
|
150
|
+
(free) ───────────▶ active ───────────────────────▶ committed
|
|
151
|
+
│
|
|
152
|
+
┌───────────┴───────────┐
|
|
153
|
+
▼ ▼
|
|
154
|
+
canceled expired
|
|
155
|
+
(finish w/o write) (TTL; holder died)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
A target is free when `ablo.<model>.intent(id).current` is `null`. Terminal
|
|
159
|
+
states drop out of the live stream — a present intent is, by definition,
|
|
160
|
+
`active`.
|
|
161
|
+
|
|
162
|
+
### Reading and claiming
|
|
112
163
|
|
|
113
164
|
```ts
|
|
114
|
-
const
|
|
115
|
-
target: { resource: 'tasks', id: 'task_123', field: 'status' },
|
|
116
|
-
action: 'update',
|
|
117
|
-
});
|
|
165
|
+
const task = ablo.tasks.intent('task_123');
|
|
118
166
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
{ intent, readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
task.status; // done
|
|
128
|
-
} finally {
|
|
129
|
-
await intent.release();
|
|
167
|
+
// Read side — who is working on this target right now?
|
|
168
|
+
if (task.current) {
|
|
169
|
+
task.current.heldBy; // 'agent:task-writer'
|
|
170
|
+
task.current.action; // 'editing'
|
|
171
|
+
await task.whenFree(); // wait until they finish, then continue
|
|
130
172
|
}
|
|
173
|
+
|
|
174
|
+
// Write side — claim, write, auto-release in one flow.
|
|
175
|
+
await task.claim({ action: 'editing', field: 'status', ttl: '2m' });
|
|
176
|
+
const updated = await task.update({ status: 'done' });
|
|
177
|
+
updated.status; // 'done'
|
|
131
178
|
```
|
|
132
179
|
|
|
133
|
-
`
|
|
134
|
-
`
|
|
180
|
+
`task.update(...)` carries the same stale-check as a plain update: it rejects
|
|
181
|
+
with `AbloStaleContextError` if the row advanced past your claim point, so you
|
|
182
|
+
re-read before retrying. The intent releases automatically when `update`
|
|
183
|
+
resolves; call `task.finish()` if the work ends without a write.
|
|
184
|
+
|
|
185
|
+
`task.whenFree({ timeout })` waits until the target is free. Pass `timeout` only
|
|
186
|
+
when your product needs an upper bound.
|
|
187
|
+
|
|
188
|
+
### Cross-resource coordination
|
|
189
|
+
|
|
190
|
+
For lower-level coordination that isn't scoped to a single model row, the
|
|
191
|
+
top-level `ablo.intents` resource (`create`, `list`, `waitFor`) remains
|
|
192
|
+
available. Most callers should prefer `ablo.<model>.intent(id)`.
|
|
135
193
|
|
|
136
194
|
## Advanced Commit API
|
|
137
195
|
|
|
@@ -176,7 +234,7 @@ import { schema } from './ablo.schema';
|
|
|
176
234
|
|
|
177
235
|
export const POST = dataSource({
|
|
178
236
|
schema,
|
|
179
|
-
|
|
237
|
+
apiKey: process.env.ABLO_API_KEY,
|
|
180
238
|
async commit({ operations, clientTxId, context }) {
|
|
181
239
|
// Write operations to the customer's database transaction.
|
|
182
240
|
return { rows: [] };
|