@syncular/server 0.39.2 → 0.41.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 +3 -2
- package/dist/d1-storage.js +60 -15
- package/dist/handler.js +79 -8
- package/dist/postgres-storage.d.ts +4 -3
- package/dist/postgres-storage.js +96 -46
- 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 +3 -2
- package/dist/sqlite-storage.js +57 -8
- package/dist/storage.d.ts +31 -1
- package/dist/storage.js +8 -0
- package/package.json +2 -2
- package/src/admin.ts +15 -0
- package/src/d1-storage.ts +109 -14
- package/src/handler.ts +126 -19
- package/src/postgres-storage.ts +154 -58
- package/src/relational-rows.ts +34 -0
- package/src/sqlite-dialect.ts +9 -0
- package/src/sqlite-storage.ts +102 -4
- package/src/storage.ts +56 -1
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,
|
|
@@ -100,6 +105,7 @@ import type {
|
|
|
100
105
|
ClientCursorInfo,
|
|
101
106
|
ClientPushResult,
|
|
102
107
|
ClientRecord,
|
|
108
|
+
ClientRecordWriteOptions,
|
|
103
109
|
ClientSubscription,
|
|
104
110
|
CommitChangeSummary,
|
|
105
111
|
CommitDetail,
|
|
@@ -241,10 +247,16 @@ CREATE TABLE IF NOT EXISTS sync_clients(
|
|
|
241
247
|
wire_version INTEGER NOT NULL DEFAULT 1,
|
|
242
248
|
cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
|
|
243
249
|
updated_at_ms BIGINT NOT NULL,
|
|
250
|
+
last_round_at_ms BIGINT, last_error_code TEXT,
|
|
251
|
+
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
244
252
|
PRIMARY KEY(partition, client_id)
|
|
245
253
|
);
|
|
246
254
|
ALTER TABLE sync_clients
|
|
247
255
|
ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;
|
|
256
|
+
ALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_round_at_ms BIGINT;
|
|
257
|
+
ALTER TABLE sync_clients ADD COLUMN IF NOT EXISTS last_error_code TEXT;
|
|
258
|
+
ALTER TABLE sync_clients
|
|
259
|
+
ADD COLUMN IF NOT EXISTS consecutive_failures INTEGER NOT NULL DEFAULT 0;
|
|
248
260
|
CREATE TABLE IF NOT EXISTS sync_blob_refs(
|
|
249
261
|
partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
250
262
|
blob_id TEXT NOT NULL,
|
|
@@ -339,6 +351,76 @@ async function installCheckpointOn(
|
|
|
339
351
|
);
|
|
340
352
|
}
|
|
341
353
|
|
|
354
|
+
/**
|
|
355
|
+
* A scalar subquery over the table names in the JSON array bound to
|
|
356
|
+
* `namesParam`: the free-standing indexes of each table as a JSON array of
|
|
357
|
+
* `{tbl, name, unique, partial, columns}`. Parity with the SQLite/D1
|
|
358
|
+
* `origin === 'c'` filter: constraint-owned indexes (PRIMARY KEY, UNIQUE
|
|
359
|
+
* constraints) can only be removed through their constraint, so DROP INDEX on
|
|
360
|
+
* them would abort the transaction. Tables resolve through `to_regclass`,
|
|
361
|
+
* which follows the session's `search_path` like the row queries do.
|
|
362
|
+
*/
|
|
363
|
+
function indexesSubquery(namesParam: string): string {
|
|
364
|
+
return `(SELECT coalesce(json_agg(json_build_object(
|
|
365
|
+
'tbl', tables.name,
|
|
366
|
+
'name', index_class.relname,
|
|
367
|
+
'unique', index_meta.indisunique,
|
|
368
|
+
'partial', index_meta.indpred IS NOT NULL,
|
|
369
|
+
'columns', ARRAY(
|
|
370
|
+
SELECT attribute.attname::text
|
|
371
|
+
FROM unnest(index_meta.indkey::int2[])
|
|
372
|
+
WITH ORDINALITY AS key(attnum, ord)
|
|
373
|
+
LEFT JOIN pg_catalog.pg_attribute AS attribute
|
|
374
|
+
ON attribute.attrelid = index_meta.indrelid
|
|
375
|
+
AND attribute.attnum = key.attnum
|
|
376
|
+
WHERE key.ord <= index_meta.indnkeyatts
|
|
377
|
+
ORDER BY key.ord))), '[]'::json)::text
|
|
378
|
+
FROM jsonb_array_elements_text(${namesParam}::text::jsonb) AS tables(name)
|
|
379
|
+
JOIN pg_catalog.pg_index AS index_meta
|
|
380
|
+
ON index_meta.indrelid = to_regclass(tables.name)
|
|
381
|
+
JOIN pg_catalog.pg_class AS index_class
|
|
382
|
+
ON index_class.oid = index_meta.indexrelid
|
|
383
|
+
WHERE NOT index_meta.indisprimary
|
|
384
|
+
AND NOT EXISTS (
|
|
385
|
+
SELECT 1
|
|
386
|
+
FROM pg_catalog.pg_constraint AS owning_constraint
|
|
387
|
+
WHERE owning_constraint.conindid = index_meta.indexrelid
|
|
388
|
+
))`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Table name to index name to {@link indexSignature}, from {@link indexesSubquery} JSON. */
|
|
392
|
+
function parseIndexSignatures(
|
|
393
|
+
json: string,
|
|
394
|
+
): Map<string, ReadonlyMap<string, string>> {
|
|
395
|
+
const byTable = new Map<string, Map<string, string>>();
|
|
396
|
+
for (const index of JSON.parse(json) as {
|
|
397
|
+
tbl: string;
|
|
398
|
+
name: string;
|
|
399
|
+
unique: boolean;
|
|
400
|
+
partial: boolean;
|
|
401
|
+
columns: (string | null)[];
|
|
402
|
+
}[]) {
|
|
403
|
+
const indexes = byTable.get(index.tbl) ?? new Map<string, string>();
|
|
404
|
+
indexes.set(
|
|
405
|
+
index.name,
|
|
406
|
+
indexSignature(index.columns, index.unique, index.partial),
|
|
407
|
+
);
|
|
408
|
+
byTable.set(index.tbl, indexes);
|
|
409
|
+
}
|
|
410
|
+
return byTable;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function readIndexSignatures(
|
|
414
|
+
client: PgQueryable,
|
|
415
|
+
tableNames: readonly string[],
|
|
416
|
+
): Promise<Map<string, ReadonlyMap<string, string>>> {
|
|
417
|
+
const { rows } = await client.query<{ indexes: string }>(
|
|
418
|
+
`SELECT ${indexesSubquery('$1')} AS indexes`,
|
|
419
|
+
[JSON.stringify(tableNames)],
|
|
420
|
+
);
|
|
421
|
+
return parseIndexSignatures(rows[0]?.indexes ?? '[]');
|
|
422
|
+
}
|
|
423
|
+
|
|
342
424
|
/** RFC 0007 serve gate on either the pool or a transaction's client. */
|
|
343
425
|
async function readServeGateOn(
|
|
344
426
|
client: PgQueryable,
|
|
@@ -1584,6 +1666,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1584
1666
|
layouts: unknown;
|
|
1585
1667
|
incomplete: string;
|
|
1586
1668
|
columns: string;
|
|
1669
|
+
indexes: string;
|
|
1587
1670
|
}>(
|
|
1588
1671
|
`SELECT meta.schema_version AS schema_version, meta.layouts AS layouts,
|
|
1589
1672
|
(SELECT coalesce(json_agg(json_build_object(
|
|
@@ -1609,7 +1692,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1609
1692
|
JOIN pg_attribute attribute
|
|
1610
1693
|
ON attribute.attrelid = to_regclass(tables.name)
|
|
1611
1694
|
WHERE attribute.attnum > 0
|
|
1612
|
-
AND NOT attribute.attisdropped) AS columns
|
|
1695
|
+
AND NOT attribute.attisdropped) AS columns,
|
|
1696
|
+
${indexesSubquery('$2')} AS indexes
|
|
1613
1697
|
FROM (SELECT 1) AS one
|
|
1614
1698
|
LEFT JOIN sync_schema_meta meta ON meta.id=1`,
|
|
1615
1699
|
[schema.version, JSON.stringify([...schema.tables.keys()])],
|
|
@@ -1674,6 +1758,25 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1674
1758
|
'postgres',
|
|
1675
1759
|
);
|
|
1676
1760
|
}
|
|
1761
|
+
// Server-only indexes reconcile at every open of a database already at
|
|
1762
|
+
// this version: they never reach a client, so no version bump is needed.
|
|
1763
|
+
// The unlocked read finds the common no-op case; the diff is recomputed
|
|
1764
|
+
// under the migration barrier before any DDL runs.
|
|
1765
|
+
const reconcile = serverIndexDdl(
|
|
1766
|
+
schema,
|
|
1767
|
+
parseIndexSignatures(marker.indexes),
|
|
1768
|
+
);
|
|
1769
|
+
if (reconcile.length > 0) {
|
|
1770
|
+
await this.#exec.transaction(async (client) => {
|
|
1771
|
+
await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
|
|
1772
|
+
for (const statement of serverIndexDdl(
|
|
1773
|
+
schema,
|
|
1774
|
+
await readIndexSignatures(client, [...schema.tables.keys()]),
|
|
1775
|
+
)) {
|
|
1776
|
+
await client.query(statement);
|
|
1777
|
+
}
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1677
1780
|
}
|
|
1678
1781
|
if (stored === undefined || stored < schema.version) {
|
|
1679
1782
|
// Introspect existing app tables, apply the migration subset
|
|
@@ -1721,6 +1824,9 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1721
1824
|
const retiredTables = retiredTableNames(schema, layouts);
|
|
1722
1825
|
const existing = new Map<string, ReadonlySet<string>>();
|
|
1723
1826
|
const existingIndexes = new Map<string, ReadonlyMap<string, string>>();
|
|
1827
|
+
const tableIndexes = await readIndexSignatures(client, [
|
|
1828
|
+
...schema.tables.keys(),
|
|
1829
|
+
]);
|
|
1724
1830
|
for (const table of schema.tables.values()) {
|
|
1725
1831
|
const { rows } = await client.query<{ column_name: string }>(
|
|
1726
1832
|
`SELECT column_name FROM information_schema.columns
|
|
@@ -1729,59 +1835,9 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
1729
1835
|
);
|
|
1730
1836
|
if (rows.length > 0) {
|
|
1731
1837
|
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
1838
|
existingIndexes.set(
|
|
1774
1839
|
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
|
-
),
|
|
1840
|
+
tableIndexes.get(table.name) ?? new Map(),
|
|
1785
1841
|
);
|
|
1786
1842
|
}
|
|
1787
1843
|
}
|
|
@@ -2694,8 +2750,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2694
2750
|
cursor: unknown;
|
|
2695
2751
|
subscriptions: unknown;
|
|
2696
2752
|
updated_at_ms: unknown;
|
|
2753
|
+
last_round_at_ms: unknown;
|
|
2754
|
+
last_error_code: string | null;
|
|
2755
|
+
consecutive_failures: unknown;
|
|
2697
2756
|
}>(
|
|
2698
|
-
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 AND client_id=$2',
|
|
2757
|
+
'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
2758
|
[partition, clientId],
|
|
2700
2759
|
);
|
|
2701
2760
|
const record = rows[0];
|
|
@@ -2707,21 +2766,34 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2707
2766
|
cursor: asNumber(record.cursor),
|
|
2708
2767
|
updatedAtMs: asNumber(record.updated_at_ms),
|
|
2709
2768
|
subscriptions: asJson<ClientSubscription[]>(record.subscriptions),
|
|
2769
|
+
...clientRoundState(
|
|
2770
|
+
record.last_round_at_ms === null
|
|
2771
|
+
? null
|
|
2772
|
+
: asNumber(record.last_round_at_ms),
|
|
2773
|
+
record.last_error_code,
|
|
2774
|
+
asNumber(record.consecutive_failures),
|
|
2775
|
+
),
|
|
2710
2776
|
};
|
|
2711
2777
|
}
|
|
2712
2778
|
|
|
2713
2779
|
async putClientRecord(
|
|
2714
2780
|
partition: string,
|
|
2715
2781
|
record: ClientRecord,
|
|
2782
|
+
options?: ClientRecordWriteOptions,
|
|
2716
2783
|
): Promise<void> {
|
|
2717
2784
|
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)
|
|
2785
|
+
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms)
|
|
2786
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$7)
|
|
2720
2787
|
ON CONFLICT (partition, client_id) DO UPDATE
|
|
2721
2788
|
SET actor_id=EXCLUDED.actor_id, wire_version=EXCLUDED.wire_version,
|
|
2722
2789
|
cursor=EXCLUDED.cursor,
|
|
2723
2790
|
subscriptions=EXCLUDED.subscriptions,
|
|
2724
|
-
updated_at_ms=EXCLUDED.updated_at_ms
|
|
2791
|
+
updated_at_ms=EXCLUDED.updated_at_ms,
|
|
2792
|
+
last_round_at_ms=EXCLUDED.last_round_at_ms${
|
|
2793
|
+
options?.keepRoundOutcome === true
|
|
2794
|
+
? ''
|
|
2795
|
+
: ',\n last_error_code=NULL, consecutive_failures=0'
|
|
2796
|
+
}`,
|
|
2725
2797
|
[
|
|
2726
2798
|
partition,
|
|
2727
2799
|
record.clientId,
|
|
@@ -2734,6 +2806,22 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2734
2806
|
);
|
|
2735
2807
|
}
|
|
2736
2808
|
|
|
2809
|
+
async recordClientRoundFailure(
|
|
2810
|
+
partition: string,
|
|
2811
|
+
clientId: string,
|
|
2812
|
+
actorId: string,
|
|
2813
|
+
errorCode: string,
|
|
2814
|
+
atMs: number,
|
|
2815
|
+
): Promise<void> {
|
|
2816
|
+
await this.#exec.query(
|
|
2817
|
+
`UPDATE sync_clients
|
|
2818
|
+
SET last_round_at_ms=$1, last_error_code=$2,
|
|
2819
|
+
consecutive_failures=consecutive_failures+1
|
|
2820
|
+
WHERE partition=$3 AND client_id=$4 AND actor_id=$5`,
|
|
2821
|
+
[atMs, errorCode, partition, clientId, actorId],
|
|
2822
|
+
);
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2737
2825
|
async advanceClientCursor(
|
|
2738
2826
|
partition: string,
|
|
2739
2827
|
clientId: string,
|
|
@@ -2910,8 +2998,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2910
2998
|
cursor: unknown;
|
|
2911
2999
|
subscriptions: unknown;
|
|
2912
3000
|
updated_at_ms: unknown;
|
|
3001
|
+
last_round_at_ms: unknown;
|
|
3002
|
+
last_error_code: string | null;
|
|
3003
|
+
consecutive_failures: unknown;
|
|
2913
3004
|
}>(
|
|
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',
|
|
3005
|
+
'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
3006
|
[partition],
|
|
2916
3007
|
);
|
|
2917
3008
|
return rows.map((r) => ({
|
|
@@ -2923,6 +3014,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
|
2923
3014
|
subscriptions: (typeof r.subscriptions === 'string'
|
|
2924
3015
|
? JSON.parse(r.subscriptions)
|
|
2925
3016
|
: r.subscriptions) as ClientSubscription[],
|
|
3017
|
+
...clientRoundState(
|
|
3018
|
+
r.last_round_at_ms === null ? null : asNumber(r.last_round_at_ms),
|
|
3019
|
+
r.last_error_code,
|
|
3020
|
+
asNumber(r.consecutive_failures),
|
|
3021
|
+
),
|
|
2926
3022
|
}));
|
|
2927
3023
|
}
|
|
2928
3024
|
|
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,
|
|
@@ -83,6 +89,7 @@ import type {
|
|
|
83
89
|
ClientCursorInfo,
|
|
84
90
|
ClientPushResult,
|
|
85
91
|
ClientRecord,
|
|
92
|
+
ClientRecordWriteOptions,
|
|
86
93
|
ClientSubscription,
|
|
87
94
|
CommitChangeSummary,
|
|
88
95
|
CommitDetail,
|
|
@@ -550,6 +557,15 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
550
557
|
'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1',
|
|
551
558
|
);
|
|
552
559
|
}
|
|
560
|
+
for (const [name, definition] of Object.entries(
|
|
561
|
+
SYNC_CLIENT_ROUND_COLUMNS,
|
|
562
|
+
)) {
|
|
563
|
+
if (!clientColumns.some((column) => column.name === name)) {
|
|
564
|
+
this.db.exec(
|
|
565
|
+
`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`,
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
553
569
|
const commitColumns = this.db
|
|
554
570
|
.query<{ name: string }, []>('PRAGMA table_info("sync_commits")')
|
|
555
571
|
.all();
|
|
@@ -649,6 +665,40 @@ END`);
|
|
|
649
665
|
'sqlite',
|
|
650
666
|
);
|
|
651
667
|
}
|
|
668
|
+
// Server-only indexes reconcile at every open of a database already at
|
|
669
|
+
// this version: they never reach a client, so no version bump is needed.
|
|
670
|
+
const reconcile = serverIndexDdl(
|
|
671
|
+
schema,
|
|
672
|
+
new Map(
|
|
673
|
+
[...schema.tables.values()].map((table) => [
|
|
674
|
+
table.name,
|
|
675
|
+
new Map(
|
|
676
|
+
Object.entries(
|
|
677
|
+
sqliteIndexSignatures(
|
|
678
|
+
this.db
|
|
679
|
+
.query<SqliteTableIndexRow, [string]>(
|
|
680
|
+
SQLITE_TABLE_INDEXES_SQL,
|
|
681
|
+
)
|
|
682
|
+
.all(table.name),
|
|
683
|
+
),
|
|
684
|
+
),
|
|
685
|
+
),
|
|
686
|
+
]),
|
|
687
|
+
),
|
|
688
|
+
);
|
|
689
|
+
if (reconcile.length > 0) {
|
|
690
|
+
await this.#serializeWrite(() => {
|
|
691
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
692
|
+
try {
|
|
693
|
+
for (const statement of reconcile) this.db.exec(statement);
|
|
694
|
+
this.db.exec('COMMIT');
|
|
695
|
+
} catch (error) {
|
|
696
|
+
rollbackAfterError(() => {
|
|
697
|
+
this.db.exec('ROLLBACK');
|
|
698
|
+
}, error);
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
}
|
|
652
702
|
}
|
|
653
703
|
if (marker === null || marker.schema_version < schema.version) {
|
|
654
704
|
// Introspect existing app tables, then apply the migration subset
|
|
@@ -1815,10 +1865,13 @@ END`);
|
|
|
1815
1865
|
cursor: number;
|
|
1816
1866
|
subscriptions: string;
|
|
1817
1867
|
updated_at_ms: number;
|
|
1868
|
+
last_round_at_ms: number | null;
|
|
1869
|
+
last_error_code: string | null;
|
|
1870
|
+
consecutive_failures: number;
|
|
1818
1871
|
},
|
|
1819
1872
|
[string, string]
|
|
1820
1873
|
>(
|
|
1821
|
-
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
|
|
1874
|
+
'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
1875
|
)
|
|
1823
1876
|
.get(partition, clientId);
|
|
1824
1877
|
if (record === null) return undefined;
|
|
@@ -1829,16 +1882,35 @@ END`);
|
|
|
1829
1882
|
cursor: record.cursor,
|
|
1830
1883
|
updatedAtMs: record.updated_at_ms,
|
|
1831
1884
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
1885
|
+
...clientRoundState(
|
|
1886
|
+
record.last_round_at_ms,
|
|
1887
|
+
record.last_error_code,
|
|
1888
|
+
record.consecutive_failures,
|
|
1889
|
+
),
|
|
1832
1890
|
};
|
|
1833
1891
|
}
|
|
1834
1892
|
|
|
1835
1893
|
async putClientRecord(
|
|
1836
1894
|
partition: string,
|
|
1837
1895
|
record: ClientRecord,
|
|
1896
|
+
options?: ClientRecordWriteOptions,
|
|
1838
1897
|
): Promise<void> {
|
|
1839
1898
|
this.db
|
|
1840
1899
|
.query(
|
|
1841
|
-
|
|
1900
|
+
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
|
|
1901
|
+
ON CONFLICT(partition, client_id) DO UPDATE SET
|
|
1902
|
+
actor_id=excluded.actor_id,
|
|
1903
|
+
wire_version=excluded.wire_version,
|
|
1904
|
+
cursor=excluded.cursor,
|
|
1905
|
+
subscriptions=excluded.subscriptions,
|
|
1906
|
+
updated_at_ms=excluded.updated_at_ms,
|
|
1907
|
+
last_round_at_ms=excluded.last_round_at_ms${
|
|
1908
|
+
options?.keepRoundOutcome === true
|
|
1909
|
+
? ''
|
|
1910
|
+
: `,
|
|
1911
|
+
last_error_code=NULL,
|
|
1912
|
+
consecutive_failures=0`
|
|
1913
|
+
}`,
|
|
1842
1914
|
)
|
|
1843
1915
|
.run(
|
|
1844
1916
|
partition,
|
|
@@ -1848,9 +1920,27 @@ END`);
|
|
|
1848
1920
|
record.cursor,
|
|
1849
1921
|
JSON.stringify(record.subscriptions),
|
|
1850
1922
|
record.updatedAtMs,
|
|
1923
|
+
record.updatedAtMs,
|
|
1851
1924
|
);
|
|
1852
1925
|
}
|
|
1853
1926
|
|
|
1927
|
+
async recordClientRoundFailure(
|
|
1928
|
+
partition: string,
|
|
1929
|
+
clientId: string,
|
|
1930
|
+
actorId: string,
|
|
1931
|
+
errorCode: string,
|
|
1932
|
+
atMs: number,
|
|
1933
|
+
): Promise<void> {
|
|
1934
|
+
this.db
|
|
1935
|
+
.query(
|
|
1936
|
+
`UPDATE sync_clients
|
|
1937
|
+
SET last_round_at_ms=?, last_error_code=?,
|
|
1938
|
+
consecutive_failures=consecutive_failures+1
|
|
1939
|
+
WHERE partition=? AND client_id=? AND actor_id=?`,
|
|
1940
|
+
)
|
|
1941
|
+
.run(atMs, errorCode, partition, clientId, actorId);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1854
1944
|
async advanceClientCursor(
|
|
1855
1945
|
partition: string,
|
|
1856
1946
|
clientId: string,
|
|
@@ -2006,10 +2096,13 @@ END`);
|
|
|
2006
2096
|
cursor: number;
|
|
2007
2097
|
subscriptions: string;
|
|
2008
2098
|
updated_at_ms: number;
|
|
2099
|
+
last_round_at_ms: number | null;
|
|
2100
|
+
last_error_code: string | null;
|
|
2101
|
+
consecutive_failures: number;
|
|
2009
2102
|
},
|
|
2010
2103
|
[string]
|
|
2011
2104
|
>(
|
|
2012
|
-
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
2105
|
+
'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
2106
|
)
|
|
2014
2107
|
.all(partition);
|
|
2015
2108
|
return records.map((record) => ({
|
|
@@ -2019,6 +2112,11 @@ END`);
|
|
|
2019
2112
|
cursor: record.cursor,
|
|
2020
2113
|
updatedAtMs: record.updated_at_ms,
|
|
2021
2114
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
2115
|
+
...clientRoundState(
|
|
2116
|
+
record.last_round_at_ms,
|
|
2117
|
+
record.last_error_code,
|
|
2118
|
+
record.consecutive_failures,
|
|
2119
|
+
),
|
|
2022
2120
|
}));
|
|
2023
2121
|
}
|
|
2024
2122
|
|
package/src/storage.ts
CHANGED
|
@@ -305,6 +305,38 @@ 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
|
+
/** `putClientRecord` options. */
|
|
317
|
+
export interface ClientRecordWriteOptions {
|
|
318
|
+
/**
|
|
319
|
+
* Leave the stored round outcome (`lastErrorCode`, `consecutiveFailures`)
|
|
320
|
+
* untouched. Set for a completed round that carried a `ROUND_FAILURE` report
|
|
321
|
+
* (§1.5, §4.5). `lastRoundAtMs` still moves to this round's completion.
|
|
322
|
+
*/
|
|
323
|
+
readonly keepRoundOutcome?: boolean;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The round-state fields of a stored `sync_clients` row. */
|
|
327
|
+
export function clientRoundState(
|
|
328
|
+
lastRoundAtMs: number | null,
|
|
329
|
+
lastErrorCode: string | null,
|
|
330
|
+
consecutiveFailures: number,
|
|
331
|
+
): Pick<
|
|
332
|
+
ClientRecord,
|
|
333
|
+
'lastRoundAtMs' | 'lastErrorCode' | 'consecutiveFailures'
|
|
334
|
+
> {
|
|
335
|
+
return {
|
|
336
|
+
...(lastRoundAtMs !== null ? { lastRoundAtMs } : {}),
|
|
337
|
+
...(lastErrorCode !== null ? { lastErrorCode } : {}),
|
|
338
|
+
...(consecutiveFailures > 0 ? { consecutiveFailures } : {}),
|
|
339
|
+
};
|
|
308
340
|
}
|
|
309
341
|
|
|
310
342
|
export interface CommitWindowQuery {
|
|
@@ -917,7 +949,30 @@ export interface ServerStorage {
|
|
|
917
949
|
partition: string,
|
|
918
950
|
clientId: string,
|
|
919
951
|
): Promise<ClientRecord | undefined>;
|
|
920
|
-
|
|
952
|
+
/**
|
|
953
|
+
* Write the record for a completed round. The round-state fields reset: the
|
|
954
|
+
* stored last round is a success at `updatedAtMs` with no failures — unless
|
|
955
|
+
* `keepRoundOutcome` is set, which leaves the reported code and streak in
|
|
956
|
+
* place (the round carried a `ROUND_FAILURE` report, §1.5/§4.5).
|
|
957
|
+
*/
|
|
958
|
+
putClientRecord(
|
|
959
|
+
partition: string,
|
|
960
|
+
record: ClientRecord,
|
|
961
|
+
options?: ClientRecordWriteOptions,
|
|
962
|
+
): Promise<void>;
|
|
963
|
+
/**
|
|
964
|
+
* Record a failed round on an existing record: set `lastRoundAtMs` and
|
|
965
|
+
* `lastErrorCode`, and increment `consecutiveFailures`. Requires a matching
|
|
966
|
+
* actor; a missing record or another actor's record is unchanged, and
|
|
967
|
+
* `updatedAtMs` (the pruning liveness signal) is never touched.
|
|
968
|
+
*/
|
|
969
|
+
recordClientRoundFailure(
|
|
970
|
+
partition: string,
|
|
971
|
+
clientId: string,
|
|
972
|
+
actorId: string,
|
|
973
|
+
errorCode: string,
|
|
974
|
+
atMs: number,
|
|
975
|
+
): Promise<void>;
|
|
921
976
|
/**
|
|
922
977
|
* Atomically advance an existing record's cursor and activity timestamp.
|
|
923
978
|
* Preserve registration fields; require matching actor and current log epoch.
|