@abloatai/ablo 0.9.0 → 0.9.1

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.
@@ -1,24 +1,30 @@
1
1
  /**
2
- * The Data Source adapter spine — ONE interface every ORM backend implements,
3
- * and the bridge that wires it into the core `dataSource()` handler.
2
+ * The Data Source adapter — ONE interface every ORM backend implements, and the
3
+ * bridge that wires it into the core `dataSource()` handler.
4
4
  *
5
5
  * Pattern (Auth.js / Better Auth): one core interface, one package per ORM
6
6
  * (`prismaDataSource`, `drizzleDataSource`, `kyselyDataSource`), each provably
7
- * correct via the shared conformance suite. The adapter owns reality-access +
8
- * the transactional outbox/idempotency, so a customer never hand-writes them:
7
+ * correct via the shared conformance suite. The adapter owns reading and writing
8
+ * the database, plus the transactional outbox and idempotency, so a customer never
9
+ * hand-writes them:
9
10
  *
10
11
  * export const POST = dataSource({
11
12
  * schema, apiKey: process.env.ABLO_API_KEY!,
12
13
  * ...sourceHandlersFromAdapter(prismaDataSource(prisma, schema), schema),
13
14
  * });
14
15
  *
15
- * The bridge below is the spine connection: it turns ONE adapter into the core
16
- * handler's `commit` / `events` / per-model `load`+`list` — no per-ORM branching
17
- * anywhere above the adapter.
16
+ * The bridge below connects the adapter to the core: it turns ONE adapter into the
17
+ * core handler's `commit` / `events` / per-model `load`+`list` — no per-ORM
18
+ * branching anywhere above the adapter.
18
19
  */
19
20
  import type { SourceListQuery, SourceRequestContext } from './index.js';
20
21
  import type { AdapterCapabilities, ChangeSet, EventsPage, Migration } from './contract.js';
21
- /** A canonical row — JSON object keyed by column. `unknown` leaf is narrowed by codegen later. */
22
+ /**
23
+ * A canonical row keyed by schema FIELD name (e.g. `operatorId`) — the SDK shape,
24
+ * NOT physical column names. Each adapter is the translation boundary: Prisma maps
25
+ * via its `@map`, Drizzle via the schema's `camelToSnake`/`column` rule. `unknown`
26
+ * leaf is narrowed by codegen later.
27
+ */
22
28
  export type Row = Record<string, unknown>;
23
29
  /** A read against the canonical store — a single-row load or a filtered list. */
24
30
  export type AdapterReadRequest = {
@@ -37,13 +43,13 @@ export interface AdapterCommitResult {
37
43
  readonly rows: readonly Row[];
38
44
  }
39
45
  /**
40
- * The spine. An ORM adapter implements exactly these. `read`/`commit` are
41
- * reality-access; `events` reads the outbox; `migrations` ships the
42
- * `ablo_idempotency` + `ablo_outbox` DDL so the customer never writes it.
46
+ * The adapter interface. An ORM adapter implements exactly these. `read`/`commit`
47
+ * read and write the database; `events` reads the outbox; `migrations` ships the
48
+ * `ablo_idempotency` + `ablo_outbox` table-creation SQL so the customer never writes it.
43
49
  */
44
50
  export interface DataSourceAdapter {
45
51
  readonly capabilities: AdapterCapabilities;
46
- /** DDL for the adapter-owned tables. `ablo init` emits these. */
52
+ /** The table-creation SQL the adapter ships for its own tables (`ablo_idempotency` + `ablo_outbox`). */
47
53
  migrations(): readonly Migration[];
48
54
  /** Canonical rows for a load/list. */
49
55
  read(req: AdapterReadRequest): Promise<readonly Row[]>;
@@ -1,19 +1,20 @@
1
1
  /**
2
- * The Data Source adapter spine — ONE interface every ORM backend implements,
3
- * and the bridge that wires it into the core `dataSource()` handler.
2
+ * The Data Source adapter — ONE interface every ORM backend implements, and the
3
+ * bridge that wires it into the core `dataSource()` handler.
4
4
  *
5
5
  * Pattern (Auth.js / Better Auth): one core interface, one package per ORM
6
6
  * (`prismaDataSource`, `drizzleDataSource`, `kyselyDataSource`), each provably
7
- * correct via the shared conformance suite. The adapter owns reality-access +
8
- * the transactional outbox/idempotency, so a customer never hand-writes them:
7
+ * correct via the shared conformance suite. The adapter owns reading and writing
8
+ * the database, plus the transactional outbox and idempotency, so a customer never
9
+ * hand-writes them:
9
10
  *
10
11
  * export const POST = dataSource({
11
12
  * schema, apiKey: process.env.ABLO_API_KEY!,
12
13
  * ...sourceHandlersFromAdapter(prismaDataSource(prisma, schema), schema),
13
14
  * });
14
15
  *
15
- * The bridge below is the spine connection: it turns ONE adapter into the core
16
- * handler's `commit` / `events` / per-model `load`+`list` — no per-ORM branching
17
- * anywhere above the adapter.
16
+ * The bridge below connects the adapter to the core: it turns ONE adapter into the
17
+ * core handler's `commit` / `events` / per-model `load`+`list` — no per-ORM
18
+ * branching anywhere above the adapter.
18
19
  */
19
20
  export {};
@@ -1,12 +1,21 @@
1
1
  /**
2
- * Drizzle Data Source adapter. Same spine + conformance as `prismaDataSource`,
2
+ * Drizzle Data Source adapter. Same adapter interface + conformance as `prismaDataSource`,
3
3
  * built against Drizzle's REAL API (read from drizzle-orm's own source/docs):
4
4
  * - `db.transaction(async (tx) => …)` — interactive transaction (commit/rollback).
5
5
  * - `db.execute(sql`…`)` — parametrized raw SQL; `sql.identifier()` safely quotes
6
6
  * dynamic table/column names, `sql`${value}`` parametrizes values.
7
- * - the customer passes their Drizzle `tables` map (`{ task: pgTable(…) }`); a
8
- * table object is resolved by name with NO reflection cast — unlike Prisma's
9
- * nominal client, a `Record<string, PgTable>` is genuinely indexable.
7
+ *
8
+ * SCHEMA-DRIVEN COLUMNS. Unlike Prisma whose delegate applies the model's
9
+ * `@map` for free — this adapter writes raw SQL, so it would otherwise bypass any
10
+ * field→column translation. It therefore derives every table + column name from
11
+ * the SAME rule the provisioner uses (`generateProvisionPlan`):
12
+ * table = `model.tableName ?? key`
13
+ * column = `fieldMeta.column ?? camelToSnake(field)` (+ the model's tenancy column)
14
+ * so `ablo migrate` (which emits `operator_id`) and this adapter (which now writes
15
+ * `operator_id`) COMPOSE. Define the schema once, point Ablo at your Postgres —
16
+ * no hand-written parallel Drizzle table. The adapter is the translation boundary:
17
+ * its public surface (rows in/out, outbox `data`) is field-keyed (the SDK shape);
18
+ * the physical columns it reads/writes are snake_case.
10
19
  *
11
20
  * IMPORTANT GOTCHAS (from drizzle-orm docs):
12
21
  * 1. Interactive `db.transaction` requires a driver that supports it. Neon's
@@ -20,8 +29,8 @@
20
29
  * adapter is one small, fully-typed unit with no per-driver builder generics.
21
30
  */
22
31
  import { type SQL } from 'drizzle-orm';
23
- import type { PgTable } from 'drizzle-orm/pg-core';
24
32
  import type { DataSourceAdapter, Row } from '../adapter.js';
33
+ import type { Schema, SchemaRecord } from '../../schema/schema.js';
25
34
  /** The subset of a Drizzle database/transaction handle the adapter calls. */
26
35
  export interface DrizzleLike {
27
36
  execute(query: SQL): Promise<DrizzleExecuteResult>;
@@ -31,4 +40,4 @@ export interface DrizzleLike {
31
40
  export type DrizzleExecuteResult = readonly Row[] | {
32
41
  readonly rows: readonly Row[];
33
42
  };
34
- export declare function drizzleDataSource(db: DrizzleLike, tables: Record<string, PgTable>): DataSourceAdapter;
43
+ export declare function drizzleDataSource<S extends SchemaRecord>(db: DrizzleLike, schema: Schema<S>): DataSourceAdapter;
@@ -1,12 +1,21 @@
1
1
  /**
2
- * Drizzle Data Source adapter. Same spine + conformance as `prismaDataSource`,
2
+ * Drizzle Data Source adapter. Same adapter interface + conformance as `prismaDataSource`,
3
3
  * built against Drizzle's REAL API (read from drizzle-orm's own source/docs):
4
4
  * - `db.transaction(async (tx) => …)` — interactive transaction (commit/rollback).
5
5
  * - `db.execute(sql`…`)` — parametrized raw SQL; `sql.identifier()` safely quotes
6
6
  * dynamic table/column names, `sql`${value}`` parametrizes values.
7
- * - the customer passes their Drizzle `tables` map (`{ task: pgTable(…) }`); a
8
- * table object is resolved by name with NO reflection cast — unlike Prisma's
9
- * nominal client, a `Record<string, PgTable>` is genuinely indexable.
7
+ *
8
+ * SCHEMA-DRIVEN COLUMNS. Unlike Prisma whose delegate applies the model's
9
+ * `@map` for free — this adapter writes raw SQL, so it would otherwise bypass any
10
+ * field→column translation. It therefore derives every table + column name from
11
+ * the SAME rule the provisioner uses (`generateProvisionPlan`):
12
+ * table = `model.tableName ?? key`
13
+ * column = `fieldMeta.column ?? camelToSnake(field)` (+ the model's tenancy column)
14
+ * so `ablo migrate` (which emits `operator_id`) and this adapter (which now writes
15
+ * `operator_id`) COMPOSE. Define the schema once, point Ablo at your Postgres —
16
+ * no hand-written parallel Drizzle table. The adapter is the translation boundary:
17
+ * its public surface (rows in/out, outbox `data`) is field-keyed (the SDK shape);
18
+ * the physical columns it reads/writes are snake_case.
10
19
  *
11
20
  * IMPORTANT GOTCHAS (from drizzle-orm docs):
12
21
  * 1. Interactive `db.transaction` requires a driver that supports it. Neon's
@@ -19,8 +28,12 @@
19
28
  * We use `sql` + `db.execute` for ALL writes (not the fluent builder) so the
20
29
  * adapter is one small, fully-typed unit with no per-driver builder generics.
21
30
  */
22
- import { sql, getTableName } from 'drizzle-orm';
31
+ import { sql } from 'drizzle-orm';
23
32
  import { outboxEventSchema } from '../contract.js';
33
+ import { adapterTableMigrations } from '../migrations.js';
34
+ import { toSchemaJSON } from '../../schema/serialize.js';
35
+ import { camelToSnake, snakeToCamel } from '../../schema/ddl.js';
36
+ import { tenancyColumn } from '../../schema/tenancy.js';
24
37
  function rowsOf(result) {
25
38
  return Array.isArray(result) ? result : result.rows;
26
39
  }
@@ -33,74 +46,99 @@ function rowId(op) {
33
46
  }
34
47
  /** `col1, col2` as a safely-quoted identifier list. */
35
48
  const identList = (cols) => sql.join(cols.map((c) => sql.identifier(c)), sql `, `);
36
- export function drizzleDataSource(db, tables) {
37
- const tableNameFor = (model) => {
38
- const table = tables[model];
39
- if (!table)
40
- throw new Error(`drizzleDataSource: no Drizzle table for model "${model}"`);
41
- return getTableName(table);
49
+ function buildColumnMaps(schema) {
50
+ const json = toSchemaJSON(schema);
51
+ const out = new Map();
52
+ for (const [key, model] of Object.entries(json.models)) {
53
+ const fieldToColumn = new Map();
54
+ const columnToField = new Map();
55
+ const register = (field, column) => {
56
+ // The default rule already covers `camelToSnake(field)`; only record real
57
+ // divergences so the reverse map never shadows a clean round-trip.
58
+ if (column === camelToSnake(field))
59
+ return;
60
+ fieldToColumn.set(field, column);
61
+ columnToField.set(column, field);
62
+ };
63
+ for (const [field, meta] of Object.entries(model.fields)) {
64
+ if (meta.column)
65
+ register(field, meta.column);
66
+ }
67
+ const orgColumn = tenancyColumn(model.tenancy);
68
+ if (orgColumn)
69
+ register('organizationId', orgColumn);
70
+ out.set(key, { table: model.tableName ?? key, fieldToColumn, columnToField });
71
+ }
72
+ return out;
73
+ }
74
+ export function drizzleDataSource(db, schema) {
75
+ const maps = buildColumnMaps(schema);
76
+ const modelColumns = (model) => {
77
+ const mc = maps.get(model);
78
+ if (!mc)
79
+ throw new Error(`drizzleDataSource: no model "${model}" in schema`);
80
+ return mc;
81
+ };
82
+ const columnFor = (mc, field) => mc.fieldToColumn.get(field) ?? camelToSnake(field);
83
+ const fieldFor = (mc, column) => mc.columnToField.get(column) ?? snakeToCamel(column);
84
+ /** Field-keyed (SDK shape) → column-keyed (physical), for INSERT/UPDATE. */
85
+ const toColumns = (mc, row) => {
86
+ const out = {};
87
+ for (const k of Object.keys(row))
88
+ out[columnFor(mc, k)] = row[k];
89
+ return out;
90
+ };
91
+ /** Column-keyed (RETURNING * / SELECT *) → field-keyed (SDK shape), for reads + results. */
92
+ const toFields = (mc, row) => {
93
+ const out = {};
94
+ for (const k of Object.keys(row))
95
+ out[fieldFor(mc, k)] = row[k];
96
+ return out;
42
97
  };
43
98
  const applyOperation = async (tx, op) => {
44
- const table = sql.identifier(tableNameFor(op.model));
99
+ const mc = modelColumns(op.model);
100
+ const table = sql.identifier(mc.table);
45
101
  const id = rowId(op);
46
102
  const input = op.input ?? {};
47
103
  if (op.type === 'DELETE') {
48
104
  const deleted = rowsOf(await tx.execute(sql `DELETE FROM ${table} WHERE id = ${id} RETURNING *`));
49
- return deleted[0] ?? { id };
105
+ return deleted[0] ? toFields(mc, deleted[0]) : { id };
50
106
  }
51
107
  if (op.type === 'CREATE') {
52
- const data = { id, ...input };
108
+ const data = toColumns(mc, { id, ...input });
53
109
  const cols = Object.keys(data);
54
110
  const values = sql.join(cols.map((c) => sql `${data[c]}`), sql `, `);
55
111
  const inserted = rowsOf(await tx.execute(sql `INSERT INTO ${table} (${identList(cols)}) VALUES (${values}) RETURNING *`));
56
- return inserted[0] ?? data;
112
+ return inserted[0] ? toFields(mc, inserted[0]) : { id, ...input };
57
113
  }
58
- // UPDATE / ARCHIVE / UNARCHIVE — a SET clause + the lifecycle column.
59
- const patch = {
114
+ // UPDATE / ARCHIVE / UNARCHIVE — a SET clause + the lifecycle field. The
115
+ // lifecycle field is `archivedAt` (camelCase) and goes through `toColumns`
116
+ // like any other, so it lands in `archived_at` — same column the provisioner
117
+ // emits and the Prisma adapter writes (no per-adapter casing divergence).
118
+ const patch = toColumns(mc, {
60
119
  ...input,
61
- ...(op.type === 'ARCHIVE' ? { archived_at: new Date() } : {}),
62
- ...(op.type === 'UNARCHIVE' ? { archived_at: null } : {}),
63
- };
120
+ ...(op.type === 'ARCHIVE' ? { archivedAt: new Date() } : {}),
121
+ ...(op.type === 'UNARCHIVE' ? { archivedAt: null } : {}),
122
+ });
64
123
  const assignments = sql.join(Object.keys(patch).map((c) => sql `${sql.identifier(c)} = ${patch[c]}`), sql `, `);
65
124
  const updated = rowsOf(await tx.execute(sql `UPDATE ${table} SET ${assignments} WHERE id = ${id} RETURNING *`));
66
- return updated[0] ?? { id, ...patch };
125
+ return updated[0] ? toFields(mc, updated[0]) : { id, ...input };
67
126
  };
68
127
  return {
69
128
  capabilities: { transactions: true, propose: false, schemaIntrospection: true },
70
129
  migrations() {
71
- return [
72
- {
73
- name: 'ablo_idempotency',
74
- up: `CREATE TABLE IF NOT EXISTS ablo_idempotency (
75
- client_tx_id TEXT PRIMARY KEY,
76
- response JSONB NOT NULL,
77
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
78
- );`,
79
- },
80
- {
81
- name: 'ablo_outbox',
82
- up: `CREATE TABLE IF NOT EXISTS ablo_outbox (
83
- cursor BIGSERIAL PRIMARY KEY,
84
- id TEXT NOT NULL UNIQUE,
85
- model TEXT NOT NULL,
86
- entity_id TEXT NOT NULL,
87
- type TEXT NOT NULL,
88
- data JSONB,
89
- organization_id TEXT,
90
- client_tx_id TEXT,
91
- occurred_at BIGINT,
92
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
93
- );`,
94
- },
95
- ];
130
+ return adapterTableMigrations();
96
131
  },
97
132
  async read(req) {
98
- const table = sql.identifier(tableNameFor(req.model));
133
+ const mc = modelColumns(req.model);
134
+ const table = sql.identifier(mc.table);
99
135
  if (req.kind === 'load') {
100
- return rowsOf(await db.execute(sql `SELECT * FROM ${table} WHERE id = ${req.id} LIMIT 1`));
136
+ const rows = rowsOf(await db.execute(sql `SELECT * FROM ${table} WHERE id = ${req.id} LIMIT 1`));
137
+ return rows.map((r) => toFields(mc, r));
101
138
  }
102
139
  const limit = req.query?.limit ?? 1000;
103
- return rowsOf(await db.execute(sql `SELECT * FROM ${table} LIMIT ${limit}`));
140
+ const rows = rowsOf(await db.execute(sql `SELECT * FROM ${table} LIMIT ${limit}`));
141
+ return rows.map((r) => toFields(mc, r));
104
142
  },
105
143
  async commit(change) {
106
144
  return db.transaction(async (tx) => {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * In-memory reference Data Source adapter — the canonical correct implementation
3
- * of the spine. It is the test double for the bridge/handler AND the thing the
3
+ * of the adapter interface. It is the test double for the bridge/handler AND the thing the
4
4
  * conformance suite runs against to prove the suite itself is real (same role as
5
5
  * the server's `memoryTenantDirectory`). A new ORM adapter is "done" when it
6
6
  * passes the same suite this one passes.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * In-memory reference Data Source adapter — the canonical correct implementation
3
- * of the spine. It is the test double for the bridge/handler AND the thing the
3
+ * of the adapter interface. It is the test double for the bridge/handler AND the thing the
4
4
  * conformance suite runs against to prove the suite itself is real (same role as
5
5
  * the server's `memoryTenantDirectory`). A new ORM adapter is "done" when it
6
6
  * passes the same suite this one passes.
@@ -62,7 +62,7 @@ export function memoryDataSource() {
62
62
  return {
63
63
  capabilities: { transactions: true, propose: false, schemaIntrospection: false },
64
64
  migrations() {
65
- // In-memory: no DDL. A real ORM adapter ships ablo_idempotency + ablo_outbox here.
65
+ // In-memory: no table-creation SQL. A real ORM adapter ships ablo_idempotency + ablo_outbox here.
66
66
  return [];
67
67
  },
68
68
  async read(req) {
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Prisma Data Source adapter. The first real ORM adapter (Auth.js pattern: one
3
- * package per ORM, all behind the `DataSourceAdapter` spine, all proven by the
3
+ * package per ORM, all behind the `DataSourceAdapter` interface, all proven by the
4
4
  * same conformance suite the in-memory reference passes).
5
5
  *
6
6
  * It owns the transactional outbox + idempotency so the customer never writes
7
7
  * them: `commit` runs the app-row mutations, the `ablo_outbox` append, and the
8
8
  * `ablo_idempotency` record in ONE `prisma.$transaction`. `migrations()` ships
9
- * the DDL for those two tables (`ablo init` emits it).
9
+ * the table-creation SQL for those two tables.
10
10
  *
11
11
  * No `@prisma/client` dependency: the client is accepted structurally
12
12
  * (`PrismaLike`), so this compiles in the SDK package and is unit-testable with
@@ -46,7 +46,7 @@ export interface PrismaRaw {
46
46
  $executeRawUnsafe(query: string, ...values: unknown[]): Promise<number>;
47
47
  $queryRawUnsafe<T = unknown>(query: string, ...values: unknown[]): Promise<T>;
48
48
  }
49
- /** A Prisma client (or interactive-tx client) — structural, no SDK dependency. */
49
+ /** A Prisma client (or interactive-transaction client) — structural, no SDK dependency. */
50
50
  export interface PrismaLike extends PrismaRaw {
51
51
  $transaction<T>(fn: (tx: PrismaLike & PrismaRaw) => Promise<T>): Promise<T>;
52
52
  }
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * Prisma Data Source adapter. The first real ORM adapter (Auth.js pattern: one
3
- * package per ORM, all behind the `DataSourceAdapter` spine, all proven by the
3
+ * package per ORM, all behind the `DataSourceAdapter` interface, all proven by the
4
4
  * same conformance suite the in-memory reference passes).
5
5
  *
6
6
  * It owns the transactional outbox + idempotency so the customer never writes
7
7
  * them: `commit` runs the app-row mutations, the `ablo_outbox` append, and the
8
8
  * `ablo_idempotency` record in ONE `prisma.$transaction`. `migrations()` ships
9
- * the DDL for those two tables (`ablo init` emits it).
9
+ * the table-creation SQL for those two tables.
10
10
  *
11
11
  * No `@prisma/client` dependency: the client is accepted structurally
12
12
  * (`PrismaLike`), so this compiles in the SDK package and is unit-testable with
13
13
  * a fake, while a real `PrismaClient` satisfies it at the call site.
14
14
  */
15
15
  import { outboxEventSchema } from '../contract.js';
16
+ import { adapterTableMigrations } from '../migrations.js';
16
17
  const lowerFirst = (s) => (s ? s[0].toLowerCase() + s.slice(1) : s);
17
18
  /**
18
19
  * Resolve a model's Prisma delegate by name. This is the ONE irreducible cast in
@@ -20,7 +21,7 @@ const lowerFirst = (s) => (s ? s[0].toLowerCase() + s.slice(1) : s);
20
21
  *
21
22
  * - Inside `prisma.$transaction(tx => …)` the writes MUST go through the
22
23
  * transactional client `tx`, and the model is only known as a runtime string.
23
- * - Prisma's client/tx is NOMINALLY keyed (`{ task: TaskDelegate; … }`), so a
24
+ * - Prisma's client (and transaction handle) is NOMINALLY keyed (`{ task: TaskDelegate; … }`), so a
24
25
  * dynamic `tx[name]` is `unknown` to the compiler — there is no key to infer.
25
26
  *
26
27
  * Dynamic property access on a statically-keyed type cannot be typed without an
@@ -122,31 +123,7 @@ export function prismaDataSource(prisma, schema, options = {}) {
122
123
  return {
123
124
  capabilities: { transactions: true, propose: false, schemaIntrospection: true },
124
125
  migrations() {
125
- return [
126
- {
127
- name: 'ablo_idempotency',
128
- up: `CREATE TABLE IF NOT EXISTS ablo_idempotency (
129
- client_tx_id TEXT PRIMARY KEY,
130
- response JSONB NOT NULL,
131
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
132
- );`,
133
- },
134
- {
135
- name: 'ablo_outbox',
136
- up: `CREATE TABLE IF NOT EXISTS ablo_outbox (
137
- cursor BIGSERIAL PRIMARY KEY,
138
- id TEXT NOT NULL UNIQUE,
139
- model TEXT NOT NULL,
140
- entity_id TEXT NOT NULL,
141
- type TEXT NOT NULL,
142
- data JSONB,
143
- organization_id TEXT,
144
- client_tx_id TEXT,
145
- occurred_at BIGINT,
146
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
147
- );`,
148
- },
149
- ];
126
+ return adapterTableMigrations();
150
127
  },
151
128
  async read(req) {
152
129
  const delegate = delegateFor(prisma, delegateName(req.model));
@@ -167,7 +144,7 @@ export function prismaDataSource(prisma, schema, options = {}) {
167
144
  const row = await applyOperation(tx, op);
168
145
  rows.push(row);
169
146
  const entityId = String(row.id ?? rowId(op));
170
- // Transactional outbox: one event per op, written in THIS tx.
147
+ // Transactional outbox: one event per op, written in THIS transaction.
171
148
  await tx.$executeRawUnsafe(`INSERT INTO ablo_outbox (id, model, entity_id, type, data, client_tx_id, occurred_at)
172
149
  VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)`, `${change.clientTxId}:${index}`, op.model, entityId, op.type, JSON.stringify(op.type === 'DELETE' ? null : row), change.clientTxId, Date.now());
173
150
  }
@@ -2,7 +2,7 @@
2
2
  * Data Source adapter conformance suite — the shared "is this adapter correct?"
3
3
  * test set, in the Auth.js `@auth/adapter-test` mould. Every ORM adapter
4
4
  * (Prisma/Drizzle/Kysely) and any hand-written handler runs THIS to prove,
5
- * before production, the guarantees the spine promises. A new adapter is "done"
5
+ * before production, the guarantees the adapter interface promises. A new adapter is "done"
6
6
  * when it passes — not when it compiles.
7
7
  *
8
8
  * Runner-agnostic: checks are plain async functions that throw (node:assert) on
@@ -2,7 +2,7 @@
2
2
  * Data Source adapter conformance suite — the shared "is this adapter correct?"
3
3
  * test set, in the Auth.js `@auth/adapter-test` mould. Every ORM adapter
4
4
  * (Prisma/Drizzle/Kysely) and any hand-written handler runs THIS to prove,
5
- * before production, the guarantees the spine promises. A new adapter is "done"
5
+ * before production, the guarantees the adapter interface promises. A new adapter is "done"
6
6
  * when it passes — not when it compiles.
7
7
  *
8
8
  * Runner-agnostic: checks are plain async functions that throw (node:assert) on
@@ -109,7 +109,7 @@ export function dataSourceConformanceChecks(make) {
109
109
  },
110
110
  },
111
111
  {
112
- name: 'a later UPDATE under a new clientTxId is applied (idempotency is per-tx)',
112
+ name: 'a later UPDATE under a new clientTxId is applied (idempotency is per-transaction)',
113
113
  run: async () => {
114
114
  const adapter = await make();
115
115
  await adapter.commit(change('tx_c1', [{ type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } }]));
@@ -126,8 +126,9 @@ export declare const eventsPageSchema: z.ZodObject<{
126
126
  }, z.core.$strip>;
127
127
  export type EventsPage = z.infer<typeof eventsPageSchema>;
128
128
  /**
129
- * A DDL migration an adapter ships so a customer never hand-writes the
130
- * `ablo_idempotency` / `ablo_outbox` tables — `ablo init` emits these.
129
+ * A table-creation migration an adapter ships so a customer never hand-writes the
130
+ * `ablo_idempotency` / `ablo_outbox` tables — the adapter returns them from
131
+ * `migrations()`.
131
132
  */
132
133
  export declare const migrationSchema: z.ZodObject<{
133
134
  name: z.ZodString;
@@ -73,8 +73,9 @@ export const eventsPageSchema = z.object({
73
73
  nextCursor: z.string().nullable(),
74
74
  });
75
75
  /**
76
- * A DDL migration an adapter ships so a customer never hand-writes the
77
- * `ablo_idempotency` / `ablo_outbox` tables — `ablo init` emits these.
76
+ * A table-creation migration an adapter ships so a customer never hand-writes the
77
+ * `ablo_idempotency` / `ablo_outbox` tables — the adapter returns them from
78
+ * `migrations()`.
78
79
  */
79
80
  export const migrationSchema = z.object({
80
81
  /** Stable name, used as the migration filename + applied-ledger key. */
@@ -466,3 +466,4 @@ export { createPushQueue, InMemoryPushQueueStorage, STANDARD_WEBHOOKS_RETRY_SCHE
466
466
  export { type DataSourceAdapter, type AdapterReadRequest, type AdapterCommitResult, type Row as AdapterRow, } from './adapter.js';
467
467
  export { operationSchema, operationTypeSchema, changeSetSchema, outboxEventSchema, eventsPageSchema, migrationSchema, adapterCapabilitiesSchema, type Operation, type ChangeSet, type OutboxEvent, type EventsPage, type Migration, type AdapterCapabilities, } from './contract.js';
468
468
  export { prismaDataSource, type PrismaLike, type PrismaDataSourceOptions } from './adapters/prisma.js';
469
+ export { adapterTableMigrations } from './migrations.js';
@@ -59,8 +59,8 @@ function json(data, status = 200) {
59
59
  });
60
60
  }
61
61
  /**
62
- * Serve a request from an ORM `adapter`. Routes the four operations to the spine
63
- * (`read`/`commit`/`events`) and shapes the wire response. The adapter is the
62
+ * Serve a request from an ORM `adapter`. Routes the four operations to the adapter
63
+ * interface (`read`/`commit`/`events`) and shapes the wire response. The adapter is the
64
64
  * single point of dispatch — no per-model branching here.
65
65
  */
66
66
  async function handleViaAdapter(adapter, body, scope) {
@@ -419,3 +419,4 @@ export function dataSource(options) {
419
419
  export { createPushQueue, InMemoryPushQueueStorage, STANDARD_WEBHOOKS_RETRY_SCHEDULE, } from './pushQueue.js';
420
420
  export { operationSchema, operationTypeSchema, changeSetSchema, outboxEventSchema, eventsPageSchema, migrationSchema, adapterCapabilitiesSchema, } from './contract.js';
421
421
  export { prismaDataSource } from './adapters/prisma.js';
422
+ export { adapterTableMigrations } from './migrations.js';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The table-creation SQL every ORM adapter ships for its OWN infrastructure tables —
3
+ * `ablo_idempotency` (dedupe by clientTxId) and `ablo_outbox` (transactional
4
+ * outbox the `events()` feed reads). Defined ONCE here so the Prisma adapter, the
5
+ * Drizzle adapter, and `ablo migrate` can never disagree on the shape (they used
6
+ * to inline their own copies, which had already drifted in whitespace).
7
+ *
8
+ * These are NOT model tables and are NOT emitted by the hosted provisioner
9
+ * (`generateProvisionPlan`) — the hosted path uses `sync_deltas` directly. They
10
+ * exist only on a customer's own database in Data Source mode.
11
+ */
12
+ import type { Migration } from './contract.js';
13
+ /** Canonical adapter-owned table-creation SQL. Idempotent (`IF NOT EXISTS`). */
14
+ export declare function adapterTableMigrations(): readonly Migration[];
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The table-creation SQL every ORM adapter ships for its OWN infrastructure tables —
3
+ * `ablo_idempotency` (dedupe by clientTxId) and `ablo_outbox` (transactional
4
+ * outbox the `events()` feed reads). Defined ONCE here so the Prisma adapter, the
5
+ * Drizzle adapter, and `ablo migrate` can never disagree on the shape (they used
6
+ * to inline their own copies, which had already drifted in whitespace).
7
+ *
8
+ * These are NOT model tables and are NOT emitted by the hosted provisioner
9
+ * (`generateProvisionPlan`) — the hosted path uses `sync_deltas` directly. They
10
+ * exist only on a customer's own database in Data Source mode.
11
+ */
12
+ /** Canonical adapter-owned table-creation SQL. Idempotent (`IF NOT EXISTS`). */
13
+ export function adapterTableMigrations() {
14
+ return [
15
+ {
16
+ name: 'ablo_idempotency',
17
+ up: `CREATE TABLE IF NOT EXISTS ablo_idempotency (
18
+ client_tx_id TEXT PRIMARY KEY,
19
+ response JSONB NOT NULL,
20
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
21
+ );`,
22
+ },
23
+ {
24
+ name: 'ablo_outbox',
25
+ up: `CREATE TABLE IF NOT EXISTS ablo_outbox (
26
+ cursor BIGSERIAL PRIMARY KEY,
27
+ id TEXT NOT NULL UNIQUE,
28
+ model TEXT NOT NULL,
29
+ entity_id TEXT NOT NULL,
30
+ type TEXT NOT NULL,
31
+ data JSONB,
32
+ organization_id TEXT,
33
+ client_tx_id TEXT,
34
+ occurred_at BIGINT,
35
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
36
+ );`,
37
+ },
38
+ ];
39
+ }
package/docs/cli.md CHANGED
@@ -57,9 +57,9 @@ either mode) defines the same models test and live see; only the rows differ.
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
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` |
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
- | `ablo migrate` | **Direct Postgres** — apply the schema to your own `DATABASE_URL` (you run the DDL). | `--dry-run`, `--output <file>`, `--schema`, `--export` |
60
+ | `ablo migrate` | **Direct Postgres** — apply the schema to your own `DATABASE_URL` (you run the table-creation SQL). | `--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` |
62
- | `ablo check` | **Direct Postgres** — verify your _existing_ tables fit the schema (read-only, no DDL). | `--schema <path>`, `--export <name>`, `--app-schema <name>` |
62
+ | `ablo check` | **Direct Postgres** — verify your _existing_ tables fit the schema (read-only, no schema changes). | `--schema <path>`, `--export <name>`, `--app-schema <name>` |
63
63
  | `ablo generate` | Emit TypeScript types from the schema. | `--out <path>`, `--schema`, `--export` |
64
64
 
65
65
  ## `ablo dev`
@@ -145,7 +145,7 @@ reshaping it. `ablo check` is read-only; it never proposes a migration.
145
145
 
146
146
  Same engine, two setups. If you use the **Direct Postgres connector**, use
147
147
  `ablo migrate` — it applies the schema to your own `DATABASE_URL`, and you run
148
- the DDL. If Ablo manages the sandbox/hosted store, use `ablo push` and
148
+ the table-creation SQL. If Ablo manages the sandbox/hosted store, use `ablo push` and
149
149
  `ablo dev` — the server applies the change and version-gates connecting clients.
150
150
 
151
151
  ```bash
package/docs/index.md CHANGED
@@ -42,7 +42,7 @@ Three things stay true no matter how you use Ablo:
42
42
  - [Quickstart](./quickstart.md) — Make your first schema-backed write.
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
- - [Identity & Sync Groups](./identity.md) — Bring your own auth; tell Ablo who's connecting and how org / team / user map to sync-group scope.
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
46
  - [Integration Guide](./integration-guide.md) — Choose Ablo-managed state, Data Source, 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "State control API for AI agents and collaborative apps.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",