@syncular/server 0.0.5-44 → 0.0.6-101
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/dialect/base.d.ts +3 -3
- package/dist/dialect/base.d.ts.map +1 -1
- package/dist/dialect/base.js.map +1 -1
- package/dist/dialect/types.d.ts +5 -7
- package/dist/dialect/types.d.ts.map +1 -1
- package/dist/handlers/collection.d.ts +12 -0
- package/dist/handlers/collection.d.ts.map +1 -0
- package/dist/handlers/collection.js +64 -0
- package/dist/handlers/collection.js.map +1 -0
- package/dist/handlers/create-handler.d.ts +10 -10
- package/dist/handlers/create-handler.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +101 -69
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/handlers/index.d.ts +1 -1
- package/dist/handlers/index.d.ts.map +1 -1
- package/dist/handlers/index.js +1 -1
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/types.d.ts +18 -12
- package/dist/handlers/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/notify.js +1 -1
- package/dist/notify.js.map +1 -1
- package/dist/proxy/collection.d.ts +9 -0
- package/dist/proxy/collection.d.ts.map +1 -0
- package/dist/proxy/collection.js +21 -0
- package/dist/proxy/collection.js.map +1 -0
- package/dist/proxy/handler.d.ts +3 -3
- package/dist/proxy/handler.d.ts.map +1 -1
- package/dist/proxy/handler.js +2 -1
- 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 +3 -3
- package/dist/proxy/index.js.map +1 -1
- package/dist/proxy/oplog.js +1 -1
- package/dist/proxy/oplog.js.map +1 -1
- package/dist/pull.d.ts +12 -5
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +101 -55
- package/dist/pull.js.map +1 -1
- package/dist/push.d.ts +5 -5
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +6 -4
- package/dist/push.js.map +1 -1
- package/dist/subscriptions/cache.d.ts +55 -0
- package/dist/subscriptions/cache.d.ts.map +1 -0
- package/dist/subscriptions/cache.js +206 -0
- package/dist/subscriptions/cache.js.map +1 -0
- package/dist/subscriptions/index.d.ts +1 -0
- package/dist/subscriptions/index.d.ts.map +1 -1
- package/dist/subscriptions/index.js +1 -0
- package/dist/subscriptions/index.js.map +1 -1
- package/dist/subscriptions/resolve.d.ts +7 -4
- package/dist/subscriptions/resolve.d.ts.map +1 -1
- package/dist/subscriptions/resolve.js +74 -11
- package/dist/subscriptions/resolve.js.map +1 -1
- package/dist/sync.d.ts +21 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +23 -0
- package/dist/sync.js.map +1 -0
- package/package.json +3 -3
- package/src/dialect/base.ts +5 -3
- package/src/dialect/types.ts +11 -8
- package/src/handlers/collection.ts +121 -0
- package/src/handlers/create-handler.ts +163 -109
- package/src/handlers/index.ts +1 -1
- package/src/handlers/types.ts +29 -12
- package/src/index.ts +1 -0
- package/src/notify.test.ts +25 -21
- package/src/notify.ts +1 -1
- package/src/proxy/collection.ts +39 -0
- package/src/proxy/handler.test.ts +15 -9
- package/src/proxy/handler.ts +4 -4
- package/src/proxy/index.ts +8 -3
- package/src/proxy/oplog.ts +1 -1
- package/src/pull.ts +155 -73
- package/src/push.ts +16 -9
- package/src/snapshot-chunks/db-metadata.test.ts +6 -3
- package/src/subscriptions/cache.ts +318 -0
- package/src/subscriptions/index.ts +1 -0
- package/src/subscriptions/resolve.test.ts +180 -0
- package/src/subscriptions/resolve.ts +94 -18
- package/src/sync.ts +101 -0
- package/dist/handlers/registry.d.ts +0 -20
- package/dist/handlers/registry.d.ts.map +0 -1
- package/dist/handlers/registry.js +0 -88
- package/dist/handlers/registry.js.map +0 -1
- package/dist/proxy/registry.d.ts +0 -35
- package/dist/proxy/registry.d.ts.map +0 -1
- package/dist/proxy/registry.js +0 -49
- package/dist/proxy/registry.js.map +0 -1
- package/src/handlers/registry.ts +0 -109
- package/src/proxy/registry.ts +0 -56
package/src/push.ts
CHANGED
|
@@ -16,7 +16,11 @@ import type {
|
|
|
16
16
|
} from 'kysely';
|
|
17
17
|
import { sql } from 'kysely';
|
|
18
18
|
import type { ServerSyncDialect } from './dialect/types';
|
|
19
|
-
import
|
|
19
|
+
import {
|
|
20
|
+
getServerHandlerOrThrow,
|
|
21
|
+
type ServerHandlerCollection,
|
|
22
|
+
} from './handlers/collection';
|
|
23
|
+
import type { SyncServerAuth } from './handlers/types';
|
|
20
24
|
import type { SyncCoreDb } from './schema';
|
|
21
25
|
|
|
22
26
|
// biome-ignore lint/complexity/noBannedTypes: Kysely uses `{}` as the initial "no selected columns yet" marker.
|
|
@@ -54,7 +58,7 @@ function toDialectJsonValue(
|
|
|
54
58
|
value: unknown
|
|
55
59
|
): unknown {
|
|
56
60
|
if (value === null || value === undefined) return null;
|
|
57
|
-
if (dialect.
|
|
61
|
+
if (dialect.family === 'sqlite') return JSON.stringify(value);
|
|
58
62
|
return value;
|
|
59
63
|
}
|
|
60
64
|
|
|
@@ -177,17 +181,19 @@ function recordPushMetrics(args: {
|
|
|
177
181
|
);
|
|
178
182
|
}
|
|
179
183
|
|
|
180
|
-
export async function pushCommit<
|
|
184
|
+
export async function pushCommit<
|
|
185
|
+
DB extends SyncCoreDb,
|
|
186
|
+
Auth extends SyncServerAuth,
|
|
187
|
+
>(args: {
|
|
181
188
|
db: Kysely<DB>;
|
|
182
189
|
dialect: ServerSyncDialect;
|
|
183
|
-
handlers:
|
|
184
|
-
|
|
185
|
-
partitionId?: string;
|
|
190
|
+
handlers: ServerHandlerCollection<DB, Auth>;
|
|
191
|
+
auth: Auth;
|
|
186
192
|
request: SyncPushRequest;
|
|
187
193
|
}): Promise<PushCommitResult> {
|
|
188
194
|
const { db, dialect, handlers, request } = args;
|
|
189
|
-
const actorId = args.actorId;
|
|
190
|
-
const partitionId = args.partitionId ?? 'default';
|
|
195
|
+
const actorId = args.auth.actorId;
|
|
196
|
+
const partitionId = args.auth.partitionId ?? 'default';
|
|
191
197
|
const requestedOps = Array.isArray(request.operations)
|
|
192
198
|
? request.operations
|
|
193
199
|
: [];
|
|
@@ -425,12 +431,13 @@ export async function pushCommit<DB extends SyncCoreDb>(args: {
|
|
|
425
431
|
|
|
426
432
|
for (let i = 0; i < ops.length; i++) {
|
|
427
433
|
const op = ops[i]!;
|
|
428
|
-
const handler = handlers
|
|
434
|
+
const handler = getServerHandlerOrThrow(handlers, op.table);
|
|
429
435
|
const applied = await handler.applyOperation(
|
|
430
436
|
{
|
|
431
437
|
db: trx,
|
|
432
438
|
trx,
|
|
433
439
|
actorId,
|
|
440
|
+
auth: args.auth,
|
|
434
441
|
clientId: request.clientId,
|
|
435
442
|
commitId,
|
|
436
443
|
schemaVersion: request.schemaVersion,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
-
import type
|
|
2
|
+
import { type BlobStorageAdapter, createDatabase } from '@syncular/core';
|
|
3
3
|
import { type Kysely, sql } from 'kysely';
|
|
4
|
-
import {
|
|
4
|
+
import { createBunSqliteDialect } from '../../../dialect-bun-sqlite/src';
|
|
5
5
|
import { createSqliteServerDialect } from '../../../server-dialect-sqlite/src';
|
|
6
6
|
import { ensureSyncSchema } from '../migrate';
|
|
7
7
|
import type { SyncCoreDb } from '../schema';
|
|
@@ -53,7 +53,10 @@ describe('createDbMetadataChunkStorage', () => {
|
|
|
53
53
|
let db: Kysely<TestDb>;
|
|
54
54
|
|
|
55
55
|
beforeEach(async () => {
|
|
56
|
-
db =
|
|
56
|
+
db = createDatabase<TestDb>({
|
|
57
|
+
dialect: createBunSqliteDialect({ path: ':memory:' }),
|
|
58
|
+
family: 'sqlite',
|
|
59
|
+
});
|
|
57
60
|
await ensureSyncSchema(db, createSqliteServerDialect());
|
|
58
61
|
});
|
|
59
62
|
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import type { ScopeValues } from '@syncular/core';
|
|
2
|
+
import { type Kysely, sql } from 'kysely';
|
|
3
|
+
import type { SyncServerAuth } from '../handlers/types';
|
|
4
|
+
import type { SyncCoreDb } from '../schema';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SCOPE_CACHE_PARTITION_ID = 'default';
|
|
7
|
+
const DEFAULT_MEMORY_SCOPE_CACHE_TTL_MS = 30_000;
|
|
8
|
+
const DEFAULT_MEMORY_SCOPE_CACHE_MAX_ENTRIES = 5_000;
|
|
9
|
+
const DEFAULT_DATABASE_SCOPE_CACHE_TTL_MS = 60_000;
|
|
10
|
+
const DEFAULT_DATABASE_SCOPE_CACHE_TABLE = 'sync_scope_cache';
|
|
11
|
+
|
|
12
|
+
export interface ScopeCacheContext<
|
|
13
|
+
DB extends SyncCoreDb = SyncCoreDb,
|
|
14
|
+
Auth extends SyncServerAuth = SyncServerAuth,
|
|
15
|
+
> {
|
|
16
|
+
db: Kysely<DB>;
|
|
17
|
+
auth: Auth;
|
|
18
|
+
table: string;
|
|
19
|
+
cacheKey: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ScopeCacheSetContext<
|
|
23
|
+
DB extends SyncCoreDb = SyncCoreDb,
|
|
24
|
+
Auth extends SyncServerAuth = SyncServerAuth,
|
|
25
|
+
> extends ScopeCacheContext<DB, Auth> {
|
|
26
|
+
scopes: ScopeValues;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Shared cache contract for scope resolution results.
|
|
31
|
+
*
|
|
32
|
+
* Pull requests always apply request-local memoization first.
|
|
33
|
+
* This cache is used to share results across pulls.
|
|
34
|
+
*/
|
|
35
|
+
export interface ScopeCacheBackend {
|
|
36
|
+
name: string;
|
|
37
|
+
get<DB extends SyncCoreDb, Auth extends SyncServerAuth>(
|
|
38
|
+
args: ScopeCacheContext<DB, Auth>
|
|
39
|
+
): Promise<ScopeValues | null>;
|
|
40
|
+
set<DB extends SyncCoreDb, Auth extends SyncServerAuth>(
|
|
41
|
+
args: ScopeCacheSetContext<DB, Auth>
|
|
42
|
+
): Promise<void>;
|
|
43
|
+
delete?<DB extends SyncCoreDb, Auth extends SyncServerAuth>(
|
|
44
|
+
args: ScopeCacheContext<DB, Auth>
|
|
45
|
+
): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createDefaultScopeCacheKey(args: {
|
|
49
|
+
table: string;
|
|
50
|
+
auth: SyncServerAuth;
|
|
51
|
+
}): string {
|
|
52
|
+
const partitionId = args.auth.partitionId ?? DEFAULT_SCOPE_CACHE_PARTITION_ID;
|
|
53
|
+
return `${partitionId}\u0000${args.auth.actorId}\u0000${args.table}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cloneScopeValues(scopes: ScopeValues): ScopeValues {
|
|
57
|
+
const cloned: ScopeValues = {};
|
|
58
|
+
for (const [key, value] of Object.entries(scopes)) {
|
|
59
|
+
if (typeof value === 'string') {
|
|
60
|
+
cloned[key] = value;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
cloned[key] = [...value];
|
|
64
|
+
}
|
|
65
|
+
return cloned;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseScopeValues(value: string): ScopeValues | null {
|
|
69
|
+
let parsed: unknown;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(value);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const out: ScopeValues = {};
|
|
81
|
+
for (const [scopeKey, scopeValue] of Object.entries(parsed)) {
|
|
82
|
+
if (typeof scopeValue === 'string') {
|
|
83
|
+
out[scopeKey] = scopeValue;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (
|
|
88
|
+
Array.isArray(scopeValue) &&
|
|
89
|
+
scopeValue.every((item) => typeof item === 'string')
|
|
90
|
+
) {
|
|
91
|
+
out[scopeKey] = [...scopeValue];
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface MemoryScopeCacheEntry {
|
|
102
|
+
scopes: ScopeValues;
|
|
103
|
+
expiresAt: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface MemoryScopeCacheOptions {
|
|
107
|
+
ttlMs?: number;
|
|
108
|
+
maxEntries?: number;
|
|
109
|
+
now?: () => number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createMemoryScopeCache(
|
|
113
|
+
options?: MemoryScopeCacheOptions
|
|
114
|
+
): ScopeCacheBackend {
|
|
115
|
+
const ttlMs = options?.ttlMs ?? DEFAULT_MEMORY_SCOPE_CACHE_TTL_MS;
|
|
116
|
+
const maxEntries =
|
|
117
|
+
options?.maxEntries ?? DEFAULT_MEMORY_SCOPE_CACHE_MAX_ENTRIES;
|
|
118
|
+
const now = options?.now ?? Date.now;
|
|
119
|
+
const buckets = new WeakMap<object, Map<string, MemoryScopeCacheEntry>>();
|
|
120
|
+
|
|
121
|
+
function getBucket(db: object): Map<string, MemoryScopeCacheEntry> {
|
|
122
|
+
const existing = buckets.get(db);
|
|
123
|
+
if (existing) {
|
|
124
|
+
return existing;
|
|
125
|
+
}
|
|
126
|
+
const created = new Map<string, MemoryScopeCacheEntry>();
|
|
127
|
+
buckets.set(db, created);
|
|
128
|
+
return created;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function evictOldest(bucket: Map<string, MemoryScopeCacheEntry>): void {
|
|
132
|
+
while (bucket.size > maxEntries) {
|
|
133
|
+
const oldestKey = bucket.keys().next().value;
|
|
134
|
+
if (!oldestKey) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
bucket.delete(oldestKey);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
name: 'memory',
|
|
143
|
+
async get(args) {
|
|
144
|
+
const bucket = getBucket(args.db);
|
|
145
|
+
const entry = bucket.get(args.cacheKey);
|
|
146
|
+
if (!entry) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const nowMs = now();
|
|
151
|
+
if (entry.expiresAt <= nowMs) {
|
|
152
|
+
bucket.delete(args.cacheKey);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return cloneScopeValues(entry.scopes);
|
|
157
|
+
},
|
|
158
|
+
async set(args) {
|
|
159
|
+
const bucket = getBucket(args.db);
|
|
160
|
+
if (ttlMs <= 0) {
|
|
161
|
+
bucket.delete(args.cacheKey);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (bucket.has(args.cacheKey)) {
|
|
166
|
+
bucket.delete(args.cacheKey);
|
|
167
|
+
}
|
|
168
|
+
bucket.set(args.cacheKey, {
|
|
169
|
+
scopes: cloneScopeValues(args.scopes),
|
|
170
|
+
expiresAt: now() + ttlMs,
|
|
171
|
+
});
|
|
172
|
+
evictOldest(bucket);
|
|
173
|
+
},
|
|
174
|
+
async delete(args) {
|
|
175
|
+
const bucket = getBucket(args.db);
|
|
176
|
+
bucket.delete(args.cacheKey);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface DatabaseScopeCacheOptions {
|
|
182
|
+
/**
|
|
183
|
+
* Scope cache table name.
|
|
184
|
+
* Default: `sync_scope_cache`
|
|
185
|
+
*/
|
|
186
|
+
tableName?: string;
|
|
187
|
+
/**
|
|
188
|
+
* TTL for cache entries (milliseconds).
|
|
189
|
+
* Default: 60000
|
|
190
|
+
*/
|
|
191
|
+
ttlMs?: number;
|
|
192
|
+
/**
|
|
193
|
+
* Automatically creates the cache table.
|
|
194
|
+
* Default: true
|
|
195
|
+
*/
|
|
196
|
+
autoCreateTable?: boolean;
|
|
197
|
+
now?: () => Date;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function createDatabaseScopeCache(
|
|
201
|
+
options?: DatabaseScopeCacheOptions
|
|
202
|
+
): ScopeCacheBackend {
|
|
203
|
+
const tableName = options?.tableName ?? DEFAULT_DATABASE_SCOPE_CACHE_TABLE;
|
|
204
|
+
const ttlMs = options?.ttlMs ?? DEFAULT_DATABASE_SCOPE_CACHE_TTL_MS;
|
|
205
|
+
const autoCreateTable = options?.autoCreateTable ?? true;
|
|
206
|
+
const now = options?.now ?? (() => new Date());
|
|
207
|
+
const schemaReady = new WeakMap<object, Promise<void>>();
|
|
208
|
+
|
|
209
|
+
async function ensureTable<DB extends SyncCoreDb>(db: Kysely<DB>) {
|
|
210
|
+
if (!autoCreateTable) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const existing = schemaReady.get(db);
|
|
215
|
+
if (existing) {
|
|
216
|
+
await existing;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const pending = sql`
|
|
221
|
+
CREATE TABLE IF NOT EXISTS ${sql.table(tableName)} (
|
|
222
|
+
cache_key TEXT PRIMARY KEY,
|
|
223
|
+
scope_values TEXT NOT NULL,
|
|
224
|
+
expires_at TEXT NOT NULL,
|
|
225
|
+
created_at TEXT NOT NULL
|
|
226
|
+
)
|
|
227
|
+
`
|
|
228
|
+
.execute(db)
|
|
229
|
+
.then(() => undefined);
|
|
230
|
+
schemaReady.set(db, pending);
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
await pending;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
schemaReady.delete(db);
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function removeCacheRow<DB extends SyncCoreDb>(args: {
|
|
241
|
+
db: Kysely<DB>;
|
|
242
|
+
cacheKey: string;
|
|
243
|
+
}): Promise<void> {
|
|
244
|
+
await sql`
|
|
245
|
+
DELETE FROM ${sql.table(tableName)}
|
|
246
|
+
WHERE cache_key = ${args.cacheKey}
|
|
247
|
+
`.execute(args.db);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
name: 'database',
|
|
252
|
+
async get(args) {
|
|
253
|
+
await ensureTable(args.db);
|
|
254
|
+
|
|
255
|
+
const rows = await sql<{
|
|
256
|
+
scope_values: string;
|
|
257
|
+
expires_at: string;
|
|
258
|
+
}>`
|
|
259
|
+
SELECT scope_values, expires_at
|
|
260
|
+
FROM ${sql.table(tableName)}
|
|
261
|
+
WHERE cache_key = ${args.cacheKey}
|
|
262
|
+
LIMIT 1
|
|
263
|
+
`.execute(args.db);
|
|
264
|
+
|
|
265
|
+
const row = rows.rows[0];
|
|
266
|
+
if (!row) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const nowIso = now().toISOString();
|
|
271
|
+
if (row.expires_at <= nowIso) {
|
|
272
|
+
await removeCacheRow({ db: args.db, cacheKey: args.cacheKey });
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const parsed = parseScopeValues(row.scope_values);
|
|
277
|
+
if (!parsed) {
|
|
278
|
+
await removeCacheRow({ db: args.db, cacheKey: args.cacheKey });
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return parsed;
|
|
283
|
+
},
|
|
284
|
+
async set(args) {
|
|
285
|
+
await ensureTable(args.db);
|
|
286
|
+
|
|
287
|
+
if (ttlMs <= 0) {
|
|
288
|
+
await removeCacheRow({ db: args.db, cacheKey: args.cacheKey });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const createdAt = now();
|
|
293
|
+
const expiresAt = new Date(createdAt.getTime() + ttlMs);
|
|
294
|
+
await sql`
|
|
295
|
+
INSERT INTO ${sql.table(tableName)} (
|
|
296
|
+
cache_key,
|
|
297
|
+
scope_values,
|
|
298
|
+
expires_at,
|
|
299
|
+
created_at
|
|
300
|
+
)
|
|
301
|
+
VALUES (
|
|
302
|
+
${args.cacheKey},
|
|
303
|
+
${JSON.stringify(cloneScopeValues(args.scopes))},
|
|
304
|
+
${expiresAt.toISOString()},
|
|
305
|
+
${createdAt.toISOString()}
|
|
306
|
+
)
|
|
307
|
+
ON CONFLICT (cache_key) DO UPDATE SET
|
|
308
|
+
scope_values = EXCLUDED.scope_values,
|
|
309
|
+
expires_at = EXCLUDED.expires_at,
|
|
310
|
+
created_at = EXCLUDED.created_at
|
|
311
|
+
`.execute(args.db);
|
|
312
|
+
},
|
|
313
|
+
async delete(args) {
|
|
314
|
+
await ensureTable(args.db);
|
|
315
|
+
await removeCacheRow({ db: args.db, cacheKey: args.cacheKey });
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createDatabase } from '@syncular/core';
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import { createBunSqliteDialect } from '../../../dialect-bun-sqlite/src';
|
|
5
|
+
import {
|
|
6
|
+
createServerHandler,
|
|
7
|
+
createServerHandlerCollection,
|
|
8
|
+
} from '../handlers';
|
|
9
|
+
import type { SyncCoreDb } from '../schema';
|
|
10
|
+
import { createDatabaseScopeCache, createDefaultScopeCacheKey } from './cache';
|
|
11
|
+
import { resolveEffectiveScopesForSubscriptions } from './resolve';
|
|
12
|
+
|
|
13
|
+
interface TasksTable {
|
|
14
|
+
id: string;
|
|
15
|
+
user_id: string;
|
|
16
|
+
server_version: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TestDb extends SyncCoreDb {
|
|
20
|
+
tasks: TasksTable;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ClientDb {
|
|
24
|
+
tasks: TasksTable;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('resolveEffectiveScopesForSubscriptions cache behavior', () => {
|
|
28
|
+
let db: Kysely<TestDb>;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
db = createDatabase<TestDb>({
|
|
32
|
+
dialect: createBunSqliteDialect({ path: ':memory:' }),
|
|
33
|
+
family: 'sqlite',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await db.destroy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('memoizes resolveScopes calls inside a single request', async () => {
|
|
42
|
+
let resolveCalls = 0;
|
|
43
|
+
const handler = createServerHandler<TestDb, ClientDb, 'tasks'>({
|
|
44
|
+
table: 'tasks',
|
|
45
|
+
scopes: ['user:{user_id}'],
|
|
46
|
+
resolveScopes: async (ctx) => {
|
|
47
|
+
resolveCalls += 1;
|
|
48
|
+
return { user_id: [ctx.actorId] };
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const handlers = createServerHandlerCollection<TestDb>([handler]);
|
|
52
|
+
|
|
53
|
+
const resolved = await resolveEffectiveScopesForSubscriptions({
|
|
54
|
+
db,
|
|
55
|
+
auth: { actorId: 'u1' },
|
|
56
|
+
handlers,
|
|
57
|
+
subscriptions: [
|
|
58
|
+
{ id: 'sub-1', table: 'tasks', scopes: { user_id: 'u1' }, cursor: -1 },
|
|
59
|
+
{ id: 'sub-2', table: 'tasks', scopes: { user_id: 'u1' }, cursor: -1 },
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(resolveCalls).toBe(1);
|
|
64
|
+
expect(resolved).toHaveLength(2);
|
|
65
|
+
expect(resolved.every((entry) => entry.status === 'active')).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('uses shared cache hit and skips resolveScopes', async () => {
|
|
69
|
+
let resolveCalls = 0;
|
|
70
|
+
let cacheGetCalls = 0;
|
|
71
|
+
let cacheSetCalls = 0;
|
|
72
|
+
const handler = createServerHandler<TestDb, ClientDb, 'tasks'>({
|
|
73
|
+
table: 'tasks',
|
|
74
|
+
scopes: ['user:{user_id}'],
|
|
75
|
+
resolveScopes: async (ctx) => {
|
|
76
|
+
resolveCalls += 1;
|
|
77
|
+
return { user_id: [ctx.actorId] };
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const handlers = createServerHandlerCollection<TestDb>([handler]);
|
|
81
|
+
|
|
82
|
+
const resolved = await resolveEffectiveScopesForSubscriptions({
|
|
83
|
+
db,
|
|
84
|
+
auth: { actorId: 'u1' },
|
|
85
|
+
handlers,
|
|
86
|
+
scopeCache: {
|
|
87
|
+
name: 'test-cache',
|
|
88
|
+
async get() {
|
|
89
|
+
cacheGetCalls += 1;
|
|
90
|
+
return { user_id: ['u1'] };
|
|
91
|
+
},
|
|
92
|
+
async set() {
|
|
93
|
+
cacheSetCalls += 1;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
subscriptions: [
|
|
97
|
+
{ id: 'sub-1', table: 'tasks', scopes: { user_id: 'u1' }, cursor: -1 },
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(resolveCalls).toBe(0);
|
|
102
|
+
expect(cacheGetCalls).toBe(1);
|
|
103
|
+
expect(cacheSetCalls).toBe(0);
|
|
104
|
+
expect(resolved[0]?.status).toBe('active');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('uses request-local memoization even with shared cache misses', async () => {
|
|
108
|
+
let resolveCalls = 0;
|
|
109
|
+
let cacheGetCalls = 0;
|
|
110
|
+
let cacheSetCalls = 0;
|
|
111
|
+
const handler = createServerHandler<TestDb, ClientDb, 'tasks'>({
|
|
112
|
+
table: 'tasks',
|
|
113
|
+
scopes: ['user:{user_id}'],
|
|
114
|
+
resolveScopes: async (ctx) => {
|
|
115
|
+
resolveCalls += 1;
|
|
116
|
+
return { user_id: [ctx.actorId] };
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const handlers = createServerHandlerCollection<TestDb>([handler]);
|
|
120
|
+
|
|
121
|
+
const resolved = await resolveEffectiveScopesForSubscriptions({
|
|
122
|
+
db,
|
|
123
|
+
auth: { actorId: 'u1' },
|
|
124
|
+
handlers,
|
|
125
|
+
scopeCache: {
|
|
126
|
+
name: 'test-cache',
|
|
127
|
+
async get() {
|
|
128
|
+
cacheGetCalls += 1;
|
|
129
|
+
return null;
|
|
130
|
+
},
|
|
131
|
+
async set() {
|
|
132
|
+
cacheSetCalls += 1;
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
subscriptions: [
|
|
136
|
+
{ id: 'sub-1', table: 'tasks', scopes: { user_id: 'u1' }, cursor: -1 },
|
|
137
|
+
{ id: 'sub-2', table: 'tasks', scopes: { user_id: 'u1' }, cursor: -1 },
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(resolveCalls).toBe(1);
|
|
142
|
+
expect(cacheGetCalls).toBe(1);
|
|
143
|
+
expect(cacheSetCalls).toBe(1);
|
|
144
|
+
expect(resolved).toHaveLength(2);
|
|
145
|
+
expect(resolved.every((entry) => entry.status === 'active')).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('roundtrips values through database scope cache', async () => {
|
|
149
|
+
const scopeCache = createDatabaseScopeCache();
|
|
150
|
+
const auth = { actorId: 'u1', partitionId: 'tenant-1' };
|
|
151
|
+
const cacheKey = createDefaultScopeCacheKey({ auth, table: 'tasks' });
|
|
152
|
+
const context = { db, auth, table: 'tasks', cacheKey };
|
|
153
|
+
const scopes = { user_id: ['u1', 'u2'] };
|
|
154
|
+
|
|
155
|
+
await scopeCache.set({ ...context, scopes });
|
|
156
|
+
const cachedScopes = await scopeCache.get(context);
|
|
157
|
+
|
|
158
|
+
expect(cachedScopes).toEqual(scopes);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('expires entries in database scope cache', async () => {
|
|
162
|
+
let nowMs = Date.now();
|
|
163
|
+
const scopeCache = createDatabaseScopeCache({
|
|
164
|
+
ttlMs: 25,
|
|
165
|
+
now: () => new Date(nowMs),
|
|
166
|
+
});
|
|
167
|
+
const auth = { actorId: 'u1', partitionId: 'tenant-1' };
|
|
168
|
+
const cacheKey = createDefaultScopeCacheKey({ auth, table: 'tasks' });
|
|
169
|
+
const context = { db, auth, table: 'tasks', cacheKey };
|
|
170
|
+
|
|
171
|
+
await scopeCache.set({
|
|
172
|
+
...context,
|
|
173
|
+
scopes: { user_id: ['u1'] },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
nowMs += 26;
|
|
177
|
+
const cachedScopes = await scopeCache.get(context);
|
|
178
|
+
expect(cachedScopes).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
});
|