@syncular/server 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context.d.ts +2 -0
- package/dist/context.js +1 -0
- package/dist/d1-storage.js +27 -1
- package/dist/errors.js +16 -6
- package/dist/frame-bytes.js +16 -18
- package/dist/handler.js +0 -3
- package/dist/operations.js +2 -2
- package/dist/postgres-storage.d.ts +1 -1
- package/dist/postgres-storage.js +35 -5
- package/dist/push.js +371 -42
- package/dist/relational-rows.d.ts +17 -1
- package/dist/relational-rows.js +76 -7
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +55 -0
- package/dist/seed.js +2 -2
- package/dist/sqlite-dialect.d.ts +2 -1
- package/dist/sqlite-dialect.js +10 -0
- package/dist/sqlite-storage.js +23 -0
- package/dist/storage.d.ts +21 -0
- package/package.json +2 -2
- package/src/context.ts +3 -0
- package/src/d1-storage.ts +40 -1
- package/src/errors.ts +16 -6
- package/src/frame-bytes.ts +16 -22
- package/src/handler.ts +0 -6
- package/src/operations.ts +2 -2
- package/src/postgres-storage.ts +51 -5
- package/src/push.ts +516 -41
- package/src/relational-rows.ts +82 -6
- package/src/schema.ts +109 -0
- package/src/seed.ts +2 -2
- package/src/sqlite-dialect.ts +12 -0
- package/src/sqlite-storage.ts +36 -0
- package/src/storage.ts +21 -0
package/src/relational-rows.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Every synced table is a REAL table in the server database — the app's
|
|
5
5
|
* columns with proper type affinities, queryable with plain SQL/joins/BI —
|
|
6
|
-
* plus
|
|
6
|
+
* plus six `_sync_*` meta columns:
|
|
7
7
|
*
|
|
8
8
|
* _sync_partition TEXT partition key (multi-partition servers
|
|
9
9
|
* share one database; same app PK in two
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
* so sync round-trips are byte-identical by
|
|
24
24
|
* construction. Typed columns are a
|
|
25
25
|
* queryable projection.
|
|
26
|
+
* _sync_column_versions BLOB/BYTEA §2.2 per-column versions: LEB128
|
|
27
|
+
* (ordinal, version) pairs for columns
|
|
28
|
+
* above 1; NULL when none exceeds 1
|
|
29
|
+
* (every fresh insert). Server-internal.
|
|
26
30
|
*
|
|
27
31
|
* PRIMARY KEY (_sync_partition, _sync_row_id).
|
|
28
32
|
*
|
|
@@ -59,6 +63,67 @@ export const SYNC_ROW_ID_COLUMN = '_sync_row_id';
|
|
|
59
63
|
export const SYNC_VERSION_COLUMN = '_sync_server_version';
|
|
60
64
|
export const SYNC_SCOPES_COLUMN = '_sync_scopes';
|
|
61
65
|
export const SYNC_PAYLOAD_COLUMN = '_sync_payload';
|
|
66
|
+
export const SYNC_COLUMN_VERSIONS_COLUMN = '_sync_column_versions';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* §2.2 column-version storage: LEB128 `(ordinal, version)` varint pairs,
|
|
70
|
+
* ascending ordinal, for the columns whose version exceeds 1. An empty (or
|
|
71
|
+
* NULL) blob means no column exceeds 1. Server-internal: these bytes never
|
|
72
|
+
* ride the wire.
|
|
73
|
+
*/
|
|
74
|
+
export function encodeColumnVersions(
|
|
75
|
+
versions: ReadonlyMap<number, number>,
|
|
76
|
+
): Uint8Array | undefined {
|
|
77
|
+
const bytes: number[] = [];
|
|
78
|
+
const varint = (value: number): void => {
|
|
79
|
+
let rest = value;
|
|
80
|
+
while (rest >= 0x80) {
|
|
81
|
+
bytes.push((rest & 0x7f) | 0x80);
|
|
82
|
+
rest = Math.floor(rest / 128);
|
|
83
|
+
}
|
|
84
|
+
bytes.push(rest);
|
|
85
|
+
};
|
|
86
|
+
for (const ordinal of [...versions.keys()].sort((a, b) => a - b)) {
|
|
87
|
+
const version = versions.get(ordinal) ?? 0;
|
|
88
|
+
if (version <= 1) continue;
|
|
89
|
+
varint(ordinal);
|
|
90
|
+
varint(version);
|
|
91
|
+
}
|
|
92
|
+
return bytes.length === 0 ? undefined : Uint8Array.from(bytes);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function decodeColumnVersions(
|
|
96
|
+
blob: Uint8Array | undefined | null,
|
|
97
|
+
): Map<number, number> {
|
|
98
|
+
const versions = new Map<number, number>();
|
|
99
|
+
if (blob === undefined || blob === null) return versions;
|
|
100
|
+
let offset = 0;
|
|
101
|
+
const varint = (): number => {
|
|
102
|
+
let value = 0;
|
|
103
|
+
let shift = 1;
|
|
104
|
+
for (;;) {
|
|
105
|
+
const byte = blob[offset];
|
|
106
|
+
if (byte === undefined) {
|
|
107
|
+
throw new Error('truncated _sync_column_versions blob');
|
|
108
|
+
}
|
|
109
|
+
offset++;
|
|
110
|
+
value += (byte & 0x7f) * shift;
|
|
111
|
+
if ((byte & 0x80) === 0) return value;
|
|
112
|
+
shift *= 128;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
while (offset < blob.length) {
|
|
116
|
+
const ordinal = varint();
|
|
117
|
+
const version = varint();
|
|
118
|
+
if (version <= 1) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
'corrupt _sync_column_versions blob: version at or below 1 is never stored',
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
versions.set(ordinal, version);
|
|
124
|
+
}
|
|
125
|
+
return versions;
|
|
126
|
+
}
|
|
62
127
|
|
|
63
128
|
/** SQL-standard identifier quoting (both dialects). */
|
|
64
129
|
export function quoteIdent(name: string): string {
|
|
@@ -122,6 +187,7 @@ export function tableColumnNames(table: CompiledTable): string[] {
|
|
|
122
187
|
SYNC_VERSION_COLUMN,
|
|
123
188
|
SYNC_SCOPES_COLUMN,
|
|
124
189
|
SYNC_PAYLOAD_COLUMN,
|
|
190
|
+
SYNC_COLUMN_VERSIONS_COLUMN,
|
|
125
191
|
];
|
|
126
192
|
}
|
|
127
193
|
|
|
@@ -145,6 +211,7 @@ export function createTableDdl(
|
|
|
145
211
|
`${quoteIdent(SYNC_VERSION_COLUMN)} ${versionType} NOT NULL`,
|
|
146
212
|
`${quoteIdent(SYNC_SCOPES_COLUMN)} ${scopesType} NOT NULL`,
|
|
147
213
|
`${quoteIdent(SYNC_PAYLOAD_COLUMN)} ${payloadType} NOT NULL`,
|
|
214
|
+
`${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${payloadType}`,
|
|
148
215
|
`PRIMARY KEY (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)})`,
|
|
149
216
|
];
|
|
150
217
|
return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs.join(', ')})`;
|
|
@@ -153,14 +220,22 @@ export function createTableDdl(
|
|
|
153
220
|
/**
|
|
154
221
|
* ALTER TABLE ADD COLUMN statements for schema columns missing from
|
|
155
222
|
* `existingColumns` (introspected). Added columns are nullable (header note).
|
|
223
|
+
* The `_sync_column_versions` meta column is added to pre-column-version
|
|
224
|
+
* tables too, materialized or not: it is storage-internal, nullable, and
|
|
225
|
+
* never part of the app-column migration subset.
|
|
156
226
|
*/
|
|
157
227
|
export function addColumnDdl(
|
|
158
228
|
table: CompiledTable,
|
|
159
229
|
existingColumns: ReadonlySet<string>,
|
|
160
230
|
dialect: RelationalDialect,
|
|
161
231
|
): string[] {
|
|
162
|
-
if (!table.materialize) return [];
|
|
163
232
|
const out: string[] = [];
|
|
233
|
+
if (!existingColumns.has(SYNC_COLUMN_VERSIONS_COLUMN)) {
|
|
234
|
+
out.push(
|
|
235
|
+
`ALTER TABLE ${quoteIdent(table.name)} ADD COLUMN ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${dialect === 'postgres' ? 'BYTEA' : 'BLOB'}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (!table.materialize) return out;
|
|
164
239
|
for (const column of table.columns) {
|
|
165
240
|
if (existingColumns.has(column.name)) continue;
|
|
166
241
|
out.push(
|
|
@@ -267,7 +342,7 @@ export function upsertValues(
|
|
|
267
342
|
dialect: RelationalDialect,
|
|
268
343
|
): unknown[] {
|
|
269
344
|
// A non-materialized table skips the decode entirely — the upsert is a
|
|
270
|
-
//
|
|
345
|
+
// six-column meta write (the old blob-store cost).
|
|
271
346
|
const projection = table.materialize
|
|
272
347
|
? decodeRow(table.columns, row.payload)
|
|
273
348
|
: undefined;
|
|
@@ -282,6 +357,7 @@ export function upsertValues(
|
|
|
282
357
|
row.serverVersion,
|
|
283
358
|
JSON.stringify(row.scopes),
|
|
284
359
|
row.payload,
|
|
360
|
+
row.columnVersions ?? null,
|
|
285
361
|
];
|
|
286
362
|
}
|
|
287
363
|
|
|
@@ -318,7 +394,7 @@ export function selectRowSql(
|
|
|
318
394
|
dialect: RelationalDialect,
|
|
319
395
|
): string {
|
|
320
396
|
const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
|
|
321
|
-
return `SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${p[0]} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${p[1]}`;
|
|
397
|
+
return `SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${p[0]} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${p[1]}`;
|
|
322
398
|
}
|
|
323
399
|
|
|
324
400
|
export interface IndexRowPageStatement {
|
|
@@ -360,7 +436,7 @@ export function indexRowPageStatement(
|
|
|
360
436
|
params.push(limit);
|
|
361
437
|
const pageLimit = placeholder();
|
|
362
438
|
return {
|
|
363
|
-
sql: `SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${dialect === 'sqlite' ? '?' : '$1'} AND ${predicates.join(' AND ')} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>${after} ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ${pageLimit}`,
|
|
439
|
+
sql: `SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${dialect === 'sqlite' ? '?' : '$1'} AND ${predicates.join(' AND ')} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>${after} ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ${pageLimit}`,
|
|
364
440
|
params,
|
|
365
441
|
};
|
|
366
442
|
}
|
|
@@ -396,7 +472,7 @@ export function scanRowPageSql(
|
|
|
396
472
|
const after = p(4 + valueCount);
|
|
397
473
|
const limit = p(5 + valueCount);
|
|
398
474
|
const joinPartition = dialect === 'sqlite' ? '?' : '$1';
|
|
399
|
-
return `SELECT c.row_id AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload
|
|
475
|
+
return `SELECT c.row_id AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
|
|
400
476
|
FROM (SELECT DISTINCT row_id FROM sync_row_scopes
|
|
401
477
|
WHERE partition=${p(1)} AND tbl=${p(2)} AND var=${p(3)} AND value IN (${values.join(',')})
|
|
402
478
|
AND row_id>${after}
|
package/src/schema.ts
CHANGED
|
@@ -23,6 +23,34 @@ export interface IndexSchema {
|
|
|
23
23
|
readonly unique?: boolean;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** One declared reference (§6.11). Absent `onDelete` means `RESTRICT`. */
|
|
27
|
+
export interface ReferenceSchema {
|
|
28
|
+
readonly column: string;
|
|
29
|
+
readonly parentTable: string;
|
|
30
|
+
readonly onDelete?: ReferenceOnDelete;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ReferenceOnDelete = 'RESTRICT' | 'CASCADE' | 'SET NULL';
|
|
34
|
+
|
|
35
|
+
/** A compiled outgoing reference (§6.11). */
|
|
36
|
+
export interface CompiledReference {
|
|
37
|
+
readonly column: string;
|
|
38
|
+
readonly columnIndex: number;
|
|
39
|
+
readonly parentTable: string;
|
|
40
|
+
readonly onDelete: ReferenceOnDelete;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A compiled INCOMING reference: rows of `table` whose `column` points at a
|
|
44
|
+
* row of the table that carries this entry. `index` is the declared
|
|
45
|
+
* single-column index the server probes through `scanRowsByIndex` (§6.8). */
|
|
46
|
+
export interface CompiledIncomingReference {
|
|
47
|
+
readonly table: string;
|
|
48
|
+
readonly column: string;
|
|
49
|
+
readonly columnIndex: number;
|
|
50
|
+
readonly onDelete: ReferenceOnDelete;
|
|
51
|
+
readonly index: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
26
54
|
export interface TableSchema {
|
|
27
55
|
readonly name: string;
|
|
28
56
|
/** Columns in schema-IR declaration order (the row-codec order, §2.4). */
|
|
@@ -33,6 +61,10 @@ export interface TableSchema {
|
|
|
33
61
|
readonly scopes: readonly ScopePatternSpec[];
|
|
34
62
|
/** User indexes (optional) — created on the server's relational tables. */
|
|
35
63
|
readonly indexes?: readonly IndexSchema[];
|
|
64
|
+
/** Declared references (§6.11). Optional so pre-reference schemas stay
|
|
65
|
+
* valid; the reference index over each child column is declared in
|
|
66
|
+
* `indexes` (typegen emits it). */
|
|
67
|
+
readonly references?: readonly ReferenceSchema[];
|
|
36
68
|
/**
|
|
37
69
|
* Server-side column materialization
|
|
38
70
|
* "optional materialization"). When `true` (the usual default) the server's
|
|
@@ -72,6 +104,10 @@ export interface CompiledTable {
|
|
|
72
104
|
readonly scopePatterns: readonly CompiledScopePattern[];
|
|
73
105
|
/** User indexes (validated: unique names, existing columns). */
|
|
74
106
|
readonly indexes: readonly IndexSchema[];
|
|
107
|
+
/** Outgoing declared references (§6.11), resolved to column indices. */
|
|
108
|
+
readonly references: readonly CompiledReference[];
|
|
109
|
+
/** Incoming declared references: child rows to reach on a parent delete. */
|
|
110
|
+
readonly referencedBy: readonly CompiledIncomingReference[];
|
|
75
111
|
/** Resolved materialization (see `TableSchema.materialize`). */
|
|
76
112
|
readonly materialize: boolean;
|
|
77
113
|
readonly columnIndex: ReadonlyMap<string, number>;
|
|
@@ -216,6 +252,21 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
|
|
|
216
252
|
const blobRefColumnIndices: number[] = [];
|
|
217
253
|
const crdtColumns: { index: number; crdtType: string }[] = [];
|
|
218
254
|
const encryptedColumnIndices: number[] = [];
|
|
255
|
+
const references: CompiledReference[] = [];
|
|
256
|
+
for (const reference of table.references ?? []) {
|
|
257
|
+
const index = columnIndex.get(reference.column);
|
|
258
|
+
if (index === undefined) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`table ${table.name}: reference column ${JSON.stringify(reference.column)} is not a column`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
references.push({
|
|
264
|
+
column: reference.column,
|
|
265
|
+
columnIndex: index,
|
|
266
|
+
parentTable: reference.parentTable,
|
|
267
|
+
onDelete: reference.onDelete ?? 'RESTRICT',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
219
270
|
table.columns.forEach((column, index) => {
|
|
220
271
|
if (column.type === 'blob_ref') blobRefColumnIndices.push(index);
|
|
221
272
|
// §5.11: an encrypted column carries wire type `bytes` + `encrypted`.
|
|
@@ -253,6 +304,8 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
|
|
|
253
304
|
primaryKeyIndex,
|
|
254
305
|
scopePatterns,
|
|
255
306
|
indexes,
|
|
307
|
+
references,
|
|
308
|
+
referencedBy: [],
|
|
256
309
|
materialize,
|
|
257
310
|
columnIndex,
|
|
258
311
|
declaredVariables: variables,
|
|
@@ -261,6 +314,62 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
|
|
|
261
314
|
encryptedColumnIndices,
|
|
262
315
|
});
|
|
263
316
|
}
|
|
317
|
+
// §6.11: resolve incoming references and enforce the declaration rules the
|
|
318
|
+
// generator enforces too, so a hand-written server schema fails loud.
|
|
319
|
+
for (const child of tables.values()) {
|
|
320
|
+
for (const reference of child.references) {
|
|
321
|
+
const parent = tables.get(reference.parentTable);
|
|
322
|
+
if (parent === undefined) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`table ${child.name}: reference column ${JSON.stringify(reference.column)} names unknown table ${JSON.stringify(reference.parentTable)}`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const childColumn = child.columns[reference.columnIndex];
|
|
328
|
+
const parentKey = parent.columns[parent.primaryKeyIndex];
|
|
329
|
+
if (childColumn === undefined || parentKey === undefined) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`unreachable: ${child.name}.${reference.column} or a missing primary key on ${parent.name}`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
if (childColumn.type !== parentKey.type) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`table ${child.name}: reference column ${JSON.stringify(reference.column)} has type ${JSON.stringify(childColumn.type)} but ${parent.name}.${parentKey.name} has type ${JSON.stringify(parentKey.type)} — the types must match (§6.11)`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (reference.onDelete === 'SET NULL' && !childColumn.nullable) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`table ${child.name}: reference column ${JSON.stringify(reference.column)} is not nullable but declares ON DELETE SET NULL (§6.11)`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const patterns = (compiled: CompiledTable): string =>
|
|
345
|
+
compiled.scopePatterns
|
|
346
|
+
.map((pattern) => `${pattern.prefix}:{${pattern.variable}}`)
|
|
347
|
+
.sort()
|
|
348
|
+
.join('\u0000');
|
|
349
|
+
if (patterns(child) !== patterns(parent)) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`table ${child.name}: reference column ${JSON.stringify(reference.column)} targets ${parent.name}, whose scope patterns differ — a cascade MUST NOT cross an authorization boundary (§6.11)`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
const index = child.indexes.find(
|
|
355
|
+
(candidate) =>
|
|
356
|
+
candidate.columns.length === 1 &&
|
|
357
|
+
candidate.columns[0] === reference.column,
|
|
358
|
+
);
|
|
359
|
+
if (index === undefined) {
|
|
360
|
+
throw new Error(
|
|
361
|
+
`table ${child.name}: reference column ${JSON.stringify(reference.column)} needs a declared single-column index so the server reaches children through scanRowsByIndex (§6.11)`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
(parent.referencedBy as CompiledIncomingReference[]).push({
|
|
365
|
+
table: child.name,
|
|
366
|
+
column: reference.column,
|
|
367
|
+
columnIndex: reference.columnIndex,
|
|
368
|
+
onDelete: reference.onDelete,
|
|
369
|
+
index: index.name,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
264
373
|
const compiled: CompiledSchema = {
|
|
265
374
|
version: schema.version,
|
|
266
375
|
tables,
|
package/src/seed.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import {
|
|
13
13
|
decodeMessage,
|
|
14
14
|
encodeMessage,
|
|
15
|
-
|
|
15
|
+
encodeSparseRow,
|
|
16
16
|
PROTOCOL_WIRE_VERSION,
|
|
17
17
|
type PushOperation,
|
|
18
18
|
type PushResultFrame,
|
|
@@ -182,7 +182,7 @@ export async function seedMutations(
|
|
|
182
182
|
table: mutation.table,
|
|
183
183
|
rowId,
|
|
184
184
|
op: 'upsert',
|
|
185
|
-
payload:
|
|
185
|
+
payload: encodeSparseRow(table.columns, pkIndex, values),
|
|
186
186
|
};
|
|
187
187
|
});
|
|
188
188
|
|
package/src/sqlite-dialect.ts
CHANGED
|
@@ -114,6 +114,11 @@ CREATE TABLE IF NOT EXISTS sync_blob_refs(
|
|
|
114
114
|
);
|
|
115
115
|
CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
|
|
116
116
|
ON sync_blob_refs(partition, blob_id);
|
|
117
|
+
CREATE TABLE IF NOT EXISTS sync_tombstones(
|
|
118
|
+
partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
119
|
+
commit_seq INTEGER NOT NULL,
|
|
120
|
+
PRIMARY KEY(partition, tbl, row_id)
|
|
121
|
+
);
|
|
117
122
|
`;
|
|
118
123
|
|
|
119
124
|
/** Split the DDL into individual statements (D1 applies them one by one). */
|
|
@@ -151,6 +156,7 @@ interface SerializedResult {
|
|
|
151
156
|
message?: string;
|
|
152
157
|
serverVersion?: number;
|
|
153
158
|
serverRow?: string;
|
|
159
|
+
conflictColumns?: string;
|
|
154
160
|
retryable?: boolean;
|
|
155
161
|
details?: import('@syncular/core').RejectionDetails;
|
|
156
162
|
}
|
|
@@ -175,6 +181,7 @@ export function serializePushResult(result: StoredPushResult): string {
|
|
|
175
181
|
message: record.message,
|
|
176
182
|
serverVersion: record.serverVersion,
|
|
177
183
|
serverRow: bytesToBase64(record.serverRow),
|
|
184
|
+
conflictColumns: bytesToBase64(record.conflictColumns),
|
|
178
185
|
};
|
|
179
186
|
}
|
|
180
187
|
if (record.status === 'error') {
|
|
@@ -209,6 +216,7 @@ export function deserializePushResult(text: string): StoredPushResult {
|
|
|
209
216
|
message: record.message ?? '',
|
|
210
217
|
serverVersion: record.serverVersion ?? 0,
|
|
211
218
|
serverRow: base64ToBytes(record.serverRow ?? ''),
|
|
219
|
+
conflictColumns: base64ToBytes(record.conflictColumns ?? ''),
|
|
212
220
|
};
|
|
213
221
|
}
|
|
214
222
|
if (record.status === 'error') {
|
|
@@ -242,6 +250,7 @@ export interface SqliteRowRecord {
|
|
|
242
250
|
server_version: number;
|
|
243
251
|
scopes: string;
|
|
244
252
|
payload: Uint8Array;
|
|
253
|
+
column_versions: Uint8Array | null;
|
|
245
254
|
}
|
|
246
255
|
|
|
247
256
|
export interface SqliteChangeRecord {
|
|
@@ -267,6 +276,9 @@ export function toStoredRow(record: SqliteRowRecord): StoredRow {
|
|
|
267
276
|
serverVersion: record.server_version,
|
|
268
277
|
scopes: JSON.parse(record.scopes) as Record<string, string>,
|
|
269
278
|
payload: asUint8Array(record.payload),
|
|
279
|
+
...(record.column_versions !== null
|
|
280
|
+
? { columnVersions: asUint8Array(record.column_versions) }
|
|
281
|
+
: {}),
|
|
270
282
|
};
|
|
271
283
|
}
|
|
272
284
|
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -163,6 +163,28 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
163
163
|
return this.#storage.getRow(this.#partition, table, rowId);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
async getTombstoneSeq(
|
|
167
|
+
table: string,
|
|
168
|
+
rowId: string,
|
|
169
|
+
): Promise<number | undefined> {
|
|
170
|
+
this.#assertOpen();
|
|
171
|
+
const row = this.#storage.db
|
|
172
|
+
.query<{ commit_seq: number }, [string, string, string]>(
|
|
173
|
+
'SELECT commit_seq FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?',
|
|
174
|
+
)
|
|
175
|
+
.get(this.#partition, table, rowId);
|
|
176
|
+
return row?.commit_seq;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async clearTombstone(table: string, rowId: string): Promise<void> {
|
|
180
|
+
this.#assertOpen();
|
|
181
|
+
this.#storage.db
|
|
182
|
+
.query(
|
|
183
|
+
'DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?',
|
|
184
|
+
)
|
|
185
|
+
.run(this.#partition, table, rowId);
|
|
186
|
+
}
|
|
187
|
+
|
|
166
188
|
getPushResult(
|
|
167
189
|
clientId: string,
|
|
168
190
|
clientCommitId: string,
|
|
@@ -302,6 +324,14 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
302
324
|
JSON.stringify(change.scopes),
|
|
303
325
|
change.payload ?? null,
|
|
304
326
|
);
|
|
327
|
+
if (change.op === 'delete') {
|
|
328
|
+
// §5 delete precedence: every applied delete leaves a tombstone,
|
|
329
|
+
// pruned with the commit log (§4.6).
|
|
330
|
+
db.query(
|
|
331
|
+
`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
|
|
332
|
+
ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`,
|
|
333
|
+
).run(p, change.table, change.rowId, commitSeq);
|
|
334
|
+
}
|
|
305
335
|
for (const [variable, value] of Object.entries(change.scopes)) {
|
|
306
336
|
db.query(
|
|
307
337
|
'INSERT OR IGNORE INTO sync_change_scopes(partition, tbl, var, value, commit_seq) VALUES (?,?,?,?,?)',
|
|
@@ -833,6 +863,12 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
833
863
|
'DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?',
|
|
834
864
|
)
|
|
835
865
|
.run(partition, horizonSeq);
|
|
866
|
+
// §4.6: tombstones prune in the same pass as the commit log.
|
|
867
|
+
this.db
|
|
868
|
+
.query(
|
|
869
|
+
'DELETE FROM sync_tombstones WHERE partition=? AND commit_seq<=?',
|
|
870
|
+
)
|
|
871
|
+
.run(partition, horizonSeq);
|
|
836
872
|
this.db.exec('COMMIT');
|
|
837
873
|
return {
|
|
838
874
|
previousHorizonSeq,
|
package/src/storage.ts
CHANGED
|
@@ -40,6 +40,13 @@ export interface StoredRow {
|
|
|
40
40
|
readonly scopes: Record<string, string>;
|
|
41
41
|
/** Row-codec payload (§2.4) for the server's schema version. */
|
|
42
42
|
readonly payload: Uint8Array;
|
|
43
|
+
/**
|
|
44
|
+
* §2.2 per-column versions: the `_sync_column_versions` blob (LEB128
|
|
45
|
+
* (ordinal, version) pairs for columns above 1). Absent when no column
|
|
46
|
+
* exceeds 1, and for rows written before column versions existed — a row
|
|
47
|
+
* without the blob carries every column at `serverVersion`.
|
|
48
|
+
*/
|
|
49
|
+
readonly columnVersions?: Uint8Array;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
export interface NewChange {
|
|
@@ -312,6 +319,20 @@ export interface AuthoritativeQueryResult {
|
|
|
312
319
|
*/
|
|
313
320
|
export interface StorageTransaction {
|
|
314
321
|
getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
322
|
+
/**
|
|
323
|
+
* §5 delete-precedence tombstone read: the `commit_seq` of the newest
|
|
324
|
+
* applied delete of this row still within the pruning horizon, or
|
|
325
|
+
* `undefined` when no tombstone exists. Consulted only when an upsert's
|
|
326
|
+
* stored row is absent. Required: in-tree adapters implement it on the
|
|
327
|
+
* transaction's own connection with read-your-own-writes semantics.
|
|
328
|
+
*/
|
|
329
|
+
getTombstoneSeq(table: string, rowId: string): Promise<number | undefined>;
|
|
330
|
+
/**
|
|
331
|
+
* Remove the row's tombstone because the row was recreated in this
|
|
332
|
+
* transaction (§5: an explicit insert recreates). Called on every insert
|
|
333
|
+
* over an absent row; a no-op when no tombstone exists.
|
|
334
|
+
*/
|
|
335
|
+
clearTombstone(table: string, rowId: string): Promise<void>;
|
|
315
336
|
/**
|
|
316
337
|
* Optional transaction-scoped idempotency lookup (§2.3) with the same
|
|
317
338
|
* semantics as `ServerStorage.getPushResult`, including the
|