@syncular/server 0.38.0 → 0.39.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/content-encoding.d.ts +24 -23
- package/dist/content-encoding.js +77 -22
- package/dist/d1-storage.js +1 -1
- package/dist/postgres-storage.d.ts +1 -1
- package/dist/postgres-storage.js +210 -64
- package/dist/pull.d.ts +7 -3
- package/dist/pull.js +21 -12
- package/dist/relational-rows.d.ts +16 -10
- package/dist/relational-rows.js +48 -42
- package/dist/sqlite-storage.js +16 -3
- package/package.json +2 -2
- package/src/content-encoding.ts +99 -31
- package/src/d1-storage.ts +1 -1
- package/src/postgres-storage.ts +280 -93
- package/src/pull.ts +31 -18
- package/src/relational-rows.ts +53 -47
- package/src/sqlite-storage.ts +20 -3
package/src/relational-rows.ts
CHANGED
|
@@ -566,26 +566,31 @@ export function upsertValues(
|
|
|
566
566
|
];
|
|
567
567
|
}
|
|
568
568
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/** Upsert statement text (bind with `upsertValues`). */
|
|
569
|
+
/**
|
|
570
|
+
* Upsert statement text (bind with `upsertValues`). PostgreSQL takes `rows`
|
|
571
|
+
* rows in one statement, bound as consecutive `upsertValues` lists; their
|
|
572
|
+
* row ids must be distinct.
|
|
573
|
+
*/
|
|
577
574
|
export function upsertSql(
|
|
578
575
|
table: CompiledTable,
|
|
579
576
|
dialect: RelationalDialect,
|
|
577
|
+
rows = 1,
|
|
580
578
|
): string {
|
|
581
579
|
const names = tableColumnNames(table);
|
|
582
580
|
const quoted = names.map((name) => quoteIdent(name)).join(', ');
|
|
583
|
-
const values =
|
|
581
|
+
const values =
|
|
582
|
+
dialect === 'sqlite'
|
|
583
|
+
? `(${new Array(names.length).fill('?').join(',')})`
|
|
584
|
+
: Array.from(
|
|
585
|
+
{ length: rows },
|
|
586
|
+
(_, row) =>
|
|
587
|
+
`(${names.map((_, column) => `$${row * names.length + column + 1}`).join(',')})`,
|
|
588
|
+
).join(',');
|
|
584
589
|
const updates = names
|
|
585
590
|
.slice(2) // partition + row id are the conflict key
|
|
586
591
|
.map((name) => `${quoteIdent(name)}=excluded.${quoteIdent(name)}`)
|
|
587
592
|
.join(', ');
|
|
588
|
-
return `INSERT INTO ${quoteIdent(table.name)} (${quoted}) VALUES
|
|
593
|
+
return `INSERT INTO ${quoteIdent(table.name)} (${quoted}) VALUES ${values}
|
|
589
594
|
ON CONFLICT (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)}) DO UPDATE SET ${updates}`;
|
|
590
595
|
}
|
|
591
596
|
|
|
@@ -742,26 +747,40 @@ export function commitWindowPageSql(
|
|
|
742
747
|
|
|
743
748
|
/**
|
|
744
749
|
* PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
|
|
745
|
-
* page of every subscription scanning
|
|
746
|
-
* of `{i, tbl, var, vals, after, lim}` records; each record runs the
|
|
747
|
-
* index-first candidate subquery through a LATERAL join, so every
|
|
748
|
-
* subscription keeps its own keyset cursor and LIMIT.
|
|
749
|
-
*
|
|
750
|
+
* page of every subscription scanning any table in a pull. `$2` is a JSON
|
|
751
|
+
* array of `{i, tbl, var, vals, after, lim}` records; each record runs the
|
|
752
|
+
* same index-first candidate subquery through a LATERAL join, so every
|
|
753
|
+
* subscription keeps its own keyset cursor and LIMIT. The candidate side is
|
|
754
|
+
* table-agnostic (a spec names its table), so the statement joins the row
|
|
755
|
+
* table once per table present in the batch through a UNION ALL branch that
|
|
756
|
+
* keeps only its own candidates. Rows arrive ordered by (i, row_id).
|
|
750
757
|
*
|
|
751
758
|
* Params: [partition, specs (JSON text)]. The specs bind as text before
|
|
752
759
|
* the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
|
|
753
760
|
* parameter (Bun.sql) cannot turn the array into a JSON string.
|
|
754
761
|
*/
|
|
755
|
-
export function scanRowBatchSql(
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
762
|
+
export function scanRowBatchSql(tables: readonly CompiledTable[]): string {
|
|
763
|
+
// One branch per table, each keeping only its own candidates and selecting
|
|
764
|
+
// the same meta columns so the caller decodes one row shape.
|
|
765
|
+
const projection = tables
|
|
766
|
+
.map((table) => {
|
|
767
|
+
const quoted = quoteIdent(table.name);
|
|
768
|
+
return `SELECT c.sub 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
|
|
769
|
+
FROM cand c
|
|
770
|
+
LEFT JOIN ${quoted} r
|
|
771
|
+
ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
|
|
772
|
+
WHERE c.tbl='${table.name.replaceAll("'", "''")}'`;
|
|
773
|
+
})
|
|
774
|
+
.join('\n UNION ALL ');
|
|
775
|
+
return `WITH cand AS (
|
|
776
|
+
SELECT s.i AS sub, c.row_id AS row_id, s.tbl AS tbl
|
|
777
|
+
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
|
|
778
|
+
CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
|
|
779
|
+
WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
|
|
780
|
+
AND row_id>s.after
|
|
781
|
+
ORDER BY row_id LIMIT s.lim) c)
|
|
782
|
+
${projection}
|
|
783
|
+
ORDER BY sub, row_id`;
|
|
765
784
|
}
|
|
766
785
|
|
|
767
786
|
/**
|
|
@@ -1054,30 +1073,17 @@ export function selectRowsForRewriteSql(
|
|
|
1054
1073
|
}
|
|
1055
1074
|
|
|
1056
1075
|
/**
|
|
1057
|
-
*
|
|
1076
|
+
* SQLite/D1: rewrite one row during a migration: refresh the projection columns (when
|
|
1058
1077
|
* materialized) and the payload. Bind with `rewriteValues`.
|
|
1059
1078
|
*/
|
|
1060
|
-
export function rewriteRowSql(
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
)
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
sets.push(
|
|
1069
|
-
`${quoteIdent(column.name)}=${dialect === 'sqlite' ? '?' : `$${i}`}`,
|
|
1070
|
-
);
|
|
1071
|
-
i++;
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
sets.push(
|
|
1075
|
-
`${quoteIdent(SYNC_PAYLOAD_COLUMN)}=${dialect === 'sqlite' ? '?' : `$${i}`}`,
|
|
1076
|
-
);
|
|
1077
|
-
i++;
|
|
1078
|
-
const wherePartition = dialect === 'sqlite' ? '?' : `$${i}`;
|
|
1079
|
-
const whereRowId = dialect === 'sqlite' ? '?' : `$${i + 1}`;
|
|
1080
|
-
return `UPDATE ${quoteIdent(table.name)} SET ${sets.join(', ')} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${wherePartition} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${whereRowId}`;
|
|
1079
|
+
export function rewriteRowSql(table: CompiledTable): string {
|
|
1080
|
+
const sets = [
|
|
1081
|
+
...(table.materialize
|
|
1082
|
+
? table.columns.map((column) => `${quoteIdent(column.name)}=?`)
|
|
1083
|
+
: []),
|
|
1084
|
+
`${quoteIdent(SYNC_PAYLOAD_COLUMN)}=?`,
|
|
1085
|
+
];
|
|
1086
|
+
return `UPDATE ${quoteIdent(table.name)} SET ${sets.join(', ')} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=?`;
|
|
1081
1087
|
}
|
|
1082
1088
|
|
|
1083
1089
|
/** Bind values for `rewriteRowSql` from the (already migrated) payload. */
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -656,8 +656,6 @@ END`);
|
|
|
656
656
|
// rewrite stored rows (payload re-encode for layout changes, and/or
|
|
657
657
|
// projection backfill for flipped-on materialization). One
|
|
658
658
|
// transaction: a failed bump leaves no half-state.
|
|
659
|
-
const layouts = parseLayouts(marker?.layouts);
|
|
660
|
-
const retiredTables = retiredTableNames(schema, layouts);
|
|
661
659
|
const existing = new Map<string, ReadonlySet<string>>();
|
|
662
660
|
const existingIndexes = new Map<string, ReadonlyMap<string, string>>();
|
|
663
661
|
for (const table of schema.tables.values()) {
|
|
@@ -693,6 +691,25 @@ END`);
|
|
|
693
691
|
await this.#serializeWrite(() => {
|
|
694
692
|
this.db.exec('BEGIN IMMEDIATE');
|
|
695
693
|
try {
|
|
694
|
+
// Another process sharing the database may have bumped between the
|
|
695
|
+
// marker read above and the writer lock; the locked marker decides
|
|
696
|
+
// (SPEC "Schema upgrade writer fence").
|
|
697
|
+
const locked = this.db
|
|
698
|
+
.query<{ schema_version: number; layouts: string }, []>(
|
|
699
|
+
'SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1',
|
|
700
|
+
)
|
|
701
|
+
.get();
|
|
702
|
+
if (locked !== null && locked.schema_version > schema.version) {
|
|
703
|
+
throw new Error(
|
|
704
|
+
`stored schema version ${locked.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`,
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
if (locked !== null && locked.schema_version === schema.version) {
|
|
708
|
+
this.db.exec('COMMIT');
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const layouts = parseLayouts(locked?.layouts);
|
|
712
|
+
const retiredTables = retiredTableNames(schema, layouts);
|
|
696
713
|
for (const tableName of retiredTables) {
|
|
697
714
|
this.db
|
|
698
715
|
.query('DELETE FROM sync_row_scopes WHERE tbl=?')
|
|
@@ -880,7 +897,7 @@ END`);
|
|
|
880
897
|
oldLayout: readonly StoredColumnLayout[] | undefined,
|
|
881
898
|
): void {
|
|
882
899
|
const select = selectRowsForRewriteSql(table, 'sqlite');
|
|
883
|
-
const update = this.db.query(rewriteRowSql(table
|
|
900
|
+
const update = this.db.query(rewriteRowSql(table));
|
|
884
901
|
const BATCH = 500;
|
|
885
902
|
let afterPartition = '';
|
|
886
903
|
let afterRowId = '';
|