@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.
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +40 -0
- package/README.md +53 -27
- package/dist/BaseSyncedStore.d.ts +2 -36
- package/dist/BaseSyncedStore.js +11 -55
- package/dist/NetworkMonitor.js +4 -1
- package/dist/SyncClient.d.ts +22 -5
- package/dist/SyncClient.js +77 -0
- package/dist/SyncEngineContext.js +5 -1
- package/dist/agent/index.js +1 -1
- package/dist/api/index.d.ts +1 -1
- package/dist/auth/index.js +3 -1
- package/dist/cli.cjs +302645 -0
- package/dist/client/Ablo.d.ts +19 -52
- package/dist/client/Ablo.js +30 -106
- package/dist/client/ApiClient.d.ts +1 -113
- package/dist/client/ApiClient.js +39 -238
- package/dist/client/auth.js +32 -2
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/createModelProxy.d.ts +9 -0
- package/dist/client/createModelProxy.js +34 -10
- package/dist/client/httpClient.d.ts +5 -6
- package/dist/client/httpClient.js +2 -3
- package/dist/client/index.d.ts +1 -1
- package/dist/client/persistence.d.ts +6 -1
- package/dist/client/persistence.js +1 -1
- package/dist/client/registerDataSource.d.ts +4 -4
- package/dist/client/registerDataSource.js +39 -31
- package/dist/client/writeOptionsSchema.d.ts +50 -0
- package/dist/client/writeOptionsSchema.js +57 -0
- package/dist/core/index.d.ts +18 -26
- package/dist/core/index.js +22 -46
- package/dist/errorCodes.d.ts +13 -0
- package/dist/errorCodes.js +19 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -1
- package/dist/interfaces/index.d.ts +14 -4
- package/dist/mutators/UndoManager.d.ts +48 -5
- package/dist/mutators/UndoManager.js +166 -1
- package/dist/react/AbloProvider.d.ts +18 -8
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/useUndoScope.js +7 -0
- package/dist/schema/ddl.js +2 -1
- package/dist/schema/field.js +2 -1
- package/dist/schema/serialize.js +2 -1
- package/dist/server/commit.d.ts +4 -5
- package/dist/server/storage-mode.d.ts +7 -0
- package/dist/server/storage-mode.js +6 -0
- package/dist/source/adapters/drizzle.js +3 -2
- package/dist/source/adapters/kysely.d.ts +68 -0
- package/dist/source/adapters/kysely.js +210 -0
- package/dist/source/adapters/memory.js +2 -1
- package/dist/source/adapters/prisma.js +3 -2
- package/dist/source/index.js +2 -1
- package/dist/transactions/TransactionQueue.d.ts +6 -7
- package/dist/transactions/TransactionQueue.js +33 -9
- package/dist/types/streams.d.ts +2 -1
- package/dist/utils/duration.js +3 -2
- package/dist/wire/frames.d.ts +6 -8
- package/docs/api.md +1 -1
- package/docs/cli.md +17 -4
- package/docs/client-behavior.md +1 -1
- package/docs/data-sources.md +129 -125
- package/docs/examples/ai-sdk-tool.md +11 -5
- package/docs/examples/existing-python-backend.md +26 -4
- package/docs/examples/nextjs.md +3 -2
- package/docs/examples/scoped-agent.md +38 -11
- package/docs/guarantees.md +2 -2
- package/docs/identity.md +86 -59
- package/docs/index.md +2 -2
- package/docs/integration-guide.md +89 -61
- package/docs/mcp.md +1 -1
- package/docs/quickstart.md +84 -37
- package/docs/react.md +39 -28
- package/docs/schema-contract.md +2 -4
- package/llms-full.txt +360 -0
- package/llms.txt +30 -18
- 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.
|
|
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
|
|
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
|
-
*
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
/**
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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.
|
|
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
|
package/dist/react/index.js
CHANGED
|
@@ -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.
|
|
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,
|
package/dist/schema/ddl.js
CHANGED
|
@@ -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
|
|
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':
|
package/dist/schema/field.js
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/schema/serialize.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/server/commit.d.ts
CHANGED
|
@@ -61,11 +61,10 @@ export interface CommitContext {
|
|
|
61
61
|
*/
|
|
62
62
|
confirmationState?: ConfirmationState;
|
|
63
63
|
/**
|
|
64
|
-
* FK to
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
|
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
|
|
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;
|