@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/dist/postgres-storage.js
CHANGED
|
@@ -6,7 +6,7 @@ import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQ
|
|
|
6
6
|
import { syncError } from './errors.js';
|
|
7
7
|
import { asBytes, asNumber, } from './pg-executor.js';
|
|
8
8
|
import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, LATEST_SCOPED_CHANGE_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowBatchSql, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, prefetchRowsSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
9
|
-
import { matchesEffective } from './scopes.js';
|
|
9
|
+
import { matchesEffective, sha256Hex } from './scopes.js';
|
|
10
10
|
import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
11
11
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
12
12
|
/**
|
|
@@ -794,23 +794,24 @@ export class PostgresServerStorage {
|
|
|
794
794
|
#schemaVersion;
|
|
795
795
|
/**
|
|
796
796
|
* Page reads waiting for the end of the current microtask turn, keyed by
|
|
797
|
-
* statement and
|
|
797
|
+
* statement and partition. A pull starts the window read (or first
|
|
798
798
|
* snapshot page) of every subscription before awaiting any of them, so the
|
|
799
|
-
* reads of
|
|
800
|
-
*
|
|
799
|
+
* window reads of all tables leave as ONE set-based statement (snapshot
|
|
800
|
+
* pages as one per table, whose SQL names the table) instead of one round
|
|
801
|
+
* trip per subscription or table (SYNCULAR-PULL-ROUNDTRIPS-001).
|
|
801
802
|
*/
|
|
802
803
|
#pageBatches = new Map();
|
|
803
804
|
constructor(exec) {
|
|
804
805
|
this.#exec = exec;
|
|
805
806
|
}
|
|
806
807
|
/**
|
|
807
|
-
* Queue one page read into the batch for (`sql`, `partition
|
|
808
|
-
*
|
|
809
|
-
*
|
|
810
|
-
*
|
|
808
|
+
* Queue one page read into the batch for (`sql`, `partition`). The batch
|
|
809
|
+
* runs as a single statement with the queued specs bound as a JSON array
|
|
810
|
+
* (`$2`); each spec gets its position as `i`, and the result rows are
|
|
811
|
+
* split back by their `sub` column.
|
|
811
812
|
*/
|
|
812
|
-
#batchedPage(sql, partition,
|
|
813
|
-
const key = `${sql}\u0000${partition}
|
|
813
|
+
#batchedPage(sql, partition, spec) {
|
|
814
|
+
const key = `${sql}\u0000${partition}`;
|
|
814
815
|
let batch = this.#pageBatches.get(key);
|
|
815
816
|
if (batch === undefined) {
|
|
816
817
|
const queued = [];
|
|
@@ -819,7 +820,7 @@ export class PostgresServerStorage {
|
|
|
819
820
|
queueMicrotask(() => {
|
|
820
821
|
this.#pageBatches.delete(key);
|
|
821
822
|
const specs = JSON.stringify(queued.map((entry, i) => ({ ...entry.spec, i })));
|
|
822
|
-
this.#exec.query(sql, [partition, specs
|
|
823
|
+
this.#exec.query(sql, [partition, specs]).then(({ rows }) => {
|
|
823
824
|
const grouped = queued.map(() => []);
|
|
824
825
|
for (const row of rows)
|
|
825
826
|
grouped[asNumber(row.sub)]?.push(row);
|
|
@@ -839,22 +840,27 @@ export class PostgresServerStorage {
|
|
|
839
840
|
});
|
|
840
841
|
});
|
|
841
842
|
}
|
|
842
|
-
/**
|
|
843
|
+
/**
|
|
844
|
+
* Apply the schema DDL (idempotent). Call once before use. The last
|
|
845
|
+
* statement records a fingerprint of the applied DDL as the comment of
|
|
846
|
+
* `sync_schema_meta`; a database that carries the current fingerprint
|
|
847
|
+
* costs one catalog read instead of the DDL round trips, so a new storage
|
|
848
|
+
* instance (a Worker isolate, a Durable Object wake) starts fast.
|
|
849
|
+
*/
|
|
843
850
|
async migrate() {
|
|
844
851
|
// Split on the statement boundary so drivers that reject multi-statement
|
|
845
852
|
// query strings (node-postgres extended protocol) still apply each DDL.
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
await this.#exec.query(`CREATE OR REPLACE FUNCTION syncular_writer_fence()
|
|
853
|
+
// The fence function is sent whole because its dollar-quoted body
|
|
854
|
+
// contains `;`.
|
|
855
|
+
const statements = [
|
|
856
|
+
...POSTGRES_DDL.split(';')
|
|
857
|
+
.map((s) => s.trim())
|
|
858
|
+
.filter((s) => s.length > 0),
|
|
859
|
+
'ALTER TABLE sync_commits ADD COLUMN IF NOT EXISTS writer_version BIGINT',
|
|
860
|
+
// Database-side fence: an old binary's INSERT never reaches JS. An
|
|
861
|
+
// absent `sync_writer_fence` row makes the EXISTS false, so a partition
|
|
862
|
+
// with no barrier behaves exactly as before.
|
|
863
|
+
`CREATE OR REPLACE FUNCTION syncular_writer_fence()
|
|
858
864
|
RETURNS trigger AS $$
|
|
859
865
|
BEGIN
|
|
860
866
|
IF EXISTS (
|
|
@@ -867,11 +873,31 @@ BEGIN
|
|
|
867
873
|
END IF;
|
|
868
874
|
RETURN NEW;
|
|
869
875
|
END;
|
|
870
|
-
$$ LANGUAGE plpgsql
|
|
871
|
-
|
|
872
|
-
|
|
876
|
+
$$ LANGUAGE plpgsql`,
|
|
877
|
+
'DROP TRIGGER IF EXISTS sync_commits_writer_fence ON sync_commits',
|
|
878
|
+
`CREATE TRIGGER sync_commits_writer_fence
|
|
873
879
|
BEFORE INSERT ON sync_commits
|
|
874
|
-
FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()
|
|
880
|
+
FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`,
|
|
881
|
+
SCHEMA_META_DDL_POSTGRES,
|
|
882
|
+
];
|
|
883
|
+
const fingerprint = `syncular.storage:${await sha256Hex(new TextEncoder().encode(statements.join('\u0000')))}`;
|
|
884
|
+
// `obj_description` and `to_regclass` of a missing table are NULL, so
|
|
885
|
+
// the probe never fails. A core table dropped out of band reapplies the
|
|
886
|
+
// DDL even under the current fingerprint.
|
|
887
|
+
const { rows } = await this.#exec.query(`SELECT obj_description(to_regclass('sync_schema_meta'), 'pg_class') AS storage,
|
|
888
|
+
(SELECT count(*)::int
|
|
889
|
+
FROM jsonb_array_elements_text($1::text::jsonb) AS core(name)
|
|
890
|
+
WHERE to_regclass(core.name) IS NULL) AS missing`, [
|
|
891
|
+
JSON.stringify(statements.flatMap((statement) => /^CREATE TABLE IF NOT EXISTS (\w+)/.exec(statement)?.[1] ?? [])),
|
|
892
|
+
]);
|
|
893
|
+
if (rows[0]?.storage === fingerprint && rows[0].missing === 0)
|
|
894
|
+
return;
|
|
895
|
+
for (const statement of statements) {
|
|
896
|
+
await this.#exec.query(statement);
|
|
897
|
+
}
|
|
898
|
+
// The fingerprint is lowercase hex, safe to inline (COMMENT takes no
|
|
899
|
+
// bound parameter).
|
|
900
|
+
await this.#exec.query(`COMMENT ON TABLE sync_schema_meta IS '${fingerprint}'`);
|
|
875
901
|
}
|
|
876
902
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
877
903
|
table(name) {
|
|
@@ -886,51 +912,71 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
886
912
|
if (this.#schemaVersion === schema.version)
|
|
887
913
|
return;
|
|
888
914
|
await this.migrate();
|
|
915
|
+
// One statement reads the marker, the incomplete checkpoints of this
|
|
916
|
+
// version and the physical columns of every app table. The aggregates
|
|
917
|
+
// leave as text so every driver hands back the same JSON strings.
|
|
918
|
+
// Columns resolve through `to_regclass`, which follows the session's
|
|
919
|
+
// `search_path` exactly as the unqualified row queries do:
|
|
920
|
+
// `current_schema()` names only the first existing schema, so it would
|
|
921
|
+
// reject a table that resolves later in the path.
|
|
922
|
+
const { rows: probe } = await this.#exec.query(`SELECT meta.schema_version AS schema_version, meta.layouts AS layouts,
|
|
923
|
+
(SELECT coalesce(json_agg(json_build_object(
|
|
924
|
+
'partition', checkpoint.partition,
|
|
925
|
+
'name', checkpoint.name)), '[]'::json)::text
|
|
926
|
+
FROM sync_backfill_checkpoints checkpoint
|
|
927
|
+
WHERE checkpoint.schema_version=$1
|
|
928
|
+
AND checkpoint.state<>'activated') AS incomplete,
|
|
929
|
+
(SELECT coalesce(json_agg(json_build_object(
|
|
930
|
+
'tbl', tables.name,
|
|
931
|
+
'name', attribute.attname,
|
|
932
|
+
'type', format_type(attribute.atttypid, attribute.atttypmod),
|
|
933
|
+
'not_null', attribute.attnotnull,
|
|
934
|
+
'pk', (SELECT key.position::int
|
|
935
|
+
FROM pg_index pk,
|
|
936
|
+
unnest(pk.indkey::int2[]) WITH ORDINALITY
|
|
937
|
+
AS key(attnum, position)
|
|
938
|
+
WHERE pk.indrelid = attribute.attrelid
|
|
939
|
+
AND pk.indisprimary
|
|
940
|
+
AND key.attnum = attribute.attnum))
|
|
941
|
+
ORDER BY tables.name, attribute.attnum), '[]'::json)::text
|
|
942
|
+
FROM jsonb_array_elements_text($2::text::jsonb) AS tables(name)
|
|
943
|
+
JOIN pg_attribute attribute
|
|
944
|
+
ON attribute.attrelid = to_regclass(tables.name)
|
|
945
|
+
WHERE attribute.attnum > 0
|
|
946
|
+
AND NOT attribute.attisdropped) AS columns
|
|
947
|
+
FROM (SELECT 1) AS one
|
|
948
|
+
LEFT JOIN sync_schema_meta meta ON meta.id=1`, [schema.version, JSON.stringify([...schema.tables.keys()])]);
|
|
949
|
+
const marker = probe[0];
|
|
950
|
+
if (marker === undefined) {
|
|
951
|
+
throw new Error('schema probe returned no row');
|
|
952
|
+
}
|
|
889
953
|
// The barrier reads stored state: a checkpoint at this schema version that
|
|
890
954
|
// is not activated and that this caller did not declare means this process
|
|
891
955
|
// must not serve. An omitted or empty declaration set declares nothing and
|
|
892
956
|
// is refused here, never treated as a bypass.
|
|
893
957
|
const declarations = checkpoints ?? [];
|
|
894
|
-
const incomplete =
|
|
895
|
-
|
|
896
|
-
if (incomplete.rows.some((row) => !declarations.some((declaration) => declaration.partition === row.partition &&
|
|
958
|
+
const incomplete = JSON.parse(marker.incomplete);
|
|
959
|
+
if (incomplete.some((row) => !declarations.some((declaration) => declaration.partition === row.partition &&
|
|
897
960
|
declaration.name === row.name))) {
|
|
898
961
|
throw new StorageQueryError('sync.storage.checkpoint_incomplete');
|
|
899
962
|
}
|
|
900
|
-
|
|
901
|
-
const marker = await this.#exec.query('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1');
|
|
902
|
-
const stored = marker.rows[0] === undefined
|
|
963
|
+
const stored = marker.schema_version === null || marker.schema_version === undefined
|
|
903
964
|
? undefined
|
|
904
|
-
: asNumber(marker.
|
|
965
|
+
: asNumber(marker.schema_version);
|
|
905
966
|
if (stored !== undefined && stored > schema.version) {
|
|
906
967
|
throw new Error(`stored schema version ${stored} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
|
|
907
968
|
}
|
|
908
969
|
if (stored !== undefined && stored === schema.version) {
|
|
909
|
-
assertStoredLayouts(schema, typeof marker.
|
|
910
|
-
|
|
911
|
-
: undefined);
|
|
912
|
-
// Resolve through `to_regclass`, which follows the session's
|
|
913
|
-
// `search_path` exactly as the unqualified row queries do:
|
|
914
|
-
// `current_schema()` names only the first existing schema, so it would
|
|
915
|
-
// reject a table that resolves later in the path.
|
|
970
|
+
assertStoredLayouts(schema, typeof marker.layouts === 'string' ? marker.layouts : undefined);
|
|
971
|
+
const columns = JSON.parse(marker.columns);
|
|
916
972
|
for (const table of schema.tables.values()) {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
WHERE pk.indrelid = attribute.attrelid AND pk.indisprimary
|
|
925
|
-
AND key.attnum = attribute.attnum) AS pk
|
|
926
|
-
FROM pg_attribute attribute
|
|
927
|
-
WHERE attribute.attrelid = to_regclass($1)
|
|
928
|
-
AND attribute.attnum > 0 AND NOT attribute.attisdropped`, [table.name]);
|
|
929
|
-
assertPhysicalColumns(table, rows.map((row) => ({
|
|
930
|
-
name: row.name,
|
|
931
|
-
type: row.type,
|
|
932
|
-
notNull: row.not_null,
|
|
933
|
-
primaryKeyPosition: row.pk ?? 0,
|
|
973
|
+
assertPhysicalColumns(table, columns
|
|
974
|
+
.filter((column) => column.tbl === table.name)
|
|
975
|
+
.map((column) => ({
|
|
976
|
+
name: column.name,
|
|
977
|
+
type: column.type,
|
|
978
|
+
notNull: column.not_null,
|
|
979
|
+
primaryKeyPosition: column.pk ?? 0,
|
|
934
980
|
})), 'postgres');
|
|
935
981
|
}
|
|
936
982
|
}
|
|
@@ -941,9 +987,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
941
987
|
// backfill for flipped-on materialization) — all inside one
|
|
942
988
|
// transaction (Postgres DDL is transactional — a failed bump leaves
|
|
943
989
|
// no half-state).
|
|
944
|
-
const layouts = parseLayouts(typeof marker.
|
|
945
|
-
? marker.rows[0].layouts
|
|
946
|
-
: undefined);
|
|
990
|
+
const layouts = parseLayouts(typeof marker.layouts === 'string' ? marker.layouts : undefined);
|
|
947
991
|
const retiredTables = retiredTableNames(schema, layouts);
|
|
948
992
|
await this.#exec.transaction(async (client) => {
|
|
949
993
|
// RFC 0007 migration barrier: `LOCK TABLE ... EXCLUSIVE` is the FIRST
|
|
@@ -1431,8 +1475,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1431
1475
|
: (query.scopeFilter[firstVariable] ?? []);
|
|
1432
1476
|
if (firstVariable === undefined || values.length === 0)
|
|
1433
1477
|
return 0;
|
|
1434
|
-
// Concurrent reads of
|
|
1435
|
-
const [row] = await this.#batchedPage(LATEST_SCOPED_CHANGE_BATCH_SQL, partition, query.table,
|
|
1478
|
+
// Concurrent reads of a pull share one statement across tables.
|
|
1479
|
+
const [row] = await this.#batchedPage(LATEST_SCOPED_CHANGE_BATCH_SQL, partition, { tbl: query.table, var: firstVariable, vals: values });
|
|
1436
1480
|
return row === undefined ? 0 : asNumber(row.seq);
|
|
1437
1481
|
}
|
|
1438
1482
|
async readCommitWindow(partition, query) {
|
|
@@ -1447,7 +1491,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1447
1491
|
// var, value, commit_seq) PK makes the candidate subquery an index range
|
|
1448
1492
|
// scan returning commit_seq already ascending (see postgres-explain
|
|
1449
1493
|
// .test.ts), LEFT JOINed to the commit meta + the table's changes. Pages
|
|
1450
|
-
// of concurrent window reads
|
|
1494
|
+
// of concurrent window reads share one statement across tables (see
|
|
1451
1495
|
// `COMMIT_WINDOW_BATCH_SQL`). Exact multi-variable verification against
|
|
1452
1496
|
// the stored scope map happens below.
|
|
1453
1497
|
const commits = [];
|
|
@@ -1455,7 +1499,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1455
1499
|
let afterSeq = query.afterSeq;
|
|
1456
1500
|
const batchSize = Math.max(64, query.limitChanges);
|
|
1457
1501
|
while (deliveredChanges < query.limitChanges) {
|
|
1458
|
-
const records = await this.#batchedPage(COMMIT_WINDOW_BATCH_SQL, partition,
|
|
1502
|
+
const records = await this.#batchedPage(COMMIT_WINDOW_BATCH_SQL, partition, {
|
|
1503
|
+
tbl: query.table,
|
|
1459
1504
|
var: firstVariable,
|
|
1460
1505
|
vals: firstValues,
|
|
1461
1506
|
after: afterSeq,
|
|
@@ -1535,7 +1580,8 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1535
1580
|
let afterRowId = query.afterRowId ?? '';
|
|
1536
1581
|
const batchSize = Math.max(64, query.limit);
|
|
1537
1582
|
while (rows.length < query.limit) {
|
|
1538
|
-
const records = await this.#batchedPage(sql, partition,
|
|
1583
|
+
const records = await this.#batchedPage(sql, partition, {
|
|
1584
|
+
tbl: query.table,
|
|
1539
1585
|
var: firstVariable,
|
|
1540
1586
|
vals: firstValues,
|
|
1541
1587
|
after: afterRowId,
|
|
@@ -1644,6 +1690,46 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1644
1690
|
const { rows } = await this.#exec.query('SELECT DISTINCT blob_id FROM sync_blob_refs WHERE partition=$1', [partition]);
|
|
1645
1691
|
return rows.map((r) => r.blob_id);
|
|
1646
1692
|
}
|
|
1693
|
+
async listScopeValueRows(partition, query) {
|
|
1694
|
+
const { rows } = await this.#exec.query(`SELECT row_id FROM sync_row_scopes
|
|
1695
|
+
WHERE partition=$1 AND tbl=$2 AND var=$3 AND value=$4
|
|
1696
|
+
ORDER BY row_id LIMIT $5`, [partition, query.table, query.variable, query.value, query.limit]);
|
|
1697
|
+
return rows.map((row) => row.row_id);
|
|
1698
|
+
}
|
|
1699
|
+
async eraseScopeValueHistory(partition, query) {
|
|
1700
|
+
const params = [
|
|
1701
|
+
partition,
|
|
1702
|
+
query.table,
|
|
1703
|
+
query.variable,
|
|
1704
|
+
query.value,
|
|
1705
|
+
query.throughSeq,
|
|
1706
|
+
query.limit,
|
|
1707
|
+
];
|
|
1708
|
+
return this.#exec.transaction(async (client) => {
|
|
1709
|
+
await lockPartitionOn(client, partition);
|
|
1710
|
+
// Upsert change records of the binding, then the change scope entries
|
|
1711
|
+
// whose commit no longer holds a change of the binding.
|
|
1712
|
+
const changes = await client.query(`DELETE FROM sync_changes c USING (
|
|
1713
|
+
SELECT c2.commit_seq, c2.idx FROM sync_change_scopes s
|
|
1714
|
+
JOIN sync_changes c2 ON c2.partition=s.partition
|
|
1715
|
+
AND c2.commit_seq=s.commit_seq AND c2.tbl=s.tbl
|
|
1716
|
+
WHERE s.partition=$1 AND s.tbl=$2 AND s.var=$3 AND s.value=$4
|
|
1717
|
+
AND s.commit_seq<=$5 AND c2.op=1 AND c2.scopes->>$3=s.value
|
|
1718
|
+
LIMIT $6) d
|
|
1719
|
+
WHERE c.partition=$1 AND c.commit_seq=d.commit_seq AND c.idx=d.idx`, params);
|
|
1720
|
+
const scopes = await client.query(`DELETE FROM sync_change_scopes cs USING (
|
|
1721
|
+
SELECT s.commit_seq FROM sync_change_scopes s
|
|
1722
|
+
WHERE s.partition=$1 AND s.tbl=$2 AND s.var=$3 AND s.value=$4
|
|
1723
|
+
AND s.commit_seq<=$5 AND NOT EXISTS (
|
|
1724
|
+
SELECT 1 FROM sync_changes c
|
|
1725
|
+
WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq
|
|
1726
|
+
AND c.tbl=s.tbl AND c.scopes->>$3=s.value)
|
|
1727
|
+
LIMIT $6) d
|
|
1728
|
+
WHERE cs.partition=$1 AND cs.tbl=$2 AND cs.var=$3 AND cs.value=$4
|
|
1729
|
+
AND cs.commit_seq=d.commit_seq`, params);
|
|
1730
|
+
return changes.rowCount + scopes.rowCount;
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1647
1733
|
// -- admin/console read surface --------------------------------------------
|
|
1648
1734
|
async listClientRecords(partition) {
|
|
1649
1735
|
const { rows } = await this.#exec.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC', [partition]);
|
package/dist/push.d.ts
CHANGED
|
@@ -17,11 +17,17 @@
|
|
|
17
17
|
* merge); a validator throw rejects the whole commit atomically with a
|
|
18
18
|
* host code.
|
|
19
19
|
*/
|
|
20
|
-
import { type PushCommitFrame, type PushOperation, type PushResultFrame } from '@syncular/core';
|
|
20
|
+
import { type PushCommitFrame, type PushOperation, type PushResultFrame, type RowValue } from '@syncular/core';
|
|
21
21
|
import type { SyncRequestContext } from './context.js';
|
|
22
|
-
import type { CompiledSchema } from './schema.js';
|
|
22
|
+
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
23
23
|
import type { ResolvedScopes } from './scopes.js';
|
|
24
24
|
import type { StorageTransaction, StoredCommit } from './storage.js';
|
|
25
|
+
/**
|
|
26
|
+
* Extract the blobIds a decoded row references through its `blob_ref`
|
|
27
|
+
* columns (§5.9.4), skipping NULLs. Malformed BlobRefs already failed at
|
|
28
|
+
* row-codec decode (§5.9.1), so `parseBlobRef` here is total.
|
|
29
|
+
*/
|
|
30
|
+
export declare function blobIdsInRow(table: CompiledTable, values: readonly RowValue[]): string[];
|
|
25
31
|
export interface ProcessedPushCommit {
|
|
26
32
|
readonly frame: PushResultFrame;
|
|
27
33
|
/** True when this request observed an already-recorded idempotency outcome. */
|
package/dist/push.js
CHANGED
|
@@ -35,7 +35,7 @@ import { CommitValidationRejection, toValidateRow, ValidationRejection, } from '
|
|
|
35
35
|
* columns (§5.9.4), skipping NULLs. Malformed BlobRefs already failed at
|
|
36
36
|
* row-codec decode (§5.9.1), so `parseBlobRef` here is total.
|
|
37
37
|
*/
|
|
38
|
-
function blobIdsInRow(table, values) {
|
|
38
|
+
export function blobIdsInRow(table, values) {
|
|
39
39
|
const ids = [];
|
|
40
40
|
for (const index of table.blobRefColumnIndices) {
|
|
41
41
|
const value = values[index];
|
|
@@ -228,27 +228,27 @@ export declare function commitWindowPageSql(valueCount: number, dialect: Relatio
|
|
|
228
228
|
/**
|
|
229
229
|
* PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
|
|
230
230
|
* page of every subscription scanning `table` in a pull. `$2` is a JSON array
|
|
231
|
-
* of `{i, var, vals, after, lim}` records; each record runs the same
|
|
231
|
+
* of `{i, tbl, var, vals, after, lim}` records; each record runs the same
|
|
232
232
|
* index-first candidate subquery through a LATERAL join, so every
|
|
233
233
|
* subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
|
|
234
234
|
* by (i, row_id).
|
|
235
235
|
*
|
|
236
|
-
* Params: [partition, specs (JSON text)
|
|
236
|
+
* Params: [partition, specs (JSON text)]. The specs bind as text before
|
|
237
237
|
* the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
|
|
238
238
|
* parameter (Bun.sql) cannot turn the array into a JSON string.
|
|
239
239
|
*/
|
|
240
240
|
export declare function scanRowBatchSql(table: CompiledTable): string;
|
|
241
241
|
/**
|
|
242
242
|
* PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
|
|
243
|
-
* the window page of every subscription
|
|
244
|
-
* JSON array of `{i, var, vals, after, through, lim}` records, each
|
|
245
|
-
* the same index-first candidate subquery through a LATERAL join.
|
|
246
|
-
* arrive ordered by (i, commit_seq, idx).
|
|
243
|
+
* the window page of every subscription in a pull, across all tables. `$2`
|
|
244
|
+
* is a JSON array of `{i, tbl, var, vals, after, through, lim}` records, each
|
|
245
|
+
* running the same index-first candidate subquery through a LATERAL join.
|
|
246
|
+
* Rows arrive ordered by (i, commit_seq, idx).
|
|
247
247
|
*
|
|
248
|
-
* Params: [partition, specs (JSON text)
|
|
248
|
+
* Params: [partition, specs (JSON text)], the specs bound as text like
|
|
249
249
|
* `scanRowBatchSql`.
|
|
250
250
|
*/
|
|
251
|
-
export declare 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,\n 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\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)\n CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes\n WHERE partition=$1 AND tbl
|
|
251
|
+
export declare 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,\n 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\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after bigint, through bigint, lim int)\n CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes\n WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)\n AND commit_seq>s.after AND commit_seq<=s.through\n ORDER BY commit_seq LIMIT s.lim) c\n LEFT JOIN sync_commits m\n ON m.partition=$1 AND m.commit_seq=c.commit_seq\n LEFT JOIN sync_changes ch\n ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=s.tbl\n ORDER BY s.i, c.commit_seq, ch.idx";
|
|
252
252
|
/**
|
|
253
253
|
* PostgreSQL push read-ahead: every requested row id with its stored row
|
|
254
254
|
* (NULL columns when absent, `selectRowSql` shape) and its §5 delete
|
|
@@ -267,10 +267,10 @@ export declare function prefetchRowsSql(table: CompiledTable): string;
|
|
|
267
267
|
export declare const LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL = "SELECT max((SELECT max(commit_seq) FROM sync_change_scopes\n WHERE partition=? AND tbl=? AND var=? AND value=v.value)) AS seq\n FROM json_each(?) AS v";
|
|
268
268
|
/**
|
|
269
269
|
* PostgreSQL set-based form of `LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL` for the
|
|
270
|
-
* subscriptions of
|
|
271
|
-
* `{i, var, vals}` records. Params: [partition, specs (JSON text)
|
|
270
|
+
* subscriptions of a pull, across all tables. `$2` is a JSON array of
|
|
271
|
+
* `{i, tbl, var, vals}` records. Params: [partition, specs (JSON text)].
|
|
272
272
|
*/
|
|
273
|
-
export declare const LATEST_SCOPED_CHANGE_BATCH_SQL = "SELECT s.i AS sub,\n (SELECT max(latest.seq) FROM (\n SELECT (SELECT max(commit_seq) FROM sync_change_scopes\n WHERE partition=$1 AND tbl
|
|
273
|
+
export declare const LATEST_SCOPED_CHANGE_BATCH_SQL = "SELECT s.i AS sub,\n (SELECT max(latest.seq) FROM (\n SELECT (SELECT max(commit_seq) FROM sync_change_scopes\n WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=v.value) AS seq\n FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals jsonb)\n ORDER BY s.i";
|
|
274
274
|
/** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
|
|
275
275
|
export declare function selectRowScopesSql(table: CompiledTable, dialect: RelationalDialect): string;
|
|
276
276
|
/** DELETE one stored row. Params: [partition, rowId]. */
|
package/dist/relational-rows.js
CHANGED
|
@@ -551,20 +551,20 @@ export function commitWindowPageSql(valueCount, dialect) {
|
|
|
551
551
|
/**
|
|
552
552
|
* PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
|
|
553
553
|
* page of every subscription scanning `table` in a pull. `$2` is a JSON array
|
|
554
|
-
* of `{i, var, vals, after, lim}` records; each record runs the same
|
|
554
|
+
* of `{i, tbl, var, vals, after, lim}` records; each record runs the same
|
|
555
555
|
* index-first candidate subquery through a LATERAL join, so every
|
|
556
556
|
* subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
|
|
557
557
|
* by (i, row_id).
|
|
558
558
|
*
|
|
559
|
-
* Params: [partition, specs (JSON text)
|
|
559
|
+
* Params: [partition, specs (JSON text)]. The specs bind as text before
|
|
560
560
|
* the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
|
|
561
561
|
* parameter (Bun.sql) cannot turn the array into a JSON string.
|
|
562
562
|
*/
|
|
563
563
|
export function scanRowBatchSql(table) {
|
|
564
564
|
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
|
|
565
|
-
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after text, lim int)
|
|
565
|
+
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
|
|
566
566
|
CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
|
|
567
|
-
WHERE partition=$1 AND tbl
|
|
567
|
+
WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
|
|
568
568
|
AND row_id>s.after
|
|
569
569
|
ORDER BY row_id LIMIT s.lim) c
|
|
570
570
|
LEFT JOIN ${quoteIdent(table.name)} r
|
|
@@ -573,25 +573,25 @@ export function scanRowBatchSql(table) {
|
|
|
573
573
|
}
|
|
574
574
|
/**
|
|
575
575
|
* PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
|
|
576
|
-
* the window page of every subscription
|
|
577
|
-
* JSON array of `{i, var, vals, after, through, lim}` records, each
|
|
578
|
-
* the same index-first candidate subquery through a LATERAL join.
|
|
579
|
-
* arrive ordered by (i, commit_seq, idx).
|
|
576
|
+
* the window page of every subscription in a pull, across all tables. `$2`
|
|
577
|
+
* is a JSON array of `{i, tbl, var, vals, after, through, lim}` records, each
|
|
578
|
+
* running the same index-first candidate subquery through a LATERAL join.
|
|
579
|
+
* Rows arrive ordered by (i, commit_seq, idx).
|
|
580
580
|
*
|
|
581
|
-
* Params: [partition, specs (JSON text)
|
|
581
|
+
* Params: [partition, specs (JSON text)], the specs bound as text like
|
|
582
582
|
* `scanRowBatchSql`.
|
|
583
583
|
*/
|
|
584
584
|
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,
|
|
585
585
|
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
|
|
586
|
-
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)
|
|
586
|
+
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after bigint, through bigint, lim int)
|
|
587
587
|
CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes
|
|
588
|
-
WHERE partition=$1 AND tbl
|
|
588
|
+
WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
|
|
589
589
|
AND commit_seq>s.after AND commit_seq<=s.through
|
|
590
590
|
ORDER BY commit_seq LIMIT s.lim) c
|
|
591
591
|
LEFT JOIN sync_commits m
|
|
592
592
|
ON m.partition=$1 AND m.commit_seq=c.commit_seq
|
|
593
593
|
LEFT JOIN sync_changes ch
|
|
594
|
-
ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl
|
|
594
|
+
ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=s.tbl
|
|
595
595
|
ORDER BY s.i, c.commit_seq, ch.idx`;
|
|
596
596
|
/**
|
|
597
597
|
* PostgreSQL push read-ahead: every requested row id with its stored row
|
|
@@ -620,15 +620,15 @@ export const LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL = `SELECT max((SELECT max(commi
|
|
|
620
620
|
FROM json_each(?) AS v`;
|
|
621
621
|
/**
|
|
622
622
|
* PostgreSQL set-based form of `LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL` for the
|
|
623
|
-
* subscriptions of
|
|
624
|
-
* `{i, var, vals}` records. Params: [partition, specs (JSON text)
|
|
623
|
+
* subscriptions of a pull, across all tables. `$2` is a JSON array of
|
|
624
|
+
* `{i, tbl, var, vals}` records. Params: [partition, specs (JSON text)].
|
|
625
625
|
*/
|
|
626
626
|
export const LATEST_SCOPED_CHANGE_BATCH_SQL = `SELECT s.i AS sub,
|
|
627
627
|
(SELECT max(latest.seq) FROM (
|
|
628
628
|
SELECT (SELECT max(commit_seq) FROM sync_change_scopes
|
|
629
|
-
WHERE partition=$1 AND tbl
|
|
629
|
+
WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=v.value) AS seq
|
|
630
630
|
FROM jsonb_array_elements_text(s.vals) AS v(value)) AS latest) AS seq
|
|
631
|
-
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals jsonb)
|
|
631
|
+
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals jsonb)
|
|
632
632
|
ORDER BY s.i`;
|
|
633
633
|
/** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
|
|
634
634
|
export function selectRowScopesSql(table, dialect) {
|
|
@@ -7,6 +7,7 @@ export declare class SqliteBlobStore implements BlobStore {
|
|
|
7
7
|
readonly db: SqliteDatabase;
|
|
8
8
|
constructor(db?: SqliteDatabase | string);
|
|
9
9
|
put(partition: string, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): Promise<BlobRecord>;
|
|
10
|
+
delete(partition: string, blobId: string): Promise<void>;
|
|
10
11
|
has(partition: string, blobId: string): Promise<boolean>;
|
|
11
12
|
get(partition: string, blobId: string): Promise<{
|
|
12
13
|
record: BlobRecord;
|
|
@@ -17,8 +17,15 @@ export class SqliteBlobStore {
|
|
|
17
17
|
}
|
|
18
18
|
async put(partition, blobId, bytes, nowMs, mediaType) {
|
|
19
19
|
const existing = await this.get(partition, blobId);
|
|
20
|
-
if (existing !== undefined)
|
|
21
|
-
|
|
20
|
+
if (existing !== undefined) {
|
|
21
|
+
if (existing.record.mediaType !== undefined || mediaType === undefined)
|
|
22
|
+
return existing.record;
|
|
23
|
+
// A re-upload names the media type an earlier upload left out.
|
|
24
|
+
this.db
|
|
25
|
+
.query('UPDATE sync_blobs SET media_type=? WHERE partition=? AND blob_id=? AND media_type IS NULL')
|
|
26
|
+
.run(mediaType, partition, blobId);
|
|
27
|
+
return { ...existing.record, mediaType };
|
|
28
|
+
}
|
|
22
29
|
const record = {
|
|
23
30
|
blobId,
|
|
24
31
|
partition,
|
|
@@ -33,6 +40,11 @@ export class SqliteBlobStore {
|
|
|
33
40
|
.run(partition, blobId, bytes.length, mediaType ?? null, nowMs, bytes);
|
|
34
41
|
return record;
|
|
35
42
|
}
|
|
43
|
+
async delete(partition, blobId) {
|
|
44
|
+
this.db
|
|
45
|
+
.query('DELETE FROM sync_blobs WHERE partition=? AND blob_id=?')
|
|
46
|
+
.run(partition, blobId);
|
|
47
|
+
}
|
|
36
48
|
async has(partition, blobId) {
|
|
37
49
|
const row = this.db
|
|
38
50
|
.query('SELECT 1 AS n FROM sync_blobs WHERE partition=? AND blob_id=?')
|
package/dist/sqlite-dialect.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* behavior is held key-for-key regardless.
|
|
17
17
|
*/
|
|
18
18
|
import type { ScopeMap } from '@syncular/core';
|
|
19
|
-
import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
|
|
19
|
+
import type { ScopeValueHistoryQuery, StoredChange, StoredCommit, StoredPushResult, StoredRow } from './storage.js';
|
|
20
20
|
/**
|
|
21
21
|
* Schema DDL, one statement per `;`-delimited chunk. Native SQLite applies
|
|
22
22
|
* the whole string via `db.exec(SQLITE_DDL)`; D1 applies each statement
|
|
@@ -39,6 +39,16 @@ import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './
|
|
|
39
39
|
export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n writer_version INTEGER,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(\n partition TEXT NOT NULL, name TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),\n watermark INTEGER NOT NULL DEFAULT 0,\n owner_epoch INTEGER NOT NULL DEFAULT 0,\n observed_rows INTEGER NOT NULL DEFAULT 0,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, name)\n);\nCREATE TABLE IF NOT EXISTS sync_writer_fence(\n partition TEXT PRIMARY KEY,\n required_writer_version INTEGER NOT NULL\n);\n";
|
|
40
40
|
/** Split the DDL into individual statements (D1 applies them one by one). */
|
|
41
41
|
export declare function sqliteDdlStatements(): string[];
|
|
42
|
+
/** §4.6.1: rowIds of one table bound to one scope value, budgeted. */
|
|
43
|
+
export declare const LIST_SCOPE_VALUE_ROWS_SQLITE_SQL = "SELECT row_id FROM sync_row_scopes WHERE partition=? AND tbl=? AND var=? AND value=? ORDER BY row_id LIMIT ?";
|
|
44
|
+
/**
|
|
45
|
+
* §4.6.1 history scrub, bound by `eraseScopeValueHistoryParams`: upsert change
|
|
46
|
+
* records of the binding first, then the change scope entries whose commit
|
|
47
|
+
* no longer holds a change of the binding. Each returns one row per removed
|
|
48
|
+
* record (D1 batch results carry no reliable change count).
|
|
49
|
+
*/
|
|
50
|
+
export declare const ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL: readonly ["DELETE FROM sync_changes WHERE rowid IN (\n SELECT c.rowid FROM sync_change_scopes s JOIN sync_changes c\n ON c.partition=s.partition AND c.commit_seq=s.commit_seq AND c.tbl=s.tbl\n WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4\n AND s.commit_seq<=?5 AND c.op=1 AND json_extract(c.scopes, ?6)=s.value\n LIMIT ?7) RETURNING 1 AS removed", "DELETE FROM sync_change_scopes WHERE rowid IN (\n SELECT s.rowid FROM sync_change_scopes s\n WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4\n AND s.commit_seq<=?5 AND NOT EXISTS (\n SELECT 1 FROM sync_changes c\n WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq\n AND c.tbl=s.tbl AND json_extract(c.scopes, ?6)=s.value)\n LIMIT ?7) RETURNING 1 AS removed"];
|
|
51
|
+
export declare function eraseScopeValueHistoryParams(partition: string, query: ScopeValueHistoryQuery): [string, string, string, string, number, string, number];
|
|
42
52
|
/** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
|
|
43
53
|
export declare function placeholders(count: number): string;
|
|
44
54
|
/** Serialize a push result to the JSON `TEXT` stored in `sync_push_results`. */
|
package/dist/sqlite-dialect.js
CHANGED
|
@@ -124,6 +124,41 @@ export function sqliteDdlStatements() {
|
|
|
124
124
|
.map((statement) => statement.trim())
|
|
125
125
|
.filter((statement) => statement.length > 0);
|
|
126
126
|
}
|
|
127
|
+
/** §4.6.1: rowIds of one table bound to one scope value, budgeted. */
|
|
128
|
+
export const LIST_SCOPE_VALUE_ROWS_SQLITE_SQL = 'SELECT row_id FROM sync_row_scopes WHERE partition=? AND tbl=? AND var=? AND value=? ORDER BY row_id LIMIT ?';
|
|
129
|
+
/**
|
|
130
|
+
* §4.6.1 history scrub, bound by `eraseScopeValueHistoryParams`: upsert change
|
|
131
|
+
* records of the binding first, then the change scope entries whose commit
|
|
132
|
+
* no longer holds a change of the binding. Each returns one row per removed
|
|
133
|
+
* record (D1 batch results carry no reliable change count).
|
|
134
|
+
*/
|
|
135
|
+
export const ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL = [
|
|
136
|
+
`DELETE FROM sync_changes WHERE rowid IN (
|
|
137
|
+
SELECT c.rowid FROM sync_change_scopes s JOIN sync_changes c
|
|
138
|
+
ON c.partition=s.partition AND c.commit_seq=s.commit_seq AND c.tbl=s.tbl
|
|
139
|
+
WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4
|
|
140
|
+
AND s.commit_seq<=?5 AND c.op=1 AND json_extract(c.scopes, ?6)=s.value
|
|
141
|
+
LIMIT ?7) RETURNING 1 AS removed`,
|
|
142
|
+
`DELETE FROM sync_change_scopes WHERE rowid IN (
|
|
143
|
+
SELECT s.rowid FROM sync_change_scopes s
|
|
144
|
+
WHERE s.partition=?1 AND s.tbl=?2 AND s.var=?3 AND s.value=?4
|
|
145
|
+
AND s.commit_seq<=?5 AND NOT EXISTS (
|
|
146
|
+
SELECT 1 FROM sync_changes c
|
|
147
|
+
WHERE c.partition=s.partition AND c.commit_seq=s.commit_seq
|
|
148
|
+
AND c.tbl=s.tbl AND json_extract(c.scopes, ?6)=s.value)
|
|
149
|
+
LIMIT ?7) RETURNING 1 AS removed`,
|
|
150
|
+
];
|
|
151
|
+
export function eraseScopeValueHistoryParams(partition, query) {
|
|
152
|
+
return [
|
|
153
|
+
partition,
|
|
154
|
+
query.table,
|
|
155
|
+
query.variable,
|
|
156
|
+
query.value,
|
|
157
|
+
query.throughSeq,
|
|
158
|
+
`$.${JSON.stringify(query.variable)}`,
|
|
159
|
+
query.limit,
|
|
160
|
+
];
|
|
161
|
+
}
|
|
127
162
|
/** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
|
|
128
163
|
export function placeholders(count) {
|
|
129
164
|
return new Array(count).fill('?').join(',');
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
|
2
2
|
import type { ScopeMap } from '@syncular/core';
|
|
3
3
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
4
4
|
import { type SqliteDatabase } from './sqlite-driver.js';
|
|
5
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
5
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientPushResult, ClientRecord, CommitDetail, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowChangeRecord, RowChangesQuery, RowListQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ScopeValueHistoryQuery, ScopeValueQuery, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
6
6
|
export declare class SqliteServerStorage implements ServerStorage {
|
|
7
7
|
#private;
|
|
8
8
|
readonly db: SqliteDatabase;
|
|
@@ -67,6 +67,8 @@ export declare class SqliteServerStorage implements ServerStorage {
|
|
|
67
67
|
readonly scopes: Record<string, string>;
|
|
68
68
|
}[]>;
|
|
69
69
|
listReferencedBlobIds(partition: string): Promise<string[]>;
|
|
70
|
+
listScopeValueRows(partition: string, query: ScopeValueQuery): Promise<string[]>;
|
|
71
|
+
eraseScopeValueHistory(partition: string, query: ScopeValueHistoryQuery): Promise<number>;
|
|
70
72
|
listClientRecords(partition: string): Promise<ClientRecord[]>;
|
|
71
73
|
listCommitMetadata(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
|
|
72
74
|
getCommit(partition: string, commitSeq: number): Promise<CommitDetail | undefined>;
|