@syncular/server 0.34.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blob-handlers.js +10 -3
- package/dist/blob-store.d.ts +9 -1
- package/dist/blob-store.js +0 -0
- package/dist/bucket-blob-store.d.ts +1 -0
- package/dist/bucket-blob-store.js +22 -16
- package/dist/d1-storage.d.ts +3 -1
- package/dist/d1-storage.js +25 -1
- package/dist/erase.d.ts +28 -0
- package/dist/erase.js +122 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/postgres-storage.d.ts +10 -2
- package/dist/postgres-storage.js +153 -67
- package/dist/push.d.ts +8 -2
- package/dist/push.js +1 -1
- package/dist/relational-rows.d.ts +11 -11
- package/dist/relational-rows.js +16 -16
- package/dist/sqlite-blob-store.d.ts +1 -0
- package/dist/sqlite-blob-store.js +14 -2
- package/dist/sqlite-dialect.d.ts +11 -1
- package/dist/sqlite-dialect.js +35 -0
- package/dist/sqlite-storage.d.ts +3 -1
- package/dist/sqlite-storage.js +26 -1
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +2 -0
- package/dist/storage.d.ts +26 -0
- package/package.json +2 -2
- package/src/blob-handlers.ts +13 -5
- package/src/blob-store.ts +0 -0
- package/src/bucket-blob-store.ts +26 -20
- package/src/d1-storage.ts +45 -0
- package/src/erase.ts +173 -0
- package/src/index.ts +1 -0
- package/src/postgres-storage.ts +203 -96
- package/src/push.ts +1 -1
- package/src/relational-rows.ts +16 -16
- package/src/sqlite-blob-store.ts +17 -1
- package/src/sqlite-dialect.ts +43 -0
- package/src/sqlite-storage.ts +39 -0
- package/src/storage-errors.ts +7 -1
- package/src/storage.ts +35 -0
package/src/postgres-storage.ts
CHANGED
|
@@ -90,7 +90,7 @@ import {
|
|
|
90
90
|
upsertValues,
|
|
91
91
|
} from './relational-rows';
|
|
92
92
|
import type { CompiledSchema, CompiledTable } from './schema';
|
|
93
|
-
import { matchesEffective } from './scopes';
|
|
93
|
+
import { matchesEffective, sha256Hex } from './scopes';
|
|
94
94
|
import type {
|
|
95
95
|
AuthoritativeQueryRequest,
|
|
96
96
|
AuthoritativeQueryResult,
|
|
@@ -121,6 +121,8 @@ import type {
|
|
|
121
121
|
RowScanQuery,
|
|
122
122
|
ScopeActivityQuery,
|
|
123
123
|
ScopeCommitActivity,
|
|
124
|
+
ScopeValueHistoryQuery,
|
|
125
|
+
ScopeValueQuery,
|
|
124
126
|
ServerStorage,
|
|
125
127
|
ServeGate,
|
|
126
128
|
StorageTransaction,
|
|
@@ -1277,10 +1279,11 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1277
1279
|
#schemaVersion: number | undefined;
|
|
1278
1280
|
/**
|
|
1279
1281
|
* Page reads waiting for the end of the current microtask turn, keyed by
|
|
1280
|
-
* statement and
|
|
1282
|
+
* statement and partition. A pull starts the window read (or first
|
|
1281
1283
|
* snapshot page) of every subscription before awaiting any of them, so the
|
|
1282
|
-
* reads of
|
|
1283
|
-
*
|
|
1284
|
+
* window reads of all tables leave as ONE set-based statement (snapshot
|
|
1285
|
+
* pages as one per table, whose SQL names the table) instead of one round
|
|
1286
|
+
* trip per subscription or table (SYNCULAR-PULL-ROUNDTRIPS-001).
|
|
1284
1287
|
*/
|
|
1285
1288
|
readonly #pageBatches = new Map<string, PendingPage[]>();
|
|
1286
1289
|
|
|
@@ -1289,18 +1292,17 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1289
1292
|
}
|
|
1290
1293
|
|
|
1291
1294
|
/**
|
|
1292
|
-
* Queue one page read into the batch for (`sql`, `partition
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1295
|
+
* Queue one page read into the batch for (`sql`, `partition`). The batch
|
|
1296
|
+
* runs as a single statement with the queued specs bound as a JSON array
|
|
1297
|
+
* (`$2`); each spec gets its position as `i`, and the result rows are
|
|
1298
|
+
* split back by their `sub` column.
|
|
1296
1299
|
*/
|
|
1297
1300
|
#batchedPage<Row>(
|
|
1298
1301
|
sql: string,
|
|
1299
1302
|
partition: string,
|
|
1300
|
-
table: string,
|
|
1301
1303
|
spec: Record<string, unknown>,
|
|
1302
1304
|
): Promise<Row[]> {
|
|
1303
|
-
const key = `${sql}\u0000${partition}
|
|
1305
|
+
const key = `${sql}\u0000${partition}`;
|
|
1304
1306
|
let batch = this.#pageBatches.get(key);
|
|
1305
1307
|
if (batch === undefined) {
|
|
1306
1308
|
const queued: PendingPage[] = [];
|
|
@@ -1311,7 +1313,7 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1311
1313
|
const specs = JSON.stringify(
|
|
1312
1314
|
queued.map((entry, i) => ({ ...entry.spec, i })),
|
|
1313
1315
|
);
|
|
1314
|
-
this.#exec.query<PgRow>(sql, [partition, specs
|
|
1316
|
+
this.#exec.query<PgRow>(sql, [partition, specs]).then(
|
|
1315
1317
|
({ rows }) => {
|
|
1316
1318
|
const grouped = queued.map((): PgRow[] => []);
|
|
1317
1319
|
for (const row of rows) grouped[asNumber(row.sub)]?.push(row);
|
|
@@ -1333,24 +1335,27 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1333
1335
|
});
|
|
1334
1336
|
}
|
|
1335
1337
|
|
|
1336
|
-
/**
|
|
1338
|
+
/**
|
|
1339
|
+
* Apply the schema DDL (idempotent). Call once before use. The last
|
|
1340
|
+
* statement records a fingerprint of the applied DDL as the comment of
|
|
1341
|
+
* `sync_schema_meta`; a database that carries the current fingerprint
|
|
1342
|
+
* costs one catalog read instead of the DDL round trips, so a new storage
|
|
1343
|
+
* instance (a Worker isolate, a Durable Object wake) starts fast.
|
|
1344
|
+
*/
|
|
1337
1345
|
async migrate(): Promise<void> {
|
|
1338
1346
|
// Split on the statement boundary so drivers that reject multi-statement
|
|
1339
1347
|
// query strings (node-postgres extended protocol) still apply each DDL.
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
await this.#exec.query(
|
|
1348
|
+
// The fence function is sent whole because its dollar-quoted body
|
|
1349
|
+
// contains `;`.
|
|
1350
|
+
const statements = [
|
|
1351
|
+
...POSTGRES_DDL.split(';')
|
|
1352
|
+
.map((s) => s.trim())
|
|
1353
|
+
.filter((s) => s.length > 0),
|
|
1347
1354
|
'ALTER TABLE sync_commits ADD COLUMN IF NOT EXISTS writer_version BIGINT',
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
// the dollar-quoted body contains `;`.
|
|
1353
|
-
await this.#exec.query(`CREATE OR REPLACE FUNCTION syncular_writer_fence()
|
|
1355
|
+
// Database-side fence: an old binary's INSERT never reaches JS. An
|
|
1356
|
+
// absent `sync_writer_fence` row makes the EXISTS false, so a partition
|
|
1357
|
+
// with no barrier behaves exactly as before.
|
|
1358
|
+
`CREATE OR REPLACE FUNCTION syncular_writer_fence()
|
|
1354
1359
|
RETURNS trigger AS $$
|
|
1355
1360
|
BEGIN
|
|
1356
1361
|
IF EXISTS (
|
|
@@ -1363,13 +1368,45 @@ BEGIN
|
|
|
1363
1368
|
END IF;
|
|
1364
1369
|
RETURN NEW;
|
|
1365
1370
|
END;
|
|
1366
|
-
$$ LANGUAGE plpgsql
|
|
1367
|
-
await this.#exec.query(
|
|
1371
|
+
$$ LANGUAGE plpgsql`,
|
|
1368
1372
|
'DROP TRIGGER IF EXISTS sync_commits_writer_fence ON sync_commits',
|
|
1369
|
-
|
|
1370
|
-
await this.#exec.query(`CREATE TRIGGER sync_commits_writer_fence
|
|
1373
|
+
`CREATE TRIGGER sync_commits_writer_fence
|
|
1371
1374
|
BEFORE INSERT ON sync_commits
|
|
1372
|
-
FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()
|
|
1375
|
+
FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
1376
|
+
SCHEMA_META_DDL_POSTGRES,
|
|
1377
|
+
];
|
|
1378
|
+
const fingerprint = `syncular.storage:${await sha256Hex(
|
|
1379
|
+
new TextEncoder().encode(statements.join('\u0000')),
|
|
1380
|
+
)}`;
|
|
1381
|
+
// `obj_description` and `to_regclass` of a missing table are NULL, so
|
|
1382
|
+
// the probe never fails. A core table dropped out of band reapplies the
|
|
1383
|
+
// DDL even under the current fingerprint.
|
|
1384
|
+
const { rows } = await this.#exec.query<{
|
|
1385
|
+
storage: string | null;
|
|
1386
|
+
missing: number;
|
|
1387
|
+
}>(
|
|
1388
|
+
`SELECT obj_description(to_regclass('sync_schema_meta'), 'pg_class') AS storage,
|
|
1389
|
+
(SELECT count(*)::int
|
|
1390
|
+
FROM jsonb_array_elements_text($1::text::jsonb) AS core(name)
|
|
1391
|
+
WHERE to_regclass(core.name) IS NULL) AS missing`,
|
|
1392
|
+
[
|
|
1393
|
+
JSON.stringify(
|
|
1394
|
+
statements.flatMap(
|
|
1395
|
+
(statement) =>
|
|
1396
|
+
/^CREATE TABLE IF NOT EXISTS (\w+)/.exec(statement)?.[1] ?? [],
|
|
1397
|
+
),
|
|
1398
|
+
),
|
|
1399
|
+
],
|
|
1400
|
+
);
|
|
1401
|
+
if (rows[0]?.storage === fingerprint && rows[0].missing === 0) return;
|
|
1402
|
+
for (const statement of statements) {
|
|
1403
|
+
await this.#exec.query(statement);
|
|
1404
|
+
}
|
|
1405
|
+
// The fingerprint is lowercase hex, safe to inline (COMMENT takes no
|
|
1406
|
+
// bound parameter).
|
|
1407
|
+
await this.#exec.query(
|
|
1408
|
+
`COMMENT ON TABLE sync_schema_meta IS '${fingerprint}'`,
|
|
1409
|
+
);
|
|
1373
1410
|
}
|
|
1374
1411
|
|
|
1375
1412
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
@@ -1390,21 +1427,63 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1390
1427
|
// Memoized fast path: same instance, same schema version.
|
|
1391
1428
|
if (this.#schemaVersion === schema.version) return;
|
|
1392
1429
|
await this.migrate();
|
|
1430
|
+
// One statement reads the marker, the incomplete checkpoints of this
|
|
1431
|
+
// version and the physical columns of every app table. The aggregates
|
|
1432
|
+
// leave as text so every driver hands back the same JSON strings.
|
|
1433
|
+
// Columns resolve through `to_regclass`, which follows the session's
|
|
1434
|
+
// `search_path` exactly as the unqualified row queries do:
|
|
1435
|
+
// `current_schema()` names only the first existing schema, so it would
|
|
1436
|
+
// reject a table that resolves later in the path.
|
|
1437
|
+
const { rows: probe } = await this.#exec.query<{
|
|
1438
|
+
schema_version: unknown;
|
|
1439
|
+
layouts: unknown;
|
|
1440
|
+
incomplete: string;
|
|
1441
|
+
columns: string;
|
|
1442
|
+
}>(
|
|
1443
|
+
`SELECT meta.schema_version AS schema_version, meta.layouts AS layouts,
|
|
1444
|
+
(SELECT coalesce(json_agg(json_build_object(
|
|
1445
|
+
'partition', checkpoint.partition,
|
|
1446
|
+
'name', checkpoint.name)), '[]'::json)::text
|
|
1447
|
+
FROM sync_backfill_checkpoints checkpoint
|
|
1448
|
+
WHERE checkpoint.schema_version=$1
|
|
1449
|
+
AND checkpoint.state<>'activated') AS incomplete,
|
|
1450
|
+
(SELECT coalesce(json_agg(json_build_object(
|
|
1451
|
+
'tbl', tables.name,
|
|
1452
|
+
'name', attribute.attname,
|
|
1453
|
+
'type', format_type(attribute.atttypid, attribute.atttypmod),
|
|
1454
|
+
'not_null', attribute.attnotnull,
|
|
1455
|
+
'pk', (SELECT key.position::int
|
|
1456
|
+
FROM pg_index pk,
|
|
1457
|
+
unnest(pk.indkey::int2[]) WITH ORDINALITY
|
|
1458
|
+
AS key(attnum, position)
|
|
1459
|
+
WHERE pk.indrelid = attribute.attrelid
|
|
1460
|
+
AND pk.indisprimary
|
|
1461
|
+
AND key.attnum = attribute.attnum))
|
|
1462
|
+
ORDER BY tables.name, attribute.attnum), '[]'::json)::text
|
|
1463
|
+
FROM jsonb_array_elements_text($2::text::jsonb) AS tables(name)
|
|
1464
|
+
JOIN pg_attribute attribute
|
|
1465
|
+
ON attribute.attrelid = to_regclass(tables.name)
|
|
1466
|
+
WHERE attribute.attnum > 0
|
|
1467
|
+
AND NOT attribute.attisdropped) AS columns
|
|
1468
|
+
FROM (SELECT 1) AS one
|
|
1469
|
+
LEFT JOIN sync_schema_meta meta ON meta.id=1`,
|
|
1470
|
+
[schema.version, JSON.stringify([...schema.tables.keys()])],
|
|
1471
|
+
);
|
|
1472
|
+
const marker = probe[0];
|
|
1473
|
+
if (marker === undefined) {
|
|
1474
|
+
throw new Error('schema probe returned no row');
|
|
1475
|
+
}
|
|
1393
1476
|
// The barrier reads stored state: a checkpoint at this schema version that
|
|
1394
1477
|
// is not activated and that this caller did not declare means this process
|
|
1395
1478
|
// must not serve. An omitted or empty declaration set declares nothing and
|
|
1396
1479
|
// is refused here, never treated as a bypass.
|
|
1397
1480
|
const declarations = checkpoints ?? [];
|
|
1398
|
-
const incomplete =
|
|
1481
|
+
const incomplete = JSON.parse(marker.incomplete) as {
|
|
1399
1482
|
partition: string;
|
|
1400
1483
|
name: string;
|
|
1401
|
-
}
|
|
1402
|
-
`SELECT partition, name FROM sync_backfill_checkpoints
|
|
1403
|
-
WHERE schema_version=$1 AND state<>'activated'`,
|
|
1404
|
-
[schema.version],
|
|
1405
|
-
);
|
|
1484
|
+
}[];
|
|
1406
1485
|
if (
|
|
1407
|
-
incomplete.
|
|
1486
|
+
incomplete.some(
|
|
1408
1487
|
(row) =>
|
|
1409
1488
|
!declarations.some(
|
|
1410
1489
|
(declaration) =>
|
|
@@ -1415,15 +1494,10 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1415
1494
|
) {
|
|
1416
1495
|
throw new StorageQueryError('sync.storage.checkpoint_incomplete');
|
|
1417
1496
|
}
|
|
1418
|
-
await this.#exec.query(SCHEMA_META_DDL_POSTGRES);
|
|
1419
|
-
const marker = await this.#exec.query<{
|
|
1420
|
-
schema_version: unknown;
|
|
1421
|
-
layouts: unknown;
|
|
1422
|
-
}>('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1');
|
|
1423
1497
|
const stored =
|
|
1424
|
-
marker.
|
|
1498
|
+
marker.schema_version === null || marker.schema_version === undefined
|
|
1425
1499
|
? undefined
|
|
1426
|
-
: asNumber(marker.
|
|
1500
|
+
: asNumber(marker.schema_version);
|
|
1427
1501
|
if (stored !== undefined && stored > schema.version) {
|
|
1428
1502
|
throw new Error(
|
|
1429
1503
|
`stored schema version ${stored} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`,
|
|
@@ -1432,43 +1506,26 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1432
1506
|
if (stored !== undefined && stored === schema.version) {
|
|
1433
1507
|
assertStoredLayouts(
|
|
1434
1508
|
schema,
|
|
1435
|
-
typeof marker.
|
|
1436
|
-
? marker.rows[0].layouts
|
|
1437
|
-
: undefined,
|
|
1509
|
+
typeof marker.layouts === 'string' ? marker.layouts : undefined,
|
|
1438
1510
|
);
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1511
|
+
const columns = JSON.parse(marker.columns) as {
|
|
1512
|
+
tbl: string;
|
|
1513
|
+
name: string;
|
|
1514
|
+
type: string;
|
|
1515
|
+
not_null: boolean;
|
|
1516
|
+
pk: number | null;
|
|
1517
|
+
}[];
|
|
1443
1518
|
for (const table of schema.tables.values()) {
|
|
1444
|
-
const { rows } = await this.#exec.query<{
|
|
1445
|
-
name: string;
|
|
1446
|
-
type: string;
|
|
1447
|
-
not_null: boolean;
|
|
1448
|
-
pk: number | null;
|
|
1449
|
-
}>(
|
|
1450
|
-
`SELECT attribute.attname AS name,
|
|
1451
|
-
format_type(attribute.atttypid, attribute.atttypmod) AS type,
|
|
1452
|
-
attribute.attnotnull AS not_null,
|
|
1453
|
-
(SELECT key.position::int
|
|
1454
|
-
FROM pg_index pk,
|
|
1455
|
-
unnest(pk.indkey::int2[]) WITH ORDINALITY
|
|
1456
|
-
AS key(attnum, position)
|
|
1457
|
-
WHERE pk.indrelid = attribute.attrelid AND pk.indisprimary
|
|
1458
|
-
AND key.attnum = attribute.attnum) AS pk
|
|
1459
|
-
FROM pg_attribute attribute
|
|
1460
|
-
WHERE attribute.attrelid = to_regclass($1)
|
|
1461
|
-
AND attribute.attnum > 0 AND NOT attribute.attisdropped`,
|
|
1462
|
-
[table.name],
|
|
1463
|
-
);
|
|
1464
1519
|
assertPhysicalColumns(
|
|
1465
1520
|
table,
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1521
|
+
columns
|
|
1522
|
+
.filter((column) => column.tbl === table.name)
|
|
1523
|
+
.map((column) => ({
|
|
1524
|
+
name: column.name,
|
|
1525
|
+
type: column.type,
|
|
1526
|
+
notNull: column.not_null,
|
|
1527
|
+
primaryKeyPosition: column.pk ?? 0,
|
|
1528
|
+
})),
|
|
1472
1529
|
'postgres',
|
|
1473
1530
|
);
|
|
1474
1531
|
}
|
|
@@ -1481,9 +1538,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1481
1538
|
// transaction (Postgres DDL is transactional — a failed bump leaves
|
|
1482
1539
|
// no half-state).
|
|
1483
1540
|
const layouts = parseLayouts(
|
|
1484
|
-
typeof marker.
|
|
1485
|
-
? marker.rows[0].layouts
|
|
1486
|
-
: undefined,
|
|
1541
|
+
typeof marker.layouts === 'string' ? marker.layouts : undefined,
|
|
1487
1542
|
);
|
|
1488
1543
|
const retiredTables = retiredTableNames(schema, layouts);
|
|
1489
1544
|
await this.#exec.transaction(async (client) => {
|
|
@@ -2260,12 +2315,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
2260
2315
|
? []
|
|
2261
2316
|
: (query.scopeFilter[firstVariable] ?? []);
|
|
2262
2317
|
if (firstVariable === undefined || values.length === 0) return 0;
|
|
2263
|
-
// Concurrent reads of
|
|
2318
|
+
// Concurrent reads of a pull share one statement across tables.
|
|
2264
2319
|
const [row] = await this.#batchedPage<{ seq: unknown }>(
|
|
2265
2320
|
LATEST_SCOPED_CHANGE_BATCH_SQL,
|
|
2266
2321
|
partition,
|
|
2267
|
-
query.table,
|
|
2268
|
-
{ var: firstVariable, vals: values },
|
|
2322
|
+
{ tbl: query.table, var: firstVariable, vals: values },
|
|
2269
2323
|
);
|
|
2270
2324
|
return row === undefined ? 0 : asNumber(row.seq);
|
|
2271
2325
|
}
|
|
@@ -2283,7 +2337,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
2283
2337
|
// var, value, commit_seq) PK makes the candidate subquery an index range
|
|
2284
2338
|
// scan returning commit_seq already ascending (see postgres-explain
|
|
2285
2339
|
// .test.ts), LEFT JOINed to the commit meta + the table's changes. Pages
|
|
2286
|
-
// of concurrent window reads
|
|
2340
|
+
// of concurrent window reads share one statement across tables (see
|
|
2287
2341
|
// `COMMIT_WINDOW_BATCH_SQL`). Exact multi-variable verification against
|
|
2288
2342
|
// the stored scope map happens below.
|
|
2289
2343
|
const commits: StoredCommit[] = [];
|
|
@@ -2294,8 +2348,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
2294
2348
|
const records = await this.#batchedPage<CommitWindowRecord>(
|
|
2295
2349
|
COMMIT_WINDOW_BATCH_SQL,
|
|
2296
2350
|
partition,
|
|
2297
|
-
query.table,
|
|
2298
2351
|
{
|
|
2352
|
+
tbl: query.table,
|
|
2299
2353
|
var: firstVariable,
|
|
2300
2354
|
vals: firstValues,
|
|
2301
2355
|
after: afterSeq,
|
|
@@ -2368,17 +2422,13 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
2368
2422
|
let afterRowId = query.afterRowId ?? '';
|
|
2369
2423
|
const batchSize = Math.max(64, query.limit);
|
|
2370
2424
|
while (rows.length < query.limit) {
|
|
2371
|
-
const records = await this.#batchedPage<RowRecord>(
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
after: afterRowId,
|
|
2379
|
-
lim: batchSize,
|
|
2380
|
-
},
|
|
2381
|
-
);
|
|
2425
|
+
const records = await this.#batchedPage<RowRecord>(sql, partition, {
|
|
2426
|
+
tbl: query.table,
|
|
2427
|
+
var: firstVariable,
|
|
2428
|
+
vals: firstValues,
|
|
2429
|
+
after: afterRowId,
|
|
2430
|
+
lim: batchSize,
|
|
2431
|
+
});
|
|
2382
2432
|
if (records.length === 0) break;
|
|
2383
2433
|
for (const record of records) {
|
|
2384
2434
|
afterRowId = record.row_id;
|
|
@@ -2567,6 +2617,63 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
2567
2617
|
return rows.map((r) => r.blob_id);
|
|
2568
2618
|
}
|
|
2569
2619
|
|
|
2620
|
+
async listScopeValueRows(
|
|
2621
|
+
partition: string,
|
|
2622
|
+
query: ScopeValueQuery,
|
|
2623
|
+
): Promise<string[]> {
|
|
2624
|
+
const { rows } = await this.#exec.query<{ row_id: string }>(
|
|
2625
|
+
`SELECT row_id FROM sync_row_scopes
|
|
2626
|
+
WHERE partition=$1 AND tbl=$2 AND var=$3 AND value=$4
|
|
2627
|
+
ORDER BY row_id LIMIT $5`,
|
|
2628
|
+
[partition, query.table, query.variable, query.value, query.limit],
|
|
2629
|
+
);
|
|
2630
|
+
return rows.map((row) => row.row_id);
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
async eraseScopeValueHistory(
|
|
2634
|
+
partition: string,
|
|
2635
|
+
query: ScopeValueHistoryQuery,
|
|
2636
|
+
): Promise<number> {
|
|
2637
|
+
const params = [
|
|
2638
|
+
partition,
|
|
2639
|
+
query.table,
|
|
2640
|
+
query.variable,
|
|
2641
|
+
query.value,
|
|
2642
|
+
query.throughSeq,
|
|
2643
|
+
query.limit,
|
|
2644
|
+
];
|
|
2645
|
+
return this.#exec.transaction(async (client) => {
|
|
2646
|
+
await lockPartitionOn(client, partition);
|
|
2647
|
+
// Upsert change records of the binding, then the change scope entries
|
|
2648
|
+
// whose commit no longer holds a change of the binding.
|
|
2649
|
+
const changes = await client.query(
|
|
2650
|
+
`DELETE FROM sync_changes c USING (
|
|
2651
|
+
SELECT c2.commit_seq, c2.idx FROM sync_change_scopes s
|
|
2652
|
+
JOIN sync_changes c2 ON c2.partition=s.partition
|
|
2653
|
+
AND c2.commit_seq=s.commit_seq AND c2.tbl=s.tbl
|
|
2654
|
+
WHERE s.partition=$1 AND s.tbl=$2 AND s.var=$3 AND s.value=$4
|
|
2655
|
+
AND s.commit_seq<=$5 AND c2.op=1 AND c2.scopes->>$3=s.value
|
|
2656
|
+
LIMIT $6) d
|
|
2657
|
+
WHERE c.partition=$1 AND c.commit_seq=d.commit_seq AND c.idx=d.idx`,
|
|
2658
|
+
params,
|
|
2659
|
+
);
|
|
2660
|
+
const scopes = await client.query(
|
|
2661
|
+
`DELETE FROM sync_change_scopes cs USING (
|
|
2662
|
+
SELECT s.commit_seq FROM sync_change_scopes s
|
|
2663
|
+
WHERE s.partition=$1 AND s.tbl=$2 AND s.var=$3 AND s.value=$4
|
|
2664
|
+
AND s.commit_seq<=$5 AND NOT EXISTS (
|
|
2665
|
+
SELECT 1 FROM sync_changes c
|
|
2666
|
+
WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq
|
|
2667
|
+
AND c.tbl=s.tbl AND c.scopes->>$3=s.value)
|
|
2668
|
+
LIMIT $6) d
|
|
2669
|
+
WHERE cs.partition=$1 AND cs.tbl=$2 AND cs.var=$3 AND cs.value=$4
|
|
2670
|
+
AND cs.commit_seq=d.commit_seq`,
|
|
2671
|
+
params,
|
|
2672
|
+
);
|
|
2673
|
+
return changes.rowCount + scopes.rowCount;
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2570
2677
|
// -- admin/console read surface --------------------------------------------
|
|
2571
2678
|
|
|
2572
2679
|
async listClientRecords(partition: string): Promise<ClientRecord[]> {
|
package/src/push.ts
CHANGED
|
@@ -75,7 +75,7 @@ import {
|
|
|
75
75
|
* columns (§5.9.4), skipping NULLs. Malformed BlobRefs already failed at
|
|
76
76
|
* row-codec decode (§5.9.1), so `parseBlobRef` here is total.
|
|
77
77
|
*/
|
|
78
|
-
function blobIdsInRow(
|
|
78
|
+
export function blobIdsInRow(
|
|
79
79
|
table: CompiledTable,
|
|
80
80
|
values: readonly RowValue[],
|
|
81
81
|
): string[] {
|
package/src/relational-rows.ts
CHANGED
|
@@ -647,20 +647,20 @@ export function commitWindowPageSql(
|
|
|
647
647
|
/**
|
|
648
648
|
* PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
|
|
649
649
|
* page of every subscription scanning `table` in a pull. `$2` is a JSON array
|
|
650
|
-
* of `{i, var, vals, after, lim}` records; each record runs the same
|
|
650
|
+
* of `{i, tbl, var, vals, after, lim}` records; each record runs the same
|
|
651
651
|
* index-first candidate subquery through a LATERAL join, so every
|
|
652
652
|
* subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
|
|
653
653
|
* by (i, row_id).
|
|
654
654
|
*
|
|
655
|
-
* Params: [partition, specs (JSON text)
|
|
655
|
+
* Params: [partition, specs (JSON text)]. The specs bind as text before
|
|
656
656
|
* the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
|
|
657
657
|
* parameter (Bun.sql) cannot turn the array into a JSON string.
|
|
658
658
|
*/
|
|
659
659
|
export function scanRowBatchSql(table: CompiledTable): string {
|
|
660
660
|
return `SELECT s.i AS sub, 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
|
|
661
|
-
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after text, lim int)
|
|
661
|
+
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
|
|
662
662
|
CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
|
|
663
|
-
WHERE partition=$1 AND tbl
|
|
663
|
+
WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
|
|
664
664
|
AND row_id>s.after
|
|
665
665
|
ORDER BY row_id LIMIT s.lim) c
|
|
666
666
|
LEFT JOIN ${quoteIdent(table.name)} r
|
|
@@ -670,25 +670,25 @@ export function scanRowBatchSql(table: CompiledTable): string {
|
|
|
670
670
|
|
|
671
671
|
/**
|
|
672
672
|
* PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
|
|
673
|
-
* the window page of every subscription
|
|
674
|
-
* JSON array of `{i, var, vals, after, through, lim}` records, each
|
|
675
|
-
* the same index-first candidate subquery through a LATERAL join.
|
|
676
|
-
* arrive ordered by (i, commit_seq, idx).
|
|
673
|
+
* the window page of every subscription in a pull, across all tables. `$2`
|
|
674
|
+
* is a JSON array of `{i, tbl, var, vals, after, through, lim}` records, each
|
|
675
|
+
* running the same index-first candidate subquery through a LATERAL join.
|
|
676
|
+
* Rows arrive ordered by (i, commit_seq, idx).
|
|
677
677
|
*
|
|
678
|
-
* Params: [partition, specs (JSON text)
|
|
678
|
+
* Params: [partition, specs (JSON text)], the specs bound as text like
|
|
679
679
|
* `scanRowBatchSql`.
|
|
680
680
|
*/
|
|
681
681
|
export const COMMIT_WINDOW_BATCH_SQL = `SELECT s.i AS sub, c.commit_seq AS commit_seq, m.actor_id AS actor_id, m.created_at_ms AS created_at_ms,
|
|
682
682
|
ch.tbl AS tbl, ch.row_id AS row_id, ch.op AS op, ch.row_version AS row_version, ch.scopes AS scopes, ch.payload AS payload
|
|
683
|
-
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)
|
|
683
|
+
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after bigint, through bigint, lim int)
|
|
684
684
|
CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes
|
|
685
|
-
WHERE partition=$1 AND tbl
|
|
685
|
+
WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
|
|
686
686
|
AND commit_seq>s.after AND commit_seq<=s.through
|
|
687
687
|
ORDER BY commit_seq LIMIT s.lim) c
|
|
688
688
|
LEFT JOIN sync_commits m
|
|
689
689
|
ON m.partition=$1 AND m.commit_seq=c.commit_seq
|
|
690
690
|
LEFT JOIN sync_changes ch
|
|
691
|
-
ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl
|
|
691
|
+
ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=s.tbl
|
|
692
692
|
ORDER BY s.i, c.commit_seq, ch.idx`;
|
|
693
693
|
|
|
694
694
|
/**
|
|
@@ -720,15 +720,15 @@ export const LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL = `SELECT max((SELECT max(commi
|
|
|
720
720
|
|
|
721
721
|
/**
|
|
722
722
|
* PostgreSQL set-based form of `LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL` for the
|
|
723
|
-
* subscriptions of
|
|
724
|
-
* `{i, var, vals}` records. Params: [partition, specs (JSON text)
|
|
723
|
+
* subscriptions of a pull, across all tables. `$2` is a JSON array of
|
|
724
|
+
* `{i, tbl, var, vals}` records. Params: [partition, specs (JSON text)].
|
|
725
725
|
*/
|
|
726
726
|
export const LATEST_SCOPED_CHANGE_BATCH_SQL = `SELECT s.i AS sub,
|
|
727
727
|
(SELECT max(latest.seq) FROM (
|
|
728
728
|
SELECT (SELECT max(commit_seq) FROM sync_change_scopes
|
|
729
|
-
WHERE partition=$1 AND tbl
|
|
729
|
+
WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=v.value) AS seq
|
|
730
730
|
FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq
|
|
731
|
-
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals jsonb)
|
|
731
|
+
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals jsonb)
|
|
732
732
|
ORDER BY s.i`;
|
|
733
733
|
|
|
734
734
|
/** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
|
package/src/sqlite-blob-store.ts
CHANGED
|
@@ -33,7 +33,17 @@ export class SqliteBlobStore implements BlobStore {
|
|
|
33
33
|
mediaType?: string,
|
|
34
34
|
): Promise<BlobRecord> {
|
|
35
35
|
const existing = await this.get(partition, blobId);
|
|
36
|
-
if (existing !== undefined)
|
|
36
|
+
if (existing !== undefined) {
|
|
37
|
+
if (existing.record.mediaType !== undefined || mediaType === undefined)
|
|
38
|
+
return existing.record;
|
|
39
|
+
// A re-upload names the media type an earlier upload left out.
|
|
40
|
+
this.db
|
|
41
|
+
.query(
|
|
42
|
+
'UPDATE sync_blobs SET media_type=? WHERE partition=? AND blob_id=? AND media_type IS NULL',
|
|
43
|
+
)
|
|
44
|
+
.run(mediaType, partition, blobId);
|
|
45
|
+
return { ...existing.record, mediaType };
|
|
46
|
+
}
|
|
37
47
|
const record: BlobRecord = {
|
|
38
48
|
blobId,
|
|
39
49
|
partition,
|
|
@@ -51,6 +61,12 @@ export class SqliteBlobStore implements BlobStore {
|
|
|
51
61
|
return record;
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
async delete(partition: string, blobId: string): Promise<void> {
|
|
65
|
+
this.db
|
|
66
|
+
.query('DELETE FROM sync_blobs WHERE partition=? AND blob_id=?')
|
|
67
|
+
.run(partition, blobId);
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
async has(partition: string, blobId: string): Promise<boolean> {
|
|
55
71
|
const row = this.db
|
|
56
72
|
.query<{ n: number }, [string, string]>(
|
package/src/sqlite-dialect.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import type { PushOperationResult, ScopeMap } from '@syncular/core';
|
|
19
19
|
import { matchesEffective } from './scopes';
|
|
20
20
|
import type {
|
|
21
|
+
ScopeValueHistoryQuery,
|
|
21
22
|
StoredChange,
|
|
22
23
|
StoredCommit,
|
|
23
24
|
StoredPushResult,
|
|
@@ -151,6 +152,48 @@ export function sqliteDdlStatements(): string[] {
|
|
|
151
152
|
.filter((statement) => statement.length > 0);
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
/** §4.6.1: rowIds of one table bound to one scope value, budgeted. */
|
|
156
|
+
export const LIST_SCOPE_VALUE_ROWS_SQLITE_SQL =
|
|
157
|
+
'SELECT row_id FROM sync_row_scopes WHERE partition=? AND tbl=? AND var=? AND value=? ORDER BY row_id LIMIT ?';
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* §4.6.1 history scrub, bound by `eraseScopeValueHistoryParams`: upsert change
|
|
161
|
+
* records of the binding first, then the change scope entries whose commit
|
|
162
|
+
* no longer holds a change of the binding. Each returns one row per removed
|
|
163
|
+
* record (D1 batch results carry no reliable change count).
|
|
164
|
+
*/
|
|
165
|
+
export const ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL = [
|
|
166
|
+
`DELETE FROM sync_changes WHERE rowid IN (
|
|
167
|
+
SELECT c.rowid FROM sync_change_scopes s JOIN sync_changes c
|
|
168
|
+
ON c.partition=s.partition AND c.commit_seq=s.commit_seq AND c.tbl=s.tbl
|
|
169
|
+
WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4
|
|
170
|
+
AND s.commit_seq<=?5 AND c.op=1 AND json_extract(c.scopes, ?6)=s.value
|
|
171
|
+
LIMIT ?7) RETURNING 1 AS removed`,
|
|
172
|
+
`DELETE FROM sync_change_scopes WHERE rowid IN (
|
|
173
|
+
SELECT s.rowid FROM sync_change_scopes s
|
|
174
|
+
WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4
|
|
175
|
+
AND s.commit_seq<=?5 AND NOT EXISTS (
|
|
176
|
+
SELECT 1 FROM sync_changes c
|
|
177
|
+
WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq
|
|
178
|
+
AND c.tbl=s.tbl AND json_extract(c.scopes, ?6)=s.value)
|
|
179
|
+
LIMIT ?7) RETURNING 1 AS removed`,
|
|
180
|
+
] as const;
|
|
181
|
+
|
|
182
|
+
export function eraseScopeValueHistoryParams(
|
|
183
|
+
partition: string,
|
|
184
|
+
query: ScopeValueHistoryQuery,
|
|
185
|
+
): [string, string, string, string, number, string, number] {
|
|
186
|
+
return [
|
|
187
|
+
partition,
|
|
188
|
+
query.table,
|
|
189
|
+
query.variable,
|
|
190
|
+
query.value,
|
|
191
|
+
query.throughSeq,
|
|
192
|
+
`$.${JSON.stringify(query.variable)}`,
|
|
193
|
+
query.limit,
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
154
197
|
/** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
|
|
155
198
|
export function placeholders(count: number): string {
|
|
156
199
|
return new Array(count).fill('?').join(',');
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -56,6 +56,9 @@ import { matchesEffective } from './scopes';
|
|
|
56
56
|
import {
|
|
57
57
|
collectCommitWindowPage,
|
|
58
58
|
deserializePushResult,
|
|
59
|
+
ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL,
|
|
60
|
+
eraseScopeValueHistoryParams,
|
|
61
|
+
LIST_SCOPE_VALUE_ROWS_SQLITE_SQL,
|
|
59
62
|
placeholders,
|
|
60
63
|
SQLITE_DDL,
|
|
61
64
|
type SqliteChangeRecord,
|
|
@@ -100,6 +103,8 @@ import type {
|
|
|
100
103
|
RowScanQuery,
|
|
101
104
|
ScopeActivityQuery,
|
|
102
105
|
ScopeCommitActivity,
|
|
106
|
+
ScopeValueHistoryQuery,
|
|
107
|
+
ScopeValueQuery,
|
|
103
108
|
ServerStorage,
|
|
104
109
|
ServeGate,
|
|
105
110
|
StorageTransaction,
|
|
@@ -1925,6 +1930,40 @@ END`);
|
|
|
1925
1930
|
return rows.map((r) => r.blob_id);
|
|
1926
1931
|
}
|
|
1927
1932
|
|
|
1933
|
+
async listScopeValueRows(
|
|
1934
|
+
partition: string,
|
|
1935
|
+
query: ScopeValueQuery,
|
|
1936
|
+
): Promise<string[]> {
|
|
1937
|
+
return this.db
|
|
1938
|
+
.query<{ row_id: string }, [string, string, string, string, number]>(
|
|
1939
|
+
LIST_SCOPE_VALUE_ROWS_SQLITE_SQL,
|
|
1940
|
+
)
|
|
1941
|
+
.all(partition, query.table, query.variable, query.value, query.limit)
|
|
1942
|
+
.map((row) => row.row_id);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
async eraseScopeValueHistory(
|
|
1946
|
+
partition: string,
|
|
1947
|
+
query: ScopeValueHistoryQuery,
|
|
1948
|
+
): Promise<number> {
|
|
1949
|
+
const params = eraseScopeValueHistoryParams(partition, query);
|
|
1950
|
+
return this.#serializeWrite(() => {
|
|
1951
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
1952
|
+
try {
|
|
1953
|
+
let removed = 0;
|
|
1954
|
+
for (const sql of ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL) {
|
|
1955
|
+
removed += this.db.query(sql).all(...params).length;
|
|
1956
|
+
}
|
|
1957
|
+
this.db.exec('COMMIT');
|
|
1958
|
+
return removed;
|
|
1959
|
+
} catch (error) {
|
|
1960
|
+
rollbackAfterError(() => {
|
|
1961
|
+
this.db.exec('ROLLBACK');
|
|
1962
|
+
}, error);
|
|
1963
|
+
}
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1928
1967
|
// -- admin/console read surface --------------------------------------------
|
|
1929
1968
|
|
|
1930
1969
|
async listClientRecords(partition: string): Promise<ClientRecord[]> {
|