@absolutejs/sync 1.20.0 → 1.20.1

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.
@@ -425,6 +425,18 @@ export declare class MutationQueueOverflowError extends Error {
425
425
  readonly queueLimit: number;
426
426
  constructor(queueLimit: number);
427
427
  }
428
+ /**
429
+ * Thrown by `engine.subscribe` when the calling tenant's active-subscription
430
+ * count is already at the configured `subscriptionLimit.max`. The caller sees
431
+ * this immediately — BEFORE authorize, hydrate, or any subscription state
432
+ * allocation — so a rejected call leaks nothing. Added in 1.20.1.
433
+ */
434
+ export declare class SubscriptionLimitError extends Error {
435
+ readonly tenantKey: string;
436
+ readonly limit: number;
437
+ readonly active: number;
438
+ constructor(tenantKey: string, limit: number, active: number);
439
+ }
428
440
  /**
429
441
  * Serializable snapshot of an engine's change log + monotonic version, returned
430
442
  * by {@link SyncEngine.exportChangeLog} and consumed by
@@ -597,6 +609,30 @@ export type SyncEngineOptions = {
597
609
  * Added in 1.20.0.
598
610
  */
599
611
  mutationQueueLimit?: number;
612
+ /**
613
+ * Per-tenant active-subscription cap. Symmetric to
614
+ * {@link SyncEngineOptions.mutationConcurrency} on the read side: a
615
+ * single tenant opening thousands of subscriptions would otherwise
616
+ * exhaust the engine's per-subscription bookkeeping
617
+ * (`active`/`tableIndex` Maps, the reactive cache, per-row diff
618
+ * computation cost).
619
+ *
620
+ * `key` derives a tenant identifier from `(ctx, args)`; returning
621
+ * `undefined` skips the cap for that call (e.g. internal/system
622
+ * subscriptions). When the active count for a key reaches `max`, the
623
+ * next `subscribe` throws {@link SubscriptionLimitError} BEFORE any
624
+ * authorize, hydrate, or state allocation — so a denied call leaks
625
+ * nothing.
626
+ *
627
+ * Active counts are surfaced through `engine.metrics().subscriptions.byTenant`
628
+ * for tier monitoring. Added in 1.20.1.
629
+ */
630
+ subscriptionLimit?: {
631
+ max: number;
632
+ key: (ctx: unknown, args: {
633
+ collection: string;
634
+ }) => string | undefined;
635
+ };
600
636
  };
601
637
  /**
602
638
  * The Tier 3 sync engine: a registry of collections plus the view syncer. It is
package/dist/index.js CHANGED
@@ -1161,6 +1161,19 @@ class MutationQueueOverflowError extends Error {
1161
1161
  this.queueLimit = queueLimit;
1162
1162
  }
1163
1163
  }
1164
+
1165
+ class SubscriptionLimitError extends Error {
1166
+ tenantKey;
1167
+ limit;
1168
+ active;
1169
+ constructor(tenantKey, limit, active) {
1170
+ super(`Tenant "${tenantKey}" is at the subscription cap ` + `(${active}/${limit}). Close an existing subscription before opening another.`);
1171
+ this.name = "SubscriptionLimitError";
1172
+ this.tenantKey = tenantKey;
1173
+ this.limit = limit;
1174
+ this.active = active;
1175
+ }
1176
+ }
1164
1177
  var defaultKey = (row) => row.id;
1165
1178
  var shallowEqual3 = (a, b) => {
1166
1179
  if (a === b) {
@@ -1311,6 +1324,31 @@ var createSyncEngine = (options = {}) => {
1311
1324
  if (next !== undefined)
1312
1325
  next();
1313
1326
  };
1327
+ const subscriptionsByTenant = new Map;
1328
+ const acquireSubscriptionSlot = (ctx, args) => {
1329
+ const cap = options.subscriptionLimit;
1330
+ if (cap === undefined)
1331
+ return;
1332
+ const tenantKey = cap.key(ctx, args);
1333
+ if (tenantKey === undefined)
1334
+ return;
1335
+ const active2 = subscriptionsByTenant.get(tenantKey) ?? 0;
1336
+ if (active2 >= cap.max) {
1337
+ throw new SubscriptionLimitError(tenantKey, cap.max, active2);
1338
+ }
1339
+ subscriptionsByTenant.set(tenantKey, active2 + 1);
1340
+ return tenantKey;
1341
+ };
1342
+ const releaseSubscriptionSlot = (tenantKey) => {
1343
+ if (tenantKey === undefined)
1344
+ return;
1345
+ const active2 = subscriptionsByTenant.get(tenantKey);
1346
+ if (active2 === undefined || active2 <= 1) {
1347
+ subscriptionsByTenant.delete(tenantKey);
1348
+ } else {
1349
+ subscriptionsByTenant.set(tenantKey, active2 - 1);
1350
+ }
1351
+ };
1314
1352
  const reactiveCacheMax = options.reactiveCache?.max ?? 256;
1315
1353
  const reactiveCacheTtlMs = options.reactiveCache?.ttlMs ?? 60000;
1316
1354
  const cachedReruns = new Map;
@@ -2148,84 +2186,103 @@ var createSyncEngine = (options = {}) => {
2148
2186
  if (registered === undefined) {
2149
2187
  throw new Error(`Unknown collection "${collection}"`);
2150
2188
  }
2151
- const typedOnDiff = onDiff;
2152
- const subscribeSet = subsFor(collection);
2153
- const wrapReturn = (sub) => {
2154
- checkAborted(signal);
2155
- linkAbortToUnsubscribe(signal, sub.unsubscribe);
2156
- return sub;
2157
- };
2158
- const registeredKind = registered.kind;
2159
- if (registeredKind === "join") {
2160
- const joined = await subscribeJoin(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2161
- return wrapReturn(joined);
2162
- }
2163
- if (registeredKind === "graph") {
2164
- const graphed = await subscribeGraph(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2165
- return wrapReturn(graphed);
2166
- }
2167
- if (registeredKind === "reactive") {
2168
- const reactived = await subscribeReactive(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2169
- return wrapReturn(reactived);
2170
- }
2171
- if (registeredKind === "search") {
2172
- const searched = await subscribeSearch(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2173
- return wrapReturn(searched);
2174
- }
2175
- const definition = registered;
2176
- if (definition.authorize !== undefined) {
2177
- const allowed = await definition.authorize(params, ctx);
2178
- if (!allowed) {
2179
- throw new UnauthorizedError(`subscribe to collection "${collection}"`);
2189
+ const tenantSlot = acquireSubscriptionSlot(ctx, { collection });
2190
+ let slotHandedOff = false;
2191
+ try {
2192
+ const typedOnDiff = onDiff;
2193
+ const subscribeSet = subsFor(collection);
2194
+ const wrapReturn = (sub) => {
2195
+ checkAborted(signal);
2196
+ const innerUnsubscribe = sub.unsubscribe;
2197
+ let released = false;
2198
+ const wrappedUnsubscribe = () => {
2199
+ if (released)
2200
+ return;
2201
+ released = true;
2202
+ releaseSubscriptionSlot(tenantSlot);
2203
+ innerUnsubscribe();
2204
+ };
2205
+ const wrapped = { ...sub, unsubscribe: wrappedUnsubscribe };
2206
+ linkAbortToUnsubscribe(signal, wrappedUnsubscribe);
2207
+ slotHandedOff = true;
2208
+ return wrapped;
2209
+ };
2210
+ const registeredKind = registered.kind;
2211
+ if (registeredKind === "join") {
2212
+ const joined = await subscribeJoin(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2213
+ return wrapReturn(joined);
2214
+ }
2215
+ if (registeredKind === "graph") {
2216
+ const graphed = await subscribeGraph(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2217
+ return wrapReturn(graphed);
2218
+ }
2219
+ if (registeredKind === "reactive") {
2220
+ const reactived = await subscribeReactive(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2221
+ return wrapReturn(reactived);
2222
+ }
2223
+ if (registeredKind === "search") {
2224
+ const searched = await subscribeSearch(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2225
+ return wrapReturn(searched);
2226
+ }
2227
+ const definition = registered;
2228
+ if (definition.authorize !== undefined) {
2229
+ const allowed = await definition.authorize(params, ctx);
2230
+ if (!allowed) {
2231
+ throw new UnauthorizedError(`subscribe to collection "${collection}"`);
2232
+ }
2233
+ }
2234
+ const key = definition.key ?? defaultKey;
2235
+ const match = definition.match;
2236
+ const tables = definition.tables ?? [collection];
2237
+ const scopedTable = tables.length === 1 ? tables[0] : undefined;
2238
+ const readRule = scopedTable !== undefined ? readRuleFor(scopedTable) : undefined;
2239
+ const rehydrate = async () => {
2240
+ const raw = [...await definition.hydrate(params, ctx)];
2241
+ const rows = scopedTable !== undefined ? raw.map((row) => migrateRow(scopedTable, row)) : raw;
2242
+ return readRule ? rows.filter((row) => readRule(ctx, row)) : rows;
2243
+ };
2244
+ const incremental = match !== undefined && tables.length === 1;
2245
+ const boundMatch = incremental ? (row) => match(row, params, ctx) && (readRule ? readRule(ctx, row) : true) : () => true;
2246
+ const view = createMaterializedView({
2247
+ key,
2248
+ match: boundMatch
2249
+ });
2250
+ const resuming = since !== undefined && canResume(since, incremental);
2251
+ view.hydrate([...await rehydrate()]);
2252
+ const atVersion = version;
2253
+ const subscription = {
2254
+ kind: "view",
2255
+ collection,
2256
+ view,
2257
+ incremental,
2258
+ rehydrate,
2259
+ key,
2260
+ onDiff: typedOnDiff
2261
+ };
2262
+ subscribeSet.add(subscription);
2263
+ const unsubscribe = () => {
2264
+ subscribeSet.delete(subscription);
2265
+ };
2266
+ if (resuming) {
2267
+ return wrapReturn({
2268
+ initial: [],
2269
+ catchup: buildCatchup(since, tables, key, boundMatch),
2270
+ cursor: currentCursor(),
2271
+ version: atVersion,
2272
+ unsubscribe
2273
+ });
2180
2274
  }
2181
- }
2182
- const key = definition.key ?? defaultKey;
2183
- const match = definition.match;
2184
- const tables = definition.tables ?? [collection];
2185
- const scopedTable = tables.length === 1 ? tables[0] : undefined;
2186
- const readRule = scopedTable !== undefined ? readRuleFor(scopedTable) : undefined;
2187
- const rehydrate = async () => {
2188
- const raw = [...await definition.hydrate(params, ctx)];
2189
- const rows = scopedTable !== undefined ? raw.map((row) => migrateRow(scopedTable, row)) : raw;
2190
- return readRule ? rows.filter((row) => readRule(ctx, row)) : rows;
2191
- };
2192
- const incremental = match !== undefined && tables.length === 1;
2193
- const boundMatch = incremental ? (row) => match(row, params, ctx) && (readRule ? readRule(ctx, row) : true) : () => true;
2194
- const view = createMaterializedView({
2195
- key,
2196
- match: boundMatch
2197
- });
2198
- const resuming = since !== undefined && canResume(since, incremental);
2199
- view.hydrate([...await rehydrate()]);
2200
- const atVersion = version;
2201
- const subscription = {
2202
- kind: "view",
2203
- collection,
2204
- view,
2205
- incremental,
2206
- rehydrate,
2207
- key,
2208
- onDiff: typedOnDiff
2209
- };
2210
- subscribeSet.add(subscription);
2211
- const unsubscribe = () => {
2212
- subscribeSet.delete(subscription);
2213
- };
2214
- if (resuming) {
2215
2275
  return wrapReturn({
2216
- initial: [],
2217
- catchup: buildCatchup(since, tables, key, boundMatch),
2276
+ initial: view.rows(),
2218
2277
  cursor: currentCursor(),
2219
2278
  version: atVersion,
2220
2279
  unsubscribe
2221
2280
  });
2281
+ } catch (error) {
2282
+ if (!slotHandedOff)
2283
+ releaseSubscriptionSlot(tenantSlot);
2284
+ throw error;
2222
2285
  }
2223
- return wrapReturn({
2224
- initial: view.rows(),
2225
- cursor: currentCursor(),
2226
- version: atVersion,
2227
- unsubscribe
2228
- });
2229
2286
  },
2230
2287
  hydrate: async (collection, params, ctx, options2) => {
2231
2288
  const signal = options2?.signal;
@@ -2690,6 +2747,7 @@ var createSyncEngine = (options = {}) => {
2690
2747
  },
2691
2748
  subscriptions: {
2692
2749
  byCollection,
2750
+ byTenant: Object.fromEntries(subscriptionsByTenant),
2693
2751
  total: totalSubscriptions
2694
2752
  },
2695
2753
  uptimeMs: now - engineStartedAt,
@@ -3072,5 +3130,5 @@ export {
3072
3130
  createPresenceHub
3073
3131
  };
3074
3132
 
3075
- //# debugId=9DD57DF563EB9CB564756E2164756E21
3133
+ //# debugId=27A5DE0DF43D569D64756E2164756E21
3076
3134
  //# sourceMappingURL=index.js.map