@abloatai/ablo 0.9.3 → 0.9.4
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 +6 -0
- package/README.md +8 -6
- package/dist/BaseSyncedStore.d.ts +5 -2
- package/dist/BaseSyncedStore.js +15 -14
- package/dist/Database.js +7 -1
- package/dist/SyncClient.d.ts +8 -0
- package/dist/SyncClient.js +9 -0
- package/dist/cli.cjs +9257 -29661
- package/dist/client/Ablo.js +8 -1
- package/dist/client/auth.d.ts +1 -1
- package/dist/client/auth.js +14 -5
- package/dist/schema/ddl.js +10 -2
- package/dist/schema/model.d.ts +9 -7
- package/dist/schema/model.js +1 -1
- package/dist/schema/schema.js +7 -1
- package/dist/sync/syncPosition.d.ts +78 -0
- package/dist/sync/syncPosition.js +111 -0
- package/dist/transactions/TransactionQueue.d.ts +16 -1
- package/dist/transactions/TransactionQueue.js +43 -25
- package/docs/api-keys.md +4 -4
- package/docs/cli.md +6 -6
- package/docs/quickstart.md +1 -1
- package/llms-full.txt +10 -5
- package/llms.txt +3 -3
- package/package.json +5 -3
package/dist/client/Ablo.js
CHANGED
|
@@ -1307,7 +1307,14 @@ export function Ablo(options) {
|
|
|
1307
1307
|
createSnapshot: (modelKey, id) => createSnapshot({
|
|
1308
1308
|
pool: objectPool,
|
|
1309
1309
|
transport: store.getSyncWebSocket(),
|
|
1310
|
-
|
|
1310
|
+
// `position.readFloor` is THE value claims/snapshots stamp as
|
|
1311
|
+
// `readAt` (max of the pool-applied cursor and the acked
|
|
1312
|
+
// watermark for our own writes — see sync/syncPosition.ts).
|
|
1313
|
+
// Stamping a bare stream cursor made a claim taken right after
|
|
1314
|
+
// an ack-confirmed write stale against that write's own delta.
|
|
1315
|
+
// The socket/store cursors are persistence-gated and therefore
|
|
1316
|
+
// never ahead of `applied` — no extra max() needed here.
|
|
1317
|
+
getLastSyncId: () => syncClient.position.readFloor,
|
|
1311
1318
|
entities: { [modelKey]: id },
|
|
1312
1319
|
}),
|
|
1313
1320
|
queue: (target) => publicIntents.queueFor({ type: target.model, id: target.id }),
|
package/dist/client/auth.d.ts
CHANGED
|
@@ -53,7 +53,7 @@ export declare function resolveAuthToken(input: AuthResolveInput): string | null
|
|
|
53
53
|
export declare function resolveDatabaseUrl(input: AuthResolveInput): string | null;
|
|
54
54
|
export declare const ABLO_HOSTED_API_DOMAIN = "api.abloatai.com";
|
|
55
55
|
export declare const ABLO_HOSTED_HTTP_BASE_URL = "https://api.abloatai.com";
|
|
56
|
-
export declare const ABLO_DEFAULT_BASE_URL = "
|
|
56
|
+
export declare const ABLO_DEFAULT_BASE_URL = "https://api.abloatai.com";
|
|
57
57
|
/**
|
|
58
58
|
* Normalize old hosted aliases to the public API domain. Self-hosted/custom
|
|
59
59
|
* URLs pass through unchanged; only first-party legacy hosts are rewritten.
|
package/dist/client/auth.js
CHANGED
|
@@ -40,7 +40,7 @@ export function resolveDatabaseUrl(input) {
|
|
|
40
40
|
}
|
|
41
41
|
export const ABLO_HOSTED_API_DOMAIN = 'api.abloatai.com';
|
|
42
42
|
export const ABLO_HOSTED_HTTP_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
|
|
43
|
-
export const ABLO_DEFAULT_BASE_URL = `
|
|
43
|
+
export const ABLO_DEFAULT_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
|
|
44
44
|
const LEGACY_HOSTED_API_HOSTS = new Set([
|
|
45
45
|
'mesh.ablo.finance',
|
|
46
46
|
'mesh-staging.ablo.finance',
|
|
@@ -65,13 +65,22 @@ export function normalizeAbloHostedBaseUrl(rawUrl) {
|
|
|
65
65
|
const schemed = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
66
66
|
try {
|
|
67
67
|
const url = new URL(schemed);
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
// Canonicalize the scheme to the HTTP family — the WHATWG WebSocket
|
|
69
|
+
// model: accept all four schemes (`http`/`https`/`ws`/`wss`), normalize
|
|
70
|
+
// ONCE at the entry point, and let each layer derive its own protocol
|
|
71
|
+
// (the socket layer maps http→ws / https→wss; fetch uses it as-is).
|
|
72
|
+
// Before this, a `ws://` baseURL reached HTTP consumers un-normalized
|
|
73
|
+
// and the client wedged at startup instead of connecting.
|
|
74
|
+
if (url.protocol === 'ws:')
|
|
75
|
+
url.protocol = 'http:';
|
|
76
|
+
if (url.protocol === 'wss:')
|
|
77
|
+
url.protocol = 'https:';
|
|
78
|
+
if (!LEGACY_HOSTED_API_HOSTS.has(url.hostname)) {
|
|
79
|
+
return url.toString().replace(/\/+$/, '');
|
|
80
|
+
}
|
|
70
81
|
url.hostname = ABLO_HOSTED_API_DOMAIN;
|
|
71
82
|
if (url.protocol === 'http:')
|
|
72
83
|
url.protocol = 'https:';
|
|
73
|
-
if (url.protocol === 'ws:')
|
|
74
|
-
url.protocol = 'wss:';
|
|
75
84
|
return url.toString().replace(/\/+$/, '');
|
|
76
85
|
}
|
|
77
86
|
catch {
|
package/dist/schema/ddl.js
CHANGED
|
@@ -315,6 +315,14 @@ export function generateMigrationPlan(steps, opts) {
|
|
|
315
315
|
const qs = q(targetSchema);
|
|
316
316
|
const statements = [];
|
|
317
317
|
const concurrent = [];
|
|
318
|
+
// The app schema must exist before any statement targets it. On a fresh
|
|
319
|
+
// org's FIRST push (`prev = null`) the migration plan IS the provisioning —
|
|
320
|
+
// `app_<orgId>` has never been created, and skipping this line made every
|
|
321
|
+
// first push die with `3F000 invalid_schema_name` at statement 0. Idempotent
|
|
322
|
+
// (`IF NOT EXISTS`), so emitting it on every later migration is free.
|
|
323
|
+
if (steps.length > 0 && targetSchema !== 'public') {
|
|
324
|
+
statements.push(`CREATE SCHEMA IF NOT EXISTS ${qs};`);
|
|
325
|
+
}
|
|
318
326
|
const qtFor = (table) => `${qs}.${q(table)}`;
|
|
319
327
|
const tableOfModel = (schema, key) => {
|
|
320
328
|
const m = schema?.models[key];
|
|
@@ -327,8 +335,8 @@ export function generateMigrationPlan(steps, opts) {
|
|
|
327
335
|
switch (step.kind) {
|
|
328
336
|
case 'create_model': {
|
|
329
337
|
// Reuse the provisioner for the full table (base cols + fields + enum
|
|
330
|
-
// checks + RLS), minus its `CREATE SCHEMA` (the
|
|
331
|
-
//
|
|
338
|
+
// checks + RLS), minus its `CREATE SCHEMA` (the plan header above
|
|
339
|
+
// already emitted it once — don't repeat it per model).
|
|
332
340
|
const def = next.models[step.model];
|
|
333
341
|
if (!def)
|
|
334
342
|
break;
|
package/dist/schema/model.d.ts
CHANGED
|
@@ -188,14 +188,16 @@ export interface ModelOptions {
|
|
|
188
188
|
entityRoles?: EntityRole | readonly EntityRole[];
|
|
189
189
|
/**
|
|
190
190
|
* Whether clients may issue CREATE/UPDATE/DELETE mutations for this
|
|
191
|
-
* model via the `commit` wire protocol. Default:
|
|
191
|
+
* model via the `commit` wire protocol. Default: **true** — declaring a
|
|
192
|
+
* model in the schema IS the opt-in; if you put an entity in your synced
|
|
193
|
+
* schema, you almost always want to write it (product decision
|
|
194
|
+
* 2026-06-10, reversing the earlier default-deny that made every fresh
|
|
195
|
+
* quickstart's first write die with `server_execute_unknown_model`).
|
|
192
196
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* incident) OR where internal tables (`sync_deltas`, `presences`,
|
|
198
|
-
* digest/ingestion tables) become writable by accident.
|
|
197
|
+
* Opt OUT for server-managed projections (stats, digests, audit views):
|
|
198
|
+
* `mutable: false`, or the `readOnly.*` sugar which sets it for you.
|
|
199
|
+
* That keeps the 2026-04-20 `AgentJob`-class protection available where
|
|
200
|
+
* it matters, as a deliberate marking instead of a silent default.
|
|
199
201
|
*
|
|
200
202
|
* The server's `buildModelMap` (src/server/commit.ts) derives
|
|
201
203
|
* the mutation allowlist from this flag — no parallel hardcoded list.
|
package/dist/schema/model.js
CHANGED
|
@@ -99,7 +99,7 @@ export function model(shape, relations, options) {
|
|
|
99
99
|
scope: options?.scope,
|
|
100
100
|
grants: options?.grants,
|
|
101
101
|
entityRoles: normalizeEntityRoles(options?.entityRoles),
|
|
102
|
-
mutable: options?.mutable,
|
|
102
|
+
mutable: options?.mutable ?? true,
|
|
103
103
|
lazyObservable: options?.lazyObservable,
|
|
104
104
|
computed: options?.computed,
|
|
105
105
|
autoFill: options?.autoFill,
|
package/dist/schema/schema.js
CHANGED
|
@@ -167,7 +167,13 @@ export function defineSchema(models, options) {
|
|
|
167
167
|
const persist = def.persist
|
|
168
168
|
? { ...def.persist, store: def.persist.store ?? typename }
|
|
169
169
|
: undefined;
|
|
170
|
-
|
|
170
|
+
// Physical table defaults to the schema key — the SAME rule the
|
|
171
|
+
// provisioner/planner use (`tableName ?? key`), resolved here once so the
|
|
172
|
+
// serialized artifact always carries it. Required now that models are
|
|
173
|
+
// mutable by default: the server's `buildModelMap` rejects a mutable
|
|
174
|
+
// model with no `tableName`, which would otherwise break every commit.
|
|
175
|
+
const tableName = def.tableName ?? name;
|
|
176
|
+
resolvedModels[name] = { ...def, typename, tableName, persist };
|
|
171
177
|
}
|
|
172
178
|
validateSyncGroupSchema(resolvedModels);
|
|
173
179
|
return {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE sync-position structure — one typed object for "where is this client
|
|
3
|
+
* in the global delta order", replacing five scattered private counters
|
|
4
|
+
* (`lastSeenSyncId` on the queue, `highestProcessedSyncId` + `lastAckedId`
|
|
5
|
+
* on the store, ad-hoc acked watermarks, `max()` calls at snapshot sites).
|
|
6
|
+
*
|
|
7
|
+
* Three facts with DIFFERENT advance disciplines — flattening them was the
|
|
8
|
+
* historical bug source, so the structure models them explicitly:
|
|
9
|
+
*
|
|
10
|
+
* - `persisted` — the resume/ack cursor. Advances ONLY after deltas have
|
|
11
|
+
* committed to IndexedDB (the Replicache "lastMutationID read in the
|
|
12
|
+
* same transaction as the client view" rule — see SyncWebSocket.sendAck).
|
|
13
|
+
* This is what reconnect catch-up sends; it must never run ahead of
|
|
14
|
+
* durable state or the server skips deltas that never landed.
|
|
15
|
+
*
|
|
16
|
+
* - `applied` — the in-memory cursor: the last delta APPLIED to the
|
|
17
|
+
* object pool. Drives delta dedup/replay guards. May run ahead of
|
|
18
|
+
* `persisted` (pool applies before the IDB flush) and behind receipt
|
|
19
|
+
* (bootstrap-queued deltas are received but not yet applied).
|
|
20
|
+
*
|
|
21
|
+
* - `acked` — the highest server watermark ACKED to this client's OWN
|
|
22
|
+
* commits. An ack at N means the server applied our write at N; the
|
|
23
|
+
* optimistic pool already reflects it, so for entities we wrote we have
|
|
24
|
+
* logically read through N even before the stream echo arrives.
|
|
25
|
+
*
|
|
26
|
+
* One derived read: `readFloor` = max(applied, acked) — the ONLY value
|
|
27
|
+
* snapshots/claims may stamp as `readAt`. The bare stream cursor made a
|
|
28
|
+
* claim taken right after an ack-confirmed write stale against that write's
|
|
29
|
+
* own delta; the bare ack would be wrong for read-only clients. Per-entity
|
|
30
|
+
* correct: a foreign change to an entity we just wrote necessarily lands
|
|
31
|
+
* ABOVE our ack and still stale-rejects.
|
|
32
|
+
*
|
|
33
|
+
* The Zod schema IS the state shape — the class holds exactly one
|
|
34
|
+
* `SyncPositionSnapshot` and applies monotonic merges to it, so
|
|
35
|
+
* snapshot/restore are identity-shaped and the schema is the single gate
|
|
36
|
+
* for anything loaded from disk (`parseSyncPosition`; a corrupted stored
|
|
37
|
+
* cursor "ahead of reality" is an existing, known failure mode).
|
|
38
|
+
*/
|
|
39
|
+
import { z } from 'zod';
|
|
40
|
+
export declare const syncPositionSchema: z.ZodObject<{
|
|
41
|
+
persisted: z.ZodNumber;
|
|
42
|
+
applied: z.ZodNumber;
|
|
43
|
+
acked: z.ZodNumber;
|
|
44
|
+
}, z.core.$strip>;
|
|
45
|
+
export type SyncPositionSnapshot = z.infer<typeof syncPositionSchema>;
|
|
46
|
+
/**
|
|
47
|
+
* PERSISTENCE DESIGN: only the `persisted` cursor is stored durably (as
|
|
48
|
+
* `WorkspaceMetadata.lastSyncId`, written by Database after each IDB delta
|
|
49
|
+
* commit and gated on load through `syncPositionSchema.shape.persisted` in
|
|
50
|
+
* `Database.requiredBootstrap`). Persisting `applied`/`acked` would be
|
|
51
|
+
* meaningless: on resume the pool is rebuilt FROM the persisted state, so
|
|
52
|
+
* the correct restore is exactly `advancePersisted(storedCursor)` — which
|
|
53
|
+
* implies `applied`, while `acked` starts at 0 (a dead session's acks carry
|
|
54
|
+
* no read authority; the offline queue re-acks its own replays).
|
|
55
|
+
*/
|
|
56
|
+
/** Validate a persisted/foreign value into a position snapshot. */
|
|
57
|
+
export declare function parseSyncPosition(value: unknown): SyncPositionSnapshot | null;
|
|
58
|
+
/** The live position. One instance per client (owned by SyncClient); the
|
|
59
|
+
* three producers advance their own fact, consumers read. */
|
|
60
|
+
export declare class SyncPosition {
|
|
61
|
+
#private;
|
|
62
|
+
/** Current state — the schema shape, frozen-by-copy. */
|
|
63
|
+
snapshot(): SyncPositionSnapshot;
|
|
64
|
+
get persisted(): number;
|
|
65
|
+
get applied(): number;
|
|
66
|
+
get acked(): number;
|
|
67
|
+
/** THE value snapshots/claims stamp as `readAt`. */
|
|
68
|
+
get readFloor(): number;
|
|
69
|
+
/** Deltas through `syncId` have COMMITTED to IndexedDB. Persisting
|
|
70
|
+
* implies applied — the flush path applies before/with persisting. */
|
|
71
|
+
advancePersisted(syncId: number): void;
|
|
72
|
+
/** A delta was APPLIED to the in-memory pool. */
|
|
73
|
+
advanceApplied(syncId: number): void;
|
|
74
|
+
/** The server acked one of OUR commits at this watermark. */
|
|
75
|
+
noteAck(lastSyncId: number | undefined): void;
|
|
76
|
+
/** Restore from a VALIDATED snapshot (e.g. IDB resume). Monotonic. */
|
|
77
|
+
restore(snapshot: SyncPositionSnapshot): void;
|
|
78
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE sync-position structure — one typed object for "where is this client
|
|
3
|
+
* in the global delta order", replacing five scattered private counters
|
|
4
|
+
* (`lastSeenSyncId` on the queue, `highestProcessedSyncId` + `lastAckedId`
|
|
5
|
+
* on the store, ad-hoc acked watermarks, `max()` calls at snapshot sites).
|
|
6
|
+
*
|
|
7
|
+
* Three facts with DIFFERENT advance disciplines — flattening them was the
|
|
8
|
+
* historical bug source, so the structure models them explicitly:
|
|
9
|
+
*
|
|
10
|
+
* - `persisted` — the resume/ack cursor. Advances ONLY after deltas have
|
|
11
|
+
* committed to IndexedDB (the Replicache "lastMutationID read in the
|
|
12
|
+
* same transaction as the client view" rule — see SyncWebSocket.sendAck).
|
|
13
|
+
* This is what reconnect catch-up sends; it must never run ahead of
|
|
14
|
+
* durable state or the server skips deltas that never landed.
|
|
15
|
+
*
|
|
16
|
+
* - `applied` — the in-memory cursor: the last delta APPLIED to the
|
|
17
|
+
* object pool. Drives delta dedup/replay guards. May run ahead of
|
|
18
|
+
* `persisted` (pool applies before the IDB flush) and behind receipt
|
|
19
|
+
* (bootstrap-queued deltas are received but not yet applied).
|
|
20
|
+
*
|
|
21
|
+
* - `acked` — the highest server watermark ACKED to this client's OWN
|
|
22
|
+
* commits. An ack at N means the server applied our write at N; the
|
|
23
|
+
* optimistic pool already reflects it, so for entities we wrote we have
|
|
24
|
+
* logically read through N even before the stream echo arrives.
|
|
25
|
+
*
|
|
26
|
+
* One derived read: `readFloor` = max(applied, acked) — the ONLY value
|
|
27
|
+
* snapshots/claims may stamp as `readAt`. The bare stream cursor made a
|
|
28
|
+
* claim taken right after an ack-confirmed write stale against that write's
|
|
29
|
+
* own delta; the bare ack would be wrong for read-only clients. Per-entity
|
|
30
|
+
* correct: a foreign change to an entity we just wrote necessarily lands
|
|
31
|
+
* ABOVE our ack and still stale-rejects.
|
|
32
|
+
*
|
|
33
|
+
* The Zod schema IS the state shape — the class holds exactly one
|
|
34
|
+
* `SyncPositionSnapshot` and applies monotonic merges to it, so
|
|
35
|
+
* snapshot/restore are identity-shaped and the schema is the single gate
|
|
36
|
+
* for anything loaded from disk (`parseSyncPosition`; a corrupted stored
|
|
37
|
+
* cursor "ahead of reality" is an existing, known failure mode).
|
|
38
|
+
*/
|
|
39
|
+
import { z } from 'zod';
|
|
40
|
+
export const syncPositionSchema = z.object({
|
|
41
|
+
/** Resume/ack cursor — advances only after IDB persistence. */
|
|
42
|
+
persisted: z.number().int().nonnegative(),
|
|
43
|
+
/** In-memory cursor — last delta applied to the pool. */
|
|
44
|
+
applied: z.number().int().nonnegative(),
|
|
45
|
+
/** Highest server watermark acked to this client's own commits. */
|
|
46
|
+
acked: z.number().int().nonnegative(),
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* PERSISTENCE DESIGN: only the `persisted` cursor is stored durably (as
|
|
50
|
+
* `WorkspaceMetadata.lastSyncId`, written by Database after each IDB delta
|
|
51
|
+
* commit and gated on load through `syncPositionSchema.shape.persisted` in
|
|
52
|
+
* `Database.requiredBootstrap`). Persisting `applied`/`acked` would be
|
|
53
|
+
* meaningless: on resume the pool is rebuilt FROM the persisted state, so
|
|
54
|
+
* the correct restore is exactly `advancePersisted(storedCursor)` — which
|
|
55
|
+
* implies `applied`, while `acked` starts at 0 (a dead session's acks carry
|
|
56
|
+
* no read authority; the offline queue re-acks its own replays).
|
|
57
|
+
*/
|
|
58
|
+
/** Validate a persisted/foreign value into a position snapshot. */
|
|
59
|
+
export function parseSyncPosition(value) {
|
|
60
|
+
const result = syncPositionSchema.safeParse(value);
|
|
61
|
+
return result.success ? result.data : null;
|
|
62
|
+
}
|
|
63
|
+
const ZERO = { persisted: 0, applied: 0, acked: 0 };
|
|
64
|
+
/** Monotonic merge: each cursor only ever moves forward. */
|
|
65
|
+
function advance(state, next) {
|
|
66
|
+
return {
|
|
67
|
+
persisted: Math.max(state.persisted, next.persisted ?? 0),
|
|
68
|
+
applied: Math.max(state.applied, next.applied ?? 0),
|
|
69
|
+
acked: Math.max(state.acked, next.acked ?? 0),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** The live position. One instance per client (owned by SyncClient); the
|
|
73
|
+
* three producers advance their own fact, consumers read. */
|
|
74
|
+
export class SyncPosition {
|
|
75
|
+
#state = ZERO;
|
|
76
|
+
/** Current state — the schema shape, frozen-by-copy. */
|
|
77
|
+
snapshot() {
|
|
78
|
+
return { ...this.#state };
|
|
79
|
+
}
|
|
80
|
+
get persisted() {
|
|
81
|
+
return this.#state.persisted;
|
|
82
|
+
}
|
|
83
|
+
get applied() {
|
|
84
|
+
return this.#state.applied;
|
|
85
|
+
}
|
|
86
|
+
get acked() {
|
|
87
|
+
return this.#state.acked;
|
|
88
|
+
}
|
|
89
|
+
/** THE value snapshots/claims stamp as `readAt`. */
|
|
90
|
+
get readFloor() {
|
|
91
|
+
return Math.max(this.#state.applied, this.#state.acked);
|
|
92
|
+
}
|
|
93
|
+
/** Deltas through `syncId` have COMMITTED to IndexedDB. Persisting
|
|
94
|
+
* implies applied — the flush path applies before/with persisting. */
|
|
95
|
+
advancePersisted(syncId) {
|
|
96
|
+
this.#state = advance(this.#state, { persisted: syncId, applied: syncId });
|
|
97
|
+
}
|
|
98
|
+
/** A delta was APPLIED to the in-memory pool. */
|
|
99
|
+
advanceApplied(syncId) {
|
|
100
|
+
this.#state = advance(this.#state, { applied: syncId });
|
|
101
|
+
}
|
|
102
|
+
/** The server acked one of OUR commits at this watermark. */
|
|
103
|
+
noteAck(lastSyncId) {
|
|
104
|
+
if (lastSyncId !== undefined)
|
|
105
|
+
this.#state = advance(this.#state, { acked: lastSyncId });
|
|
106
|
+
}
|
|
107
|
+
/** Restore from a VALIDATED snapshot (e.g. IDB resume). Monotonic. */
|
|
108
|
+
restore(snapshot) {
|
|
109
|
+
this.#state = advance(this.#state, snapshot);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
11
11
|
import type { Database } from '../Database.js';
|
|
12
12
|
import { Model } from '../Model.js';
|
|
13
|
+
import { SyncPosition } from '../sync/syncPosition.js';
|
|
13
14
|
import type { WriteOptions } from '../interfaces/index.js';
|
|
14
15
|
export interface UserContext {
|
|
15
16
|
userId: string;
|
|
@@ -82,6 +83,8 @@ interface ConflictResolution {
|
|
|
82
83
|
resolver?: (local: MutationInput | undefined, remote: MutationInput) => MutationInput;
|
|
83
84
|
}
|
|
84
85
|
interface TransactionQueueConfig {
|
|
86
|
+
/** Shared client position (see sync/syncPosition.ts). One per client. */
|
|
87
|
+
position?: SyncPosition;
|
|
85
88
|
maxBatchSize: number;
|
|
86
89
|
batchDelay: number;
|
|
87
90
|
maxRetries: number;
|
|
@@ -140,7 +143,17 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
140
143
|
private deltaConfirmationRetries;
|
|
141
144
|
private isConnectedFn;
|
|
142
145
|
private commitOfflineGraceTimer;
|
|
143
|
-
|
|
146
|
+
/**
|
|
147
|
+
* THE client's place in the global delta order — the SHARED instance
|
|
148
|
+
* (injected by SyncClient; standalone construction gets its own). The
|
|
149
|
+
* queue advances `acked` on commit responses; the store advances
|
|
150
|
+
* `applied`/`persisted`; snapshots/claims read `readFloor`. Contract +
|
|
151
|
+
* rationale live in `sync/syncPosition.ts`.
|
|
152
|
+
*/
|
|
153
|
+
readonly position: SyncPosition;
|
|
154
|
+
/** Applied-cursor alias, kept so the many internal read sites stay legible. */
|
|
155
|
+
private get lastSeenSyncId();
|
|
156
|
+
private noteAck;
|
|
144
157
|
private static readonly DELTA_MAX_RETRIES;
|
|
145
158
|
private static readonly DELTA_INITIAL_TIMEOUT_MS;
|
|
146
159
|
private static readonly DELTA_MAX_TIMEOUT_MS;
|
|
@@ -414,6 +427,8 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
414
427
|
totalTransactions: number;
|
|
415
428
|
batchIndex: number;
|
|
416
429
|
config: {
|
|
430
|
+
/** Shared client position (see sync/syncPosition.ts). One per client. */
|
|
431
|
+
position?: SyncPosition;
|
|
417
432
|
maxBatchSize: number;
|
|
418
433
|
batchDelay: number;
|
|
419
434
|
maxRetries: number;
|
|
@@ -13,6 +13,7 @@ import { getActiveRegistry } from '../ModelRegistry.js';
|
|
|
13
13
|
import { MutationOperationType } from '../types/index.js';
|
|
14
14
|
import { handleMutationError } from './mutation-error-handler.js';
|
|
15
15
|
import { AbloError, AbloConnectionError, errorCodeSpec } from '../errors.js';
|
|
16
|
+
import { SyncPosition } from '../sync/syncPosition.js';
|
|
16
17
|
/**
|
|
17
18
|
* Framework-internal keys added by `Model.toJSON()` that must never
|
|
18
19
|
* reach the wire. The server treats each top-level key as a target
|
|
@@ -323,10 +324,21 @@ export class TransactionQueue extends EventEmitter {
|
|
|
323
324
|
// cleared on `'connected'`. The reconnect-retry behavior of the queue
|
|
324
325
|
// is preserved for brief blips; this only catches persistent disconnects.
|
|
325
326
|
commitOfflineGraceTimer = null;
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
327
|
+
/**
|
|
328
|
+
* THE client's place in the global delta order — the SHARED instance
|
|
329
|
+
* (injected by SyncClient; standalone construction gets its own). The
|
|
330
|
+
* queue advances `acked` on commit responses; the store advances
|
|
331
|
+
* `applied`/`persisted`; snapshots/claims read `readFloor`. Contract +
|
|
332
|
+
* rationale live in `sync/syncPosition.ts`.
|
|
333
|
+
*/
|
|
334
|
+
position;
|
|
335
|
+
/** Applied-cursor alias, kept so the many internal read sites stay legible. */
|
|
336
|
+
get lastSeenSyncId() {
|
|
337
|
+
return this.position.applied;
|
|
338
|
+
}
|
|
339
|
+
noteAck(lastSyncId) {
|
|
340
|
+
this.position.noteAck(lastSyncId);
|
|
341
|
+
}
|
|
330
342
|
// Delta confirmation retry config (Replicache-style exponential backoff)
|
|
331
343
|
// Max retries before requesting full reconciliation
|
|
332
344
|
static DELTA_MAX_RETRIES = 5;
|
|
@@ -346,6 +358,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
346
358
|
confirmationResolvers = new Map();
|
|
347
359
|
constructor(config) {
|
|
348
360
|
super();
|
|
361
|
+
this.position = config?.position ?? new SyncPosition();
|
|
349
362
|
if (config) {
|
|
350
363
|
this.config = { ...this.config, ...config };
|
|
351
364
|
}
|
|
@@ -972,6 +985,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
972
985
|
// the coalescing test's tight bound on batch count.
|
|
973
986
|
const result = await this.mutationExecutor.commit(operations);
|
|
974
987
|
const lastSyncId = result?.lastSyncId ?? 0;
|
|
988
|
+
this.noteAck(lastSyncId);
|
|
975
989
|
// Detect server bug: lastSyncId 0 means mutation succeeded but no sync delta was emitted
|
|
976
990
|
if (lastSyncId === 0) {
|
|
977
991
|
getContext().observability.captureCommitZeroSyncId({
|
|
@@ -999,34 +1013,44 @@ export class TransactionQueue extends EventEmitter {
|
|
|
999
1013
|
});
|
|
1000
1014
|
continue;
|
|
1001
1015
|
}
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
|
|
1006
|
-
|
|
1016
|
+
// ACK-BASED CONFIRMATION. A successful commit response with a
|
|
1017
|
+
// real watermark means the server durably applied the write —
|
|
1018
|
+
// that IS the confirmation (the documented `wait: 'confirmed'`
|
|
1019
|
+
// contract, and how Replicache/Zero treat the push response's
|
|
1020
|
+
// lastMutationID). The delta echo is NOT an acknowledgement
|
|
1021
|
+
// channel: it's replication for OTHER clients, and this
|
|
1022
|
+
// client's own echo is suppressed by the OptimisticEchoTracker
|
|
1023
|
+
// anyway. Gating confirmation on the echo coupled "did my
|
|
1024
|
+
// write land" to subscription-stream health — a bare-Node
|
|
1025
|
+
// client with no live delta stream hung forever in
|
|
1026
|
+
// `awaiting_delta` on a write the server had already applied.
|
|
1027
|
+
if (lastSyncId > 0) {
|
|
1007
1028
|
this.store.updateStatus(tx.id, 'completed');
|
|
1008
1029
|
this.emit('transaction:completed', tx);
|
|
1009
1030
|
this.emit(`transaction:completed:${tx.id}`, tx);
|
|
1010
1031
|
this.optimisticUpdates.delete(tx.id);
|
|
1011
|
-
getContext().logger.debug('tx:
|
|
1032
|
+
getContext().logger.debug('tx:confirm_ack', {
|
|
1012
1033
|
txId: tx.id.slice(0, 8),
|
|
1013
1034
|
model: tx.modelName,
|
|
1014
|
-
|
|
1035
|
+
serverSyncId: lastSyncId,
|
|
1015
1036
|
lastSeenSyncId: this.lastSeenSyncId,
|
|
1016
|
-
reason: 'delta_arrived_before_http',
|
|
1017
1037
|
});
|
|
1018
1038
|
}
|
|
1019
1039
|
else {
|
|
1020
|
-
//
|
|
1040
|
+
// lastSyncId === 0 on a non-DELETE: the server accepted the
|
|
1041
|
+
// commit but emitted no delta — a server-side anomaly
|
|
1042
|
+
// (already captured via captureCommitZeroSyncId above). Keep
|
|
1043
|
+
// the delta-wait + reconciliation timeout for THIS case
|
|
1044
|
+
// only, so the anomaly surfaces instead of silently
|
|
1045
|
+
// confirming a write with no watermark.
|
|
1021
1046
|
this.store.updateStatus(tx.id, 'awaiting_delta');
|
|
1022
1047
|
getContext().logger.debug('tx:awaiting_delta', {
|
|
1023
1048
|
txId: tx.id.slice(0, 8),
|
|
1024
1049
|
model: tx.modelName,
|
|
1025
1050
|
neededSyncId: lastSyncId,
|
|
1026
1051
|
lastSeenSyncId: this.lastSeenSyncId,
|
|
1027
|
-
|
|
1052
|
+
reason: 'zero_sync_id_anomaly',
|
|
1028
1053
|
});
|
|
1029
|
-
// Schedule timeout-based rollback for unconfirmed transactions
|
|
1030
1054
|
this.scheduleDeltaConfirmationTimeout(tx, this.config.deltaConfirmationTimeout);
|
|
1031
1055
|
}
|
|
1032
1056
|
}
|
|
@@ -1153,16 +1177,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1153
1177
|
* @param syncId - The sync ID of the received delta
|
|
1154
1178
|
*/
|
|
1155
1179
|
onDeltaReceived(syncId) {
|
|
1156
|
-
|
|
1157
|
-
//
|
|
1158
|
-
|
|
1159
|
-
this.lastSeenSyncId = syncId;
|
|
1160
|
-
getContext().logger.debug('tx:highwater_update', {
|
|
1161
|
-
prev: prevLastSeen,
|
|
1162
|
-
new: syncId,
|
|
1163
|
-
delta: syncId - prevLastSeen,
|
|
1164
|
-
});
|
|
1165
|
-
}
|
|
1180
|
+
// Cursor advancing happens where the delta is APPLIED (the store calls
|
|
1181
|
+
// position.advanceApplied / advancePersisted); this hook only resolves
|
|
1182
|
+
// confirmation thresholds against the incoming id.
|
|
1166
1183
|
const awaitingTxs = this.store.getByStatus('awaiting_delta');
|
|
1167
1184
|
const executingTxs = this.store.getByStatus('executing');
|
|
1168
1185
|
// Debug: Show state when delta arrives
|
|
@@ -1409,6 +1426,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1409
1426
|
causedByTaskId: tx.causedByTaskId ?? undefined,
|
|
1410
1427
|
});
|
|
1411
1428
|
tx.lastSyncId = result?.lastSyncId ?? 0;
|
|
1429
|
+
this.noteAck(tx.lastSyncId);
|
|
1412
1430
|
tx.status = 'completed';
|
|
1413
1431
|
this.commitLane.shift();
|
|
1414
1432
|
this.emit('transaction:completed', tx);
|
package/docs/api-keys.md
CHANGED
|
@@ -23,7 +23,7 @@ Use API keys from trusted (server-side) runtimes:
|
|
|
23
23
|
|
|
24
24
|
Never ship a secret API key to a browser bundle.
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## Sandboxes and production
|
|
27
27
|
|
|
28
28
|
Test and live keys are the same shape; the prefix names the environment:
|
|
29
29
|
|
|
@@ -31,11 +31,11 @@ Test and live keys are the same shape; the prefix names the environment:
|
|
|
31
31
|
to that sandbox and are invisible to live keys (and to other sandboxes).
|
|
32
32
|
- `sk_live_…` — a key against your live data.
|
|
33
33
|
|
|
34
|
-
Every org has a default
|
|
34
|
+
Every org has a default sandbox, plus any number of additional
|
|
35
35
|
sandboxes you create. **Data is isolated per sandbox; the schema is shared
|
|
36
36
|
across the whole org.** A schema you push from a test key defines the same
|
|
37
37
|
models your live keys see — only the rows differ. This mirrors how Stripe
|
|
38
|
-
separates
|
|
38
|
+
separates sandbox and production data while keeping the API shape identical.
|
|
39
39
|
|
|
40
40
|
## Scopes
|
|
41
41
|
|
|
@@ -51,7 +51,7 @@ restricted to exactly those grants:
|
|
|
51
51
|
- `sandbox:<id>` — identifies which sandbox the key belongs to. (The key's data
|
|
52
52
|
isolation comes from that sandbox binding, not from this scope string.)
|
|
53
53
|
|
|
54
|
-
A key minted from the default
|
|
54
|
+
A key minted from the default sandbox carries `schema:push`, so
|
|
55
55
|
`ablo dev` works out of the box. Keys from other sandboxes are **data-only** by
|
|
56
56
|
default — enable "schema authoring" when minting if you want that key to push
|
|
57
57
|
schema too. Hand data-only keys to embedded apps and CI agents; reserve
|
package/docs/cli.md
CHANGED
|
@@ -32,7 +32,7 @@ mirrors `stripe login`.
|
|
|
32
32
|
| `ablo login` | Authorize in the browser; provisions + stores a test and a live key. |
|
|
33
33
|
| `ablo logout` | Remove the stored keys. |
|
|
34
34
|
| `ablo status` | Show the active org, mode, both keys (prefix + expiry), and server health. |
|
|
35
|
-
| `ablo mode [
|
|
35
|
+
| `ablo mode [sandbox\|production]` | Switch the active environment. With no argument, prompts. |
|
|
36
36
|
|
|
37
37
|
Keys are stored in `~/.config/ablo/config.json` (mode `0600`). In **CI**, don't
|
|
38
38
|
log in — set `ABLO_API_KEY`, which always overrides the stored key.
|
|
@@ -41,11 +41,11 @@ log in — set `ABLO_API_KEY`, which always overrides the stored key.
|
|
|
41
41
|
|
|
42
42
|
Like Stripe, every account has a **test** mode and a **live** mode, and a key
|
|
43
43
|
belongs to one of them. Test keys are bound to an isolated sandbox: their reads
|
|
44
|
-
and writes never touch
|
|
45
|
-
|
|
44
|
+
and writes never touch production data. Switch with `ablo mode`; `ablo dev` is always
|
|
45
|
+
the sandbox by design.
|
|
46
46
|
|
|
47
47
|
The schema, however, is **shared** across the org — pushing a schema (from
|
|
48
|
-
either
|
|
48
|
+
either environment) defines the same models sandbox and production see; only the rows differ.
|
|
49
49
|
|
|
50
50
|
## Commands
|
|
51
51
|
|
|
@@ -53,9 +53,9 @@ either mode) defines the same models test and live see; only the rows differ.
|
|
|
53
53
|
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
54
54
|
| `ablo init` | Scaffold `ablo/` (`schema.ts`, client, optional Data Source / agent / component), write `.env`, install the SDK. Offers to log in at the end. | — |
|
|
55
55
|
| `ablo login` / `logout` / `status` | Authentication & status (above). | — |
|
|
56
|
-
| `ablo mode [
|
|
56
|
+
| `ablo mode [sandbox\|production]` | Switch active environment. | — |
|
|
57
57
|
| `ablo dev` | **Hosted** — push the schema to your test sandbox, then watch `ablo/schema.ts` and re-push on save. | `--no-watch`, `--schema <path>`, `--export <name>`, `--url <url>` |
|
|
58
|
-
| `ablo logs` | Tail your scope's commit activity (`stripe logs tail`). Follows by default. | `-n, --tail <N>`, `--since <dur\|ts>`, `--model`, `--op`, `--json`, `--no-follow`, `--mode
|
|
58
|
+
| `ablo logs` | Tail your scope's commit activity (`stripe logs tail`). Follows by default. | `-n, --tail <N>`, `--since <dur\|ts>`, `--model`, `--op`, `--json`, `--no-follow`, `--mode sandbox\|production` |
|
|
59
59
|
| `ablo push` | **Hosted** — upload the schema to Ablo; the server diffs, migrates, and activates it. | `--force`, `--rename old:new`, `--backfill model.field=value`, `--schema`, `--export`, `--url` |
|
|
60
60
|
| `ablo migrate` | **Direct Postgres** — provision just the synced models (plus the adapter's `ablo_outbox` / `ablo_idempotency`) in your own `DATABASE_URL`. Leaves your other tables alone. | `--dry-run`, `--output <file>`, `--schema`, `--export` |
|
|
61
61
|
| `ablo pull` | **Direct Postgres** — generate `defineSchema(...)` from your existing tables (read-only, like `prisma db pull`). | `--out <path>`, `--app-schema <name>`, `--import <pkg>`, `--force` |
|
package/docs/quickstart.md
CHANGED
|
@@ -87,7 +87,7 @@ from `Ablo(...)`.
|
|
|
87
87
|
```bash
|
|
88
88
|
npx ablo migrate # creates your synced-model tables (with row-level security)
|
|
89
89
|
# in YOUR database — your other tables are left untouched
|
|
90
|
-
npx ablo dev # pushes the schema (
|
|
90
|
+
npx ablo dev # pushes the schema (sandbox), writes ABLO_API_KEY to
|
|
91
91
|
# .env.local, and re-pushes on every save — the dev loop
|
|
92
92
|
```
|
|
93
93
|
|
package/llms-full.txt
CHANGED
|
@@ -144,8 +144,11 @@ its service layer and DB credentials. Ablo gets a Data Source endpoint and uses
|
|
|
144
144
|
|
|
145
145
|
## Client Behavior
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
`
|
|
147
|
+
The options that matter: `schema` and `apiKey`. Everything else
|
|
148
|
+
(`persistence`, `logger`, transport tuning) has correct defaults — do NOT set
|
|
149
|
+
`baseURL`; the default already routes to the hosted API, and overriding it
|
|
150
|
+
breaks the connection. It exists only for self-hosted/proxy setups the human
|
|
151
|
+
explicitly asks for.
|
|
149
152
|
|
|
150
153
|
There is intentionally no `databaseURL` option on `Ablo(...)`. Application and
|
|
151
154
|
agent code use `ABLO_API_KEY`. Customer-owned app databases stay private behind
|
|
@@ -171,7 +174,7 @@ There are two sandbox surfaces:
|
|
|
171
174
|
making real Ablo calls. It is agent-first: it should expose a prompt that can
|
|
172
175
|
be pasted into Claude Code or Codex to wire one real model through Ablo.
|
|
173
176
|
- Authenticated org sandboxes are real test environments. The default sandbox is
|
|
174
|
-
the Stripe-style
|
|
177
|
+
the Stripe-style sandbox for an org. It has an isolated sync group prefix,
|
|
175
178
|
can mint `sk_test_*` keys, and can be reset without touching live state.
|
|
176
179
|
|
|
177
180
|
Additional org sandboxes can start blank or copy live configuration. Keep
|
|
@@ -190,8 +193,10 @@ Use these public environment names:
|
|
|
190
193
|
- `ABLO_API_KEY` — SDK authentication for app and agent code. Where it comes
|
|
191
194
|
from: the human runs `npx ablo login` once (browser; an agent must not run
|
|
192
195
|
it), and `npx ablo dev` then writes `ABLO_API_KEY=sk_test_…` into
|
|
193
|
-
`.env.local` automatically. Check the environment and
|
|
194
|
-
|
|
196
|
+
`.env.local` automatically (and gitignores it). Check the environment and
|
|
197
|
+
`.env.local` for PRESENCE only (`grep -cq '^ABLO_API_KEY=' .env.local`)
|
|
198
|
+
before asking the human for a key — never print or echo the key value; a
|
|
199
|
+
secret in agent output lives in the conversation history forever.
|
|
195
200
|
|
|
196
201
|
Do not ask customers to paste their app database URL into Ablo. If their app
|
|
197
202
|
database is canonical, they expose a Data Source endpoint and keep database
|