@abloatai/ablo 0.8.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.
Files changed (162) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +32 -27
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +172 -2
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/agent/session.js +3 -3
  8. package/dist/ai-sdk/coordination-context.js +4 -0
  9. package/dist/ai-sdk/index.d.ts +56 -47
  10. package/dist/ai-sdk/index.js +56 -47
  11. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  12. package/dist/ai-sdk/intent-broadcast.js +11 -4
  13. package/dist/ai-sdk/wrap.d.ts +14 -11
  14. package/dist/ai-sdk/wrap.js +11 -13
  15. package/dist/auth/credentialSource.d.ts +34 -0
  16. package/dist/auth/credentialSource.js +63 -0
  17. package/dist/auth/index.d.ts +2 -22
  18. package/dist/auth/index.js +4 -42
  19. package/dist/auth/schemas.d.ts +35 -0
  20. package/dist/auth/schemas.js +53 -0
  21. package/dist/client/Ablo.d.ts +160 -42
  22. package/dist/client/Ablo.js +145 -75
  23. package/dist/client/ApiClient.d.ts +20 -4
  24. package/dist/client/ApiClient.js +166 -28
  25. package/dist/client/auth.d.ts +14 -5
  26. package/dist/client/auth.js +60 -7
  27. package/dist/client/createInternalComponents.d.ts +2 -0
  28. package/dist/client/createInternalComponents.js +8 -1
  29. package/dist/client/createModelProxy.d.ts +130 -66
  30. package/dist/client/createModelProxy.js +152 -49
  31. package/dist/client/httpClient.d.ts +71 -0
  32. package/dist/client/httpClient.js +69 -0
  33. package/dist/client/identity.d.ts +2 -6
  34. package/dist/client/identity.js +49 -11
  35. package/dist/client/index.d.ts +1 -0
  36. package/dist/client/index.js +1 -0
  37. package/dist/client/registerDataSource.d.ts +3 -3
  38. package/dist/client/registerDataSource.js +11 -9
  39. package/dist/client/validateAbloOptions.js +1 -1
  40. package/dist/core/DatabaseManager.js +30 -2
  41. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  42. package/dist/core/openIDBWithTimeout.js +88 -1
  43. package/dist/errorCodes.d.ts +70 -1
  44. package/dist/errorCodes.js +108 -9
  45. package/dist/errors.d.ts +2 -2
  46. package/dist/errors.js +72 -22
  47. package/dist/index.d.ts +17 -8
  48. package/dist/index.js +15 -6
  49. package/dist/keys/index.d.ts +16 -1
  50. package/dist/keys/index.js +26 -6
  51. package/dist/mutators/UndoManager.d.ts +86 -50
  52. package/dist/mutators/UndoManager.js +129 -22
  53. package/dist/mutators/inverseOp.d.ts +129 -0
  54. package/dist/mutators/inverseOp.js +74 -0
  55. package/dist/mutators/readerActions.d.ts +1 -1
  56. package/dist/mutators/undoApply.d.ts +42 -0
  57. package/dist/mutators/undoApply.js +143 -0
  58. package/dist/query/client.d.ts +10 -9
  59. package/dist/query/client.js +3 -6
  60. package/dist/react/AbloProvider.d.ts +23 -126
  61. package/dist/react/AbloProvider.js +62 -199
  62. package/dist/react/useAblo.d.ts +2 -2
  63. package/dist/react/useCurrentUserId.d.ts +1 -1
  64. package/dist/react/useCurrentUserId.js +1 -1
  65. package/dist/react/useMutators.js +19 -12
  66. package/dist/schema/ddl.d.ts +26 -3
  67. package/dist/schema/ddl.js +152 -4
  68. package/dist/schema/index.d.ts +4 -0
  69. package/dist/schema/index.js +12 -0
  70. package/dist/schema/model.d.ts +11 -0
  71. package/dist/schema/model.js +2 -0
  72. package/dist/schema/openapi.d.ts +28 -0
  73. package/dist/schema/openapi.js +118 -0
  74. package/dist/schema/plane.d.ts +23 -0
  75. package/dist/schema/plane.js +19 -0
  76. package/dist/schema/relation.d.ts +20 -0
  77. package/dist/schema/serialize.d.ts +4 -0
  78. package/dist/schema/serialize.js +4 -0
  79. package/dist/schema/sync-delta-row.d.ts +157 -0
  80. package/dist/schema/sync-delta-row.js +102 -0
  81. package/dist/schema/sync-delta-wire.d.ts +180 -0
  82. package/dist/schema/sync-delta-wire.js +102 -0
  83. package/dist/server/adapter.d.ts +156 -0
  84. package/dist/server/adapter.js +19 -0
  85. package/dist/server/commit.d.ts +82 -0
  86. package/dist/server/commit.js +1 -0
  87. package/dist/server/index.d.ts +14 -0
  88. package/dist/server/index.js +1 -0
  89. package/dist/server/next.d.ts +51 -0
  90. package/dist/server/next.js +47 -0
  91. package/dist/server/read-config.d.ts +60 -0
  92. package/dist/server/read-config.js +8 -0
  93. package/dist/server/storage-mode.d.ts +17 -0
  94. package/dist/server/storage-mode.js +12 -0
  95. package/dist/source/adapter.d.ts +59 -0
  96. package/dist/source/adapter.js +19 -0
  97. package/dist/source/adapters/drizzle.d.ts +34 -0
  98. package/dist/source/adapters/drizzle.js +147 -0
  99. package/dist/source/adapters/memory.d.ts +12 -0
  100. package/dist/source/adapters/memory.js +114 -0
  101. package/dist/source/adapters/prisma.d.ts +57 -0
  102. package/dist/source/adapters/prisma.js +199 -0
  103. package/dist/source/conformance.d.ts +32 -0
  104. package/dist/source/conformance.js +134 -0
  105. package/dist/source/contract.d.ts +143 -0
  106. package/dist/source/contract.js +98 -0
  107. package/dist/source/index.d.ts +61 -10
  108. package/dist/source/index.js +98 -0
  109. package/dist/source/next.d.ts +33 -0
  110. package/dist/source/next.js +26 -0
  111. package/dist/sync/BootstrapHelper.d.ts +10 -0
  112. package/dist/sync/BootstrapHelper.js +10 -15
  113. package/dist/sync/ConnectionManager.d.ts +55 -1
  114. package/dist/sync/ConnectionManager.js +155 -16
  115. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  116. package/dist/sync/HydrationCoordinator.js +238 -39
  117. package/dist/sync/NetworkProbe.d.ts +58 -24
  118. package/dist/sync/NetworkProbe.js +118 -42
  119. package/dist/sync/SyncWebSocket.d.ts +45 -70
  120. package/dist/sync/SyncWebSocket.js +70 -36
  121. package/dist/sync/createIntentStream.js +10 -1
  122. package/dist/types/streams.d.ts +9 -0
  123. package/dist/utils/mobx-setup.js +1 -0
  124. package/dist/webhooks/events.d.ts +38 -0
  125. package/dist/webhooks/events.js +40 -0
  126. package/dist/webhooks/index.d.ts +10 -0
  127. package/dist/webhooks/index.js +10 -0
  128. package/dist/wire/errorEnvelope.d.ts +34 -0
  129. package/dist/wire/errorEnvelope.js +86 -0
  130. package/dist/wire/frames.d.ts +119 -0
  131. package/dist/wire/frames.js +1 -0
  132. package/dist/wire/index.d.ts +24 -0
  133. package/dist/wire/index.js +21 -0
  134. package/dist/wire/listEnvelope.d.ts +45 -0
  135. package/dist/wire/listEnvelope.js +17 -0
  136. package/docs/api.md +47 -44
  137. package/docs/cli.md +44 -44
  138. package/docs/client-behavior.md +30 -30
  139. package/docs/coordination.md +33 -36
  140. package/docs/data-sources.md +35 -15
  141. package/docs/examples/agent-human.md +45 -43
  142. package/docs/examples/ai-sdk-tool.md +20 -16
  143. package/docs/examples/existing-python-backend.md +16 -12
  144. package/docs/examples/nextjs.md +14 -12
  145. package/docs/examples/scoped-agent.md +1 -1
  146. package/docs/examples/server-agent.md +24 -21
  147. package/docs/guarantees.md +15 -13
  148. package/docs/index.md +1 -1
  149. package/docs/integration-guide.md +30 -30
  150. package/docs/interaction-model.md +19 -23
  151. package/docs/mcp/claude-code.md +3 -3
  152. package/docs/mcp/cursor.md +1 -1
  153. package/docs/mcp/windsurf.md +2 -2
  154. package/docs/mcp.md +6 -6
  155. package/docs/quickstart.md +41 -31
  156. package/docs/react.md +13 -9
  157. package/docs/schema-contract.md +12 -10
  158. package/docs/the-loop.md +21 -0
  159. package/examples/data-source/README.md +4 -5
  160. package/examples/data-source/customer-server.ts +27 -25
  161. package/llms.txt +28 -5
  162. package/package.json +43 -3
@@ -0,0 +1,134 @@
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 assert from 'node:assert/strict';
20
+ const change = (clientTxId, ops) => ({
21
+ clientTxId,
22
+ operations: ops,
23
+ });
24
+ export function dataSourceConformanceChecks(make) {
25
+ return [
26
+ {
27
+ name: 'commit applies a CREATE and returns the canonical row',
28
+ run: async () => {
29
+ const adapter = await make();
30
+ const result = await adapter.commit(change('tx_create', [{ type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } }]));
31
+ assert.equal(result.rows.length, 1, 'one row returned');
32
+ assert.equal(result.rows[0].id, 't1');
33
+ assert.equal(result.rows[0].title, 'A');
34
+ },
35
+ },
36
+ {
37
+ name: 'read load returns a committed row, and null-equivalent for an unknown id',
38
+ run: async () => {
39
+ const adapter = await make();
40
+ await adapter.commit(change('tx1', [{ type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } }]));
41
+ const found = await adapter.read({ kind: 'load', model: 'task', id: 't1' });
42
+ assert.equal(found.length, 1);
43
+ assert.equal(found[0].title, 'A');
44
+ const missing = await adapter.read({ kind: 'load', model: 'task', id: 'nope' });
45
+ assert.equal(missing.length, 0, 'unknown id reads empty');
46
+ },
47
+ },
48
+ {
49
+ name: 'read list returns committed rows',
50
+ run: async () => {
51
+ const adapter = await make();
52
+ await adapter.commit(change('tx_list', [
53
+ { type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } },
54
+ { type: 'CREATE', model: 'task', id: 't2', input: { title: 'B' } },
55
+ ]));
56
+ const rows = await adapter.read({ kind: 'list', model: 'task' });
57
+ const ids = rows.map((r) => r.id).sort();
58
+ assert.deepEqual(ids, ['t1', 't2']);
59
+ },
60
+ },
61
+ {
62
+ name: 'duplicate clientTxId is idempotent — same rows, applied once',
63
+ run: async () => {
64
+ const adapter = await make();
65
+ const cs = change('tx_dup', [{ type: 'CREATE', model: 'task', id: 't1', input: { title: 'A', n: 1 } }]);
66
+ const first = await adapter.commit(cs);
67
+ const second = await adapter.commit(cs);
68
+ assert.deepEqual(second.rows, first.rows, 'replay returns the original rows');
69
+ // Applied once: still exactly one row, and the outbox did not double up.
70
+ const rows = await adapter.read({ kind: 'list', model: 'task' });
71
+ assert.equal(rows.length, 1, 'no duplicate row');
72
+ const page = await adapter.events(null, 100);
73
+ const forTx = page.events.filter((e) => e.clientTxId === 'tx_dup');
74
+ assert.equal(forTx.length, 1, 'outbox not double-appended on replay');
75
+ },
76
+ },
77
+ {
78
+ name: 'commit appends outbox events with the originating clientTxId',
79
+ run: async () => {
80
+ const adapter = await make();
81
+ await adapter.commit(change('tx_evt', [{ type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } }]));
82
+ const page = await adapter.events(null, 100);
83
+ assert.ok(page.events.length >= 1, 'at least one event');
84
+ const evt = page.events.find((e) => e.entityId === 't1');
85
+ assert.ok(evt, 'event for the committed row');
86
+ assert.equal(evt?.model, 'task');
87
+ assert.equal(evt?.type, 'CREATE');
88
+ assert.equal(evt?.clientTxId, 'tx_evt');
89
+ },
90
+ },
91
+ {
92
+ name: 'events cursor advances and never re-delivers a page',
93
+ run: async () => {
94
+ const adapter = await make();
95
+ await adapter.commit(change('tx_a', [{ type: 'CREATE', model: 'task', id: 't1', input: {} }]));
96
+ await adapter.commit(change('tx_b', [{ type: 'CREATE', model: 'task', id: 't2', input: {} }]));
97
+ const first = await adapter.events(null, 1);
98
+ assert.equal(first.events.length, 1, 'respects limit');
99
+ assert.ok(first.nextCursor, 'returns a cursor');
100
+ const second = await adapter.events(first.nextCursor, 100);
101
+ // No overlap: the second page starts strictly after the first cursor.
102
+ const firstIds = new Set(first.events.map((e) => e.id));
103
+ for (const e of second.events) {
104
+ assert.ok(!firstIds.has(e.id), `event ${e.id} re-delivered across cursor`);
105
+ }
106
+ // Draining to the end yields a stable terminal cursor.
107
+ const drained = await adapter.events(second.nextCursor ?? first.nextCursor, 100);
108
+ assert.equal(drained.events.length, 0, 'fully drained');
109
+ },
110
+ },
111
+ {
112
+ name: 'a later UPDATE under a new clientTxId is applied (idempotency is per-tx)',
113
+ run: async () => {
114
+ const adapter = await make();
115
+ await adapter.commit(change('tx_c1', [{ type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } }]));
116
+ await adapter.commit(change('tx_u1', [{ type: 'UPDATE', model: 'task', id: 't1', input: { title: 'B' } }]));
117
+ const found = await adapter.read({ kind: 'load', model: 'task', id: 't1' });
118
+ assert.equal(found[0].title, 'B', 'update applied');
119
+ },
120
+ },
121
+ ];
122
+ }
123
+ /**
124
+ * Register the conformance checks with a test runner's `it`/`test` function.
125
+ * `register(name, fn)` — pass vitest/jest `it` or `node:test` `test`.
126
+ */
127
+ export function runDataSourceTests(make, register) {
128
+ for (const check of dataSourceConformanceChecks(make)) {
129
+ register(check.name, check.run);
130
+ }
131
+ }
132
+ // Re-export the reference adapter so `@abloatai/ablo/source/conformance`
133
+ // exposes both the suite and the in-memory double in one import.
134
+ export { memoryDataSource } from './adapters/memory.js';
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Data Source adapter contract — Zod-first.
3
+ *
4
+ * The wire shapes an ORM adapter (Prisma/Drizzle/Kysely) consumes and produces
5
+ * are defined here as Zod schemas, not hand-typed interfaces, so they are
6
+ * VALIDATED at the boundary (a malformed op / outbox row is rejected at the
7
+ * edge, not deep inside a transaction) and every inferred type flows from one
8
+ * source. This mirrors the server's `tenant-connection.schema.ts` convention:
9
+ * schema-validate what crosses a trust boundary, infer the TS types from it.
10
+ *
11
+ * Scope note: the existing `SourceOperation` / `SourceEvent` interfaces in
12
+ * `index.ts` are the established cross-package wire types — this module does NOT
13
+ * redefine them. It owns the ADAPTER-level contract (the change envelope the
14
+ * adapter commits, the outbox row it persists, the migration it ships) and keeps
15
+ * `operationSchema` structurally compatible with `SourceOperation` (asserted at
16
+ * the bottom of the file) so the two never drift.
17
+ */
18
+ import { z } from 'zod';
19
+ /** Mirrors `SourceOperation['type']`. */
20
+ export declare const operationTypeSchema: z.ZodEnum<{
21
+ CREATE: "CREATE";
22
+ UPDATE: "UPDATE";
23
+ DELETE: "DELETE";
24
+ ARCHIVE: "ARCHIVE";
25
+ UNARCHIVE: "UNARCHIVE";
26
+ }>;
27
+ export type OperationType = z.infer<typeof operationTypeSchema>;
28
+ /**
29
+ * One operation in a change set. Structurally compatible with `SourceOperation`
30
+ * (see the assertion below) — this is its runtime validator.
31
+ */
32
+ export declare const operationSchema: z.ZodObject<{
33
+ type: z.ZodEnum<{
34
+ CREATE: "CREATE";
35
+ UPDATE: "UPDATE";
36
+ DELETE: "DELETE";
37
+ ARCHIVE: "ARCHIVE";
38
+ UNARCHIVE: "UNARCHIVE";
39
+ }>;
40
+ model: z.ZodString;
41
+ id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
42
+ input: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
43
+ transactionId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
44
+ readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
45
+ onStale: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
46
+ reject: "reject";
47
+ force: "force";
48
+ flag: "flag";
49
+ merge: "merge";
50
+ }>>>;
51
+ }, z.core.$strip>;
52
+ export type Operation = z.infer<typeof operationSchema>;
53
+ /**
54
+ * The atomic unit an adapter commits: one or more operations under a single
55
+ * `clientTxId`. The `clientTxId` is the idempotency key — committing the same
56
+ * change set twice must produce the same result exactly once.
57
+ */
58
+ export declare const changeSetSchema: z.ZodObject<{
59
+ operations: z.ZodArray<z.ZodObject<{
60
+ type: z.ZodEnum<{
61
+ CREATE: "CREATE";
62
+ UPDATE: "UPDATE";
63
+ DELETE: "DELETE";
64
+ ARCHIVE: "ARCHIVE";
65
+ UNARCHIVE: "UNARCHIVE";
66
+ }>;
67
+ model: z.ZodString;
68
+ id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
69
+ input: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
70
+ transactionId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
71
+ readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
72
+ onStale: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
73
+ reject: "reject";
74
+ force: "force";
75
+ flag: "flag";
76
+ merge: "merge";
77
+ }>>>;
78
+ }, z.core.$strip>>;
79
+ clientTxId: z.ZodString;
80
+ }, z.core.$strip>;
81
+ export type ChangeSet = z.infer<typeof changeSetSchema>;
82
+ /**
83
+ * A row in the adapter-owned `ablo_outbox` table. Written in the SAME
84
+ * transaction as the app-row mutation (transactional outbox), then read back by
85
+ * `events()` and handed to Ablo. `cursor` is the monotonic ordering key Ablo
86
+ * round-trips to resume.
87
+ */
88
+ export declare const outboxEventSchema: z.ZodObject<{
89
+ id: z.ZodString;
90
+ model: z.ZodString;
91
+ entityId: z.ZodString;
92
+ type: z.ZodEnum<{
93
+ CREATE: "CREATE";
94
+ UPDATE: "UPDATE";
95
+ DELETE: "DELETE";
96
+ ARCHIVE: "ARCHIVE";
97
+ UNARCHIVE: "UNARCHIVE";
98
+ }>;
99
+ data: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
100
+ organizationId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
101
+ clientTxId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
102
+ occurredAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
103
+ cursor: z.ZodString;
104
+ }, z.core.$strip>;
105
+ export type OutboxEvent = z.infer<typeof outboxEventSchema>;
106
+ /** A page of outbox events returned by `events()`. */
107
+ export declare const eventsPageSchema: z.ZodObject<{
108
+ events: z.ZodArray<z.ZodObject<{
109
+ id: z.ZodString;
110
+ model: z.ZodString;
111
+ entityId: z.ZodString;
112
+ type: z.ZodEnum<{
113
+ CREATE: "CREATE";
114
+ UPDATE: "UPDATE";
115
+ DELETE: "DELETE";
116
+ ARCHIVE: "ARCHIVE";
117
+ UNARCHIVE: "UNARCHIVE";
118
+ }>;
119
+ data: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
120
+ organizationId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
121
+ clientTxId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
122
+ occurredAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
123
+ cursor: z.ZodString;
124
+ }, z.core.$strip>>;
125
+ nextCursor: z.ZodNullable<z.ZodString>;
126
+ }, z.core.$strip>;
127
+ export type EventsPage = z.infer<typeof eventsPageSchema>;
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.
131
+ */
132
+ export declare const migrationSchema: z.ZodObject<{
133
+ name: z.ZodString;
134
+ up: z.ZodString;
135
+ }, z.core.$strip>;
136
+ export type Migration = z.infer<typeof migrationSchema>;
137
+ /** What an adapter's backend can do — a capability profile (no behavior inference). */
138
+ export declare const adapterCapabilitiesSchema: z.ZodObject<{
139
+ transactions: z.ZodBoolean;
140
+ propose: z.ZodBoolean;
141
+ schemaIntrospection: z.ZodBoolean;
142
+ }, z.core.$strip>;
143
+ export type AdapterCapabilities = z.infer<typeof adapterCapabilitiesSchema>;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Data Source adapter contract — Zod-first.
3
+ *
4
+ * The wire shapes an ORM adapter (Prisma/Drizzle/Kysely) consumes and produces
5
+ * are defined here as Zod schemas, not hand-typed interfaces, so they are
6
+ * VALIDATED at the boundary (a malformed op / outbox row is rejected at the
7
+ * edge, not deep inside a transaction) and every inferred type flows from one
8
+ * source. This mirrors the server's `tenant-connection.schema.ts` convention:
9
+ * schema-validate what crosses a trust boundary, infer the TS types from it.
10
+ *
11
+ * Scope note: the existing `SourceOperation` / `SourceEvent` interfaces in
12
+ * `index.ts` are the established cross-package wire types — this module does NOT
13
+ * redefine them. It owns the ADAPTER-level contract (the change envelope the
14
+ * adapter commits, the outbox row it persists, the migration it ships) and keeps
15
+ * `operationSchema` structurally compatible with `SourceOperation` (asserted at
16
+ * the bottom of the file) so the two never drift.
17
+ */
18
+ import { z } from 'zod';
19
+ const jsonObject = z.record(z.string(), z.unknown());
20
+ /** Mirrors `SourceOperation['type']`. */
21
+ export const operationTypeSchema = z.enum([
22
+ 'CREATE',
23
+ 'UPDATE',
24
+ 'DELETE',
25
+ 'ARCHIVE',
26
+ 'UNARCHIVE',
27
+ ]);
28
+ /**
29
+ * One operation in a change set. Structurally compatible with `SourceOperation`
30
+ * (see the assertion below) — this is its runtime validator.
31
+ */
32
+ export const operationSchema = z.object({
33
+ type: operationTypeSchema,
34
+ model: z.string().min(1),
35
+ id: z.string().min(1).nullish(),
36
+ input: jsonObject.nullish(),
37
+ transactionId: z.string().nullish(),
38
+ readAt: z.number().nullish(),
39
+ onStale: z.enum(['reject', 'force', 'flag', 'merge']).nullish(),
40
+ });
41
+ /**
42
+ * The atomic unit an adapter commits: one or more operations under a single
43
+ * `clientTxId`. The `clientTxId` is the idempotency key — committing the same
44
+ * change set twice must produce the same result exactly once.
45
+ */
46
+ export const changeSetSchema = z.object({
47
+ operations: z.array(operationSchema).min(1),
48
+ clientTxId: z.string().min(1),
49
+ });
50
+ /**
51
+ * A row in the adapter-owned `ablo_outbox` table. Written in the SAME
52
+ * transaction as the app-row mutation (transactional outbox), then read back by
53
+ * `events()` and handed to Ablo. `cursor` is the monotonic ordering key Ablo
54
+ * round-trips to resume.
55
+ */
56
+ export const outboxEventSchema = z.object({
57
+ /** Stable, globally-unique id — Ablo's replay-protection key. */
58
+ id: z.string().min(1),
59
+ model: z.string().min(1),
60
+ entityId: z.string().min(1),
61
+ type: operationTypeSchema,
62
+ data: jsonObject.nullish(),
63
+ organizationId: z.string().nullish(),
64
+ /** Round-tripped so Ablo can filter SDK-origin echoes after a direct append. */
65
+ clientTxId: z.string().nullish(),
66
+ occurredAt: z.number().nullish(),
67
+ /** Monotonic ordering key (bigint as string). `events()` pages by `cursor > ?`. */
68
+ cursor: z.string().min(1),
69
+ });
70
+ /** A page of outbox events returned by `events()`. */
71
+ export const eventsPageSchema = z.object({
72
+ events: z.array(outboxEventSchema),
73
+ nextCursor: z.string().nullable(),
74
+ });
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.
78
+ */
79
+ export const migrationSchema = z.object({
80
+ /** Stable name, used as the migration filename + applied-ledger key. */
81
+ name: z.string().min(1),
82
+ /** The forward SQL. */
83
+ up: z.string().min(1),
84
+ });
85
+ /** What an adapter's backend can do — a capability profile (no behavior inference). */
86
+ export const adapterCapabilitiesSchema = z.object({
87
+ /** `commit` is atomic across all operations in the change set. */
88
+ transactions: z.boolean(),
89
+ /** A dry-run `propose` is supported (else proposal lives above the adapter). */
90
+ propose: z.boolean(),
91
+ /** The backend can be introspected for its schema. */
92
+ schemaIntrospection: z.boolean(),
93
+ });
94
+ const _operationContractInSync = [
95
+ true,
96
+ true,
97
+ ];
98
+ void _operationContractInSync;
@@ -1,4 +1,5 @@
1
1
  import type { Schema, SchemaRecord, InferCreate } from '../schema/schema.js';
2
+ import type { DataSourceAdapter } from './adapter.js';
2
3
  export type SourcePrimitive = string | number | boolean | null;
3
4
  export type SourceWhere = readonly [field: string, value: SourcePrimitive] | readonly [
4
5
  field: string,
@@ -84,10 +85,11 @@ export interface SourceDelta {
84
85
  * `sync_deltas` and fan them out to connected clients exactly like
85
86
  * SDK-originated commits.
86
87
  *
87
- * The events handler can return everything from the outbox unfiltered;
88
- * Ablo dedupes against `mutation_log` server-side using `clientTxId`.
89
- * Events with no `clientTxId` are treated as external (cron jobs,
90
- * dashboard edits, batch imports) and always fan out.
88
+ * The events handler can return everything from the outbox unfiltered. Ablo
89
+ * dedupes stable `event.id` values and uses `clientTxId` to filter SDK-origin
90
+ * echoes after the direct append has already succeeded. If the direct append
91
+ * failed, the same outbox event repairs it on poll/push because no matching
92
+ * `mutation_log` row exists yet.
91
93
  */
92
94
  export interface SourceEvent {
93
95
  /**
@@ -122,6 +124,42 @@ export interface SourceEvent {
122
124
  */
123
125
  readonly occurredAt?: number;
124
126
  }
127
+ export interface SourceEventForOperationOptions {
128
+ /**
129
+ * Stable id from the customer's outbox table. This is Ablo's replay-
130
+ * protection key; retries must return the same id.
131
+ */
132
+ readonly eventId: string;
133
+ readonly operation: SourceOperation;
134
+ /**
135
+ * Committed row id. Defaults to `operation.id`; pass this for generated-id
136
+ * CREATEs where the database assigns the id inside the transaction.
137
+ */
138
+ readonly entityId?: string;
139
+ /**
140
+ * Canonical row payload after the write. Pass `null` for DELETE. When omitted
141
+ * the event carries no row payload, which is valid but less useful for
142
+ * realtime hydration.
143
+ */
144
+ readonly data?: Record<string, unknown> | null;
145
+ /**
146
+ * Batch idempotency key from the Data Source commit request. Round-tripping it
147
+ * lets Ablo filter SDK-origin echoes after the direct append succeeds, while
148
+ * still using the outbox event to repair a failed direct append.
149
+ */
150
+ readonly clientTxId?: string;
151
+ readonly organizationId?: string;
152
+ readonly occurredAt?: number | Date;
153
+ }
154
+ /**
155
+ * Build the source-event marker customers should write to their outbox table in
156
+ * the SAME transaction as their app-row mutation.
157
+ *
158
+ * This helper does not persist anything. It only standardizes the marker shape
159
+ * so Prisma/Drizzle/Kysely/raw-SQL adapters all emit the fields Ablo's
160
+ * reconciler expects.
161
+ */
162
+ export declare function sourceEventForOperation(options: SourceEventForOperationOptions): SourceEvent;
125
163
  export interface SourceCommitResult<Row = Record<string, unknown>> {
126
164
  /**
127
165
  * Canonical rows after the write. Ablo uses these to update hosted
@@ -181,7 +219,8 @@ export interface SourceHandlerContext<TAuth = unknown> {
181
219
  * `webhook-id` from the signed request — globally unique per the
182
220
  * Standard Webhooks spec. Customers should dedupe by this id to
183
221
  * defend against replay (Ablo doesn't dedupe at the source-handler
184
- * boundary; it does at the delta-append boundary via `clientTxId`).
222
+ * boundary; commit idempotency is `clientTxId`, and event replay
223
+ * protection is the outbox event `id`).
185
224
  */
186
225
  readonly messageId?: string;
187
226
  readonly signedAt?: number;
@@ -317,11 +356,10 @@ export type AbloSourceOptions<S extends SchemaRecord, TAuth = unknown> = {
317
356
  * imports). Each returned event becomes a delta and fans out to
318
357
  * connected clients.
319
358
  *
320
- * MUST exclude events that originated from Ablo SDK commits — those
321
- * already produced deltas via the `commit` path. Returning them here
322
- * would surface as duplicate updates on every connected client.
323
- * Customers typically tag outbox rows with the originating
324
- * `clientTxId` and skip rows whose tag is non-null.
359
+ * Handlers may return the raw outbox feed. Ablo dedupes stable
360
+ * `event.id` values and filters SDK-origin echoes when rows carry
361
+ * the originating `clientTxId`; customers should persist both fields
362
+ * in their outbox table.
325
363
  */
326
364
  readonly events?: SourceEventsHandler<TAuth>;
327
365
  /**
@@ -329,6 +367,15 @@ export type AbloSourceOptions<S extends SchemaRecord, TAuth = unknown> = {
329
367
  * `abloSource({ schema, files: { load, list, commit } })`.
330
368
  */
331
369
  readonly models?: SourceModels<S, TAuth>;
370
+ /**
371
+ * An ORM adapter (`prismaDataSource(prisma, schema)`, …). When set, it serves
372
+ * ALL four operations — read (load/list), commit (idempotent + outbox), and
373
+ * events — so no hand-written `commit`/`events`/model handlers are needed. The
374
+ * adapter is consumed at the generic dispatch layer (rows are JSON on the wire),
375
+ * which is why it carries no per-model types and needs no cast at the call site.
376
+ * Mutually exclusive with hand-written handlers.
377
+ */
378
+ readonly adapter?: DataSourceAdapter;
332
379
  } & SourceModels<S, TAuth>;
333
380
  export type SourceLoadRequest = {
334
381
  readonly type: 'load';
@@ -392,6 +439,7 @@ export type DataSourceRequestContext = SourceRequestContext;
392
439
  export type DataSourceOperation = SourceOperation;
393
440
  export type DataSourceDelta = SourceDelta;
394
441
  export type DataSourceEvent = SourceEvent;
442
+ export type DataSourceEventForOperationOptions = SourceEventForOperationOptions;
395
443
  export type DataSourceCommitResult<Row = Record<string, unknown>> = SourceCommitResult<Row>;
396
444
  export type DataSourceCommitParams<TAuth = unknown> = SourceCommitParams<TAuth>;
397
445
  export type DataSourceScope = SourceScope;
@@ -415,3 +463,6 @@ export type DataSourceResponse<Row = Record<string, unknown>> = SourceResponse<R
415
463
  export declare function abloSource<const S extends SchemaRecord, TAuth = unknown>(options: AbloSourceOptions<S, TAuth>): (request: Request) => Promise<Response>;
416
464
  export declare function dataSource<const S extends SchemaRecord, TAuth = unknown>(options: DataSourceOptions<S, TAuth>): (request: Request) => Promise<Response>;
417
465
  export { createPushQueue, InMemoryPushQueueStorage, STANDARD_WEBHOOKS_RETRY_SCHEDULE, type PushQueue, type PushQueueItem, type PushQueueOptions, type PushQueueStorage, } from './pushQueue.js';
466
+ export { type DataSourceAdapter, type AdapterReadRequest, type AdapterCommitResult, type Row as AdapterRow, } from './adapter.js';
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
+ export { prismaDataSource, type PrismaLike, type PrismaDataSourceOptions } from './adapters/prisma.js';
@@ -1,3 +1,35 @@
1
+ import { changeSetSchema } from './contract.js';
2
+ /**
3
+ * Build the source-event marker customers should write to their outbox table in
4
+ * the SAME transaction as their app-row mutation.
5
+ *
6
+ * This helper does not persist anything. It only standardizes the marker shape
7
+ * so Prisma/Drizzle/Kysely/raw-SQL adapters all emit the fields Ablo's
8
+ * reconciler expects.
9
+ */
10
+ export function sourceEventForOperation(options) {
11
+ const entityId = options.entityId ?? options.operation.id;
12
+ if (typeof entityId !== 'string' || entityId.length === 0) {
13
+ throw new Error('sourceEventForOperation requires operation.id or an explicit entityId');
14
+ }
15
+ const occurredAt = normalizeEventOccurredAt(options.occurredAt);
16
+ return {
17
+ id: options.eventId,
18
+ model: options.operation.model,
19
+ entityId,
20
+ type: options.operation.type,
21
+ ...(options.data !== undefined ? { data: options.data } : {}),
22
+ ...(options.organizationId ? { organizationId: options.organizationId } : {}),
23
+ ...(options.clientTxId ? { clientTxId: options.clientTxId } : {}),
24
+ ...(occurredAt !== undefined ? { occurredAt } : {}),
25
+ };
26
+ }
27
+ function normalizeEventOccurredAt(value) {
28
+ if (value === undefined)
29
+ return undefined;
30
+ const timestamp = value instanceof Date ? value.getTime() : value;
31
+ return Number.isFinite(timestamp) ? timestamp : undefined;
32
+ }
1
33
  /**
2
34
  * HTTP headers used on signed source requests. Conforms to the
3
35
  * Standard Webhooks specification (https://www.standardwebhooks.com/)
@@ -26,6 +58,64 @@ function json(data, status = 200) {
26
58
  headers: { 'Content-Type': 'application/json' },
27
59
  });
28
60
  }
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
64
+ * single point of dispatch — no per-model branching here.
65
+ */
66
+ async function handleViaAdapter(adapter, body, scope) {
67
+ if (body.type === 'load') {
68
+ const rows = await adapter.read({
69
+ kind: 'load',
70
+ model: body.model,
71
+ id: body.id,
72
+ ...(scope ? { scope } : {}),
73
+ });
74
+ return json({ row: rows[0] ?? null });
75
+ }
76
+ if (body.type === 'list') {
77
+ const rows = await adapter.read({
78
+ kind: 'list',
79
+ model: body.model,
80
+ ...(body.query ? { query: body.query } : {}),
81
+ ...(scope ? { scope } : {}),
82
+ });
83
+ return json({ rows });
84
+ }
85
+ if (body.type === 'commit') {
86
+ if (!body.clientTxId) {
87
+ return json({ error: 'source_commit_requires_client_tx_id', message: 'commit requires a clientTxId for idempotency' }, 400);
88
+ }
89
+ const parsed = changeSetSchema.safeParse({
90
+ operations: body.operations,
91
+ clientTxId: body.clientTxId,
92
+ });
93
+ if (!parsed.success) {
94
+ return json({ error: 'source_commit_invalid', message: parsed.error.message }, 400);
95
+ }
96
+ const result = await adapter.commit(parsed.data);
97
+ return json({ rows: result.rows });
98
+ }
99
+ if (body.type === 'events') {
100
+ const page = await adapter.events(body.cursor ?? null, body.limit ?? 100);
101
+ return json({
102
+ events: page.events.map((event) => ({
103
+ id: event.id,
104
+ model: event.model,
105
+ entityId: event.entityId,
106
+ type: event.type,
107
+ ...(event.data !== undefined && event.data !== null ? { data: event.data } : {}),
108
+ ...(event.organizationId ? { organizationId: event.organizationId } : {}),
109
+ ...(event.clientTxId ? { clientTxId: event.clientTxId } : {}),
110
+ ...(event.occurredAt !== undefined && event.occurredAt !== null
111
+ ? { occurredAt: event.occurredAt }
112
+ : {}),
113
+ })),
114
+ ...(page.nextCursor !== null ? { nextCursor: page.nextCursor } : {}),
115
+ });
116
+ }
117
+ return json({ error: 'unknown_source_request' }, 400);
118
+ }
29
119
  async function readBody(request) {
30
120
  if (typeof request.text === 'function') {
31
121
  const rawBody = await request.text();
@@ -257,6 +347,12 @@ export function abloSource(options) {
257
347
  signedAt: signature?.signedAt,
258
348
  ...(body.scope ? { scope: body.scope } : {}),
259
349
  };
350
+ // Adapter path: when an ORM adapter is configured it serves every operation,
351
+ // consumed at this generic layer (rows are JSON on the wire), so no per-model
352
+ // handler lookup and no typed↔generic boundary.
353
+ if (options.adapter) {
354
+ return handleViaAdapter(options.adapter, body, context.scope);
355
+ }
260
356
  if (body.type === 'load') {
261
357
  const handlers = getModelHandlers(options, body.model);
262
358
  if (!handlers?.load) {
@@ -321,3 +417,5 @@ export function dataSource(options) {
321
417
  return abloSource(options);
322
418
  }
323
419
  export { createPushQueue, InMemoryPushQueueStorage, STANDARD_WEBHOOKS_RETRY_SCHEDULE, } from './pushQueue.js';
420
+ export { operationSchema, operationTypeSchema, changeSetSchema, outboxEventSchema, eventsPageSchema, migrationSchema, adapterCapabilitiesSchema, } from './contract.js';
421
+ export { prismaDataSource } from './adapters/prisma.js';
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Next.js App Router adapter for Data Source. The core `dataSource()` already
3
+ * returns a Web-standard `(Request) => Promise<Response>`, which Next App Router
4
+ * accepts directly — so this is pure ergonomics: wire an ORM `adapter` in via the
5
+ * bridge and hand back a named `POST` so the customer's route file is the minimum:
6
+ *
7
+ * // app/api/ablo/source/route.ts
8
+ * import { dataSourceNext } from '@abloatai/ablo/source/next';
9
+ * import { prismaDataSource } from '@abloatai/ablo/source';
10
+ * import { schema } from '@/ablo/schema';
11
+ * import { prisma } from '@/lib/prisma';
12
+ *
13
+ * export const { POST } = dataSourceNext({
14
+ * schema,
15
+ * apiKey: process.env.ABLO_API_KEY!,
16
+ * adapter: prismaDataSource(prisma, schema),
17
+ * });
18
+ *
19
+ * Day-one scope: Next + the adapter form only. Hand-written handlers use the core
20
+ * `dataSource()` directly; Hono/Express are the same one-liner and land on demand
21
+ * — not pre-built.
22
+ */
23
+ import type { SchemaRecord } from '../schema/schema.js';
24
+ import { type DataSourceOptions } from './index.js';
25
+ /**
26
+ * Next options ARE the core options — the `adapter` field lives on the core
27
+ * handler now, so there is no bridging, no cast, and no per-model-typed boundary
28
+ * at the call site. Pass `{ schema, apiKey, adapter }`.
29
+ */
30
+ export type DataSourceNextOptions<S extends SchemaRecord, TAuth = unknown> = DataSourceOptions<S, TAuth>;
31
+ export declare function dataSourceNext<const S extends SchemaRecord, TAuth = unknown>(options: DataSourceNextOptions<S, TAuth>): {
32
+ readonly POST: (request: Request) => Promise<Response>;
33
+ };