@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,301 @@
1
+ /**
2
+ * @syncular/server - Encoded snapshot chunk cache (server-side)
3
+ *
4
+ * Used for efficiently serving large bootstrap snapshots (e.g. catalogs)
5
+ * without embedding huge JSON payloads into pull responses.
6
+ */
7
+
8
+ import type { SyncSnapshotChunkRef } from '@syncular/core';
9
+ import { type Kysely, sql } from 'kysely';
10
+ import type { SyncCoreDb } from './schema';
11
+
12
+ export interface SnapshotChunkPageKey {
13
+ partitionId: string;
14
+ scopeKey: string;
15
+ scope: string;
16
+ asOfCommitSeq: number;
17
+ rowCursor: string | null;
18
+ rowLimit: number;
19
+ encoding: 'ndjson';
20
+ compression: 'gzip';
21
+ }
22
+
23
+ export interface SnapshotChunkRow {
24
+ chunkId: string;
25
+ partitionId: string;
26
+ scopeKey: string;
27
+ scope: string;
28
+ asOfCommitSeq: number;
29
+ rowCursor: string;
30
+ rowLimit: number;
31
+ encoding: 'ndjson';
32
+ compression: 'gzip';
33
+ sha256: string;
34
+ byteLength: number;
35
+ body: Uint8Array;
36
+ expiresAt: string;
37
+ }
38
+
39
+ function coerceChunkRow(value: unknown): Uint8Array {
40
+ // pg returns Buffer (subclass of Uint8Array); sqlite returns Uint8Array
41
+ if (value instanceof Uint8Array) return value;
42
+ if (typeof Buffer !== 'undefined' && value instanceof Buffer) return value;
43
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
44
+ if (Array.isArray(value) && value.every((v) => typeof v === 'number')) {
45
+ return new Uint8Array(value);
46
+ }
47
+ throw new Error(
48
+ `Unexpected snapshot chunk body type: ${Object.prototype.toString.call(value)}`
49
+ );
50
+ }
51
+
52
+ function coerceIsoString(value: unknown): string {
53
+ if (typeof value === 'string') return value;
54
+ if (value instanceof Date) return value.toISOString();
55
+ return String(value);
56
+ }
57
+
58
+ export async function readSnapshotChunkRefByPageKey<DB extends SyncCoreDb>(
59
+ db: Kysely<DB>,
60
+ args: SnapshotChunkPageKey & { nowIso?: string }
61
+ ): Promise<SyncSnapshotChunkRef | null> {
62
+ const nowIso = args.nowIso ?? new Date().toISOString();
63
+ const rowCursorKey = args.rowCursor ?? '';
64
+
65
+ const rowResult = await sql<{
66
+ chunk_id: string;
67
+ sha256: string;
68
+ byte_length: number;
69
+ encoding: string;
70
+ compression: string;
71
+ }>`
72
+ select chunk_id, sha256, byte_length, encoding, compression
73
+ from ${sql.table('sync_snapshot_chunks')}
74
+ where
75
+ partition_id = ${args.partitionId}
76
+ and scope_key = ${args.scopeKey}
77
+ and scope = ${args.scope}
78
+ and as_of_commit_seq = ${args.asOfCommitSeq}
79
+ and row_cursor = ${rowCursorKey}
80
+ and row_limit = ${args.rowLimit}
81
+ and encoding = ${args.encoding}
82
+ and compression = ${args.compression}
83
+ and expires_at > ${nowIso}
84
+ limit 1
85
+ `.execute(db);
86
+ const row = rowResult.rows[0];
87
+
88
+ if (!row) return null;
89
+
90
+ if (row.encoding !== 'ndjson') {
91
+ throw new Error(
92
+ `Unexpected snapshot chunk encoding: ${String(row.encoding)}`
93
+ );
94
+ }
95
+ if (row.compression !== 'gzip') {
96
+ throw new Error(
97
+ `Unexpected snapshot chunk compression: ${String(row.compression)}`
98
+ );
99
+ }
100
+
101
+ return {
102
+ id: row.chunk_id,
103
+ sha256: row.sha256,
104
+ byteLength: Number(row.byte_length ?? 0),
105
+ encoding: row.encoding,
106
+ compression: row.compression,
107
+ };
108
+ }
109
+
110
+ export async function insertSnapshotChunk<DB extends SyncCoreDb>(
111
+ db: Kysely<DB>,
112
+ args: {
113
+ chunkId: string;
114
+ partitionId: string;
115
+ scopeKey: string;
116
+ scope: string;
117
+ asOfCommitSeq: number;
118
+ rowCursor: string | null;
119
+ rowLimit: number;
120
+ encoding: 'ndjson';
121
+ compression: 'gzip';
122
+ sha256: string;
123
+ body: Uint8Array;
124
+ expiresAt: string;
125
+ }
126
+ ): Promise<SyncSnapshotChunkRef> {
127
+ const now = new Date().toISOString();
128
+ const rowCursorKey = args.rowCursor ?? '';
129
+
130
+ // Use content hash as blob_hash for legacy storage in DB
131
+ const blobHash = `sha256:${args.sha256}`;
132
+
133
+ await sql`
134
+ insert into ${sql.table('sync_snapshot_chunks')} (
135
+ chunk_id,
136
+ partition_id,
137
+ scope_key,
138
+ scope,
139
+ as_of_commit_seq,
140
+ row_cursor,
141
+ row_limit,
142
+ encoding,
143
+ compression,
144
+ sha256,
145
+ byte_length,
146
+ blob_hash,
147
+ body,
148
+ created_at,
149
+ expires_at
150
+ )
151
+ values (
152
+ ${args.chunkId},
153
+ ${args.partitionId},
154
+ ${args.scopeKey},
155
+ ${args.scope},
156
+ ${args.asOfCommitSeq},
157
+ ${rowCursorKey},
158
+ ${args.rowLimit},
159
+ ${args.encoding},
160
+ ${args.compression},
161
+ ${args.sha256},
162
+ ${args.body.length},
163
+ ${blobHash},
164
+ ${args.body},
165
+ ${now},
166
+ ${args.expiresAt}
167
+ )
168
+ on conflict (
169
+ partition_id,
170
+ scope_key,
171
+ scope,
172
+ as_of_commit_seq,
173
+ row_cursor,
174
+ row_limit,
175
+ encoding,
176
+ compression
177
+ )
178
+ do update set
179
+ expires_at = ${args.expiresAt},
180
+ blob_hash = ${blobHash}
181
+ `.execute(db);
182
+
183
+ const ref = await readSnapshotChunkRefByPageKey(db, {
184
+ partitionId: args.partitionId,
185
+ scopeKey: args.scopeKey,
186
+ scope: args.scope,
187
+ asOfCommitSeq: args.asOfCommitSeq,
188
+ rowCursor: args.rowCursor,
189
+ rowLimit: args.rowLimit,
190
+ encoding: args.encoding,
191
+ compression: args.compression,
192
+ });
193
+
194
+ if (!ref) {
195
+ throw new Error('Failed to read inserted snapshot chunk');
196
+ }
197
+
198
+ return ref;
199
+ }
200
+
201
+ export async function readSnapshotChunk<DB extends SyncCoreDb>(
202
+ db: Kysely<DB>,
203
+ chunkId: string,
204
+ options?: {
205
+ /** External chunk storage for reading from S3/R2/etc */
206
+ chunkStorage?: { readChunk(chunkId: string): Promise<Uint8Array | null> };
207
+ }
208
+ ): Promise<SnapshotChunkRow | null> {
209
+ const rowResult = await sql<{
210
+ chunk_id: string;
211
+ partition_id: string;
212
+ scope_key: string;
213
+ scope: string;
214
+ as_of_commit_seq: number;
215
+ row_cursor: string;
216
+ row_limit: number;
217
+ encoding: string;
218
+ compression: string;
219
+ sha256: string;
220
+ byte_length: number;
221
+ blob_hash: string;
222
+ body: unknown;
223
+ expires_at: unknown;
224
+ }>`
225
+ select
226
+ chunk_id,
227
+ partition_id,
228
+ scope_key,
229
+ scope,
230
+ as_of_commit_seq,
231
+ row_cursor,
232
+ row_limit,
233
+ encoding,
234
+ compression,
235
+ sha256,
236
+ byte_length,
237
+ blob_hash,
238
+ body,
239
+ expires_at
240
+ from ${sql.table('sync_snapshot_chunks')}
241
+ where chunk_id = ${chunkId}
242
+ limit 1
243
+ `.execute(db);
244
+ const row = rowResult.rows[0];
245
+
246
+ if (!row) return null;
247
+
248
+ if (row.encoding !== 'ndjson') {
249
+ throw new Error(
250
+ `Unexpected snapshot chunk encoding: ${String(row.encoding)}`
251
+ );
252
+ }
253
+ if (row.compression !== 'gzip') {
254
+ throw new Error(
255
+ `Unexpected snapshot chunk compression: ${String(row.compression)}`
256
+ );
257
+ }
258
+
259
+ // Read body from external storage if available, otherwise use inline body
260
+ let body: Uint8Array;
261
+ if (options?.chunkStorage) {
262
+ const externalBody = await options.chunkStorage.readChunk(chunkId);
263
+ if (externalBody) {
264
+ body = externalBody;
265
+ } else if (row.body) {
266
+ body = coerceChunkRow(row.body);
267
+ } else {
268
+ throw new Error(`Snapshot chunk body missing for chunk ${chunkId}`);
269
+ }
270
+ } else {
271
+ body = coerceChunkRow(row.body);
272
+ }
273
+
274
+ return {
275
+ chunkId: row.chunk_id,
276
+ partitionId: row.partition_id,
277
+ scopeKey: row.scope_key,
278
+ scope: row.scope,
279
+ asOfCommitSeq: Number(row.as_of_commit_seq ?? 0),
280
+ rowCursor: row.row_cursor,
281
+ rowLimit: Number(row.row_limit ?? 0),
282
+ encoding: row.encoding,
283
+ compression: row.compression,
284
+ sha256: row.sha256,
285
+ byteLength: Number(row.byte_length ?? 0),
286
+ body,
287
+ expiresAt: coerceIsoString(row.expires_at),
288
+ };
289
+ }
290
+
291
+ export async function deleteExpiredSnapshotChunks<DB extends SyncCoreDb>(
292
+ db: Kysely<DB>,
293
+ nowIso = new Date().toISOString()
294
+ ): Promise<number> {
295
+ const res = await sql`
296
+ delete from ${sql.table('sync_snapshot_chunks')}
297
+ where expires_at <= ${nowIso}
298
+ `.execute(db);
299
+
300
+ return Number(res.numAffectedRows ?? 0);
301
+ }
package/src/stats.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @syncular/server - Observability helpers
3
+ */
4
+
5
+ import type { Kysely, SelectQueryBuilder, SqlBool } from 'kysely';
6
+ import { sql } from 'kysely';
7
+ import type { SyncCoreDb } from './schema';
8
+
9
+ // biome-ignore lint/complexity/noBannedTypes: Kysely uses `{}` as the initial "no selected columns yet" marker.
10
+ type EmptySelection = {};
11
+
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
+ export interface SyncStats {
25
+ commitCount: number;
26
+ changeCount: number;
27
+ minCommitSeq: number;
28
+ maxCommitSeq: number;
29
+ clientCount: number;
30
+ activeClientCount: number;
31
+ minActiveClientCursor: number | null;
32
+ maxActiveClientCursor: number | null;
33
+ }
34
+
35
+ export async function readSyncStats<DB extends SyncCoreDb>(
36
+ db: Kysely<DB>,
37
+ options: { activeWindowMs?: number } = {}
38
+ ): Promise<SyncStats> {
39
+ type SyncDb = Pick<Kysely<SyncCoreDb>, 'selectFrom'>;
40
+ const syncDb = db as SyncDb;
41
+
42
+ const activeWindowMs = options.activeWindowMs ?? 14 * 24 * 60 * 60 * 1000;
43
+ const cutoffIso = new Date(Date.now() - activeWindowMs).toISOString();
44
+
45
+ const [commitRow, changeRow, clientRow, activeClientRow] = await Promise.all([
46
+ (
47
+ syncDb.selectFrom('sync_commits') as SelectQueryBuilder<
48
+ SyncCoreDb,
49
+ 'sync_commits',
50
+ EmptySelection
51
+ >
52
+ )
53
+ .select(({ fn }) => [
54
+ fn.countAll().as('commitCount'),
55
+ fn.min('commit_seq').as('minCommitSeq'),
56
+ fn.max('commit_seq').as('maxCommitSeq'),
57
+ ])
58
+ .executeTakeFirst(),
59
+ (
60
+ syncDb.selectFrom('sync_changes') as SelectQueryBuilder<
61
+ SyncCoreDb,
62
+ 'sync_changes',
63
+ EmptySelection
64
+ >
65
+ )
66
+ .select(({ fn }) => [fn.countAll().as('changeCount')])
67
+ .executeTakeFirst(),
68
+ (
69
+ syncDb.selectFrom('sync_client_cursors') as SelectQueryBuilder<
70
+ SyncCoreDb,
71
+ 'sync_client_cursors',
72
+ EmptySelection
73
+ >
74
+ )
75
+ .select(({ fn }) => [fn.countAll().as('clientCount')])
76
+ .executeTakeFirst(),
77
+ (
78
+ syncDb.selectFrom('sync_client_cursors') as SelectQueryBuilder<
79
+ SyncCoreDb,
80
+ 'sync_client_cursors',
81
+ EmptySelection
82
+ >
83
+ )
84
+ .where(sql<SqlBool>`updated_at >= ${cutoffIso}`)
85
+ .where(sql<SqlBool>`cursor >= ${0}`)
86
+ .select(({ fn }) => [
87
+ fn.countAll().as('activeClientCount'),
88
+ fn.min('cursor').as('minActiveClientCursor'),
89
+ fn.max('cursor').as('maxActiveClientCursor'),
90
+ ])
91
+ .executeTakeFirst(),
92
+ ]);
93
+
94
+ return {
95
+ commitCount: coerceNumber(commitRow?.commitCount) ?? 0,
96
+ changeCount: coerceNumber(changeRow?.changeCount) ?? 0,
97
+ minCommitSeq: coerceNumber(commitRow?.minCommitSeq) ?? 0,
98
+ maxCommitSeq: coerceNumber(commitRow?.maxCommitSeq) ?? 0,
99
+ clientCount: coerceNumber(clientRow?.clientCount) ?? 0,
100
+ activeClientCount: coerceNumber(activeClientRow?.activeClientCount) ?? 0,
101
+ minActiveClientCursor: coerceNumber(activeClientRow?.minActiveClientCursor),
102
+ maxActiveClientCursor: coerceNumber(activeClientRow?.maxActiveClientCursor),
103
+ };
104
+ }
@@ -0,0 +1 @@
1
+ export * from './resolve';
@@ -0,0 +1,185 @@
1
+ import type { ScopeValues, SyncSubscriptionRequest } from '@syncular/core';
2
+ import type { Kysely } from 'kysely';
3
+ import type { SyncCoreDb } from '../schema';
4
+ import type { TableRegistry } from '../shapes/registry';
5
+
6
+ export class InvalidSubscriptionScopeError extends Error {
7
+ constructor(message: string) {
8
+ super(message);
9
+ this.name = 'InvalidSubscriptionScopeError';
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Resolved subscription with effective scopes.
15
+ */
16
+ export interface ResolvedSubscription {
17
+ id: string;
18
+ shape: string;
19
+ scopes: ScopeValues;
20
+ params: Record<string, unknown> | undefined;
21
+ cursor: number;
22
+ bootstrapState?: SyncSubscriptionRequest['bootstrapState'];
23
+ status: 'active' | 'revoked';
24
+ }
25
+
26
+ /**
27
+ * Intersect requested scopes with allowed scopes.
28
+ *
29
+ * For each key in requested:
30
+ * - If allowed has the same key, intersect the values
31
+ * - If allowed doesn't have the key, exclude it (no access)
32
+ *
33
+ * Returns only keys where there's intersection.
34
+ */
35
+ function intersectScopes(
36
+ requested: ScopeValues,
37
+ allowed: ScopeValues
38
+ ): ScopeValues {
39
+ const result: ScopeValues = {};
40
+
41
+ for (const [key, reqValues] of Object.entries(requested)) {
42
+ const allowedValues = allowed[key];
43
+ if (allowedValues === undefined) {
44
+ // No access to this scope key
45
+ continue;
46
+ }
47
+
48
+ const reqArray = Array.isArray(reqValues) ? reqValues : [reqValues];
49
+ const allowedArray = Array.isArray(allowedValues)
50
+ ? allowedValues
51
+ : [allowedValues];
52
+
53
+ // Wildcard: allowed '*' means "allow any requested values for this key".
54
+ if (allowedArray.includes('*')) {
55
+ result[key] = reqValues;
56
+ continue;
57
+ }
58
+ const allowedSet = new Set(allowedArray);
59
+
60
+ // Intersect
61
+ const intersection = reqArray.filter((v) => allowedSet.has(v));
62
+
63
+ if (intersection.length > 0) {
64
+ // Keep as array if original was array, otherwise single value
65
+ result[key] =
66
+ intersection.length === 1 && !Array.isArray(reqValues)
67
+ ? intersection[0]!
68
+ : intersection;
69
+ }
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ /**
76
+ * Check if scopes are empty (no effective scope values).
77
+ */
78
+ function scopesEmpty(scopes: ScopeValues): boolean {
79
+ for (const value of Object.values(scopes)) {
80
+ const arr = Array.isArray(value) ? value : [value];
81
+ if (arr.length > 0) return false;
82
+ }
83
+ return true;
84
+ }
85
+
86
+ /**
87
+ * Resolve effective scopes for subscriptions.
88
+ *
89
+ * For each subscription:
90
+ * 1. Look up the shape by subscription.shape
91
+ * 2. Call shape.resolveScopes() to get allowed scopes for this actor
92
+ * 3. Intersect requested scopes with allowed scopes
93
+ * 4. Mark as revoked if no effective scopes
94
+ */
95
+ export async function resolveEffectiveScopesForSubscriptions<
96
+ DB extends SyncCoreDb,
97
+ >(args: {
98
+ db: Kysely<DB>;
99
+ actorId: string;
100
+ subscriptions: SyncSubscriptionRequest[];
101
+ shapes: TableRegistry<DB>;
102
+ }): Promise<ResolvedSubscription[]> {
103
+ const out: ResolvedSubscription[] = [];
104
+ const seenIds = new Set<string>();
105
+
106
+ for (const sub of args.subscriptions) {
107
+ if (!sub.id || typeof sub.id !== 'string') {
108
+ throw new InvalidSubscriptionScopeError('Subscription id is required');
109
+ }
110
+ if (seenIds.has(sub.id)) {
111
+ throw new InvalidSubscriptionScopeError(
112
+ `Duplicate subscription id: ${sub.id}`
113
+ );
114
+ }
115
+ seenIds.add(sub.id);
116
+
117
+ if (!sub.shape || typeof sub.shape !== 'string') {
118
+ throw new InvalidSubscriptionScopeError(
119
+ `Subscription ${sub.id} requires a shape (table name)`
120
+ );
121
+ }
122
+
123
+ const shape = args.shapes.get(sub.shape);
124
+ if (!shape) {
125
+ throw new InvalidSubscriptionScopeError(
126
+ `Unknown shape: ${sub.shape} for subscription ${sub.id}`
127
+ );
128
+ }
129
+
130
+ // Get allowed scopes from the shape
131
+ let allowed: ScopeValues;
132
+ try {
133
+ allowed = await shape.resolveScopes({
134
+ db: args.db,
135
+ actorId: args.actorId,
136
+ });
137
+ } catch (resolveErr) {
138
+ // Scope resolution failed - mark subscription as revoked
139
+ // rather than failing the entire pull
140
+ console.error(
141
+ `[resolveScopes] Failed for shape ${sub.shape}, subscription ${sub.id}:`,
142
+ resolveErr
143
+ );
144
+ out.push({
145
+ id: sub.id,
146
+ shape: sub.shape,
147
+ scopes: {},
148
+ params: sub.params,
149
+ cursor: sub.cursor,
150
+ bootstrapState: sub.bootstrapState ?? null,
151
+ status: 'revoked',
152
+ });
153
+ continue;
154
+ }
155
+
156
+ // Intersect with requested scopes
157
+ const requested = sub.scopes ?? {};
158
+ const effective = intersectScopes(requested, allowed);
159
+
160
+ if (scopesEmpty(effective)) {
161
+ out.push({
162
+ id: sub.id,
163
+ shape: sub.shape,
164
+ scopes: {},
165
+ params: sub.params,
166
+ cursor: sub.cursor,
167
+ bootstrapState: sub.bootstrapState ?? null,
168
+ status: 'revoked',
169
+ });
170
+ continue;
171
+ }
172
+
173
+ out.push({
174
+ id: sub.id,
175
+ shape: sub.shape,
176
+ scopes: effective,
177
+ params: sub.params,
178
+ cursor: sub.cursor,
179
+ bootstrapState: sub.bootstrapState ?? null,
180
+ status: 'active',
181
+ });
182
+ }
183
+
184
+ return out;
185
+ }