@abloatai/ablo 0.9.5 → 0.9.7

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.
@@ -442,6 +442,15 @@ export interface IntentCreateOptions {
442
442
  }
443
443
  export interface IntentHandle extends AsyncDisposable {
444
444
  readonly id: string;
445
+ /**
446
+ * True when the lease was granted AFTER waiting in the server's FIFO
447
+ * queue behind a holder (`intent_granted`), false/absent for an
448
+ * immediate grant (`intent_acquired`). Consumers re-read the target
449
+ * row when this is set — the row may have changed while we queued, and
450
+ * the local coordination snapshot can't detect that for org-scoped
451
+ * subscriptions (intent fan-out is entity-scoped).
452
+ */
453
+ readonly waited?: boolean;
445
454
  release(): Promise<void>;
446
455
  revoke(): void;
447
456
  }
@@ -1238,12 +1238,13 @@ export function Ablo(options) {
1238
1238
  }
1239
1239
  await waitForModelUnclaimed(target, { timeout: options?.claimedTimeout });
1240
1240
  }
1241
- function wrapIntentHandle(claim) {
1241
+ function wrapIntentHandle(claim, waited = false) {
1242
1242
  const release = async () => {
1243
1243
  claim.revoke();
1244
1244
  };
1245
1245
  return {
1246
1246
  id: claim.id,
1247
+ waited,
1247
1248
  release,
1248
1249
  revoke: claim.revoke,
1249
1250
  [Symbol.asyncDispose]: release,
@@ -1269,14 +1270,15 @@ export function Ablo(options) {
1269
1270
  // we reach the head of the FIFO line). Block here on that grant so
1270
1271
  // callers — chiefly `ablo.<model>.claim` — get a handle that already
1271
1272
  // holds the lease, never a half-claimed one racing the queue.
1273
+ let waited = false;
1272
1274
  if (intentOptions.queue) {
1273
1275
  const ws = store.getSyncWebSocket();
1274
1276
  if (ws) {
1275
1277
  try {
1276
- await awaitIntentGrant(ws, claim.id, {
1278
+ ({ waited } = await awaitIntentGrant(ws, claim.id, {
1277
1279
  timeoutMs: intentOptions.waitTimeoutMs,
1278
1280
  maxQueueDepth: intentOptions.maxQueueDepth,
1279
- });
1281
+ }));
1280
1282
  }
1281
1283
  catch (err) {
1282
1284
  // Gave up waiting (queue too deep, timed out, or lost) — abandon
@@ -1287,7 +1289,7 @@ export function Ablo(options) {
1287
1289
  }
1288
1290
  }
1289
1291
  }
1290
- return wrapIntentHandle(claim);
1292
+ return wrapIntentHandle(claim, waited);
1291
1293
  },
1292
1294
  list(target) {
1293
1295
  return listModelClaims(target);
@@ -75,6 +75,14 @@ export interface ModelLoadOptions<T> {
75
75
  export type ModelRetrieveOptions = Pick<ModelLoadOptions<unknown>, 'type' | 'expand'>;
76
76
  export interface IntentLeaseHandle {
77
77
  readonly id: string;
78
+ /**
79
+ * True when the grant came AFTER waiting in the server's FIFO line
80
+ * (`intent_granted`) — the authoritative "the row may have changed
81
+ * underneath us" signal. The local `observe()` snapshot can't stand in
82
+ * for this: intent fan-out is entity-scoped, so org-wide subscriptions
83
+ * (the default hosted client) never see peers' claims at all.
84
+ */
85
+ readonly waited?: boolean;
78
86
  release(): Promise<void>;
79
87
  revoke(): void;
80
88
  }
@@ -31,6 +31,16 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
31
31
  throw new AbloValidationError(`Ablo: schema model "${schemaKey}" resolved to "${registeredModelName}", ` +
32
32
  'but no matching constructor was registered.', { code: 'model_not_registered' });
33
33
  }
34
+ // The coordination plane (claims/intents) must speak the SAME wire dialect
35
+ // as the commit plane: the lowercased TYPENAME (`task`), not the schema key
36
+ // (`tasks`). The server's commit-time intent guard probes the lease store
37
+ // with the commit op's model name; a lease recorded under the schema key
38
+ // never collides with it — which silently disarmed the guard for every
39
+ // model whose schema key differs from its typename (i.e. nearly all of
40
+ // them, plural key vs singular typename). Public surfaces (ClaimHandle.
41
+ // target.model) keep the schema key; only the wire/coordination targets
42
+ // use this.
43
+ const wireModel = registeredModelName.toLowerCase();
34
44
  // Last-line guarantee for the public surface: any rejection from a lower
35
45
  // layer (transport timeout, IndexedDB failure, a third-party throw) is
36
46
  // coerced to an AbloError before it reaches the consumer. The SDK's
@@ -98,7 +108,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
98
108
  // Is someone ELSE already on this target? Read the local coordination
99
109
  // snapshot up front — it decides whether we'll need to re-read after the
100
110
  // claim (a free / already-mine target can't have changed under us).
101
- const held = collaboration.observe({ model: schemaKey, id });
111
+ const held = collaboration.observe({ model: wireModel, id });
102
112
  const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
103
113
  const failFast = options?.wait === false;
104
114
  // Fail-fast (`wait: false`): if another participant already holds it,
@@ -113,7 +123,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
113
123
  // Ensure the row exists locally before claiming.
114
124
  let model = objectPool.get(id);
115
125
  if (!model) {
116
- await load({ where: { id } });
126
+ await load({ where: [['id', id]] });
117
127
  model = objectPool.get(id);
118
128
  }
119
129
  if (!model) {
@@ -126,7 +136,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
126
136
  // observed conflict above, so this just records our lease.
127
137
  const lease = await collaboration.createIntent({
128
138
  target: {
129
- model: schemaKey,
139
+ model: wireModel,
130
140
  id,
131
141
  ...(options?.field ? { field: options.field } : {}),
132
142
  ...(options?.path ? { path: options.path } : {}),
@@ -140,9 +150,19 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
140
150
  });
141
151
  // Only when we actually waited behind another holder can the row have
142
152
  // changed underneath us — re-read so the claimed snapshot reflects what
143
- // they committed before releasing.
144
- if (contended && !failFast) {
145
- await load({ where: { id } });
153
+ // they committed before releasing. Two signals, either suffices:
154
+ // - `lease.waited` the server granted via `intent_granted`, i.e. we
155
+ // provably queued behind a holder. Authoritative; works even when
156
+ // the local snapshot is blind (intent fan-out is entity-scoped, so
157
+ // org-wide-subscribed clients never observe peers' claims).
158
+ // - `contended` — the local snapshot saw a holder up front. Kept for
159
+ // the no-queue paths where no grant frame exists.
160
+ if ((contended || lease.waited === true) && !failFast) {
161
+ // `type: 'complete'` forces the round-trip: the hydration ledger
162
+ // otherwise serves the LOCAL row for an already-hydrated id, and the
163
+ // holder's final write may not have fanned out to us yet — the exact
164
+ // stale-snapshot race this re-read exists to close.
165
+ await load({ where: [['id', id]], type: 'complete' });
146
166
  model = objectPool.get(id) ?? model;
147
167
  }
148
168
  const snapshot = collaboration.createSnapshot(schemaKey, id);
@@ -178,16 +198,16 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
178
198
  // are the same object.
179
199
  const claimApi = Object.assign(guard(claim), {
180
200
  state(params) {
181
- return collaboration?.observe({ model: schemaKey, id: params.id }) ?? null;
201
+ return collaboration?.observe({ model: wireModel, id: params.id }) ?? null;
182
202
  },
183
203
  queue(params) {
184
204
  return {
185
205
  object: 'list',
186
- data: collaboration?.queue({ model: schemaKey, id: params.id }) ?? [],
206
+ data: collaboration?.queue({ model: wireModel, id: params.id }) ?? [],
187
207
  };
188
208
  },
189
209
  reorder(params) {
190
- collaboration?.reorder({ model: schemaKey, id: params.id }, params.order);
210
+ collaboration?.reorder({ model: wireModel, id: params.id }, params.order);
191
211
  },
192
212
  release: guard((params) => releaseClaim(isClaimHandle(params) ? params.target.id : params.id)),
193
213
  });
@@ -195,7 +215,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
195
215
  retrieve: guard(async (params) => {
196
216
  const rows = await load({
197
217
  ...params,
198
- where: { id: params.id },
218
+ where: [['id', params.id]],
199
219
  limit: 1,
200
220
  });
201
221
  return rows[0];
@@ -251,7 +271,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
251
271
  }
252
272
  autoLease = await collaboration.createIntent({
253
273
  target: {
254
- model: schemaKey,
274
+ model: wireModel,
255
275
  id,
256
276
  ...(claim.field ? { field: claim.field } : {}),
257
277
  ...(claim.path ? { path: claim.path } : {}),
@@ -142,13 +142,22 @@ export const ERROR_CODES = {
142
142
  byo_tenant_tables_unforced_rls: wire('permission', 403, false, 'Tenant tables do not have RLS forced under the direct Postgres connector role.'),
143
143
  byo_host_not_allowed: wire('permission', 403, false, 'The direct Postgres connector host resolves to a private, loopback, or link-local address and cannot be used.'),
144
144
  // ── claim / intent conflict (409) ──────────────────────────────────
145
- claim_conflict: wire('claim', 409, true, 'The target entity is claimed by another participant.'),
146
- claim_lost: wire('claim', 409, true, 'A previously held claim was lost before the write applied.'),
147
- entity_claimed: wire('claim', 409, true, 'The target entity is currently claimed; write was blocked.'),
148
- intent_conflict: wire('claim', 409, true, 'An intent on the target conflicts with an active intent (server-internal alias of claim_conflict).'),
145
+ // Held-claim rejections are NOT queue-retryable (gRPC FAILED_PRECONDITION /
146
+ // ABORTED semantics; Replicache/Zero SETTLE a rejected mutation reject the
147
+ // caller, roll back the optimistic effect instead of resending it).
148
+ // Blindly re-sending the same payload cannot succeed while the lease is
149
+ // held, and a lease can outlive any sane retry budget. The correct recovery
150
+ // lives at the CALLER: take a claim (`ablo.<model>.claim` queues fairly
151
+ // behind the holder) or re-read and rebase. `retryable: true` here turned
152
+ // every cross-client claim conflict into an infinite client resend loop
153
+ // (~150ms storm — found by the claims journey, 2026-06-10).
154
+ claim_conflict: wire('claim', 409, false, 'The target entity is claimed by another participant.'),
155
+ claim_lost: wire('claim', 409, false, 'A previously held claim was lost before the write applied.'),
156
+ entity_claimed: wire('claim', 409, false, 'The target entity is currently claimed; write was blocked.'),
157
+ intent_conflict: wire('claim', 409, false, 'An intent on the target conflicts with an active intent (server-internal alias of claim_conflict).'),
149
158
  malformed_claim: wire('claim', 400, false, 'The claim payload was malformed.'),
150
- model_claimed: wire('claim', 409, true, 'The model instance is claimed by another participant.'),
151
- model_claimed_timeout: wire('claim', 409, true, 'Timed out waiting for a model claim to clear.'),
159
+ model_claimed: wire('claim', 409, false, 'The model instance is claimed by another participant.'),
160
+ model_claimed_timeout: wire('claim', 409, false, 'Timed out waiting for a model claim to clear.'),
152
161
  model_claim_not_configured: client('claim', 'Claiming was requested on a model that has no claim configuration.'),
153
162
  // ── stale context / idempotency (409) ──────────────────────────────
154
163
  stale_context: wire('conflict', 409, true, 'The write carried a readAt watermark that is now stale; re-read and retry.'),
@@ -385,6 +385,19 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
385
385
  * Setup WebSocket event handlers
386
386
  */
387
387
  private setupEventHandlers;
388
+ /**
389
+ * Normalize a wire delta at the receive boundary. The contract
390
+ * (`syncDeltaWireCoreSchema`) says `id: number`, but deployed servers
391
+ * have sent the raw Postgres BIGINT serialization — a STRING — and
392
+ * every downstream watermark gate (`typeof syncId === 'number'` in
393
+ * `Database.processDeltaBatch`, the metadata-cursor update, numeric
394
+ * `>=` threshold comparisons in TransactionQueue) silently breaks on
395
+ * strings: acks are withheld, the resume cursor never advances, and
396
+ * every reconnect replays from 0 (or force-bootstraps once the gap
397
+ * exceeds maxDeltaGapForPartial). Coerce ONCE here so the rest of the
398
+ * client can trust the declared type — and old servers stay compatible.
399
+ */
400
+ private normalizeWireDelta;
388
401
  /**
389
402
  * Handle incoming sync delta
390
403
  */
@@ -318,8 +318,11 @@ export class SyncWebSocket extends EventEmitter {
318
318
  clearTimeout(pending.timeout);
319
319
  this.pendingMutations.delete(clientTxId);
320
320
  if (success) {
321
+ // Coerce defensively — bigint columns serialize as strings
322
+ // from older servers (see normalizeWireDelta).
323
+ const ackedSyncId = Number(lastSyncId);
321
324
  pending.resolve({
322
- lastSyncId: typeof lastSyncId === 'number' ? lastSyncId : 0,
325
+ lastSyncId: Number.isFinite(ackedSyncId) ? ackedSyncId : 0,
323
326
  });
324
327
  }
325
328
  else {
@@ -631,10 +634,29 @@ export class SyncWebSocket extends EventEmitter {
631
634
  }
632
635
  };
633
636
  }
637
+ /**
638
+ * Normalize a wire delta at the receive boundary. The contract
639
+ * (`syncDeltaWireCoreSchema`) says `id: number`, but deployed servers
640
+ * have sent the raw Postgres BIGINT serialization — a STRING — and
641
+ * every downstream watermark gate (`typeof syncId === 'number'` in
642
+ * `Database.processDeltaBatch`, the metadata-cursor update, numeric
643
+ * `>=` threshold comparisons in TransactionQueue) silently breaks on
644
+ * strings: acks are withheld, the resume cursor never advances, and
645
+ * every reconnect replays from 0 (or force-bootstraps once the gap
646
+ * exceeds maxDeltaGapForPartial). Coerce ONCE here so the rest of the
647
+ * client can trust the declared type — and old servers stay compatible.
648
+ */
649
+ normalizeWireDelta(delta) {
650
+ if (typeof delta.id === 'number')
651
+ return delta;
652
+ const coerced = Number(delta.id);
653
+ return { ...delta, id: Number.isFinite(coerced) ? coerced : 0 };
654
+ }
634
655
  /**
635
656
  * Handle incoming sync delta
636
657
  */
637
- handleDelta(delta) {
658
+ handleDelta(rawDelta) {
659
+ const delta = this.normalizeWireDelta(rawDelta);
638
660
  getContext().logger.debug('Received delta', {
639
661
  action: delta.actionType,
640
662
  model: delta.modelName,
@@ -1342,8 +1364,10 @@ export class SyncWebSocket extends EventEmitter {
1342
1364
  }
1343
1365
  // Process incremental deltas
1344
1366
  if (payload.deltas && Array.isArray(payload.deltas)) {
1345
- // Process all deltas from sync response - store handles idempotency
1346
- const newDeltas = payload.deltas;
1367
+ // Process all deltas from sync response - store handles idempotency.
1368
+ // Same receive-boundary normalization as handleDelta — catch-up
1369
+ // replays from older servers carry string ids too.
1370
+ const newDeltas = payload.deltas.map((d) => this.normalizeWireDelta(d));
1347
1371
  if (newDeltas.length > 0) {
1348
1372
  // DO NOT pre-advance `this.lastSyncId` here. Same reasoning as
1349
1373
  // `handleDelta`: the runtime cursor must stay consistent with
@@ -15,6 +15,20 @@
15
15
  export interface GrantTransport {
16
16
  subscribe(event: 'intent_acquired' | 'intent_granted' | 'intent_lost' | 'intent_queued', handler: (payload: Record<string, unknown>) => void): () => void;
17
17
  }
18
+ export interface IntentGrantInfo {
19
+ /**
20
+ * True when the grant arrived as `intent_granted` — i.e. the target was
21
+ * HELD when we asked and we waited in the FIFO line behind the holder.
22
+ * False for the immediate `intent_acquired` (target was free).
23
+ *
24
+ * Callers use this to know the row may have changed while we queued:
25
+ * intent VISIBILITY is entity-scoped (org-wide subscriptions receive no
26
+ * presence/intent fan-out — see Hub.broadcastPresenceChange), so the
27
+ * local coordination snapshot cannot be trusted to detect "we waited".
28
+ * The grant frame itself is the authoritative signal.
29
+ */
30
+ readonly waited: boolean;
31
+ }
18
32
  export declare function awaitIntentGrant(transport: GrantTransport, intentId: string, options?: {
19
33
  timeoutMs?: number;
20
34
  /**
@@ -23,4 +37,4 @@ export declare function awaitIntentGrant(transport: GrantTransport, intentId: st
23
37
  * already ahead of us). Omit to wait however deep the queue is.
24
38
  */
25
39
  maxQueueDepth?: number;
26
- }): Promise<void>;
40
+ }): Promise<IntentGrantInfo>;
@@ -24,15 +24,17 @@ export function awaitIntentGrant(transport, intentId, options) {
24
24
  u();
25
25
  fn();
26
26
  };
27
- const onGrant = (p) => {
28
- if (p?.intentId === intentId)
29
- settle(resolve);
30
- };
31
27
  // The target was free → `intent_acquired` (immediate); it was contended,
32
28
  // we waited in line, and reached the head → `intent_granted`. Either frame
33
- // means the lease is now ours, so one await covers both grant paths.
34
- unsubs.push(transport.subscribe('intent_acquired', onGrant));
35
- unsubs.push(transport.subscribe('intent_granted', onGrant));
29
+ // means the lease is now ours; `waited` records which path it was.
30
+ unsubs.push(transport.subscribe('intent_acquired', (p) => {
31
+ if (p?.intentId === intentId)
32
+ settle(() => resolve({ waited: false }));
33
+ }));
34
+ unsubs.push(transport.subscribe('intent_granted', (p) => {
35
+ if (p?.intentId === intentId)
36
+ settle(() => resolve({ waited: true }));
37
+ }));
36
38
  if (options?.maxQueueDepth !== undefined) {
37
39
  const max = options.maxQueueDepth;
38
40
  unsubs.push(transport.subscribe('intent_queued', (p) => {
@@ -852,7 +852,17 @@ export class TransactionQueue extends EventEmitter {
852
852
  // LINEAR PATTERN: Simplified coalescing for updates
853
853
  // Staging already batches all transactions in same event loop tick
854
854
  // We only need to handle: (1) in-flight merging, (2) same-entity merging
855
- if (transaction.type === 'update') {
855
+ //
856
+ // RETRIES (attempts > 0) must NEVER take either merge path. A retry is
857
+ // re-enqueued from `handleFailure` while its own modelKey is still in
858
+ // `inFlightByModel` (the post-batch cleanup runs after the catch), so the
859
+ // in-flight merge mistook the retry for a concurrent user edit: it moved
860
+ // the data into `pendingMergeByModel`, REMOVED the transaction, and the
861
+ // post-batch block minted a fresh follow-up with `attempts: 0`. The
862
+ // attempt counter never accumulated and `maxRetries` never tripped — an
863
+ // infinite ~`batchDelay` resend storm of self-laundering transaction
864
+ // clones (found by the claims journey, 2026-06-10).
865
+ if (transaction.type === 'update' && transaction.attempts === 0) {
856
866
  const preserveWatermark = hasStaleWriteOptions(transaction.writeOptions);
857
867
  // If there is an in-flight update for this model, merge into post-flight buffer
858
868
  if (!preserveWatermark && this.inFlightByModel.has(modelKey)) {
@@ -1606,25 +1616,40 @@ export class TransactionQueue extends EventEmitter {
1606
1616
  await this.rollbackOptimistic(transaction, 'permanent_error', error);
1607
1617
  }
1608
1618
  this.emit('transaction:failed', { transaction, error, permanent: true });
1619
+ // The id-suffixed event is what `waitForConfirmation` (the
1620
+ // `wait:'confirmed'` path) listens on — without it a permanently
1621
+ // rejected write left the caller's promise hanging forever.
1622
+ this.emit(`transaction:failed:${transaction.id}`, { error });
1609
1623
  return;
1610
1624
  }
1611
1625
  if (transaction.attempts < this.config.maxRetries) {
1612
- // Backoff for retryable server responses (HTTP 429/503).
1613
- // Exponential with jitter, capped tunable via
1614
- // `TransactionQueueConfig.retryBackoff`.
1626
+ // Exponential backoff with FULL jitter for EVERY transient retry
1627
+ // (AWS-standard shape: `sleep = random(0, min(cap, base × 2^attempt))`
1628
+ // — see the Exponential Backoff And Jitter analysis). Throttling
1629
+ // signals (429/503) get a longer base, mirroring the AWS SDK's
1630
+ // transient-vs-throttle split. Previously only 429/503 backed off
1631
+ // AND the sleep blocked the whole batch loop — every other failure
1632
+ // retried at raw `batchDelay` cadence. The re-enqueue is scheduled
1633
+ // (never awaited) so one backing-off transaction can't stall
1634
+ // unrelated commits.
1635
+ const { baseMs, capMs } = this.config.retryBackoff;
1636
+ let base = baseMs;
1615
1637
  try {
1616
1638
  const status = extractStatusCode(error);
1617
- if (status === 429 || status === 503) {
1618
- const { baseMs, capMs } = this.config.retryBackoff;
1619
- const delay = Math.min(capMs, Math.floor(baseMs * Math.pow(2, transaction.attempts - 1)));
1620
- const jitter = Math.floor(Math.random() * 100);
1621
- await new Promise((r) => setTimeout(r, delay + jitter));
1622
- }
1639
+ if (status === 429 || status === 503)
1640
+ base = Math.max(baseMs, 1_000);
1623
1641
  }
1624
1642
  catch { }
1625
- // Retry
1643
+ const ceiling = Math.min(capMs, base * Math.pow(2, transaction.attempts - 1));
1644
+ const delay = Math.floor(Math.random() * ceiling);
1626
1645
  this.store.updateStatus(transaction.id, 'pending');
1627
- this.enqueue(transaction);
1646
+ setTimeout(() => {
1647
+ // The queue may have shut down or the tx may have been settled
1648
+ // (e.g. delta-confirmed) while we backed off.
1649
+ if (this.store.get(transaction.id)?.status !== 'pending')
1650
+ return;
1651
+ this.enqueue(transaction);
1652
+ }, delay);
1628
1653
  }
1629
1654
  else {
1630
1655
  // Mark as failed and rollback
@@ -1633,6 +1658,8 @@ export class TransactionQueue extends EventEmitter {
1633
1658
  await this.rollbackOptimistic(transaction, 'max_retries_exhausted', error);
1634
1659
  }
1635
1660
  this.emit('transaction:failed', { transaction, error });
1661
+ // Settle `waitForConfirmation` waiters (see the permanent branch above).
1662
+ this.emit(`transaction:failed:${transaction.id}`, { error });
1636
1663
  }
1637
1664
  }
1638
1665
  /**
@@ -7,17 +7,19 @@ is the system of record — Ablo never hosts your data. It is the transaction
7
7
  layer on top: it registers your connection, commits every write there behind
8
8
  row-level security, and fans the confirmed rows out to every connected client.
9
9
 
10
- ## 1. Install and get a key
10
+ ## 1. Install and initialize
11
11
 
12
12
  ```bash
13
13
  npm install @abloatai/ablo
14
- npx ablo login
14
+ npx ablo init
15
15
  ```
16
16
 
17
- `ablo login` opens the browser sign in (or sign up) and a `sk_test_` key is
18
- saved locally for the CLI. Later, `npx ablo dev` (step 4) writes
19
- `ABLO_API_KEY` into your `.env.local` so the SDK finds it too no manual
20
- copy-paste. In CI, or to manage it by hand, set it yourself instead:
17
+ `ablo init` scaffolds your project (next step shows what it creates) and ends
18
+ by signing you in one browser click, and a `sk_test_` key is saved locally
19
+ for the CLI. Later, `npx ablo push` (step 4) writes `ABLO_API_KEY` into your
20
+ `.env.local` so the SDK finds it too no manual copy-paste. `npx ablo login`
21
+ also exists standalone. In CI, or to manage the key by hand, set it yourself
22
+ instead:
21
23
 
22
24
  ```bash
23
25
  export ABLO_API_KEY=sk_test_...
@@ -28,7 +30,7 @@ except both point at databases *you* own: `sk_test_*` for your dev database,
28
30
  `sk_live_*` for production. There is no keyless mode; the public `/sandbox` page
29
31
  is a hosted demo, not your app.
30
32
 
31
- ## 2. Declare your Ablo schema
33
+ ## 2. Your Ablo schema (init scaffolded it)
32
34
 
33
35
  The schema is the contract — it generates `ablo.<model>` methods for app code,
34
36
  server actions, agents, and React reads. Declare **only the synced models** Ablo
@@ -78,10 +80,11 @@ tenant isolation with row-level security, so the server rejects superuser or
78
80
 
79
81
  > **Neon / Supabase note:** the connection string those dashboards hand you
80
82
  > uses the database OWNER role (e.g. `neondb_owner`), which is `BYPASSRLS` —
81
- > Ablo will reject it. You don't have to fix that by hand: `npx ablo migrate`
82
- > detects the unsafe role and offers to create the scoped one for you — from
83
- > your machine, so the owner credential never reaches Ablo. It writes the new
84
- > `DATABASE_URL` into your env file (the generated password is never printed).
83
+ > Ablo will reject it. You don't have to fix that by hand: `npx ablo dev`
84
+ > (next step) detects the unsafe role and offers to create the scoped one for
85
+ > you — from your machine, so the owner credential never reaches Ablo. It
86
+ > writes the new `DATABASE_URL` into your env file (the generated password is
87
+ > never printed).
85
88
  >
86
89
  > Prefer to do it manually? The equivalent SQL:
87
90
  >
@@ -101,16 +104,25 @@ built from an ORM adapter instead — same product, same writes, see
101
104
  [Connect Your Database](./data-sources.md). In that setup, omit `databaseUrl`
102
105
  from `Ablo(...)`.
103
106
 
104
- ## 4. Provision your tables, then push the schema
107
+ ## 4. Push — Ablo provisions your tables for you
105
108
 
106
109
  ```bash
107
- npx ablo migrate # creates your synced-model tables (with row-level security)
108
- # in YOUR database — your other tables are left untouched
109
- npx ablo dev # pushes the schema (sandbox), writes ABLO_API_KEY to
110
- # .env.local, and re-pushes on every save — the dev loop
110
+ npx ablo push # checks your DATABASE_URL role, pushes the schema (sandbox),
111
+ # provisions your synced-model tables (with row-level
112
+ # security) IN YOUR database, and writes ABLO_API_KEY to
113
+ # .env.local. Add --watch to re-push on every save.
111
114
  ```
112
115
 
113
- `ablo dev` (or one-shot `npx ablo push`) uploads the schema *definition*
116
+ Nothing runs locally there is no dev server to start. Your app talks to
117
+ Ablo's hosted API with the sandbox key; the rows land in your database.
118
+
119
+ There is no separate migration step: the push provisions your synced-model
120
+ tables in the registered database server-side — your other tables are left
121
+ untouched. (`npx ablo migrate` still exists for the signed Data Source
122
+ endpoint mode, where Ablo never touches your database and DDL must run from
123
+ your side.)
124
+
125
+ `ablo push` uploads the schema *definition* —
114
126
  model names, fields, types. That metadata is the only thing Ablo keeps; the
115
127
  rows stay in your database. Skipping the push makes every write to a new or
116
128
  changed model fail with `server_execute_unknown_model` — that error literally
package/llms-full.txt CHANGED
@@ -201,7 +201,7 @@ Use these public environment names:
201
201
 
202
202
  - `ABLO_API_KEY` — SDK authentication for app and agent code. Where it comes
203
203
  from: the human runs `npx ablo login` once (browser; an agent must not run
204
- it), and `npx ablo dev` then writes `ABLO_API_KEY=sk_test_…` into
204
+ it), and `npx ablo push` then writes `ABLO_API_KEY=sk_test_…` into
205
205
  `.env.local` automatically (and gitignores it). Check the environment and
206
206
  `.env.local` for PRESENCE only (`grep -cq '^ABLO_API_KEY=' .env.local`)
207
207
  before asking the human for a key — never print or echo the key value; a
package/llms.txt CHANGED
@@ -12,7 +12,7 @@ First action when integrating into an app: run `npx ablo init --yes --framework
12
12
 
13
13
  Second: make sure a key exists — WITHOUT printing it. The key is a secret; it must never appear in your output, your reasoning, or a file you echo (it would live in the conversation history forever). Check PRESENCE only: `[ -n "$ABLO_API_KEY" ] && echo set` and `grep -cq '^ABLO_API_KEY=' .env.local && echo wired` — never `cat .env.local`, never `echo $ABLO_API_KEY`. If neither check passes, ask the HUMAN to run `npx ablo login` once — it opens a browser and saves a `sk_test_` key locally; an agent must NOT run it. You never copy the key by hand: the next step writes it into `.env.local` (and gitignores it) for you.
14
14
 
15
- Then PUSH — this is the step everything depends on. The server keeps its OWN copy of the schema. Run `npx ablo dev --no-watch`: it pushes `ablo/schema.ts` (sandbox) AND writes `ABLO_API_KEY` into `.env.local` from the stored login. Until the schema is pushed, EVERY write to a new or changed model fails with `server_execute_unknown_model`. Re-run it after schema changes (`npx ablo push` also works once the key is wired; bare `npx ablo dev` watches forever — don't, you have no TTY).
15
+ Then PUSH — this is the step everything depends on. The server keeps its OWN copy of the schema. Run `npx ablo push --no-watch`: it pushes `ablo/schema.ts` (sandbox) AND writes `ABLO_API_KEY` into `.env.local` from the stored login. Until the schema is pushed, EVERY write to a new or changed model fails with `server_execute_unknown_model`. Re-run it after schema changes (`npx ablo push` also works once the key is wired; bare `npx ablo push` watches forever — don't, you have no TTY).
16
16
 
17
17
  ## Use this API
18
18
 
@@ -179,8 +179,8 @@ Do not teach `/api`, `/agent`, `/ai-sdk`, `/core`, `/realtime`, or internal subp
179
179
  `ablo init` and other prompts need a TTY; an agent/CI run has none and will HANG. Always:
180
180
 
181
181
  - `npx ablo init --yes` (flags: `--framework`, `--auth`, `--storage`, `--no-agent`, `--no-pull`, `--no-install`, `--no-login`). Generates `ablo/schema.ts` + the `ablo/data-source.ts` endpoint above.
182
- - Key: see "Start here" — env → `.env.local` → ask the human to `npx ablo login`; never run `login` yourself, never copy keys by hand (`ablo dev` writes `.env.local`).
182
+ - Key: see "Start here" — env → `.env.local` → ask the human to `npx ablo login`; never run `login` yourself, never copy keys by hand (`ablo push` writes `.env.local`).
183
183
  - Adopt an existing DB: `npx ablo pull prisma [path]` / `npx ablo pull drizzle <module>`.
184
- - `npx ablo dev --no-watch` pushes the schema (sandbox) AND writes `ABLO_API_KEY` to `.env.local` (default watches forever); `npx ablo logs --no-follow` (default tails forever); `npx ablo mode sandbox|production` (always pass the arg). `npx ablo push`/`status`/`pull`/`check`/`generate` are one-shot.
184
+ - `npx ablo push --no-watch` pushes the schema (sandbox) AND writes `ABLO_API_KEY` to `.env.local` (default watches forever); `npx ablo logs --no-follow` (default tails forever); `npx ablo mode sandbox|production` (always pass the arg). `npx ablo push`/`status`/`pull`/`check`/`generate` are one-shot.
185
185
 
186
186
  Canonical docs to read before integrating: `quickstart`, `schema-contract`, `integration-guide`, `guarantees`, `client-behavior`, `data-sources`, `examples/existing-python-backend`, `api`, `examples/ai-sdk-tool`, and `examples/server-agent`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "State control API for AI agents and collaborative apps.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",