@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.
- package/dist/blobs/adapters/database.d.ts +83 -0
- package/dist/blobs/adapters/database.d.ts.map +1 -0
- package/dist/blobs/adapters/database.js +180 -0
- package/dist/blobs/adapters/database.js.map +1 -0
- package/dist/blobs/adapters/s3.d.ts +82 -0
- package/dist/blobs/adapters/s3.d.ts.map +1 -0
- package/dist/blobs/adapters/s3.js +170 -0
- package/dist/blobs/adapters/s3.js.map +1 -0
- package/dist/blobs/index.d.ts +9 -0
- package/dist/blobs/index.d.ts.map +1 -0
- package/dist/blobs/index.js +9 -0
- package/dist/blobs/index.js.map +1 -0
- package/dist/blobs/manager.d.ts +195 -0
- package/dist/blobs/manager.d.ts.map +1 -0
- package/dist/blobs/manager.js +440 -0
- package/dist/blobs/manager.js.map +1 -0
- package/dist/blobs/migrate.d.ts +27 -0
- package/dist/blobs/migrate.d.ts.map +1 -0
- package/dist/blobs/migrate.js +119 -0
- package/dist/blobs/migrate.js.map +1 -0
- package/dist/blobs/types.d.ts +54 -0
- package/dist/blobs/types.d.ts.map +1 -0
- package/dist/blobs/types.js +5 -0
- package/dist/blobs/types.js.map +1 -0
- package/dist/clients.d.ts +14 -0
- package/dist/clients.d.ts.map +1 -0
- package/dist/clients.js +7 -0
- package/dist/clients.js.map +1 -0
- package/dist/compaction.d.ts +27 -0
- package/dist/compaction.d.ts.map +1 -0
- package/dist/compaction.js +49 -0
- package/dist/compaction.js.map +1 -0
- package/dist/dialect/index.d.ts +5 -0
- package/dist/dialect/index.d.ts.map +1 -0
- package/dist/dialect/index.js +5 -0
- package/dist/dialect/index.js.map +1 -0
- package/dist/dialect/types.d.ts +170 -0
- package/dist/dialect/types.d.ts.map +1 -0
- package/dist/dialect/types.js +8 -0
- package/dist/dialect/types.js.map +1 -0
- package/dist/helpers/conflict.d.ts +52 -0
- package/dist/helpers/conflict.d.ts.map +1 -0
- package/dist/helpers/conflict.js +49 -0
- package/dist/helpers/conflict.js.map +1 -0
- package/dist/helpers/emitted-change.d.ts +56 -0
- package/dist/helpers/emitted-change.d.ts.map +1 -0
- package/dist/helpers/emitted-change.js +46 -0
- package/dist/helpers/emitted-change.js.map +1 -0
- package/dist/helpers/index.d.ts +10 -0
- package/dist/helpers/index.d.ts.map +1 -0
- package/dist/helpers/index.js +10 -0
- package/dist/helpers/index.js.map +1 -0
- package/dist/helpers/paginate.d.ts +49 -0
- package/dist/helpers/paginate.d.ts.map +1 -0
- package/dist/helpers/paginate.js +54 -0
- package/dist/helpers/paginate.js.map +1 -0
- package/dist/helpers/scope-strings.d.ts +74 -0
- package/dist/helpers/scope-strings.d.ts.map +1 -0
- package/dist/helpers/scope-strings.js +82 -0
- package/dist/helpers/scope-strings.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/migrate.d.ts +14 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +13 -0
- package/dist/migrate.js.map +1 -0
- package/dist/proxy/handler.d.ts +42 -0
- package/dist/proxy/handler.d.ts.map +1 -0
- package/dist/proxy/handler.js +99 -0
- package/dist/proxy/handler.js.map +1 -0
- package/dist/proxy/index.d.ts +9 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +14 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/mutation-detector.d.ts +31 -0
- package/dist/proxy/mutation-detector.d.ts.map +1 -0
- package/dist/proxy/mutation-detector.js +61 -0
- package/dist/proxy/mutation-detector.js.map +1 -0
- package/dist/proxy/oplog.d.ts +30 -0
- package/dist/proxy/oplog.d.ts.map +1 -0
- package/dist/proxy/oplog.js +110 -0
- package/dist/proxy/oplog.js.map +1 -0
- package/dist/proxy/registry.d.ts +35 -0
- package/dist/proxy/registry.d.ts.map +1 -0
- package/dist/proxy/registry.js +49 -0
- package/dist/proxy/registry.js.map +1 -0
- package/dist/proxy/types.d.ts +44 -0
- package/dist/proxy/types.d.ts.map +1 -0
- package/dist/proxy/types.js +7 -0
- package/dist/proxy/types.js.map +1 -0
- package/dist/prune.d.ts +37 -0
- package/dist/prune.d.ts.map +1 -0
- package/dist/prune.js +112 -0
- package/dist/prune.js.map +1 -0
- package/dist/pull.d.ts +31 -0
- package/dist/pull.d.ts.map +1 -0
- package/dist/pull.js +414 -0
- package/dist/pull.js.map +1 -0
- package/dist/push.d.ts +33 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +329 -0
- package/dist/push.js.map +1 -0
- package/dist/realtime/in-memory.d.ts +13 -0
- package/dist/realtime/in-memory.d.ts.map +1 -0
- package/dist/realtime/in-memory.js +28 -0
- package/dist/realtime/in-memory.js.map +1 -0
- package/dist/realtime/index.d.ts +3 -0
- package/dist/realtime/index.d.ts.map +1 -0
- package/dist/realtime/index.js +2 -0
- package/dist/realtime/index.js.map +1 -0
- package/dist/realtime/types.d.ts +50 -0
- package/dist/realtime/types.d.ts.map +1 -0
- package/dist/realtime/types.js +7 -0
- package/dist/realtime/types.js.map +1 -0
- package/dist/schema.d.ts +164 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +10 -0
- package/dist/schema.js.map +1 -0
- package/dist/shapes/create-handler.d.ts +119 -0
- package/dist/shapes/create-handler.d.ts.map +1 -0
- package/dist/shapes/create-handler.js +327 -0
- package/dist/shapes/create-handler.js.map +1 -0
- package/dist/shapes/index.d.ts +4 -0
- package/dist/shapes/index.d.ts.map +1 -0
- package/dist/shapes/index.js +4 -0
- package/dist/shapes/index.js.map +1 -0
- package/dist/shapes/registry.d.ts +20 -0
- package/dist/shapes/registry.d.ts.map +1 -0
- package/dist/shapes/registry.js +88 -0
- package/dist/shapes/registry.js.map +1 -0
- package/dist/shapes/types.d.ts +204 -0
- package/dist/shapes/types.d.ts.map +1 -0
- package/dist/shapes/types.js +2 -0
- package/dist/shapes/types.js.map +1 -0
- package/dist/snapshot-chunks/adapters/s3.d.ts +63 -0
- package/dist/snapshot-chunks/adapters/s3.d.ts.map +1 -0
- package/dist/snapshot-chunks/adapters/s3.js +50 -0
- package/dist/snapshot-chunks/adapters/s3.js.map +1 -0
- package/dist/snapshot-chunks/db-metadata.d.ts +33 -0
- package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -0
- package/dist/snapshot-chunks/db-metadata.js +169 -0
- package/dist/snapshot-chunks/db-metadata.js.map +1 -0
- package/dist/snapshot-chunks/index.d.ts +9 -0
- package/dist/snapshot-chunks/index.d.ts.map +1 -0
- package/dist/snapshot-chunks/index.js +9 -0
- package/dist/snapshot-chunks/index.js.map +1 -0
- package/dist/snapshot-chunks/types.d.ts +65 -0
- package/dist/snapshot-chunks/types.d.ts.map +1 -0
- package/dist/snapshot-chunks/types.js +8 -0
- package/dist/snapshot-chunks/types.js.map +1 -0
- package/dist/snapshot-chunks.d.ts +59 -0
- package/dist/snapshot-chunks.d.ts.map +1 -0
- package/dist/snapshot-chunks.js +202 -0
- package/dist/snapshot-chunks.js.map +1 -0
- package/dist/stats.d.ts +19 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +57 -0
- package/dist/stats.js.map +1 -0
- package/dist/subscriptions/index.d.ts +2 -0
- package/dist/subscriptions/index.d.ts.map +1 -0
- package/dist/subscriptions/index.js +2 -0
- package/dist/subscriptions/index.js.map +1 -0
- package/dist/subscriptions/resolve.d.ts +35 -0
- package/dist/subscriptions/resolve.d.ts.map +1 -0
- package/dist/subscriptions/resolve.js +134 -0
- package/dist/subscriptions/resolve.js.map +1 -0
- package/package.json +80 -0
- package/src/blobs/adapters/database.ts +290 -0
- package/src/blobs/adapters/s3.ts +271 -0
- package/src/blobs/index.ts +9 -0
- package/src/blobs/manager.ts +600 -0
- package/src/blobs/migrate.ts +150 -0
- package/src/blobs/types.ts +70 -0
- package/src/clients.ts +21 -0
- package/src/compaction.ts +77 -0
- package/src/dialect/index.ts +5 -0
- package/src/dialect/types.ts +222 -0
- package/src/helpers/conflict.ts +64 -0
- package/src/helpers/emitted-change.ts +69 -0
- package/src/helpers/index.ts +10 -0
- package/src/helpers/paginate.ts +82 -0
- package/src/helpers/scope-strings.ts +101 -0
- package/src/index.ts +28 -0
- package/src/migrate.ts +20 -0
- package/src/proxy/handler.ts +152 -0
- package/src/proxy/index.ts +18 -0
- package/src/proxy/mutation-detector.ts +83 -0
- package/src/proxy/oplog.ts +144 -0
- package/src/proxy/registry.ts +56 -0
- package/src/proxy/types.ts +46 -0
- package/src/prune.ts +200 -0
- package/src/pull.ts +551 -0
- package/src/push.ts +457 -0
- package/src/realtime/in-memory.ts +33 -0
- package/src/realtime/index.ts +5 -0
- package/src/realtime/types.ts +55 -0
- package/src/schema.ts +172 -0
- package/src/shapes/create-handler.ts +590 -0
- package/src/shapes/index.ts +3 -0
- package/src/shapes/registry.ts +109 -0
- package/src/shapes/types.ts +267 -0
- package/src/snapshot-chunks/adapters/s3.ts +68 -0
- package/src/snapshot-chunks/db-metadata.ts +238 -0
- package/src/snapshot-chunks/index.ts +9 -0
- package/src/snapshot-chunks/types.ts +79 -0
- package/src/snapshot-chunks.ts +301 -0
- package/src/stats.ts +104 -0
- package/src/subscriptions/index.ts +1 -0
- 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
|
+
}
|