@abloatai/ablo 0.9.2 → 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.
Files changed (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +47 -27
  4. package/dist/BaseSyncedStore.d.ts +7 -38
  5. package/dist/BaseSyncedStore.js +20 -67
  6. package/dist/Database.js +7 -1
  7. package/dist/NetworkMonitor.js +4 -1
  8. package/dist/SyncClient.d.ts +18 -5
  9. package/dist/SyncClient.js +72 -1
  10. package/dist/SyncEngineContext.js +5 -1
  11. package/dist/auth/index.js +3 -1
  12. package/dist/cli.cjs +282241 -0
  13. package/dist/client/Ablo.d.ts +12 -3
  14. package/dist/client/Ablo.js +36 -3
  15. package/dist/client/ApiClient.js +39 -6
  16. package/dist/client/auth.d.ts +1 -1
  17. package/dist/client/auth.js +14 -5
  18. package/dist/client/createInternalComponents.js +1 -1
  19. package/dist/client/createModelProxy.d.ts +9 -0
  20. package/dist/client/createModelProxy.js +34 -10
  21. package/dist/client/persistence.d.ts +6 -1
  22. package/dist/client/persistence.js +1 -1
  23. package/dist/client/registerDataSource.d.ts +4 -4
  24. package/dist/client/registerDataSource.js +39 -31
  25. package/dist/client/writeOptionsSchema.d.ts +50 -0
  26. package/dist/client/writeOptionsSchema.js +57 -0
  27. package/dist/core/index.d.ts +18 -26
  28. package/dist/core/index.js +22 -46
  29. package/dist/errorCodes.d.ts +13 -0
  30. package/dist/errorCodes.js +16 -1
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +7 -0
  33. package/dist/interfaces/index.d.ts +10 -0
  34. package/dist/mutators/UndoManager.d.ts +31 -5
  35. package/dist/mutators/UndoManager.js +113 -1
  36. package/dist/schema/ddl.js +12 -3
  37. package/dist/schema/field.js +2 -1
  38. package/dist/schema/model.d.ts +9 -7
  39. package/dist/schema/model.js +1 -1
  40. package/dist/schema/schema.js +7 -1
  41. package/dist/schema/serialize.js +2 -1
  42. package/dist/server/storage-mode.d.ts +7 -0
  43. package/dist/server/storage-mode.js +6 -0
  44. package/dist/source/adapters/drizzle.js +3 -2
  45. package/dist/source/adapters/kysely.d.ts +68 -0
  46. package/dist/source/adapters/kysely.js +210 -0
  47. package/dist/source/adapters/memory.js +2 -1
  48. package/dist/source/adapters/prisma.js +3 -2
  49. package/dist/source/index.js +2 -1
  50. package/dist/sync/syncPosition.d.ts +78 -0
  51. package/dist/sync/syncPosition.js +111 -0
  52. package/dist/transactions/TransactionQueue.d.ts +22 -8
  53. package/dist/transactions/TransactionQueue.js +76 -34
  54. package/dist/utils/duration.js +3 -2
  55. package/docs/api-keys.md +4 -4
  56. package/docs/cli.md +6 -6
  57. package/docs/client-behavior.md +1 -1
  58. package/docs/data-sources.md +61 -42
  59. package/docs/guarantees.md +2 -2
  60. package/docs/index.md +2 -2
  61. package/docs/integration-guide.md +4 -7
  62. package/docs/mcp.md +1 -1
  63. package/docs/quickstart.md +84 -37
  64. package/docs/schema-contract.md +2 -4
  65. package/llms-full.txt +365 -0
  66. package/llms.txt +14 -9
  67. package/package.json +26 -4
@@ -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
@@ -117,13 +118,31 @@ function hasStaleWriteOptions(options) {
117
118
  return (options?.readAt !== undefined ||
118
119
  options?.onStale !== undefined);
119
120
  }
120
- function applyStaleWriteOptions(op, transaction) {
121
+ /**
122
+ * Project a transaction's `writeOptions` onto the wire operation. Stale
123
+ * guards (`readAt`/`onStale`) ride at the op root; `idempotencyKey`/`label`
124
+ * ride in the op's `options` slot (`MutationOperation.options` — the
125
+ * mutation_log cache key + audit tag). This is the single place the
126
+ * caller-supplied write vocabulary crosses onto the wire.
127
+ */
128
+ function applyWriteOptions(op, transaction) {
121
129
  const operation = op;
122
- if (transaction.writeOptions?.readAt !== undefined) {
123
- operation.readAt = transaction.writeOptions.readAt;
124
- }
125
- if (transaction.writeOptions?.onStale !== undefined) {
126
- operation.onStale = transaction.writeOptions.onStale;
130
+ const writeOptions = transaction.writeOptions;
131
+ if (!writeOptions)
132
+ return operation;
133
+ if (writeOptions.readAt !== undefined) {
134
+ operation.readAt = writeOptions.readAt;
135
+ }
136
+ if (writeOptions.onStale !== undefined) {
137
+ operation.onStale = writeOptions.onStale;
138
+ }
139
+ if (writeOptions.idempotencyKey != null || writeOptions.label !== undefined) {
140
+ operation.options = {
141
+ ...(writeOptions.idempotencyKey != null
142
+ ? { idempotencyKey: writeOptions.idempotencyKey }
143
+ : {}),
144
+ ...(writeOptions.label !== undefined ? { label: writeOptions.label } : {}),
145
+ };
127
146
  }
128
147
  return operation;
129
148
  }
@@ -305,10 +324,21 @@ export class TransactionQueue extends EventEmitter {
305
324
  // cleared on `'connected'`. The reconnect-retry behavior of the queue
306
325
  // is preserved for brief blips; this only catches persistent disconnects.
307
326
  commitOfflineGraceTimer = null;
308
- // Track the highest syncId received from WebSocket deltas
309
- // Used to immediately confirm transactions when HTTP response arrives AFTER the delta
310
- // (fixes race condition where WebSocket delta arrives before HTTP response)
311
- 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
+ }
312
342
  // Delta confirmation retry config (Replicache-style exponential backoff)
313
343
  // Max retries before requesting full reconciliation
314
344
  static DELTA_MAX_RETRIES = 5;
@@ -328,6 +358,7 @@ export class TransactionQueue extends EventEmitter {
328
358
  confirmationResolvers = new Map();
329
359
  constructor(config) {
330
360
  super();
361
+ this.position = config?.position ?? new SyncPosition();
331
362
  if (config) {
332
363
  this.config = { ...this.config, ...config };
333
364
  }
@@ -552,7 +583,7 @@ export class TransactionQueue extends EventEmitter {
552
583
  // Build operations list
553
584
  const operations = pending.map((tx) => {
554
585
  this.ensureDerivedFields(tx);
555
- return applyStaleWriteOptions({
586
+ return applyWriteOptions({
556
587
  type: TX_TYPE_TO_MUTATION_OP[tx.type],
557
588
  model: tx.modelKey,
558
589
  id: tx.modelId,
@@ -930,7 +961,7 @@ export class TransactionQueue extends EventEmitter {
930
961
  // matches it via `OptimisticEchoTracker.consumeEcho` to suppress
931
962
  // double-applying optimistic mutations. Distinct from the
932
963
  // batch-level idempotency key in mutation_log.
933
- const op = applyStaleWriteOptions({
964
+ const op = applyWriteOptions({
934
965
  type: TX_TYPE_TO_MUTATION_OP[tx.type],
935
966
  model: tx.modelKey,
936
967
  id: tx.modelId,
@@ -954,6 +985,7 @@ export class TransactionQueue extends EventEmitter {
954
985
  // the coalescing test's tight bound on batch count.
955
986
  const result = await this.mutationExecutor.commit(operations);
956
987
  const lastSyncId = result?.lastSyncId ?? 0;
988
+ this.noteAck(lastSyncId);
957
989
  // Detect server bug: lastSyncId 0 means mutation succeeded but no sync delta was emitted
958
990
  if (lastSyncId === 0) {
959
991
  getContext().observability.captureCommitZeroSyncId({
@@ -981,34 +1013,44 @@ export class TransactionQueue extends EventEmitter {
981
1013
  });
982
1014
  continue;
983
1015
  }
984
- // FIX: Check if delta already arrived before HTTP response (race condition)
985
- // WebSocket can be faster than HTTP, so the delta might already be here
986
- // Guard: only do immediate confirm if lastSyncId > 0 (valid server response)
987
- if (lastSyncId > 0 && this.lastSeenSyncId >= lastSyncId) {
988
- // 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) {
989
1028
  this.store.updateStatus(tx.id, 'completed');
990
1029
  this.emit('transaction:completed', tx);
991
1030
  this.emit(`transaction:completed:${tx.id}`, tx);
992
1031
  this.optimisticUpdates.delete(tx.id);
993
- getContext().logger.debug('tx:confirm_immediate', {
1032
+ getContext().logger.debug('tx:confirm_ack', {
994
1033
  txId: tx.id.slice(0, 8),
995
1034
  model: tx.modelName,
996
- neededSyncId: lastSyncId,
1035
+ serverSyncId: lastSyncId,
997
1036
  lastSeenSyncId: this.lastSeenSyncId,
998
- reason: 'delta_arrived_before_http',
999
1037
  });
1000
1038
  }
1001
1039
  else {
1002
- // 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.
1003
1046
  this.store.updateStatus(tx.id, 'awaiting_delta');
1004
1047
  getContext().logger.debug('tx:awaiting_delta', {
1005
1048
  txId: tx.id.slice(0, 8),
1006
1049
  model: tx.modelName,
1007
1050
  neededSyncId: lastSyncId,
1008
1051
  lastSeenSyncId: this.lastSeenSyncId,
1009
- gap: lastSyncId - this.lastSeenSyncId,
1052
+ reason: 'zero_sync_id_anomaly',
1010
1053
  });
1011
- // Schedule timeout-based rollback for unconfirmed transactions
1012
1054
  this.scheduleDeltaConfirmationTimeout(tx, this.config.deltaConfirmationTimeout);
1013
1055
  }
1014
1056
  }
@@ -1135,16 +1177,9 @@ export class TransactionQueue extends EventEmitter {
1135
1177
  * @param syncId - The sync ID of the received delta
1136
1178
  */
1137
1179
  onDeltaReceived(syncId) {
1138
- const prevLastSeen = this.lastSeenSyncId;
1139
- // Track highest syncId seen (fixes race: delta arrives before HTTP response)
1140
- if (syncId > this.lastSeenSyncId) {
1141
- this.lastSeenSyncId = syncId;
1142
- getContext().logger.debug('tx:highwater_update', {
1143
- prev: prevLastSeen,
1144
- new: syncId,
1145
- delta: syncId - prevLastSeen,
1146
- });
1147
- }
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.
1148
1183
  const awaitingTxs = this.store.getByStatus('awaiting_delta');
1149
1184
  const executingTxs = this.store.getByStatus('executing');
1150
1185
  // Debug: Show state when delta arrives
@@ -1358,6 +1393,12 @@ export class TransactionQueue extends EventEmitter {
1358
1393
  };
1359
1394
  this.commitStore.set(clientTxId, tx);
1360
1395
  this.commitLane.push(tx);
1396
+ // Surface the envelope on its OWN event so the undo stream can record
1397
+ // commit-lane writes too (`SyncClient.onLocalTransaction` enriches each
1398
+ // operation with pool-captured previous state). Deliberately NOT
1399
+ // `transaction:created` — that event also feeds the optimistic-echo
1400
+ // tracker, and commit-lane ops have no optimistic pool apply to echo.
1401
+ this.emit('commit:created', { clientTxId, operations: tx.operations });
1361
1402
  void this.processCommitLane();
1362
1403
  }
1363
1404
  /**
@@ -1385,6 +1426,7 @@ export class TransactionQueue extends EventEmitter {
1385
1426
  causedByTaskId: tx.causedByTaskId ?? undefined,
1386
1427
  });
1387
1428
  tx.lastSyncId = result?.lastSyncId ?? 0;
1429
+ this.noteAck(tx.lastSyncId);
1388
1430
  tx.status = 'completed';
1389
1431
  this.commitLane.shift();
1390
1432
  this.emit('transaction:completed', tx);
@@ -1674,7 +1716,7 @@ export class TransactionQueue extends EventEmitter {
1674
1716
  const input = (type === 'create' || type === 'update') ? data : undefined;
1675
1717
  try {
1676
1718
  await this.mutationExecutor.commit([
1677
- applyStaleWriteOptions({ type: mutationType, model, id: modelId, input }, transaction),
1719
+ applyWriteOptions({ type: mutationType, model, id: modelId, input }, transaction),
1678
1720
  ]);
1679
1721
  }
1680
1722
  catch (error) {
@@ -16,6 +16,7 @@
16
16
  * without breaking numeric callers — the wrapper below branches on
17
17
  * the input type.
18
18
  */
19
+ import { AbloValidationError } from '../errors.js';
19
20
  const PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/;
20
21
  const UNIT_MS = {
21
22
  ms: 1,
@@ -34,8 +35,8 @@ export function toMs(input) {
34
35
  return input * 1_000;
35
36
  const match = PATTERN.exec(input);
36
37
  if (!match) {
37
- throw new Error(`Invalid duration "${input}" — expected number (seconds) or ` +
38
- `a string like "500ms" | "30s" | "3m" | "24h".`);
38
+ throw new AbloValidationError(`Invalid duration "${input}" — expected number (seconds) or ` +
39
+ `a string like "500ms" | "30s" | "3m" | "24h".`, { code: 'duration_invalid' });
39
40
  }
40
41
  const value = Number(match[1]);
41
42
  const unit = match[2];
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` |
@@ -30,7 +30,7 @@ Common options:
30
30
  | `schema` | Required for typed model clients. |
31
31
  | `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
32
32
  | `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
33
- | `persistence` | `volatile` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
33
+ | `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
34
34
  | `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
35
35
  | `defaultHeaders` | Extra headers attached to every HTTP request. |
36
36
  | `defaultQuery` | Extra query parameters attached to every HTTP request. |
@@ -1,14 +1,14 @@
1
1
  # Connect Your Database
2
2
 
3
- By default, Ablo stores the rows for the models you define, so you don't need a
4
- database to get started. But if you already have your own application database
5
- and want it to stay the source of truth, you can attach it as a Data Source —
6
- then Ablo coordinates each write and calls your app to commit it, instead of
7
- storing the data itself.
3
+ **Your database is the system of record Ablo never hosts your data.** Every
4
+ synced model is backed by your own Postgres; Ablo is the transaction layer on
5
+ top of it. There are two ways to connect, and they are the same product with the
6
+ same writes the only difference is where your database credential lives:
8
7
 
9
- That default makes Ablo the managed state store for your models, the same way
10
- Stripe stores `Customer` and `PaymentIntent` objects that you create through
11
- Stripe's API.
8
+ | | How Ablo reaches your Postgres | Use when |
9
+ |---|---|---|
10
+ | **Connection string** (default) | You pass `databaseUrl` to `Ablo(...)`; Ablo registers the connection and commits each write directly, behind row-level security. | You can hand over a scoped connection string. |
11
+ | **Signed endpoint** | Your app exposes one route built from an ORM adapter; Ablo sends signed commit requests and your app writes its own database. | Database credentials must never leave your infrastructure. |
12
12
 
13
13
  Either way, you define an Ablo schema with `defineSchema`, `model`, and Zod. The
14
14
  Ablo schema describes **only your synced, collaborative models** — the rows Ablo
@@ -16,17 +16,18 @@ coordinates and fans out in realtime. It is *not* your whole-database schema and
16
16
  does *not* replace your `schema.prisma` (or your Drizzle schema). Your auth,
17
17
  billing, and any other non-synced tables stay in your own ORM schema, owned by
18
18
  your own migrations. One database, two schemas, side by side: Ablo owns the
19
- synced models (plus the small `ablo_outbox` / `ablo_idempotency` bookkeeping
20
- tables its adapter needs); you keep owning everything else. `ablo check` reflects
21
- this — it reports your other tables as "ignored / owned by you," which is exactly
22
- right.
19
+ synced models; you keep owning everything else. `ablo check` reflects this — it
20
+ reports your other tables as "ignored / owned by you," which is exactly right.
23
21
 
24
- Your app can keep using its own `DATABASE_URL`. Store that value in your app or
25
- backend environment, not in Ablo. The integration boundary is the HTTPS
26
- endpoint your app exposes. The happy path uses the same server-side
27
- `ABLO_API_KEY` to verify Ablo calls.
22
+ What Ablo stores, in both shapes: your schema *definition* (model names, fields,
23
+ types — pushed with `ablo push`), your hashed API keys, a safe projection of the
24
+ connection registration (host, database, schema the connection string itself
25
+ is sealed and never echoed back), and the commit log that drives sync. Never
26
+ your rows.
28
27
 
29
- Use the SDK with an API key:
28
+ ## Connection String (default)
29
+
30
+ The canonical client carries all three values:
30
31
 
31
32
  ```ts
32
33
  import Ablo from '@abloatai/ablo';
@@ -35,29 +36,50 @@ import { schema } from './ablo/schema';
35
36
  export const ablo = Ablo({
36
37
  schema,
37
38
  apiKey: process.env.ABLO_API_KEY,
39
+ databaseUrl: process.env.DATABASE_URL, // your Postgres — rows live here, never with Ablo
38
40
  });
39
41
  ```
40
42
 
41
- Do not pass a database URL to `Ablo(...)`.
42
-
43
- For the first production integration, prefer this shape:
44
-
45
43
  ```bash
46
- # Stored only in your app/backend
47
- DATABASE_URL=postgres://...
48
-
49
- # The only Ablo credential in the customer app
44
+ # .env — server runtime only, never the browser
45
+ DATABASE_URL=postgres://ablo_app:...@host:5432/db
50
46
  ABLO_API_KEY=sk_live_...
51
47
  ```
52
48
 
53
- ## Backing Modes
49
+ On first connect the SDK registers the connection — sent once over TLS, stored
50
+ sealed, never returned by any API. From then on Ablo commits every confirmed
51
+ write directly to your database and reads canonical rows from it.
52
+
53
+ Safety requirements, enforced server-side before the first write:
54
+
55
+ - **Non-superuser role.** The connection must not be a superuser or hold
56
+ `BYPASSRLS` — Ablo's tenant isolation is row-level security, and a role that
57
+ can bypass it is rejected outright.
58
+ - **Row-level security on synced tables.** `npx ablo migrate` provisions your
59
+ synced-model tables with `FORCE ROW LEVEL SECURITY` already applied; tables
60
+ you create yourself must do the same.
61
+ - **Public hosts only.** Connection strings resolving to loopback or private
62
+ address ranges are rejected.
63
+
64
+ `databaseUrl` is server-only: the SDK throws if it sees one in a browser-like
65
+ environment, and `dangerouslyAllowBrowser` does not override that.
66
+
67
+ ## Signed Endpoint
54
68
 
55
- | Mode | Where rows live | What `create/update/delete` does | Use when |
56
- |---|---|---|---|
57
- | Ablo-managed | Ablo | Writes directly to Ablo's managed state store, then returns the confirmed row and fans out realtime deltas. | New collaborative/agent state that can live in Ablo. |
58
- | Data Source | Your app database | Sends a signed commit request to your route; your app writes its DB and returns canonical rows. | Existing app tables, regulated data, or teams that need their DB to stay canonical. |
69
+ When a connection string must not leave your infrastructure, keep
70
+ `DATABASE_URL` in your app and expose one HTTPS endpoint instead. Ablo signs a
71
+ commit request; an ORM adapter in your route runs it in one transaction against
72
+ your Postgres and returns the canonical rows. Omit `databaseUrl` from
73
+ `Ablo(...)` in this setup — the client takes only the schema and the API key:
59
74
 
60
- The SDK call is the same in both modes:
75
+ ```ts
76
+ export const ablo = Ablo({
77
+ schema,
78
+ apiKey: process.env.ABLO_API_KEY,
79
+ });
80
+ ```
81
+
82
+ The SDK call is identical in both shapes:
61
83
 
62
84
  ```ts
63
85
  await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' } });
@@ -65,9 +87,7 @@ await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'read
65
87
  const report = ablo.weatherReports.get('report_stockholm');
66
88
  ```
67
89
 
68
- Only the backing store changes.
69
-
70
- Multiplayer behavior is the same in both modes. Writes made through
90
+ Multiplayer behavior is built in. Writes made through
71
91
  `ablo.<model>.create/update/delete` are coordinated by Ablo, then confirmed rows
72
92
  fan out to subscribers. If something writes to your database without going
73
93
  through Ablo (a cron job, an admin tool), Ablo can't know about it
@@ -75,10 +95,10 @@ automatically. To keep everyone's screen up to date, your app reports those
75
95
  outside changes back through the outbox feed — shown below in
76
96
  [Outbox Events](#outbox-events).
77
97
 
78
- ## When To Use A Data Source
98
+ ## Your Database Stays Canonical
79
99
 
80
- Use a Data Source only when your existing application database remains the
81
- source of truth and Ablo should coordinate writes against it.
100
+ Your application database remains the source of truth and Ablo coordinates writes
101
+ against it.
82
102
 
83
103
  If you are migrating an app where every button already calls a backend endpoint,
84
104
  read [Integration Guide](./integration-guide.md) first, then
@@ -221,9 +241,9 @@ cron job or admin tool that never went through Ablo. Append an `ablo_outbox` row
221
241
  (with no `clientTxId`) for those in the same transaction as the change, and the
222
242
  adapter's feed carries them to every connected screen.
223
243
 
224
- ## Production Checklist
244
+ ## Production Checklist (signed endpoint)
225
245
 
226
- Before using a customer-owned database in production:
246
+ Before using the signed-endpoint shape in production:
227
247
 
228
248
  - Keep `DATABASE_URL` in the customer app or backend environment.
229
249
  - Use only the Data Source endpoint and `ABLO_API_KEY` as the customer-facing integration boundary.
@@ -239,9 +259,8 @@ transaction, `clientTxId` idempotency, returning canonical rows, the outbox
239
259
  append per operation, and deduping the feed by event `id`. You don't write any of
240
260
  that by hand.
241
261
 
242
- Don't give Ablo your database URL for this integration Ablo never connects to
243
- your database directly. (Direct database access would be a separate product with
244
- its own security model.)
262
+ In this shape, leave `databaseUrl` out of `Ablo(...)`the endpoint *is* the
263
+ connection, and registering both would point Ablo at your database twice.
245
264
 
246
265
  ## Security
247
266
 
@@ -109,7 +109,7 @@ authorized it, which run did it, and what state was it based on?"
109
109
 
110
110
  ## Persistence
111
111
 
112
- Ablo defaults to volatile in-memory persistence, so nothing is written to disk
112
+ Ablo defaults to in-memory persistence ('memory'), so nothing is written to disk
113
113
  unless you ask for it.
114
114
 
115
115
  Opt into a durable browser cache that survives reloads when you need it:
@@ -122,7 +122,7 @@ const ablo = Ablo({
122
122
  });
123
123
  ```
124
124
 
125
- Node, SSR, tests, and agents use volatile in-memory persistence automatically.
125
+ Node, SSR, tests, and agents use in-memory persistence ('memory') automatically.
126
126
 
127
127
  ## Storage Boundary
128
128
 
package/docs/index.md CHANGED
@@ -43,7 +43,7 @@ Three things stay true no matter how you use Ablo:
43
43
  - [Schema Contract](./schema-contract.md) — One schema becomes typed model clients, React reads, agent writes, Data Source shape, and schema push.
44
44
  - [CLI & Migrations](./cli.md) — `init` / `migrate` / `push` / `generate`, the shared Zod→Postgres type map, and structured migration errors.
45
45
  - [Identity & Sync Groups](./identity.md) — Use your own authentication; tell Ablo who's connecting and how org / team / user map to sync-group scope.
46
- - [Integration Guide](./integration-guide.md) — Choose Ablo-managed state, Data Source, React, multiplayer, and agent patterns.
46
+ - [Integration Guide](./integration-guide.md) — Connect your database via Data Source, plus React, multiplayer, and agent patterns.
47
47
  - [Guarantees](./guarantees.md) — What confirmed writes, stale checks, and claims guarantee.
48
48
  - [Interaction Model](./interaction-model.md) — The schema, claim, update, confirmation loop.
49
49
  - [API Reference](./api.md) — Model-by-model method shape.
@@ -57,7 +57,7 @@ Three things stay true no matter how you use Ablo:
57
57
  | Plane | Primitives | Purpose |
58
58
  |---|---|---|
59
59
  | State | `Schema`, `Model`, `Claim`, `Receipt` | The product path. Load, coordinate, write, confirm. |
60
- | Storage | `Managed State`, `Data Source` | Ablo stores declared models by default; existing app tables use a signed Data Source. |
60
+ | Storage | `Data Source` | Your rows live in your own database behind a signed Data Source endpoint. |
61
61
 
62
62
  ## Use cases
63
63
 
@@ -42,14 +42,11 @@ schema -> ablo.<model>.list(...) -> ablo.<model>.update(...)
42
42
  Commits and receipts exist under the hood. Most apps do not create protocol
43
43
  objects by hand.
44
44
 
45
- ## Pick The Backing Mode
45
+ ## Your Database
46
46
 
47
- Every schema model has a backing store. The SDK call shape stays the same.
48
-
49
- | Mode | Rows live in | Use when |
50
- | ------------ | ----------------- | -------------------------------------------------------------------------------- |
51
- | Ablo-managed | Ablo | New collaborative or agent-written state can live in Ablo. |
52
- | Data Source | Your app database | You already have tables, service logic, and API endpoints that remain canonical. |
47
+ Every schema model is backed by **your own database**. You expose a signed Data
48
+ Source endpoint; Ablo coordinates each write and your app commits it to your
49
+ Postgres. The SDK call shape is the same everywhere.
53
50
 
54
51
  Do not pass a database URL to `Ablo(...)`. Application and agent code use
55
52
  `ABLO_API_KEY`. If your database stays canonical, expose a signed Data Source
package/docs/mcp.md CHANGED
@@ -83,7 +83,7 @@ Per-client walkthroughs:
83
83
  | `get_recipe` | Returns the full markdown of one doc by name (e.g. `readme`, `quickstart`, `schema-contract`, `integration-guide`, `api`, `guarantees`). |
84
84
  | `get_api_surface` | Returns the structured export list for an SDK subpath (`@abloatai/ablo`, `./react`, `./schema`, `./testing`, …). Call with no argument to list every subpath. |
85
85
  | `validate_schema` | Lints `defineSchema` source against the DSL rules (camelCase fields, lowercase model keys, `scope`/`grants` sync groups, valid `load` strategies, no legacy builders) and returns a structured issue list. Runs no code. |
86
- | `scaffold_app` | Emits a starter file tree for a schema-first integration — `next`, `node-agent`, or `plain`, with `managed` or `data-source` storage. |
86
+ | `scaffold_app` | Emits a starter file tree for a schema-first integration — `next`, `node-agent`, or `plain`, with a `data-source` (your own database) endpoint. |
87
87
 
88
88
  #### Resources
89
89