@powersync/service-module-postgres 0.13.0 → 0.14.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/api/PostgresRouteAPIAdapter.d.ts +1 -1
  3. package/dist/api/PostgresRouteAPIAdapter.js +5 -1
  4. package/dist/api/PostgresRouteAPIAdapter.js.map +1 -1
  5. package/dist/replication/SnapshotQuery.d.ts +78 -0
  6. package/dist/replication/SnapshotQuery.js +175 -0
  7. package/dist/replication/SnapshotQuery.js.map +1 -0
  8. package/dist/replication/WalStream.d.ts +37 -4
  9. package/dist/replication/WalStream.js +318 -91
  10. package/dist/replication/WalStream.js.map +1 -1
  11. package/dist/replication/WalStreamReplicationJob.d.ts +2 -0
  12. package/dist/replication/WalStreamReplicationJob.js +14 -3
  13. package/dist/replication/WalStreamReplicationJob.js.map +1 -1
  14. package/dist/replication/WalStreamReplicator.d.ts +1 -0
  15. package/dist/replication/WalStreamReplicator.js +22 -0
  16. package/dist/replication/WalStreamReplicator.js.map +1 -1
  17. package/dist/replication/replication-utils.d.ts +4 -0
  18. package/dist/replication/replication-utils.js +46 -2
  19. package/dist/replication/replication-utils.js.map +1 -1
  20. package/package.json +11 -10
  21. package/src/api/PostgresRouteAPIAdapter.ts +5 -1
  22. package/src/replication/SnapshotQuery.ts +209 -0
  23. package/src/replication/WalStream.ts +373 -98
  24. package/src/replication/WalStreamReplicationJob.ts +15 -3
  25. package/src/replication/WalStreamReplicator.ts +26 -0
  26. package/src/replication/replication-utils.ts +60 -2
  27. package/test/src/__snapshots__/schema_changes.test.ts.snap +2 -2
  28. package/test/src/checkpoints.test.ts +17 -7
  29. package/test/src/chunked_snapshots.test.ts +156 -0
  30. package/test/src/large_batch.test.ts +5 -154
  31. package/test/src/resuming_snapshots.test.ts +150 -0
  32. package/test/src/schema_changes.test.ts +5 -10
  33. package/test/src/slow_tests.test.ts +13 -30
  34. package/test/src/util.ts +12 -1
  35. package/test/src/validation.test.ts +0 -1
  36. package/test/src/wal_stream.test.ts +4 -9
  37. package/test/src/wal_stream_utils.ts +15 -7
  38. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,209 @@
1
+ import { ColumnDescriptor, SourceTable, bson } from '@powersync/service-core';
2
+ import { PgChunk, PgConnection, PgType, StatementParam, PgTypeOid } from '@powersync/service-jpgwire';
3
+ import { escapeIdentifier } from '@powersync/lib-service-postgres';
4
+ import { SqliteValue } from '@powersync/service-sync-rules';
5
+ import { ServiceAssertionError } from '@powersync/lib-services-framework';
6
+
7
+ export interface SnapshotQuery {
8
+ initialize(): Promise<void>;
9
+ /**
10
+ * Returns an async iterable iterator that yields chunks of data.
11
+ *
12
+ * If the last chunk has 0 rows, it indicates that there are no more rows to fetch.
13
+ */
14
+ nextChunk(): AsyncIterableIterator<PgChunk>;
15
+ }
16
+
17
+ export type PrimaryKeyValue = Record<string, SqliteValue>;
18
+
19
+ export interface MissingRow {
20
+ table: SourceTable;
21
+ key: PrimaryKeyValue;
22
+ }
23
+
24
+ /**
25
+ * Snapshot query using a plain SELECT * FROM table; chunked using
26
+ * DELCLARE CURSOR / FETCH.
27
+ *
28
+ * This supports all tables, but does not efficiently resume the snapshot
29
+ * if the process is restarted.
30
+ */
31
+ export class SimpleSnapshotQuery implements SnapshotQuery {
32
+ public constructor(
33
+ private readonly connection: PgConnection,
34
+ private readonly table: SourceTable,
35
+ private readonly chunkSize: number = 10_000
36
+ ) {}
37
+
38
+ public async initialize(): Promise<void> {
39
+ await this.connection.query(`DECLARE snapshot_cursor CURSOR FOR SELECT * FROM ${this.table.escapedIdentifier}`);
40
+ }
41
+
42
+ public nextChunk(): AsyncIterableIterator<PgChunk> {
43
+ return this.connection.stream(`FETCH ${this.chunkSize} FROM snapshot_cursor`);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Performs a table snapshot query, chunking by ranges of primary key data.
49
+ *
50
+ * This may miss some rows if they are modified during the snapshot query.
51
+ * In that case, logical replication will pick up those rows afterwards,
52
+ * possibly resulting in an IdSnapshotQuery.
53
+ *
54
+ * Currently, this only supports a table with a single primary key column,
55
+ * of a select few types.
56
+ */
57
+ export class ChunkedSnapshotQuery implements SnapshotQuery {
58
+ /**
59
+ * Primary key types that we support for chunked snapshots.
60
+ *
61
+ * Can expand this over time as we add more tests,
62
+ * and ensure there are no issues with type conversion.
63
+ */
64
+ static SUPPORTED_TYPES = [
65
+ PgTypeOid.TEXT,
66
+ PgTypeOid.VARCHAR,
67
+ PgTypeOid.UUID,
68
+ PgTypeOid.INT2,
69
+ PgTypeOid.INT4,
70
+ PgTypeOid.INT8
71
+ ];
72
+
73
+ static supports(table: SourceTable) {
74
+ if (table.replicaIdColumns.length != 1) {
75
+ return false;
76
+ }
77
+ const primaryKey = table.replicaIdColumns[0];
78
+
79
+ return primaryKey.typeId != null && ChunkedSnapshotQuery.SUPPORTED_TYPES.includes(Number(primaryKey.typeId));
80
+ }
81
+
82
+ private readonly key: ColumnDescriptor;
83
+ lastKey: string | bigint | null = null;
84
+
85
+ public constructor(
86
+ private readonly connection: PgConnection,
87
+ private readonly table: SourceTable,
88
+ private readonly chunkSize: number = 10_000,
89
+ lastKeySerialized: Uint8Array | null
90
+ ) {
91
+ this.key = table.replicaIdColumns[0];
92
+
93
+ if (lastKeySerialized != null) {
94
+ this.lastKey = this.deserializeKey(lastKeySerialized);
95
+ }
96
+ }
97
+
98
+ public async initialize(): Promise<void> {
99
+ // No-op
100
+ }
101
+
102
+ private deserializeKey(key: Uint8Array) {
103
+ const decoded = bson.deserialize(key, { useBigInt64: true });
104
+ const keys = Object.keys(decoded);
105
+ if (keys.length != 1) {
106
+ throw new ServiceAssertionError(`Multiple keys found: ${keys.join(', ')}`);
107
+ }
108
+ if (keys[0] != this.key.name) {
109
+ throw new ServiceAssertionError(`Key name mismatch: expected ${this.key.name}, got ${keys[0]}`);
110
+ }
111
+ const value = decoded[this.key.name];
112
+ return value;
113
+ }
114
+
115
+ public getLastKeySerialized(): Uint8Array {
116
+ return bson.serialize({ [this.key.name]: this.lastKey });
117
+ }
118
+
119
+ public async *nextChunk(): AsyncIterableIterator<PgChunk> {
120
+ let stream: AsyncIterableIterator<PgChunk>;
121
+ const escapedKeyName = escapeIdentifier(this.key.name);
122
+ if (this.lastKey == null) {
123
+ stream = this.connection.stream(
124
+ `SELECT * FROM ${this.table.escapedIdentifier} ORDER BY ${escapedKeyName} LIMIT ${this.chunkSize}`
125
+ );
126
+ } else {
127
+ if (this.key.typeId == null) {
128
+ throw new Error(`typeId required for primary key ${this.key.name}`);
129
+ }
130
+ let type: StatementParam['type'] = Number(this.key.typeId);
131
+ stream = this.connection.stream({
132
+ statement: `SELECT * FROM ${this.table.escapedIdentifier} WHERE ${escapedKeyName} > $1 ORDER BY ${escapedKeyName} LIMIT ${this.chunkSize}`,
133
+ params: [{ value: this.lastKey, type }]
134
+ });
135
+ }
136
+ let primaryKeyIndex: number = -1;
137
+
138
+ for await (let chunk of stream) {
139
+ if (chunk.tag == 'RowDescription') {
140
+ // We get a RowDescription for each FETCH call, but they should
141
+ // all be the same.
142
+ let i = 0;
143
+ const pk = chunk.payload.findIndex((c) => c.name == this.key.name);
144
+ if (pk < 0) {
145
+ throw new Error(
146
+ `Cannot find primary key column ${this.key} in results. Keys: ${chunk.payload.map((c) => c.name).join(', ')}`
147
+ );
148
+ }
149
+ primaryKeyIndex = pk;
150
+ }
151
+
152
+ if (chunk.rows.length > 0) {
153
+ this.lastKey = chunk.rows[chunk.rows.length - 1][primaryKeyIndex];
154
+ }
155
+ yield chunk;
156
+ }
157
+ }
158
+ }
159
+
160
+ /**
161
+ * This performs a snapshot query using a list of primary keys.
162
+ *
163
+ * This is not used for general snapshots, but is used when we need to re-fetch specific rows
164
+ * during streaming replication.
165
+ */
166
+ export class IdSnapshotQuery implements SnapshotQuery {
167
+ private didChunk = false;
168
+
169
+ static supports(table: SourceTable) {
170
+ // We have the same requirements as ChunkedSnapshotQuery.
171
+ // This is typically only used as a fallback when ChunkedSnapshotQuery
172
+ // skipped some rows.
173
+ return ChunkedSnapshotQuery.supports(table);
174
+ }
175
+
176
+ public constructor(
177
+ private readonly connection: PgConnection,
178
+ private readonly table: SourceTable,
179
+ private readonly keys: PrimaryKeyValue[]
180
+ ) {}
181
+
182
+ public async initialize(): Promise<void> {
183
+ // No-op
184
+ }
185
+
186
+ public async *nextChunk(): AsyncIterableIterator<PgChunk> {
187
+ // Only produce one chunk
188
+ if (this.didChunk) {
189
+ return;
190
+ }
191
+ this.didChunk = true;
192
+
193
+ const keyDefinition = this.table.replicaIdColumns[0];
194
+ const ids = this.keys.map((record) => record[keyDefinition.name]);
195
+ const type = PgType.getArrayType(keyDefinition.typeId!);
196
+ if (type == null) {
197
+ throw new Error(`Cannot determine primary key array type for ${JSON.stringify(keyDefinition)}`);
198
+ }
199
+ yield* this.connection.stream({
200
+ statement: `SELECT * FROM ${this.table.escapedIdentifier} WHERE ${escapeIdentifier(keyDefinition.name)} = ANY($1)`,
201
+ params: [
202
+ {
203
+ type: type,
204
+ value: ids
205
+ }
206
+ ]
207
+ });
208
+ }
209
+ }