@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.
- package/dist/blobs/adapters/database.d.ts +26 -9
- package/dist/blobs/adapters/database.d.ts.map +1 -1
- package/dist/blobs/adapters/database.js +65 -21
- package/dist/blobs/adapters/database.js.map +1 -1
- package/dist/blobs/manager.d.ts +60 -3
- package/dist/blobs/manager.d.ts.map +1 -1
- package/dist/blobs/manager.js +227 -56
- package/dist/blobs/manager.js.map +1 -1
- package/dist/blobs/migrate.d.ts.map +1 -1
- package/dist/blobs/migrate.js +16 -8
- package/dist/blobs/migrate.js.map +1 -1
- package/dist/blobs/types.d.ts +4 -0
- package/dist/blobs/types.d.ts.map +1 -1
- package/dist/dialect/helpers.d.ts +3 -0
- package/dist/dialect/helpers.d.ts.map +1 -1
- package/dist/dialect/helpers.js +17 -0
- package/dist/dialect/helpers.js.map +1 -1
- package/dist/handlers/collection.d.ts +0 -2
- package/dist/handlers/collection.d.ts.map +1 -1
- package/dist/handlers/collection.js +5 -56
- package/dist/handlers/collection.js.map +1 -1
- package/dist/handlers/create-handler.d.ts +0 -4
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +6 -34
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/notify.d.ts.map +1 -1
- package/dist/notify.js +13 -37
- package/dist/notify.js.map +1 -1
- package/dist/proxy/collection.d.ts +0 -2
- package/dist/proxy/collection.d.ts.map +1 -1
- package/dist/proxy/collection.js +2 -17
- package/dist/proxy/collection.js.map +1 -1
- package/dist/proxy/handler.d.ts +1 -1
- package/dist/proxy/handler.d.ts.map +1 -1
- package/dist/proxy/handler.js +1 -2
- package/dist/proxy/handler.js.map +1 -1
- package/dist/proxy/index.d.ts +1 -1
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +1 -1
- package/dist/proxy/index.js.map +1 -1
- package/dist/proxy/oplog.d.ts.map +1 -1
- package/dist/proxy/oplog.js +1 -7
- package/dist/proxy/oplog.js.map +1 -1
- package/dist/prune.d.ts.map +1 -1
- package/dist/prune.js +1 -13
- package/dist/prune.js.map +1 -1
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +186 -54
- package/dist/pull.js.map +1 -1
- package/dist/push.d.ts +1 -1
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +9 -36
- package/dist/push.js.map +1 -1
- package/dist/snapshot-chunks/db-metadata.d.ts +18 -0
- package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.js +71 -23
- package/dist/snapshot-chunks/db-metadata.js.map +1 -1
- package/dist/snapshot-chunks.d.ts +5 -1
- package/dist/snapshot-chunks.d.ts.map +1 -1
- package/dist/snapshot-chunks.js +14 -1
- package/dist/snapshot-chunks.js.map +1 -1
- package/dist/stats.d.ts.map +1 -1
- package/dist/stats.js +1 -13
- package/dist/stats.js.map +1 -1
- package/dist/subscriptions/resolve.d.ts +1 -1
- package/dist/subscriptions/resolve.d.ts.map +1 -1
- package/dist/subscriptions/resolve.js +3 -16
- package/dist/subscriptions/resolve.js.map +1 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +2 -4
- package/dist/sync.js.map +1 -1
- package/package.json +2 -2
- package/src/blobs/adapters/database.test.ts +7 -0
- package/src/blobs/adapters/database.ts +119 -39
- package/src/blobs/manager.ts +339 -53
- package/src/blobs/migrate.ts +16 -8
- package/src/blobs/types.ts +4 -0
- package/src/dialect/helpers.ts +19 -0
- package/src/handlers/collection.ts +17 -86
- package/src/handlers/create-handler.ts +9 -44
- package/src/notify.ts +15 -40
- package/src/proxy/collection.ts +5 -27
- package/src/proxy/handler.ts +2 -2
- package/src/proxy/index.ts +0 -2
- package/src/proxy/oplog.ts +1 -9
- package/src/prune.ts +1 -12
- package/src/pull.ts +280 -105
- package/src/push.ts +14 -43
- package/src/snapshot-chunks/db-metadata.ts +107 -27
- package/src/snapshot-chunks.ts +18 -0
- package/src/stats.ts +1 -12
- package/src/subscriptions/resolve.ts +4 -20
- 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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
555
|
+
return deleted;
|
|
476
556
|
},
|
|
477
557
|
};
|
|
478
558
|
}
|
package/src/snapshot-chunks.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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>) {
|