@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.
@@ -1307,7 +1307,14 @@ export function Ablo(options) {
1307
1307
  createSnapshot: (modelKey, id) => createSnapshot({
1308
1308
  pool: objectPool,
1309
1309
  transport: store.getSyncWebSocket(),
1310
- getLastSyncId: () => store.getSyncWebSocket()?.getLastSyncId() ?? store.lastSyncId ?? 0,
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 }),
@@ -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 = "wss://api.abloatai.com";
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.
@@ -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 = `wss://${ABLO_HOSTED_API_DOMAIN}`;
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
- if (!LEGACY_HOSTED_API_HOSTS.has(url.hostname))
69
- return schemed.replace(/\/+$/, '');
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 {
@@ -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 schema already exists
331
- // mid-migration).
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;
@@ -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: false.
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
- * Safety-by-default: a newly-declared schema entity is read-only from
194
- * the client side until the author explicitly opts into wire mutability.
195
- * Prevents the class of bug where adding a new entity to the schema
196
- * silently exposes it as a write surface (the 2026-04-20 `AgentJob`
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.
@@ -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,
@@ -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
- resolvedModels[name] = { ...def, typename, persist };
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
- private lastSeenSyncId;
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
- // Track the highest syncId received from WebSocket deltas
327
- // Used to immediately confirm transactions when HTTP response arrives AFTER the delta
328
- // (fixes race condition where WebSocket delta arrives before HTTP response)
329
- lastSeenSyncId = 0;
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
- // FIX: Check if delta already arrived before HTTP response (race condition)
1003
- // WebSocket can be faster than HTTP, so the delta might already be here
1004
- // Guard: only do immediate confirm if lastSyncId > 0 (valid server response)
1005
- if (lastSyncId > 0 && this.lastSeenSyncId >= lastSyncId) {
1006
- // Delta already arrived! Confirm immediately without timeout
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:confirm_immediate', {
1032
+ getContext().logger.debug('tx:confirm_ack', {
1012
1033
  txId: tx.id.slice(0, 8),
1013
1034
  model: tx.modelName,
1014
- neededSyncId: lastSyncId,
1035
+ serverSyncId: lastSyncId,
1015
1036
  lastSeenSyncId: this.lastSeenSyncId,
1016
- reason: 'delta_arrived_before_http',
1017
1037
  });
1018
1038
  }
1019
1039
  else {
1020
- // Delta hasn't arrived yet, wait for it
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
- gap: lastSyncId - this.lastSeenSyncId,
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
- const prevLastSeen = this.lastSeenSyncId;
1157
- // Track highest syncId seen (fixes race: delta arrives before HTTP response)
1158
- if (syncId > this.lastSeenSyncId) {
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
- ## Test mode and sandboxes
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 **Test mode** sandbox, plus any number of additional
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 test and live data while keeping the API shape identical.
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 **Test mode** sandbox carries `schema:push`, so
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 [test\|live]` | Switch the active mode. With no argument, prompts. |
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 live data. Switch with `ablo mode`; `ablo dev` is always
45
- test mode by design.
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 mode) defines the same models test and live see; only the rows differ.
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 [test\|live]` | Switch active 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 test\|live` |
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` |
@@ -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 (test mode), writes ABLO_API_KEY to
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
- Important options: `schema`, `apiKey`, `baseURL`, `persistence`, `fetch`,
148
- `defaultHeaders`, `defaultQuery`, `logger`, and `dangerouslyAllowBrowser`.
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 test mode for an org. It has an isolated sync group prefix,
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 `.env.local` before
194
- asking the human for a key.
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