@powersync/service-core 1.20.5 → 1.21.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 (102) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/api/RouteAPI.d.ts +3 -3
  3. package/dist/api/diagnostics.d.ts +1 -1
  4. package/dist/api/diagnostics.js +18 -2
  5. package/dist/api/diagnostics.js.map +1 -1
  6. package/dist/entry/commands/teardown-action.js +1 -1
  7. package/dist/entry/commands/teardown-action.js.map +1 -1
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.js +1 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/modules/AbstractModule.d.ts +1 -1
  12. package/dist/replication/AbstractReplicationJob.js +1 -1
  13. package/dist/replication/AbstractReplicationJob.js.map +1 -1
  14. package/dist/replication/AbstractReplicator.d.ts +6 -6
  15. package/dist/replication/AbstractReplicator.js +21 -21
  16. package/dist/replication/AbstractReplicator.js.map +1 -1
  17. package/dist/routes/endpoints/admin.js +7 -3
  18. package/dist/routes/endpoints/admin.js.map +1 -1
  19. package/dist/routes/endpoints/checkpointing.js +1 -1
  20. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  21. package/dist/routes/endpoints/socket-route.js +1 -1
  22. package/dist/routes/endpoints/socket-route.js.map +1 -1
  23. package/dist/routes/endpoints/sync-rules.js +7 -7
  24. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  25. package/dist/routes/endpoints/sync-stream.js +2 -2
  26. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  27. package/dist/runner/teardown.js +4 -4
  28. package/dist/runner/teardown.js.map +1 -1
  29. package/dist/storage/BucketStorage.d.ts +9 -9
  30. package/dist/storage/BucketStorage.js +9 -9
  31. package/dist/storage/BucketStorageFactory.d.ts +23 -18
  32. package/dist/storage/BucketStorageFactory.js +12 -11
  33. package/dist/storage/BucketStorageFactory.js.map +1 -1
  34. package/dist/storage/PersistedSyncRulesContent.d.ts +3 -1
  35. package/dist/storage/PersistedSyncRulesContent.js +10 -3
  36. package/dist/storage/PersistedSyncRulesContent.js.map +1 -1
  37. package/dist/storage/SourceTable.d.ts +3 -3
  38. package/dist/storage/SourceTable.js +3 -3
  39. package/dist/storage/StorageVersionConfig.d.ts +1 -1
  40. package/dist/storage/StorageVersionConfig.js +1 -1
  41. package/dist/storage/SyncRulesBucketStorage.d.ts +38 -6
  42. package/dist/storage/SyncRulesBucketStorage.js +14 -0
  43. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  44. package/dist/storage/WriteCheckpointAPI.d.ts +6 -6
  45. package/dist/storage/WriteCheckpointAPI.js +1 -1
  46. package/dist/storage/bson.d.ts +0 -1
  47. package/dist/storage/bson.js +0 -4
  48. package/dist/storage/bson.js.map +1 -1
  49. package/dist/sync/BucketChecksumState.d.ts +2 -5
  50. package/dist/sync/BucketChecksumState.js +116 -57
  51. package/dist/sync/BucketChecksumState.js.map +1 -1
  52. package/dist/tracing/PerformanceTracer.d.ts +44 -0
  53. package/dist/tracing/PerformanceTracer.js +102 -0
  54. package/dist/tracing/PerformanceTracer.js.map +1 -0
  55. package/dist/tracing/TraceWriter.d.ts +22 -0
  56. package/dist/tracing/TraceWriter.js +63 -0
  57. package/dist/tracing/TraceWriter.js.map +1 -0
  58. package/dist/util/checkpointing.js +1 -1
  59. package/dist/util/config/compound-config-collector.d.ts +1 -1
  60. package/dist/util/config/compound-config-collector.js +2 -2
  61. package/dist/util/config/compound-config-collector.js.map +1 -1
  62. package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js +1 -1
  63. package/dist/util/config/sync-rules/impl/filesystem-sync-rules-collector.js.map +1 -1
  64. package/dist/util/env.js +1 -1
  65. package/dist/util/protocol-types.d.ts +1 -1
  66. package/dist/util/protocol-types.js +1 -1
  67. package/package.json +11 -11
  68. package/src/api/RouteAPI.ts +3 -3
  69. package/src/api/diagnostics.ts +26 -3
  70. package/src/entry/commands/teardown-action.ts +1 -1
  71. package/src/index.ts +2 -0
  72. package/src/modules/AbstractModule.ts +1 -1
  73. package/src/replication/AbstractReplicationJob.ts +1 -1
  74. package/src/replication/AbstractReplicator.ts +23 -23
  75. package/src/routes/endpoints/admin.ts +7 -3
  76. package/src/routes/endpoints/checkpointing.ts +1 -1
  77. package/src/routes/endpoints/socket-route.ts +1 -1
  78. package/src/routes/endpoints/sync-rules.ts +7 -7
  79. package/src/routes/endpoints/sync-stream.ts +2 -2
  80. package/src/runner/teardown.ts +4 -4
  81. package/src/storage/BucketStorage.ts +9 -9
  82. package/src/storage/BucketStorageFactory.ts +29 -22
  83. package/src/storage/PersistedSyncRulesContent.ts +12 -4
  84. package/src/storage/SourceTable.ts +3 -3
  85. package/src/storage/StorageVersionConfig.ts +1 -1
  86. package/src/storage/SyncRulesBucketStorage.ts +46 -7
  87. package/src/storage/WriteCheckpointAPI.ts +6 -6
  88. package/src/storage/bson.ts +0 -5
  89. package/src/sync/BucketChecksumState.ts +137 -73
  90. package/src/sync/sync.ts +1 -1
  91. package/src/tracing/PerformanceTracer.ts +126 -0
  92. package/src/tracing/TraceWriter.ts +67 -0
  93. package/src/util/checkpointing.ts +1 -1
  94. package/src/util/config/compound-config-collector.ts +3 -3
  95. package/src/util/config/sync-rules/impl/filesystem-sync-rules-collector.ts +1 -1
  96. package/src/util/env.ts +1 -1
  97. package/src/util/protocol-types.ts +1 -1
  98. package/test/src/auth.test.ts +109 -1
  99. package/test/src/diagnostics.test.ts +151 -0
  100. package/test/src/sync/BucketChecksumState.test.ts +221 -65
  101. package/test/tsconfig.json +0 -1
  102. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,67 @@
1
+ import { Mutex } from 'async-mutex';
2
+ import * as fs from 'node:fs/promises';
3
+
4
+ /**
5
+ * Write traces in the Chrome JSON Trace Format.
6
+ *
7
+ * View at https://ui.perfetto.dev/
8
+ */
9
+ class TraceWriter {
10
+ handle: fs.FileHandle | null = null;
11
+ length = 0;
12
+ queue: any[] = [];
13
+ private mutex = new Mutex();
14
+
15
+ constructor(public readonly path: string) {
16
+ this.open().catch((e) => {
17
+ console.error(`Failed to open trace file at ${path}`, e);
18
+ });
19
+ }
20
+
21
+ async open() {
22
+ await this.mutex.runExclusive(async () => {
23
+ this.handle = await fs.open(this.path, 'w+');
24
+ this.handle.truncate(0);
25
+ await this.handle.write('[]');
26
+ this.length = 2;
27
+ });
28
+ }
29
+
30
+ write(...traceEvents: any[]) {
31
+ this.writeAsync(...traceEvents).catch((e) => {
32
+ console.error(`Failed to write trace file`, e);
33
+ });
34
+ }
35
+
36
+ async writeAsync(...traceEvents: any[]) {
37
+ this.queue.push(...traceEvents);
38
+ await this.mutex.runExclusive(async () => {
39
+ if (this.queue.length > 0) {
40
+ // Write queued events.
41
+ // After each write, we end the file as a valid JSON array.
42
+ // On the next write, we overwrite the last character to extend the array.
43
+ const buffer = Buffer.from(JSON.stringify(this.queue));
44
+ await this.handle?.write(buffer, 1, buffer.length - 1, this.length - 1);
45
+ this.queue = [];
46
+ this.length += buffer.length - 2;
47
+ }
48
+ });
49
+ }
50
+ }
51
+
52
+ const traceFile = process.env.POWERSYNC_TRACE_FILE;
53
+ /**
54
+ * traceWriter, only present if POWERSYNC_TRACE_FILE env var is configured.
55
+ */
56
+ export const traceWriter = traceFile ? new TraceWriter(traceFile) : null;
57
+
58
+ if (traceWriter) {
59
+ traceWriter.write({
60
+ ph: 'M',
61
+ cat: '__metadata',
62
+ name: 'process_name',
63
+ pid: process.pid,
64
+ tid: 1000,
65
+ args: { name: 'powersync' }
66
+ });
67
+ }
@@ -13,7 +13,7 @@ export async function createWriteCheckpoint(options: CreateWriteCheckpointOption
13
13
 
14
14
  const syncBucketStorage = await options.storage.getActiveStorage();
15
15
  if (!syncBucketStorage) {
16
- throw new ServiceError(ErrorCode.PSYNC_S2302, `Cannot create Write Checkpoint since no sync rules are active.`);
16
+ throw new ServiceError(ErrorCode.PSYNC_S2302, `Cannot create Write Checkpoint since no sync config is active.`);
17
17
  }
18
18
 
19
19
  const { writeCheckpoint, currentCheckpoint } = await options.api.createReplicationHead(async (currentCheckpoint) => {
@@ -26,7 +26,7 @@ export type CompoundConfigCollectorOptions = {
26
26
  */
27
27
  configCollectors: ConfigCollector[];
28
28
  /**
29
- * Collectors for PowerSync sync rules content.
29
+ * Collectors for PowerSync sync config content.
30
30
  * The configuration from first collector to provide a configuration
31
31
  * is used. The order of the collectors specifies precedence
32
32
  */
@@ -236,11 +236,11 @@ export class CompoundConfigCollector {
236
236
  return config;
237
237
  }
238
238
  logger.debug(
239
- `Could not collect sync rules with ${collector.name} method. Moving on to next method if available.`
239
+ `Could not collect sync config with ${collector.name} method. Moving on to next method if available.`
240
240
  );
241
241
  } catch (ex) {
242
242
  // An error in a collector is a hard stop
243
- throw new Error(`Could not collect sync rules using ${collector.name} method. Caught exception: ${ex}`);
243
+ throw new Error(`Could not collect sync config using ${collector.name} method. Caught exception: ${ex}`);
244
244
  }
245
245
  }
246
246
  return {
@@ -16,7 +16,7 @@ export class FileSystemSyncRulesCollector extends SyncRulesCollector {
16
16
 
17
17
  const { config_path } = runnerConfig;
18
18
 
19
- // Depending on the container, the sync rules may not actually be present.
19
+ // Depending on the container, the sync config may not actually be present.
20
20
  // Only persist the path here, and load on demand using `loadSyncRules()`.
21
21
  return {
22
22
  present: true,
package/src/util/env.ts CHANGED
@@ -13,7 +13,7 @@ export const env = utils.collectEnvironmentVariables({
13
13
  POWERSYNC_CONFIG_B64: utils.type.string.optional(),
14
14
  /**
15
15
  * @deprecated use POWERSYNC_SYNC_CONFIG_B64 instead.
16
- * Base64 encoded contents of sync rules YAML
16
+ * Base64 encoded contents of sync config YAML
17
17
  */
18
18
  POWERSYNC_SYNC_RULES_B64: utils.type.string.optional(),
19
19
  /**
@@ -77,7 +77,7 @@ export const StreamingSyncRequest = t.object({
77
77
  raw_data: t.boolean.optional(),
78
78
 
79
79
  /**
80
- * Client parameters to be passed to the sync rules.
80
+ * Client parameters to be passed to the sync config.
81
81
  */
82
82
  parameters: t.record(t.any).optional(),
83
83
 
@@ -1,9 +1,17 @@
1
1
  import { StaticSupabaseKeyCollector } from '@/index.js';
2
+ import { configFile } from '@powersync/service-types';
2
3
  import * as jose from 'jose';
3
4
  import { describe, expect, test } from 'vitest';
4
5
  import { CachedKeyCollector } from '../../src/auth/CachedKeyCollector.js';
5
6
  import { KeyResult } from '../../src/auth/KeyCollector.js';
6
- import { KeySpec } from '../../src/auth/KeySpec.js';
7
+ import {
8
+ EC_ALGORITHMS,
9
+ HS_ALGORITHMS,
10
+ KeySpec,
11
+ OKP_ALGORITHMS,
12
+ RSA_ALGORITHMS,
13
+ SUPPORTED_ALGORITHMS
14
+ } from '../../src/auth/KeySpec.js';
7
15
  import { KeyStore } from '../../src/auth/KeyStore.js';
8
16
  import { RemoteJWKSCollector } from '../../src/auth/RemoteJWKSCollector.js';
9
17
  import { StaticKeyCollector } from '../../src/auth/StaticKeyCollector.js';
@@ -52,6 +60,47 @@ const privateKeyECDSA: jose.JWK = {
52
60
  alg: 'ES256'
53
61
  };
54
62
 
63
+ const EC_ALGORITHM_CURVES = [
64
+ ['ES256', 'P-256'],
65
+ ['ES384', 'P-384'],
66
+ ['ES512', 'P-521']
67
+ ] satisfies [string, string][];
68
+
69
+ const EDDSA_CURVES = ['Ed25519', 'Ed448'];
70
+
71
+ function roundTripJwkThroughPowerSyncConfig(key: jose.JWK): jose.JWK {
72
+ const encoded = configFile.strictJwks.encode({
73
+ keys: [key as configFile.StrictJwk]
74
+ });
75
+
76
+ const decoded = configFile.strictJwks.decode(encoded);
77
+ return decoded.keys[0] as jose.JWK;
78
+ }
79
+
80
+ async function signAndVerifyWithKey(alg: string, key: jose.JWK, signKey: jose.KeyLike | Uint8Array) {
81
+ const parsedKey = roundTripJwkThroughPowerSyncConfig(key);
82
+ expect(parsedKey).toEqual(key);
83
+
84
+ const keys = await StaticKeyCollector.importKeys([parsedKey]);
85
+ const store = new KeyStore(keys);
86
+
87
+ const signedJwt = await new jose.SignJWT({ claim: alg })
88
+ .setProtectedHeader({ alg, kid: key.kid })
89
+ .setSubject('f1')
90
+ .setIssuedAt()
91
+ .setIssuer('tester')
92
+ .setAudience('tests')
93
+ .setExpirationTime('5m')
94
+ .sign(signKey);
95
+
96
+ const verified = await store.verifyJwt(signedJwt, {
97
+ defaultAudiences: ['tests'],
98
+ maxAge: '6m'
99
+ });
100
+
101
+ expect(verified.parsedPayload.claim).toEqual(alg);
102
+ }
103
+
55
104
  describe('JWT Auth', () => {
56
105
  test('KeyStore basics', async () => {
57
106
  const keys = await StaticKeyCollector.importKeys([sharedKey]);
@@ -208,6 +257,65 @@ describe('JWT Auth', () => {
208
257
  ).rejects.toThrow('Unexpected token algorithm HS256');
209
258
  });
210
259
 
260
+ describe('supported JWT algorithms', () => {
261
+ test('covers every declared supported algorithm', () => {
262
+ const testedAlgorithms = new Set([
263
+ ...HS_ALGORITHMS,
264
+ ...RSA_ALGORITHMS,
265
+ ...EC_ALGORITHM_CURVES.map(([alg]) => alg),
266
+ ...OKP_ALGORITHMS
267
+ ]);
268
+
269
+ expect([...testedAlgorithms].sort()).toEqual([...SUPPORTED_ALGORITHMS].sort());
270
+ });
271
+
272
+ test.each(HS_ALGORITHMS)('verifies %s tokens', async (alg) => {
273
+ const secret = await jose.generateSecret(alg);
274
+ const key = await jose.exportJWK(secret);
275
+ key.kid = `test-${alg}`;
276
+ key.alg = alg;
277
+
278
+ await signAndVerifyWithKey(alg, key, secret);
279
+ });
280
+
281
+ test.each(RSA_ALGORITHMS)('verifies %s tokens', async (alg) => {
282
+ const { privateKey, publicKey } = await jose.generateKeyPair(alg);
283
+ const key = await jose.exportJWK(publicKey);
284
+ key.kid = `test-${alg}`;
285
+ key.alg = alg;
286
+ key.use = 'sig';
287
+
288
+ await signAndVerifyWithKey(alg, key, privateKey);
289
+ });
290
+
291
+ test.each(EC_ALGORITHM_CURVES)('verifies %s tokens with curve %s', async (alg, crv) => {
292
+ expect(EC_ALGORITHMS).toContain(alg);
293
+
294
+ const { privateKey, publicKey } = await jose.generateKeyPair(alg);
295
+ const key = await jose.exportJWK(publicKey);
296
+ key.kid = `test-${alg}`;
297
+ key.alg = alg;
298
+ key.use = 'sig';
299
+
300
+ expect(key.crv).toEqual(crv);
301
+ await signAndVerifyWithKey(alg, key, privateKey);
302
+ });
303
+
304
+ test.each(EDDSA_CURVES)('verifies EdDSA tokens with curve %s', async (crv) => {
305
+ const alg = 'EdDSA';
306
+ expect(OKP_ALGORITHMS).toContain(alg);
307
+
308
+ const { privateKey, publicKey } = await jose.generateKeyPair(alg, { crv });
309
+ const key = await jose.exportJWK(publicKey);
310
+ key.kid = `test-${alg}-${crv}`;
311
+ key.alg = alg;
312
+ key.use = 'sig';
313
+
314
+ expect(key.crv).toEqual(crv);
315
+ await signAndVerifyWithKey(alg, key, privateKey);
316
+ });
317
+ });
318
+
211
319
  test('key selection for key with kid', async () => {
212
320
  const keys = await StaticKeyCollector.importKeys([publicKeyRSA, sharedKey, sharedKey2]);
213
321
  const store = new KeyStore(keys);
@@ -0,0 +1,151 @@
1
+ import { DiagnosticsOptions, getSyncRulesStatus } from '@/api/diagnostics.js';
2
+ import { RouteAPI, SlotWalBudgetInfo } from '@/api/RouteAPI.js';
3
+ import { BucketStorageFactory } from '@/index.js';
4
+ import { SqlSyncRules } from '@powersync/service-sync-rules';
5
+ import { describe, expect, test } from 'vitest';
6
+
7
+ const GB = 1024 * 1024 * 1024;
8
+
9
+ const MINIMAL_SYNC_RULES = `
10
+ bucket_definitions:
11
+ global:
12
+ data:
13
+ - SELECT id FROM test_table
14
+ `;
15
+
16
+ function makeSyncRulesContent(overrides?: { slot_name?: string }) {
17
+ return {
18
+ id: 1,
19
+ slot_name: overrides?.slot_name ?? 'test_slot',
20
+ sync_rules_content: MINIMAL_SYNC_RULES,
21
+ compiled_plan: null,
22
+ active: true,
23
+ storageVersion: 1,
24
+ last_checkpoint_lsn: 'some_lsn',
25
+ last_fatal_error: null,
26
+ last_fatal_error_ts: null,
27
+ last_keepalive_ts: new Date(),
28
+ last_checkpoint_ts: new Date(),
29
+ parsed(options?: any) {
30
+ const syncRules = SqlSyncRules.fromYaml(MINIMAL_SYNC_RULES, {
31
+ ...options,
32
+ defaultSchema: 'public'
33
+ });
34
+ return {
35
+ sync_rules: syncRules
36
+ };
37
+ },
38
+ lock() {
39
+ throw new Error('Not implemented in mock');
40
+ },
41
+ current_lock: undefined
42
+ } as any;
43
+ }
44
+
45
+ function makeBucketStorage() {
46
+ return {
47
+ getInstance() {
48
+ return {
49
+ async getStatus() {
50
+ return {
51
+ snapshot_done: true,
52
+ checkpoint_lsn: 'some_lsn',
53
+ active: true
54
+ };
55
+ }
56
+ };
57
+ }
58
+ } as unknown as BucketStorageFactory;
59
+ }
60
+
61
+ function makeRouteAPI(walBudget?: SlotWalBudgetInfo | undefined): RouteAPI {
62
+ return {
63
+ getParseSyncRulesOptions() {
64
+ return { defaultSchema: 'public' };
65
+ },
66
+ async getSourceConfig() {
67
+ return { tag: 'test', id: 'test', type: 'postgresql' };
68
+ },
69
+ async getConnectionStatus() {
70
+ return { connected: true };
71
+ },
72
+ async getDebugTablesInfo() {
73
+ return [];
74
+ },
75
+ async getReplicationLagBytes() {
76
+ return 0;
77
+ },
78
+ ...(walBudget !== undefined
79
+ ? {
80
+ async getSlotWalBudget() {
81
+ return walBudget;
82
+ }
83
+ }
84
+ : {})
85
+ } as unknown as RouteAPI;
86
+ }
87
+
88
+ const OPTIONS: DiagnosticsOptions = {
89
+ live_status: true,
90
+ check_connection: true,
91
+ include_content: false
92
+ };
93
+
94
+ describe('getSyncRulesStatus WAL budget warnings', () => {
95
+ test('warns when WAL budget is at 40%', async () => {
96
+ const api = makeRouteAPI({
97
+ wal_status: 'extended',
98
+ safe_wal_size: 4 * GB,
99
+ max_slot_wal_keep_size: 10 * GB
100
+ });
101
+ const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
102
+ const walWarnings = result!.errors.filter((e) => e.message.includes('WAL budget'));
103
+ expect(walWarnings).toHaveLength(1);
104
+ expect(walWarnings[0].level).toBe('warning');
105
+ expect(walWarnings[0].message).toContain('40%');
106
+ });
107
+
108
+ test('no warning when WAL budget is at 80%', async () => {
109
+ const api = makeRouteAPI({
110
+ wal_status: 'reserved',
111
+ safe_wal_size: 8 * GB,
112
+ max_slot_wal_keep_size: 10 * GB
113
+ });
114
+ const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
115
+ const walWarnings = result!.errors.filter((e) => e.message.includes('WAL budget'));
116
+ expect(walWarnings).toHaveLength(0);
117
+ });
118
+
119
+ test('clamps negative safe_wal_size to 0%', async () => {
120
+ const api = makeRouteAPI({
121
+ wal_status: 'unreserved',
122
+ safe_wal_size: -2.4 * GB,
123
+ max_slot_wal_keep_size: 1 * 1024 * 1024 // 1MB
124
+ });
125
+ const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
126
+ const walWarnings = result!.errors.filter((e) => e.message.includes('WAL budget'));
127
+ expect(walWarnings).toHaveLength(1);
128
+ expect(walWarnings[0].message).toContain('0%');
129
+ expect(walWarnings[0].message).not.toMatch(/-\d+%/);
130
+ });
131
+
132
+ test('no WAL budget error when slot status is lost', async () => {
133
+ const api = makeRouteAPI({
134
+ wal_status: 'lost'
135
+ });
136
+ const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
137
+ const walErrors = result!.errors.filter(
138
+ (e) => e.message.includes('WAL budget') || e.message.includes('PSYNC_S1146')
139
+ );
140
+ expect(walErrors).toHaveLength(0);
141
+ });
142
+
143
+ test('no WAL error when getSlotWalBudget is not defined', async () => {
144
+ const api = makeRouteAPI();
145
+ const result = await getSyncRulesStatus(makeBucketStorage(), api, makeSyncRulesContent(), OPTIONS);
146
+ const walErrors = result!.errors.filter(
147
+ (e) => e.message.includes('WAL budget') || e.message.includes('PSYNC_S1146')
148
+ );
149
+ expect(walErrors).toHaveLength(0);
150
+ });
151
+ });