@abloatai/ablo 0.9.2 → 0.9.4
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 +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +47 -27
- package/dist/BaseSyncedStore.d.ts +7 -38
- package/dist/BaseSyncedStore.js +20 -67
- package/dist/Database.js +7 -1
- package/dist/NetworkMonitor.js +4 -1
- package/dist/SyncClient.d.ts +18 -5
- package/dist/SyncClient.js +72 -1
- package/dist/SyncEngineContext.js +5 -1
- package/dist/auth/index.js +3 -1
- package/dist/cli.cjs +282241 -0
- package/dist/client/Ablo.d.ts +12 -3
- package/dist/client/Ablo.js +36 -3
- package/dist/client/ApiClient.js +39 -6
- package/dist/client/auth.d.ts +1 -1
- package/dist/client/auth.js +14 -5
- 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/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 +16 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/interfaces/index.d.ts +10 -0
- package/dist/mutators/UndoManager.d.ts +31 -5
- package/dist/mutators/UndoManager.js +113 -1
- package/dist/schema/ddl.js +12 -3
- package/dist/schema/field.js +2 -1
- package/dist/schema/model.d.ts +9 -7
- package/dist/schema/model.js +1 -1
- package/dist/schema/schema.js +7 -1
- package/dist/schema/serialize.js +2 -1
- 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/sync/syncPosition.d.ts +78 -0
- package/dist/sync/syncPosition.js +111 -0
- package/dist/transactions/TransactionQueue.d.ts +22 -8
- package/dist/transactions/TransactionQueue.js +76 -34
- package/dist/utils/duration.js +3 -2
- package/docs/api-keys.md +4 -4
- package/docs/cli.md +6 -6
- package/docs/client-behavior.md +1 -1
- package/docs/data-sources.md +61 -42
- package/docs/guarantees.md +2 -2
- package/docs/index.md +2 -2
- package/docs/integration-guide.md +4 -7
- package/docs/mcp.md +1 -1
- package/docs/quickstart.md +84 -37
- package/docs/schema-contract.md +2 -4
- package/llms-full.txt +365 -0
- package/llms.txt +14 -9
- package/package.json +26 -4
|
@@ -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;
|
|
@@ -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 {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE sync-position structure — one typed object for "where is this client
|
|
3
|
+
* in the global delta order", replacing five scattered private counters
|
|
4
|
+
* (`lastSeenSyncId` on the queue, `highestProcessedSyncId` + `lastAckedId`
|
|
5
|
+
* on the store, ad-hoc acked watermarks, `max()` calls at snapshot sites).
|
|
6
|
+
*
|
|
7
|
+
* Three facts with DIFFERENT advance disciplines — flattening them was the
|
|
8
|
+
* historical bug source, so the structure models them explicitly:
|
|
9
|
+
*
|
|
10
|
+
* - `persisted` — the resume/ack cursor. Advances ONLY after deltas have
|
|
11
|
+
* committed to IndexedDB (the Replicache "lastMutationID read in the
|
|
12
|
+
* same transaction as the client view" rule — see SyncWebSocket.sendAck).
|
|
13
|
+
* This is what reconnect catch-up sends; it must never run ahead of
|
|
14
|
+
* durable state or the server skips deltas that never landed.
|
|
15
|
+
*
|
|
16
|
+
* - `applied` — the in-memory cursor: the last delta APPLIED to the
|
|
17
|
+
* object pool. Drives delta dedup/replay guards. May run ahead of
|
|
18
|
+
* `persisted` (pool applies before the IDB flush) and behind receipt
|
|
19
|
+
* (bootstrap-queued deltas are received but not yet applied).
|
|
20
|
+
*
|
|
21
|
+
* - `acked` — the highest server watermark ACKED to this client's OWN
|
|
22
|
+
* commits. An ack at N means the server applied our write at N; the
|
|
23
|
+
* optimistic pool already reflects it, so for entities we wrote we have
|
|
24
|
+
* logically read through N even before the stream echo arrives.
|
|
25
|
+
*
|
|
26
|
+
* One derived read: `readFloor` = max(applied, acked) — the ONLY value
|
|
27
|
+
* snapshots/claims may stamp as `readAt`. The bare stream cursor made a
|
|
28
|
+
* claim taken right after an ack-confirmed write stale against that write's
|
|
29
|
+
* own delta; the bare ack would be wrong for read-only clients. Per-entity
|
|
30
|
+
* correct: a foreign change to an entity we just wrote necessarily lands
|
|
31
|
+
* ABOVE our ack and still stale-rejects.
|
|
32
|
+
*
|
|
33
|
+
* The Zod schema IS the state shape — the class holds exactly one
|
|
34
|
+
* `SyncPositionSnapshot` and applies monotonic merges to it, so
|
|
35
|
+
* snapshot/restore are identity-shaped and the schema is the single gate
|
|
36
|
+
* for anything loaded from disk (`parseSyncPosition`; a corrupted stored
|
|
37
|
+
* cursor "ahead of reality" is an existing, known failure mode).
|
|
38
|
+
*/
|
|
39
|
+
import { z } from 'zod';
|
|
40
|
+
export declare const syncPositionSchema: z.ZodObject<{
|
|
41
|
+
persisted: z.ZodNumber;
|
|
42
|
+
applied: z.ZodNumber;
|
|
43
|
+
acked: z.ZodNumber;
|
|
44
|
+
}, z.core.$strip>;
|
|
45
|
+
export type SyncPositionSnapshot = z.infer<typeof syncPositionSchema>;
|
|
46
|
+
/**
|
|
47
|
+
* PERSISTENCE DESIGN: only the `persisted` cursor is stored durably (as
|
|
48
|
+
* `WorkspaceMetadata.lastSyncId`, written by Database after each IDB delta
|
|
49
|
+
* commit and gated on load through `syncPositionSchema.shape.persisted` in
|
|
50
|
+
* `Database.requiredBootstrap`). Persisting `applied`/`acked` would be
|
|
51
|
+
* meaningless: on resume the pool is rebuilt FROM the persisted state, so
|
|
52
|
+
* the correct restore is exactly `advancePersisted(storedCursor)` — which
|
|
53
|
+
* implies `applied`, while `acked` starts at 0 (a dead session's acks carry
|
|
54
|
+
* no read authority; the offline queue re-acks its own replays).
|
|
55
|
+
*/
|
|
56
|
+
/** Validate a persisted/foreign value into a position snapshot. */
|
|
57
|
+
export declare function parseSyncPosition(value: unknown): SyncPositionSnapshot | null;
|
|
58
|
+
/** The live position. One instance per client (owned by SyncClient); the
|
|
59
|
+
* three producers advance their own fact, consumers read. */
|
|
60
|
+
export declare class SyncPosition {
|
|
61
|
+
#private;
|
|
62
|
+
/** Current state — the schema shape, frozen-by-copy. */
|
|
63
|
+
snapshot(): SyncPositionSnapshot;
|
|
64
|
+
get persisted(): number;
|
|
65
|
+
get applied(): number;
|
|
66
|
+
get acked(): number;
|
|
67
|
+
/** THE value snapshots/claims stamp as `readAt`. */
|
|
68
|
+
get readFloor(): number;
|
|
69
|
+
/** Deltas through `syncId` have COMMITTED to IndexedDB. Persisting
|
|
70
|
+
* implies applied — the flush path applies before/with persisting. */
|
|
71
|
+
advancePersisted(syncId: number): void;
|
|
72
|
+
/** A delta was APPLIED to the in-memory pool. */
|
|
73
|
+
advanceApplied(syncId: number): void;
|
|
74
|
+
/** The server acked one of OUR commits at this watermark. */
|
|
75
|
+
noteAck(lastSyncId: number | undefined): void;
|
|
76
|
+
/** Restore from a VALIDATED snapshot (e.g. IDB resume). Monotonic. */
|
|
77
|
+
restore(snapshot: SyncPositionSnapshot): void;
|
|
78
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE sync-position structure — one typed object for "where is this client
|
|
3
|
+
* in the global delta order", replacing five scattered private counters
|
|
4
|
+
* (`lastSeenSyncId` on the queue, `highestProcessedSyncId` + `lastAckedId`
|
|
5
|
+
* on the store, ad-hoc acked watermarks, `max()` calls at snapshot sites).
|
|
6
|
+
*
|
|
7
|
+
* Three facts with DIFFERENT advance disciplines — flattening them was the
|
|
8
|
+
* historical bug source, so the structure models them explicitly:
|
|
9
|
+
*
|
|
10
|
+
* - `persisted` — the resume/ack cursor. Advances ONLY after deltas have
|
|
11
|
+
* committed to IndexedDB (the Replicache "lastMutationID read in the
|
|
12
|
+
* same transaction as the client view" rule — see SyncWebSocket.sendAck).
|
|
13
|
+
* This is what reconnect catch-up sends; it must never run ahead of
|
|
14
|
+
* durable state or the server skips deltas that never landed.
|
|
15
|
+
*
|
|
16
|
+
* - `applied` — the in-memory cursor: the last delta APPLIED to the
|
|
17
|
+
* object pool. Drives delta dedup/replay guards. May run ahead of
|
|
18
|
+
* `persisted` (pool applies before the IDB flush) and behind receipt
|
|
19
|
+
* (bootstrap-queued deltas are received but not yet applied).
|
|
20
|
+
*
|
|
21
|
+
* - `acked` — the highest server watermark ACKED to this client's OWN
|
|
22
|
+
* commits. An ack at N means the server applied our write at N; the
|
|
23
|
+
* optimistic pool already reflects it, so for entities we wrote we have
|
|
24
|
+
* logically read through N even before the stream echo arrives.
|
|
25
|
+
*
|
|
26
|
+
* One derived read: `readFloor` = max(applied, acked) — the ONLY value
|
|
27
|
+
* snapshots/claims may stamp as `readAt`. The bare stream cursor made a
|
|
28
|
+
* claim taken right after an ack-confirmed write stale against that write's
|
|
29
|
+
* own delta; the bare ack would be wrong for read-only clients. Per-entity
|
|
30
|
+
* correct: a foreign change to an entity we just wrote necessarily lands
|
|
31
|
+
* ABOVE our ack and still stale-rejects.
|
|
32
|
+
*
|
|
33
|
+
* The Zod schema IS the state shape — the class holds exactly one
|
|
34
|
+
* `SyncPositionSnapshot` and applies monotonic merges to it, so
|
|
35
|
+
* snapshot/restore are identity-shaped and the schema is the single gate
|
|
36
|
+
* for anything loaded from disk (`parseSyncPosition`; a corrupted stored
|
|
37
|
+
* cursor "ahead of reality" is an existing, known failure mode).
|
|
38
|
+
*/
|
|
39
|
+
import { z } from 'zod';
|
|
40
|
+
export const syncPositionSchema = z.object({
|
|
41
|
+
/** Resume/ack cursor — advances only after IDB persistence. */
|
|
42
|
+
persisted: z.number().int().nonnegative(),
|
|
43
|
+
/** In-memory cursor — last delta applied to the pool. */
|
|
44
|
+
applied: z.number().int().nonnegative(),
|
|
45
|
+
/** Highest server watermark acked to this client's own commits. */
|
|
46
|
+
acked: z.number().int().nonnegative(),
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* PERSISTENCE DESIGN: only the `persisted` cursor is stored durably (as
|
|
50
|
+
* `WorkspaceMetadata.lastSyncId`, written by Database after each IDB delta
|
|
51
|
+
* commit and gated on load through `syncPositionSchema.shape.persisted` in
|
|
52
|
+
* `Database.requiredBootstrap`). Persisting `applied`/`acked` would be
|
|
53
|
+
* meaningless: on resume the pool is rebuilt FROM the persisted state, so
|
|
54
|
+
* the correct restore is exactly `advancePersisted(storedCursor)` — which
|
|
55
|
+
* implies `applied`, while `acked` starts at 0 (a dead session's acks carry
|
|
56
|
+
* no read authority; the offline queue re-acks its own replays).
|
|
57
|
+
*/
|
|
58
|
+
/** Validate a persisted/foreign value into a position snapshot. */
|
|
59
|
+
export function parseSyncPosition(value) {
|
|
60
|
+
const result = syncPositionSchema.safeParse(value);
|
|
61
|
+
return result.success ? result.data : null;
|
|
62
|
+
}
|
|
63
|
+
const ZERO = { persisted: 0, applied: 0, acked: 0 };
|
|
64
|
+
/** Monotonic merge: each cursor only ever moves forward. */
|
|
65
|
+
function advance(state, next) {
|
|
66
|
+
return {
|
|
67
|
+
persisted: Math.max(state.persisted, next.persisted ?? 0),
|
|
68
|
+
applied: Math.max(state.applied, next.applied ?? 0),
|
|
69
|
+
acked: Math.max(state.acked, next.acked ?? 0),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** The live position. One instance per client (owned by SyncClient); the
|
|
73
|
+
* three producers advance their own fact, consumers read. */
|
|
74
|
+
export class SyncPosition {
|
|
75
|
+
#state = ZERO;
|
|
76
|
+
/** Current state — the schema shape, frozen-by-copy. */
|
|
77
|
+
snapshot() {
|
|
78
|
+
return { ...this.#state };
|
|
79
|
+
}
|
|
80
|
+
get persisted() {
|
|
81
|
+
return this.#state.persisted;
|
|
82
|
+
}
|
|
83
|
+
get applied() {
|
|
84
|
+
return this.#state.applied;
|
|
85
|
+
}
|
|
86
|
+
get acked() {
|
|
87
|
+
return this.#state.acked;
|
|
88
|
+
}
|
|
89
|
+
/** THE value snapshots/claims stamp as `readAt`. */
|
|
90
|
+
get readFloor() {
|
|
91
|
+
return Math.max(this.#state.applied, this.#state.acked);
|
|
92
|
+
}
|
|
93
|
+
/** Deltas through `syncId` have COMMITTED to IndexedDB. Persisting
|
|
94
|
+
* implies applied — the flush path applies before/with persisting. */
|
|
95
|
+
advancePersisted(syncId) {
|
|
96
|
+
this.#state = advance(this.#state, { persisted: syncId, applied: syncId });
|
|
97
|
+
}
|
|
98
|
+
/** A delta was APPLIED to the in-memory pool. */
|
|
99
|
+
advanceApplied(syncId) {
|
|
100
|
+
this.#state = advance(this.#state, { applied: syncId });
|
|
101
|
+
}
|
|
102
|
+
/** The server acked one of OUR commits at this watermark. */
|
|
103
|
+
noteAck(lastSyncId) {
|
|
104
|
+
if (lastSyncId !== undefined)
|
|
105
|
+
this.#state = advance(this.#state, { acked: lastSyncId });
|
|
106
|
+
}
|
|
107
|
+
/** Restore from a VALIDATED snapshot (e.g. IDB resume). Monotonic. */
|
|
108
|
+
restore(snapshot) {
|
|
109
|
+
this.#state = advance(this.#state, snapshot);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
11
11
|
import type { Database } from '../Database.js';
|
|
12
12
|
import { Model } from '../Model.js';
|
|
13
|
-
import
|
|
13
|
+
import { SyncPosition } from '../sync/syncPosition.js';
|
|
14
|
+
import type { WriteOptions } from '../interfaces/index.js';
|
|
14
15
|
export interface UserContext {
|
|
15
16
|
userId: string;
|
|
16
17
|
organizationId: string;
|
|
@@ -19,7 +20,6 @@ export interface UserContext {
|
|
|
19
20
|
}
|
|
20
21
|
/** Wire-format mutation payload (post-projection). */
|
|
21
22
|
type MutationInput = Record<string, unknown>;
|
|
22
|
-
type TransactionWriteOptions = Pick<MutationOptions, 'readAt' | 'onStale'>;
|
|
23
23
|
export interface Transaction {
|
|
24
24
|
id: string;
|
|
25
25
|
type: 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
|
|
@@ -34,7 +34,7 @@ export interface Transaction {
|
|
|
34
34
|
attempts: number;
|
|
35
35
|
priority: 'normal' | 'high';
|
|
36
36
|
priorityScore: number;
|
|
37
|
-
writeOptions?:
|
|
37
|
+
writeOptions?: WriteOptions;
|
|
38
38
|
batchId?: string;
|
|
39
39
|
/** LINEAR PATTERN: syncId threshold - transaction confirms when delta.id >= this value */
|
|
40
40
|
syncIdNeededForCompletion?: number;
|
|
@@ -83,6 +83,8 @@ interface ConflictResolution {
|
|
|
83
83
|
resolver?: (local: MutationInput | undefined, remote: MutationInput) => MutationInput;
|
|
84
84
|
}
|
|
85
85
|
interface TransactionQueueConfig {
|
|
86
|
+
/** Shared client position (see sync/syncPosition.ts). One per client. */
|
|
87
|
+
position?: SyncPosition;
|
|
86
88
|
maxBatchSize: number;
|
|
87
89
|
batchDelay: number;
|
|
88
90
|
maxRetries: number;
|
|
@@ -141,7 +143,17 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
141
143
|
private deltaConfirmationRetries;
|
|
142
144
|
private isConnectedFn;
|
|
143
145
|
private commitOfflineGraceTimer;
|
|
144
|
-
|
|
146
|
+
/**
|
|
147
|
+
* THE client's place in the global delta order — the SHARED instance
|
|
148
|
+
* (injected by SyncClient; standalone construction gets its own). The
|
|
149
|
+
* queue advances `acked` on commit responses; the store advances
|
|
150
|
+
* `applied`/`persisted`; snapshots/claims read `readFloor`. Contract +
|
|
151
|
+
* rationale live in `sync/syncPosition.ts`.
|
|
152
|
+
*/
|
|
153
|
+
readonly position: SyncPosition;
|
|
154
|
+
/** Applied-cursor alias, kept so the many internal read sites stay legible. */
|
|
155
|
+
private get lastSeenSyncId();
|
|
156
|
+
private noteAck;
|
|
145
157
|
private static readonly DELTA_MAX_RETRIES;
|
|
146
158
|
private static readonly DELTA_INITIAL_TIMEOUT_MS;
|
|
147
159
|
private static readonly DELTA_MAX_TIMEOUT_MS;
|
|
@@ -237,16 +249,16 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
237
249
|
/**
|
|
238
250
|
* Create operation with optimistic update
|
|
239
251
|
*/
|
|
240
|
-
create(model: Model, context: UserContext, writeOptions?:
|
|
252
|
+
create(model: Model, context: UserContext, writeOptions?: WriteOptions): Promise<Transaction>;
|
|
241
253
|
/**
|
|
242
254
|
* Update operation with conflict detection
|
|
243
255
|
* @param precomputedChanges - Optional pre-captured changes (avoids re-reading from model)
|
|
244
256
|
*/
|
|
245
|
-
update(model: Model, context: UserContext, precomputedChanges?: Record<string, unknown>, writeOptions?:
|
|
257
|
+
update(model: Model, context: UserContext, precomputedChanges?: Record<string, unknown>, writeOptions?: WriteOptions): Promise<Transaction>;
|
|
246
258
|
/**
|
|
247
259
|
* Delete operation with cascade handling
|
|
248
260
|
*/
|
|
249
|
-
delete(model: Model, context: UserContext, writeOptions?:
|
|
261
|
+
delete(model: Model, context: UserContext, writeOptions?: WriteOptions): Promise<Transaction>;
|
|
250
262
|
/**
|
|
251
263
|
* Upload attachment — delegates to attachment-uploader.ts
|
|
252
264
|
*/
|
|
@@ -269,7 +281,7 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
269
281
|
/**
|
|
270
282
|
* Archive operation
|
|
271
283
|
*/
|
|
272
|
-
archive(model: Model, context: UserContext, writeOptions?:
|
|
284
|
+
archive(model: Model, context: UserContext, writeOptions?: WriteOptions): Promise<Transaction>;
|
|
273
285
|
/**
|
|
274
286
|
* Unarchive operation
|
|
275
287
|
*/
|
|
@@ -415,6 +427,8 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
415
427
|
totalTransactions: number;
|
|
416
428
|
batchIndex: number;
|
|
417
429
|
config: {
|
|
430
|
+
/** Shared client position (see sync/syncPosition.ts). One per client. */
|
|
431
|
+
position?: SyncPosition;
|
|
418
432
|
maxBatchSize: number;
|
|
419
433
|
batchDelay: number;
|
|
420
434
|
maxRetries: number;
|