@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +47 -27
- package/dist/BaseSyncedStore.d.ts +7 -38
- package/dist/BaseSyncedStore.js +20 -67
- package/dist/Database.js +7 -1
- package/dist/NetworkMonitor.js +4 -1
- package/dist/SyncClient.d.ts +18 -5
- package/dist/SyncClient.js +72 -1
- package/dist/SyncEngineContext.js +5 -1
- package/dist/auth/index.js +3 -1
- package/dist/cli.cjs +282241 -0
- package/dist/client/Ablo.d.ts +12 -3
- package/dist/client/Ablo.js +36 -3
- package/dist/client/ApiClient.js +39 -6
- package/dist/client/auth.d.ts +1 -1
- package/dist/client/auth.js +14 -5
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/createModelProxy.d.ts +9 -0
- package/dist/client/createModelProxy.js +34 -10
- package/dist/client/persistence.d.ts +6 -1
- package/dist/client/persistence.js +1 -1
- package/dist/client/registerDataSource.d.ts +4 -4
- package/dist/client/registerDataSource.js +39 -31
- package/dist/client/writeOptionsSchema.d.ts +50 -0
- package/dist/client/writeOptionsSchema.js +57 -0
- package/dist/core/index.d.ts +18 -26
- package/dist/core/index.js +22 -46
- package/dist/errorCodes.d.ts +13 -0
- package/dist/errorCodes.js +16 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/interfaces/index.d.ts +10 -0
- package/dist/mutators/UndoManager.d.ts +31 -5
- package/dist/mutators/UndoManager.js +113 -1
- package/dist/schema/ddl.js +12 -3
- package/dist/schema/field.js +2 -1
- package/dist/schema/model.d.ts +9 -7
- package/dist/schema/model.js +1 -1
- package/dist/schema/schema.js +7 -1
- package/dist/schema/serialize.js +2 -1
- package/dist/server/storage-mode.d.ts +7 -0
- package/dist/server/storage-mode.js +6 -0
- package/dist/source/adapters/drizzle.js +3 -2
- package/dist/source/adapters/kysely.d.ts +68 -0
- package/dist/source/adapters/kysely.js +210 -0
- package/dist/source/adapters/memory.js +2 -1
- package/dist/source/adapters/prisma.js +3 -2
- package/dist/source/index.js +2 -1
- package/dist/sync/syncPosition.d.ts +78 -0
- package/dist/sync/syncPosition.js +111 -0
- package/dist/transactions/TransactionQueue.d.ts +22 -8
- package/dist/transactions/TransactionQueue.js +76 -34
- package/dist/utils/duration.js +3 -2
- package/docs/api-keys.md +4 -4
- package/docs/cli.md +6 -6
- package/docs/client-behavior.md +1 -1
- package/docs/data-sources.md +61 -42
- package/docs/guarantees.md +2 -2
- package/docs/index.md +2 -2
- package/docs/integration-guide.md +4 -7
- package/docs/mcp.md +1 -1
- package/docs/quickstart.md +84 -37
- package/docs/schema-contract.md +2 -4
- package/llms-full.txt +365 -0
- package/llms.txt +14 -9
- 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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
operation.
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
|
|
988
|
-
|
|
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:
|
|
1032
|
+
getContext().logger.debug('tx:confirm_ack', {
|
|
994
1033
|
txId: tx.id.slice(0, 8),
|
|
995
1034
|
model: tx.modelName,
|
|
996
|
-
|
|
1035
|
+
serverSyncId: lastSyncId,
|
|
997
1036
|
lastSeenSyncId: this.lastSeenSyncId,
|
|
998
|
-
reason: 'delta_arrived_before_http',
|
|
999
1037
|
});
|
|
1000
1038
|
}
|
|
1001
1039
|
else {
|
|
1002
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1139
|
-
//
|
|
1140
|
-
|
|
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
|
-
|
|
1719
|
+
applyWriteOptions({ type: mutationType, model, id: modelId, input }, transaction),
|
|
1678
1720
|
]);
|
|
1679
1721
|
}
|
|
1680
1722
|
catch (error) {
|
package/dist/utils/duration.js
CHANGED
|
@@ -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
|
|
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
|
-
##
|
|
26
|
+
## Sandboxes and production
|
|
27
27
|
|
|
28
28
|
Test and live keys are the same shape; the prefix names the environment:
|
|
29
29
|
|
|
@@ -31,11 +31,11 @@ Test and live keys are the same shape; the prefix names the environment:
|
|
|
31
31
|
to that sandbox and are invisible to live keys (and to other sandboxes).
|
|
32
32
|
- `sk_live_…` — a key against your live data.
|
|
33
33
|
|
|
34
|
-
Every org has a default
|
|
34
|
+
Every org has a default sandbox, plus any number of additional
|
|
35
35
|
sandboxes you create. **Data is isolated per sandbox; the schema is shared
|
|
36
36
|
across the whole org.** A schema you push from a test key defines the same
|
|
37
37
|
models your live keys see — only the rows differ. This mirrors how Stripe
|
|
38
|
-
separates
|
|
38
|
+
separates sandbox and production data while keeping the API shape identical.
|
|
39
39
|
|
|
40
40
|
## Scopes
|
|
41
41
|
|
|
@@ -51,7 +51,7 @@ restricted to exactly those grants:
|
|
|
51
51
|
- `sandbox:<id>` — identifies which sandbox the key belongs to. (The key's data
|
|
52
52
|
isolation comes from that sandbox binding, not from this scope string.)
|
|
53
53
|
|
|
54
|
-
A key minted from the default
|
|
54
|
+
A key minted from the default sandbox carries `schema:push`, so
|
|
55
55
|
`ablo dev` works out of the box. Keys from other sandboxes are **data-only** by
|
|
56
56
|
default — enable "schema authoring" when minting if you want that key to push
|
|
57
57
|
schema too. Hand data-only keys to embedded apps and CI agents; reserve
|
package/docs/cli.md
CHANGED
|
@@ -32,7 +32,7 @@ mirrors `stripe login`.
|
|
|
32
32
|
| `ablo login` | Authorize in the browser; provisions + stores a test and a live key. |
|
|
33
33
|
| `ablo logout` | Remove the stored keys. |
|
|
34
34
|
| `ablo status` | Show the active org, mode, both keys (prefix + expiry), and server health. |
|
|
35
|
-
| `ablo mode [
|
|
35
|
+
| `ablo mode [sandbox\|production]` | Switch the active environment. With no argument, prompts. |
|
|
36
36
|
|
|
37
37
|
Keys are stored in `~/.config/ablo/config.json` (mode `0600`). In **CI**, don't
|
|
38
38
|
log in — set `ABLO_API_KEY`, which always overrides the stored key.
|
|
@@ -41,11 +41,11 @@ log in — set `ABLO_API_KEY`, which always overrides the stored key.
|
|
|
41
41
|
|
|
42
42
|
Like Stripe, every account has a **test** mode and a **live** mode, and a key
|
|
43
43
|
belongs to one of them. Test keys are bound to an isolated sandbox: their reads
|
|
44
|
-
and writes never touch
|
|
45
|
-
|
|
44
|
+
and writes never touch production data. Switch with `ablo mode`; `ablo dev` is always
|
|
45
|
+
the sandbox by design.
|
|
46
46
|
|
|
47
47
|
The schema, however, is **shared** across the org — pushing a schema (from
|
|
48
|
-
either
|
|
48
|
+
either environment) defines the same models sandbox and production see; only the rows differ.
|
|
49
49
|
|
|
50
50
|
## Commands
|
|
51
51
|
|
|
@@ -53,9 +53,9 @@ either mode) defines the same models test and live see; only the rows differ.
|
|
|
53
53
|
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
54
54
|
| `ablo init` | Scaffold `ablo/` (`schema.ts`, client, optional Data Source / agent / component), write `.env`, install the SDK. Offers to log in at the end. | — |
|
|
55
55
|
| `ablo login` / `logout` / `status` | Authentication & status (above). | — |
|
|
56
|
-
| `ablo mode [
|
|
56
|
+
| `ablo mode [sandbox\|production]` | Switch active environment. | — |
|
|
57
57
|
| `ablo dev` | **Hosted** — push the schema to your test sandbox, then watch `ablo/schema.ts` and re-push on save. | `--no-watch`, `--schema <path>`, `--export <name>`, `--url <url>` |
|
|
58
|
-
| `ablo logs` | Tail your scope's commit activity (`stripe logs tail`). Follows by default. | `-n, --tail <N>`, `--since <dur\|ts>`, `--model`, `--op`, `--json`, `--no-follow`, `--mode
|
|
58
|
+
| `ablo logs` | Tail your scope's commit activity (`stripe logs tail`). Follows by default. | `-n, --tail <N>`, `--since <dur\|ts>`, `--model`, `--op`, `--json`, `--no-follow`, `--mode sandbox\|production` |
|
|
59
59
|
| `ablo push` | **Hosted** — upload the schema to Ablo; the server diffs, migrates, and activates it. | `--force`, `--rename old:new`, `--backfill model.field=value`, `--schema`, `--export`, `--url` |
|
|
60
60
|
| `ablo migrate` | **Direct Postgres** — provision just the synced models (plus the adapter's `ablo_outbox` / `ablo_idempotency`) in your own `DATABASE_URL`. Leaves your other tables alone. | `--dry-run`, `--output <file>`, `--schema`, `--export` |
|
|
61
61
|
| `ablo pull` | **Direct Postgres** — generate `defineSchema(...)` from your existing tables (read-only, like `prisma db pull`). | `--out <path>`, `--app-schema <name>`, `--import <pkg>`, `--force` |
|
package/docs/client-behavior.md
CHANGED
|
@@ -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` | `
|
|
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. |
|
package/docs/data-sources.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# Connect Your Database
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
20
|
-
tables
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
98
|
+
## Your Database Stays Canonical
|
|
79
99
|
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
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
|
|
package/docs/guarantees.md
CHANGED
|
@@ -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
|
|
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
|
|
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) —
|
|
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 | `
|
|
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
|
-
##
|
|
45
|
+
## Your Database
|
|
46
46
|
|
|
47
|
-
Every schema model
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|