@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/NOTICE +2 -2
  3. package/README.md +99 -78
  4. package/dist/BaseSyncedStore.d.ts +3 -2
  5. package/dist/agent/Agent.d.ts +1 -1
  6. package/dist/agent/Agent.js +1 -1
  7. package/dist/agent/index.d.ts +4 -4
  8. package/dist/agent/index.js +6 -6
  9. package/dist/agent/types.d.ts +1 -1
  10. package/dist/ai-sdk/index.d.ts +3 -3
  11. package/dist/ai-sdk/index.js +3 -3
  12. package/dist/ai-sdk/intent-broadcast.d.ts +1 -1
  13. package/dist/ai-sdk/intent-broadcast.js +1 -1
  14. package/dist/auth/index.d.ts +1 -1
  15. package/dist/client/Ablo.d.ts +53 -27
  16. package/dist/client/Ablo.js +32 -1
  17. package/dist/client/auth.d.ts +3 -3
  18. package/dist/client/auth.js +5 -5
  19. package/dist/client/createModelProxy.d.ts +118 -32
  20. package/dist/client/createModelProxy.js +87 -44
  21. package/dist/client/index.d.ts +3 -3
  22. package/dist/client/index.js +3 -3
  23. package/dist/config/index.d.ts +1 -1
  24. package/dist/config/index.js +1 -1
  25. package/dist/core/index.d.ts +1 -1
  26. package/dist/core/index.js +2 -2
  27. package/dist/errors.d.ts +9 -7
  28. package/dist/errors.js +9 -7
  29. package/dist/index.d.ts +20 -6
  30. package/dist/index.js +41 -22
  31. package/dist/interfaces/headless.d.ts +1 -1
  32. package/dist/interfaces/headless.js +2 -2
  33. package/dist/policy/index.d.ts +2 -2
  34. package/dist/policy/index.js +2 -2
  35. package/dist/policy/types.d.ts +10 -0
  36. package/dist/principal.d.ts +3 -3
  37. package/dist/principal.js +3 -3
  38. package/dist/query/client.d.ts +7 -6
  39. package/dist/react/AbloProvider.d.ts +44 -1
  40. package/dist/react/AbloProvider.js +3 -1
  41. package/dist/react/ClientSideSuspense.d.ts +1 -1
  42. package/dist/react/SyncGroupProvider.js +1 -1
  43. package/dist/react/context.d.ts +1 -1
  44. package/dist/react/context.js +1 -1
  45. package/dist/react/index.d.ts +1 -1
  46. package/dist/react/index.js +1 -1
  47. package/dist/react/useCurrentUserId.js +1 -1
  48. package/dist/react/useErrorListener.js +1 -1
  49. package/dist/react/useMutate.d.ts +1 -1
  50. package/dist/react/useMutationFailureListener.js +1 -1
  51. package/dist/react/useReader.d.ts +1 -1
  52. package/dist/schema/field.d.ts +1 -1
  53. package/dist/schema/field.js +1 -1
  54. package/dist/schema/index.d.ts +2 -2
  55. package/dist/schema/index.js +2 -2
  56. package/dist/schema/model.d.ts +2 -2
  57. package/dist/schema/model.js +2 -2
  58. package/dist/schema/queries.d.ts +1 -1
  59. package/dist/schema/queries.js +1 -1
  60. package/dist/schema/relation.d.ts +1 -1
  61. package/dist/schema/relation.js +1 -1
  62. package/dist/schema/schema.d.ts +1 -1
  63. package/dist/schema/schema.js +1 -1
  64. package/dist/source/index.d.ts +22 -28
  65. package/dist/source/index.js +23 -20
  66. package/dist/source/pushQueue.d.ts +1 -1
  67. package/dist/source/pushQueue.js +2 -2
  68. package/dist/sync/SyncWebSocket.d.ts +20 -5
  69. package/dist/sync/createIntentStream.js +7 -0
  70. package/dist/testing/fixtures/models.d.ts +1 -1
  71. package/dist/testing/fixtures/models.js +1 -1
  72. package/dist/testing/helpers/react-wrapper.d.ts +2 -2
  73. package/dist/testing/helpers/react-wrapper.js +2 -2
  74. package/dist/testing/index.d.ts +1 -1
  75. package/dist/testing/index.js +1 -1
  76. package/dist/types/streams.d.ts +41 -1
  77. package/docs/api.md +78 -20
  78. package/docs/data-sources.md +50 -16
  79. package/docs/examples/ai-sdk-tool.md +14 -31
  80. package/docs/examples/existing-python-backend.md +6 -6
  81. package/docs/integration-guide.md +8 -7
  82. package/docs/interaction-model.md +16 -4
  83. package/docs/mcp.md +1 -1
  84. package/docs/quickstart.md +20 -18
  85. package/examples/data-source/README.md +1 -1
  86. package/examples/data-source/ablo-driver.ts +5 -5
  87. package/examples/data-source/customer-server.ts +10 -10
  88. package/examples/data-source/run.ts +9 -11
  89. package/examples/data-source/schema.ts +1 -1
  90. package/examples/quickstart.ts +2 -2
  91. package/llms.txt +1 -1
  92. package/package.json +1 -1
@@ -1,11 +1,11 @@
1
1
  /**
2
- * @ablo/sync-engine/schema — Schema Definition DSL
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/sync-engine/schema';
8
+ * import { defineSchema, model, relation } from '@abloatai/ablo/schema';
9
9
  *
10
10
  * export const schema = defineSchema({
11
11
  * tasks: model({
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Usage:
8
8
  * import { z } from 'zod';
9
- * import { model, relation } from '@ablo/sync-engine/schema';
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/sync-engine/schema';
296
+ * import { model, relation } from '@abloatai/ablo/schema';
297
297
  *
298
298
  * const tasks = model({
299
299
  * title: z.string(),
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Usage:
8
8
  * import { z } from 'zod';
9
- * import { model, relation } from '@ablo/sync-engine/schema';
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/sync-engine/schema';
27
+ * import { model, relation } from '@abloatai/ablo/schema';
28
28
  *
29
29
  * const tasks = model({
30
30
  * title: z.string(),
@@ -10,7 +10,7 @@
10
10
  * import { z } from 'zod';
11
11
  * import {
12
12
  * defineSchema, defineQueries, model, query, relation,
13
- * } from '@ablo/sync-engine/schema';
13
+ * } from '@abloatai/ablo/schema';
14
14
  *
15
15
  * const schema = defineSchema({
16
16
  * slideLayer: model(
@@ -10,7 +10,7 @@
10
10
  * import { z } from 'zod';
11
11
  * import {
12
12
  * defineSchema, defineQueries, model, query, relation,
13
- * } from '@ablo/sync-engine/schema';
13
+ * } from '@abloatai/ablo/schema';
14
14
  *
15
15
  * const schema = defineSchema({
16
16
  * slideLayer: model(
@@ -7,7 +7,7 @@
7
7
  * - Query include/join support
8
8
  *
9
9
  * Usage:
10
- * import { relation } from '@ablo/sync-engine/schema';
10
+ * import { relation } from '@abloatai/ablo/schema';
11
11
  *
12
12
  * const taskRelations = {
13
13
  * project: relation.belongsTo('projects', 'projectId'),
@@ -7,7 +7,7 @@
7
7
  * - Query include/join support
8
8
  *
9
9
  * Usage:
10
- * import { relation } from '@ablo/sync-engine/schema';
10
+ * import { relation } from '@abloatai/ablo/schema';
11
11
  *
12
12
  * const taskRelations = {
13
13
  * project: relation.belongsTo('projects', 'projectId'),
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Usage:
7
7
  * import { z } from 'zod';
8
- * import { defineSchema, model, relation } from '@ablo/sync-engine/schema';
8
+ * import { defineSchema, model, relation } from '@abloatai/ablo/schema';
9
9
  *
10
10
  * const schema = defineSchema({
11
11
  * tasks: model({
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Usage:
7
7
  * import { z } from 'zod';
8
- * import { defineSchema, model, relation } from '@ablo/sync-engine/schema';
8
+ * import { defineSchema, model, relation } from '@abloatai/ablo/schema';
9
9
  *
10
10
  * const schema = defineSchema({
11
11
  * tasks: model({
@@ -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: a secret/key carries the set of operations
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 (legacy callers,
195
- * tests, or single-tenant deployments that haven't enabled it).
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 SourceSecret = string | ((context: SourceAuthorizeContext) => Promise<string> | string);
224
+ export type SourceApiKey = string | ((context: SourceAuthorizeContext) => Promise<string> | string);
225
225
  export interface SourceSignatureOptions {
226
- readonly secret: string;
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 secret: string;
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
- * Shared secret for Ablo Cloud -> customer source calls. When set,
267
- * abloSource verifies HMAC_SHA256(secret, `${timestamp}.${body}`)
268
- * before `authorize` or any model handler runs.
269
- *
270
- * @deprecated Use `signingSecret`. Kept so existing source routes
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 signingSecret?: SourceSecret;
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 `secret` before this function
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 secret itself) and
303
- * look up the scopes for that key in their store. Mirrors Stripe
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 (back-compat). Returning
307
- * an empty set denies all operations.
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
- * Legacy single-model hint. Omit for cross-model commits; top-level
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 DataSourceSecret = SourceSecret;
404
+ export type DataSourceApiKey = SourceApiKey;
411
405
  export type DataSourceSignatureOptions = SourceSignatureOptions;
412
406
  export type DataSourceSignatureVerificationOptions = SourceSignatureVerificationOptions;
413
407
  export type DataSourceSignatureVerificationResult = SourceSignatureVerificationResult;
@@ -79,13 +79,13 @@ function bufferToBase64(buffer) {
79
79
  }
80
80
  return btoa(binary);
81
81
  }
82
- async function hmacSha256Base64(secret, payload) {
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(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
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
- // Timestamps are seconds-since-epoch in the spec; we keep millis on
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
- if (Math.abs(Date.now() - signedAt) > toleranceMs) {
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.secret, `${messageId}.${signedAt}.${options.body}`);
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 resolveSecret(secret, context) {
151
- if (!secret)
149
+ async function resolveApiKey(apiKey, context) {
150
+ if (!apiKey)
152
151
  return null;
153
- return typeof secret === 'function' ? secret(context) : secret;
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 secret = await resolveSecret(options.signingSecret ?? options.secret, {
211
+ const apiKey = await resolveApiKey(options.apiKey, {
213
212
  request,
214
213
  body,
215
214
  rawBody,
216
215
  });
217
- if (secret) {
218
- signature = await verifyAbloSourceRequest({
219
- request,
220
- body: rawBody,
221
- secret,
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 secret: string;
63
+ readonly apiKey: string;
64
64
  readonly storage: PushQueueStorage;
65
65
  /**
66
66
  * Override the retry delays. Default: Standard Webhooks schedule.
@@ -84,9 +84,9 @@ export function createPushQueue(options) {
84
84
  let signed;
85
85
  try {
86
86
  signed = await signAbloSourceRequest({
87
- secret: options.secret,
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
- * Biscuit capability bearer token. When set, sent as
117
- * `?authorization=Bearer+<token>` on the WS upgrade — query-param
118
- * form so it works in both Node (no header support) and browsers.
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,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Test Model subclasses for @ablo/sync-engine tests.
2
+ * Test Model subclasses for @abloatai/ablo tests.
3
3
  *
4
4
  * Lightweight Model implementations with FK relationships matching
5
5
  * the MODEL_CREATE_PRIORITY map in TransactionQueue:
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Test Model subclasses for @ablo/sync-engine tests.
2
+ * Test Model subclasses for @abloatai/ablo tests.
3
3
  *
4
4
  * Lightweight Model implementations with FK relationships matching
5
5
  * the MODEL_CREATE_PRIORITY map in TransactionQueue:
@@ -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/sync-engine/testing';
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/sync-engine/testing';
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/sync-engine/testing';
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/sync-engine/testing';
40
+ * import { renderSyncHook, createMockSyncStore } from '@abloatai/ablo/testing';
41
41
  *
42
42
  * const mockStore = createMockSyncStore();
43
43
  * mockStore.addModel(Task, myTask);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @ablo/sync-engine/testing — Public test utilities for SDK consumers.
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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @ablo/sync-engine/testing — Public test utilities for SDK consumers.
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.
@@ -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 cryptographically via Biscuit attenuation.
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 signal. It tells humans and agents who is working on
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 intent = await ablo.intents.create({
115
- target: { resource: 'tasks', id: 'task_123', field: 'status' },
116
- action: 'update',
117
- });
165
+ const task = ablo.tasks.intent('task_123');
118
166
 
119
- try {
120
- const snap = ablo.snapshot({ tasks: 'task_123' });
121
- const task = await ablo.tasks.update(
122
- 'task_123',
123
- { status: 'done' },
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
- `intents.waitFor(target)` waits until the live intent stream clears. Pass
134
- `timeout` only when your product needs an upper bound.
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
- signingSecret: process.env.ABLO_DATA_SOURCE_SIGNING_SECRET,
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: [] };