@abloatai/ablo 0.13.0 → 0.15.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/dist/BaseSyncedStore.js +39 -32
- package/dist/Database.d.ts +1 -1
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +1 -0
- package/dist/batching/index.d.ts +57 -0
- package/dist/batching/index.js +150 -0
- package/dist/cli.cjs +63 -1
- package/dist/client/Ablo.d.ts +43 -28
- package/dist/client/Ablo.js +12 -5
- package/dist/client/auth.js +11 -0
- package/dist/client/createModelProxy.d.ts +33 -8
- package/dist/client/createModelProxy.js +4 -4
- package/dist/client/sessionMint.js +1 -0
- package/dist/client/writeOptionsSchema.d.ts +4 -6
- package/dist/client/writeOptionsSchema.js +1 -1
- package/dist/coordination/schema.d.ts +90 -12
- package/dist/coordination/schema.js +99 -4
- package/dist/errorCodes.d.ts +3 -1
- package/dist/errorCodes.js +10 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +9 -0
- package/dist/interfaces/index.d.ts +18 -2
- package/dist/policy/types.d.ts +35 -3
- package/dist/policy/types.js +20 -7
- package/dist/server/commit.d.ts +17 -0
- package/dist/source/connector-protocol.d.ts +159 -0
- package/dist/source/connector-protocol.js +161 -0
- package/dist/source/connector.d.ts +96 -0
- package/dist/source/connector.js +264 -0
- package/dist/source/contract.d.ts +4 -6
- package/dist/source/contract.js +1 -1
- package/dist/source/index.d.ts +3 -1
- package/dist/source/index.js +6 -0
- package/dist/sync/SyncWebSocket.d.ts +32 -5
- package/dist/sync/SyncWebSocket.js +40 -6
- package/dist/transactions/TransactionQueue.d.ts +7 -1
- package/dist/transactions/TransactionQueue.js +43 -2
- package/dist/wire/frames.d.ts +21 -4
- package/docs/api.md +6 -5
- package/docs/concurrency-convention.md +222 -0
- package/docs/coordination.md +16 -11
- package/docs/data-sources.md +41 -0
- package/docs/react.md +69 -0
- package/package.json +11 -1
package/dist/client/auth.js
CHANGED
|
@@ -54,6 +54,14 @@ export function resolveDatabaseUrl(input) {
|
|
|
54
54
|
* explicit option instead of flipping their mode for them. Warns once per process
|
|
55
55
|
* so it never spams, and falls back to `console.warn` when no logger is supplied
|
|
56
56
|
* (the `transport: 'api'` client has none).
|
|
57
|
+
*
|
|
58
|
+
* Suppressed entirely on the hosted/token path: if an `apiKey` resolves (option
|
|
59
|
+
* or `ABLO_API_KEY` env), the caller has chosen the hosted capability-token /
|
|
60
|
+
* Data Source transport, which is mutually exclusive with direct `databaseUrl`
|
|
61
|
+
* mode. A `DATABASE_URL` sitting in that environment is unrelated infra (Prisma,
|
|
62
|
+
* Drizzle, the sync-server) — never an omitted option — so nudging would be a
|
|
63
|
+
* false positive. This is the first-party hosted app's exact shape, where the
|
|
64
|
+
* stray nudge otherwise reaches end-user desktop logs.
|
|
57
65
|
*/
|
|
58
66
|
let warnedDatabaseUrlEnvIgnored = false;
|
|
59
67
|
export function warnIfDatabaseUrlEnvIgnored(input, warn) {
|
|
@@ -61,6 +69,9 @@ export function warnIfDatabaseUrlEnvIgnored(input, warn) {
|
|
|
61
69
|
return;
|
|
62
70
|
if (input.options.databaseUrl != null)
|
|
63
71
|
return;
|
|
72
|
+
// Hosted/token path → DATABASE_URL is unrelated infra, not an omitted option.
|
|
73
|
+
if (resolveApiKey(input) != null)
|
|
74
|
+
return;
|
|
64
75
|
const envUrl = input.env.DATABASE_URL;
|
|
65
76
|
if (typeof envUrl !== 'string' || envUrl.length === 0)
|
|
66
77
|
return;
|
|
@@ -109,8 +109,13 @@ export interface ModelCollaboration<T> {
|
|
|
109
109
|
* `null` when the target is free. The wiring site computes it because
|
|
110
110
|
* only it knows the local participant id (needed to distinguish "I
|
|
111
111
|
* hold it" from "someone else holds it").
|
|
112
|
+
*
|
|
113
|
+
* Named `state` to match the public `ablo.<model>.claim.state({ id })` read —
|
|
114
|
+
* one verb for "who holds this" across every claim surface; the only
|
|
115
|
+
* difference is this internal contract takes an explicit `{ model, id }`
|
|
116
|
+
* target because it isn't bound to a single model.
|
|
112
117
|
*/
|
|
113
|
-
|
|
118
|
+
state(target: {
|
|
114
119
|
model: string;
|
|
115
120
|
id: string;
|
|
116
121
|
}): Claim | null;
|
|
@@ -202,10 +207,11 @@ export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
|
202
207
|
* work-distribution dedup ("if someone else has this job, skip it") where
|
|
203
208
|
* waiting would mean double-processing.
|
|
204
209
|
*
|
|
205
|
-
* Named `queue` to match every other claim surface
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
210
|
+
* Named `queue` to match every other claim surface — `ablo.<model>.claim`
|
|
211
|
+
* on both the WS and HTTP clients (take-a-claim is the callable `claim({ id
|
|
212
|
+
* })` on both; the HTTP reads are just awaited) and the wire. The high-level
|
|
213
|
+
* typed claim defaults it ON because it serializes writers; the low-level
|
|
214
|
+
* lease and HTTP default it OFF — they return/resolve immediately and can't
|
|
209
215
|
* transparently wait for a grant.
|
|
210
216
|
*/
|
|
211
217
|
queue?: boolean;
|
|
@@ -276,9 +282,18 @@ export type ClaimOptions<T = Record<string, unknown>> = ClaimTargetOptions<T>;
|
|
|
276
282
|
* handle. `state`, `queue`, and `reorder` are coordination reads/scheduler
|
|
277
283
|
* controls for UI and operators.
|
|
278
284
|
*/
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
285
|
+
/**
|
|
286
|
+
* Coordination reads + scheduler controls on a claim namespace, in their
|
|
287
|
+
* REACTIVE (synchronous) form — `state`/`queue`/`reorder` resolve against the
|
|
288
|
+
* local pool with no round-trip, which is what lets `useAblo((ablo) =>
|
|
289
|
+
* ablo.x.claim.state({ id }))` read coordination state inside a React render.
|
|
290
|
+
*
|
|
291
|
+
* This is the single source of truth for the claim read surface: the stateless
|
|
292
|
+
* HTTP client exposes the *awaited* projection of EXACTLY these methods
|
|
293
|
+
* (`HttpClaimApi` in `Ablo.ts`, derived via {@link AwaitedClaimMethod}), so the
|
|
294
|
+
* two transports can never drift — edit a signature here and HTTP follows.
|
|
295
|
+
*/
|
|
296
|
+
export interface ClaimReadApi<T = Record<string, unknown>> {
|
|
282
297
|
/**
|
|
283
298
|
* Current holder for a row, or `null` when free. Use this for UI badges and
|
|
284
299
|
* preflight checks, not for the normal write path.
|
|
@@ -299,6 +314,16 @@ export interface ClaimApi<T> {
|
|
|
299
314
|
/** Release a manual claim handle early. Single-write claims auto-release. */
|
|
300
315
|
release(params: ClaimLookupParams<T> | ClaimHandle<T>): Promise<void>;
|
|
301
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* The awaited form of a claim method: a synchronous return becomes a `Promise`,
|
|
319
|
+
* an already-async one (`release`) is left untouched. Used to derive the
|
|
320
|
+
* stateless HTTP claim surface from the reactive {@link ClaimReadApi}.
|
|
321
|
+
*/
|
|
322
|
+
export type AwaitedClaimMethod<F> = F extends (...args: infer A) => infer R ? R extends Promise<unknown> ? (...args: A) => R : (...args: A) => Promise<R> : F;
|
|
323
|
+
export interface ClaimApi<T> extends ClaimReadApi<T> {
|
|
324
|
+
/** Take a claim and get an explicit held-work handle back. */
|
|
325
|
+
(params: ClaimParams<T>): Promise<ClaimHandle<T>>;
|
|
326
|
+
}
|
|
302
327
|
export interface ModelRetrieveParams extends ServerRetrieveOptions {
|
|
303
328
|
readonly id: string;
|
|
304
329
|
}
|
|
@@ -141,7 +141,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
141
141
|
// Is someone ELSE already on this target? Read the local coordination
|
|
142
142
|
// snapshot up front — it decides whether we'll need to re-read after the
|
|
143
143
|
// claim (a free / already-mine target can't have changed under us).
|
|
144
|
-
const held = collaboration.
|
|
144
|
+
const held = collaboration.state({ model: wireModel, id });
|
|
145
145
|
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
146
146
|
const failFast = options?.queue === false;
|
|
147
147
|
// Fail-fast (`queue: false`): if another participant already holds it,
|
|
@@ -215,7 +215,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
215
215
|
const snapshot = collaboration.createSnapshot(schemaKey, id);
|
|
216
216
|
const reason = options?.reason ?? 'editing';
|
|
217
217
|
// The self-claim's `EntityRef` mirrors what a peer's `claim.state` would
|
|
218
|
-
// report (`
|
|
218
|
+
// report (`state` 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.
|
|
220
220
|
const selfTarget = {
|
|
221
221
|
type: wireModel,
|
|
@@ -271,7 +271,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
271
271
|
// presence. Soft + fire-and-forget — never blocks or rejects the read.
|
|
272
272
|
void collaboration?.enterScope?.({ [schemaKey]: params.id });
|
|
273
273
|
// Self-awareness: the server excludes a holder's OWN presence frames and
|
|
274
|
-
// the client skips them, so `
|
|
274
|
+
// the client skips them, so `state` returns null for a row WE hold.
|
|
275
275
|
// Synthesize the active claim for self from the stored lease so the
|
|
276
276
|
// holder sees its own claim (the JSDoc contract on `claim.state`).
|
|
277
277
|
const own = activeClaims.get(params.id);
|
|
@@ -287,7 +287,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
287
287
|
expiresAt: own.expiresAt,
|
|
288
288
|
};
|
|
289
289
|
}
|
|
290
|
-
return collaboration?.
|
|
290
|
+
return collaboration?.state({ model: wireModel, id: params.id }) ?? null;
|
|
291
291
|
},
|
|
292
292
|
queue(params) {
|
|
293
293
|
return {
|
|
@@ -40,6 +40,7 @@ export async function mintSession(params, ctx) {
|
|
|
40
40
|
apiKey,
|
|
41
41
|
baseUrl,
|
|
42
42
|
userId: params.user.id,
|
|
43
|
+
...(params.organizationId ? { organizationId: params.organizationId } : {}),
|
|
43
44
|
...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
|
|
44
45
|
ttlSeconds: params.ttlSeconds ?? 900,
|
|
45
46
|
...(ctx.fetch ? { fetch: ctx.fetch } : {}),
|
|
@@ -17,9 +17,8 @@
|
|
|
17
17
|
import { z } from 'zod';
|
|
18
18
|
export declare const onStaleModeSchema: z.ZodEnum<{
|
|
19
19
|
reject: "reject";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
merge: "merge";
|
|
20
|
+
overwrite: "overwrite";
|
|
21
|
+
notify: "notify";
|
|
23
22
|
}>;
|
|
24
23
|
export declare const writeOptionsSchema: z.ZodObject<{
|
|
25
24
|
idempotencyKey: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
@@ -31,9 +30,8 @@ export declare const writeOptionsSchema: z.ZodObject<{
|
|
|
31
30
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
32
31
|
onStale: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
33
32
|
reject: "reject";
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
merge: "merge";
|
|
33
|
+
overwrite: "overwrite";
|
|
34
|
+
notify: "notify";
|
|
37
35
|
}>>>;
|
|
38
36
|
claim: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
39
37
|
id: z.ZodString;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { z } from 'zod';
|
|
18
18
|
import { AbloValidationError } from '../errors.js';
|
|
19
|
-
export const onStaleModeSchema = z.enum(['reject', '
|
|
19
|
+
export const onStaleModeSchema = z.enum(['reject', 'overwrite', 'notify']);
|
|
20
20
|
export const writeOptionsSchema = z.object({
|
|
21
21
|
/** Server-side mutation_log cache key; `null` opts out of retry-safety. */
|
|
22
22
|
idempotencyKey: z.string().min(1).max(255).nullish(),
|
|
@@ -82,15 +82,19 @@ export declare const targetRefSchema: z.ZodObject<{
|
|
|
82
82
|
export type TargetRef = z.infer<typeof targetRefSchema>;
|
|
83
83
|
/**
|
|
84
84
|
* Mode applied when a write's snapshot watermark (`readAt`) is older than the
|
|
85
|
-
* target row's latest delta.
|
|
86
|
-
*
|
|
87
|
-
*
|
|
85
|
+
* target row's latest delta. Three dispositions, split by the non-coercion
|
|
86
|
+
* convention (see docs/concurrency-convention.md):
|
|
87
|
+
* • `notify` — NON-COERCIVE: hold the write, return a `StaleNotification`
|
|
88
|
+
* with the current value; the actor (agent or human) resolves.
|
|
89
|
+
* • `reject` — coercive escape hatch: throw `AbloStaleContextError`
|
|
90
|
+
* (default when `readAt` is present).
|
|
91
|
+
* • `overwrite` — coercive escape hatch: apply blindly last-writer-wins, no
|
|
92
|
+
* signal.
|
|
88
93
|
*/
|
|
89
94
|
export declare const onStaleModeSchema: z.ZodEnum<{
|
|
90
95
|
reject: "reject";
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
merge: "merge";
|
|
96
|
+
overwrite: "overwrite";
|
|
97
|
+
notify: "notify";
|
|
94
98
|
}>;
|
|
95
99
|
export type OnStaleMode = z.infer<typeof onStaleModeSchema>;
|
|
96
100
|
/**
|
|
@@ -103,13 +107,88 @@ export declare const writeGuardSchema: z.ZodObject<{
|
|
|
103
107
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
104
108
|
onStale: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
105
109
|
reject: "reject";
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
merge: "merge";
|
|
110
|
+
overwrite: "overwrite";
|
|
111
|
+
notify: "notify";
|
|
109
112
|
}>>>;
|
|
110
113
|
bypass: z.ZodOptional<z.ZodBoolean>;
|
|
111
114
|
}, z.core.$strip>;
|
|
112
115
|
export type WriteGuard = z.infer<typeof writeGuardSchema>;
|
|
116
|
+
/**
|
|
117
|
+
* The advisory signal returned to a committer whose write hit a stale-context
|
|
118
|
+
* conflict under `onStale: 'notify'` — the engine's answer to "the typed value
|
|
119
|
+
* you reasoned against changed while you were away."
|
|
120
|
+
*
|
|
121
|
+
* Philosophy: NON-COERCION. The engine's job is to surface the truthful current
|
|
122
|
+
* state and let the intelligent actor — agent OR human — decide what to do; it
|
|
123
|
+
* does NOT force an outcome. The two *forcing* dispositions are `reject`
|
|
124
|
+
* (force-abort, discards the work) and `overwrite` (force-clobber). This
|
|
125
|
+
* notification is the non-coercive path: instead of throwing, the server hands
|
|
126
|
+
* back the conflicting field's *current* value as data so the actor can solve
|
|
127
|
+
* it. The CLAIM is the prospective form of the same principle (coordinate
|
|
128
|
+
* before acting); this notification is the in-flight form (here's what changed,
|
|
129
|
+
* you resolve). Both an agent reasoning over the change and a human watching the
|
|
130
|
+
* row are valid resolvers. (Cf. CoAgent/MTPO, arXiv:2606.15376, which bets the
|
|
131
|
+
* resolver is specifically an LLM; Ablo's bet is the same non-coercion, actor
|
|
132
|
+
* left to agent or human.) Rides on the commit ack alongside `lastSyncId`; an
|
|
133
|
+
* empty/absent array means no premise moved.
|
|
134
|
+
*
|
|
135
|
+
* Only `notify` produces this: the conflicting op was HELD (not written), and
|
|
136
|
+
* the actor reconciles against `currentValues` and re-commits. (`reject` throws,
|
|
137
|
+
* `overwrite` is silent — neither notifies.)
|
|
138
|
+
*/
|
|
139
|
+
export declare const staleNotificationSchema: z.ZodObject<{
|
|
140
|
+
object: z.ZodOptional<z.ZodLiteral<"stale_notification">>;
|
|
141
|
+
model: z.ZodString;
|
|
142
|
+
id: z.ZodString;
|
|
143
|
+
readAt: z.ZodNumber;
|
|
144
|
+
observedSyncId: z.ZodNumber;
|
|
145
|
+
conflictingFields: z.ZodArray<z.ZodString>;
|
|
146
|
+
currentValues: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
147
|
+
writtenBy: z.ZodObject<{
|
|
148
|
+
kind: z.ZodEnum<{
|
|
149
|
+
user: "user";
|
|
150
|
+
agent: "agent";
|
|
151
|
+
system: "system";
|
|
152
|
+
}>;
|
|
153
|
+
id: z.ZodString;
|
|
154
|
+
}, z.core.$strip>;
|
|
155
|
+
group: z.ZodOptional<z.ZodString>;
|
|
156
|
+
}, z.core.$strip>;
|
|
157
|
+
export type StaleNotification = z.infer<typeof staleNotificationSchema>;
|
|
158
|
+
/**
|
|
159
|
+
* A read DEPENDENCY declared on a commit — the STORM "did anything I looked at
|
|
160
|
+
* change?" layer (vs. the write-target check that only validates the rows being
|
|
161
|
+
* written). The server re-runs stale detection against each declared read at
|
|
162
|
+
* `readAt`; a moved premise fires the entry's `onStale` disposition (default
|
|
163
|
+
* `reject`) over the WHOLE batch (`notify` holds every write + notifies;
|
|
164
|
+
* `reject` aborts; `overwrite` proceeds silently). Two granularities, choice:
|
|
165
|
+
*
|
|
166
|
+
* • ROW — `{ model, id, readAt, fields? }`: did this specific row (optionally
|
|
167
|
+
* these fields) change? The literal STORM/per-object premise.
|
|
168
|
+
* • GROUP — `{ group, readAt }`: did ANYTHING in this sync group change? `group`
|
|
169
|
+
* is a sync-group key like `deck:abc` or `slide:s1` — the same unit a
|
|
170
|
+
* human/agent watches and claims. Coarser, and more Ablo-native.
|
|
171
|
+
*/
|
|
172
|
+
export declare const readDependencySchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
173
|
+
model: z.ZodString;
|
|
174
|
+
id: z.ZodString;
|
|
175
|
+
readAt: z.ZodNumber;
|
|
176
|
+
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
177
|
+
onStale: z.ZodOptional<z.ZodEnum<{
|
|
178
|
+
reject: "reject";
|
|
179
|
+
overwrite: "overwrite";
|
|
180
|
+
notify: "notify";
|
|
181
|
+
}>>;
|
|
182
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
183
|
+
group: z.ZodString;
|
|
184
|
+
readAt: z.ZodNumber;
|
|
185
|
+
onStale: z.ZodOptional<z.ZodEnum<{
|
|
186
|
+
reject: "reject";
|
|
187
|
+
overwrite: "overwrite";
|
|
188
|
+
notify: "notify";
|
|
189
|
+
}>>;
|
|
190
|
+
}, z.core.$strip>]>;
|
|
191
|
+
export type ReadDependency = z.infer<typeof readDependencySchema>;
|
|
113
192
|
/**
|
|
114
193
|
* Lifecycle of an claim — the Stripe `PaymentIntent.status` shape. Absent on
|
|
115
194
|
* the wire ⇒ `'active'` (additive back-compat). The server stamps `'active'`
|
|
@@ -407,9 +486,8 @@ export declare const commitOperationSchema: z.ZodObject<{
|
|
|
407
486
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
408
487
|
onStale: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
409
488
|
reject: "reject";
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
merge: "merge";
|
|
489
|
+
overwrite: "overwrite";
|
|
490
|
+
notify: "notify";
|
|
413
491
|
}>>>;
|
|
414
492
|
bypass: z.ZodOptional<z.ZodBoolean>;
|
|
415
493
|
type: z.ZodEnum<{
|
|
@@ -80,11 +80,16 @@ export const targetRefSchema = z.object({
|
|
|
80
80
|
// ─────────────────────────────────────────────────────────────────────────
|
|
81
81
|
/**
|
|
82
82
|
* Mode applied when a write's snapshot watermark (`readAt`) is older than the
|
|
83
|
-
* target row's latest delta.
|
|
84
|
-
*
|
|
85
|
-
*
|
|
83
|
+
* target row's latest delta. Three dispositions, split by the non-coercion
|
|
84
|
+
* convention (see docs/concurrency-convention.md):
|
|
85
|
+
* • `notify` — NON-COERCIVE: hold the write, return a `StaleNotification`
|
|
86
|
+
* with the current value; the actor (agent or human) resolves.
|
|
87
|
+
* • `reject` — coercive escape hatch: throw `AbloStaleContextError`
|
|
88
|
+
* (default when `readAt` is present).
|
|
89
|
+
* • `overwrite` — coercive escape hatch: apply blindly last-writer-wins, no
|
|
90
|
+
* signal.
|
|
86
91
|
*/
|
|
87
|
-
export const onStaleModeSchema = z.enum(['reject', '
|
|
92
|
+
export const onStaleModeSchema = z.enum(['reject', 'overwrite', 'notify']);
|
|
88
93
|
/**
|
|
89
94
|
* The optimistic guard carried on a commit operation. `readAt` is the
|
|
90
95
|
* snapshot watermark from `context.capture` (null/absent ⇒ unguarded write).
|
|
@@ -96,6 +101,96 @@ export const writeGuardSchema = z.object({
|
|
|
96
101
|
onStale: onStaleModeSchema.nullish(),
|
|
97
102
|
bypass: z.boolean().optional(),
|
|
98
103
|
});
|
|
104
|
+
/**
|
|
105
|
+
* The advisory signal returned to a committer whose write hit a stale-context
|
|
106
|
+
* conflict under `onStale: 'notify'` — the engine's answer to "the typed value
|
|
107
|
+
* you reasoned against changed while you were away."
|
|
108
|
+
*
|
|
109
|
+
* Philosophy: NON-COERCION. The engine's job is to surface the truthful current
|
|
110
|
+
* state and let the intelligent actor — agent OR human — decide what to do; it
|
|
111
|
+
* does NOT force an outcome. The two *forcing* dispositions are `reject`
|
|
112
|
+
* (force-abort, discards the work) and `overwrite` (force-clobber). This
|
|
113
|
+
* notification is the non-coercive path: instead of throwing, the server hands
|
|
114
|
+
* back the conflicting field's *current* value as data so the actor can solve
|
|
115
|
+
* it. The CLAIM is the prospective form of the same principle (coordinate
|
|
116
|
+
* before acting); this notification is the in-flight form (here's what changed,
|
|
117
|
+
* you resolve). Both an agent reasoning over the change and a human watching the
|
|
118
|
+
* row are valid resolvers. (Cf. CoAgent/MTPO, arXiv:2606.15376, which bets the
|
|
119
|
+
* resolver is specifically an LLM; Ablo's bet is the same non-coercion, actor
|
|
120
|
+
* left to agent or human.) Rides on the commit ack alongside `lastSyncId`; an
|
|
121
|
+
* empty/absent array means no premise moved.
|
|
122
|
+
*
|
|
123
|
+
* Only `notify` produces this: the conflicting op was HELD (not written), and
|
|
124
|
+
* the actor reconciles against `currentValues` and re-commits. (`reject` throws,
|
|
125
|
+
* `overwrite` is silent — neither notifies.)
|
|
126
|
+
*/
|
|
127
|
+
export const staleNotificationSchema = z.object({
|
|
128
|
+
/** Stripe-style object tag — every returned object names its type. */
|
|
129
|
+
object: z.literal('stale_notification').optional(),
|
|
130
|
+
/** Model name of the conflicting row. */
|
|
131
|
+
model: z.string(),
|
|
132
|
+
/** Row id. */
|
|
133
|
+
id: z.string(),
|
|
134
|
+
/** The watermark the committer reasoned against (its `readAt`). */
|
|
135
|
+
readAt: z.number(),
|
|
136
|
+
/**
|
|
137
|
+
* Newest delta id on the row — the committer's new watermark. Re-capture
|
|
138
|
+
* context at/after this id to reconcile.
|
|
139
|
+
*/
|
|
140
|
+
observedSyncId: z.number(),
|
|
141
|
+
/**
|
|
142
|
+
* Fields whose concurrent change collided with this write (intersection of
|
|
143
|
+
* the committer's written columns and a newer delta's `changed_fields`).
|
|
144
|
+
* Empty ⇒ a whole-entity change (CREATE/DELETE/legacy delta).
|
|
145
|
+
*/
|
|
146
|
+
conflictingFields: z.array(z.string()),
|
|
147
|
+
/**
|
|
148
|
+
* Post-conflict live values of `conflictingFields` — the part a plain stale
|
|
149
|
+
* error never carried. Lets the LLM self-heal without a round-trip read.
|
|
150
|
+
*/
|
|
151
|
+
currentValues: z.record(z.string(), z.unknown()),
|
|
152
|
+
/** Who wrote the conflicting delta. */
|
|
153
|
+
writtenBy: z.object({
|
|
154
|
+
kind: participantKindSchema,
|
|
155
|
+
id: z.string(),
|
|
156
|
+
}),
|
|
157
|
+
/**
|
|
158
|
+
* Set when this notification is for a GROUP read-dependency (e.g. `deck:abc`,
|
|
159
|
+
* `slide:s1`) rather than a single row — "something in the group you read
|
|
160
|
+
* changed." For a group notification `conflictingFields`/`currentValues` are
|
|
161
|
+
* empty (the change could span many rows); re-read the group at
|
|
162
|
+
* `observedSyncId` to reconcile. Absent ⇒ a row-scoped notification.
|
|
163
|
+
*/
|
|
164
|
+
group: z.string().optional(),
|
|
165
|
+
});
|
|
166
|
+
/**
|
|
167
|
+
* A read DEPENDENCY declared on a commit — the STORM "did anything I looked at
|
|
168
|
+
* change?" layer (vs. the write-target check that only validates the rows being
|
|
169
|
+
* written). The server re-runs stale detection against each declared read at
|
|
170
|
+
* `readAt`; a moved premise fires the entry's `onStale` disposition (default
|
|
171
|
+
* `reject`) over the WHOLE batch (`notify` holds every write + notifies;
|
|
172
|
+
* `reject` aborts; `overwrite` proceeds silently). Two granularities, choice:
|
|
173
|
+
*
|
|
174
|
+
* • ROW — `{ model, id, readAt, fields? }`: did this specific row (optionally
|
|
175
|
+
* these fields) change? The literal STORM/per-object premise.
|
|
176
|
+
* • GROUP — `{ group, readAt }`: did ANYTHING in this sync group change? `group`
|
|
177
|
+
* is a sync-group key like `deck:abc` or `slide:s1` — the same unit a
|
|
178
|
+
* human/agent watches and claims. Coarser, and more Ablo-native.
|
|
179
|
+
*/
|
|
180
|
+
export const readDependencySchema = z.union([
|
|
181
|
+
z.object({
|
|
182
|
+
model: z.string(),
|
|
183
|
+
id: z.string(),
|
|
184
|
+
readAt: z.number(),
|
|
185
|
+
fields: z.array(z.string()).optional(),
|
|
186
|
+
onStale: onStaleModeSchema.optional(),
|
|
187
|
+
}),
|
|
188
|
+
z.object({
|
|
189
|
+
group: z.string(),
|
|
190
|
+
readAt: z.number(),
|
|
191
|
+
onStale: onStaleModeSchema.optional(),
|
|
192
|
+
}),
|
|
193
|
+
]);
|
|
99
194
|
// ─────────────────────────────────────────────────────────────────────────
|
|
100
195
|
// Layer 2 — PESSIMISTIC claim / claim-lease
|
|
101
196
|
// ─────────────────────────────────────────────────────────────────────────
|
package/dist/errorCodes.d.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { z } from 'zod';
|
|
|
37
37
|
* code, a changed HTTP status, an envelope field. Emitted in `errors.json`
|
|
38
38
|
* and on the `Ablo-Version` response header so a consumer can detect drift.
|
|
39
39
|
*/
|
|
40
|
-
export declare const ERROR_CONTRACT_VERSION = "2026-06-
|
|
40
|
+
export declare const ERROR_CONTRACT_VERSION = "2026-06-20";
|
|
41
41
|
/** Coarse grouping for metrics dashboards and docs sectioning. */
|
|
42
42
|
export type ErrorCategory = 'auth' | 'permission' | 'capability' | 'claim' | 'conflict' | 'validation' | 'not_found' | 'tenant' | 'schema' | 'claim' | 'bootstrap' | 'transport' | 'rate_limit' | 'server' | 'client';
|
|
43
43
|
/**
|
|
@@ -239,8 +239,10 @@ export declare const ERROR_CODES: {
|
|
|
239
239
|
readonly ws_not_ready: ErrorCodeSpec;
|
|
240
240
|
readonly quota_exceeded: ErrorCodeSpec;
|
|
241
241
|
readonly connection_limit_exceeded: ErrorCodeSpec;
|
|
242
|
+
readonly rate_limit_exceeded: ErrorCodeSpec;
|
|
242
243
|
readonly internal_error: ErrorCodeSpec;
|
|
243
244
|
readonly quota_lookup_failed: ErrorCodeSpec;
|
|
245
|
+
readonly rate_limiter_unavailable: ErrorCodeSpec;
|
|
244
246
|
readonly turn_open_failed: ErrorCodeSpec;
|
|
245
247
|
readonly turn_close_failed: ErrorCodeSpec;
|
|
246
248
|
readonly invalid_options: ErrorCodeSpec;
|
package/dist/errorCodes.js
CHANGED
|
@@ -37,7 +37,7 @@ import { z } from 'zod';
|
|
|
37
37
|
* code, a changed HTTP status, an envelope field. Emitted in `errors.json`
|
|
38
38
|
* and on the `Ablo-Version` response header so a consumer can detect drift.
|
|
39
39
|
*/
|
|
40
|
-
export const ERROR_CONTRACT_VERSION = '2026-06-
|
|
40
|
+
export const ERROR_CONTRACT_VERSION = '2026-06-20';
|
|
41
41
|
/**
|
|
42
42
|
* The closed taxonomy of *how a failure recovers* — one rung above the raw
|
|
43
43
|
* `code`. Where `code` says **what** went wrong, `RecoveryClass` says **what
|
|
@@ -258,9 +258,18 @@ export const ERROR_CODES = {
|
|
|
258
258
|
// ── quota / rate limit (429) ──────────────────────────────────────
|
|
259
259
|
quota_exceeded: wire('rate_limit', 429, true, 'The organization exceeded its configured usage quota.'),
|
|
260
260
|
connection_limit_exceeded: wire('rate_limit', 429, true, 'Too many concurrent WebSocket connections for this principal or organization. Close idle connections, or retry once others drain.'),
|
|
261
|
+
// Per-CREDENTIAL request-rate limit — the fast (RPS/burst) axis, distinct from
|
|
262
|
+
// the slow-axis `quota_exceeded` (org daily/monthly usage). Keyed per API key,
|
|
263
|
+
// so one noisy key backs off without affecting the rest of the org. The
|
|
264
|
+
// `Retry-After` header carries the bucket-refill delay.
|
|
265
|
+
rate_limit_exceeded: wire('rate_limit', 429, true, 'This API key is sending requests too quickly; slow down and retry after the indicated delay.'),
|
|
261
266
|
// ── server (5xx) ───────────────────────────────────────────────────
|
|
262
267
|
internal_error: wire('server', 500, true, 'An unexpected server error occurred.'),
|
|
263
268
|
quota_lookup_failed: wire('server', 503, true, 'The quota decision could not be loaded.'),
|
|
269
|
+
// The per-key rate-limiter backend (Redis) was unreachable and the API is
|
|
270
|
+
// configured to FAIL CLOSED on that path, so the request was rejected rather
|
|
271
|
+
// than admitted unchecked. Retryable: the next attempt re-probes the backend.
|
|
272
|
+
rate_limiter_unavailable: wire('server', 503, true, 'The rate-limiter backend is unavailable and this endpoint is configured to fail closed; retry shortly.'),
|
|
264
273
|
turn_open_failed: wire('server', 500, true, 'The agent turn failed to open.'),
|
|
265
274
|
turn_close_failed: wire('server', 500, true, 'The agent turn failed to close cleanly.'),
|
|
266
275
|
// ── client-only invariants (never serialized) ──────────────────────
|
package/dist/index.d.ts
CHANGED
|
@@ -57,6 +57,7 @@ export type { AbloPersistence } from './client/persistence.js';
|
|
|
57
57
|
import { Ablo } from './client/Ablo.js';
|
|
58
58
|
export default Ablo;
|
|
59
59
|
export { dataSource, abloSource, sourceEventForOperation, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
|
|
60
|
+
export { createSourceConnector, type SourceConnector, type SourceConnectorOptions, type ConnectorStatus, } from './source/connector.js';
|
|
60
61
|
export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
|
|
61
62
|
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
|
|
62
63
|
export type { CommitReceipt, RequiredCapability } from './errors.js';
|
|
@@ -67,6 +68,8 @@ export type { Environment, KeyPrefixEnvironment } from './environment.js';
|
|
|
67
68
|
export { writeOptionsSchema, onStaleModeSchema, assertWriteOptions, } from './client/writeOptionsSchema.js';
|
|
68
69
|
export type { WriteOptionsInput } from './client/writeOptionsSchema.js';
|
|
69
70
|
export type { WriteOptions, MutationOptions } from './interfaces/index.js';
|
|
71
|
+
export { staleNotificationSchema, readDependencySchema } from './coordination/schema.js';
|
|
72
|
+
export type { StaleNotification, ReadDependency } from './coordination/schema.js';
|
|
70
73
|
export { IDBOpenTimeoutError, isStorageOpenTimeout } from './core/openIDBWithTimeout.js';
|
|
71
74
|
export { PUBLIC_MODEL_VERBS, PUBLIC_LIST_OPTION_KEYS, PUBLIC_ABLO_OPTION_KEYS, } from './surface.js';
|
|
72
75
|
export type { ModelVerb, ListOptionKey, AbloOptionKey } from './surface.js';
|
package/dist/index.js
CHANGED
|
@@ -69,6 +69,10 @@ export default Ablo;
|
|
|
69
69
|
// canonical, skip this entirely. Type counterparts live under
|
|
70
70
|
// `Ablo.Source.*` (`Ablo.Source.Operation`, `Ablo.Source.Commit.Params`).
|
|
71
71
|
export { dataSource, abloSource, sourceEventForOperation, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
|
|
72
|
+
// Reverse-channel connector: serve the Data Source `commit`/`load`/`list` leg
|
|
73
|
+
// over an OUTBOUND WebSocket (localhost / locked-down VPC, no public inbound
|
|
74
|
+
// URL). The dial-out counterpart to `createPushQueue` for the `events` leg.
|
|
75
|
+
export { createSourceConnector, } from './source/connector.js';
|
|
72
76
|
// Schema DSL is intentionally published from `@abloatai/ablo/schema`.
|
|
73
77
|
// Keeping it out of the root import preserves one clean runtime surface:
|
|
74
78
|
// `import Ablo from '@abloatai/ablo'`.
|
|
@@ -90,6 +94,11 @@ export { ENVIRONMENTS, environmentSchema, normalizeEnvironment, environmentFromK
|
|
|
90
94
|
// (e.g. an agent tool's input schema). Runtime twin of `MutationOptions`,
|
|
91
95
|
// drift-guarded at compile time.
|
|
92
96
|
export { writeOptionsSchema, onStaleModeSchema, assertWriteOptions, } from './client/writeOptionsSchema.js';
|
|
97
|
+
// Notify-instead-of-abort signal: the value handed back to a committer whose
|
|
98
|
+
// write hit a stale-context conflict under `onStale: 'notify' so its
|
|
99
|
+
// agent can self-heal rather than discard work (see coordination/schema.ts and
|
|
100
|
+
// docs/coordination.md → "Notify, do not abort").
|
|
101
|
+
export { staleNotificationSchema, readDependencySchema } from './coordination/schema.js';
|
|
93
102
|
// Storage-wedge detection — lets app shells render a recovery screen when the
|
|
94
103
|
// IndexedDB backing store is stuck (see core/openIDBWithTimeout.ts).
|
|
95
104
|
export { IDBOpenTimeoutError, isStorageOpenTimeout } from './core/openIDBWithTimeout.js';
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Consumers implement them to wire in their own logging, observability,
|
|
6
6
|
* GraphQL client, session handling, and analytics.
|
|
7
7
|
*/
|
|
8
|
+
import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
|
|
8
9
|
export interface SyncLogger {
|
|
9
10
|
debug(message: string, ...args: unknown[]): void;
|
|
10
11
|
info(message: string, ...args: unknown[]): void;
|
|
@@ -140,6 +141,13 @@ export interface ModelDebugLoggerContract {
|
|
|
140
141
|
/** Result of a successful `commit()` — server's sync cursor after the batch landed. */
|
|
141
142
|
export interface CommitResult {
|
|
142
143
|
lastSyncId: number;
|
|
144
|
+
/**
|
|
145
|
+
* Stale-context notifications (CoAgent/MTPO notify-instead-of-abort). Present
|
|
146
|
+
* only when a write guarded with `onStale: 'notify' collided with a
|
|
147
|
+
* concurrent change; the committer self-heals from these rather than
|
|
148
|
+
* receiving an `AbloStaleContextError`. See `StaleNotification`.
|
|
149
|
+
*/
|
|
150
|
+
notifications?: StaleNotification[];
|
|
143
151
|
}
|
|
144
152
|
/**
|
|
145
153
|
* Per-call knobs attached to any mutation. Mirrors Stripe's options
|
|
@@ -159,7 +167,7 @@ export interface MutationOptions {
|
|
|
159
167
|
label?: string;
|
|
160
168
|
wait?: 'queued' | 'confirmed';
|
|
161
169
|
readAt?: number | null;
|
|
162
|
-
onStale?: 'reject' | '
|
|
170
|
+
onStale?: 'reject' | 'overwrite' | 'notify' | null;
|
|
163
171
|
/** Claim-pin attribution: the id (or `{ id }`) of the claim this write
|
|
164
172
|
* belongs to. Distinct from the `claim` HANDLE on the model write params —
|
|
165
173
|
* this is the low-level reference the commit carries to bypass the holder's
|
|
@@ -174,6 +182,14 @@ export interface MutationOptions {
|
|
|
174
182
|
* id). Kept optional for wire-compat; always `null` from the client.
|
|
175
183
|
*/
|
|
176
184
|
causedByTaskId?: string | null;
|
|
185
|
+
/**
|
|
186
|
+
* Batch-level read dependencies (the STORM "did anything I looked at change?"
|
|
187
|
+
* layer). Each entry is a row (`{model,id,readAt,fields?}`) or a sync group
|
|
188
|
+
* (`{group,readAt}`) this write was premised on; the server validates none
|
|
189
|
+
* moved since `readAt` and fires the entry's `onStale` over the batch.
|
|
190
|
+
* Distinct from per-op `readAt` (which guards only the row being written).
|
|
191
|
+
*/
|
|
192
|
+
reads?: ReadDependency[] | null;
|
|
177
193
|
}
|
|
178
194
|
/**
|
|
179
195
|
* The `MutationOptions` subset carried per-write through the offline
|
|
@@ -208,7 +224,7 @@ export interface MutationOperation {
|
|
|
208
224
|
*/
|
|
209
225
|
transactionId?: string;
|
|
210
226
|
readAt?: number | null;
|
|
211
|
-
onStale?: 'reject' | '
|
|
227
|
+
onStale?: 'reject' | 'overwrite' | 'notify' | null;
|
|
212
228
|
/**
|
|
213
229
|
* Per-op idempotency + audit metadata. `idempotencyKey` doubles as
|
|
214
230
|
* the `mutation_log.client_tx_id` cache key; `label` is persisted to
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -39,6 +39,13 @@ export interface StaleContextConflict extends ConflictBase {
|
|
|
39
39
|
* cosmetic field. See `docs/internal/per-field-conflict-detection.md`.
|
|
40
40
|
*/
|
|
41
41
|
readonly conflictingFields?: readonly string[];
|
|
42
|
+
/**
|
|
43
|
+
* The committer's declared `onStale` intent for this op. The default policy
|
|
44
|
+
* honors it: `'notify'` → notify+hold, anything else → reject. A custom policy
|
|
45
|
+
* may override (e.g. gate notify on claim ownership). Absent ⇒ treat as
|
|
46
|
+
* `'reject'` (the unguarded-write default).
|
|
47
|
+
*/
|
|
48
|
+
readonly requestedMode?: 'reject' | 'overwrite' | 'notify';
|
|
42
49
|
}
|
|
43
50
|
export interface ClaimHeldConflict extends ConflictBase {
|
|
44
51
|
readonly kind: 'claim_held';
|
|
@@ -83,6 +90,22 @@ export type ConflictDecision = {
|
|
|
83
90
|
| {
|
|
84
91
|
readonly action: 'preempt';
|
|
85
92
|
readonly reason?: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Notify-instead-of-abort (non-coercion). Only meaningful for a
|
|
96
|
+
* `stale_context` conflict, and the engine's aligned disposition: HOLD the
|
|
97
|
+
* conflicting op (don't write it) and return a `StaleNotification` with the
|
|
98
|
+
* current value so the actor (agent or human) resolves and re-commits. The
|
|
99
|
+
* rest of the batch still commits. Maps from `onStale: 'notify'`.
|
|
100
|
+
*
|
|
101
|
+
* Serialization order is supplied by the monotonic `sync_id` landing order
|
|
102
|
+
* (the stale committer always yields/recomputes — an asymmetry that rules out
|
|
103
|
+
* a symmetric notify-rewrite livelock). Unbounded retry is bounded by the
|
|
104
|
+
* client's reconciliation retry cap.
|
|
105
|
+
*/
|
|
106
|
+
| {
|
|
107
|
+
readonly action: 'notify';
|
|
108
|
+
readonly reason?: string;
|
|
86
109
|
};
|
|
87
110
|
/**
|
|
88
111
|
* Pluggable decision function. Sync or async.
|
|
@@ -98,9 +121,18 @@ export type ConflictDecision = {
|
|
|
98
121
|
*/
|
|
99
122
|
export type ConflictPolicy = (conflict: Conflict) => ConflictDecision | Promise<ConflictDecision>;
|
|
100
123
|
/**
|
|
101
|
-
* Default
|
|
102
|
-
*
|
|
103
|
-
* claim
|
|
124
|
+
* Default policy.
|
|
125
|
+
*
|
|
126
|
+
* `claim_held` conflicts always reject (a foreign claim is honored unless a
|
|
127
|
+
* privileged policy preempts). `stale_context` conflicts honor the committer's
|
|
128
|
+
* declared `onStale` intent:
|
|
129
|
+
*
|
|
130
|
+
* • `'notify'` → notify + hold (op withheld; the actor resolves)
|
|
131
|
+
* • anything else (incl. `'reject'`, absent) → reject
|
|
132
|
+
*
|
|
133
|
+
* `'overwrite'` never reaches a policy — it's a hard opt-out resolved before
|
|
134
|
+
* detection. This preserves the legacy always-reject default for callers that
|
|
135
|
+
* don't opt into `notify`.
|
|
104
136
|
*/
|
|
105
137
|
export declare const defaultPolicy: ConflictPolicy;
|
|
106
138
|
/**
|