@syncular/server 0.0.6-159 → 0.0.6-167

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 (93) hide show
  1. package/dist/blobs/adapters/database.d.ts +26 -9
  2. package/dist/blobs/adapters/database.d.ts.map +1 -1
  3. package/dist/blobs/adapters/database.js +65 -21
  4. package/dist/blobs/adapters/database.js.map +1 -1
  5. package/dist/blobs/manager.d.ts +60 -3
  6. package/dist/blobs/manager.d.ts.map +1 -1
  7. package/dist/blobs/manager.js +227 -56
  8. package/dist/blobs/manager.js.map +1 -1
  9. package/dist/blobs/migrate.d.ts.map +1 -1
  10. package/dist/blobs/migrate.js +16 -8
  11. package/dist/blobs/migrate.js.map +1 -1
  12. package/dist/blobs/types.d.ts +4 -0
  13. package/dist/blobs/types.d.ts.map +1 -1
  14. package/dist/dialect/helpers.d.ts +3 -0
  15. package/dist/dialect/helpers.d.ts.map +1 -1
  16. package/dist/dialect/helpers.js +17 -0
  17. package/dist/dialect/helpers.js.map +1 -1
  18. package/dist/handlers/collection.d.ts +0 -2
  19. package/dist/handlers/collection.d.ts.map +1 -1
  20. package/dist/handlers/collection.js +5 -56
  21. package/dist/handlers/collection.js.map +1 -1
  22. package/dist/handlers/create-handler.d.ts +0 -4
  23. package/dist/handlers/create-handler.d.ts.map +1 -1
  24. package/dist/handlers/create-handler.js +6 -34
  25. package/dist/handlers/create-handler.js.map +1 -1
  26. package/dist/notify.d.ts.map +1 -1
  27. package/dist/notify.js +13 -37
  28. package/dist/notify.js.map +1 -1
  29. package/dist/proxy/collection.d.ts +0 -2
  30. package/dist/proxy/collection.d.ts.map +1 -1
  31. package/dist/proxy/collection.js +2 -17
  32. package/dist/proxy/collection.js.map +1 -1
  33. package/dist/proxy/handler.d.ts +1 -1
  34. package/dist/proxy/handler.d.ts.map +1 -1
  35. package/dist/proxy/handler.js +1 -2
  36. package/dist/proxy/handler.js.map +1 -1
  37. package/dist/proxy/index.d.ts +1 -1
  38. package/dist/proxy/index.d.ts.map +1 -1
  39. package/dist/proxy/index.js +1 -1
  40. package/dist/proxy/index.js.map +1 -1
  41. package/dist/proxy/oplog.d.ts.map +1 -1
  42. package/dist/proxy/oplog.js +1 -7
  43. package/dist/proxy/oplog.js.map +1 -1
  44. package/dist/prune.d.ts.map +1 -1
  45. package/dist/prune.js +1 -13
  46. package/dist/prune.js.map +1 -1
  47. package/dist/pull.d.ts.map +1 -1
  48. package/dist/pull.js +186 -54
  49. package/dist/pull.js.map +1 -1
  50. package/dist/push.d.ts +1 -1
  51. package/dist/push.d.ts.map +1 -1
  52. package/dist/push.js +9 -36
  53. package/dist/push.js.map +1 -1
  54. package/dist/snapshot-chunks/db-metadata.d.ts +18 -0
  55. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
  56. package/dist/snapshot-chunks/db-metadata.js +71 -23
  57. package/dist/snapshot-chunks/db-metadata.js.map +1 -1
  58. package/dist/snapshot-chunks.d.ts +5 -1
  59. package/dist/snapshot-chunks.d.ts.map +1 -1
  60. package/dist/snapshot-chunks.js +14 -1
  61. package/dist/snapshot-chunks.js.map +1 -1
  62. package/dist/stats.d.ts.map +1 -1
  63. package/dist/stats.js +1 -13
  64. package/dist/stats.js.map +1 -1
  65. package/dist/subscriptions/resolve.d.ts +1 -1
  66. package/dist/subscriptions/resolve.d.ts.map +1 -1
  67. package/dist/subscriptions/resolve.js +3 -16
  68. package/dist/subscriptions/resolve.js.map +1 -1
  69. package/dist/sync.d.ts.map +1 -1
  70. package/dist/sync.js +2 -4
  71. package/dist/sync.js.map +1 -1
  72. package/package.json +2 -2
  73. package/src/blobs/adapters/database.test.ts +7 -0
  74. package/src/blobs/adapters/database.ts +119 -39
  75. package/src/blobs/manager.ts +339 -53
  76. package/src/blobs/migrate.ts +16 -8
  77. package/src/blobs/types.ts +4 -0
  78. package/src/dialect/helpers.ts +19 -0
  79. package/src/handlers/collection.ts +17 -86
  80. package/src/handlers/create-handler.ts +9 -44
  81. package/src/notify.ts +15 -40
  82. package/src/proxy/collection.ts +5 -27
  83. package/src/proxy/handler.ts +2 -2
  84. package/src/proxy/index.ts +0 -2
  85. package/src/proxy/oplog.ts +1 -9
  86. package/src/prune.ts +1 -12
  87. package/src/pull.ts +280 -105
  88. package/src/push.ts +14 -43
  89. package/src/snapshot-chunks/db-metadata.ts +107 -27
  90. package/src/snapshot-chunks.ts +18 -0
  91. package/src/stats.ts +1 -12
  92. package/src/subscriptions/resolve.ts +4 -20
  93. package/src/sync.ts +6 -6
@@ -19,6 +19,27 @@ import type { Kysely } from 'kysely';
19
19
  import type { SyncCoreDb } from '../schema';
20
20
  import type { SnapshotChunkMetadata, SnapshotChunkPageKey } from './types';
21
21
 
22
+ export interface SnapshotChunkCleanupTuning {
23
+ /** Cleanup select/delete batch size. Default: 250 */
24
+ batchSize?: number;
25
+ /** Max concurrent blob deletes during cleanup. Default: 8 */
26
+ deleteConcurrency?: number;
27
+ }
28
+
29
+ export const SNAPSHOT_CHUNK_CLEANUP_TUNING_PRESETS = {
30
+ server: {
31
+ batchSize: 500,
32
+ deleteConcurrency: 16,
33
+ },
34
+ edge: {
35
+ batchSize: 100,
36
+ deleteConcurrency: 4,
37
+ },
38
+ } as const satisfies Record<
39
+ 'server' | 'edge',
40
+ Required<SnapshotChunkCleanupTuning>
41
+ >;
42
+
22
43
  export interface DbMetadataSnapshotChunkStorageOptions {
23
44
  /** Database instance */
24
45
  db: Kysely<SyncCoreDb>;
@@ -26,6 +47,8 @@ export interface DbMetadataSnapshotChunkStorageOptions {
26
47
  blobAdapter: BlobStorageAdapter;
27
48
  /** Optional prefix for chunk IDs */
28
49
  chunkIdPrefix?: string;
50
+ /** Optional cleanup throughput tuning knobs. */
51
+ cleanupTuning?: SnapshotChunkCleanupTuning;
29
52
  }
30
53
 
31
54
  /**
@@ -63,7 +86,23 @@ export function createDbMetadataChunkStorage(
63
86
  ) => Promise<SyncSnapshotChunkRef | null>;
64
87
  cleanupExpired: (beforeIso: string) => Promise<number>;
65
88
  } {
66
- const { db, blobAdapter, chunkIdPrefix = 'chunk_' } = options;
89
+ const { db, blobAdapter, chunkIdPrefix = 'chunk_', cleanupTuning } = options;
90
+
91
+ function positiveIntOrDefault(value: number | undefined, fallback: number) {
92
+ if (value === undefined) return fallback;
93
+ if (!Number.isFinite(value)) return fallback;
94
+ const normalized = Math.trunc(value);
95
+ return normalized > 0 ? normalized : fallback;
96
+ }
97
+
98
+ const CLEANUP_BATCH_SIZE = positiveIntOrDefault(
99
+ cleanupTuning?.batchSize,
100
+ 250
101
+ );
102
+ const CLEANUP_DELETE_CONCURRENCY = positiveIntOrDefault(
103
+ cleanupTuning?.deleteConcurrency,
104
+ 8
105
+ );
67
106
 
68
107
  // Generate deterministic blob hash from chunk identity metadata.
69
108
  async function computeBlobHash(metadata: {
@@ -132,6 +171,29 @@ export function createDbMetadataChunkStorage(
132
171
  }
133
172
  }
134
173
 
174
+ async function runWithConcurrency<T>(
175
+ items: readonly T[],
176
+ concurrency: number,
177
+ worker: (item: T) => Promise<void>
178
+ ): Promise<void> {
179
+ if (items.length === 0) return;
180
+
181
+ const workerCount = Math.max(1, Math.min(concurrency, items.length));
182
+ let nextIndex = 0;
183
+
184
+ async function runWorker(): Promise<void> {
185
+ while (nextIndex < items.length) {
186
+ const index = nextIndex;
187
+ nextIndex += 1;
188
+ const item = items[index];
189
+ if (item === undefined) continue;
190
+ await worker(item);
191
+ }
192
+ }
193
+
194
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
195
+ }
196
+
135
197
  // Generate unique chunk ID
136
198
  function generateChunkId(): string {
137
199
  return `${chunkIdPrefix}${randomId()}`;
@@ -444,35 +506,53 @@ export function createDbMetadataChunkStorage(
444
506
  },
445
507
 
446
508
  async cleanupExpired(beforeIso: string): Promise<number> {
447
- // Find expired chunks
448
- const expiredRows = await db
449
- .selectFrom('sync_snapshot_chunks')
450
- .select(['chunk_id', 'blob_hash'])
451
- .where('expires_at', '<=', beforeIso)
452
- .execute();
453
-
454
- if (expiredRows.length === 0) return 0;
455
-
456
- // Delete from blob storage (best effort)
457
- for (const row of expiredRows) {
458
- try {
459
- await blobAdapter.delete(row.blob_hash);
460
- } catch {
461
- // Ignore deletion errors - blob may be shared or already deleted
462
- // Log for observability but don't fail the cleanup
463
- console.warn(
464
- `Failed to delete blob ${row.blob_hash} for chunk ${row.chunk_id}, may be already deleted or shared`
465
- );
509
+ let deleted = 0;
510
+
511
+ while (true) {
512
+ const expiredRows = await db
513
+ .selectFrom('sync_snapshot_chunks')
514
+ .select(['chunk_id', 'blob_hash'])
515
+ .where('expires_at', '<=', beforeIso)
516
+ .orderBy('expires_at', 'asc')
517
+ .limit(CLEANUP_BATCH_SIZE)
518
+ .execute();
519
+
520
+ if (expiredRows.length === 0) {
521
+ break;
466
522
  }
467
- }
468
523
 
469
- // Delete metadata from database
470
- const result = await db
471
- .deleteFrom('sync_snapshot_chunks')
472
- .where('expires_at', '<=', beforeIso)
473
- .executeTakeFirst();
524
+ // Delete from blob storage (best effort).
525
+ await runWithConcurrency(
526
+ expiredRows,
527
+ CLEANUP_DELETE_CONCURRENCY,
528
+ async (row) => {
529
+ try {
530
+ await blobAdapter.delete(row.blob_hash);
531
+ } catch {
532
+ // Ignore deletion errors - blob may be shared or already deleted
533
+ // Log for observability but don't fail the cleanup
534
+ console.warn(
535
+ `Failed to delete blob ${row.blob_hash} for chunk ${row.chunk_id}, may be already deleted or shared`
536
+ );
537
+ }
538
+ }
539
+ );
540
+
541
+ const chunkIds = expiredRows.map((row) => row.chunk_id);
542
+ if (chunkIds.length > 0) {
543
+ const result = await db
544
+ .deleteFrom('sync_snapshot_chunks')
545
+ .where('chunk_id', 'in', chunkIds)
546
+ .executeTakeFirst();
547
+ deleted += Number(result.numDeletedRows ?? 0);
548
+ }
549
+
550
+ if (expiredRows.length < CLEANUP_BATCH_SIZE) {
551
+ break;
552
+ }
553
+ }
474
554
 
475
- return Number(result.numDeletedRows ?? 0);
555
+ return deleted;
476
556
  },
477
557
  };
478
558
  }
@@ -6,11 +6,13 @@
6
6
  */
7
7
 
8
8
  import {
9
+ type ScopeValues,
9
10
  SYNC_SNAPSHOT_CHUNK_COMPRESSION,
10
11
  SYNC_SNAPSHOT_CHUNK_ENCODING,
11
12
  type SyncSnapshotChunkCompression,
12
13
  type SyncSnapshotChunkEncoding,
13
14
  type SyncSnapshotChunkRef,
15
+ sha256Hex,
14
16
  } from '@syncular/core';
15
17
  import { type Kysely, sql } from 'kysely';
16
18
  import type { SyncCoreDb } from './schema';
@@ -42,6 +44,22 @@ export interface SnapshotChunkRow {
42
44
  expiresAt: string;
43
45
  }
44
46
 
47
+ /**
48
+ * Generate a stable cache key for snapshot chunks from effective scopes.
49
+ */
50
+ export async function scopesToSnapshotChunkScopeKey(
51
+ scopes: ScopeValues
52
+ ): Promise<string> {
53
+ const sorted = Object.entries(scopes)
54
+ .sort(([a], [b]) => a.localeCompare(b))
55
+ .map(([key, value]) => {
56
+ const values = Array.isArray(value) ? [...value].sort() : [value];
57
+ return `${key}:${values.join(',')}`;
58
+ })
59
+ .join('|');
60
+ return sha256Hex(sorted);
61
+ }
62
+
45
63
  function coerceChunkRow(value: unknown): Uint8Array {
46
64
  // pg returns Buffer (subclass of Uint8Array); sqlite returns Uint8Array
47
65
  if (value instanceof Uint8Array) return value;
package/src/stats.ts CHANGED
@@ -4,23 +4,12 @@
4
4
 
5
5
  import type { Kysely, SelectQueryBuilder, SqlBool } from 'kysely';
6
6
  import { sql } from 'kysely';
7
+ import { coerceNumber } from './dialect/helpers';
7
8
  import type { SyncCoreDb } from './schema';
8
9
 
9
10
  // biome-ignore lint/complexity/noBannedTypes: Kysely uses `{}` as the initial "no selected columns yet" marker.
10
11
  type EmptySelection = {};
11
12
 
12
- function coerceNumber(value: unknown): number | null {
13
- if (value === null || value === undefined) return null;
14
- if (typeof value === 'number') return Number.isFinite(value) ? value : null;
15
- if (typeof value === 'bigint')
16
- return Number.isFinite(Number(value)) ? Number(value) : null;
17
- if (typeof value === 'string') {
18
- const n = Number(value);
19
- return Number.isFinite(n) ? n : null;
20
- }
21
- return null;
22
- }
23
-
24
13
  export interface SyncStats {
25
14
  commitCount: number;
26
15
  changeCount: number;
@@ -1,13 +1,10 @@
1
1
  import {
2
- extractScopeVars,
2
+ collectScopeVars,
3
3
  type ScopeValues,
4
4
  type SyncSubscriptionRequest,
5
5
  } from '@syncular/core';
6
6
  import type { Kysely } from 'kysely';
7
- import {
8
- getServerHandler,
9
- type ServerHandlerCollection,
10
- } from '../handlers/collection';
7
+ import type { ServerHandlerCollection } from '../handlers/collection';
11
8
  import type { SyncServerAuth } from '../handlers/types';
12
9
  import type { SyncCoreDb } from '../schema';
13
10
  import { createDefaultScopeCacheKey, type ScopeCacheBackend } from './cache';
@@ -92,19 +89,6 @@ function scopesEmpty(scopes: ScopeValues): boolean {
92
89
  return true;
93
90
  }
94
91
 
95
- /**
96
- * Collect valid scope keys from handler scope patterns.
97
- */
98
- function collectScopeKeys(scopePatterns: readonly string[]): Set<string> {
99
- const keys = new Set<string>();
100
- for (const pattern of scopePatterns) {
101
- for (const key of extractScopeVars(pattern)) {
102
- keys.add(key);
103
- }
104
- }
105
- return keys;
106
- }
107
-
108
92
  function validateScopeKeys(args: {
109
93
  scopeValues: ScopeValues;
110
94
  validScopeKeys: Set<string>;
@@ -166,14 +150,14 @@ export async function resolveEffectiveScopesForSubscriptions<
166
150
  );
167
151
  }
168
152
 
169
- const handler = getServerHandler(args.handlers, sub.table);
153
+ const handler = args.handlers.byTable.get(sub.table);
170
154
  if (!handler) {
171
155
  throw new InvalidSubscriptionScopeError(
172
156
  `Unknown table: ${sub.table} for subscription ${sub.id}`
173
157
  );
174
158
  }
175
159
 
176
- const validScopeKeys = collectScopeKeys(handler.scopePatterns);
160
+ const validScopeKeys = collectScopeVars(handler.scopePatterns);
177
161
  const requested = sub.scopes ?? {};
178
162
  validateScopeKeys({
179
163
  scopeValues: requested,
package/src/sync.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  ColumnCodecSource,
4
4
  ScopeDefinition,
5
5
  } from '@syncular/core';
6
+ import { registerTableOrThrow } from '@syncular/core';
6
7
  import {
7
8
  type CreateServerHandlerOptions,
8
9
  createServerHandler,
@@ -85,11 +86,11 @@ export function defineServerSync<
85
86
  ScopeDefs
86
87
  >
87
88
  ) {
88
- if (registeredTables.has(handlerOptions.table)) {
89
- throw new Error(
90
- `Server table handler already registered: ${handlerOptions.table}`
91
- );
92
- }
89
+ registerTableOrThrow(
90
+ registeredTables,
91
+ handlerOptions.table,
92
+ (table) => `Server table handler already registered: ${table}`
93
+ );
93
94
 
94
95
  handlers.push(
95
96
  createServerHandler({
@@ -98,7 +99,6 @@ export function defineServerSync<
98
99
  codecDialect: options.codecDialect,
99
100
  })
100
101
  );
101
- registeredTables.add(handlerOptions.table);
102
102
  return sync;
103
103
  },
104
104
  addPushPlugin(plugin: SyncServerPushPlugin<ServerDB, Auth>) {