@syncular/server 0.20.1 → 0.21.1
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/dist/relational-rows.js
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
|
*
|
|
@@ -49,6 +53,62 @@ export const SYNC_ROW_ID_COLUMN = '_sync_row_id';
|
|
|
49
53
|
export const SYNC_VERSION_COLUMN = '_sync_server_version';
|
|
50
54
|
export const SYNC_SCOPES_COLUMN = '_sync_scopes';
|
|
51
55
|
export const SYNC_PAYLOAD_COLUMN = '_sync_payload';
|
|
56
|
+
export const SYNC_COLUMN_VERSIONS_COLUMN = '_sync_column_versions';
|
|
57
|
+
/**
|
|
58
|
+
* §2.2 column-version storage: LEB128 `(ordinal, version)` varint pairs,
|
|
59
|
+
* ascending ordinal, for the columns whose version exceeds 1. An empty (or
|
|
60
|
+
* NULL) blob means no column exceeds 1. Server-internal: these bytes never
|
|
61
|
+
* ride the wire.
|
|
62
|
+
*/
|
|
63
|
+
export function encodeColumnVersions(versions) {
|
|
64
|
+
const bytes = [];
|
|
65
|
+
const varint = (value) => {
|
|
66
|
+
let rest = value;
|
|
67
|
+
while (rest >= 0x80) {
|
|
68
|
+
bytes.push((rest & 0x7f) | 0x80);
|
|
69
|
+
rest = Math.floor(rest / 128);
|
|
70
|
+
}
|
|
71
|
+
bytes.push(rest);
|
|
72
|
+
};
|
|
73
|
+
for (const ordinal of [...versions.keys()].sort((a, b) => a - b)) {
|
|
74
|
+
const version = versions.get(ordinal) ?? 0;
|
|
75
|
+
if (version <= 1)
|
|
76
|
+
continue;
|
|
77
|
+
varint(ordinal);
|
|
78
|
+
varint(version);
|
|
79
|
+
}
|
|
80
|
+
return bytes.length === 0 ? undefined : Uint8Array.from(bytes);
|
|
81
|
+
}
|
|
82
|
+
export function decodeColumnVersions(blob) {
|
|
83
|
+
const versions = new Map();
|
|
84
|
+
if (blob === undefined || blob === null)
|
|
85
|
+
return versions;
|
|
86
|
+
let offset = 0;
|
|
87
|
+
const varint = () => {
|
|
88
|
+
let value = 0;
|
|
89
|
+
let shift = 1;
|
|
90
|
+
for (;;) {
|
|
91
|
+
const byte = blob[offset];
|
|
92
|
+
if (byte === undefined) {
|
|
93
|
+
throw new Error('truncated _sync_column_versions blob');
|
|
94
|
+
}
|
|
95
|
+
offset++;
|
|
96
|
+
value += (byte & 0x7f) * shift;
|
|
97
|
+
if ((byte & 0x80) === 0)
|
|
98
|
+
return value;
|
|
99
|
+
shift *= 128;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
while (offset < blob.length) {
|
|
103
|
+
const ordinal = varint();
|
|
104
|
+
const version = varint();
|
|
105
|
+
if (version <= 1) {
|
|
106
|
+
throw new Error('corrupt _sync_column_versions blob: version at or below 1 is never stored');
|
|
107
|
+
}
|
|
108
|
+
versions.set(ordinal, version);
|
|
109
|
+
}
|
|
110
|
+
return versions;
|
|
111
|
+
}
|
|
52
112
|
/** SQL-standard identifier quoting (both dialects). */
|
|
53
113
|
export function quoteIdent(name) {
|
|
54
114
|
return `"${name.replaceAll('"', '""')}"`;
|
|
@@ -106,6 +166,7 @@ export function tableColumnNames(table) {
|
|
|
106
166
|
SYNC_VERSION_COLUMN,
|
|
107
167
|
SYNC_SCOPES_COLUMN,
|
|
108
168
|
SYNC_PAYLOAD_COLUMN,
|
|
169
|
+
SYNC_COLUMN_VERSIONS_COLUMN,
|
|
109
170
|
];
|
|
110
171
|
}
|
|
111
172
|
/** CREATE TABLE IF NOT EXISTS for one app table. */
|
|
@@ -125,6 +186,7 @@ export function createTableDdl(table, dialect) {
|
|
|
125
186
|
`${quoteIdent(SYNC_VERSION_COLUMN)} ${versionType} NOT NULL`,
|
|
126
187
|
`${quoteIdent(SYNC_SCOPES_COLUMN)} ${scopesType} NOT NULL`,
|
|
127
188
|
`${quoteIdent(SYNC_PAYLOAD_COLUMN)} ${payloadType} NOT NULL`,
|
|
189
|
+
`${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${payloadType}`,
|
|
128
190
|
`PRIMARY KEY (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)})`,
|
|
129
191
|
];
|
|
130
192
|
return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs.join(', ')})`;
|
|
@@ -132,11 +194,17 @@ export function createTableDdl(table, dialect) {
|
|
|
132
194
|
/**
|
|
133
195
|
* ALTER TABLE ADD COLUMN statements for schema columns missing from
|
|
134
196
|
* `existingColumns` (introspected). Added columns are nullable (header note).
|
|
197
|
+
* The `_sync_column_versions` meta column is added to pre-column-version
|
|
198
|
+
* tables too, materialized or not: it is storage-internal, nullable, and
|
|
199
|
+
* never part of the app-column migration subset.
|
|
135
200
|
*/
|
|
136
201
|
export function addColumnDdl(table, existingColumns, dialect) {
|
|
137
|
-
if (!table.materialize)
|
|
138
|
-
return [];
|
|
139
202
|
const out = [];
|
|
203
|
+
if (!existingColumns.has(SYNC_COLUMN_VERSIONS_COLUMN)) {
|
|
204
|
+
out.push(`ALTER TABLE ${quoteIdent(table.name)} ADD COLUMN ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${dialect === 'postgres' ? 'BYTEA' : 'BLOB'}`);
|
|
205
|
+
}
|
|
206
|
+
if (!table.materialize)
|
|
207
|
+
return out;
|
|
140
208
|
for (const column of table.columns) {
|
|
141
209
|
if (existingColumns.has(column.name))
|
|
142
210
|
continue;
|
|
@@ -231,7 +299,7 @@ export function toSqlValue(column, value, dialect) {
|
|
|
231
299
|
*/
|
|
232
300
|
export function upsertValues(table, partition, row, dialect) {
|
|
233
301
|
// A non-materialized table skips the decode entirely — the upsert is a
|
|
234
|
-
//
|
|
302
|
+
// six-column meta write (the old blob-store cost).
|
|
235
303
|
const projection = table.materialize
|
|
236
304
|
? decodeRow(table.columns, row.payload)
|
|
237
305
|
: undefined;
|
|
@@ -244,6 +312,7 @@ export function upsertValues(table, partition, row, dialect) {
|
|
|
244
312
|
row.serverVersion,
|
|
245
313
|
JSON.stringify(row.scopes),
|
|
246
314
|
row.payload,
|
|
315
|
+
row.columnVersions ?? null,
|
|
247
316
|
];
|
|
248
317
|
}
|
|
249
318
|
function placeholderList(count, dialect) {
|
|
@@ -273,7 +342,7 @@ export function upsertSql(table, dialect) {
|
|
|
273
342
|
*/
|
|
274
343
|
export function selectRowSql(table, dialect) {
|
|
275
344
|
const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
|
|
276
|
-
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]}`;
|
|
345
|
+
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]}`;
|
|
277
346
|
}
|
|
278
347
|
/**
|
|
279
348
|
* Bounded exact lookup through one declared relational index. Unlike
|
|
@@ -300,7 +369,7 @@ export function indexRowPageStatement(table, index, values, partition, afterRowI
|
|
|
300
369
|
params.push(limit);
|
|
301
370
|
const pageLimit = placeholder();
|
|
302
371
|
return {
|
|
303
|
-
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}`,
|
|
372
|
+
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}`,
|
|
304
373
|
params,
|
|
305
374
|
};
|
|
306
375
|
}
|
|
@@ -332,7 +401,7 @@ export function scanRowPageSql(table, valueCount, dialect) {
|
|
|
332
401
|
const after = p(4 + valueCount);
|
|
333
402
|
const limit = p(5 + valueCount);
|
|
334
403
|
const joinPartition = dialect === 'sqlite' ? '?' : '$1';
|
|
335
|
-
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
|
|
404
|
+
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
|
|
336
405
|
FROM (SELECT DISTINCT row_id FROM sync_row_scopes
|
|
337
406
|
WHERE partition=${p(1)} AND tbl=${p(2)} AND var=${p(3)} AND value IN (${values.join(',')})
|
|
338
407
|
AND row_id>${after}
|
package/dist/schema.d.ts
CHANGED
|
@@ -20,6 +20,30 @@ export interface IndexSchema {
|
|
|
20
20
|
readonly columns: readonly string[];
|
|
21
21
|
readonly unique?: boolean;
|
|
22
22
|
}
|
|
23
|
+
/** One declared reference (§6.11). Absent `onDelete` means `RESTRICT`. */
|
|
24
|
+
export interface ReferenceSchema {
|
|
25
|
+
readonly column: string;
|
|
26
|
+
readonly parentTable: string;
|
|
27
|
+
readonly onDelete?: ReferenceOnDelete;
|
|
28
|
+
}
|
|
29
|
+
export type ReferenceOnDelete = 'RESTRICT' | 'CASCADE' | 'SET NULL';
|
|
30
|
+
/** A compiled outgoing reference (§6.11). */
|
|
31
|
+
export interface CompiledReference {
|
|
32
|
+
readonly column: string;
|
|
33
|
+
readonly columnIndex: number;
|
|
34
|
+
readonly parentTable: string;
|
|
35
|
+
readonly onDelete: ReferenceOnDelete;
|
|
36
|
+
}
|
|
37
|
+
/** A compiled INCOMING reference: rows of `table` whose `column` points at a
|
|
38
|
+
* row of the table that carries this entry. `index` is the declared
|
|
39
|
+
* single-column index the server probes through `scanRowsByIndex` (§6.8). */
|
|
40
|
+
export interface CompiledIncomingReference {
|
|
41
|
+
readonly table: string;
|
|
42
|
+
readonly column: string;
|
|
43
|
+
readonly columnIndex: number;
|
|
44
|
+
readonly onDelete: ReferenceOnDelete;
|
|
45
|
+
readonly index: string;
|
|
46
|
+
}
|
|
23
47
|
export interface TableSchema {
|
|
24
48
|
readonly name: string;
|
|
25
49
|
/** Columns in schema-IR declaration order (the row-codec order, §2.4). */
|
|
@@ -30,6 +54,10 @@ export interface TableSchema {
|
|
|
30
54
|
readonly scopes: readonly ScopePatternSpec[];
|
|
31
55
|
/** User indexes (optional) — created on the server's relational tables. */
|
|
32
56
|
readonly indexes?: readonly IndexSchema[];
|
|
57
|
+
/** Declared references (§6.11). Optional so pre-reference schemas stay
|
|
58
|
+
* valid; the reference index over each child column is declared in
|
|
59
|
+
* `indexes` (typegen emits it). */
|
|
60
|
+
readonly references?: readonly ReferenceSchema[];
|
|
33
61
|
/**
|
|
34
62
|
* Server-side column materialization
|
|
35
63
|
* "optional materialization"). When `true` (the usual default) the server's
|
|
@@ -66,6 +94,10 @@ export interface CompiledTable {
|
|
|
66
94
|
readonly scopePatterns: readonly CompiledScopePattern[];
|
|
67
95
|
/** User indexes (validated: unique names, existing columns). */
|
|
68
96
|
readonly indexes: readonly IndexSchema[];
|
|
97
|
+
/** Outgoing declared references (§6.11), resolved to column indices. */
|
|
98
|
+
readonly references: readonly CompiledReference[];
|
|
99
|
+
/** Incoming declared references: child rows to reach on a parent delete. */
|
|
100
|
+
readonly referencedBy: readonly CompiledIncomingReference[];
|
|
69
101
|
/** Resolved materialization (see `TableSchema.materialize`). */
|
|
70
102
|
readonly materialize: boolean;
|
|
71
103
|
readonly columnIndex: ReadonlyMap<string, number>;
|
package/dist/schema.js
CHANGED
|
@@ -88,6 +88,19 @@ export function compileSchema(schema) {
|
|
|
88
88
|
const blobRefColumnIndices = [];
|
|
89
89
|
const crdtColumns = [];
|
|
90
90
|
const encryptedColumnIndices = [];
|
|
91
|
+
const references = [];
|
|
92
|
+
for (const reference of table.references ?? []) {
|
|
93
|
+
const index = columnIndex.get(reference.column);
|
|
94
|
+
if (index === undefined) {
|
|
95
|
+
throw new Error(`table ${table.name}: reference column ${JSON.stringify(reference.column)} is not a column`);
|
|
96
|
+
}
|
|
97
|
+
references.push({
|
|
98
|
+
column: reference.column,
|
|
99
|
+
columnIndex: index,
|
|
100
|
+
parentTable: reference.parentTable,
|
|
101
|
+
onDelete: reference.onDelete ?? 'RESTRICT',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
91
104
|
table.columns.forEach((column, index) => {
|
|
92
105
|
if (column.type === 'blob_ref')
|
|
93
106
|
blobRefColumnIndices.push(index);
|
|
@@ -118,6 +131,8 @@ export function compileSchema(schema) {
|
|
|
118
131
|
primaryKeyIndex,
|
|
119
132
|
scopePatterns,
|
|
120
133
|
indexes,
|
|
134
|
+
references,
|
|
135
|
+
referencedBy: [],
|
|
121
136
|
materialize,
|
|
122
137
|
columnIndex,
|
|
123
138
|
declaredVariables: variables,
|
|
@@ -126,6 +141,46 @@ export function compileSchema(schema) {
|
|
|
126
141
|
encryptedColumnIndices,
|
|
127
142
|
});
|
|
128
143
|
}
|
|
144
|
+
// §6.11: resolve incoming references and enforce the declaration rules the
|
|
145
|
+
// generator enforces too, so a hand-written server schema fails loud.
|
|
146
|
+
for (const child of tables.values()) {
|
|
147
|
+
for (const reference of child.references) {
|
|
148
|
+
const parent = tables.get(reference.parentTable);
|
|
149
|
+
if (parent === undefined) {
|
|
150
|
+
throw new Error(`table ${child.name}: reference column ${JSON.stringify(reference.column)} names unknown table ${JSON.stringify(reference.parentTable)}`);
|
|
151
|
+
}
|
|
152
|
+
const childColumn = child.columns[reference.columnIndex];
|
|
153
|
+
const parentKey = parent.columns[parent.primaryKeyIndex];
|
|
154
|
+
if (childColumn === undefined || parentKey === undefined) {
|
|
155
|
+
throw new Error(`unreachable: ${child.name}.${reference.column} or a missing primary key on ${parent.name}`);
|
|
156
|
+
}
|
|
157
|
+
if (childColumn.type !== parentKey.type) {
|
|
158
|
+
throw new Error(`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)`);
|
|
159
|
+
}
|
|
160
|
+
if (reference.onDelete === 'SET NULL' && !childColumn.nullable) {
|
|
161
|
+
throw new Error(`table ${child.name}: reference column ${JSON.stringify(reference.column)} is not nullable but declares ON DELETE SET NULL (§6.11)`);
|
|
162
|
+
}
|
|
163
|
+
const patterns = (compiled) => compiled.scopePatterns
|
|
164
|
+
.map((pattern) => `${pattern.prefix}:{${pattern.variable}}`)
|
|
165
|
+
.sort()
|
|
166
|
+
.join('\u0000');
|
|
167
|
+
if (patterns(child) !== patterns(parent)) {
|
|
168
|
+
throw new Error(`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)`);
|
|
169
|
+
}
|
|
170
|
+
const index = child.indexes.find((candidate) => candidate.columns.length === 1 &&
|
|
171
|
+
candidate.columns[0] === reference.column);
|
|
172
|
+
if (index === undefined) {
|
|
173
|
+
throw new Error(`table ${child.name}: reference column ${JSON.stringify(reference.column)} needs a declared single-column index so the server reaches children through scanRowsByIndex (§6.11)`);
|
|
174
|
+
}
|
|
175
|
+
parent.referencedBy.push({
|
|
176
|
+
table: child.name,
|
|
177
|
+
column: reference.column,
|
|
178
|
+
columnIndex: reference.columnIndex,
|
|
179
|
+
onDelete: reference.onDelete,
|
|
180
|
+
index: index.name,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
129
184
|
const compiled = {
|
|
130
185
|
version: schema.version,
|
|
131
186
|
tables,
|
package/dist/seed.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Idempotent by construction (§2.3): the commit id is stable, so re-running
|
|
10
10
|
* the seed replays the cached push result and writes nothing twice.
|
|
11
11
|
*/
|
|
12
|
-
import { decodeMessage, encodeMessage,
|
|
12
|
+
import { decodeMessage, encodeMessage, encodeSparseRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
|
|
13
13
|
import { SyncError } from './errors.js';
|
|
14
14
|
import { composeEvents } from './events-ring.js';
|
|
15
15
|
import { handleSyncRequest } from './handler.js';
|
|
@@ -102,7 +102,7 @@ export async function seedMutations(config, target, mutations) {
|
|
|
102
102
|
table: mutation.table,
|
|
103
103
|
rowId,
|
|
104
104
|
op: 'upsert',
|
|
105
|
-
payload:
|
|
105
|
+
payload: encodeSparseRow(table.columns, pkIndex, values),
|
|
106
106
|
};
|
|
107
107
|
});
|
|
108
108
|
const requestContext = {
|
package/dist/sqlite-dialect.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './
|
|
|
30
30
|
* the Postgres storage documents (§3.1, performance-by-
|
|
31
31
|
* construction).
|
|
32
32
|
*/
|
|
33
|
-
export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
|
|
33
|
+
export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\n";
|
|
34
34
|
/** Split the DDL into individual statements (D1 applies them one by one). */
|
|
35
35
|
export declare function sqliteDdlStatements(): string[];
|
|
36
36
|
/** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
|
|
@@ -44,6 +44,7 @@ export interface SqliteRowRecord {
|
|
|
44
44
|
server_version: number;
|
|
45
45
|
scopes: string;
|
|
46
46
|
payload: Uint8Array;
|
|
47
|
+
column_versions: Uint8Array | null;
|
|
47
48
|
}
|
|
48
49
|
export interface SqliteChangeRecord {
|
|
49
50
|
tbl: string;
|
package/dist/sqlite-dialect.js
CHANGED
|
@@ -89,6 +89,11 @@ CREATE TABLE IF NOT EXISTS sync_blob_refs(
|
|
|
89
89
|
);
|
|
90
90
|
CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
|
|
91
91
|
ON sync_blob_refs(partition, blob_id);
|
|
92
|
+
CREATE TABLE IF NOT EXISTS sync_tombstones(
|
|
93
|
+
partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
94
|
+
commit_seq INTEGER NOT NULL,
|
|
95
|
+
PRIMARY KEY(partition, tbl, row_id)
|
|
96
|
+
);
|
|
92
97
|
`;
|
|
93
98
|
/** Split the DDL into individual statements (D1 applies them one by one). */
|
|
94
99
|
export function sqliteDdlStatements() {
|
|
@@ -135,6 +140,7 @@ export function serializePushResult(result) {
|
|
|
135
140
|
message: record.message,
|
|
136
141
|
serverVersion: record.serverVersion,
|
|
137
142
|
serverRow: bytesToBase64(record.serverRow),
|
|
143
|
+
conflictColumns: bytesToBase64(record.conflictColumns),
|
|
138
144
|
};
|
|
139
145
|
}
|
|
140
146
|
if (record.status === 'error') {
|
|
@@ -162,6 +168,7 @@ export function deserializePushResult(text) {
|
|
|
162
168
|
message: record.message ?? '',
|
|
163
169
|
serverVersion: record.serverVersion ?? 0,
|
|
164
170
|
serverRow: base64ToBytes(record.serverRow ?? ''),
|
|
171
|
+
conflictColumns: base64ToBytes(record.conflictColumns ?? ''),
|
|
165
172
|
};
|
|
166
173
|
}
|
|
167
174
|
if (record.status === 'error') {
|
|
@@ -204,6 +211,9 @@ export function toStoredRow(record) {
|
|
|
204
211
|
serverVersion: record.server_version,
|
|
205
212
|
scopes: JSON.parse(record.scopes),
|
|
206
213
|
payload: asUint8Array(record.payload),
|
|
214
|
+
...(record.column_versions !== null
|
|
215
|
+
? { columnVersions: asUint8Array(record.column_versions) }
|
|
216
|
+
: {}),
|
|
207
217
|
};
|
|
208
218
|
}
|
|
209
219
|
export function toStoredChange(record) {
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -62,6 +62,19 @@ class SqliteTransaction {
|
|
|
62
62
|
this.#assertOpen();
|
|
63
63
|
return this.#storage.getRow(this.#partition, table, rowId);
|
|
64
64
|
}
|
|
65
|
+
async getTombstoneSeq(table, rowId) {
|
|
66
|
+
this.#assertOpen();
|
|
67
|
+
const row = this.#storage.db
|
|
68
|
+
.query('SELECT commit_seq FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?')
|
|
69
|
+
.get(this.#partition, table, rowId);
|
|
70
|
+
return row?.commit_seq;
|
|
71
|
+
}
|
|
72
|
+
async clearTombstone(table, rowId) {
|
|
73
|
+
this.#assertOpen();
|
|
74
|
+
this.#storage.db
|
|
75
|
+
.query('DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?')
|
|
76
|
+
.run(this.#partition, table, rowId);
|
|
77
|
+
}
|
|
65
78
|
getPushResult(clientId, clientCommitId) {
|
|
66
79
|
this.#assertOpen();
|
|
67
80
|
// One shared SQLite connection: this read runs inside this
|
|
@@ -136,6 +149,12 @@ class SqliteTransaction {
|
|
|
136
149
|
db.query('INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms) VALUES (?,?,?,?,?,?)').run(p, commitSeq, commit.clientId, commit.clientCommitId, commit.actorId, commit.createdAtMs);
|
|
137
150
|
commit.changes.forEach((change, idx) => {
|
|
138
151
|
db.query('INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)').run(p, commitSeq, idx, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
|
|
152
|
+
if (change.op === 'delete') {
|
|
153
|
+
// §5 delete precedence: every applied delete leaves a tombstone,
|
|
154
|
+
// pruned with the commit log (§4.6).
|
|
155
|
+
db.query(`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
|
|
156
|
+
ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`).run(p, change.table, change.rowId, commitSeq);
|
|
157
|
+
}
|
|
139
158
|
for (const [variable, value] of Object.entries(change.scopes)) {
|
|
140
159
|
db.query('INSERT OR IGNORE INTO sync_change_scopes(partition, tbl, var, value, commit_seq) VALUES (?,?,?,?,?)').run(p, change.table, variable, value, commitSeq);
|
|
141
160
|
}
|
|
@@ -525,6 +544,10 @@ export class SqliteServerStorage {
|
|
|
525
544
|
this.db
|
|
526
545
|
.query('DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?')
|
|
527
546
|
.run(partition, horizonSeq);
|
|
547
|
+
// §4.6: tombstones prune in the same pass as the commit log.
|
|
548
|
+
this.db
|
|
549
|
+
.query('DELETE FROM sync_tombstones WHERE partition=? AND commit_seq<=?')
|
|
550
|
+
.run(partition, horizonSeq);
|
|
528
551
|
this.db.exec('COMMIT');
|
|
529
552
|
return {
|
|
530
553
|
previousHorizonSeq,
|
package/dist/storage.d.ts
CHANGED
|
@@ -37,6 +37,13 @@ export interface StoredRow {
|
|
|
37
37
|
readonly scopes: Record<string, string>;
|
|
38
38
|
/** Row-codec payload (§2.4) for the server's schema version. */
|
|
39
39
|
readonly payload: Uint8Array;
|
|
40
|
+
/**
|
|
41
|
+
* §2.2 per-column versions: the `_sync_column_versions` blob (LEB128
|
|
42
|
+
* (ordinal, version) pairs for columns above 1). Absent when no column
|
|
43
|
+
* exceeds 1, and for rows written before column versions existed — a row
|
|
44
|
+
* without the blob carries every column at `serverVersion`.
|
|
45
|
+
*/
|
|
46
|
+
readonly columnVersions?: Uint8Array;
|
|
40
47
|
}
|
|
41
48
|
export interface NewChange {
|
|
42
49
|
readonly table: string;
|
|
@@ -271,6 +278,20 @@ export interface AuthoritativeQueryResult {
|
|
|
271
278
|
*/
|
|
272
279
|
export interface StorageTransaction {
|
|
273
280
|
getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
281
|
+
/**
|
|
282
|
+
* §5 delete-precedence tombstone read: the `commit_seq` of the newest
|
|
283
|
+
* applied delete of this row still within the pruning horizon, or
|
|
284
|
+
* `undefined` when no tombstone exists. Consulted only when an upsert's
|
|
285
|
+
* stored row is absent. Required: in-tree adapters implement it on the
|
|
286
|
+
* transaction's own connection with read-your-own-writes semantics.
|
|
287
|
+
*/
|
|
288
|
+
getTombstoneSeq(table: string, rowId: string): Promise<number | undefined>;
|
|
289
|
+
/**
|
|
290
|
+
* Remove the row's tombstone because the row was recreated in this
|
|
291
|
+
* transaction (§5: an explicit insert recreates). Called on every insert
|
|
292
|
+
* over an absent row; a no-op when no tombstone exists.
|
|
293
|
+
*/
|
|
294
|
+
clearTombstone(table: string, rowId: string): Promise<void>;
|
|
274
295
|
/**
|
|
275
296
|
* Optional transaction-scoped idempotency lookup (§2.3) with the same
|
|
276
297
|
* semantics as `ServerStorage.getPushResult`, including the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.
|
|
71
|
+
"@syncular/core": "0.21.1"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/context.ts
CHANGED
|
@@ -78,11 +78,14 @@ export interface ServerLimits {
|
|
|
78
78
|
readonly maxOperationsPerRequest: number;
|
|
79
79
|
/** Inline rows segments up to this size (§5.7 SHOULD: 256 KiB). */
|
|
80
80
|
readonly inlineSegmentMaxBytes: number;
|
|
81
|
+
/** §6.11 declared-reference cascade cap per commit (reference default). */
|
|
82
|
+
readonly maxCascadeOperationsPerCommit: number;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
export const DEFAULT_LIMITS: ServerLimits = {
|
|
84
86
|
maxOperationsPerRequest: 500,
|
|
85
87
|
inlineSegmentMaxBytes: 256 * 1024,
|
|
88
|
+
maxCascadeOperationsPerCommit: 1_000,
|
|
86
89
|
};
|
|
87
90
|
|
|
88
91
|
/** Receives applied commits for realtime fanout (§8.2). */
|
package/src/d1-storage.ts
CHANGED
|
@@ -271,6 +271,31 @@ class D1Transaction implements StorageTransaction {
|
|
|
271
271
|
return record === null ? undefined : toStoredRow(record);
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
async getTombstoneSeq(
|
|
275
|
+
table: string,
|
|
276
|
+
rowId: string,
|
|
277
|
+
): Promise<number | undefined> {
|
|
278
|
+
this.#assertOpen();
|
|
279
|
+
// Persisted read, no #pending overlay: a delete buffered by this commit
|
|
280
|
+
// records its tombstone at appendCommit, matching the SQLite/Postgres
|
|
281
|
+
// adapters where the tombstone insert also runs at commit append.
|
|
282
|
+
const record = await this.#db
|
|
283
|
+
.prepare(
|
|
284
|
+
'SELECT commit_seq FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?',
|
|
285
|
+
)
|
|
286
|
+
.bind(this.#partition, table, rowId)
|
|
287
|
+
.first<{ commit_seq: number }>();
|
|
288
|
+
return record?.commit_seq;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async clearTombstone(table: string, rowId: string): Promise<void> {
|
|
292
|
+
this.#assertOpen();
|
|
293
|
+
this.#buffer_(
|
|
294
|
+
'DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?',
|
|
295
|
+
[this.#partition, table, rowId],
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
274
299
|
async getPushResult(
|
|
275
300
|
clientId: string,
|
|
276
301
|
clientCommitId: string,
|
|
@@ -637,6 +662,15 @@ class D1Transaction implements StorageTransaction {
|
|
|
637
662
|
change.payload ?? null,
|
|
638
663
|
],
|
|
639
664
|
);
|
|
665
|
+
if (change.op === 'delete') {
|
|
666
|
+
// §5 delete precedence: every applied delete leaves a tombstone,
|
|
667
|
+
// pruned with the commit log (§4.6).
|
|
668
|
+
this.#buffer_(
|
|
669
|
+
`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
|
|
670
|
+
ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`,
|
|
671
|
+
[p, change.table, change.rowId, commitSeq],
|
|
672
|
+
);
|
|
673
|
+
}
|
|
640
674
|
for (const [variable, value] of Object.entries(change.scopes)) {
|
|
641
675
|
this.#buffer_(
|
|
642
676
|
'INSERT OR IGNORE INTO sync_change_scopes(partition, tbl, var, value, commit_seq) VALUES (?,?,?,?,?)',
|
|
@@ -1501,7 +1535,12 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1501
1535
|
`SELECT horizon_seq, (SELECT count(*) FROM sync_commits WHERE partition=? AND commit_seq<=${horizon}) AS removed_commits FROM sync_partitions WHERE partition=?`,
|
|
1502
1536
|
)
|
|
1503
1537
|
.bind(partition, partition, partition),
|
|
1504
|
-
...[
|
|
1538
|
+
...[
|
|
1539
|
+
'sync_commits',
|
|
1540
|
+
'sync_changes',
|
|
1541
|
+
'sync_change_scopes',
|
|
1542
|
+
'sync_tombstones',
|
|
1543
|
+
].map((table) =>
|
|
1505
1544
|
this.#db
|
|
1506
1545
|
.prepare(
|
|
1507
1546
|
`DELETE FROM ${table} WHERE partition=? AND commit_seq<=${horizon} AND ${epochGuard}`,
|
package/src/errors.ts
CHANGED
|
@@ -119,6 +119,22 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
|
|
|
119
119
|
recommendedAction: 'forceResync',
|
|
120
120
|
httpStatus: 404,
|
|
121
121
|
},
|
|
122
|
+
// §5 delete precedence: an unversioned upsert lost to a delete inside the
|
|
123
|
+
// tombstone horizon. The client drops the operation on rebuild.
|
|
124
|
+
'sync.row_deleted': {
|
|
125
|
+
category: 'not-found',
|
|
126
|
+
retryable: false,
|
|
127
|
+
recommendedAction: 'fixRequest',
|
|
128
|
+
httpStatus: 404,
|
|
129
|
+
},
|
|
130
|
+
// §6.11 declared references: absent parent, RESTRICT delete with live
|
|
131
|
+
// children, or the cascade cap. A push operation-result error only.
|
|
132
|
+
'sync.reference_violation': {
|
|
133
|
+
category: 'invalid-request',
|
|
134
|
+
retryable: false,
|
|
135
|
+
recommendedAction: 'fixRequest',
|
|
136
|
+
httpStatus: 400,
|
|
137
|
+
},
|
|
122
138
|
'sync.version_conflict': {
|
|
123
139
|
category: 'conflict',
|
|
124
140
|
retryable: false,
|
|
@@ -199,12 +215,6 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
|
|
|
199
215
|
recommendedAction: 'upgradeClient',
|
|
200
216
|
httpStatus: 400,
|
|
201
217
|
},
|
|
202
|
-
'sync.client_wire_unsupported': {
|
|
203
|
-
category: 'schema-mismatch',
|
|
204
|
-
retryable: false,
|
|
205
|
-
recommendedAction: 'upgradeClient',
|
|
206
|
-
httpStatus: 400,
|
|
207
|
-
},
|
|
208
218
|
'sync.websocket_connection_limit': {
|
|
209
219
|
category: 'rate-limited',
|
|
210
220
|
retryable: true,
|