@abloatai/ablo 0.7.0 → 0.9.0
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/CHANGELOG.md +72 -1
- package/README.md +80 -66
- package/dist/BaseSyncedStore.d.ts +73 -0
- package/dist/BaseSyncedStore.js +179 -5
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +6 -5
- package/dist/ai-sdk/coordination-context.js +4 -0
- package/dist/ai-sdk/index.d.ts +56 -47
- package/dist/ai-sdk/index.js +56 -47
- package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
- package/dist/ai-sdk/intent-broadcast.js +11 -4
- package/dist/ai-sdk/wrap.d.ts +14 -11
- package/dist/ai-sdk/wrap.js +11 -13
- package/dist/auth/credentialSource.d.ts +34 -0
- package/dist/auth/credentialSource.js +63 -0
- package/dist/auth/index.d.ts +2 -22
- package/dist/auth/index.js +26 -36
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +259 -33
- package/dist/client/Ablo.js +276 -73
- package/dist/client/ApiClient.d.ts +52 -4
- package/dist/client/ApiClient.js +236 -66
- package/dist/client/auth.d.ts +21 -2
- package/dist/client/auth.js +77 -5
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +187 -79
- package/dist/client/createModelProxy.js +203 -68
- package/dist/client/httpClient.d.ts +71 -0
- package/dist/client/httpClient.js +69 -0
- package/dist/client/identity.d.ts +2 -6
- package/dist/client/identity.js +63 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +59 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/core/DatabaseManager.js +30 -2
- package/dist/core/openIDBWithTimeout.d.ts +36 -0
- package/dist/core/openIDBWithTimeout.js +88 -1
- package/dist/errorCodes.d.ts +92 -1
- package/dist/errorCodes.js +139 -7
- package/dist/errors.d.ts +54 -3
- package/dist/errors.js +192 -44
- package/dist/index.d.ts +23 -10
- package/dist/index.js +21 -8
- package/dist/keys/index.d.ts +76 -0
- package/dist/keys/index.js +171 -0
- package/dist/mutators/UndoManager.d.ts +86 -50
- package/dist/mutators/UndoManager.js +129 -22
- package/dist/mutators/inverseOp.d.ts +129 -0
- package/dist/mutators/inverseOp.js +74 -0
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +42 -0
- package/dist/mutators/undoApply.js +143 -0
- package/dist/query/client.d.ts +10 -9
- package/dist/query/client.js +22 -14
- package/dist/react/AbloProvider.d.ts +23 -101
- package/dist/react/AbloProvider.js +61 -103
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +26 -3
- package/dist/schema/ddl.js +152 -4
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.js +12 -0
- package/dist/schema/model.d.ts +11 -0
- package/dist/schema/model.js +2 -0
- package/dist/schema/openapi.d.ts +28 -0
- package/dist/schema/openapi.js +118 -0
- package/dist/schema/plane.d.ts +23 -0
- package/dist/schema/plane.js +19 -0
- package/dist/schema/relation.d.ts +20 -0
- package/dist/schema/serialize.d.ts +7 -3
- package/dist/schema/serialize.js +6 -2
- package/dist/schema/sync-delta-row.d.ts +157 -0
- package/dist/schema/sync-delta-row.js +102 -0
- package/dist/schema/sync-delta-wire.d.ts +180 -0
- package/dist/schema/sync-delta-wire.js +102 -0
- package/dist/server/adapter.d.ts +156 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/commit.d.ts +82 -0
- package/dist/server/commit.js +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +1 -0
- package/dist/server/next.d.ts +51 -0
- package/dist/server/next.js +47 -0
- package/dist/server/read-config.d.ts +60 -0
- package/dist/server/read-config.js +8 -0
- package/dist/server/storage-mode.d.ts +17 -0
- package/dist/server/storage-mode.js +12 -0
- package/dist/source/adapter.d.ts +59 -0
- package/dist/source/adapter.js +19 -0
- package/dist/source/adapters/drizzle.d.ts +34 -0
- package/dist/source/adapters/drizzle.js +147 -0
- package/dist/source/adapters/memory.d.ts +12 -0
- package/dist/source/adapters/memory.js +114 -0
- package/dist/source/adapters/prisma.d.ts +57 -0
- package/dist/source/adapters/prisma.js +199 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +143 -0
- package/dist/source/contract.js +98 -0
- package/dist/source/index.d.ts +61 -10
- package/dist/source/index.js +98 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.js +26 -0
- package/dist/sync/BootstrapHelper.d.ts +10 -0
- package/dist/sync/BootstrapHelper.js +56 -42
- package/dist/sync/ConnectionManager.d.ts +57 -1
- package/dist/sync/ConnectionManager.js +186 -11
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +241 -41
- package/dist/sync/NetworkProbe.d.ts +60 -18
- package/dist/sync/NetworkProbe.js +121 -23
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +113 -89
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +9 -0
- package/dist/utils/mobx-setup.js +1 -0
- package/dist/webhooks/events.d.ts +38 -0
- package/dist/webhooks/events.js +40 -0
- package/dist/webhooks/index.d.ts +10 -0
- package/dist/webhooks/index.js +10 -0
- package/dist/wire/errorEnvelope.d.ts +34 -0
- package/dist/wire/errorEnvelope.js +86 -0
- package/dist/wire/frames.d.ts +119 -0
- package/dist/wire/frames.js +1 -0
- package/dist/wire/index.d.ts +24 -0
- package/dist/wire/index.js +21 -0
- package/dist/wire/listEnvelope.d.ts +45 -0
- package/dist/wire/listEnvelope.js +17 -0
- package/docs/api-keys.md +5 -5
- package/docs/api.md +125 -65
- package/docs/audit.md +16 -9
- package/docs/cli.md +57 -47
- package/docs/client-behavior.md +54 -40
- package/docs/coordination.md +66 -80
- package/docs/data-sources.md +56 -34
- package/docs/examples/agent-human.md +74 -28
- package/docs/examples/ai-sdk-tool.md +29 -22
- package/docs/examples/existing-python-backend.md +41 -26
- package/docs/examples/nextjs.md +32 -17
- package/docs/examples/scoped-agent.md +43 -28
- package/docs/examples/server-agent.md +40 -15
- package/docs/guarantees.md +38 -27
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +78 -78
- package/docs/interaction-model.md +43 -35
- package/docs/mcp/claude-code.md +11 -19
- package/docs/mcp/cursor.md +7 -25
- package/docs/mcp/windsurf.md +7 -20
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +63 -61
- package/docs/react.md +24 -16
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +111 -0
- package/docs/the-loop.md +21 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +10 -7
- package/examples/data-source/customer-server.ts +27 -25
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +55 -21
- package/package.json +48 -3
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle Data Source adapter. Same spine + conformance as `prismaDataSource`,
|
|
3
|
+
* built against Drizzle's REAL API (read from drizzle-orm's own source/docs):
|
|
4
|
+
* - `db.transaction(async (tx) => …)` — interactive transaction (commit/rollback).
|
|
5
|
+
* - `db.execute(sql`…`)` — parametrized raw SQL; `sql.identifier()` safely quotes
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* IMPORTANT GOTCHAS (from drizzle-orm docs):
|
|
12
|
+
* 1. Interactive `db.transaction` requires a driver that supports it. Neon's
|
|
13
|
+
* `neon-http` driver does NOT (single-shot only) — use `neon-serverless`
|
|
14
|
+
* (WebSocket) or `pg`. With neon-http the commit path throws at runtime.
|
|
15
|
+
* 2. `db.execute` result shape is driver-specific (postgres-js returns an
|
|
16
|
+
* array-like RowList; node-postgres returns `{ rows }`). `rowsOf()`
|
|
17
|
+
* normalizes both.
|
|
18
|
+
*
|
|
19
|
+
* We use `sql` + `db.execute` for ALL writes (not the fluent builder) so the
|
|
20
|
+
* adapter is one small, fully-typed unit with no per-driver builder generics.
|
|
21
|
+
*/
|
|
22
|
+
import { sql, getTableName } from 'drizzle-orm';
|
|
23
|
+
import { outboxEventSchema } from '../contract.js';
|
|
24
|
+
function rowsOf(result) {
|
|
25
|
+
return Array.isArray(result) ? result : result.rows;
|
|
26
|
+
}
|
|
27
|
+
function rowId(op) {
|
|
28
|
+
const id = op.id ?? op.input?.id;
|
|
29
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
30
|
+
throw new Error(`operation on "${op.model}" requires an id`);
|
|
31
|
+
}
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
/** `col1, col2` as a safely-quoted identifier list. */
|
|
35
|
+
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);
|
|
42
|
+
};
|
|
43
|
+
const applyOperation = async (tx, op) => {
|
|
44
|
+
const table = sql.identifier(tableNameFor(op.model));
|
|
45
|
+
const id = rowId(op);
|
|
46
|
+
const input = op.input ?? {};
|
|
47
|
+
if (op.type === 'DELETE') {
|
|
48
|
+
const deleted = rowsOf(await tx.execute(sql `DELETE FROM ${table} WHERE id = ${id} RETURNING *`));
|
|
49
|
+
return deleted[0] ?? { id };
|
|
50
|
+
}
|
|
51
|
+
if (op.type === 'CREATE') {
|
|
52
|
+
const data = { id, ...input };
|
|
53
|
+
const cols = Object.keys(data);
|
|
54
|
+
const values = sql.join(cols.map((c) => sql `${data[c]}`), sql `, `);
|
|
55
|
+
const inserted = rowsOf(await tx.execute(sql `INSERT INTO ${table} (${identList(cols)}) VALUES (${values}) RETURNING *`));
|
|
56
|
+
return inserted[0] ?? data;
|
|
57
|
+
}
|
|
58
|
+
// UPDATE / ARCHIVE / UNARCHIVE — a SET clause + the lifecycle column.
|
|
59
|
+
const patch = {
|
|
60
|
+
...input,
|
|
61
|
+
...(op.type === 'ARCHIVE' ? { archived_at: new Date() } : {}),
|
|
62
|
+
...(op.type === 'UNARCHIVE' ? { archived_at: null } : {}),
|
|
63
|
+
};
|
|
64
|
+
const assignments = sql.join(Object.keys(patch).map((c) => sql `${sql.identifier(c)} = ${patch[c]}`), sql `, `);
|
|
65
|
+
const updated = rowsOf(await tx.execute(sql `UPDATE ${table} SET ${assignments} WHERE id = ${id} RETURNING *`));
|
|
66
|
+
return updated[0] ?? { id, ...patch };
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
capabilities: { transactions: true, propose: false, schemaIntrospection: true },
|
|
70
|
+
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
|
+
];
|
|
96
|
+
},
|
|
97
|
+
async read(req) {
|
|
98
|
+
const table = sql.identifier(tableNameFor(req.model));
|
|
99
|
+
if (req.kind === 'load') {
|
|
100
|
+
return rowsOf(await db.execute(sql `SELECT * FROM ${table} WHERE id = ${req.id} LIMIT 1`));
|
|
101
|
+
}
|
|
102
|
+
const limit = req.query?.limit ?? 1000;
|
|
103
|
+
return rowsOf(await db.execute(sql `SELECT * FROM ${table} LIMIT ${limit}`));
|
|
104
|
+
},
|
|
105
|
+
async commit(change) {
|
|
106
|
+
return db.transaction(async (tx) => {
|
|
107
|
+
const cached = rowsOf(await tx.execute(sql `SELECT response FROM ablo_idempotency WHERE client_tx_id = ${change.clientTxId} LIMIT 1`));
|
|
108
|
+
if (cached.length > 0)
|
|
109
|
+
return { rows: cached[0].response };
|
|
110
|
+
const rows = [];
|
|
111
|
+
for (const [index, op] of change.operations.entries()) {
|
|
112
|
+
const row = await applyOperation(tx, op);
|
|
113
|
+
rows.push(row);
|
|
114
|
+
const entityId = String(row.id ?? rowId(op));
|
|
115
|
+
await tx.execute(sql `
|
|
116
|
+
INSERT INTO ablo_outbox (id, model, entity_id, type, data, client_tx_id, occurred_at)
|
|
117
|
+
VALUES (
|
|
118
|
+
${`${change.clientTxId}:${index}`}, ${op.model}, ${entityId}, ${op.type},
|
|
119
|
+
${op.type === 'DELETE' ? null : JSON.stringify(row)}::jsonb, ${change.clientTxId}, ${Date.now()}
|
|
120
|
+
)`);
|
|
121
|
+
}
|
|
122
|
+
await tx.execute(sql `
|
|
123
|
+
INSERT INTO ablo_idempotency (client_tx_id, response)
|
|
124
|
+
VALUES (${change.clientTxId}, ${JSON.stringify(rows)}::jsonb)`);
|
|
125
|
+
return { rows };
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
async events(cursor, limit) {
|
|
129
|
+
const after = cursor ?? '0';
|
|
130
|
+
const rows = rowsOf(await db.execute(sql `
|
|
131
|
+
SELECT cursor, id, model, entity_id, type, data, organization_id, client_tx_id, occurred_at
|
|
132
|
+
FROM ablo_outbox WHERE cursor > ${after} ORDER BY cursor ASC LIMIT ${limit}`));
|
|
133
|
+
const events = rows.map((r) => outboxEventSchema.parse({
|
|
134
|
+
id: r.id,
|
|
135
|
+
model: r.model,
|
|
136
|
+
entityId: r.entity_id,
|
|
137
|
+
type: r.type,
|
|
138
|
+
data: r.data ?? null,
|
|
139
|
+
organizationId: r.organization_id ?? null,
|
|
140
|
+
clientTxId: r.client_tx_id ?? null,
|
|
141
|
+
occurredAt: r.occurred_at != null ? Number(r.occurred_at) : null,
|
|
142
|
+
cursor: String(r.cursor),
|
|
143
|
+
}));
|
|
144
|
+
return { events, nextCursor: events.length > 0 ? events[events.length - 1].cursor : null };
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
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
|
|
4
|
+
* conformance suite runs against to prove the suite itself is real (same role as
|
|
5
|
+
* the server's `memoryTenantDirectory`). A new ORM adapter is "done" when it
|
|
6
|
+
* passes the same suite this one passes.
|
|
7
|
+
*
|
|
8
|
+
* It models the real semantics minimally but faithfully: one canonical row store
|
|
9
|
+
* per model, an idempotency ledger keyed by `clientTxId`, and a monotonic outbox.
|
|
10
|
+
*/
|
|
11
|
+
import type { DataSourceAdapter } from '../adapter.js';
|
|
12
|
+
export declare function memoryDataSource(): DataSourceAdapter;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
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
|
|
4
|
+
* conformance suite runs against to prove the suite itself is real (same role as
|
|
5
|
+
* the server's `memoryTenantDirectory`). A new ORM adapter is "done" when it
|
|
6
|
+
* passes the same suite this one passes.
|
|
7
|
+
*
|
|
8
|
+
* It models the real semantics minimally but faithfully: one canonical row store
|
|
9
|
+
* per model, an idempotency ledger keyed by `clientTxId`, and a monotonic outbox.
|
|
10
|
+
*/
|
|
11
|
+
function rowId(op) {
|
|
12
|
+
const id = op.id ?? op.input?.id;
|
|
13
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
14
|
+
throw new Error(`operation on "${op.model}" requires an id`);
|
|
15
|
+
}
|
|
16
|
+
return id;
|
|
17
|
+
}
|
|
18
|
+
export function memoryDataSource() {
|
|
19
|
+
/** model → (id → row). */
|
|
20
|
+
const store = new Map();
|
|
21
|
+
/** clientTxId → the rows that commit returned (idempotency ledger). */
|
|
22
|
+
const idempotency = new Map();
|
|
23
|
+
/** Append-only outbox; `cursor` is the 1-based index as a string. */
|
|
24
|
+
const outbox = [];
|
|
25
|
+
const modelStore = (model) => {
|
|
26
|
+
let m = store.get(model);
|
|
27
|
+
if (!m) {
|
|
28
|
+
m = new Map();
|
|
29
|
+
store.set(model, m);
|
|
30
|
+
}
|
|
31
|
+
return m;
|
|
32
|
+
};
|
|
33
|
+
const applyOperation = (op) => {
|
|
34
|
+
const m = modelStore(op.model);
|
|
35
|
+
const id = rowId(op);
|
|
36
|
+
switch (op.type) {
|
|
37
|
+
case 'CREATE': {
|
|
38
|
+
const row = { id, ...(op.input ?? {}) };
|
|
39
|
+
m.set(id, row);
|
|
40
|
+
return row;
|
|
41
|
+
}
|
|
42
|
+
case 'UPDATE':
|
|
43
|
+
case 'ARCHIVE':
|
|
44
|
+
case 'UNARCHIVE': {
|
|
45
|
+
const prev = m.get(id) ?? { id };
|
|
46
|
+
const row = {
|
|
47
|
+
...prev,
|
|
48
|
+
...(op.input ?? {}),
|
|
49
|
+
...(op.type === 'ARCHIVE' ? { archivedAt: Date.now() } : {}),
|
|
50
|
+
...(op.type === 'UNARCHIVE' ? { archivedAt: null } : {}),
|
|
51
|
+
};
|
|
52
|
+
m.set(id, row);
|
|
53
|
+
return row;
|
|
54
|
+
}
|
|
55
|
+
case 'DELETE': {
|
|
56
|
+
const prev = m.get(id) ?? { id };
|
|
57
|
+
m.delete(id);
|
|
58
|
+
return prev;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
capabilities: { transactions: true, propose: false, schemaIntrospection: false },
|
|
64
|
+
migrations() {
|
|
65
|
+
// In-memory: no DDL. A real ORM adapter ships ablo_idempotency + ablo_outbox here.
|
|
66
|
+
return [];
|
|
67
|
+
},
|
|
68
|
+
async read(req) {
|
|
69
|
+
const m = store.get(req.model);
|
|
70
|
+
if (!m)
|
|
71
|
+
return [];
|
|
72
|
+
if (req.kind === 'load') {
|
|
73
|
+
const row = m.get(req.id);
|
|
74
|
+
return row ? [row] : [];
|
|
75
|
+
}
|
|
76
|
+
let rows = [...m.values()];
|
|
77
|
+
const limit = req.query?.limit;
|
|
78
|
+
if (typeof limit === 'number')
|
|
79
|
+
rows = rows.slice(0, limit);
|
|
80
|
+
return rows;
|
|
81
|
+
},
|
|
82
|
+
async commit(change) {
|
|
83
|
+
// Idempotency: a duplicate clientTxId returns the original rows, no re-apply.
|
|
84
|
+
const cached = idempotency.get(change.clientTxId);
|
|
85
|
+
if (cached)
|
|
86
|
+
return { rows: cached };
|
|
87
|
+
const rows = [];
|
|
88
|
+
for (const [index, op] of change.operations.entries()) {
|
|
89
|
+
const row = applyOperation(op);
|
|
90
|
+
rows.push(row);
|
|
91
|
+
// Transactional outbox: one event per op, monotonic cursor.
|
|
92
|
+
outbox.push({
|
|
93
|
+
id: `${change.clientTxId}:${index}`,
|
|
94
|
+
model: op.model,
|
|
95
|
+
entityId: String(row.id ?? rowId(op)),
|
|
96
|
+
type: op.type,
|
|
97
|
+
data: op.type === 'DELETE' ? null : row,
|
|
98
|
+
clientTxId: change.clientTxId,
|
|
99
|
+
cursor: String(outbox.length + 1),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
idempotency.set(change.clientTxId, rows);
|
|
103
|
+
return { rows };
|
|
104
|
+
},
|
|
105
|
+
async events(cursor, limit) {
|
|
106
|
+
const after = cursor ? Number(cursor) : 0;
|
|
107
|
+
const page = outbox.filter((e) => Number(e.cursor) > after).slice(0, limit);
|
|
108
|
+
return {
|
|
109
|
+
events: page,
|
|
110
|
+
nextCursor: page.length > 0 ? page[page.length - 1].cursor : null,
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
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
|
|
4
|
+
* same conformance suite the in-memory reference passes).
|
|
5
|
+
*
|
|
6
|
+
* It owns the transactional outbox + idempotency so the customer never writes
|
|
7
|
+
* them: `commit` runs the app-row mutations, the `ablo_outbox` append, and the
|
|
8
|
+
* `ablo_idempotency` record in ONE `prisma.$transaction`. `migrations()` ships
|
|
9
|
+
* the DDL for those two tables (`ablo init` emits it).
|
|
10
|
+
*
|
|
11
|
+
* No `@prisma/client` dependency: the client is accepted structurally
|
|
12
|
+
* (`PrismaLike`), so this compiles in the SDK package and is unit-testable with
|
|
13
|
+
* a fake, while a real `PrismaClient` satisfies it at the call site.
|
|
14
|
+
*/
|
|
15
|
+
import type { DataSourceAdapter, Row } from '../adapter.js';
|
|
16
|
+
import type { SchemaRecord, Schema } from '../../schema/schema.js';
|
|
17
|
+
/** A Prisma model delegate (the subset we call). */
|
|
18
|
+
export interface PrismaDelegate {
|
|
19
|
+
findUnique(args: {
|
|
20
|
+
where: {
|
|
21
|
+
id: string;
|
|
22
|
+
};
|
|
23
|
+
}): Promise<Row | null>;
|
|
24
|
+
findMany(args: {
|
|
25
|
+
where?: Record<string, unknown>;
|
|
26
|
+
take?: number;
|
|
27
|
+
orderBy?: Record<string, 'asc' | 'desc'>;
|
|
28
|
+
}): Promise<Row[]>;
|
|
29
|
+
create(args: {
|
|
30
|
+
data: Row;
|
|
31
|
+
}): Promise<Row>;
|
|
32
|
+
update(args: {
|
|
33
|
+
where: {
|
|
34
|
+
id: string;
|
|
35
|
+
};
|
|
36
|
+
data: Row;
|
|
37
|
+
}): Promise<Row>;
|
|
38
|
+
delete(args: {
|
|
39
|
+
where: {
|
|
40
|
+
id: string;
|
|
41
|
+
};
|
|
42
|
+
}): Promise<Row>;
|
|
43
|
+
}
|
|
44
|
+
/** The raw-SQL surface used for the adapter-owned tables. */
|
|
45
|
+
export interface PrismaRaw {
|
|
46
|
+
$executeRawUnsafe(query: string, ...values: unknown[]): Promise<number>;
|
|
47
|
+
$queryRawUnsafe<T = unknown>(query: string, ...values: unknown[]): Promise<T>;
|
|
48
|
+
}
|
|
49
|
+
/** A Prisma client (or interactive-tx client) — structural, no SDK dependency. */
|
|
50
|
+
export interface PrismaLike extends PrismaRaw {
|
|
51
|
+
$transaction<T>(fn: (tx: PrismaLike & PrismaRaw) => Promise<T>): Promise<T>;
|
|
52
|
+
}
|
|
53
|
+
export interface PrismaDataSourceOptions {
|
|
54
|
+
/** Map a schema model name → its Prisma delegate name. Default: lower-first-letter. */
|
|
55
|
+
readonly delegateName?: (model: string) => string;
|
|
56
|
+
}
|
|
57
|
+
export declare function prismaDataSource<S extends SchemaRecord>(prisma: PrismaLike, schema: Schema<S>, options?: PrismaDataSourceOptions): DataSourceAdapter;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
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
|
|
4
|
+
* same conformance suite the in-memory reference passes).
|
|
5
|
+
*
|
|
6
|
+
* It owns the transactional outbox + idempotency so the customer never writes
|
|
7
|
+
* them: `commit` runs the app-row mutations, the `ablo_outbox` append, and the
|
|
8
|
+
* `ablo_idempotency` record in ONE `prisma.$transaction`. `migrations()` ships
|
|
9
|
+
* the DDL for those two tables (`ablo init` emits it).
|
|
10
|
+
*
|
|
11
|
+
* No `@prisma/client` dependency: the client is accepted structurally
|
|
12
|
+
* (`PrismaLike`), so this compiles in the SDK package and is unit-testable with
|
|
13
|
+
* a fake, while a real `PrismaClient` satisfies it at the call site.
|
|
14
|
+
*/
|
|
15
|
+
import { outboxEventSchema } from '../contract.js';
|
|
16
|
+
const lowerFirst = (s) => (s ? s[0].toLowerCase() + s.slice(1) : s);
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a model's Prisma delegate by name. This is the ONE irreducible cast in
|
|
19
|
+
* the adapter layer, and it's a genuine type-system limit, not laziness:
|
|
20
|
+
*
|
|
21
|
+
* - Inside `prisma.$transaction(tx => …)` the writes MUST go through the
|
|
22
|
+
* 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
|
+
* dynamic `tx[name]` is `unknown` to the compiler — there is no key to infer.
|
|
25
|
+
*
|
|
26
|
+
* Dynamic property access on a statically-keyed type cannot be typed without an
|
|
27
|
+
* assertion; this is the reflection boundary, validated at runtime (`findMany` is
|
|
28
|
+
* a function) right after. `ablo generate` removes even this by emitting a typed
|
|
29
|
+
* `model → delegate` map, at which point this helper is replaced by a lookup.
|
|
30
|
+
*/
|
|
31
|
+
function delegateFor(client, name) {
|
|
32
|
+
const delegate = client[name];
|
|
33
|
+
if (!delegate || typeof delegate.findMany !== 'function') {
|
|
34
|
+
throw new Error(`prismaDataSource: no Prisma delegate "${name}" on the client`);
|
|
35
|
+
}
|
|
36
|
+
return delegate;
|
|
37
|
+
}
|
|
38
|
+
/** Translate a Source `where` tuple set into a Prisma `where` object. */
|
|
39
|
+
function toPrismaWhere(where) {
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const clause of where ?? []) {
|
|
42
|
+
const [field] = clause;
|
|
43
|
+
if (clause.length === 2) {
|
|
44
|
+
out[field] = clause[1];
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const [, op, value] = clause;
|
|
48
|
+
switch (op) {
|
|
49
|
+
case '=':
|
|
50
|
+
out[field] = value;
|
|
51
|
+
break;
|
|
52
|
+
case '!=':
|
|
53
|
+
out[field] = { not: value };
|
|
54
|
+
break;
|
|
55
|
+
case '<':
|
|
56
|
+
out[field] = { lt: value };
|
|
57
|
+
break;
|
|
58
|
+
case '<=':
|
|
59
|
+
out[field] = { lte: value };
|
|
60
|
+
break;
|
|
61
|
+
case '>':
|
|
62
|
+
out[field] = { gt: value };
|
|
63
|
+
break;
|
|
64
|
+
case '>=':
|
|
65
|
+
out[field] = { gte: value };
|
|
66
|
+
break;
|
|
67
|
+
case 'IN':
|
|
68
|
+
out[field] = { in: value };
|
|
69
|
+
break;
|
|
70
|
+
case 'NOT IN':
|
|
71
|
+
out[field] = { notIn: value };
|
|
72
|
+
break;
|
|
73
|
+
case 'LIKE':
|
|
74
|
+
case 'ILIKE':
|
|
75
|
+
out[field] = { contains: value, mode: op === 'ILIKE' ? 'insensitive' : 'default' };
|
|
76
|
+
break;
|
|
77
|
+
case 'NOT LIKE':
|
|
78
|
+
case 'NOT ILIKE':
|
|
79
|
+
out[field] = { not: { contains: value } };
|
|
80
|
+
break;
|
|
81
|
+
case 'IS':
|
|
82
|
+
case 'IS NOT':
|
|
83
|
+
out[field] = op === 'IS' ? value : { not: value };
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
function findManyArgs(query) {
|
|
90
|
+
return {
|
|
91
|
+
where: toPrismaWhere(query?.where),
|
|
92
|
+
...(typeof query?.limit === 'number' ? { take: query.limit } : {}),
|
|
93
|
+
...(query?.orderBy ? { orderBy: { [query.orderBy]: query.order ?? 'asc' } } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function rowId(op) {
|
|
97
|
+
const id = op.id ?? op.input?.id;
|
|
98
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
99
|
+
throw new Error(`operation on "${op.model}" requires an id`);
|
|
100
|
+
}
|
|
101
|
+
return id;
|
|
102
|
+
}
|
|
103
|
+
export function prismaDataSource(prisma, schema, options = {}) {
|
|
104
|
+
const delegateName = options.delegateName ?? lowerFirst;
|
|
105
|
+
void schema; // reserved for codegen-typed reads / model validation
|
|
106
|
+
const applyOperation = async (tx, op) => {
|
|
107
|
+
const delegate = delegateFor(tx, delegateName(op.model));
|
|
108
|
+
const id = rowId(op);
|
|
109
|
+
switch (op.type) {
|
|
110
|
+
case 'CREATE':
|
|
111
|
+
return delegate.create({ data: { id, ...(op.input ?? {}) } });
|
|
112
|
+
case 'UPDATE':
|
|
113
|
+
return delegate.update({ where: { id }, data: { ...(op.input ?? {}) } });
|
|
114
|
+
case 'ARCHIVE':
|
|
115
|
+
return delegate.update({ where: { id }, data: { ...(op.input ?? {}), archivedAt: new Date() } });
|
|
116
|
+
case 'UNARCHIVE':
|
|
117
|
+
return delegate.update({ where: { id }, data: { ...(op.input ?? {}), archivedAt: null } });
|
|
118
|
+
case 'DELETE':
|
|
119
|
+
return delegate.delete({ where: { id } });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
capabilities: { transactions: true, propose: false, schemaIntrospection: true },
|
|
124
|
+
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
|
+
];
|
|
150
|
+
},
|
|
151
|
+
async read(req) {
|
|
152
|
+
const delegate = delegateFor(prisma, delegateName(req.model));
|
|
153
|
+
if (req.kind === 'load') {
|
|
154
|
+
const row = await delegate.findUnique({ where: { id: req.id } });
|
|
155
|
+
return row ? [row] : [];
|
|
156
|
+
}
|
|
157
|
+
return delegate.findMany(findManyArgs(req.query));
|
|
158
|
+
},
|
|
159
|
+
async commit(change) {
|
|
160
|
+
return prisma.$transaction(async (tx) => {
|
|
161
|
+
// Idempotency: a duplicate clientTxId returns the original rows, no re-apply.
|
|
162
|
+
const cached = await tx.$queryRawUnsafe(`SELECT response FROM ablo_idempotency WHERE client_tx_id = $1 LIMIT 1`, change.clientTxId);
|
|
163
|
+
if (cached.length > 0)
|
|
164
|
+
return { rows: cached[0].response };
|
|
165
|
+
const rows = [];
|
|
166
|
+
for (const [index, op] of change.operations.entries()) {
|
|
167
|
+
const row = await applyOperation(tx, op);
|
|
168
|
+
rows.push(row);
|
|
169
|
+
const entityId = String(row.id ?? rowId(op));
|
|
170
|
+
// Transactional outbox: one event per op, written in THIS tx.
|
|
171
|
+
await tx.$executeRawUnsafe(`INSERT INTO ablo_outbox (id, model, entity_id, type, data, client_tx_id, occurred_at)
|
|
172
|
+
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
|
+
}
|
|
174
|
+
await tx.$executeRawUnsafe(`INSERT INTO ablo_idempotency (client_tx_id, response) VALUES ($1, $2::jsonb)`, change.clientTxId, JSON.stringify(rows));
|
|
175
|
+
return { rows };
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
async events(cursor, limit) {
|
|
179
|
+
const after = cursor ? cursor : '0';
|
|
180
|
+
const rows = await prisma.$queryRawUnsafe(`SELECT cursor, id, model, entity_id, type, data, organization_id, client_tx_id, occurred_at
|
|
181
|
+
FROM ablo_outbox WHERE cursor > $1 ORDER BY cursor ASC LIMIT $2`, after, limit);
|
|
182
|
+
const events = rows.map((r) => outboxEventSchema.parse({
|
|
183
|
+
id: r.id,
|
|
184
|
+
model: r.model,
|
|
185
|
+
entityId: r.entity_id,
|
|
186
|
+
type: r.type,
|
|
187
|
+
data: r.data ?? null,
|
|
188
|
+
organizationId: r.organization_id ?? null,
|
|
189
|
+
clientTxId: r.client_tx_id ?? null,
|
|
190
|
+
occurredAt: r.occurred_at != null ? Number(r.occurred_at) : null,
|
|
191
|
+
cursor: String(r.cursor),
|
|
192
|
+
}));
|
|
193
|
+
return {
|
|
194
|
+
events,
|
|
195
|
+
nextCursor: events.length > 0 ? events[events.length - 1].cursor : null,
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Source adapter conformance suite — the shared "is this adapter correct?"
|
|
3
|
+
* test set, in the Auth.js `@auth/adapter-test` mould. Every ORM adapter
|
|
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"
|
|
6
|
+
* when it passes — not when it compiles.
|
|
7
|
+
*
|
|
8
|
+
* Runner-agnostic: checks are plain async functions that throw (node:assert) on
|
|
9
|
+
* failure. `runDataSourceTests` registers them with whatever `it`/`test` you
|
|
10
|
+
* pass, so it works under vitest, jest, or node:test:
|
|
11
|
+
*
|
|
12
|
+
* import { it } from 'vitest';
|
|
13
|
+
* runDataSourceTests(memoryDataSource, it);
|
|
14
|
+
*
|
|
15
|
+
* Scope: this covers the ADAPTER contract (commit idempotency, read-after-write,
|
|
16
|
+
* the transactional outbox + cursor). Signature/scope rejection is a HANDLER
|
|
17
|
+
* concern (the adapter never sees a signature) and is tested separately.
|
|
18
|
+
*/
|
|
19
|
+
import type { DataSourceAdapter } from './adapter.js';
|
|
20
|
+
export type MakeAdapter = () => DataSourceAdapter | Promise<DataSourceAdapter>;
|
|
21
|
+
/** A single conformance check. `run` throws on failure. */
|
|
22
|
+
export interface ConformanceCheck {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
run(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export declare function dataSourceConformanceChecks(make: MakeAdapter): ConformanceCheck[];
|
|
27
|
+
/**
|
|
28
|
+
* Register the conformance checks with a test runner's `it`/`test` function.
|
|
29
|
+
* `register(name, fn)` — pass vitest/jest `it` or `node:test` `test`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function runDataSourceTests(make: MakeAdapter, register: (name: string, fn: () => Promise<void>) => void): void;
|
|
32
|
+
export { memoryDataSource } from './adapters/memory.js';
|