@syncular/server 0.30.2 → 0.30.4
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/postgres-storage.js +48 -26
- package/dist/sqlite-storage.js +11 -0
- package/package.json +2 -2
- package/src/postgres-storage.ts +66 -36
- package/src/sqlite-storage.ts +13 -0
package/dist/postgres-storage.js
CHANGED
|
@@ -686,34 +686,41 @@ class PostgresTransaction {
|
|
|
686
686
|
const commitSeq = asNumber(rows[0]?.commit_seq);
|
|
687
687
|
if (commit.changes.length === 0)
|
|
688
688
|
return commitSeq;
|
|
689
|
-
// One
|
|
690
|
-
// entries, and the §5 delete tombstones
|
|
689
|
+
// One writable-CTE chain per chunk of changes: the changes, their
|
|
690
|
+
// inverted scope entries, and the §5 delete tombstones. A statement binds
|
|
691
|
+
// at most a 16-bit count of parameters, so a chunk carries
|
|
692
|
+
// PG_CHANGES_PER_STATEMENT changes; every chunk runs in the
|
|
693
|
+
// commit's transaction under the same commit_seq with its global idx.
|
|
691
694
|
// Serialized scopes bind as text before parsing JSONB; drivers that
|
|
692
695
|
// encode JSONB parameters would otherwise store the string as a scalar.
|
|
693
|
-
// A commit can delete one row twice, so
|
|
694
|
-
// row per key
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
696
|
+
// A commit can delete one row twice, so a chunk's tombstone insert keeps
|
|
697
|
+
// one row per key and a later chunk updates it to the same commit_seq.
|
|
698
|
+
for (let first = 0; first < commit.changes.length; first += PG_CHANGES_PER_STATEMENT) {
|
|
699
|
+
const params = [p, commitSeq];
|
|
700
|
+
const values = commit.changes
|
|
701
|
+
.slice(first, first + PG_CHANGES_PER_STATEMENT)
|
|
702
|
+
.map((change, offset) => {
|
|
703
|
+
const base = params.length;
|
|
704
|
+
params.push(first + offset, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
|
|
705
|
+
return `($1,$2::bigint,$${base + 1}::int,$${base + 2},$${base + 3},$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
|
|
706
|
+
});
|
|
707
|
+
await q.query(`WITH inserted AS (
|
|
708
|
+
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
709
|
+
VALUES ${values.join(',')}
|
|
710
|
+
RETURNING partition, tbl, row_id, op, commit_seq, scopes
|
|
711
|
+
),
|
|
712
|
+
scoped AS (
|
|
713
|
+
INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
714
|
+
SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
|
|
715
|
+
FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
|
|
716
|
+
ON CONFLICT DO NOTHING
|
|
717
|
+
)
|
|
718
|
+
INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
|
|
719
|
+
SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
|
|
720
|
+
FROM inserted WHERE op = 2
|
|
721
|
+
ON CONFLICT (partition, tbl, row_id)
|
|
722
|
+
DO UPDATE SET commit_seq = excluded.commit_seq`, params);
|
|
723
|
+
}
|
|
717
724
|
return commitSeq;
|
|
718
725
|
}
|
|
719
726
|
async putPushResult(clientId, clientCommitId, result) {
|
|
@@ -776,6 +783,10 @@ class RollbackSignal extends Error {
|
|
|
776
783
|
this.name = 'RollbackSignal';
|
|
777
784
|
}
|
|
778
785
|
}
|
|
786
|
+
/** Changes per sync_changes statement: 2 + 7 × 4,096 = 28,674 bound
|
|
787
|
+
* parameters. The Bind message counts parameters in 16 bits; PostgreSQL reads
|
|
788
|
+
* up to 65,535, and PGlite 0.5 corrupts its state above 32,767. */
|
|
789
|
+
const PG_CHANGES_PER_STATEMENT = 4096;
|
|
779
790
|
export class PostgresServerStorage {
|
|
780
791
|
#exec;
|
|
781
792
|
/** Set by `ensureSchema`: app-table lookup for the relational row store. */
|
|
@@ -999,6 +1010,17 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
999
1010
|
await client.query(`INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, $1, $2)
|
|
1000
1011
|
ON CONFLICT (id) DO UPDATE
|
|
1001
1012
|
SET schema_version=EXCLUDED.schema_version, layouts=EXCLUDED.layouts`, [schema.version, layoutsOf(schema)]);
|
|
1013
|
+
// A writer of the previous schema still running against this
|
|
1014
|
+
// database would append payloads in the previous layout. Its push
|
|
1015
|
+
// is refused by the serve gate, but a host's server-side
|
|
1016
|
+
// `begin()`/`appendCommit` reads no gate, so the bump raises the
|
|
1017
|
+
// writer fence of every partition to the new version.
|
|
1018
|
+
await client.query(`INSERT INTO sync_writer_fence(partition, required_writer_version)
|
|
1019
|
+
SELECT partition, $1 FROM sync_partitions
|
|
1020
|
+
ON CONFLICT (partition) DO UPDATE SET
|
|
1021
|
+
required_writer_version=GREATEST(
|
|
1022
|
+
sync_writer_fence.required_writer_version,
|
|
1023
|
+
EXCLUDED.required_writer_version)`, [schema.version]);
|
|
1002
1024
|
// The host's declared checkpoints install in the schema-bump
|
|
1003
1025
|
// transaction, so no observer sees the bumped marker without the
|
|
1004
1026
|
// fence, or the fence without its declaration row.
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -412,6 +412,17 @@ END`);
|
|
|
412
412
|
this.db
|
|
413
413
|
.query('INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts')
|
|
414
414
|
.run(schema.version, layoutsOf(schema));
|
|
415
|
+
// A writer of the previous schema would append payloads in the
|
|
416
|
+
// previous layout; the bump fences every partition at the new
|
|
417
|
+
// version (see PostgresServerStorage.ensureSchema).
|
|
418
|
+
this.db
|
|
419
|
+
.query(`INSERT INTO sync_writer_fence(partition, required_writer_version)
|
|
420
|
+
SELECT partition, ? FROM sync_partitions WHERE true
|
|
421
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
422
|
+
required_writer_version=max(
|
|
423
|
+
sync_writer_fence.required_writer_version,
|
|
424
|
+
excluded.required_writer_version)`)
|
|
425
|
+
.run(schema.version);
|
|
415
426
|
// The host's declared checkpoints install in the schema-bump
|
|
416
427
|
// transaction, so no observer sees the bumped marker without the
|
|
417
428
|
// fence, or the fence without its declaration row.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.4",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.30.
|
|
71
|
+
"@syncular/core": "0.30.4"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/postgres-storage.ts
CHANGED
|
@@ -1117,45 +1117,56 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
1117
1117
|
);
|
|
1118
1118
|
const commitSeq = asNumber(rows[0]?.commit_seq);
|
|
1119
1119
|
if (commit.changes.length === 0) return commitSeq;
|
|
1120
|
-
// One
|
|
1121
|
-
// entries, and the §5 delete tombstones
|
|
1120
|
+
// One writable-CTE chain per chunk of changes: the changes, their
|
|
1121
|
+
// inverted scope entries, and the §5 delete tombstones. A statement binds
|
|
1122
|
+
// at most a 16-bit count of parameters, so a chunk carries
|
|
1123
|
+
// PG_CHANGES_PER_STATEMENT changes; every chunk runs in the
|
|
1124
|
+
// commit's transaction under the same commit_seq with its global idx.
|
|
1122
1125
|
// Serialized scopes bind as text before parsing JSONB; drivers that
|
|
1123
1126
|
// encode JSONB parameters would otherwise store the string as a scalar.
|
|
1124
|
-
// A commit can delete one row twice, so
|
|
1125
|
-
// row per key
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
change
|
|
1135
|
-
|
|
1136
|
-
|
|
1127
|
+
// A commit can delete one row twice, so a chunk's tombstone insert keeps
|
|
1128
|
+
// one row per key and a later chunk updates it to the same commit_seq.
|
|
1129
|
+
for (
|
|
1130
|
+
let first = 0;
|
|
1131
|
+
first < commit.changes.length;
|
|
1132
|
+
first += PG_CHANGES_PER_STATEMENT
|
|
1133
|
+
) {
|
|
1134
|
+
const params: unknown[] = [p, commitSeq];
|
|
1135
|
+
const values = commit.changes
|
|
1136
|
+
.slice(first, first + PG_CHANGES_PER_STATEMENT)
|
|
1137
|
+
.map((change, offset) => {
|
|
1138
|
+
const base = params.length;
|
|
1139
|
+
params.push(
|
|
1140
|
+
first + offset,
|
|
1141
|
+
change.table,
|
|
1142
|
+
change.rowId,
|
|
1143
|
+
change.op === 'upsert' ? 1 : 2,
|
|
1144
|
+
change.rowVersion ?? null,
|
|
1145
|
+
JSON.stringify(change.scopes),
|
|
1146
|
+
change.payload ?? null,
|
|
1147
|
+
);
|
|
1148
|
+
return `($1,$2::bigint,$${base + 1}::int,$${base + 2},$${base + 3},$${base + 4}::smallint,$${base + 5}::bigint,$${base + 6}::text::jsonb,$${base + 7}::bytea)`;
|
|
1149
|
+
});
|
|
1150
|
+
await q.query(
|
|
1151
|
+
`WITH inserted AS (
|
|
1152
|
+
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
1153
|
+
VALUES ${values.join(',')}
|
|
1154
|
+
RETURNING partition, tbl, row_id, op, commit_seq, scopes
|
|
1155
|
+
),
|
|
1156
|
+
scoped AS (
|
|
1157
|
+
INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
1158
|
+
SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
|
|
1159
|
+
FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
|
|
1160
|
+
ON CONFLICT DO NOTHING
|
|
1161
|
+
)
|
|
1162
|
+
INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
|
|
1163
|
+
SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
|
|
1164
|
+
FROM inserted WHERE op = 2
|
|
1165
|
+
ON CONFLICT (partition, tbl, row_id)
|
|
1166
|
+
DO UPDATE SET commit_seq = excluded.commit_seq`,
|
|
1167
|
+
params,
|
|
1137
1168
|
);
|
|
1138
|
-
|
|
1139
|
-
});
|
|
1140
|
-
await q.query(
|
|
1141
|
-
`WITH inserted AS (
|
|
1142
|
-
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
1143
|
-
VALUES ${values.join(',')}
|
|
1144
|
-
RETURNING partition, tbl, row_id, op, commit_seq, scopes
|
|
1145
|
-
),
|
|
1146
|
-
scoped AS (
|
|
1147
|
-
INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
1148
|
-
SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
|
|
1149
|
-
FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
|
|
1150
|
-
ON CONFLICT DO NOTHING
|
|
1151
|
-
)
|
|
1152
|
-
INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
|
|
1153
|
-
SELECT DISTINCT ON (tbl, row_id) partition, tbl, row_id, commit_seq
|
|
1154
|
-
FROM inserted WHERE op = 2
|
|
1155
|
-
ON CONFLICT (partition, tbl, row_id)
|
|
1156
|
-
DO UPDATE SET commit_seq = excluded.commit_seq`,
|
|
1157
|
-
params,
|
|
1158
|
-
);
|
|
1169
|
+
}
|
|
1159
1170
|
return commitSeq;
|
|
1160
1171
|
}
|
|
1161
1172
|
|
|
@@ -1234,6 +1245,11 @@ class RollbackSignal extends Error {
|
|
|
1234
1245
|
}
|
|
1235
1246
|
}
|
|
1236
1247
|
|
|
1248
|
+
/** Changes per sync_changes statement: 2 + 7 × 4,096 = 28,674 bound
|
|
1249
|
+
* parameters. The Bind message counts parameters in 16 bits; PostgreSQL reads
|
|
1250
|
+
* up to 65,535, and PGlite 0.5 corrupts its state above 32,767. */
|
|
1251
|
+
const PG_CHANGES_PER_STATEMENT = 4096;
|
|
1252
|
+
|
|
1237
1253
|
/** One page read waiting in `PostgresServerStorage#batchedPage`. */
|
|
1238
1254
|
interface PendingPage {
|
|
1239
1255
|
readonly spec: Record<string, unknown>;
|
|
@@ -1542,6 +1558,20 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1542
1558
|
SET schema_version=EXCLUDED.schema_version, layouts=EXCLUDED.layouts`,
|
|
1543
1559
|
[schema.version, layoutsOf(schema)],
|
|
1544
1560
|
);
|
|
1561
|
+
// A writer of the previous schema still running against this
|
|
1562
|
+
// database would append payloads in the previous layout. Its push
|
|
1563
|
+
// is refused by the serve gate, but a host's server-side
|
|
1564
|
+
// `begin()`/`appendCommit` reads no gate, so the bump raises the
|
|
1565
|
+
// writer fence of every partition to the new version.
|
|
1566
|
+
await client.query(
|
|
1567
|
+
`INSERT INTO sync_writer_fence(partition, required_writer_version)
|
|
1568
|
+
SELECT partition, $1 FROM sync_partitions
|
|
1569
|
+
ON CONFLICT (partition) DO UPDATE SET
|
|
1570
|
+
required_writer_version=GREATEST(
|
|
1571
|
+
sync_writer_fence.required_writer_version,
|
|
1572
|
+
EXCLUDED.required_writer_version)`,
|
|
1573
|
+
[schema.version],
|
|
1574
|
+
);
|
|
1545
1575
|
// The host's declared checkpoints install in the schema-bump
|
|
1546
1576
|
// transaction, so no observer sees the bumped marker without the
|
|
1547
1577
|
// fence, or the fence without its declaration row.
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -698,6 +698,19 @@ END`);
|
|
|
698
698
|
'INSERT INTO sync_schema_meta(id, schema_version, layouts) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version=excluded.schema_version, layouts=excluded.layouts',
|
|
699
699
|
)
|
|
700
700
|
.run(schema.version, layoutsOf(schema));
|
|
701
|
+
// A writer of the previous schema would append payloads in the
|
|
702
|
+
// previous layout; the bump fences every partition at the new
|
|
703
|
+
// version (see PostgresServerStorage.ensureSchema).
|
|
704
|
+
this.db
|
|
705
|
+
.query(
|
|
706
|
+
`INSERT INTO sync_writer_fence(partition, required_writer_version)
|
|
707
|
+
SELECT partition, ? FROM sync_partitions WHERE true
|
|
708
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
709
|
+
required_writer_version=max(
|
|
710
|
+
sync_writer_fence.required_writer_version,
|
|
711
|
+
excluded.required_writer_version)`,
|
|
712
|
+
)
|
|
713
|
+
.run(schema.version);
|
|
701
714
|
// The host's declared checkpoints install in the schema-bump
|
|
702
715
|
// transaction, so no observer sees the bumped marker without the
|
|
703
716
|
// fence, or the fence without its declaration row.
|