@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.
@@ -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 { createDatabaseScopeCache, createDefaultScopeCacheKey } from './cache';
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
- allowed = null;
181
- let sharedCacheHit = false;
180
+ let resolveFailed = false;
182
181
 
183
- if (args.scopeCache) {
182
+ const loadAllowedScopes = async (): Promise<ScopeValues> => {
184
183
  try {
185
- const cachedAllowed = await args.scopeCache.get({
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
- if (cachedAllowed !== null) {
192
- allowed = cachedAllowed;
193
- sharedCacheHit = true;
194
- }
195
- } catch (cacheErr) {
189
+ } catch (resolveErr) {
190
+ resolveFailed = true;
196
191
  console.error(
197
- `[scopeCache.get] Failed for table ${sub.table}, subscription ${sub.id}:`,
198
- cacheErr
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 (!sharedCacheHit) {
199
+ if (args.scopeCache?.getOrResolve) {
204
200
  try {
205
- allowed = await handler.resolveScopes({
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
- scopes: {},
222
- params: sub.params,
223
- cursor: sub.cursor,
224
- bootstrapState: sub.bootstrapState ?? null,
225
- status: 'revoked',
205
+ cacheKey: scopeCacheKey,
206
+ load: loadAllowedScopes,
226
207
  });
227
- continue;
208
+ } catch {
209
+ allowed = null;
228
210
  }
211
+ } else {
212
+ allowed = null;
213
+ let sharedCacheHit = false;
229
214
 
230
- if (args.scopeCache && allowed !== null) {
215
+ if (args.scopeCache) {
231
216
  try {
232
- await args.scopeCache.set({
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.set] Failed for table ${sub.table}, subscription ${sub.id}:`,
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);