@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
|
@@ -0,0 +1,210 @@
|
|
|
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 { AbloValidationError } from '../../errors.js';
|
|
28
|
+
import { outboxEventSchema } from '../contract.js';
|
|
29
|
+
import { adapterTableMigrations } from '../migrations.js';
|
|
30
|
+
import { toSchemaJSON } from '../../schema/serialize.js';
|
|
31
|
+
import { camelToSnake, snakeToCamel } from '../../schema/ddl.js';
|
|
32
|
+
import { tenancyColumn } from '../../schema/tenancy.js';
|
|
33
|
+
function rowId(op) {
|
|
34
|
+
const id = op.id ?? op.input?.id;
|
|
35
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
36
|
+
throw new AbloValidationError(`operation on "${op.model}" requires an id`, {
|
|
37
|
+
code: 'source_operation_id_required',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
function buildColumnMaps(schema) {
|
|
43
|
+
const json = toSchemaJSON(schema);
|
|
44
|
+
const out = new Map();
|
|
45
|
+
for (const [key, model] of Object.entries(json.models)) {
|
|
46
|
+
const fieldToColumn = new Map();
|
|
47
|
+
const columnToField = new Map();
|
|
48
|
+
const register = (field, column) => {
|
|
49
|
+
if (column === camelToSnake(field))
|
|
50
|
+
return;
|
|
51
|
+
fieldToColumn.set(field, column);
|
|
52
|
+
columnToField.set(column, field);
|
|
53
|
+
};
|
|
54
|
+
for (const [field, meta] of Object.entries(model.fields)) {
|
|
55
|
+
if (meta.column)
|
|
56
|
+
register(field, meta.column);
|
|
57
|
+
}
|
|
58
|
+
const orgColumn = tenancyColumn(model.tenancy);
|
|
59
|
+
if (orgColumn)
|
|
60
|
+
register('organizationId', orgColumn);
|
|
61
|
+
out.set(key, { table: model.tableName ?? key, fieldToColumn, columnToField });
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
export function kyselyDataSource(db, schema) {
|
|
66
|
+
const maps = buildColumnMaps(schema);
|
|
67
|
+
const modelColumns = (model) => {
|
|
68
|
+
const mc = maps.get(model);
|
|
69
|
+
if (!mc) {
|
|
70
|
+
throw new AbloValidationError(`kyselyDataSource: no model "${model}" in schema`, {
|
|
71
|
+
code: 'source_adapter_misconfigured',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return mc;
|
|
75
|
+
};
|
|
76
|
+
const columnFor = (mc, field) => mc.fieldToColumn.get(field) ?? camelToSnake(field);
|
|
77
|
+
const fieldFor = (mc, column) => mc.columnToField.get(column) ?? snakeToCamel(column);
|
|
78
|
+
/** Field-keyed (SDK shape) → column-keyed (physical), for INSERT/UPDATE. */
|
|
79
|
+
const toColumns = (mc, row) => {
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const k of Object.keys(row))
|
|
82
|
+
out[columnFor(mc, k)] = row[k];
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
/** Column-keyed (RETURNING * / SELECT *) → field-keyed (SDK shape). */
|
|
86
|
+
const toFields = (mc, row) => {
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const k of Object.keys(row))
|
|
89
|
+
out[fieldFor(mc, k)] = row[k];
|
|
90
|
+
return out;
|
|
91
|
+
};
|
|
92
|
+
const applyOperation = async (trx, op) => {
|
|
93
|
+
const mc = modelColumns(op.model);
|
|
94
|
+
const id = rowId(op);
|
|
95
|
+
const input = op.input ?? {};
|
|
96
|
+
if (op.type === 'DELETE') {
|
|
97
|
+
const deleted = await trx
|
|
98
|
+
.deleteFrom(mc.table)
|
|
99
|
+
.where('id', '=', id)
|
|
100
|
+
.returningAll()
|
|
101
|
+
.execute();
|
|
102
|
+
return deleted[0] ? toFields(mc, deleted[0]) : { id };
|
|
103
|
+
}
|
|
104
|
+
if (op.type === 'CREATE') {
|
|
105
|
+
const inserted = await trx
|
|
106
|
+
.insertInto(mc.table)
|
|
107
|
+
.values(toColumns(mc, { id, ...input }))
|
|
108
|
+
.returningAll()
|
|
109
|
+
.execute();
|
|
110
|
+
return inserted[0] ? toFields(mc, inserted[0]) : { id, ...input };
|
|
111
|
+
}
|
|
112
|
+
// UPDATE / ARCHIVE / UNARCHIVE — the lifecycle field is `archivedAt`
|
|
113
|
+
// (camelCase) and goes through `toColumns` like any other, so it lands in
|
|
114
|
+
// `archived_at` — the same column the provisioner emits.
|
|
115
|
+
const patch = toColumns(mc, {
|
|
116
|
+
...input,
|
|
117
|
+
...(op.type === 'ARCHIVE' ? { archivedAt: new Date() } : {}),
|
|
118
|
+
...(op.type === 'UNARCHIVE' ? { archivedAt: null } : {}),
|
|
119
|
+
});
|
|
120
|
+
const updated = await trx
|
|
121
|
+
.updateTable(mc.table)
|
|
122
|
+
.set(patch)
|
|
123
|
+
.where('id', '=', id)
|
|
124
|
+
.returningAll()
|
|
125
|
+
.execute();
|
|
126
|
+
return updated[0] ? toFields(mc, updated[0]) : { id, ...input };
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
capabilities: { transactions: true, propose: false, schemaIntrospection: true },
|
|
130
|
+
migrations() {
|
|
131
|
+
return adapterTableMigrations();
|
|
132
|
+
},
|
|
133
|
+
async read(req) {
|
|
134
|
+
const mc = modelColumns(req.model);
|
|
135
|
+
if (req.kind === 'load') {
|
|
136
|
+
const rows = await db
|
|
137
|
+
.selectFrom(mc.table)
|
|
138
|
+
.selectAll()
|
|
139
|
+
.where('id', '=', req.id)
|
|
140
|
+
.limit(1)
|
|
141
|
+
.execute();
|
|
142
|
+
return rows.map((r) => toFields(mc, r));
|
|
143
|
+
}
|
|
144
|
+
const limit = req.query?.limit ?? 1000;
|
|
145
|
+
const rows = await db.selectFrom(mc.table).selectAll().limit(limit).execute();
|
|
146
|
+
return rows.map((r) => toFields(mc, r));
|
|
147
|
+
},
|
|
148
|
+
async commit(change) {
|
|
149
|
+
return db.transaction().execute(async (trx) => {
|
|
150
|
+
const cached = await trx
|
|
151
|
+
.selectFrom('ablo_idempotency')
|
|
152
|
+
.selectAll()
|
|
153
|
+
.where('client_tx_id', '=', change.clientTxId)
|
|
154
|
+
.limit(1)
|
|
155
|
+
.execute();
|
|
156
|
+
if (cached.length > 0) {
|
|
157
|
+
const response = cached[0].response;
|
|
158
|
+
return {
|
|
159
|
+
rows: (typeof response === 'string' ? JSON.parse(response) : response),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const rows = [];
|
|
163
|
+
for (const [index, op] of change.operations.entries()) {
|
|
164
|
+
const row = await applyOperation(trx, op);
|
|
165
|
+
rows.push(row);
|
|
166
|
+
const entityId = String(row.id ?? rowId(op));
|
|
167
|
+
await trx
|
|
168
|
+
.insertInto('ablo_outbox')
|
|
169
|
+
.values({
|
|
170
|
+
id: `${change.clientTxId}:${index}`,
|
|
171
|
+
model: op.model,
|
|
172
|
+
entity_id: entityId,
|
|
173
|
+
type: op.type,
|
|
174
|
+
data: op.type === 'DELETE' ? null : JSON.stringify(row),
|
|
175
|
+
client_tx_id: change.clientTxId,
|
|
176
|
+
occurred_at: Date.now(),
|
|
177
|
+
})
|
|
178
|
+
.execute();
|
|
179
|
+
}
|
|
180
|
+
await trx
|
|
181
|
+
.insertInto('ablo_idempotency')
|
|
182
|
+
.values({ client_tx_id: change.clientTxId, response: JSON.stringify(rows) })
|
|
183
|
+
.execute();
|
|
184
|
+
return { rows };
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
async events(cursor, limit) {
|
|
188
|
+
const after = cursor ?? '0';
|
|
189
|
+
const rows = await db
|
|
190
|
+
.selectFrom('ablo_outbox')
|
|
191
|
+
.selectAll()
|
|
192
|
+
.where('cursor', '>', after)
|
|
193
|
+
.orderBy('cursor', 'asc')
|
|
194
|
+
.limit(limit)
|
|
195
|
+
.execute();
|
|
196
|
+
const events = rows.map((r) => outboxEventSchema.parse({
|
|
197
|
+
id: r.id,
|
|
198
|
+
model: r.model,
|
|
199
|
+
entityId: r.entity_id,
|
|
200
|
+
type: r.type,
|
|
201
|
+
data: typeof r.data === 'string' ? JSON.parse(r.data) : r.data ?? null,
|
|
202
|
+
organizationId: r.organization_id ?? null,
|
|
203
|
+
clientTxId: r.client_tx_id ?? null,
|
|
204
|
+
occurredAt: r.occurred_at != null ? Number(r.occurred_at) : null,
|
|
205
|
+
cursor: String(r.cursor),
|
|
206
|
+
}));
|
|
207
|
+
return { events, nextCursor: events.length > 0 ? events[events.length - 1].cursor : null };
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
* It models the real semantics minimally but faithfully: one canonical row store
|
|
9
9
|
* per model, an idempotency ledger keyed by `clientTxId`, and a monotonic outbox.
|
|
10
10
|
*/
|
|
11
|
+
import { AbloValidationError } from '../../errors.js';
|
|
11
12
|
function rowId(op) {
|
|
12
13
|
const id = op.id ?? op.input?.id;
|
|
13
14
|
if (typeof id !== 'string' || id.length === 0) {
|
|
14
|
-
throw new
|
|
15
|
+
throw new AbloValidationError(`operation on "${op.model}" requires an id`, { code: 'source_operation_id_required' });
|
|
15
16
|
}
|
|
16
17
|
return id;
|
|
17
18
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* (`PrismaLike`), so this compiles in the SDK package and is unit-testable with
|
|
13
13
|
* a fake, while a real `PrismaClient` satisfies it at the call site.
|
|
14
14
|
*/
|
|
15
|
+
import { AbloValidationError } from '../../errors.js';
|
|
15
16
|
import { outboxEventSchema } from '../contract.js';
|
|
16
17
|
import { adapterTableMigrations } from '../migrations.js';
|
|
17
18
|
const lowerFirst = (s) => (s ? s[0].toLowerCase() + s.slice(1) : s);
|
|
@@ -32,7 +33,7 @@ const lowerFirst = (s) => (s ? s[0].toLowerCase() + s.slice(1) : s);
|
|
|
32
33
|
function delegateFor(client, name) {
|
|
33
34
|
const delegate = client[name];
|
|
34
35
|
if (!delegate || typeof delegate.findMany !== 'function') {
|
|
35
|
-
throw new
|
|
36
|
+
throw new AbloValidationError(`prismaDataSource: no Prisma delegate "${name}" on the client`, { code: 'source_adapter_misconfigured' });
|
|
36
37
|
}
|
|
37
38
|
return delegate;
|
|
38
39
|
}
|
|
@@ -97,7 +98,7 @@ function findManyArgs(query) {
|
|
|
97
98
|
function rowId(op) {
|
|
98
99
|
const id = op.id ?? op.input?.id;
|
|
99
100
|
if (typeof id !== 'string' || id.length === 0) {
|
|
100
|
-
throw new
|
|
101
|
+
throw new AbloValidationError(`operation on "${op.model}" requires an id`, { code: 'source_operation_id_required' });
|
|
101
102
|
}
|
|
102
103
|
return id;
|
|
103
104
|
}
|
package/dist/source/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AbloValidationError } from '../errors.js';
|
|
1
2
|
import { changeSetSchema } from './contract.js';
|
|
2
3
|
/**
|
|
3
4
|
* Build the source-event marker customers should write to their outbox table in
|
|
@@ -10,7 +11,7 @@ import { changeSetSchema } from './contract.js';
|
|
|
10
11
|
export function sourceEventForOperation(options) {
|
|
11
12
|
const entityId = options.entityId ?? options.operation.id;
|
|
12
13
|
if (typeof entityId !== 'string' || entityId.length === 0) {
|
|
13
|
-
throw new
|
|
14
|
+
throw new AbloValidationError('sourceEventForOperation requires operation.id or an explicit entityId', { code: 'source_event_invalid' });
|
|
14
15
|
}
|
|
15
16
|
const occurredAt = normalizeEventOccurredAt(options.occurredAt);
|
|
16
17
|
return {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
11
11
|
import type { Database } from '../Database.js';
|
|
12
12
|
import { Model } from '../Model.js';
|
|
13
|
-
import type {
|
|
13
|
+
import type { WriteOptions } from '../interfaces/index.js';
|
|
14
14
|
export interface UserContext {
|
|
15
15
|
userId: string;
|
|
16
16
|
organizationId: string;
|
|
@@ -19,7 +19,6 @@ export interface UserContext {
|
|
|
19
19
|
}
|
|
20
20
|
/** Wire-format mutation payload (post-projection). */
|
|
21
21
|
type MutationInput = Record<string, unknown>;
|
|
22
|
-
type TransactionWriteOptions = Pick<MutationOptions, 'readAt' | 'onStale'>;
|
|
23
22
|
export interface Transaction {
|
|
24
23
|
id: string;
|
|
25
24
|
type: 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
|
|
@@ -34,7 +33,7 @@ export interface Transaction {
|
|
|
34
33
|
attempts: number;
|
|
35
34
|
priority: 'normal' | 'high';
|
|
36
35
|
priorityScore: number;
|
|
37
|
-
writeOptions?:
|
|
36
|
+
writeOptions?: WriteOptions;
|
|
38
37
|
batchId?: string;
|
|
39
38
|
/** LINEAR PATTERN: syncId threshold - transaction confirms when delta.id >= this value */
|
|
40
39
|
syncIdNeededForCompletion?: number;
|
|
@@ -237,16 +236,16 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
237
236
|
/**
|
|
238
237
|
* Create operation with optimistic update
|
|
239
238
|
*/
|
|
240
|
-
create(model: Model, context: UserContext, writeOptions?:
|
|
239
|
+
create(model: Model, context: UserContext, writeOptions?: WriteOptions): Promise<Transaction>;
|
|
241
240
|
/**
|
|
242
241
|
* Update operation with conflict detection
|
|
243
242
|
* @param precomputedChanges - Optional pre-captured changes (avoids re-reading from model)
|
|
244
243
|
*/
|
|
245
|
-
update(model: Model, context: UserContext, precomputedChanges?: Record<string, unknown>, writeOptions?:
|
|
244
|
+
update(model: Model, context: UserContext, precomputedChanges?: Record<string, unknown>, writeOptions?: WriteOptions): Promise<Transaction>;
|
|
246
245
|
/**
|
|
247
246
|
* Delete operation with cascade handling
|
|
248
247
|
*/
|
|
249
|
-
delete(model: Model, context: UserContext, writeOptions?:
|
|
248
|
+
delete(model: Model, context: UserContext, writeOptions?: WriteOptions): Promise<Transaction>;
|
|
250
249
|
/**
|
|
251
250
|
* Upload attachment — delegates to attachment-uploader.ts
|
|
252
251
|
*/
|
|
@@ -269,7 +268,7 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
269
268
|
/**
|
|
270
269
|
* Archive operation
|
|
271
270
|
*/
|
|
272
|
-
archive(model: Model, context: UserContext, writeOptions?:
|
|
271
|
+
archive(model: Model, context: UserContext, writeOptions?: WriteOptions): Promise<Transaction>;
|
|
273
272
|
/**
|
|
274
273
|
* Unarchive operation
|
|
275
274
|
*/
|
|
@@ -117,13 +117,31 @@ function hasStaleWriteOptions(options) {
|
|
|
117
117
|
return (options?.readAt !== undefined ||
|
|
118
118
|
options?.onStale !== undefined);
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Project a transaction's `writeOptions` onto the wire operation. Stale
|
|
122
|
+
* guards (`readAt`/`onStale`) ride at the op root; `idempotencyKey`/`label`
|
|
123
|
+
* ride in the op's `options` slot (`MutationOperation.options` — the
|
|
124
|
+
* mutation_log cache key + audit tag). This is the single place the
|
|
125
|
+
* caller-supplied write vocabulary crosses onto the wire.
|
|
126
|
+
*/
|
|
127
|
+
function applyWriteOptions(op, transaction) {
|
|
121
128
|
const operation = op;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
operation.
|
|
129
|
+
const writeOptions = transaction.writeOptions;
|
|
130
|
+
if (!writeOptions)
|
|
131
|
+
return operation;
|
|
132
|
+
if (writeOptions.readAt !== undefined) {
|
|
133
|
+
operation.readAt = writeOptions.readAt;
|
|
134
|
+
}
|
|
135
|
+
if (writeOptions.onStale !== undefined) {
|
|
136
|
+
operation.onStale = writeOptions.onStale;
|
|
137
|
+
}
|
|
138
|
+
if (writeOptions.idempotencyKey != null || writeOptions.label !== undefined) {
|
|
139
|
+
operation.options = {
|
|
140
|
+
...(writeOptions.idempotencyKey != null
|
|
141
|
+
? { idempotencyKey: writeOptions.idempotencyKey }
|
|
142
|
+
: {}),
|
|
143
|
+
...(writeOptions.label !== undefined ? { label: writeOptions.label } : {}),
|
|
144
|
+
};
|
|
127
145
|
}
|
|
128
146
|
return operation;
|
|
129
147
|
}
|
|
@@ -552,7 +570,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
552
570
|
// Build operations list
|
|
553
571
|
const operations = pending.map((tx) => {
|
|
554
572
|
this.ensureDerivedFields(tx);
|
|
555
|
-
return
|
|
573
|
+
return applyWriteOptions({
|
|
556
574
|
type: TX_TYPE_TO_MUTATION_OP[tx.type],
|
|
557
575
|
model: tx.modelKey,
|
|
558
576
|
id: tx.modelId,
|
|
@@ -930,7 +948,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
930
948
|
// matches it via `OptimisticEchoTracker.consumeEcho` to suppress
|
|
931
949
|
// double-applying optimistic mutations. Distinct from the
|
|
932
950
|
// batch-level idempotency key in mutation_log.
|
|
933
|
-
const op =
|
|
951
|
+
const op = applyWriteOptions({
|
|
934
952
|
type: TX_TYPE_TO_MUTATION_OP[tx.type],
|
|
935
953
|
model: tx.modelKey,
|
|
936
954
|
id: tx.modelId,
|
|
@@ -1358,6 +1376,12 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1358
1376
|
};
|
|
1359
1377
|
this.commitStore.set(clientTxId, tx);
|
|
1360
1378
|
this.commitLane.push(tx);
|
|
1379
|
+
// Surface the envelope on its OWN event so the undo stream can record
|
|
1380
|
+
// commit-lane writes too (`SyncClient.onLocalTransaction` enriches each
|
|
1381
|
+
// operation with pool-captured previous state). Deliberately NOT
|
|
1382
|
+
// `transaction:created` — that event also feeds the optimistic-echo
|
|
1383
|
+
// tracker, and commit-lane ops have no optimistic pool apply to echo.
|
|
1384
|
+
this.emit('commit:created', { clientTxId, operations: tx.operations });
|
|
1361
1385
|
void this.processCommitLane();
|
|
1362
1386
|
}
|
|
1363
1387
|
/**
|
|
@@ -1674,7 +1698,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1674
1698
|
const input = (type === 'create' || type === 'update') ? data : undefined;
|
|
1675
1699
|
try {
|
|
1676
1700
|
await this.mutationExecutor.commit([
|
|
1677
|
-
|
|
1701
|
+
applyWriteOptions({ type: mutationType, model, id: modelId, input }, transaction),
|
|
1678
1702
|
]);
|
|
1679
1703
|
}
|
|
1680
1704
|
catch (error) {
|
package/dist/types/streams.d.ts
CHANGED
|
@@ -52,7 +52,8 @@ export interface AgentDelta {
|
|
|
52
52
|
capabilityId?: string | null;
|
|
53
53
|
/** Whether the human explicitly approved the change. */
|
|
54
54
|
confirmationState?: ConfirmationState | null;
|
|
55
|
-
/**
|
|
55
|
+
/** Agent-task id that caused this commit, if any. Dormant on new client
|
|
56
|
+
* writes (turns/tasks removed); may hold historical values. */
|
|
56
57
|
causedByTaskId?: string | null;
|
|
57
58
|
createdAt: string;
|
|
58
59
|
}
|
package/dist/utils/duration.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* without breaking numeric callers — the wrapper below branches on
|
|
17
17
|
* the input type.
|
|
18
18
|
*/
|
|
19
|
+
import { AbloValidationError } from '../errors.js';
|
|
19
20
|
const PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/;
|
|
20
21
|
const UNIT_MS = {
|
|
21
22
|
ms: 1,
|
|
@@ -34,8 +35,8 @@ export function toMs(input) {
|
|
|
34
35
|
return input * 1_000;
|
|
35
36
|
const match = PATTERN.exec(input);
|
|
36
37
|
if (!match) {
|
|
37
|
-
throw new
|
|
38
|
-
`a string like "500ms" | "30s" | "3m" | "24h"
|
|
38
|
+
throw new AbloValidationError(`Invalid duration "${input}" — expected number (seconds) or ` +
|
|
39
|
+
`a string like "500ms" | "30s" | "3m" | "24h".`, { code: 'duration_invalid' });
|
|
39
40
|
}
|
|
40
41
|
const value = Number(match[1]);
|
|
41
42
|
const unit = match[2];
|
package/dist/wire/frames.d.ts
CHANGED
|
@@ -76,14 +76,12 @@ export interface CommitMessage {
|
|
|
76
76
|
operations: CommitOperation[];
|
|
77
77
|
clientTxId: string;
|
|
78
78
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* with `caused_by_task_id = NULL`, which the audit pane treats as "no
|
|
86
|
-
* prompt-side context recorded."
|
|
79
|
+
* Dormant agent-task lineage field. The SDK no longer populates it —
|
|
80
|
+
* turns/tasks were removed and write attribution now rides on the
|
|
81
|
+
* claim (`intent`) id plus the server-stamped actor/capability. Kept
|
|
82
|
+
* optional for wire-compat; when present the Hub still validates and
|
|
83
|
+
* threads it onto `caused_by_task_id`, but client writes leave it
|
|
84
|
+
* `null` (the audit pane treats null as "no prompt-side context").
|
|
87
85
|
*/
|
|
88
86
|
causedByTaskId?: string | null;
|
|
89
87
|
};
|
package/docs/api.md
CHANGED
|
@@ -8,7 +8,7 @@ row, you can optionally `claim` it so they serialize instead of clobbering
|
|
|
8
8
|
each other.
|
|
9
9
|
|
|
10
10
|
Two things to know before the method list. **Reads come in two flavors:**
|
|
11
|
-
`retrieve(id)` / `list({ where })` are async and hit the server (use them when
|
|
11
|
+
`retrieve({ id })` / `list({ where })` are async and hit the server (use them when
|
|
12
12
|
the row may not be local yet); `get(id)` / `getAll({ where })` / `getCount({ where })`
|
|
13
13
|
are synchronous reads off the local graph (use them in render, after data has
|
|
14
14
|
synced). **Claims don't lock.** If another writer holds the row, `claim` waits
|
package/docs/cli.md
CHANGED
|
@@ -57,7 +57,7 @@ either mode) defines the same models test and live see; only the rows differ.
|
|
|
57
57
|
| `ablo dev` | **Hosted** — push the schema to your test sandbox, then watch `ablo/schema.ts` and re-push on save. | `--no-watch`, `--schema <path>`, `--export <name>`, `--url <url>` |
|
|
58
58
|
| `ablo logs` | Tail your scope's commit activity (`stripe logs tail`). Follows by default. | `-n, --tail <N>`, `--since <dur\|ts>`, `--model`, `--op`, `--json`, `--no-follow`, `--mode test\|live` |
|
|
59
59
|
| `ablo push` | **Hosted** — upload the schema to Ablo; the server diffs, migrates, and activates it. | `--force`, `--rename old:new`, `--backfill model.field=value`, `--schema`, `--export`, `--url` |
|
|
60
|
-
| `ablo migrate` | **Direct Postgres** —
|
|
60
|
+
| `ablo migrate` | **Direct Postgres** — provision just the synced models (plus the adapter's `ablo_outbox` / `ablo_idempotency`) in your own `DATABASE_URL`. Leaves your other tables alone. | `--dry-run`, `--output <file>`, `--schema`, `--export` |
|
|
61
61
|
| `ablo pull` | **Direct Postgres** — generate `defineSchema(...)` from your existing tables (read-only, like `prisma db pull`). | `--out <path>`, `--app-schema <name>`, `--import <pkg>`, `--force` |
|
|
62
62
|
| `ablo check` | **Direct Postgres** — verify your _existing_ tables fit the schema (read-only, no schema changes). | `--schema <path>`, `--export <name>`, `--app-schema <name>` |
|
|
63
63
|
| `ablo generate` | Emit TypeScript types from the schema. | `--out <path>`, `--schema`, `--export` |
|
|
@@ -144,9 +144,9 @@ reshaping it. `ablo check` is read-only; it never proposes a migration.
|
|
|
144
144
|
## `migrate` (Direct Postgres) vs `push` (Hosted)
|
|
145
145
|
|
|
146
146
|
Same engine, two setups. If you use the **Direct Postgres connector**, use
|
|
147
|
-
`ablo migrate` — it
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
`ablo migrate` — it provisions the synced models in your own `DATABASE_URL`. If
|
|
148
|
+
Ablo manages the sandbox/hosted store, use `ablo push` and `ablo dev` — the
|
|
149
|
+
server applies the change and version-gates connecting clients.
|
|
150
150
|
|
|
151
151
|
```bash
|
|
152
152
|
ablo migrate --dry-run # preview the exact SQL
|
|
@@ -154,6 +154,19 @@ ablo migrate # apply to DATABASE_URL
|
|
|
154
154
|
ablo migrate --output schema.sql # write SQL to a file
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
### One database, two schemas
|
|
158
|
+
|
|
159
|
+
`ablo migrate` does **not** own your whole database. It creates exactly the
|
|
160
|
+
models in your `defineSchema(...)` — the synced, collaborative tables — plus the
|
|
161
|
+
adapter's bookkeeping tables (`ablo_outbox`, `ablo_idempotency`). Nothing else.
|
|
162
|
+
|
|
163
|
+
Your auth, billing, and any other non-synced tables stay in **your own ORM
|
|
164
|
+
schema** (Drizzle's `schema.ts`, Prisma's `schema.prisma`) and are provisioned by
|
|
165
|
+
**your own migrations** (`drizzle-kit push` / `prisma migrate`). The Ablo schema
|
|
166
|
+
is not a replacement for `schema.prisma`, and `ablo migrate` won't touch, drop,
|
|
167
|
+
or adopt the tables it doesn't manage. One database, two schemas, side by side —
|
|
168
|
+
each owned by its own migration tool.
|
|
169
|
+
|
|
157
170
|
## Zod → Postgres type mapping
|
|
158
171
|
|
|
159
172
|
The one type map, shared by both paths (there is no second mapping):
|
package/docs/client-behavior.md
CHANGED
|
@@ -30,7 +30,7 @@ Common options:
|
|
|
30
30
|
| `schema` | Required for typed model clients. |
|
|
31
31
|
| `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
|
|
32
32
|
| `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
|
|
33
|
-
| `persistence` | `
|
|
33
|
+
| `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
|
|
34
34
|
| `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
|
|
35
35
|
| `defaultHeaders` | Extra headers attached to every HTTP request. |
|
|
36
36
|
| `defaultQuery` | Extra query parameters attached to every HTTP request. |
|