@abloatai/ablo 0.14.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 +39 -0
- package/dist/Database.d.ts +1 -1
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +1 -0
- package/dist/cli.cjs +54 -1
- package/dist/client/Ablo.d.ts +27 -3
- package/dist/client/Ablo.js +11 -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/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/concurrency-convention.md +222 -0
- package/docs/coordination.md +5 -0
- package/docs/data-sources.md +41 -0
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- **Notify-instead-of-abort: non-coercive conflict handling + read-set (the "did anything I looked at change?" layer).**
|
|
8
|
+
|
|
9
|
+
The principle: on a stale-context conflict the engine now **surfaces the current state and lets the actor — agent or human — resolve it**, instead of forcing an outcome. See `docs/concurrency-convention.md`.
|
|
10
|
+
|
|
11
|
+
**`onStale` redesigned — Stripe-aligned values (BREAKING).**
|
|
12
|
+
|
|
13
|
+
The mode set is now `'reject' | 'overwrite' | 'notify'`. Each value names its outcome:
|
|
14
|
+
- **`notify` (new, non-coercive)** — the conflicting write is **held** (not applied) and the commit returns a `StaleNotification` carrying the conflicting field's *current* value, so the actor reconciles and re-commits rather than losing work. The rest of the batch still commits.
|
|
15
|
+
- **`overwrite`** (was `force`) — blind last-writer-wins, no signal.
|
|
16
|
+
- **`reject`** (default, unchanged) — throws `AbloStaleContextError`.
|
|
17
|
+
|
|
18
|
+
Migration:
|
|
19
|
+
- `onStale: 'force'` → `onStale: 'overwrite'`.
|
|
20
|
+
- `onStale: 'flag'` / `onStale: 'merge'` → `onStale: 'notify'` (both removed; `notify` is the single hold-and-surface mode).
|
|
21
|
+
|
|
22
|
+
**`StaleNotification` — the new advisory signal.** New public type + `staleNotificationSchema`:
|
|
23
|
+
`{ object: 'stale_notification', model, id, readAt, observedSyncId, conflictingFields, currentValues, writtenBy, group? }`. Delivered two ways:
|
|
24
|
+
- on the receipt — `CommitReceipt.notifications` (and `CommitResult.notifications`);
|
|
25
|
+
- on a new SDK event — **`conflict:notified`** `{ clientTxId, notifications }` (mirrors `reconciliation:needed` / `sync:rollback`).
|
|
26
|
+
|
|
27
|
+
**Read-set (`reads[]`) — declare what you looked at, not just what you write (new).** A commit may carry batch-level read dependencies; a moved premise fires that entry's `onStale` over the whole batch (`notify` holds every write + notifies, `reject` aborts, `overwrite` proceeds). Two granularities:
|
|
28
|
+
- **Row** — `{ model, id, readAt, fields? }`: did this row (optionally these fields) change?
|
|
29
|
+
- **Group** — `{ group, readAt }`: did anything in this sync group (`deck:abc`, `org:X`) change? — the same unit a participant watches and claims.
|
|
30
|
+
|
|
31
|
+
New public type `ReadDependency` + `readDependencySchema`; available on `ablo.commits.create({ operations, reads })` and the lower-level write options. This closes the gap the write-target check alone could not: a premise that changed without the written row changing.
|
|
32
|
+
|
|
33
|
+
**Conflict policy.** `ConflictDecision` gains `{ action: 'notify' }`; `defaultPolicy` maps `onStale: 'notify'` → notify-and-hold, everything else → reject. `StaleContextConflict.requestedMode` is added so custom policies can honor the caller's declared intent.
|
|
34
|
+
|
|
35
|
+
- **Data Source reverse-channel connector (new).** A customer Data Source can now **dial out** to the engine over a single outbound WebSocket (`ablo.source.v1` subprotocol) instead of exposing an inbound HTTP endpoint — the deployment shape private/VPC stores need.
|
|
36
|
+
|
|
37
|
+
- **`createSourceConnector({ apiKey, handler, baseURL? })`** (new public API, exported from the root and `/source`) — opens one outbound socket (Node global `WebSocket`, no new dependency), with reconnect/backoff, and serves the customer's existing Data Source `handler`.
|
|
38
|
+
- Server side: a connector registry + `/v1/source/listen` upgrade route bridge requests down / responses up, teed into `SourceClient` through the storage resolver.
|
|
39
|
+
- **Trust model unchanged:** the Standard-Webhooks HMAC is signed *above* the transport, so the socket carries the signed envelope byte-for-byte and the customer's `verifyAbloSourceRequest` is untouched. Transport changes, trust model doesn't.
|
|
40
|
+
- Opt-in per source via `reverse_channel_prod` (migration `20260622150000`); gated in `authorizeUpgrade`.
|
|
41
|
+
|
|
3
42
|
## 0.14.0
|
|
4
43
|
|
|
5
44
|
### Minor Changes
|
package/dist/Database.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ interface PersistedMutation {
|
|
|
17
17
|
timestamp: string;
|
|
18
18
|
writeOptions?: {
|
|
19
19
|
readAt?: number | null;
|
|
20
|
-
onStale?: 'reject' | '
|
|
20
|
+
onStale?: 'reject' | 'overwrite' | 'notify' | null;
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
/** Persisted transaction for offline/retry support.
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -34,6 +34,10 @@ export interface MintUserSessionRequest {
|
|
|
34
34
|
readonly baseUrl: string;
|
|
35
35
|
/** The end user's external IdP id — becomes the session's `participantId`. */
|
|
36
36
|
readonly userId: string;
|
|
37
|
+
/** Target org for a cross-org (platform) mint — the Stripe-Connect
|
|
38
|
+
* `Stripe-Account` analogue. Requires the `sk_` to carry
|
|
39
|
+
* `ephemeral:mint-any-org`; omit to mint into the key's own org. */
|
|
40
|
+
readonly organizationId?: string;
|
|
37
41
|
readonly syncGroups?: readonly string[];
|
|
38
42
|
readonly ttlSeconds: number;
|
|
39
43
|
readonly label?: string;
|
package/dist/auth/index.js
CHANGED
|
@@ -107,6 +107,7 @@ export async function mintUserSessionKey(options) {
|
|
|
107
107
|
},
|
|
108
108
|
body: JSON.stringify({
|
|
109
109
|
user: { id: options.userId },
|
|
110
|
+
...(options.organizationId ? { organizationId: options.organizationId } : {}),
|
|
110
111
|
...(options.syncGroups ? { syncGroups: options.syncGroups } : {}),
|
|
111
112
|
ttlSeconds: options.ttlSeconds,
|
|
112
113
|
...(options.label ? { label: options.label } : {}),
|
package/dist/cli.cjs
CHANGED
|
@@ -277067,12 +277067,65 @@ var targetRefSchema = import_zod3.z.object({
|
|
|
277067
277067
|
field: import_zod3.z.string().optional(),
|
|
277068
277068
|
meta: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()).optional()
|
|
277069
277069
|
});
|
|
277070
|
-
var onStaleModeSchema = import_zod3.z.enum(["reject", "
|
|
277070
|
+
var onStaleModeSchema = import_zod3.z.enum(["reject", "overwrite", "notify"]);
|
|
277071
277071
|
var writeGuardSchema = import_zod3.z.object({
|
|
277072
277072
|
readAt: import_zod3.z.number().nullish(),
|
|
277073
277073
|
onStale: onStaleModeSchema.nullish(),
|
|
277074
277074
|
bypass: import_zod3.z.boolean().optional()
|
|
277075
277075
|
});
|
|
277076
|
+
var staleNotificationSchema = import_zod3.z.object({
|
|
277077
|
+
/** Stripe-style object tag — every returned object names its type. */
|
|
277078
|
+
object: import_zod3.z.literal("stale_notification").optional(),
|
|
277079
|
+
/** Model name of the conflicting row. */
|
|
277080
|
+
model: import_zod3.z.string(),
|
|
277081
|
+
/** Row id. */
|
|
277082
|
+
id: import_zod3.z.string(),
|
|
277083
|
+
/** The watermark the committer reasoned against (its `readAt`). */
|
|
277084
|
+
readAt: import_zod3.z.number(),
|
|
277085
|
+
/**
|
|
277086
|
+
* Newest delta id on the row — the committer's new watermark. Re-capture
|
|
277087
|
+
* context at/after this id to reconcile.
|
|
277088
|
+
*/
|
|
277089
|
+
observedSyncId: import_zod3.z.number(),
|
|
277090
|
+
/**
|
|
277091
|
+
* Fields whose concurrent change collided with this write (intersection of
|
|
277092
|
+
* the committer's written columns and a newer delta's `changed_fields`).
|
|
277093
|
+
* Empty ⇒ a whole-entity change (CREATE/DELETE/legacy delta).
|
|
277094
|
+
*/
|
|
277095
|
+
conflictingFields: import_zod3.z.array(import_zod3.z.string()),
|
|
277096
|
+
/**
|
|
277097
|
+
* Post-conflict live values of `conflictingFields` — the part a plain stale
|
|
277098
|
+
* error never carried. Lets the LLM self-heal without a round-trip read.
|
|
277099
|
+
*/
|
|
277100
|
+
currentValues: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()),
|
|
277101
|
+
/** Who wrote the conflicting delta. */
|
|
277102
|
+
writtenBy: import_zod3.z.object({
|
|
277103
|
+
kind: participantKindSchema,
|
|
277104
|
+
id: import_zod3.z.string()
|
|
277105
|
+
}),
|
|
277106
|
+
/**
|
|
277107
|
+
* Set when this notification is for a GROUP read-dependency (e.g. `deck:abc`,
|
|
277108
|
+
* `slide:s1`) rather than a single row — "something in the group you read
|
|
277109
|
+
* changed." For a group notification `conflictingFields`/`currentValues` are
|
|
277110
|
+
* empty (the change could span many rows); re-read the group at
|
|
277111
|
+
* `observedSyncId` to reconcile. Absent ⇒ a row-scoped notification.
|
|
277112
|
+
*/
|
|
277113
|
+
group: import_zod3.z.string().optional()
|
|
277114
|
+
});
|
|
277115
|
+
var readDependencySchema = import_zod3.z.union([
|
|
277116
|
+
import_zod3.z.object({
|
|
277117
|
+
model: import_zod3.z.string(),
|
|
277118
|
+
id: import_zod3.z.string(),
|
|
277119
|
+
readAt: import_zod3.z.number(),
|
|
277120
|
+
fields: import_zod3.z.array(import_zod3.z.string()).optional(),
|
|
277121
|
+
onStale: onStaleModeSchema.optional()
|
|
277122
|
+
}),
|
|
277123
|
+
import_zod3.z.object({
|
|
277124
|
+
group: import_zod3.z.string(),
|
|
277125
|
+
readAt: import_zod3.z.number(),
|
|
277126
|
+
onStale: onStaleModeSchema.optional()
|
|
277127
|
+
})
|
|
277128
|
+
]);
|
|
277076
277129
|
var claimStatusSchema = import_zod3.z.enum([
|
|
277077
277130
|
"active",
|
|
277078
277131
|
"committed",
|
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* });
|
|
19
19
|
* await sync.reports.delete({ id: reportId });
|
|
20
20
|
*/
|
|
21
|
+
import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
|
|
21
22
|
import type { Schema, SchemaRecord, InferModel, InferCreate } from '../schema/schema.js';
|
|
22
23
|
import type { SyncEngineConfig, SyncLogger, MutationExecutor, MutationDispatcher, SyncObservabilityProvider, SyncAnalytics, SessionErrorDetector, OnlineStatusProvider } from '../interfaces/index.js';
|
|
23
24
|
import type { ModelTarget, ModelClaim } from '../coordination/schema.js';
|
|
@@ -424,7 +425,7 @@ export interface CommitOperationInput {
|
|
|
424
425
|
readonly data?: Record<string, unknown> | null;
|
|
425
426
|
readonly transactionId?: string | null;
|
|
426
427
|
readonly readAt?: number | null;
|
|
427
|
-
readonly onStale?: 'reject' | '
|
|
428
|
+
readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
|
|
428
429
|
}
|
|
429
430
|
export interface CommitCreateOptions {
|
|
430
431
|
readonly claimRef?: string | {
|
|
@@ -432,7 +433,7 @@ export interface CommitCreateOptions {
|
|
|
432
433
|
} | null;
|
|
433
434
|
readonly idempotencyKey?: string | null;
|
|
434
435
|
readonly readAt?: number | null;
|
|
435
|
-
readonly onStale?: 'reject' | '
|
|
436
|
+
readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
|
|
436
437
|
/**
|
|
437
438
|
* A claim handle from `ablo.<model>.claim({ id })` (or the HTTP claim
|
|
438
439
|
* surface). Same vocabulary as the per-model writes: the handle's
|
|
@@ -445,11 +446,29 @@ export interface CommitCreateOptions {
|
|
|
445
446
|
readonly operation?: CommitOperationInput;
|
|
446
447
|
readonly operations?: readonly CommitOperationInput[];
|
|
447
448
|
readonly wait?: CommitWait;
|
|
449
|
+
/**
|
|
450
|
+
* Batch-level read dependencies (the STORM "did anything I looked at change?"
|
|
451
|
+
* layer). Declare the rows (`{model,id,readAt,fields?}`) or sync groups
|
|
452
|
+
* (`{group,readAt}`, e.g. `deck:abc`) this batch was premised on; the server
|
|
453
|
+
* validates none moved since `readAt` and fires the entry's `onStale` over the
|
|
454
|
+
* batch. Distinct from the write-target `readAt` — this guards what you READ,
|
|
455
|
+
* not what you write.
|
|
456
|
+
*/
|
|
457
|
+
readonly reads?: readonly ReadDependency[] | null;
|
|
448
458
|
}
|
|
449
459
|
export interface CommitReceipt {
|
|
450
460
|
readonly id: string;
|
|
451
461
|
readonly status: CommitWait;
|
|
452
462
|
readonly lastSyncId?: number;
|
|
463
|
+
/**
|
|
464
|
+
* Stale-context notifications (notify-instead-of-abort, non-coercion). Present
|
|
465
|
+
* only when this commit guarded a write with `onStale: 'notify' and
|
|
466
|
+
* the premise moved concurrently — the conflicting field's current value,
|
|
467
|
+
* handed back as data instead of a forced `AbloStaleContextError`. The engine
|
|
468
|
+
* surfaces state; the intelligent actor (agent or human) decides how to
|
|
469
|
+
* resolve. Also fires on `conflict:notified`.
|
|
470
|
+
*/
|
|
471
|
+
readonly notifications?: readonly StaleNotification[];
|
|
453
472
|
}
|
|
454
473
|
export interface CommitResource {
|
|
455
474
|
create(options: CommitCreateOptions): Promise<CommitReceipt>;
|
|
@@ -465,7 +484,7 @@ export interface ModelMutationOptions extends ClaimedOptions {
|
|
|
465
484
|
} | null;
|
|
466
485
|
readonly idempotencyKey?: string | null;
|
|
467
486
|
readonly readAt?: number | null;
|
|
468
|
-
readonly onStale?: 'reject' | '
|
|
487
|
+
readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
|
|
469
488
|
readonly wait?: CommitWait;
|
|
470
489
|
readonly claim?: ClaimHandle | ClaimOptions | null;
|
|
471
490
|
}
|
|
@@ -543,6 +562,11 @@ export interface CreateUserSessionParams {
|
|
|
543
562
|
user: {
|
|
544
563
|
id: string;
|
|
545
564
|
};
|
|
565
|
+
/** Mint the session into THIS organization instead of the key's own org — the
|
|
566
|
+
* Stripe Connect `Stripe-Account` pattern, for a platform serving many tenants
|
|
567
|
+
* from one backend. Requires the `sk_` to carry the `ephemeral:mint-any-org`
|
|
568
|
+
* scope; omit for the normal single-tenant case. */
|
|
569
|
+
organizationId?: string;
|
|
546
570
|
/** Sync groups this session may subscribe to — typed (`'default'` or
|
|
547
571
|
* `<namespace>:<id>`; build with `syncGroup(kind, id)` from
|
|
548
572
|
* `@abloatai/ablo/schema`). Omit for the server default:
|
package/dist/client/Ablo.js
CHANGED
|
@@ -606,7 +606,7 @@ function createDefaultMutationExecutor(getWs) {
|
|
|
606
606
|
: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
|
|
607
607
|
try {
|
|
608
608
|
return await ws.sendCommit(operations, clientTxId, undefined, // use sendCommit's built-in 15s default; no per-call override
|
|
609
|
-
options?.causedByTaskId);
|
|
609
|
+
options?.causedByTaskId, options?.reads);
|
|
610
610
|
}
|
|
611
611
|
catch (err) {
|
|
612
612
|
// Wrap transport-level failures as connection errors so the
|
|
@@ -1412,12 +1412,19 @@ export function Ablo(options) {
|
|
|
1412
1412
|
// SyncClient we already hold from createInternalComponents —
|
|
1413
1413
|
// no need to leak an accessor through BaseSyncedStore.
|
|
1414
1414
|
const queue = syncClient.getTransactionQueue();
|
|
1415
|
-
queue.enqueueCommit(clientTxId, operations
|
|
1415
|
+
queue.enqueueCommit(clientTxId, operations, {
|
|
1416
|
+
...(commitOptions.reads ? { reads: [...commitOptions.reads] } : {}),
|
|
1417
|
+
});
|
|
1416
1418
|
if (wait === 'queued') {
|
|
1417
1419
|
return { id: clientTxId, status: 'queued' };
|
|
1418
1420
|
}
|
|
1419
|
-
const { lastSyncId } = await queue.waitForCommitReceipt(clientTxId);
|
|
1420
|
-
return {
|
|
1421
|
+
const { lastSyncId, notifications } = await queue.waitForCommitReceipt(clientTxId);
|
|
1422
|
+
return {
|
|
1423
|
+
id: clientTxId,
|
|
1424
|
+
status: 'confirmed',
|
|
1425
|
+
lastSyncId,
|
|
1426
|
+
...(notifications && notifications.length > 0 ? { notifications } : {}),
|
|
1427
|
+
};
|
|
1421
1428
|
},
|
|
1422
1429
|
};
|
|
1423
1430
|
async function retrieveModel(modelName, id, options) {
|
|
@@ -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/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
|