@abloatai/ablo 0.9.1 → 0.9.3

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 (79) hide show
  1. package/AGENTS.md +84 -0
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +53 -27
  4. package/dist/BaseSyncedStore.d.ts +2 -36
  5. package/dist/BaseSyncedStore.js +11 -55
  6. package/dist/NetworkMonitor.js +4 -1
  7. package/dist/SyncClient.d.ts +22 -5
  8. package/dist/SyncClient.js +77 -0
  9. package/dist/SyncEngineContext.js +5 -1
  10. package/dist/agent/index.js +1 -1
  11. package/dist/api/index.d.ts +1 -1
  12. package/dist/auth/index.js +3 -1
  13. package/dist/cli.cjs +302645 -0
  14. package/dist/client/Ablo.d.ts +19 -52
  15. package/dist/client/Ablo.js +30 -106
  16. package/dist/client/ApiClient.d.ts +1 -113
  17. package/dist/client/ApiClient.js +39 -238
  18. package/dist/client/auth.js +32 -2
  19. package/dist/client/createInternalComponents.js +1 -1
  20. package/dist/client/createModelProxy.d.ts +9 -0
  21. package/dist/client/createModelProxy.js +34 -10
  22. package/dist/client/httpClient.d.ts +5 -6
  23. package/dist/client/httpClient.js +2 -3
  24. package/dist/client/index.d.ts +1 -1
  25. package/dist/client/persistence.d.ts +6 -1
  26. package/dist/client/persistence.js +1 -1
  27. package/dist/client/registerDataSource.d.ts +4 -4
  28. package/dist/client/registerDataSource.js +39 -31
  29. package/dist/client/writeOptionsSchema.d.ts +50 -0
  30. package/dist/client/writeOptionsSchema.js +57 -0
  31. package/dist/core/index.d.ts +18 -26
  32. package/dist/core/index.js +22 -46
  33. package/dist/errorCodes.d.ts +13 -0
  34. package/dist/errorCodes.js +19 -4
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.js +8 -1
  37. package/dist/interfaces/index.d.ts +14 -4
  38. package/dist/mutators/UndoManager.d.ts +48 -5
  39. package/dist/mutators/UndoManager.js +166 -1
  40. package/dist/react/AbloProvider.d.ts +18 -8
  41. package/dist/react/index.d.ts +1 -1
  42. package/dist/react/index.js +1 -1
  43. package/dist/react/useUndoScope.js +7 -0
  44. package/dist/schema/ddl.js +2 -1
  45. package/dist/schema/field.js +2 -1
  46. package/dist/schema/serialize.js +2 -1
  47. package/dist/server/commit.d.ts +4 -5
  48. package/dist/server/storage-mode.d.ts +7 -0
  49. package/dist/server/storage-mode.js +6 -0
  50. package/dist/source/adapters/drizzle.js +3 -2
  51. package/dist/source/adapters/kysely.d.ts +68 -0
  52. package/dist/source/adapters/kysely.js +210 -0
  53. package/dist/source/adapters/memory.js +2 -1
  54. package/dist/source/adapters/prisma.js +3 -2
  55. package/dist/source/index.js +2 -1
  56. package/dist/transactions/TransactionQueue.d.ts +6 -7
  57. package/dist/transactions/TransactionQueue.js +33 -9
  58. package/dist/types/streams.d.ts +2 -1
  59. package/dist/utils/duration.js +3 -2
  60. package/dist/wire/frames.d.ts +6 -8
  61. package/docs/api.md +1 -1
  62. package/docs/cli.md +17 -4
  63. package/docs/client-behavior.md +1 -1
  64. package/docs/data-sources.md +129 -125
  65. package/docs/examples/ai-sdk-tool.md +11 -5
  66. package/docs/examples/existing-python-backend.md +26 -4
  67. package/docs/examples/nextjs.md +3 -2
  68. package/docs/examples/scoped-agent.md +38 -11
  69. package/docs/guarantees.md +2 -2
  70. package/docs/identity.md +86 -59
  71. package/docs/index.md +2 -2
  72. package/docs/integration-guide.md +89 -61
  73. package/docs/mcp.md +1 -1
  74. package/docs/quickstart.md +84 -37
  75. package/docs/react.md +39 -28
  76. package/docs/schema-contract.md +2 -4
  77. package/llms-full.txt +360 -0
  78. package/llms.txt +30 -18
  79. package/package.json +23 -3
@@ -28,6 +28,13 @@ const normalizeModelAlias = (modelName) => modelName.replace('Model', '').toLowe
28
28
  * Consumers call `record(entry)` after each mutator; `undo()` / `redo()` to
29
29
  * traverse the stacks.
30
30
  */
31
+ /**
32
+ * How long a marked replay-echo stays armed before it's pruned. The real echo
33
+ * arrives within a couple of IndexedDB round-trips (tens of ms); this is a
34
+ * generous safety ceiling so a never-arriving echo (e.g. the commit was skipped
35
+ * offline) can't suppress a genuine later edit to the same row indefinitely.
36
+ */
37
+ const REPLAY_ECHO_TTL_MS = 5000;
31
38
  export class UndoScope {
32
39
  schema;
33
40
  store;
@@ -46,6 +53,15 @@ export class UndoScope {
46
53
  * observer can never wedge the editor's recording path.
47
54
  */
48
55
  recordListeners = new Set();
56
+ /**
57
+ * Observers notified after ANY stack change — record, undo, redo, or clear.
58
+ * Distinct from {@link recordListeners} (forward actions only): this fires on
59
+ * reversals too, so React consumers can keep `canUndo`/`canRedo` live. The
60
+ * stream-recording path pushes entries WITHOUT a React render, so without this
61
+ * a freshly-recorded entry leaves `canUndo` stale (snapshot from last render)
62
+ * and a Cmd+Z handler gated on `canUndo !== false` silently no-ops.
63
+ */
64
+ changeListeners = new Set();
49
65
  /**
50
66
  * Serialization tail. Recording, undo, and redo all chain off this single
51
67
  * promise so they run strictly in the order they were *invoked* — never
@@ -83,6 +99,23 @@ export class UndoScope {
83
99
  * response) collapses into ONE Cmd+Z. `endGroup()` flushes it.
84
100
  */
85
101
  group = null;
102
+ /**
103
+ * ASYNC replay-echo suppression, keyed by `${modelKey}:${id}`.
104
+ *
105
+ * The synchronous {@link replaying} flag only catches echoes delivered INLINE
106
+ * during `applyOps`. The real engine doesn't emit `transaction:created`
107
+ * synchronously: `SyncClient` defers the commit behind `scheduleSync()` +
108
+ * `await persistMutationQueue()` (an IndexedDB write), so a replayed write's
109
+ * echo lands on the stream AFTER `undo()`/`redo()` has already reset
110
+ * `replaying` and pushed the entry. That late echo would be recorded as a
111
+ * NEW edit — and `record()` clears the redo stack, so every undo silently
112
+ * destroyed its own redo. We mark the (modelKey,id) of every op we're about
113
+ * to replay here (synchronously, before the write), and consume one mark when
114
+ * the matching mutation arrives — independent of WHEN it arrives. Entries
115
+ * carry a TTL so a never-arriving echo (offline: the commit is skipped) can't
116
+ * leak and wrongly suppress a much-later genuine edit to the same row.
117
+ */
118
+ pendingReplayEchoes = new Map();
86
119
  constructor(schema, store, organizationId, options = {}) {
87
120
  this.schema = schema;
88
121
  this.store = store;
@@ -141,6 +174,80 @@ export class UndoScope {
141
174
  return;
142
175
  this.record({ label: label ?? g.label, inverses, forwards });
143
176
  }
177
+ /** Every `${modelKey}:${id}` a set of ops will touch (all op kinds). */
178
+ *replayEchoKeys(ops) {
179
+ for (const op of ops) {
180
+ switch (op.kind) {
181
+ case 'create': {
182
+ const id = op.data.id;
183
+ if (typeof id === 'string')
184
+ yield `${op.modelKey}:${id}`;
185
+ break;
186
+ }
187
+ case 'update':
188
+ yield `${op.modelKey}:${op.patch.id}`;
189
+ break;
190
+ case 'delete':
191
+ yield `${op.modelKey}:${op.id}`;
192
+ break;
193
+ case 'createMany':
194
+ for (const d of op.data) {
195
+ const id = d.id;
196
+ if (typeof id === 'string')
197
+ yield `${op.modelKey}:${id}`;
198
+ }
199
+ break;
200
+ case 'updateMany':
201
+ for (const p of op.patches)
202
+ yield `${op.modelKey}:${p.id}`;
203
+ break;
204
+ case 'deleteMany':
205
+ for (const id of op.ids)
206
+ yield `${op.modelKey}:${id}`;
207
+ break;
208
+ }
209
+ }
210
+ }
211
+ /**
212
+ * Arm async-echo suppression for the rows a replay is about to write. Called
213
+ * synchronously, before `applyOps`, so the marks exist no matter how long the
214
+ * engine takes to surface the echo on the stream. See {@link pendingReplayEchoes}.
215
+ */
216
+ markReplayEchoes(ops) {
217
+ const expiresAt = Date.now() + REPLAY_ECHO_TTL_MS;
218
+ for (const key of this.replayEchoKeys(ops)) {
219
+ const existing = this.pendingReplayEchoes.get(key);
220
+ if (existing) {
221
+ existing.count += 1;
222
+ existing.expiresAt = expiresAt;
223
+ }
224
+ else {
225
+ this.pendingReplayEchoes.set(key, { count: 1, expiresAt });
226
+ }
227
+ }
228
+ }
229
+ /**
230
+ * If `${schemaKey}:${modelId}` has an armed echo mark, consume one and report
231
+ * that this mutation is our own replay echo (caller drops it). Prunes expired
232
+ * marks opportunistically so a skipped/never-arriving echo can't leak.
233
+ */
234
+ consumeReplayEcho(schemaKey, modelId) {
235
+ if (this.pendingReplayEchoes.size === 0)
236
+ return false;
237
+ const now = Date.now();
238
+ for (const [k, v] of this.pendingReplayEchoes) {
239
+ if (v.expiresAt <= now)
240
+ this.pendingReplayEchoes.delete(k);
241
+ }
242
+ const key = `${schemaKey}:${modelId}`;
243
+ const pending = this.pendingReplayEchoes.get(key);
244
+ if (!pending)
245
+ return false;
246
+ pending.count -= 1;
247
+ if (pending.count <= 0)
248
+ this.pendingReplayEchoes.delete(key);
249
+ return true;
250
+ }
144
251
  /** Resolve a stream mutation's registered name to its schema key, or null. */
145
252
  resolveSchemaKey(modelName) {
146
253
  return (this.schemaKeyByAlias.get(modelName) ??
@@ -160,6 +267,13 @@ export class UndoScope {
160
267
  const schemaKey = this.resolveSchemaKey(m.modelName);
161
268
  if (!schemaKey)
162
269
  return;
270
+ // Drop the ASYNC echo of our own replayed writes. The engine surfaces a
271
+ // replay's `transaction:created` only after an IndexedDB-gated commit, i.e.
272
+ // after `replaying` has already reset — so the synchronous flag above misses
273
+ // it. The (modelKey,id) marks armed in `markReplayEchoes` catch it whenever
274
+ // it lands, which is what stops every undo from wiping its own redo stack.
275
+ if (this.consumeReplayEcho(schemaKey, m.modelId))
276
+ return;
163
277
  if (this.tracksModel && !this.tracksModel(schemaKey))
164
278
  return;
165
279
  const ops = buildUndoOps(m, schemaKey);
@@ -246,6 +360,7 @@ export class UndoScope {
246
360
  this.undoStack.shift();
247
361
  this.redoStack = [];
248
362
  this.emitRecord(entry);
363
+ this.emitChange();
249
364
  }
250
365
  /**
251
366
  * Subscribe to every recorded mutation. Fires synchronously at the tail of
@@ -275,6 +390,30 @@ export class UndoScope {
275
390
  }
276
391
  }
277
392
  }
393
+ /**
394
+ * Subscribe to ANY stack change (record/undo/redo/clear). Used by
395
+ * `useUndoScope` to re-render so `canUndo`/`canRedo` stay live across every
396
+ * consumer — not just the component that invoked undo/redo. Returns an
397
+ * unsubscribe function.
398
+ */
399
+ onChange(listener) {
400
+ this.changeListeners.add(listener);
401
+ return () => {
402
+ this.changeListeners.delete(listener);
403
+ };
404
+ }
405
+ emitChange() {
406
+ for (const listener of this.changeListeners) {
407
+ try {
408
+ listener();
409
+ }
410
+ catch (err) {
411
+ if (typeof console !== 'undefined') {
412
+ console.error('[UndoScope] onChange listener threw', err);
413
+ }
414
+ }
415
+ }
416
+ }
278
417
  canUndo() {
279
418
  return this.undoStack.length > 0;
280
419
  }
@@ -297,17 +436,29 @@ export class UndoScope {
297
436
  const tx = createTransaction(this.schema, this.store, this.organizationId);
298
437
  const ops = resolveOps(entry.inverses, entry.forwards, this.store, this.conflictPolicy);
299
438
  // Suppress our own stream listener so replayed writes don't record as
300
- // new undo entries. Cleared in `finally` even if a replay op throws.
439
+ // new undo entries. `replaying` covers inline echoes; `markReplayEchoes`
440
+ // covers the engine's async (IDB-gated) echo that lands after this method
441
+ // returns. Cleared in `finally` even if a replay op throws.
442
+ this.markReplayEchoes(ops);
301
443
  this.replaying = true;
302
444
  try {
303
445
  await applyOps(tx, ops);
304
446
  }
447
+ catch (err) {
448
+ // The replay was rejected (e.g. a server 409): the world didn't change,
449
+ // so restore the entry to the undo stack rather than silently dropping
450
+ // it (which would also strand it off the redo stack — invisible undo).
451
+ this.undoStack.push(entry);
452
+ this.emitChange();
453
+ throw err;
454
+ }
305
455
  finally {
306
456
  this.replaying = false;
307
457
  }
308
458
  this.redoStack.push(entry);
309
459
  if (this.redoStack.length > this.maxHistory)
310
460
  this.redoStack.shift();
461
+ this.emitChange();
311
462
  });
312
463
  }
313
464
  /**
@@ -323,16 +474,26 @@ export class UndoScope {
323
474
  return;
324
475
  const tx = createTransaction(this.schema, this.store, this.organizationId);
325
476
  const ops = resolveOps(entry.forwards, entry.inverses, this.store, this.conflictPolicy);
477
+ // See undo(): arm async-echo suppression before the replayed writes.
478
+ this.markReplayEchoes(ops);
326
479
  this.replaying = true;
327
480
  try {
328
481
  await applyOps(tx, ops);
329
482
  }
483
+ catch (err) {
484
+ // Symmetric to undo: a rejected re-apply leaves state unchanged, so put
485
+ // the entry back on the redo stack instead of losing it.
486
+ this.redoStack.push(entry);
487
+ this.emitChange();
488
+ throw err;
489
+ }
330
490
  finally {
331
491
  this.replaying = false;
332
492
  }
333
493
  this.undoStack.push(entry);
334
494
  if (this.undoStack.length > this.maxHistory)
335
495
  this.undoStack.shift();
496
+ this.emitChange();
336
497
  });
337
498
  }
338
499
  /** Drop all history. Use after bootstrap / sync group change / sync error. */
@@ -340,6 +501,8 @@ export class UndoScope {
340
501
  this.undoStack = [];
341
502
  this.redoStack = [];
342
503
  this.batch = [];
504
+ this.pendingReplayEchoes.clear();
505
+ this.emitChange();
343
506
  }
344
507
  /** Introspection — for debug panels / e2e tests. */
345
508
  size() {
@@ -353,7 +516,9 @@ export class UndoScope {
353
516
  dispose() {
354
517
  this.unsubscribe();
355
518
  this.recordListeners.clear();
519
+ this.changeListeners.clear();
356
520
  this.batch = [];
521
+ this.pendingReplayEchoes.clear();
357
522
  }
358
523
  }
359
524
  /**
@@ -28,20 +28,30 @@ import { type SyncStoreContract } from './context.js';
28
28
  /**
29
29
  * Props for `<AbloProvider>`.
30
30
  *
31
- * The default path is one prop:
31
+ * The one required prop is a prebuilt {@link Ablo} client — the client
32
+ * owns auth and the credential lifecycle; this provider is the reactive
33
+ * binding over it (Stripe's `<Elements stripe={...}>` model):
32
34
  *
33
35
  * ```tsx
34
- * <AbloProvider schema={schema}>
36
+ * // Build once at module scope — a new instance per render tears down the socket.
37
+ * const ablo = Ablo({
38
+ * schema,
39
+ * getToken: () =>
40
+ * fetch('/api/ablo-session', { method: 'POST' })
41
+ * .then((r) => r.json())
42
+ * .then((d) => d.token),
43
+ * });
44
+ *
45
+ * <AbloProvider client={ablo}>
35
46
  * <App />
36
47
  * </AbloProvider>
37
48
  * ```
38
49
  *
39
- * That's it for most apps the provider resolves identity, account
40
- * scope, and realtime permissions from auth. `userId`/`apiKey`/`url`
41
- * are situational; the `bootstrapMode`, `persistence`, and `fallback`
42
- * props are opt-in tuning; and the block tagged "Optional DI (advanced)"
43
- * below is escape-hatch wiring for tests and platform builders — if you
44
- * don't recognize a prop there, you don't need it.
50
+ * That's it for most apps. `userId` is informational; the `fallback`,
51
+ * `preventUnsavedChanges`, and `on*` props are opt-in app glue; and the
52
+ * block tagged "Optional DI (advanced)" below is escape-hatch wiring for
53
+ * tests and platform builders if you don't recognize a prop there, you
54
+ * don't need it.
45
55
  */
46
56
  export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
47
57
  /**
@@ -27,7 +27,7 @@
27
27
  * useCurrentUserId() — the provider's userId prop
28
28
  *
29
29
  * Multiplayer (always available — `<AbloProvider>` always constructs a client):
30
- * useAblo((ablo) => ablo.intents.list(...)) — reactive coordination reads
30
+ * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
31
31
  * useParticipant({ scope }) — join multiplayer for a scope, get peers/claims
32
32
  * usePresence() — typed presence view
33
33
  * useIntent(name) — typed intent dispatcher
@@ -27,7 +27,7 @@
27
27
  * useCurrentUserId() — the provider's userId prop
28
28
  *
29
29
  * Multiplayer (always available — `<AbloProvider>` always constructs a client):
30
- * useAblo((ablo) => ablo.intents.list(...)) — reactive coordination reads
30
+ * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
31
31
  * useParticipant({ scope }) — join multiplayer for a scope, get peers/claims
32
32
  * usePresence() — typed presence view
33
33
  * useIntent(name) — typed intent dispatcher
@@ -52,6 +52,13 @@ export function useUndoScope(schemaOrName, nameOrOptions, maybeOptions) {
52
52
  useEffect(() => {
53
53
  setTick(0);
54
54
  }, [scope]);
55
+ // Re-render on ANY stack change — including entries recorded from the local-
56
+ // mutation stream, which don't otherwise trigger a React update. Without this
57
+ // `canUndo`/`canRedo` go stale in every consumer that didn't itself call
58
+ // undo/redo (e.g. a keyboard handler whose Cmd+Z gate then never fires).
59
+ useEffect(() => {
60
+ return scope.onChange(() => setTick((t) => t + 1));
61
+ }, [scope]);
55
62
  const size = scope.size();
56
63
  return {
57
64
  scope,
@@ -17,6 +17,7 @@
17
17
  * - `generateMigrationPlan` — the destructive-aware counterpart driven by the
18
18
  * {@link diffSchema} step list (drops, renames, type casts, backfills).
19
19
  */
20
+ import { AbloValidationError } from '../errors.js';
20
21
  import { resolveTenancy, tenancyColumn } from './tenancy.js';
21
22
  // ── Identifier safety ────────────────────────────────────────────────────────
22
23
  /** Postgres unquoted-identifier-safe slug: lowercase `[a-z0-9_]`, ≤50 chars. */
@@ -289,7 +290,7 @@ function sqlLiteral(value, fieldType) {
289
290
  switch (fieldType) {
290
291
  case 'number':
291
292
  if (typeof value !== 'number' || !Number.isFinite(value)) {
292
- throw new Error(`backfill for a number field must be a finite number, got ${JSON.stringify(value)}`);
293
+ throw new AbloValidationError(`backfill for a number field must be a finite number, got ${JSON.stringify(value)}`, { code: 'schema_definition_invalid' });
293
294
  }
294
295
  return String(value);
295
296
  case 'boolean':
@@ -23,6 +23,7 @@
23
23
  * });
24
24
  */
25
25
  import { z } from 'zod';
26
+ import { AbloValidationError } from '../errors.js';
26
27
  // ── Helpers ───────────────────────────────────────────────────────────────
27
28
  /** Distinguish a Zod schema from a plain object shape (ZodRawShape). */
28
29
  function isZodSchema(value) {
@@ -179,7 +180,7 @@ export function resolveFieldMeta(schema) {
179
180
  }
180
181
  function assertColumnName(column) {
181
182
  if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(column)) {
182
- throw new Error(`field.from(): invalid column identifier ${JSON.stringify(column)}`);
183
+ throw new AbloValidationError(`field.from(): invalid column identifier ${JSON.stringify(column)}`, { code: 'schema_definition_invalid' });
183
184
  }
184
185
  }
185
186
  /** Add sync-engine chain methods to a Zod schema without disturbing its type. */
@@ -25,6 +25,7 @@
25
25
  * `FieldMeta` (the server does no field-shape validation anyway)
26
26
  */
27
27
  import { z } from 'zod';
28
+ import { AbloValidationError } from '../errors.js';
28
29
  import { baseFieldsSchema, } from './schema.js';
29
30
  /** Current schema-JSON envelope version. Bump on a breaking change to the
30
31
  * JSON shape itself (not the user's schema). v2 replaced the per-model
@@ -201,7 +202,7 @@ export function fromSchemaJSON(json) {
201
202
  export function parseSchema(json) {
202
203
  const parsed = JSON.parse(json);
203
204
  if (parsed.v !== SCHEMA_JSON_VERSION) {
204
- throw new Error(`parseSchema: unsupported schema-JSON version ${parsed.v} (expected ${SCHEMA_JSON_VERSION})`);
205
+ throw new AbloValidationError(`parseSchema: unsupported schema-JSON version ${parsed.v} (expected ${SCHEMA_JSON_VERSION})`, { code: 'schema_definition_invalid' });
205
206
  }
206
207
  return fromSchemaJSON(parsed);
207
208
  }
@@ -61,11 +61,10 @@ export interface CommitContext {
61
61
  */
62
62
  confirmationState?: ConfirmationState;
63
63
  /**
64
- * FK to AgentTurn.id. Pinned at turn open by `SyncAgent.beginTurn`, threaded
65
- * through the wire frame's `causedByTaskId`, validated by the commit handler
66
- * (turn must belong to the same agent and be open), and written onto every
67
- * delta this batch produces. Absent for human-direct commits and SDKs that
68
- * predate the turn protocol (→ `caused_by_task_id = NULL`).
64
+ * Dormant FK to the agent-task id (`agent_tasks.id`). The SDK no longer
65
+ * sets it (turns/tasks removed; attribution rides on the claim/intent id
66
+ * + server-stamped actor/capability). Still validated + written onto
67
+ * `caused_by_task_id` when present, but client writes leave it `null`.
69
68
  */
70
69
  causedByTaskId?: string | null;
71
70
  }
@@ -7,11 +7,18 @@
7
7
  * - `hosted` — Ablo's control-plane database.
8
8
  * - `selfHosted` — the customer's database, same execution path as hosted.
9
9
  * - `source` — a customer-owned endpoint (credentialless ingestion).
10
+ *
11
+ * @internal Deployment topology, not product vocabulary. Customers never see a
12
+ * "storage mode" — their story is `Ablo({ schema, apiKey, databaseUrl })` and
13
+ * one `datasource` resource (docs/plans/sync-engine-stripe-story-scope.md).
14
+ * This export exists for the sync-server host only.
10
15
  */
11
16
  import { z } from 'zod';
17
+ /** @internal See module note — host-deployment vocabulary, never customer-facing. */
12
18
  export declare const storageModeSchema: z.ZodEnum<{
13
19
  source: "source";
14
20
  hosted: "hosted";
15
21
  selfHosted: "selfHosted";
16
22
  }>;
23
+ /** @internal See module note — host-deployment vocabulary, never customer-facing. */
17
24
  export type StorageMode = z.infer<typeof storageModeSchema>;
@@ -7,6 +7,12 @@
7
7
  * - `hosted` — Ablo's control-plane database.
8
8
  * - `selfHosted` — the customer's database, same execution path as hosted.
9
9
  * - `source` — a customer-owned endpoint (credentialless ingestion).
10
+ *
11
+ * @internal Deployment topology, not product vocabulary. Customers never see a
12
+ * "storage mode" — their story is `Ablo({ schema, apiKey, databaseUrl })` and
13
+ * one `datasource` resource (docs/plans/sync-engine-stripe-story-scope.md).
14
+ * This export exists for the sync-server host only.
10
15
  */
11
16
  import { z } from 'zod';
17
+ /** @internal See module note — host-deployment vocabulary, never customer-facing. */
12
18
  export const storageModeSchema = z.enum(['hosted', 'source', 'selfHosted']);
@@ -28,6 +28,7 @@
28
28
  * We use `sql` + `db.execute` for ALL writes (not the fluent builder) so the
29
29
  * adapter is one small, fully-typed unit with no per-driver builder generics.
30
30
  */
31
+ import { AbloValidationError } from '../../errors.js';
31
32
  import { sql } from 'drizzle-orm';
32
33
  import { outboxEventSchema } from '../contract.js';
33
34
  import { adapterTableMigrations } from '../migrations.js';
@@ -40,7 +41,7 @@ function rowsOf(result) {
40
41
  function rowId(op) {
41
42
  const id = op.id ?? op.input?.id;
42
43
  if (typeof id !== 'string' || id.length === 0) {
43
- throw new Error(`operation on "${op.model}" requires an id`);
44
+ throw new AbloValidationError(`operation on "${op.model}" requires an id`, { code: 'source_operation_id_required' });
44
45
  }
45
46
  return id;
46
47
  }
@@ -76,7 +77,7 @@ export function drizzleDataSource(db, schema) {
76
77
  const modelColumns = (model) => {
77
78
  const mc = maps.get(model);
78
79
  if (!mc)
79
- throw new Error(`drizzleDataSource: no model "${model}" in schema`);
80
+ throw new AbloValidationError(`drizzleDataSource: no model "${model}" in schema`, { code: 'source_adapter_misconfigured' });
80
81
  return mc;
81
82
  };
82
83
  const columnFor = (mc, field) => mc.fieldToColumn.get(field) ?? camelToSnake(field);
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Kysely Data Source adapter. Same adapter interface + conformance shape as
3
+ * `prismaDataSource` / `drizzleDataSource`, built against Kysely's REAL
4
+ * query-builder API:
5
+ * - `db.transaction().execute(async (trx) => …)` — interactive transaction.
6
+ * - `insertInto/updateTable/deleteFrom/selectFrom` + `returningAll()` —
7
+ * the fluent builder; table/column names are plain strings, so no raw
8
+ * SQL tag is needed and this module imports NOTHING from `kysely`
9
+ * (structural `KyselyLike`, mirroring the Prisma adapter's zero-dep
10
+ * `PrismaLike`).
11
+ *
12
+ * SCHEMA-DRIVEN COLUMNS. Kysely is SQL-near: it passes the column names you
13
+ * give it through verbatim (no Prisma-style `@map`). Like the Drizzle
14
+ * adapter, every table + column name is derived from the SAME rule the
15
+ * provisioner uses (`generateProvisionPlan`):
16
+ * table = `model.tableName ?? key`
17
+ * column = `fieldMeta.column ?? camelToSnake(field)` (+ the tenancy column)
18
+ * so `ablo migrate` (which emits `operator_id`) and this adapter COMPOSE.
19
+ * The adapter is the translation boundary: rows in/out are field-keyed (the
20
+ * SDK shape); the physical columns it reads/writes are snake_case.
21
+ *
22
+ * JSONB note: the outbox `data` / idempotency `response` values are passed
23
+ * as JSON strings — Postgres infers the parameter type from the target
24
+ * `jsonb` column, so the coercion is server-side and driver-agnostic (no
25
+ * `::jsonb` cast available without raw SQL).
26
+ */
27
+ import type { DataSourceAdapter, Row } from '../adapter.js';
28
+ import type { Schema, SchemaRecord } from '../../schema/schema.js';
29
+ /**
30
+ * The subset of a Kysely instance (or transaction handle) the adapter calls.
31
+ * Structural on purpose — declared with method shorthand so a real
32
+ * `Kysely<DB>` (whose params are narrowed to `keyof DB`) stays assignable
33
+ * under TypeScript's method bivariance, exactly like `PrismaLike`.
34
+ */
35
+ export interface KyselyLike {
36
+ selectFrom(table: string): KyselySelectBuilder;
37
+ insertInto(table: string): KyselyInsertBuilder;
38
+ updateTable(table: string): KyselyUpdateBuilder;
39
+ deleteFrom(table: string): KyselyDeleteBuilder;
40
+ transaction(): KyselyTransactionBuilder;
41
+ }
42
+ export interface KyselyTransactionBuilder {
43
+ execute<T>(fn: (trx: KyselyLike) => Promise<T>): Promise<T>;
44
+ }
45
+ export interface KyselySelectBuilder {
46
+ selectAll(): KyselySelectBuilder;
47
+ where(column: string, operator: string, value: unknown): KyselySelectBuilder;
48
+ orderBy(column: string, direction: 'asc' | 'desc'): KyselySelectBuilder;
49
+ limit(limit: number): KyselySelectBuilder;
50
+ execute(): Promise<readonly Row[]>;
51
+ }
52
+ export interface KyselyInsertBuilder {
53
+ values(row: Row): KyselyInsertBuilder;
54
+ returningAll(): KyselyInsertBuilder;
55
+ execute(): Promise<readonly Row[]>;
56
+ }
57
+ export interface KyselyUpdateBuilder {
58
+ set(patch: Row): KyselyUpdateBuilder;
59
+ where(column: string, operator: string, value: unknown): KyselyUpdateBuilder;
60
+ returningAll(): KyselyUpdateBuilder;
61
+ execute(): Promise<readonly Row[]>;
62
+ }
63
+ export interface KyselyDeleteBuilder {
64
+ where(column: string, operator: string, value: unknown): KyselyDeleteBuilder;
65
+ returningAll(): KyselyDeleteBuilder;
66
+ execute(): Promise<readonly Row[]>;
67
+ }
68
+ export declare function kyselyDataSource<S extends SchemaRecord>(db: KyselyLike, schema: Schema<S>): DataSourceAdapter;