@syncular/server 0.0.1-60

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 (211) hide show
  1. package/dist/blobs/adapters/database.d.ts +83 -0
  2. package/dist/blobs/adapters/database.d.ts.map +1 -0
  3. package/dist/blobs/adapters/database.js +180 -0
  4. package/dist/blobs/adapters/database.js.map +1 -0
  5. package/dist/blobs/adapters/s3.d.ts +82 -0
  6. package/dist/blobs/adapters/s3.d.ts.map +1 -0
  7. package/dist/blobs/adapters/s3.js +170 -0
  8. package/dist/blobs/adapters/s3.js.map +1 -0
  9. package/dist/blobs/index.d.ts +9 -0
  10. package/dist/blobs/index.d.ts.map +1 -0
  11. package/dist/blobs/index.js +9 -0
  12. package/dist/blobs/index.js.map +1 -0
  13. package/dist/blobs/manager.d.ts +195 -0
  14. package/dist/blobs/manager.d.ts.map +1 -0
  15. package/dist/blobs/manager.js +440 -0
  16. package/dist/blobs/manager.js.map +1 -0
  17. package/dist/blobs/migrate.d.ts +27 -0
  18. package/dist/blobs/migrate.d.ts.map +1 -0
  19. package/dist/blobs/migrate.js +119 -0
  20. package/dist/blobs/migrate.js.map +1 -0
  21. package/dist/blobs/types.d.ts +54 -0
  22. package/dist/blobs/types.d.ts.map +1 -0
  23. package/dist/blobs/types.js +5 -0
  24. package/dist/blobs/types.js.map +1 -0
  25. package/dist/clients.d.ts +14 -0
  26. package/dist/clients.d.ts.map +1 -0
  27. package/dist/clients.js +7 -0
  28. package/dist/clients.js.map +1 -0
  29. package/dist/compaction.d.ts +27 -0
  30. package/dist/compaction.d.ts.map +1 -0
  31. package/dist/compaction.js +49 -0
  32. package/dist/compaction.js.map +1 -0
  33. package/dist/dialect/index.d.ts +5 -0
  34. package/dist/dialect/index.d.ts.map +1 -0
  35. package/dist/dialect/index.js +5 -0
  36. package/dist/dialect/index.js.map +1 -0
  37. package/dist/dialect/types.d.ts +170 -0
  38. package/dist/dialect/types.d.ts.map +1 -0
  39. package/dist/dialect/types.js +8 -0
  40. package/dist/dialect/types.js.map +1 -0
  41. package/dist/helpers/conflict.d.ts +52 -0
  42. package/dist/helpers/conflict.d.ts.map +1 -0
  43. package/dist/helpers/conflict.js +49 -0
  44. package/dist/helpers/conflict.js.map +1 -0
  45. package/dist/helpers/emitted-change.d.ts +56 -0
  46. package/dist/helpers/emitted-change.d.ts.map +1 -0
  47. package/dist/helpers/emitted-change.js +46 -0
  48. package/dist/helpers/emitted-change.js.map +1 -0
  49. package/dist/helpers/index.d.ts +10 -0
  50. package/dist/helpers/index.d.ts.map +1 -0
  51. package/dist/helpers/index.js +10 -0
  52. package/dist/helpers/index.js.map +1 -0
  53. package/dist/helpers/paginate.d.ts +49 -0
  54. package/dist/helpers/paginate.d.ts.map +1 -0
  55. package/dist/helpers/paginate.js +54 -0
  56. package/dist/helpers/paginate.js.map +1 -0
  57. package/dist/helpers/scope-strings.d.ts +74 -0
  58. package/dist/helpers/scope-strings.d.ts.map +1 -0
  59. package/dist/helpers/scope-strings.js +82 -0
  60. package/dist/helpers/scope-strings.js.map +1 -0
  61. package/dist/index.d.ts +28 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +27 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/migrate.d.ts +14 -0
  66. package/dist/migrate.d.ts.map +1 -0
  67. package/dist/migrate.js +13 -0
  68. package/dist/migrate.js.map +1 -0
  69. package/dist/proxy/handler.d.ts +42 -0
  70. package/dist/proxy/handler.d.ts.map +1 -0
  71. package/dist/proxy/handler.js +99 -0
  72. package/dist/proxy/handler.js.map +1 -0
  73. package/dist/proxy/index.d.ts +9 -0
  74. package/dist/proxy/index.d.ts.map +1 -0
  75. package/dist/proxy/index.js +14 -0
  76. package/dist/proxy/index.js.map +1 -0
  77. package/dist/proxy/mutation-detector.d.ts +31 -0
  78. package/dist/proxy/mutation-detector.d.ts.map +1 -0
  79. package/dist/proxy/mutation-detector.js +61 -0
  80. package/dist/proxy/mutation-detector.js.map +1 -0
  81. package/dist/proxy/oplog.d.ts +30 -0
  82. package/dist/proxy/oplog.d.ts.map +1 -0
  83. package/dist/proxy/oplog.js +110 -0
  84. package/dist/proxy/oplog.js.map +1 -0
  85. package/dist/proxy/registry.d.ts +35 -0
  86. package/dist/proxy/registry.d.ts.map +1 -0
  87. package/dist/proxy/registry.js +49 -0
  88. package/dist/proxy/registry.js.map +1 -0
  89. package/dist/proxy/types.d.ts +44 -0
  90. package/dist/proxy/types.d.ts.map +1 -0
  91. package/dist/proxy/types.js +7 -0
  92. package/dist/proxy/types.js.map +1 -0
  93. package/dist/prune.d.ts +37 -0
  94. package/dist/prune.d.ts.map +1 -0
  95. package/dist/prune.js +112 -0
  96. package/dist/prune.js.map +1 -0
  97. package/dist/pull.d.ts +31 -0
  98. package/dist/pull.d.ts.map +1 -0
  99. package/dist/pull.js +414 -0
  100. package/dist/pull.js.map +1 -0
  101. package/dist/push.d.ts +33 -0
  102. package/dist/push.d.ts.map +1 -0
  103. package/dist/push.js +329 -0
  104. package/dist/push.js.map +1 -0
  105. package/dist/realtime/in-memory.d.ts +13 -0
  106. package/dist/realtime/in-memory.d.ts.map +1 -0
  107. package/dist/realtime/in-memory.js +28 -0
  108. package/dist/realtime/in-memory.js.map +1 -0
  109. package/dist/realtime/index.d.ts +3 -0
  110. package/dist/realtime/index.d.ts.map +1 -0
  111. package/dist/realtime/index.js +2 -0
  112. package/dist/realtime/index.js.map +1 -0
  113. package/dist/realtime/types.d.ts +50 -0
  114. package/dist/realtime/types.d.ts.map +1 -0
  115. package/dist/realtime/types.js +7 -0
  116. package/dist/realtime/types.js.map +1 -0
  117. package/dist/schema.d.ts +164 -0
  118. package/dist/schema.d.ts.map +1 -0
  119. package/dist/schema.js +10 -0
  120. package/dist/schema.js.map +1 -0
  121. package/dist/shapes/create-handler.d.ts +119 -0
  122. package/dist/shapes/create-handler.d.ts.map +1 -0
  123. package/dist/shapes/create-handler.js +327 -0
  124. package/dist/shapes/create-handler.js.map +1 -0
  125. package/dist/shapes/index.d.ts +4 -0
  126. package/dist/shapes/index.d.ts.map +1 -0
  127. package/dist/shapes/index.js +4 -0
  128. package/dist/shapes/index.js.map +1 -0
  129. package/dist/shapes/registry.d.ts +20 -0
  130. package/dist/shapes/registry.d.ts.map +1 -0
  131. package/dist/shapes/registry.js +88 -0
  132. package/dist/shapes/registry.js.map +1 -0
  133. package/dist/shapes/types.d.ts +204 -0
  134. package/dist/shapes/types.d.ts.map +1 -0
  135. package/dist/shapes/types.js +2 -0
  136. package/dist/shapes/types.js.map +1 -0
  137. package/dist/snapshot-chunks/adapters/s3.d.ts +63 -0
  138. package/dist/snapshot-chunks/adapters/s3.d.ts.map +1 -0
  139. package/dist/snapshot-chunks/adapters/s3.js +50 -0
  140. package/dist/snapshot-chunks/adapters/s3.js.map +1 -0
  141. package/dist/snapshot-chunks/db-metadata.d.ts +33 -0
  142. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -0
  143. package/dist/snapshot-chunks/db-metadata.js +169 -0
  144. package/dist/snapshot-chunks/db-metadata.js.map +1 -0
  145. package/dist/snapshot-chunks/index.d.ts +9 -0
  146. package/dist/snapshot-chunks/index.d.ts.map +1 -0
  147. package/dist/snapshot-chunks/index.js +9 -0
  148. package/dist/snapshot-chunks/index.js.map +1 -0
  149. package/dist/snapshot-chunks/types.d.ts +65 -0
  150. package/dist/snapshot-chunks/types.d.ts.map +1 -0
  151. package/dist/snapshot-chunks/types.js +8 -0
  152. package/dist/snapshot-chunks/types.js.map +1 -0
  153. package/dist/snapshot-chunks.d.ts +59 -0
  154. package/dist/snapshot-chunks.d.ts.map +1 -0
  155. package/dist/snapshot-chunks.js +202 -0
  156. package/dist/snapshot-chunks.js.map +1 -0
  157. package/dist/stats.d.ts +19 -0
  158. package/dist/stats.d.ts.map +1 -0
  159. package/dist/stats.js +57 -0
  160. package/dist/stats.js.map +1 -0
  161. package/dist/subscriptions/index.d.ts +2 -0
  162. package/dist/subscriptions/index.d.ts.map +1 -0
  163. package/dist/subscriptions/index.js +2 -0
  164. package/dist/subscriptions/index.js.map +1 -0
  165. package/dist/subscriptions/resolve.d.ts +35 -0
  166. package/dist/subscriptions/resolve.d.ts.map +1 -0
  167. package/dist/subscriptions/resolve.js +134 -0
  168. package/dist/subscriptions/resolve.js.map +1 -0
  169. package/package.json +80 -0
  170. package/src/blobs/adapters/database.ts +290 -0
  171. package/src/blobs/adapters/s3.ts +271 -0
  172. package/src/blobs/index.ts +9 -0
  173. package/src/blobs/manager.ts +600 -0
  174. package/src/blobs/migrate.ts +150 -0
  175. package/src/blobs/types.ts +70 -0
  176. package/src/clients.ts +21 -0
  177. package/src/compaction.ts +77 -0
  178. package/src/dialect/index.ts +5 -0
  179. package/src/dialect/types.ts +222 -0
  180. package/src/helpers/conflict.ts +64 -0
  181. package/src/helpers/emitted-change.ts +69 -0
  182. package/src/helpers/index.ts +10 -0
  183. package/src/helpers/paginate.ts +82 -0
  184. package/src/helpers/scope-strings.ts +101 -0
  185. package/src/index.ts +28 -0
  186. package/src/migrate.ts +20 -0
  187. package/src/proxy/handler.ts +152 -0
  188. package/src/proxy/index.ts +18 -0
  189. package/src/proxy/mutation-detector.ts +83 -0
  190. package/src/proxy/oplog.ts +144 -0
  191. package/src/proxy/registry.ts +56 -0
  192. package/src/proxy/types.ts +46 -0
  193. package/src/prune.ts +200 -0
  194. package/src/pull.ts +551 -0
  195. package/src/push.ts +457 -0
  196. package/src/realtime/in-memory.ts +33 -0
  197. package/src/realtime/index.ts +5 -0
  198. package/src/realtime/types.ts +55 -0
  199. package/src/schema.ts +172 -0
  200. package/src/shapes/create-handler.ts +590 -0
  201. package/src/shapes/index.ts +3 -0
  202. package/src/shapes/registry.ts +109 -0
  203. package/src/shapes/types.ts +267 -0
  204. package/src/snapshot-chunks/adapters/s3.ts +68 -0
  205. package/src/snapshot-chunks/db-metadata.ts +238 -0
  206. package/src/snapshot-chunks/index.ts +9 -0
  207. package/src/snapshot-chunks/types.ts +79 -0
  208. package/src/snapshot-chunks.ts +301 -0
  209. package/src/stats.ts +104 -0
  210. package/src/subscriptions/index.ts +1 -0
  211. package/src/subscriptions/resolve.ts +185 -0
@@ -0,0 +1,267 @@
1
+ import type {
2
+ ScopePattern,
3
+ ScopeValues,
4
+ StoredScopes,
5
+ SyncOp,
6
+ SyncOperation,
7
+ SyncOperationResult,
8
+ } from '@syncular/core';
9
+ import type { ZodSchema, z } from 'zod';
10
+ import type { DbExecutor } from '../dialect/types';
11
+ import type { SyncCoreDb } from '../schema';
12
+
13
+ /**
14
+ * Emitted change to be stored in the oplog.
15
+ * Uses JSONB scopes instead of scope_keys array.
16
+ */
17
+ export interface EmittedChange {
18
+ /** Table name */
19
+ table: string;
20
+ /** Row primary key */
21
+ row_id: string;
22
+ /** Operation type */
23
+ op: SyncOp;
24
+ /** Row data as JSON (null for deletes) */
25
+ row_json: unknown | null;
26
+ /** Row version for optimistic concurrency */
27
+ row_version: number | null;
28
+ /**
29
+ * Scope values for this change (stored as JSONB).
30
+ * Example: { user_id: 'U1', project_id: 'P1' }
31
+ */
32
+ scopes: StoredScopes;
33
+ }
34
+
35
+ export interface ApplyOperationResult {
36
+ result: SyncOperationResult;
37
+ emittedChanges: EmittedChange[];
38
+ }
39
+
40
+ /**
41
+ * Context for server operations.
42
+ */
43
+ export interface ServerContext<DB extends SyncCoreDb = SyncCoreDb> {
44
+ /** Database connection (transaction in applyOperation) */
45
+ db: DbExecutor<DB>;
46
+ /** Actor ID (user ID from auth) */
47
+ actorId: string;
48
+ }
49
+
50
+ /**
51
+ * Context passed to snapshot method.
52
+ */
53
+ export interface ServerSnapshotContext<DB extends SyncCoreDb = SyncCoreDb>
54
+ extends ServerContext<DB> {
55
+ /** Database executor for the snapshot */
56
+ db: DbExecutor<DB>;
57
+ /** Effective scope values for this subscription */
58
+ scopeValues: ScopeValues;
59
+ /** Pagination cursor (row_id for keyset pagination) */
60
+ cursor: string | null;
61
+ /** Max rows to return */
62
+ limit: number;
63
+ }
64
+
65
+ /**
66
+ * Context passed to applyOperation method.
67
+ */
68
+ export interface ServerApplyOperationContext<DB extends SyncCoreDb = SyncCoreDb>
69
+ extends ServerContext<DB> {
70
+ /** Database executor for the operation */
71
+ trx: DbExecutor<DB>;
72
+ /** Client/device identifier */
73
+ clientId: string;
74
+ /** Unique commit identifier */
75
+ commitId: string;
76
+ /**
77
+ * Client's schema version when the commit was created.
78
+ * Use this to transform payloads from older client versions.
79
+ */
80
+ schemaVersion: number;
81
+ }
82
+
83
+ /**
84
+ * Server-side scope configuration for advanced use cases.
85
+ * Use this when you need custom extraction, access control, or filtering.
86
+ *
87
+ * For simple cases, use the simplified scope array format:
88
+ * `scopes: ['user:{user_id}']`
89
+ */
90
+ interface ServerScopeConfig {
91
+ /**
92
+ * Column name containing the scope value.
93
+ * For simple patterns like 'user:{user_id}' → column: 'user_id'
94
+ */
95
+ column?: string;
96
+
97
+ /**
98
+ * Custom extractor for complex patterns.
99
+ * Example: extract year/month from a date column
100
+ */
101
+ extract?: (row: Record<string, unknown>) => Record<string, string>;
102
+
103
+ /**
104
+ * Optional access control per scope pattern.
105
+ * Return true if the actor can access this scope value.
106
+ */
107
+ access?: (
108
+ ctx: ServerContext,
109
+ vars: Record<string, string>
110
+ ) => Promise<boolean>;
111
+
112
+ /**
113
+ * Optional filter builder for wildcard subscriptions.
114
+ * Called when the subscription uses wildcards for this pattern.
115
+ */
116
+ toFilter?: (
117
+ vars: Record<string, string | undefined>,
118
+ query: unknown
119
+ ) => unknown;
120
+ }
121
+
122
+ /**
123
+ * Server shape options - configuration for a table's sync behavior.
124
+ */
125
+ export interface ServerShapeOptions<
126
+ DB extends SyncCoreDb = SyncCoreDb,
127
+ Scopes extends Record<ScopePattern, Record<string, string>> = Record<
128
+ ScopePattern,
129
+ Record<string, string>
130
+ >,
131
+ TableName extends string = string,
132
+ Params extends ZodSchema = ZodSchema,
133
+ > {
134
+ /**
135
+ * Scope patterns this shape uses.
136
+ * Array of pattern keys from SharedScopes.
137
+ */
138
+ scopes: (keyof Scopes)[];
139
+
140
+ /**
141
+ * Scope definitions - how each pattern maps to row data.
142
+ * Defaults to using column name = variable name.
143
+ */
144
+ scopeDefinitions?: Partial<Record<keyof Scopes, ServerScopeConfig>>;
145
+
146
+ /**
147
+ * Resolve allowed scope values for the current actor.
148
+ * Called once per request to determine what the actor can access.
149
+ *
150
+ * Returns scope values the actor is allowed to access.
151
+ * The server will intersect requested scopes with these.
152
+ *
153
+ * @example
154
+ * resolveScopes: async (ctx) => ({
155
+ * user_id: [ctx.user.id],
156
+ * project_id: ctx.user.projectIds,
157
+ * })
158
+ */
159
+ resolveScopes: (ctx: ServerContext<DB>) => Promise<ScopeValues>;
160
+
161
+ /**
162
+ * Optional Zod schema for subscription parameters.
163
+ */
164
+ params?: Params;
165
+
166
+ /**
167
+ * Primary key column (default: 'id')
168
+ */
169
+ primaryKey?: string;
170
+
171
+ /**
172
+ * Version column for optimistic concurrency (default: 'server_version')
173
+ */
174
+ versionColumn?: string;
175
+
176
+ /**
177
+ * Tables that must be bootstrapped before this one.
178
+ */
179
+ dependsOn?: string[];
180
+
181
+ /**
182
+ * TTL for cached snapshot chunks (ms). Default: 24 hours.
183
+ */
184
+ snapshotChunkTtlMs?: number;
185
+
186
+ /**
187
+ * Transform client payload → server row on writes.
188
+ */
189
+ transformInbound?: (
190
+ payload: Record<string, unknown>,
191
+ ctx: ServerApplyOperationContext<DB>
192
+ ) => Partial<DB[TableName & keyof DB]>;
193
+
194
+ /**
195
+ * Transform server row → client payload on reads.
196
+ */
197
+ transformOutbound?: (
198
+ row: DB[TableName & keyof DB]
199
+ ) => Record<string, unknown>;
200
+
201
+ /**
202
+ * Custom snapshot implementation.
203
+ * Default uses keyset pagination ordered by primary key.
204
+ */
205
+ snapshot?: (
206
+ ctx: ServerSnapshotContext<DB>,
207
+ params: Params extends ZodSchema ? z.infer<Params> : undefined
208
+ ) => Promise<{ rows: unknown[]; nextCursor: string | null }>;
209
+
210
+ /**
211
+ * Custom apply operation implementation.
212
+ */
213
+ applyOperation?: (
214
+ ctx: ServerApplyOperationContext<DB>,
215
+ op: SyncOperation,
216
+ opIndex: number
217
+ ) => Promise<ApplyOperationResult>;
218
+ }
219
+
220
+ /**
221
+ * Server-side table handler for snapshots and mutations.
222
+ * This is the internal handler interface used by the sync engine.
223
+ */
224
+ export interface ServerTableHandler<DB extends SyncCoreDb = SyncCoreDb> {
225
+ /** Table name */
226
+ table: string;
227
+
228
+ /** Scope patterns used by this shape */
229
+ scopePatterns: ScopePattern[];
230
+
231
+ /**
232
+ * Tables that must be bootstrapped before this one.
233
+ */
234
+ dependsOn?: string[];
235
+
236
+ /**
237
+ * TTL for cached snapshot chunks (ms).
238
+ */
239
+ snapshotChunkTtlMs?: number;
240
+
241
+ /**
242
+ * Resolve allowed scope values for the current actor.
243
+ */
244
+ resolveScopes: (ctx: ServerContext<DB>) => Promise<ScopeValues>;
245
+
246
+ /**
247
+ * Extract stored scopes from a row.
248
+ */
249
+ extractScopes: (row: Record<string, unknown>) => StoredScopes;
250
+
251
+ /**
252
+ * Build a bootstrap snapshot page.
253
+ */
254
+ snapshot(
255
+ ctx: ServerSnapshotContext<DB>,
256
+ params: Record<string, unknown> | undefined
257
+ ): Promise<{ rows: unknown[]; nextCursor: string | null }>;
258
+
259
+ /**
260
+ * Apply a single operation.
261
+ */
262
+ applyOperation(
263
+ ctx: ServerApplyOperationContext<DB>,
264
+ op: SyncOperation,
265
+ opIndex: number
266
+ ): Promise<ApplyOperationResult>;
267
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @syncular/server - S3-compatible snapshot chunk storage adapter
3
+ *
4
+ * Stores snapshot chunk bodies in S3/R2/MinIO with metadata in database.
5
+ */
6
+
7
+ import type { BlobStorageAdapter } from '@syncular/core';
8
+ import type { Kysely } from 'kysely';
9
+ import type { SyncCoreDb } from '../../schema';
10
+ import { createDbMetadataChunkStorage } from '../db-metadata';
11
+
12
+ export interface S3SnapshotChunkStorageOptions {
13
+ /** Database instance for metadata */
14
+ db: Kysely<SyncCoreDb>;
15
+ /** S3 blob storage adapter */
16
+ s3Adapter: BlobStorageAdapter;
17
+ /** Optional key prefix for all chunks */
18
+ keyPrefix?: string;
19
+ }
20
+
21
+ /**
22
+ * Create S3-compatible snapshot chunk storage.
23
+ *
24
+ * Stores chunk bodies in S3/R2/MinIO and metadata in the database.
25
+ * Supports presigned URLs for direct client downloads.
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * import { createS3BlobStorageAdapter } from '@syncular/server/blobs/adapters/s3';
30
+ * import { createS3SnapshotChunkStorage } from '@syncular/server/snapshot-chunks/adapters/s3';
31
+ *
32
+ * const s3Adapter = createS3BlobStorageAdapter({
33
+ * client: new S3Client({ region: 'us-east-1' }),
34
+ * bucket: 'my-snapshot-chunks',
35
+ * commands: { PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand },
36
+ * getSignedUrl,
37
+ * });
38
+ *
39
+ * const chunkStorage = createS3SnapshotChunkStorage({
40
+ * db: kysely,
41
+ * s3Adapter,
42
+ * keyPrefix: 'snapshots/',
43
+ * });
44
+ * ```
45
+ */
46
+ export function createS3SnapshotChunkStorage(
47
+ options: S3SnapshotChunkStorageOptions
48
+ ) {
49
+ const { db, s3Adapter, keyPrefix } = options;
50
+
51
+ // Wrap the S3 adapter to use prefixed keys
52
+ const prefixedAdapter: BlobStorageAdapter = keyPrefix
53
+ ? {
54
+ ...s3Adapter,
55
+ name: `${s3Adapter.name}+prefixed`,
56
+ // Keys are already handled by the S3 adapter, prefix is applied there
57
+ }
58
+ : s3Adapter;
59
+
60
+ // Use the database metadata storage with S3 for bodies
61
+ const storage = createDbMetadataChunkStorage({
62
+ db,
63
+ blobAdapter: prefixedAdapter,
64
+ chunkIdPrefix: keyPrefix ? `${keyPrefix.replace(/\/$/, '')}_` : 'chunk_',
65
+ });
66
+
67
+ return storage;
68
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * @syncular/server - Database-backed metadata store for snapshot chunks
3
+ *
4
+ * Stores chunk metadata in sync_snapshot_chunks_metadata table,
5
+ * body content in blob storage adapter.
6
+ */
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import type { BlobStorageAdapter, SyncSnapshotChunkRef } from '@syncular/core';
10
+ import type { Kysely } from 'kysely';
11
+ import type { SyncCoreDb } from '../schema';
12
+ import type { SnapshotChunkMetadata, SnapshotChunkPageKey } from './types';
13
+
14
+ export interface DbMetadataSnapshotChunkStorageOptions {
15
+ /** Database instance */
16
+ db: Kysely<SyncCoreDb>;
17
+ /** Blob storage adapter for body content */
18
+ blobAdapter: BlobStorageAdapter;
19
+ /** Optional prefix for chunk IDs */
20
+ chunkIdPrefix?: string;
21
+ }
22
+
23
+ /**
24
+ * Create a snapshot chunk storage that uses:
25
+ * - Database for metadata (scope, commit seq, etc.)
26
+ * - Blob adapter for body content
27
+ */
28
+ export function createDbMetadataChunkStorage(
29
+ options: DbMetadataSnapshotChunkStorageOptions
30
+ ): {
31
+ name: string;
32
+ storeChunk: (
33
+ metadata: Omit<
34
+ SnapshotChunkMetadata,
35
+ 'chunkId' | 'byteLength' | 'blobHash'
36
+ > & {
37
+ body: Uint8Array;
38
+ }
39
+ ) => Promise<SyncSnapshotChunkRef>;
40
+ readChunk: (chunkId: string) => Promise<Uint8Array | null>;
41
+ findChunk: (
42
+ pageKey: SnapshotChunkPageKey
43
+ ) => Promise<SyncSnapshotChunkRef | null>;
44
+ cleanupExpired: (beforeIso: string) => Promise<number>;
45
+ } {
46
+ const { db, blobAdapter, chunkIdPrefix = 'chunk_' } = options;
47
+
48
+ // Generate deterministic blob hash from content
49
+ function computeBlobHash(body: Uint8Array): string {
50
+ return `sha256:${createHash('sha256').update(body).digest('hex')}`;
51
+ }
52
+
53
+ // Generate unique chunk ID
54
+ function generateChunkId(): string {
55
+ return `${chunkIdPrefix}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
56
+ }
57
+
58
+ return {
59
+ name: `db-metadata+${blobAdapter.name}`,
60
+
61
+ async storeChunk(
62
+ metadata: Omit<
63
+ SnapshotChunkMetadata,
64
+ 'chunkId' | 'byteLength' | 'blobHash'
65
+ > & {
66
+ body: Uint8Array;
67
+ }
68
+ ): Promise<SyncSnapshotChunkRef> {
69
+ const { body, ...metaWithoutBody } = metadata;
70
+ const blobHash = computeBlobHash(body);
71
+ const chunkId = generateChunkId();
72
+ const now = new Date().toISOString();
73
+
74
+ // Check if blob already exists (content-addressed dedup)
75
+ const blobExists = await blobAdapter.exists(blobHash);
76
+
77
+ if (!blobExists) {
78
+ // Store body in blob adapter
79
+ if (blobAdapter.put) {
80
+ await blobAdapter.put(blobHash, body);
81
+ } else {
82
+ throw new Error(
83
+ `Blob adapter ${blobAdapter.name} does not support direct put() for snapshot chunks`
84
+ );
85
+ }
86
+ }
87
+
88
+ // Upsert metadata in database
89
+ await db
90
+ .insertInto('sync_snapshot_chunks')
91
+ .values({
92
+ chunk_id: chunkId,
93
+ partition_id: metaWithoutBody.partitionId,
94
+ scope_key: metaWithoutBody.scopeKey,
95
+ scope: metaWithoutBody.scope,
96
+ as_of_commit_seq: metaWithoutBody.asOfCommitSeq,
97
+ row_cursor: metaWithoutBody.rowCursor ?? '',
98
+ row_limit: metaWithoutBody.rowLimit,
99
+ encoding: metaWithoutBody.encoding,
100
+ compression: metaWithoutBody.compression,
101
+ sha256: metaWithoutBody.sha256,
102
+ byte_length: body.length,
103
+ blob_hash: blobHash,
104
+ expires_at: metaWithoutBody.expiresAt,
105
+ created_at: now,
106
+ })
107
+ .onConflict((oc) =>
108
+ oc
109
+ .columns([
110
+ 'partition_id',
111
+ 'scope_key',
112
+ 'scope',
113
+ 'as_of_commit_seq',
114
+ 'row_cursor',
115
+ 'row_limit',
116
+ 'encoding',
117
+ 'compression',
118
+ ])
119
+ .doUpdateSet({
120
+ expires_at: metaWithoutBody.expiresAt,
121
+ blob_hash: blobHash,
122
+ sha256: metaWithoutBody.sha256,
123
+ byte_length: body.length,
124
+ row_cursor: metaWithoutBody.rowCursor ?? '',
125
+ })
126
+ )
127
+ .execute();
128
+
129
+ return {
130
+ id: chunkId,
131
+ sha256: metaWithoutBody.sha256,
132
+ byteLength: body.length,
133
+ encoding: metaWithoutBody.encoding,
134
+ compression: metaWithoutBody.compression,
135
+ };
136
+ },
137
+
138
+ async readChunk(chunkId: string): Promise<Uint8Array | null> {
139
+ // Get metadata to find blob hash
140
+ const row = await db
141
+ .selectFrom('sync_snapshot_chunks')
142
+ .select(['blob_hash'])
143
+ .where('chunk_id', '=', chunkId)
144
+ .executeTakeFirst();
145
+
146
+ if (!row) return null;
147
+
148
+ // Read from blob adapter
149
+ if (blobAdapter.get) {
150
+ return blobAdapter.get(row.blob_hash);
151
+ }
152
+
153
+ throw new Error(
154
+ `Blob adapter ${blobAdapter.name} does not support direct get() for snapshot chunks`
155
+ );
156
+ },
157
+
158
+ async findChunk(
159
+ pageKey: SnapshotChunkPageKey
160
+ ): Promise<SyncSnapshotChunkRef | null> {
161
+ const nowIso = new Date().toISOString();
162
+ const rowCursorKey = pageKey.rowCursor ?? '';
163
+
164
+ const row = await db
165
+ .selectFrom('sync_snapshot_chunks')
166
+ .select([
167
+ 'chunk_id',
168
+ 'sha256',
169
+ 'byte_length',
170
+ 'encoding',
171
+ 'compression',
172
+ ])
173
+ .where('partition_id', '=', pageKey.partitionId)
174
+ .where('scope_key', '=', pageKey.scopeKey)
175
+ .where('scope', '=', pageKey.scope)
176
+ .where('as_of_commit_seq', '=', pageKey.asOfCommitSeq)
177
+ .where('row_cursor', '=', rowCursorKey)
178
+ .where('row_limit', '=', pageKey.rowLimit)
179
+ .where('encoding', '=', pageKey.encoding)
180
+ .where('compression', '=', pageKey.compression)
181
+ .where('expires_at', '>', nowIso)
182
+ .executeTakeFirst();
183
+
184
+ if (!row) return null;
185
+
186
+ if (row.encoding !== 'ndjson') {
187
+ throw new Error(
188
+ `Unexpected snapshot chunk encoding: ${String(row.encoding)}`
189
+ );
190
+ }
191
+ if (row.compression !== 'gzip') {
192
+ throw new Error(
193
+ `Unexpected snapshot chunk compression: ${String(row.compression)}`
194
+ );
195
+ }
196
+
197
+ return {
198
+ id: row.chunk_id,
199
+ sha256: row.sha256,
200
+ byteLength: Number(row.byte_length ?? 0),
201
+ encoding: row.encoding,
202
+ compression: row.compression,
203
+ };
204
+ },
205
+
206
+ async cleanupExpired(beforeIso: string): Promise<number> {
207
+ // Find expired chunks
208
+ const expiredRows = await db
209
+ .selectFrom('sync_snapshot_chunks')
210
+ .select(['chunk_id', 'blob_hash'])
211
+ .where('expires_at', '<=', beforeIso)
212
+ .execute();
213
+
214
+ if (expiredRows.length === 0) return 0;
215
+
216
+ // Delete from blob storage (best effort)
217
+ for (const row of expiredRows) {
218
+ try {
219
+ await blobAdapter.delete(row.blob_hash);
220
+ } catch {
221
+ // Ignore deletion errors - blob may be shared or already deleted
222
+ // Log for observability but don't fail the cleanup
223
+ console.warn(
224
+ `Failed to delete blob ${row.blob_hash} for chunk ${row.chunk_id}, may be already deleted or shared`
225
+ );
226
+ }
227
+ }
228
+
229
+ // Delete metadata from database
230
+ const result = await db
231
+ .deleteFrom('sync_snapshot_chunks')
232
+ .where('expires_at', '<=', beforeIso)
233
+ .executeTakeFirst();
234
+
235
+ return Number(result.numDeletedRows ?? 0);
236
+ },
237
+ };
238
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @syncular/server - Snapshot chunk storage
3
+ *
4
+ * Separates chunk metadata (database) from body content (blob storage).
5
+ */
6
+
7
+ export * from './adapters/s3';
8
+ export * from './db-metadata';
9
+ export * from './types';
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @syncular/server - Snapshot chunk storage types
3
+ *
4
+ * Separates chunk metadata (in database) from chunk body (in blob storage).
5
+ * Enables flexible storage backends (database, S3, R2, etc.)
6
+ */
7
+
8
+ import type { SyncSnapshotChunkRef } from '@syncular/core';
9
+
10
+ /**
11
+ * Page key for identifying a specific chunk
12
+ */
13
+ export interface SnapshotChunkPageKey {
14
+ partitionId: string;
15
+ scopeKey: string;
16
+ scope: string;
17
+ asOfCommitSeq: number;
18
+ rowCursor: string | null;
19
+ rowLimit: number;
20
+ encoding: 'ndjson';
21
+ compression: 'gzip';
22
+ }
23
+
24
+ /**
25
+ * Metadata stored in the database for each chunk
26
+ */
27
+ export interface SnapshotChunkMetadata {
28
+ chunkId: string;
29
+ partitionId: string;
30
+ scopeKey: string;
31
+ scope: string;
32
+ asOfCommitSeq: number;
33
+ rowCursor: string | null;
34
+ rowLimit: number;
35
+ encoding: 'ndjson';
36
+ compression: 'gzip';
37
+ sha256: string;
38
+ byteLength: number;
39
+ blobHash: string; // Reference to blob storage
40
+ expiresAt: string;
41
+ }
42
+
43
+ /**
44
+ * Storage interface for snapshot chunks
45
+ */
46
+ export interface SnapshotChunkStorage {
47
+ /** Storage adapter name */
48
+ readonly name: string;
49
+
50
+ /**
51
+ * Store a chunk. Returns chunk reference.
52
+ * If chunk with same content already exists (by hash), returns existing reference.
53
+ */
54
+ storeChunk(
55
+ metadata: Omit<
56
+ SnapshotChunkMetadata,
57
+ 'chunkId' | 'byteLength' | 'blobHash'
58
+ > & {
59
+ body: Uint8Array;
60
+ }
61
+ ): Promise<SyncSnapshotChunkRef>;
62
+
63
+ /**
64
+ * Read chunk body by chunk ID
65
+ */
66
+ readChunk(chunkId: string): Promise<Uint8Array | null>;
67
+
68
+ /**
69
+ * Find existing chunk by page key
70
+ */
71
+ findChunk(
72
+ pageKey: SnapshotChunkPageKey
73
+ ): Promise<SyncSnapshotChunkRef | null>;
74
+
75
+ /**
76
+ * Delete expired chunks. Returns number deleted.
77
+ */
78
+ cleanupExpired(beforeIso: string): Promise<number>;
79
+ }