@powersync/service-core 1.13.4 → 1.15.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 (119) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +3 -3
  3. package/dist/api/api-metrics.js +5 -0
  4. package/dist/api/api-metrics.js.map +1 -1
  5. package/dist/api/diagnostics.js +31 -1
  6. package/dist/api/diagnostics.js.map +1 -1
  7. package/dist/auth/KeyStore.d.ts +19 -0
  8. package/dist/auth/KeyStore.js +16 -4
  9. package/dist/auth/KeyStore.js.map +1 -1
  10. package/dist/auth/RemoteJWKSCollector.d.ts +3 -0
  11. package/dist/auth/RemoteJWKSCollector.js +3 -1
  12. package/dist/auth/RemoteJWKSCollector.js.map +1 -1
  13. package/dist/auth/StaticSupabaseKeyCollector.d.ts +2 -1
  14. package/dist/auth/StaticSupabaseKeyCollector.js +1 -1
  15. package/dist/auth/StaticSupabaseKeyCollector.js.map +1 -1
  16. package/dist/auth/utils.d.ts +19 -0
  17. package/dist/auth/utils.js +106 -3
  18. package/dist/auth/utils.js.map +1 -1
  19. package/dist/entry/commands/compact-action.js +10 -1
  20. package/dist/entry/commands/compact-action.js.map +1 -1
  21. package/dist/metrics/open-telemetry/util.d.ts +0 -3
  22. package/dist/metrics/open-telemetry/util.js +19 -12
  23. package/dist/metrics/open-telemetry/util.js.map +1 -1
  24. package/dist/replication/AbstractReplicator.js +2 -2
  25. package/dist/replication/AbstractReplicator.js.map +1 -1
  26. package/dist/routes/compression.d.ts +19 -0
  27. package/dist/routes/compression.js +70 -0
  28. package/dist/routes/compression.js.map +1 -0
  29. package/dist/routes/configure-fastify.d.ts +40 -5
  30. package/dist/routes/configure-fastify.js +2 -1
  31. package/dist/routes/configure-fastify.js.map +1 -1
  32. package/dist/routes/endpoints/socket-route.js +25 -17
  33. package/dist/routes/endpoints/socket-route.js.map +1 -1
  34. package/dist/routes/endpoints/sync-rules.js +1 -27
  35. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  36. package/dist/routes/endpoints/sync-stream.d.ts +80 -10
  37. package/dist/routes/endpoints/sync-stream.js +29 -11
  38. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  39. package/dist/routes/route-register.d.ts +4 -0
  40. package/dist/routes/route-register.js +29 -15
  41. package/dist/routes/route-register.js.map +1 -1
  42. package/dist/storage/BucketStorage.d.ts +1 -1
  43. package/dist/storage/BucketStorage.js.map +1 -1
  44. package/dist/storage/BucketStorageBatch.d.ts +16 -6
  45. package/dist/storage/BucketStorageBatch.js.map +1 -1
  46. package/dist/storage/ChecksumCache.d.ts +4 -19
  47. package/dist/storage/ChecksumCache.js +4 -0
  48. package/dist/storage/ChecksumCache.js.map +1 -1
  49. package/dist/storage/ReplicationEventPayload.d.ts +2 -2
  50. package/dist/storage/SourceEntity.d.ts +5 -4
  51. package/dist/storage/SourceTable.d.ts +22 -20
  52. package/dist/storage/SourceTable.js +34 -30
  53. package/dist/storage/SourceTable.js.map +1 -1
  54. package/dist/storage/SyncRulesBucketStorage.d.ts +19 -4
  55. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  56. package/dist/sync/BucketChecksumState.d.ts +41 -11
  57. package/dist/sync/BucketChecksumState.js +155 -19
  58. package/dist/sync/BucketChecksumState.js.map +1 -1
  59. package/dist/sync/RequestTracker.d.ts +7 -1
  60. package/dist/sync/RequestTracker.js +22 -2
  61. package/dist/sync/RequestTracker.js.map +1 -1
  62. package/dist/sync/sync.d.ts +3 -3
  63. package/dist/sync/sync.js +23 -42
  64. package/dist/sync/sync.js.map +1 -1
  65. package/dist/sync/util.d.ts +3 -1
  66. package/dist/sync/util.js +30 -2
  67. package/dist/sync/util.js.map +1 -1
  68. package/dist/util/config/compound-config-collector.js +23 -0
  69. package/dist/util/config/compound-config-collector.js.map +1 -1
  70. package/dist/util/lsn.d.ts +4 -0
  71. package/dist/util/lsn.js +11 -0
  72. package/dist/util/lsn.js.map +1 -0
  73. package/dist/util/protocol-types.d.ts +153 -9
  74. package/dist/util/protocol-types.js +41 -6
  75. package/dist/util/protocol-types.js.map +1 -1
  76. package/dist/util/util-index.d.ts +1 -0
  77. package/dist/util/util-index.js +1 -0
  78. package/dist/util/util-index.js.map +1 -1
  79. package/dist/util/utils.d.ts +18 -3
  80. package/dist/util/utils.js +33 -9
  81. package/dist/util/utils.js.map +1 -1
  82. package/package.json +16 -14
  83. package/src/api/api-metrics.ts +6 -0
  84. package/src/api/diagnostics.ts +33 -1
  85. package/src/auth/KeyStore.ts +28 -4
  86. package/src/auth/RemoteJWKSCollector.ts +5 -2
  87. package/src/auth/StaticSupabaseKeyCollector.ts +1 -1
  88. package/src/auth/utils.ts +123 -3
  89. package/src/entry/commands/compact-action.ts +9 -1
  90. package/src/metrics/open-telemetry/util.ts +23 -19
  91. package/src/replication/AbstractReplicator.ts +2 -2
  92. package/src/routes/compression.ts +75 -0
  93. package/src/routes/configure-fastify.ts +3 -1
  94. package/src/routes/endpoints/socket-route.ts +25 -16
  95. package/src/routes/endpoints/sync-rules.ts +1 -28
  96. package/src/routes/endpoints/sync-stream.ts +37 -26
  97. package/src/routes/route-register.ts +41 -15
  98. package/src/storage/BucketStorage.ts +2 -2
  99. package/src/storage/BucketStorageBatch.ts +23 -6
  100. package/src/storage/ChecksumCache.ts +8 -22
  101. package/src/storage/ReplicationEventPayload.ts +2 -2
  102. package/src/storage/SourceEntity.ts +5 -5
  103. package/src/storage/SourceTable.ts +48 -34
  104. package/src/storage/SyncRulesBucketStorage.ts +26 -7
  105. package/src/sync/BucketChecksumState.ts +194 -31
  106. package/src/sync/RequestTracker.ts +27 -2
  107. package/src/sync/sync.ts +53 -51
  108. package/src/sync/util.ts +32 -3
  109. package/src/util/config/compound-config-collector.ts +24 -0
  110. package/src/util/lsn.ts +8 -0
  111. package/src/util/protocol-types.ts +138 -10
  112. package/src/util/util-index.ts +1 -0
  113. package/src/util/utils.ts +59 -12
  114. package/test/src/auth.test.ts +323 -1
  115. package/test/src/checksum_cache.test.ts +6 -8
  116. package/test/src/routes/mocks.ts +59 -0
  117. package/test/src/routes/stream.test.ts +84 -0
  118. package/test/src/sync/BucketChecksumState.test.ts +375 -76
  119. package/tsconfig.tsbuildinfo +1 -1
@@ -6,7 +6,8 @@ import { KeySpec } from '../../src/auth/KeySpec.js';
6
6
  import { RemoteJWKSCollector } from '../../src/auth/RemoteJWKSCollector.js';
7
7
  import { KeyResult } from '../../src/auth/KeyCollector.js';
8
8
  import { CachedKeyCollector } from '../../src/auth/CachedKeyCollector.js';
9
- import { JwtPayload } from '@/index.js';
9
+ import { JwtPayload, StaticSupabaseKeyCollector } from '@/index.js';
10
+ import { debugKeyNotFound } from '../../src/auth/utils.js';
10
11
 
11
12
  const publicKeyRSA: jose.JWK = {
12
13
  use: 'sig',
@@ -438,4 +439,325 @@ describe('JWT Auth', () => {
438
439
 
439
440
  expect(verified.claim).toEqual('test-claim-2');
440
441
  });
442
+
443
+ describe('debugKeyNotFound', () => {
444
+ test('Supabase token with legacy auth not configured', async () => {
445
+ const keys = await StaticSupabaseKeyCollector.importKeys([]);
446
+ const store = new KeyStore(keys);
447
+
448
+ // Mock Supabase debug info - legacy not enabled
449
+ store.supabaseAuthDebug = {
450
+ jwksDetails: null,
451
+ jwksEnabled: false,
452
+ sharedSecretEnabled: false
453
+ };
454
+
455
+ // Create a legacy Supabase token (HS256)
456
+ const token = await new jose.SignJWT({})
457
+ .setProtectedHeader({ alg: 'HS256', kid: 'test' })
458
+ .setSubject('test')
459
+ .setIssuer('https://abc123.supabase.co/auth/v1')
460
+ .setAudience('authenticated')
461
+ .setExpirationTime('1h')
462
+ .setIssuedAt()
463
+ .sign(Buffer.from('secret'));
464
+
465
+ const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
466
+ expect(err.configurationDetails).toMatch(
467
+ 'Token is a Supabase Legacy HS256 (Shared Secret) token, but Supabase JWT secret is not configured'
468
+ );
469
+ });
470
+
471
+ test('Legacy Supabase token with wrong secret', async () => {
472
+ const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]);
473
+ const store = new KeyStore(keys);
474
+
475
+ // Mock Supabase debug info - legacy enabled
476
+ store.supabaseAuthDebug = {
477
+ jwksDetails: null,
478
+ jwksEnabled: false,
479
+ sharedSecretEnabled: true
480
+ };
481
+
482
+ // Create a legacy Supabase token (HS256)
483
+ const token = await new jose.SignJWT({})
484
+ .setProtectedHeader({ alg: 'HS256', kid: sharedKey2.kid })
485
+ .setSubject('test')
486
+ .setIssuer('https://abc123.supabase.co/auth/v1')
487
+ .setAudience('authenticated')
488
+ .setExpirationTime('1h')
489
+ .setIssuedAt()
490
+ .sign(await jose.importJWK(sharedKey2));
491
+
492
+ const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
493
+ expect(err.configurationDetails).toMatch(
494
+ 'Token is a Supabase Legacy HS256 (Shared Secret) token, but configured Supabase JWT secret does not match'
495
+ );
496
+ });
497
+
498
+ test('New HS256 Supabase token with wrong secret', async () => {
499
+ const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]);
500
+ const store = new KeyStore(keys);
501
+
502
+ // Mock Supabase debug info - legacy enabled
503
+ store.supabaseAuthDebug = {
504
+ jwksDetails: null,
505
+ jwksEnabled: false,
506
+ sharedSecretEnabled: true
507
+ };
508
+
509
+ // Create a new HS256 Supabase token.
510
+ // The only real difference here is that the kid is a UUID
511
+ const token = await new jose.SignJWT({})
512
+ .setProtectedHeader({ alg: 'HS256', kid: '2fc01f1d-90fb-4c8b-b646-1c06ed86be46' })
513
+ .setSubject('test')
514
+ .setIssuer('https://abc123.supabase.co/auth/v1')
515
+ .setAudience('authenticated')
516
+ .setExpirationTime('1h')
517
+ .setIssuedAt()
518
+ .sign(await jose.importJWK(sharedKey2));
519
+
520
+ const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
521
+ expect(err.configurationDetails).toMatch(
522
+ 'Token is a Supabase HS256 (Shared Secret) token, but configured Supabase JWT secret does not match'
523
+ );
524
+ });
525
+
526
+ test('Supabase signing key token with no Supabase connection', async () => {
527
+ const keys = await StaticSupabaseKeyCollector.importKeys([]);
528
+ const store = new KeyStore(keys);
529
+
530
+ // Mock Supabase debug info - no Supabase connection
531
+ store.supabaseAuthDebug = {
532
+ jwksDetails: null,
533
+ jwksEnabled: false,
534
+ sharedSecretEnabled: false
535
+ };
536
+
537
+ const signKey = await jose.importJWK(privateKeyECDSA);
538
+ const token = await new jose.SignJWT({})
539
+ .setProtectedHeader({ alg: 'ES256', kid: 'test-kid' })
540
+ .setSubject('test')
541
+ .setIssuer('https://abc123.supabase.co/auth/v1')
542
+ .setAudience('authenticated')
543
+ .setExpirationTime('1h')
544
+ .setIssuedAt()
545
+ .sign(signKey);
546
+
547
+ const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
548
+ expect(err.configurationDetails).toMatch(
549
+ 'Token uses Supabase JWT Signing Keys, but no Supabase connection is configured'
550
+ );
551
+ });
552
+
553
+ test('Supabase signing key token with Supabase auth disabled', async () => {
554
+ const keys = await StaticSupabaseKeyCollector.importKeys([]);
555
+ const store = new KeyStore(keys);
556
+
557
+ // Mock Supabase debug info - Supabase project, but Supabase auth not enabled
558
+ store.supabaseAuthDebug = {
559
+ jwksDetails: {
560
+ projectId: 'abc123',
561
+ hostname: 'db.abc123.supabase.co',
562
+ url: 'https://abc123.supabase.co/auth/v1/.well-known/jwks.json'
563
+ },
564
+ jwksEnabled: false,
565
+ sharedSecretEnabled: false
566
+ };
567
+
568
+ const signKey = await jose.importJWK(privateKeyECDSA);
569
+ const token = await new jose.SignJWT({})
570
+ .setProtectedHeader({ alg: 'ES256', kid: 'test-kid' })
571
+ .setSubject('test')
572
+ .setIssuer('https://abc123.supabase.co/auth/v1')
573
+ .setAudience('authenticated')
574
+ .setExpirationTime('1h')
575
+ .setIssuedAt()
576
+ .sign(signKey);
577
+
578
+ const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
579
+ expect(err.configurationDetails).toMatch(
580
+ 'Token uses Supabase JWT Signing Keys, but Supabase Auth is not enabled'
581
+ );
582
+ });
583
+
584
+ test('Supabase project ID mismatch', async () => {
585
+ const keys = await StaticSupabaseKeyCollector.importKeys([publicKeyRSA]);
586
+ const store = new KeyStore(keys);
587
+
588
+ // Mock Supabase debug info - JWKS enabled with different project ID
589
+ store.supabaseAuthDebug = {
590
+ jwksDetails: {
591
+ projectId: 'expected123',
592
+ hostname: 'db.expected123.supabase.co',
593
+ url: 'https://expected123.supabase.co/auth/v1/.well-known/jwks.json'
594
+ },
595
+ jwksEnabled: true,
596
+ sharedSecretEnabled: false
597
+ };
598
+
599
+ // Create a modern Supabase token with different project ID
600
+ const token = await new jose.SignJWT({})
601
+ .setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid })
602
+ .setSubject('test')
603
+ .setIssuer('https://different456.supabase.co/auth/v1')
604
+ .setAudience('authenticated')
605
+ .setExpirationTime('1h')
606
+ .setIssuedAt()
607
+ .sign(await jose.importJWK(privateKeyECDSA));
608
+
609
+ const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
610
+ expect(err.configurationDetails).toMatch(
611
+ 'Supabase project id mismatch. Expected project: expected123, got issuer: https://different456.supabase.co/auth/v1'
612
+ );
613
+ });
614
+
615
+ test('Supabase signing keys configured but no matching keys', async () => {
616
+ const keys = await StaticSupabaseKeyCollector.importKeys([publicKeyRSA]);
617
+ const store = new KeyStore(keys);
618
+
619
+ // Mock Supabase debug info - JWKS enabled with matching project ID
620
+ store.supabaseAuthDebug = {
621
+ jwksDetails: {
622
+ projectId: 'abc123',
623
+ hostname: 'db.abc123.supabase.co',
624
+ url: 'https://abc123.supabase.co/auth/v1/.well-known/jwks.json'
625
+ },
626
+ jwksEnabled: true,
627
+ sharedSecretEnabled: false
628
+ };
629
+
630
+ // Create a modern Supabase token with matching project ID
631
+ const token = await new jose.SignJWT({})
632
+ .setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid })
633
+ .setSubject('test')
634
+ .setIssuer('https://abc123.supabase.co/auth/v1')
635
+ .setAudience('authenticated')
636
+ .setExpirationTime('1h')
637
+ .setIssuedAt()
638
+ .sign(await jose.importJWK(privateKeyECDSA));
639
+
640
+ const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
641
+ expect(err.configurationDetails).toMatch(
642
+ 'Supabase signing keys configured, but no matching keys found. Known keys: '
643
+ );
644
+ });
645
+
646
+ test('non-Supabase token', async () => {
647
+ const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]);
648
+ const store = new KeyStore(keys);
649
+
650
+ // Create a regular JWT token (not Supabase)
651
+ const token = await new jose.SignJWT({})
652
+ .setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid })
653
+ .setSubject('test')
654
+ .setIssuer('https://regular-issuer.com')
655
+ .setAudience('my-audience')
656
+ .setExpirationTime('1h')
657
+ .setIssuedAt()
658
+ .sign(await jose.importJWK(privateKeyECDSA));
659
+
660
+ // Treated as just a generic unknown key
661
+ const err = await store.verifyJwt(token, { defaultAudiences: ['my-audience'], maxAge: '1d' }).catch((e) => e);
662
+ expect(err.configurationDetails).toMatch('Known keys:');
663
+ });
664
+
665
+ test('Valid legacy Supabase token', async () => {
666
+ const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]);
667
+ const store = new KeyStore(keys);
668
+
669
+ // Mock Supabase debug info - legacy enabled
670
+ store.supabaseAuthDebug = {
671
+ jwksDetails: null,
672
+ jwksEnabled: false,
673
+ sharedSecretEnabled: true
674
+ };
675
+
676
+ // Create a legacy Supabase token (HS256)
677
+ const token = await new jose.SignJWT({})
678
+ .setProtectedHeader({ alg: 'HS256', kid: sharedKey.kid })
679
+ .setSubject('test')
680
+ .setIssuer('https://abc123.supabase.co/auth/v1')
681
+ .setAudience('authenticated')
682
+ .setExpirationTime('1h')
683
+ .setIssuedAt()
684
+ .sign(await jose.importJWK(sharedKey));
685
+
686
+ await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' });
687
+ });
688
+
689
+ test('Valid Supabase signing key', async () => {
690
+ const keys = await StaticSupabaseKeyCollector.importKeys([privateKeyECDSA]);
691
+ const store = new KeyStore(keys);
692
+
693
+ // Mock Supabase debug info - JWKS enabled, legacy disabled
694
+ store.supabaseAuthDebug = {
695
+ jwksDetails: null,
696
+ jwksEnabled: true,
697
+ sharedSecretEnabled: false
698
+ };
699
+
700
+ // Create a modern Supabase signing key token (ES256)
701
+ const token = await new jose.SignJWT({})
702
+ .setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid })
703
+ .setSubject('test')
704
+ .setIssuer('https://abc123.supabase.co/auth/v1')
705
+ .setAudience('authenticated')
706
+ .setExpirationTime('1h')
707
+ .setIssuedAt()
708
+ .sign(await jose.importJWK(privateKeyECDSA));
709
+
710
+ await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' });
711
+ });
712
+
713
+ test('Legacy Supabase anon token', async () => {
714
+ const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]);
715
+ const store = new KeyStore(keys);
716
+
717
+ // Mock Supabase debug info - legacy enabled
718
+ store.supabaseAuthDebug = {
719
+ jwksDetails: null,
720
+ jwksEnabled: false,
721
+ sharedSecretEnabled: true
722
+ };
723
+
724
+ // Create a legacy Supabase token (HS256)
725
+ const token = await new jose.SignJWT({})
726
+ .setProtectedHeader({ alg: 'HS256', kid: sharedKey.kid })
727
+ .setSubject('test')
728
+ .setIssuer('https://abc123.supabase.co/auth/v1')
729
+ .setAudience('anon')
730
+ .setExpirationTime('1h')
731
+ .setIssuedAt()
732
+ .sign(await jose.importJWK(sharedKey));
733
+
734
+ const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
735
+ expect(err.message).toMatch('[PSYNC_S2105] Unexpected "aud" claim value: "anon"');
736
+ });
737
+
738
+ test('Supabase signing key anon token', async () => {
739
+ const keys = await StaticSupabaseKeyCollector.importKeys([privateKeyECDSA]);
740
+ const store = new KeyStore(keys);
741
+
742
+ // Mock Supabase debug info - JWKS enabled
743
+ store.supabaseAuthDebug = {
744
+ jwksDetails: null,
745
+ jwksEnabled: true,
746
+ sharedSecretEnabled: false
747
+ };
748
+
749
+ // Create a modern Supabase signing key token (ES256)
750
+ const token = await new jose.SignJWT({})
751
+ .setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid })
752
+ .setSubject('test')
753
+ .setIssuer('https://abc123.supabase.co/auth/v1')
754
+ .setAudience('anon')
755
+ .setExpirationTime('1h')
756
+ .setIssuedAt()
757
+ .sign(await jose.importJWK(privateKeyECDSA));
758
+
759
+ const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
760
+ expect(err.message).toMatch('[PSYNC_S2105] Unexpected "aud" claim value: "anon"');
761
+ });
762
+ });
441
763
  });
@@ -1,5 +1,5 @@
1
- import { ChecksumCache, FetchChecksums, FetchPartialBucketChecksum, PartialChecksum } from '@/storage/ChecksumCache.js';
2
- import { addChecksums, InternalOpId } from '@/util/util-index.js';
1
+ import { ChecksumCache, FetchChecksums, FetchPartialBucketChecksum } from '@/storage/ChecksumCache.js';
2
+ import { addChecksums, BucketChecksum, InternalOpId, PartialChecksum } from '@/util/util-index.js';
3
3
  import * as crypto from 'node:crypto';
4
4
  import { describe, expect, it } from 'vitest';
5
5
 
@@ -12,22 +12,20 @@ function testHash(bucket: string, checkpoint: InternalOpId) {
12
12
  return hash;
13
13
  }
14
14
 
15
- function testPartialHash(request: FetchPartialBucketChecksum): PartialChecksum {
15
+ function testPartialHash(request: FetchPartialBucketChecksum): PartialChecksum | BucketChecksum {
16
16
  if (request.start) {
17
17
  const a = testHash(request.bucket, request.start);
18
18
  const b = testHash(request.bucket, request.end);
19
19
  return {
20
20
  bucket: request.bucket,
21
21
  partialCount: Number(request.end) - Number(request.start),
22
- partialChecksum: addChecksums(b, -a),
23
- isFullChecksum: false
22
+ partialChecksum: addChecksums(b, -a)
24
23
  };
25
24
  } else {
26
25
  return {
27
26
  bucket: request.bucket,
28
- partialChecksum: testHash(request.bucket, request.end),
29
- partialCount: Number(request.end),
30
- isFullChecksum: true
27
+ checksum: testHash(request.bucket, request.end),
28
+ count: Number(request.end)
31
29
  };
32
30
  }
33
31
  }
@@ -0,0 +1,59 @@
1
+ import {
2
+ BucketStorageFactory,
3
+ createCoreAPIMetrics,
4
+ MetricsEngine,
5
+ OpenTelemetryMetricsFactory,
6
+ RouteAPI,
7
+ RouterEngine,
8
+ ServiceContext,
9
+ StorageEngine,
10
+ SyncContext,
11
+ SyncRulesBucketStorage
12
+ } from '@/index.js';
13
+ import { MeterProvider } from '@opentelemetry/sdk-metrics';
14
+
15
+ export function mockServiceContext(storage: Partial<SyncRulesBucketStorage> | null) {
16
+ // This is very incomplete - just enough to get the current tests passing.
17
+
18
+ const storageEngine: StorageEngine = {
19
+ activeBucketStorage: {
20
+ async getActiveStorage() {
21
+ return storage;
22
+ }
23
+ } as Partial<BucketStorageFactory>
24
+ } as any;
25
+
26
+ const meterProvider = new MeterProvider({
27
+ readers: []
28
+ });
29
+ const meter = meterProvider.getMeter('powersync-tests');
30
+ const metricsEngine = new MetricsEngine({
31
+ disable_telemetry_sharing: true,
32
+ factory: new OpenTelemetryMetricsFactory(meter)
33
+ });
34
+ createCoreAPIMetrics(metricsEngine);
35
+ const service_context: Partial<ServiceContext> = {
36
+ syncContext: new SyncContext({ maxBuckets: 1, maxDataFetchConcurrency: 1, maxParameterQueryResults: 1 }),
37
+ routerEngine: {
38
+ getAPI() {
39
+ return {
40
+ getParseSyncRulesOptions() {
41
+ return { defaultSchema: 'public' };
42
+ }
43
+ } as Partial<RouteAPI>;
44
+ },
45
+ addStopHandler() {
46
+ return () => {};
47
+ }
48
+ } as Partial<RouterEngine> as any,
49
+ storageEngine,
50
+ metricsEngine: metricsEngine,
51
+ // Not used
52
+ configuration: null as any,
53
+ lifeCycleEngine: null as any,
54
+ migrations: null as any,
55
+ replicationEngine: null as any,
56
+ serviceMode: null as any
57
+ };
58
+ return service_context as ServiceContext;
59
+ }
@@ -0,0 +1,84 @@
1
+ import { BasicRouterRequest, Context, SyncRulesBucketStorage } from '@/index.js';
2
+ import { logger, RouterResponse, ServiceError } from '@powersync/lib-services-framework';
3
+ import { SqlSyncRules } from '@powersync/service-sync-rules';
4
+ import { Readable, Writable } from 'stream';
5
+ import { pipeline } from 'stream/promises';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { syncStreamed } from '../../../src/routes/endpoints/sync-stream.js';
8
+ import { mockServiceContext } from './mocks.js';
9
+
10
+ describe('Stream Route', () => {
11
+ describe('compressed stream', () => {
12
+ it('handles missing sync rules', async () => {
13
+ const context: Context = {
14
+ logger: logger,
15
+ service_context: mockServiceContext(null)
16
+ };
17
+
18
+ const request: BasicRouterRequest = {
19
+ headers: {},
20
+ hostname: '',
21
+ protocol: 'http'
22
+ };
23
+
24
+ const error = (await (syncStreamed.handler({ context, params: {}, request }) as Promise<RouterResponse>).catch(
25
+ (e) => e
26
+ )) as ServiceError;
27
+
28
+ expect(error.errorData.status).toEqual(500);
29
+ expect(error.errorData.code).toEqual('PSYNC_S2302');
30
+ });
31
+
32
+ it('handles a stream error with compression', async () => {
33
+ // This primarily tests that an underlying storage error doesn't result in an uncaught error
34
+ // when compressing the stream.
35
+
36
+ const storage = {
37
+ getParsedSyncRules() {
38
+ return new SqlSyncRules('bucket_definitions: {}');
39
+ },
40
+ watchCheckpointChanges: async function* (options) {
41
+ throw new Error('Simulated storage error');
42
+ }
43
+ } as Partial<SyncRulesBucketStorage>;
44
+ const serviceContext = mockServiceContext(storage);
45
+
46
+ const context: Context = {
47
+ logger: logger,
48
+ service_context: serviceContext,
49
+ token_payload: {
50
+ exp: new Date().getTime() / 1000 + 10000,
51
+ iat: new Date().getTime() / 1000 - 10000,
52
+ sub: 'test-user'
53
+ }
54
+ };
55
+
56
+ // It may be worth eventually doing this via Fastify to test the full stack
57
+
58
+ const request: BasicRouterRequest = {
59
+ headers: {
60
+ 'accept-encoding': 'gzip'
61
+ },
62
+ hostname: '',
63
+ protocol: 'http'
64
+ };
65
+
66
+ const response = await (syncStreamed.handler({ context, params: {}, request }) as Promise<RouterResponse>);
67
+ expect(response.status).toEqual(200);
68
+ const stream = response.data as Readable;
69
+ const r = await drainWithTimeout(stream).catch((error) => error);
70
+ expect(r.message).toContain('Simulated storage error');
71
+ });
72
+ });
73
+ });
74
+
75
+ export async function drainWithTimeout(readable: Readable, ms = 2_000) {
76
+ const devNull = new Writable({
77
+ write(_chunk, _enc, cb) {
78
+ cb();
79
+ } // discard everything
80
+ });
81
+
82
+ // Throws AbortError if it takes longer than ms, and destroys the stream
83
+ await pipeline(readable, devNull, { signal: AbortSignal.timeout(ms) });
84
+ }