@syncular/server 0.0.6-202 → 0.0.6-205
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/types.d.ts +1 -0
- package/dist/dialect/types.d.ts.map +1 -1
- package/dist/handlers/create-handler.js +7 -11
- package/dist/handlers/create-handler.js.map +1 -1
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +544 -502
- package/dist/pull.js.map +1 -1
- package/dist/subscriptions/cache.d.ts +3 -0
- package/dist/subscriptions/cache.d.ts.map +1 -1
- package/dist/subscriptions/cache.js +44 -0
- package/dist/subscriptions/cache.js.map +1 -1
- package/dist/subscriptions/resolve.d.ts.map +1 -1
- package/dist/subscriptions/resolve.js +62 -35
- package/dist/subscriptions/resolve.js.map +1 -1
- package/package.json +2 -2
- package/src/dialect/types.ts +1 -0
- package/src/handlers/create-handler.ts +15 -15
- package/src/pull.ts +737 -660
- package/src/subscriptions/cache.ts +58 -0
- package/src/subscriptions/resolve.test.ts +71 -1
- package/src/subscriptions/resolve.ts +65 -38
|
@@ -37,6 +37,11 @@ export interface ScopeCacheBackend {
|
|
|
37
37
|
get<DB extends SyncCoreDb, Auth extends SyncServerAuth>(
|
|
38
38
|
args: ScopeCacheContext<DB, Auth>
|
|
39
39
|
): Promise<ScopeValues | null>;
|
|
40
|
+
getOrResolve?<DB extends SyncCoreDb, Auth extends SyncServerAuth>(
|
|
41
|
+
args: ScopeCacheContext<DB, Auth> & {
|
|
42
|
+
load: () => Promise<ScopeValues | null>;
|
|
43
|
+
}
|
|
44
|
+
): Promise<ScopeValues | null>;
|
|
40
45
|
set<DB extends SyncCoreDb, Auth extends SyncServerAuth>(
|
|
41
46
|
args: ScopeCacheSetContext<DB, Auth>
|
|
42
47
|
): Promise<void>;
|
|
@@ -117,6 +122,10 @@ export function createMemoryScopeCache(
|
|
|
117
122
|
options?.maxEntries ?? DEFAULT_MEMORY_SCOPE_CACHE_MAX_ENTRIES;
|
|
118
123
|
const now = options?.now ?? Date.now;
|
|
119
124
|
const buckets = new WeakMap<object, Map<string, MemoryScopeCacheEntry>>();
|
|
125
|
+
const inflightByDb = new WeakMap<
|
|
126
|
+
object,
|
|
127
|
+
Map<string, Promise<ScopeValues | null>>
|
|
128
|
+
>();
|
|
120
129
|
|
|
121
130
|
function getBucket(db: object): Map<string, MemoryScopeCacheEntry> {
|
|
122
131
|
const existing = buckets.get(db);
|
|
@@ -128,6 +137,18 @@ export function createMemoryScopeCache(
|
|
|
128
137
|
return created;
|
|
129
138
|
}
|
|
130
139
|
|
|
140
|
+
function getInflightBucket(
|
|
141
|
+
db: object
|
|
142
|
+
): Map<string, Promise<ScopeValues | null>> {
|
|
143
|
+
const existing = inflightByDb.get(db);
|
|
144
|
+
if (existing) {
|
|
145
|
+
return existing;
|
|
146
|
+
}
|
|
147
|
+
const created = new Map<string, Promise<ScopeValues | null>>();
|
|
148
|
+
inflightByDb.set(db, created);
|
|
149
|
+
return created;
|
|
150
|
+
}
|
|
151
|
+
|
|
131
152
|
function evictOldest(bucket: Map<string, MemoryScopeCacheEntry>): void {
|
|
132
153
|
while (bucket.size > maxEntries) {
|
|
133
154
|
const oldestKey = bucket.keys().next().value;
|
|
@@ -155,6 +176,43 @@ export function createMemoryScopeCache(
|
|
|
155
176
|
|
|
156
177
|
return cloneScopeValues(entry.scopes);
|
|
157
178
|
},
|
|
179
|
+
async getOrResolve(args) {
|
|
180
|
+
const cached = await this.get(args);
|
|
181
|
+
if (cached !== null) {
|
|
182
|
+
return cached;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const inflightBucket = getInflightBucket(args.db);
|
|
186
|
+
const inflight = inflightBucket.get(args.cacheKey);
|
|
187
|
+
if (inflight) {
|
|
188
|
+
const resolved = await inflight;
|
|
189
|
+
return resolved ? cloneScopeValues(resolved) : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const pending = args
|
|
193
|
+
.load()
|
|
194
|
+
.then(async (resolved) => {
|
|
195
|
+
if (resolved !== null && ttlMs > 0) {
|
|
196
|
+
const bucket = getBucket(args.db);
|
|
197
|
+
if (bucket.has(args.cacheKey)) {
|
|
198
|
+
bucket.delete(args.cacheKey);
|
|
199
|
+
}
|
|
200
|
+
bucket.set(args.cacheKey, {
|
|
201
|
+
scopes: cloneScopeValues(resolved),
|
|
202
|
+
expiresAt: now() + ttlMs,
|
|
203
|
+
});
|
|
204
|
+
evictOldest(bucket);
|
|
205
|
+
}
|
|
206
|
+
return resolved;
|
|
207
|
+
})
|
|
208
|
+
.finally(() => {
|
|
209
|
+
inflightBucket.delete(args.cacheKey);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
inflightBucket.set(args.cacheKey, pending);
|
|
213
|
+
const resolved = await pending;
|
|
214
|
+
return resolved ? cloneScopeValues(resolved) : null;
|
|
215
|
+
},
|
|
158
216
|
async set(args) {
|
|
159
217
|
const bucket = getBucket(args.db);
|
|
160
218
|
if (ttlMs <= 0) {
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
createServerHandlerCollection,
|
|
8
8
|
} from '../handlers';
|
|
9
9
|
import type { SyncCoreDb } from '../schema';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createDatabaseScopeCache,
|
|
12
|
+
createDefaultScopeCacheKey,
|
|
13
|
+
createMemoryScopeCache,
|
|
14
|
+
} from './cache';
|
|
11
15
|
import { resolveEffectiveScopesForSubscriptions } from './resolve';
|
|
12
16
|
|
|
13
17
|
interface TasksTable {
|
|
@@ -145,6 +149,72 @@ describe('resolveEffectiveScopesForSubscriptions cache behavior', () => {
|
|
|
145
149
|
expect(resolved.every((entry) => entry.status === 'active')).toBe(true);
|
|
146
150
|
});
|
|
147
151
|
|
|
152
|
+
it('dedupes concurrent resolveScopes calls through shared memory cache', async () => {
|
|
153
|
+
let resolveCalls = 0;
|
|
154
|
+
let releaseLoad: (() => void) | null = null;
|
|
155
|
+
const loadReleased = new Promise<void>((resolve) => {
|
|
156
|
+
releaseLoad = resolve;
|
|
157
|
+
});
|
|
158
|
+
let firstLoadStarted = false;
|
|
159
|
+
let markFirstLoadStarted: (() => void) | null = null;
|
|
160
|
+
const firstLoadStartedPromise = new Promise<void>((resolve) => {
|
|
161
|
+
markFirstLoadStarted = resolve;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const handler = createServerHandler<TestDb, ClientDb, 'tasks'>({
|
|
165
|
+
table: 'tasks',
|
|
166
|
+
scopes: ['user:{user_id}'],
|
|
167
|
+
resolveScopes: async (ctx) => {
|
|
168
|
+
resolveCalls += 1;
|
|
169
|
+
if (!firstLoadStarted) {
|
|
170
|
+
firstLoadStarted = true;
|
|
171
|
+
if (!markFirstLoadStarted) {
|
|
172
|
+
throw new Error('Missing first-load start resolver');
|
|
173
|
+
}
|
|
174
|
+
markFirstLoadStarted();
|
|
175
|
+
await loadReleased;
|
|
176
|
+
}
|
|
177
|
+
return { user_id: [ctx.actorId] };
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const handlers = createServerHandlerCollection<TestDb>([handler]);
|
|
181
|
+
const scopeCache = createMemoryScopeCache();
|
|
182
|
+
|
|
183
|
+
const firstResolve = resolveEffectiveScopesForSubscriptions({
|
|
184
|
+
db,
|
|
185
|
+
auth: { actorId: 'u1' },
|
|
186
|
+
handlers,
|
|
187
|
+
scopeCache,
|
|
188
|
+
subscriptions: [
|
|
189
|
+
{ id: 'sub-1', table: 'tasks', scopes: { user_id: 'u1' }, cursor: -1 },
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
await firstLoadStartedPromise;
|
|
193
|
+
const secondResolve = resolveEffectiveScopesForSubscriptions({
|
|
194
|
+
db,
|
|
195
|
+
auth: { actorId: 'u1' },
|
|
196
|
+
handlers,
|
|
197
|
+
scopeCache,
|
|
198
|
+
subscriptions: [
|
|
199
|
+
{ id: 'sub-2', table: 'tasks', scopes: { user_id: 'u1' }, cursor: -1 },
|
|
200
|
+
],
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (!releaseLoad) {
|
|
204
|
+
throw new Error('Missing load-release resolver');
|
|
205
|
+
}
|
|
206
|
+
releaseLoad();
|
|
207
|
+
|
|
208
|
+
const [firstResult, secondResult] = await Promise.all([
|
|
209
|
+
firstResolve,
|
|
210
|
+
secondResolve,
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
expect(resolveCalls).toBe(1);
|
|
214
|
+
expect(firstResult[0]?.status).toBe('active');
|
|
215
|
+
expect(secondResult[0]?.status).toBe('active');
|
|
216
|
+
});
|
|
217
|
+
|
|
148
218
|
it('roundtrips values through database scope cache', async () => {
|
|
149
219
|
const scopeCache = createDatabaseScopeCache();
|
|
150
220
|
const auth = { actorId: 'u1', partitionId: 'tenant-1' };
|
|
@@ -177,72 +177,99 @@ export async function resolveEffectiveScopesForSubscriptions<
|
|
|
177
177
|
if (requestScopeCache.has(scopeCacheKey)) {
|
|
178
178
|
allowed = requestScopeCache.get(scopeCacheKey) ?? null;
|
|
179
179
|
} else {
|
|
180
|
-
|
|
181
|
-
let sharedCacheHit = false;
|
|
180
|
+
let resolveFailed = false;
|
|
182
181
|
|
|
183
|
-
|
|
182
|
+
const loadAllowedScopes = async (): Promise<ScopeValues> => {
|
|
184
183
|
try {
|
|
185
|
-
|
|
184
|
+
return await handler.resolveScopes({
|
|
186
185
|
db: args.db,
|
|
186
|
+
actorId: args.auth.actorId,
|
|
187
187
|
auth: args.auth,
|
|
188
|
-
table: sub.table,
|
|
189
|
-
cacheKey: scopeCacheKey,
|
|
190
188
|
});
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
sharedCacheHit = true;
|
|
194
|
-
}
|
|
195
|
-
} catch (cacheErr) {
|
|
189
|
+
} catch (resolveErr) {
|
|
190
|
+
resolveFailed = true;
|
|
196
191
|
console.error(
|
|
197
|
-
`[
|
|
198
|
-
|
|
192
|
+
`[resolveScopes] Failed for table ${sub.table}, subscription ${sub.id}:`,
|
|
193
|
+
resolveErr
|
|
199
194
|
);
|
|
195
|
+
throw resolveErr;
|
|
200
196
|
}
|
|
201
|
-
}
|
|
197
|
+
};
|
|
202
198
|
|
|
203
|
-
if (
|
|
199
|
+
if (args.scopeCache?.getOrResolve) {
|
|
204
200
|
try {
|
|
205
|
-
allowed = await
|
|
201
|
+
allowed = await args.scopeCache.getOrResolve({
|
|
206
202
|
db: args.db,
|
|
207
|
-
actorId: args.auth.actorId,
|
|
208
203
|
auth: args.auth,
|
|
209
|
-
});
|
|
210
|
-
} catch (resolveErr) {
|
|
211
|
-
// Scope resolution failed - mark subscription as revoked
|
|
212
|
-
// rather than failing the entire pull
|
|
213
|
-
console.error(
|
|
214
|
-
`[resolveScopes] Failed for table ${sub.table}, subscription ${sub.id}:`,
|
|
215
|
-
resolveErr
|
|
216
|
-
);
|
|
217
|
-
requestScopeCache.set(scopeCacheKey, null);
|
|
218
|
-
out.push({
|
|
219
|
-
id: sub.id,
|
|
220
204
|
table: sub.table,
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
cursor: sub.cursor,
|
|
224
|
-
bootstrapState: sub.bootstrapState ?? null,
|
|
225
|
-
status: 'revoked',
|
|
205
|
+
cacheKey: scopeCacheKey,
|
|
206
|
+
load: loadAllowedScopes,
|
|
226
207
|
});
|
|
227
|
-
|
|
208
|
+
} catch {
|
|
209
|
+
allowed = null;
|
|
228
210
|
}
|
|
211
|
+
} else {
|
|
212
|
+
allowed = null;
|
|
213
|
+
let sharedCacheHit = false;
|
|
229
214
|
|
|
230
|
-
if (args.scopeCache
|
|
215
|
+
if (args.scopeCache) {
|
|
231
216
|
try {
|
|
232
|
-
await args.scopeCache.
|
|
217
|
+
const cachedAllowed = await args.scopeCache.get({
|
|
233
218
|
db: args.db,
|
|
234
219
|
auth: args.auth,
|
|
235
220
|
table: sub.table,
|
|
236
221
|
cacheKey: scopeCacheKey,
|
|
237
|
-
scopes: allowed,
|
|
238
222
|
});
|
|
223
|
+
if (cachedAllowed !== null) {
|
|
224
|
+
allowed = cachedAllowed;
|
|
225
|
+
sharedCacheHit = true;
|
|
226
|
+
}
|
|
239
227
|
} catch (cacheErr) {
|
|
240
228
|
console.error(
|
|
241
|
-
`[scopeCache.
|
|
229
|
+
`[scopeCache.get] Failed for table ${sub.table}, subscription ${sub.id}:`,
|
|
242
230
|
cacheErr
|
|
243
231
|
);
|
|
244
232
|
}
|
|
245
233
|
}
|
|
234
|
+
|
|
235
|
+
if (!sharedCacheHit) {
|
|
236
|
+
try {
|
|
237
|
+
allowed = await loadAllowedScopes();
|
|
238
|
+
} catch {
|
|
239
|
+
allowed = null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (args.scopeCache && allowed !== null) {
|
|
243
|
+
try {
|
|
244
|
+
await args.scopeCache.set({
|
|
245
|
+
db: args.db,
|
|
246
|
+
auth: args.auth,
|
|
247
|
+
table: sub.table,
|
|
248
|
+
cacheKey: scopeCacheKey,
|
|
249
|
+
scopes: allowed,
|
|
250
|
+
});
|
|
251
|
+
} catch (cacheErr) {
|
|
252
|
+
console.error(
|
|
253
|
+
`[scopeCache.set] Failed for table ${sub.table}, subscription ${sub.id}:`,
|
|
254
|
+
cacheErr
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (resolveFailed) {
|
|
262
|
+
requestScopeCache.set(scopeCacheKey, null);
|
|
263
|
+
out.push({
|
|
264
|
+
id: sub.id,
|
|
265
|
+
table: sub.table,
|
|
266
|
+
scopes: {},
|
|
267
|
+
params: sub.params,
|
|
268
|
+
cursor: sub.cursor,
|
|
269
|
+
bootstrapState: sub.bootstrapState ?? null,
|
|
270
|
+
status: 'revoked',
|
|
271
|
+
});
|
|
272
|
+
continue;
|
|
246
273
|
}
|
|
247
274
|
|
|
248
275
|
requestScopeCache.set(scopeCacheKey, allowed);
|