@syncular/server 0.39.2 → 0.40.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/README.md +4 -1
- package/dist/admin.d.ts +6 -0
- package/dist/admin.js +9 -0
- package/dist/d1-storage.d.ts +1 -0
- package/dist/d1-storage.js +56 -14
- package/dist/handler.js +56 -6
- package/dist/postgres-storage.d.ts +2 -1
- package/dist/postgres-storage.js +94 -45
- package/dist/relational-rows.d.ts +9 -0
- package/dist/relational-rows.js +30 -0
- package/dist/sqlite-dialect.d.ts +7 -1
- package/dist/sqlite-dialect.js +8 -0
- package/dist/sqlite-storage.d.ts +1 -0
- package/dist/sqlite-storage.js +45 -7
- package/dist/storage.d.ts +20 -1
- package/dist/storage.js +8 -0
- package/package.json +2 -2
- package/src/admin.ts +15 -0
- package/src/d1-storage.ts +102 -14
- package/src/handler.ts +78 -10
- package/src/postgres-storage.ts +149 -58
- package/src/relational-rows.ts +34 -0
- package/src/sqlite-dialect.ts +9 -0
- package/src/sqlite-storage.ts +87 -4
- package/src/storage.ts +39 -0
package/src/postgres-storage.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { validateCommitPruneQuery } from './prune';
|
|
2
2
|
import { StorageQueryError } from './storage-errors';
|
|
3
3
|
import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
clientRoundState,
|
|
6
|
+
PARTITION_TOUCH_INTERVAL_MS,
|
|
7
|
+
serveGateRefusal,
|
|
8
|
+
} from './storage';
|
|
5
9
|
import { serveNotReadyError } from './readiness';
|
|
6
10
|
/**
|
|
7
11
|
* Postgres server storage: the production database path.
|
|
@@ -84,6 +88,7 @@ import {
|
|
|
84
88
|
scanRowPageSql,
|
|
85
89
|
indexSignature,
|
|
86
90
|
schemaDdl,
|
|
91
|
+
serverIndexDdl,
|
|
87
92
|
selectRowScopesSql,
|
|
88
93
|
selectRowSql,
|
|
89
94
|
prefetchRowsSql,
|
|
@@ -241,10 +246,16 @@ CREATE TABLE IF NOT EXISTS sync_clients(
|
|
|
241
246
|
wire_version INTEGER NOT NULL DEFAULT 1,
|
|
242
247
|
cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
|
|
243
248
|
updated_at_ms BIGINT NOT NULL,
|
|
249
|
+
last_round_at_ms BIGINT, last_error_code TEXT,
|
|
250
|
+
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
244
251
|
PRIMARY KEY(partition, client_id)
|
|
245
252
|
);
|
|
246
253
|
ALTER TABLE sync_clients
|
|
247
254
|
ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;
|
|
255
|
+
ALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_round_at_ms BIGINT;
|
|
256
|
+
ALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_error_code TEXT;
|
|
257
|
+
ALTER TABLE sync_clients
|
|
258
|
+
ADD COLUMN IF NOT EXISTS consecutive_failures INTEGER NOT NULL DEFAULT 0;
|
|
248
259
|
CREATE TABLE IF NOT EXISTS sync_blob_refs(
|
|
249
260
|
partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
250
261
|
blob_id TEXT NOT NULL,
|
|
@@ -339,6 +350,76 @@ async function installCheckpointOn(
|
|
|
339
350
|
);
|
|
340
351
|
}
|
|
341
352
|
|
|
353
|
+
/**
|
|
354
|
+
* A scalar subquery over the table names in the JSON array bound to
|
|
355
|
+
* `namesParam`: the free-standing indexes of each table as a JSON array of
|
|
356
|
+
* `{tbl, name, unique, partial, columns}`. Parity with the SQLite/D1
|
|
357
|
+
* `origin === 'c'` filter: constraint-owned indexes (PRIMARY KEY, UNIQUE
|
|
358
|
+
* constraints) can only be removed through their constraint, so DROP INDEX on
|
|
359
|
+
* them would abort the transaction. Tables resolve through `to_regclass`,
|
|
360
|
+
* which follows the session's `search_path` like the row queries do.
|
|
361
|
+
*/
|
|
362
|
+
function indexesSubquery(namesParam: string): string {
|
|
363
|
+
return `(SELECT coalesce(json_agg(json_build_object(
|
|
364
|
+
'tbl', tables.name,
|
|
365
|
+
'name', index_class.relname,
|
|
366
|
+
'unique', index_meta.indisunique,
|
|
367
|
+
'partial', index_meta.indpred IS NOT NULL,
|
|
368
|
+
'columns', ARRAY(
|
|
369
|
+
SELECT attribute.attname::text
|
|
370
|
+
FROM unnest(index_meta.indkey::int2[])
|
|
371
|
+
WITH ORDINALITY AS key(attnum, ord)
|
|
372
|
+
LEFT JOIN pg_catalog.pg_attribute AS attribute
|
|
373
|
+
ON attribute.attrelid = index_meta.indrelid
|
|
374
|
+
AND attribute.attnum = key.attnum
|
|
375
|
+
WHERE key.ord <= index_meta.indnkeyatts
|
|
376
|
+
ORDER BY key.ord))), '[]'::json)::text
|
|
377
|
+
FROM jsonb_array_elements_text(${namesParam}::text::jsonb) AS tables(name)
|
|
378
|
+
JOIN pg_catalog.pg_index AS index_meta
|
|
379
|
+
ON index_meta.indrelid = to_regclass(tables.name)
|
|
380
|
+
JOIN pg_catalog.pg_class AS index_class
|
|
381
|
+
ON index_class.oid = index_meta.indexrelid
|
|
382
|
+
WHERE NOT index_meta.indisprimary
|
|
383
|
+
AND NOT EXISTS (
|
|
384
|
+
SELECT 1
|
|
385
|
+
FROM pg_catalog.pg_constraint AS owning_constraint
|
|
386
|
+
WHERE owning_constraint.conindid = index_meta.indexrelid
|
|
387
|
+
))`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Table name to index name to {@link indexSignature}, from {@link indexesSubquery} JSON. */
|
|
391
|
+
function parseIndexSignatures(
|
|
392
|
+
json: string,
|
|
393
|
+
): Map<string, ReadonlyMap<string, string>> {
|
|
394
|
+
const byTable = new Map<string, Map<string, string>>();
|
|
395
|
+
for (const index of JSON.parse(json) as {
|
|
396
|
+
tbl: string;
|
|
397
|
+
name: string;
|
|
398
|
+
unique: boolean;
|
|
399
|
+
partial: boolean;
|
|
400
|
+
columns: (string | null)[];
|
|
401
|
+
}[]) {
|
|
402
|
+
const indexes = byTable.get(index.tbl) ?? new Map<string, string>();
|
|
403
|
+
indexes.set(
|
|
404
|
+
index.name,
|
|
405
|
+
indexSignature(index.columns, index.unique, index.partial),
|
|
406
|
+
);
|
|
407
|
+
byTable.set(index.tbl, indexes);
|
|
408
|
+
}
|
|
409
|
+
return byTable;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function readIndexSignatures(
|
|
413
|
+
client: PgQueryable,
|
|
414
|
+
tableNames: readonly string[],
|
|
415
|
+
): Promise<Map<string, ReadonlyMap<string, string>>> {
|
|
416
|
+
const { rows } = await client.query<{ indexes: string }>(
|
|
417
|
+
`SELECT ${indexesSubquery('$1')} AS indexes`,
|
|
418
|
+
[JSON.stringify(tableNames)],
|
|
419
|
+
);
|
|
420
|
+
return parseIndexSignatures(rows[0]?.indexes ?? '[]');
|
|
421
|
+
}
|
|
422
|
+
|
|
342
423
|
/** RFC 0007 serve gate on either the pool or a transaction's client. */
|
|
343
424
|
async function readServeGateOn(
|
|
344
425
|
client: PgQueryable,
|
|
@@ -1584,6 +1665,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1584
1665
|
layouts: unknown;
|
|
1585
1666
|
incomplete: string;
|
|
1586
1667
|
columns: string;
|
|
1668
|
+
indexes: string;
|
|
1587
1669
|
}>(
|
|
1588
1670
|
`SELECT meta.schema_version AS schema_version, meta.layouts AS layouts,
|
|
1589
1671
|
(SELECT coalesce(json_agg(json_build_object(
|
|
@@ -1609,7 +1691,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1609
1691
|
JOIN pg_attribute attribute
|
|
1610
1692
|
ON attribute.attrelid = to_regclass(tables.name)
|
|
1611
1693
|
WHERE attribute.attnum > 0
|
|
1612
|
-
AND NOT attribute.attisdropped) AS columns
|
|
1694
|
+
AND NOT attribute.attisdropped) AS columns,
|
|
1695
|
+
${indexesSubquery('$2')} AS indexes
|
|
1613
1696
|
FROM (SELECT 1) AS one
|
|
1614
1697
|
LEFT JOIN sync_schema_meta meta ON meta.id=1`,
|
|
1615
1698
|
[schema.version, JSON.stringify([...schema.tables.keys()])],
|
|
@@ -1674,6 +1757,25 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1674
1757
|
'postgres',
|
|
1675
1758
|
);
|
|
1676
1759
|
}
|
|
1760
|
+
// Server-only indexes reconcile at every open of a database already at
|
|
1761
|
+
// this version: they never reach a client, so no version bump is needed.
|
|
1762
|
+
// The unlocked read finds the common no-op case; the diff is recomputed
|
|
1763
|
+
// under the migration barrier before any DDL runs.
|
|
1764
|
+
const reconcile = serverIndexDdl(
|
|
1765
|
+
schema,
|
|
1766
|
+
parseIndexSignatures(marker.indexes),
|
|
1767
|
+
);
|
|
1768
|
+
if (reconcile.length > 0) {
|
|
1769
|
+
await this.#exec.transaction(async (client) => {
|
|
1770
|
+
await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
|
|
1771
|
+
for (const statement of serverIndexDdl(
|
|
1772
|
+
schema,
|
|
1773
|
+
await readIndexSignatures(client, [...schema.tables.keys()]),
|
|
1774
|
+
)) {
|
|
1775
|
+
await client.query(statement);
|
|
1776
|
+
}
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1677
1779
|
}
|
|
1678
1780
|
if (stored === undefined || stored < schema.version) {
|
|
1679
1781
|
// Introspect existing app tables, apply the migration subset
|
|
@@ -1721,6 +1823,9 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1721
1823
|
const retiredTables = retiredTableNames(schema, layouts);
|
|
1722
1824
|
const existing = new Map<string, ReadonlySet<string>>();
|
|
1723
1825
|
const existingIndexes = new Map<string, ReadonlyMap<string, string>>();
|
|
1826
|
+
const tableIndexes = await readIndexSignatures(client, [
|
|
1827
|
+
...schema.tables.keys(),
|
|
1828
|
+
]);
|
|
1724
1829
|
for (const table of schema.tables.values()) {
|
|
1725
1830
|
const { rows } = await client.query<{ column_name: string }>(
|
|
1726
1831
|
`SELECT column_name FROM information_schema.columns
|
|
@@ -1729,59 +1834,9 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1729
1834
|
);
|
|
1730
1835
|
if (rows.length > 0) {
|
|
1731
1836
|
existing.set(table.name, new Set(rows.map((r) => r.column_name)));
|
|
1732
|
-
// Parity with the SQLite/D1 `origin === 'c'` filter: only
|
|
1733
|
-
// free-standing indexes enter the rebuild set. Constraint-owned
|
|
1734
|
-
// indexes (PRIMARY KEY, UNIQUE constraints) can only be removed
|
|
1735
|
-
// through their constraint, so DROP INDEX on them would abort
|
|
1736
|
-
// the migration transaction.
|
|
1737
|
-
const indexes = await client.query<{
|
|
1738
|
-
index_name: string;
|
|
1739
|
-
is_unique: boolean;
|
|
1740
|
-
is_partial: boolean;
|
|
1741
|
-
columns: (string | null)[];
|
|
1742
|
-
}>(
|
|
1743
|
-
`SELECT index_class.relname AS index_name,
|
|
1744
|
-
index_meta.indisunique AS is_unique,
|
|
1745
|
-
index_meta.indpred IS NOT NULL AS is_partial,
|
|
1746
|
-
ARRAY(
|
|
1747
|
-
SELECT attribute.attname::text
|
|
1748
|
-
FROM unnest(index_meta.indkey::int2[])
|
|
1749
|
-
WITH ORDINALITY AS key(attnum, ord)
|
|
1750
|
-
LEFT JOIN pg_catalog.pg_attribute AS attribute
|
|
1751
|
-
ON attribute.attrelid = table_class.oid
|
|
1752
|
-
AND attribute.attnum = key.attnum
|
|
1753
|
-
WHERE key.ord <= index_meta.indnkeyatts
|
|
1754
|
-
ORDER BY key.ord
|
|
1755
|
-
) AS columns
|
|
1756
|
-
FROM pg_catalog.pg_class AS table_class
|
|
1757
|
-
JOIN pg_catalog.pg_namespace AS namespace
|
|
1758
|
-
ON namespace.oid = table_class.relnamespace
|
|
1759
|
-
JOIN pg_catalog.pg_index AS index_meta
|
|
1760
|
-
ON index_meta.indrelid = table_class.oid
|
|
1761
|
-
JOIN pg_catalog.pg_class AS index_class
|
|
1762
|
-
ON index_class.oid = index_meta.indexrelid
|
|
1763
|
-
WHERE namespace.nspname = current_schema()
|
|
1764
|
-
AND table_class.relname = $1
|
|
1765
|
-
AND NOT index_meta.indisprimary
|
|
1766
|
-
AND NOT EXISTS (
|
|
1767
|
-
SELECT 1
|
|
1768
|
-
FROM pg_catalog.pg_constraint AS owning_constraint
|
|
1769
|
-
WHERE owning_constraint.conindid = index_meta.indexrelid
|
|
1770
|
-
)`,
|
|
1771
|
-
[table.name],
|
|
1772
|
-
);
|
|
1773
1837
|
existingIndexes.set(
|
|
1774
1838
|
table.name,
|
|
1775
|
-
new Map(
|
|
1776
|
-
indexes.rows.map((index) => [
|
|
1777
|
-
index.index_name,
|
|
1778
|
-
indexSignature(
|
|
1779
|
-
index.columns,
|
|
1780
|
-
index.is_unique,
|
|
1781
|
-
index.is_partial,
|
|
1782
|
-
),
|
|
1783
|
-
]),
|
|
1784
|
-
),
|
|
1839
|
+
tableIndexes.get(table.name) ?? new Map(),
|
|
1785
1840
|
);
|
|
1786
1841
|
}
|
|
1787
1842
|
}
|
|
@@ -2694,8 +2749,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2694
2749
|
cursor: unknown;
|
|
2695
2750
|
subscriptions: unknown;
|
|
2696
2751
|
updated_at_ms: unknown;
|
|
2752
|
+
last_round_at_ms: unknown;
|
|
2753
|
+
last_error_code: string | null;
|
|
2754
|
+
consecutive_failures: unknown;
|
|
2697
2755
|
}>(
|
|
2698
|
-
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 AND client_id=$2',
|
|
2756
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=$1 AND client_id=$2',
|
|
2699
2757
|
[partition, clientId],
|
|
2700
2758
|
);
|
|
2701
2759
|
const record = rows[0];
|
|
@@ -2707,6 +2765,13 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2707
2765
|
cursor: asNumber(record.cursor),
|
|
2708
2766
|
updatedAtMs: asNumber(record.updated_at_ms),
|
|
2709
2767
|
subscriptions: asJson<ClientSubscription[]>(record.subscriptions),
|
|
2768
|
+
...clientRoundState(
|
|
2769
|
+
record.last_round_at_ms === null
|
|
2770
|
+
? null
|
|
2771
|
+
: asNumber(record.last_round_at_ms),
|
|
2772
|
+
record.last_error_code,
|
|
2773
|
+
asNumber(record.consecutive_failures),
|
|
2774
|
+
),
|
|
2710
2775
|
};
|
|
2711
2776
|
}
|
|
2712
2777
|
|
|
@@ -2715,13 +2780,15 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2715
2780
|
record: ClientRecord,
|
|
2716
2781
|
): Promise<void> {
|
|
2717
2782
|
await this.#exec.query(
|
|
2718
|
-
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms)
|
|
2719
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
2783
|
+
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms)
|
|
2784
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$7)
|
|
2720
2785
|
ON CONFLICT (partition, client_id) DO UPDATE
|
|
2721
2786
|
SET actor_id=EXCLUDED.actor_id, wire_version=EXCLUDED.wire_version,
|
|
2722
2787
|
cursor=EXCLUDED.cursor,
|
|
2723
2788
|
subscriptions=EXCLUDED.subscriptions,
|
|
2724
|
-
updated_at_ms=EXCLUDED.updated_at_ms
|
|
2789
|
+
updated_at_ms=EXCLUDED.updated_at_ms,
|
|
2790
|
+
last_round_at_ms=EXCLUDED.last_round_at_ms,
|
|
2791
|
+
last_error_code=NULL, consecutive_failures=0`,
|
|
2725
2792
|
[
|
|
2726
2793
|
partition,
|
|
2727
2794
|
record.clientId,
|
|
@@ -2734,6 +2801,22 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2734
2801
|
);
|
|
2735
2802
|
}
|
|
2736
2803
|
|
|
2804
|
+
async recordClientRoundFailure(
|
|
2805
|
+
partition: string,
|
|
2806
|
+
clientId: string,
|
|
2807
|
+
actorId: string,
|
|
2808
|
+
errorCode: string,
|
|
2809
|
+
atMs: number,
|
|
2810
|
+
): Promise<void> {
|
|
2811
|
+
await this.#exec.query(
|
|
2812
|
+
`UPDATE sync_clients
|
|
2813
|
+
SET last_round_at_ms=$1, last_error_code=$2,
|
|
2814
|
+
consecutive_failures=consecutive_failures+1
|
|
2815
|
+
WHERE partition=$3 AND client_id=$4 AND actor_id=$5`,
|
|
2816
|
+
[atMs, errorCode, partition, clientId, actorId],
|
|
2817
|
+
);
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2737
2820
|
async advanceClientCursor(
|
|
2738
2821
|
partition: string,
|
|
2739
2822
|
clientId: string,
|
|
@@ -2910,8 +2993,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2910
2993
|
cursor: unknown;
|
|
2911
2994
|
subscriptions: unknown;
|
|
2912
2995
|
updated_at_ms: unknown;
|
|
2996
|
+
last_round_at_ms: unknown;
|
|
2997
|
+
last_error_code: string | null;
|
|
2998
|
+
consecutive_failures: unknown;
|
|
2913
2999
|
}>(
|
|
2914
|
-
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC',
|
|
3000
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC',
|
|
2915
3001
|
[partition],
|
|
2916
3002
|
);
|
|
2917
3003
|
return rows.map((r) => ({
|
|
@@ -2923,6 +3009,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2923
3009
|
subscriptions: (typeof r.subscriptions === 'string'
|
|
2924
3010
|
? JSON.parse(r.subscriptions)
|
|
2925
3011
|
: r.subscriptions) as ClientSubscription[],
|
|
3012
|
+
...clientRoundState(
|
|
3013
|
+
r.last_round_at_ms === null ? null : asNumber(r.last_round_at_ms),
|
|
3014
|
+
r.last_error_code,
|
|
3015
|
+
asNumber(r.consecutive_failures),
|
|
3016
|
+
),
|
|
2926
3017
|
}));
|
|
2927
3018
|
}
|
|
2928
3019
|
|
package/src/relational-rows.ts
CHANGED
|
@@ -1196,3 +1196,37 @@ export function schemaDdl(
|
|
|
1196
1196
|
// receiving table re-creates it.
|
|
1197
1197
|
return [...drops, ...creates];
|
|
1198
1198
|
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* DDL that reconciles the server-only indexes of a database already at
|
|
1202
|
+
* `schema.version`, so declaring or changing a `serverIndexes` entry needs no
|
|
1203
|
+
* schema-version bump (server indexes never reach a client). It creates
|
|
1204
|
+
* missing server indexes and drops every Syncular-owned index the schema no
|
|
1205
|
+
* longer names. Declared `indexes` belong to the version bump and are never
|
|
1206
|
+
* created or rebuilt here.
|
|
1207
|
+
*/
|
|
1208
|
+
export function serverIndexDdl(
|
|
1209
|
+
schema: CompiledSchema,
|
|
1210
|
+
existingIndexesByTable: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
|
1211
|
+
): string[] {
|
|
1212
|
+
const drops: string[] = [];
|
|
1213
|
+
const creates: string[] = [];
|
|
1214
|
+
for (const table of schema.tables.values()) {
|
|
1215
|
+
const existing = existingIndexesByTable.get(table.name);
|
|
1216
|
+
if (existing === undefined) continue;
|
|
1217
|
+
const desired = desiredIndexes(table);
|
|
1218
|
+
for (const indexName of existing.keys()) {
|
|
1219
|
+
if (
|
|
1220
|
+
indexName.startsWith(SYNC_INDEX_PREFIX) &&
|
|
1221
|
+
!desired.some((index) => index.name === indexName)
|
|
1222
|
+
) {
|
|
1223
|
+
drops.push(dropIndexDdl(indexName));
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
// `desiredIndexes` lists the declared indexes first, then the server ones.
|
|
1227
|
+
for (const index of desired.slice(table.indexes.length)) {
|
|
1228
|
+
if (!existing.has(index.name)) creates.push(index.ddl);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return [...drops, ...creates];
|
|
1232
|
+
}
|
package/src/sqlite-dialect.ts
CHANGED
|
@@ -115,6 +115,8 @@ CREATE TABLE IF NOT EXISTS sync_clients(
|
|
|
115
115
|
wire_version INTEGER NOT NULL DEFAULT 1,
|
|
116
116
|
cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,
|
|
117
117
|
updated_at_ms INTEGER NOT NULL,
|
|
118
|
+
last_round_at_ms INTEGER, last_error_code TEXT,
|
|
119
|
+
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
118
120
|
PRIMARY KEY(partition, client_id)
|
|
119
121
|
);
|
|
120
122
|
CREATE TABLE IF NOT EXISTS sync_blob_refs(
|
|
@@ -145,6 +147,13 @@ CREATE TABLE IF NOT EXISTS sync_writer_fence(
|
|
|
145
147
|
);
|
|
146
148
|
`;
|
|
147
149
|
|
|
150
|
+
/** Columns added to `sync_clients` after its first release, by column name. */
|
|
151
|
+
export const SYNC_CLIENT_ROUND_COLUMNS = {
|
|
152
|
+
last_round_at_ms: 'INTEGER',
|
|
153
|
+
last_error_code: 'TEXT',
|
|
154
|
+
consecutive_failures: 'INTEGER NOT NULL DEFAULT 0',
|
|
155
|
+
} as const;
|
|
156
|
+
|
|
148
157
|
/** Split the DDL into individual statements (D1 applies them one by one). */
|
|
149
158
|
export function sqliteDdlStatements(): string[] {
|
|
150
159
|
return SQLITE_DDL.split(';')
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { retainRollbackFailure, rollbackAfterError } from './storage-errors';
|
|
|
2
2
|
import { validateCommitPruneQuery } from './prune';
|
|
3
3
|
import { StorageQueryError } from './storage-errors';
|
|
4
4
|
import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
clientRoundState,
|
|
7
|
+
PARTITION_TOUCH_INTERVAL_MS,
|
|
8
|
+
serveGateRefusal,
|
|
9
|
+
} from './storage';
|
|
6
10
|
import { serveNotReadyError } from './readiness';
|
|
7
11
|
/**
|
|
8
12
|
* SQLite server storage over the shared synchronous driver.
|
|
@@ -45,6 +49,7 @@ import {
|
|
|
45
49
|
type StoredColumnLayout,
|
|
46
50
|
scanRowPageSql,
|
|
47
51
|
schemaDdl,
|
|
52
|
+
serverIndexDdl,
|
|
48
53
|
SQLITE_TABLE_INDEXES_SQL,
|
|
49
54
|
type SqliteTableIndexRow,
|
|
50
55
|
sqliteIndexSignatures,
|
|
@@ -64,6 +69,7 @@ import {
|
|
|
64
69
|
LIST_SCOPE_VALUE_ROWS_SQLITE_SQL,
|
|
65
70
|
placeholders,
|
|
66
71
|
SQLITE_DDL,
|
|
72
|
+
SYNC_CLIENT_ROUND_COLUMNS,
|
|
67
73
|
type SqliteChangeRecord,
|
|
68
74
|
type SqliteCommitWindowRecord,
|
|
69
75
|
type SqliteRowRecord,
|
|
@@ -550,6 +556,15 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
550
556
|
'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1',
|
|
551
557
|
);
|
|
552
558
|
}
|
|
559
|
+
for (const [name, definition] of Object.entries(
|
|
560
|
+
SYNC_CLIENT_ROUND_COLUMNS,
|
|
561
|
+
)) {
|
|
562
|
+
if (!clientColumns.some((column) => column.name === name)) {
|
|
563
|
+
this.db.exec(
|
|
564
|
+
`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
553
568
|
const commitColumns = this.db
|
|
554
569
|
.query<{ name: string }, []>('PRAGMA table_info("sync_commits")')
|
|
555
570
|
.all();
|
|
@@ -649,6 +664,40 @@ END`);
|
|
|
649
664
|
'sqlite',
|
|
650
665
|
);
|
|
651
666
|
}
|
|
667
|
+
// Server-only indexes reconcile at every open of a database already at
|
|
668
|
+
// this version: they never reach a client, so no version bump is needed.
|
|
669
|
+
const reconcile = serverIndexDdl(
|
|
670
|
+
schema,
|
|
671
|
+
new Map(
|
|
672
|
+
[...schema.tables.values()].map((table) => [
|
|
673
|
+
table.name,
|
|
674
|
+
new Map(
|
|
675
|
+
Object.entries(
|
|
676
|
+
sqliteIndexSignatures(
|
|
677
|
+
this.db
|
|
678
|
+
.query<SqliteTableIndexRow, [string]>(
|
|
679
|
+
SQLITE_TABLE_INDEXES_SQL,
|
|
680
|
+
)
|
|
681
|
+
.all(table.name),
|
|
682
|
+
),
|
|
683
|
+
),
|
|
684
|
+
),
|
|
685
|
+
]),
|
|
686
|
+
),
|
|
687
|
+
);
|
|
688
|
+
if (reconcile.length > 0) {
|
|
689
|
+
await this.#serializeWrite(() => {
|
|
690
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
691
|
+
try {
|
|
692
|
+
for (const statement of reconcile) this.db.exec(statement);
|
|
693
|
+
this.db.exec('COMMIT');
|
|
694
|
+
} catch (error) {
|
|
695
|
+
rollbackAfterError(() => {
|
|
696
|
+
this.db.exec('ROLLBACK');
|
|
697
|
+
}, error);
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
}
|
|
652
701
|
}
|
|
653
702
|
if (marker === null || marker.schema_version < schema.version) {
|
|
654
703
|
// Introspect existing app tables, then apply the migration subset
|
|
@@ -1815,10 +1864,13 @@ END`);
|
|
|
1815
1864
|
cursor: number;
|
|
1816
1865
|
subscriptions: string;
|
|
1817
1866
|
updated_at_ms: number;
|
|
1867
|
+
last_round_at_ms: number | null;
|
|
1868
|
+
last_error_code: string | null;
|
|
1869
|
+
consecutive_failures: number;
|
|
1818
1870
|
},
|
|
1819
1871
|
[string, string]
|
|
1820
1872
|
>(
|
|
1821
|
-
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
|
|
1873
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=? AND client_id=?',
|
|
1822
1874
|
)
|
|
1823
1875
|
.get(partition, clientId);
|
|
1824
1876
|
if (record === null) return undefined;
|
|
@@ -1829,6 +1881,11 @@ END`);
|
|
|
1829
1881
|
cursor: record.cursor,
|
|
1830
1882
|
updatedAtMs: record.updated_at_ms,
|
|
1831
1883
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
1884
|
+
...clientRoundState(
|
|
1885
|
+
record.last_round_at_ms,
|
|
1886
|
+
record.last_error_code,
|
|
1887
|
+
record.consecutive_failures,
|
|
1888
|
+
),
|
|
1832
1889
|
};
|
|
1833
1890
|
}
|
|
1834
1891
|
|
|
@@ -1838,7 +1895,7 @@ END`);
|
|
|
1838
1895
|
): Promise<void> {
|
|
1839
1896
|
this.db
|
|
1840
1897
|
.query(
|
|
1841
|
-
'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (
|
|
1898
|
+
'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)',
|
|
1842
1899
|
)
|
|
1843
1900
|
.run(
|
|
1844
1901
|
partition,
|
|
@@ -1848,9 +1905,27 @@ END`);
|
|
|
1848
1905
|
record.cursor,
|
|
1849
1906
|
JSON.stringify(record.subscriptions),
|
|
1850
1907
|
record.updatedAtMs,
|
|
1908
|
+
record.updatedAtMs,
|
|
1851
1909
|
);
|
|
1852
1910
|
}
|
|
1853
1911
|
|
|
1912
|
+
async recordClientRoundFailure(
|
|
1913
|
+
partition: string,
|
|
1914
|
+
clientId: string,
|
|
1915
|
+
actorId: string,
|
|
1916
|
+
errorCode: string,
|
|
1917
|
+
atMs: number,
|
|
1918
|
+
): Promise<void> {
|
|
1919
|
+
this.db
|
|
1920
|
+
.query(
|
|
1921
|
+
`UPDATE sync_clients
|
|
1922
|
+
SET last_round_at_ms=?, last_error_code=?,
|
|
1923
|
+
consecutive_failures=consecutive_failures+1
|
|
1924
|
+
WHERE partition=? AND client_id=? AND actor_id=?`,
|
|
1925
|
+
)
|
|
1926
|
+
.run(atMs, errorCode, partition, clientId, actorId);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1854
1929
|
async advanceClientCursor(
|
|
1855
1930
|
partition: string,
|
|
1856
1931
|
clientId: string,
|
|
@@ -2006,10 +2081,13 @@ END`);
|
|
|
2006
2081
|
cursor: number;
|
|
2007
2082
|
subscriptions: string;
|
|
2008
2083
|
updated_at_ms: number;
|
|
2084
|
+
last_round_at_ms: number | null;
|
|
2085
|
+
last_error_code: string | null;
|
|
2086
|
+
consecutive_failures: number;
|
|
2009
2087
|
},
|
|
2010
2088
|
[string]
|
|
2011
2089
|
>(
|
|
2012
|
-
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
2090
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
2013
2091
|
)
|
|
2014
2092
|
.all(partition);
|
|
2015
2093
|
return records.map((record) => ({
|
|
@@ -2019,6 +2097,11 @@ END`);
|
|
|
2019
2097
|
cursor: record.cursor,
|
|
2020
2098
|
updatedAtMs: record.updated_at_ms,
|
|
2021
2099
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
2100
|
+
...clientRoundState(
|
|
2101
|
+
record.last_round_at_ms,
|
|
2102
|
+
record.last_error_code,
|
|
2103
|
+
record.consecutive_failures,
|
|
2104
|
+
),
|
|
2022
2105
|
}));
|
|
2023
2106
|
}
|
|
2024
2107
|
|
package/src/storage.ts
CHANGED
|
@@ -305,6 +305,28 @@ export interface ClientRecord {
|
|
|
305
305
|
readonly cursor: number;
|
|
306
306
|
readonly updatedAtMs: number;
|
|
307
307
|
readonly subscriptions: readonly ClientSubscription[];
|
|
308
|
+
/** When this client's last sync round finished, successfully or not. */
|
|
309
|
+
readonly lastRoundAtMs?: number;
|
|
310
|
+
/** Code of the last round when it failed; absent after a successful round. */
|
|
311
|
+
readonly lastErrorCode?: string;
|
|
312
|
+
/** Rounds that failed in a row since the last successful round. */
|
|
313
|
+
readonly consecutiveFailures?: number;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The round-state fields of a stored `sync_clients` row. */
|
|
317
|
+
export function clientRoundState(
|
|
318
|
+
lastRoundAtMs: number | null,
|
|
319
|
+
lastErrorCode: string | null,
|
|
320
|
+
consecutiveFailures: number,
|
|
321
|
+
): Pick<
|
|
322
|
+
ClientRecord,
|
|
323
|
+
'lastRoundAtMs' | 'lastErrorCode' | 'consecutiveFailures'
|
|
324
|
+
> {
|
|
325
|
+
return {
|
|
326
|
+
...(lastRoundAtMs !== null ? { lastRoundAtMs } : {}),
|
|
327
|
+
...(lastErrorCode !== null ? { lastErrorCode } : {}),
|
|
328
|
+
...(consecutiveFailures > 0 ? { consecutiveFailures } : {}),
|
|
329
|
+
};
|
|
308
330
|
}
|
|
309
331
|
|
|
310
332
|
export interface CommitWindowQuery {
|
|
@@ -917,7 +939,24 @@ export interface ServerStorage {
|
|
|
917
939
|
partition: string,
|
|
918
940
|
clientId: string,
|
|
919
941
|
): Promise<ClientRecord | undefined>;
|
|
942
|
+
/**
|
|
943
|
+
* Write the record for a completed round. The round-state fields reset: the
|
|
944
|
+
* stored last round is a success at `updatedAtMs` with no failures.
|
|
945
|
+
*/
|
|
920
946
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
947
|
+
/**
|
|
948
|
+
* Record a failed round on an existing record: set `lastRoundAtMs` and
|
|
949
|
+
* `lastErrorCode`, and increment `consecutiveFailures`. Requires a matching
|
|
950
|
+
* actor; a missing record or another actor's record is unchanged, and
|
|
951
|
+
* `updatedAtMs` (the pruning liveness signal) is never touched.
|
|
952
|
+
*/
|
|
953
|
+
recordClientRoundFailure(
|
|
954
|
+
partition: string,
|
|
955
|
+
clientId: string,
|
|
956
|
+
actorId: string,
|
|
957
|
+
errorCode: string,
|
|
958
|
+
atMs: number,
|
|
959
|
+
): Promise<void>;
|
|
921
960
|
/**
|
|
922
961
|
* Atomically advance an existing record's cursor and activity timestamp.
|
|
923
962
|
* Preserve registration fields; require matching actor and current log epoch.
|