@absolutejs/sync 1.19.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.
@@ -414,6 +414,29 @@ export declare class CdcConsumerSlowError extends Error {
414
414
  readonly lastDeliveredVersion: number;
415
415
  constructor(maxBuffer: number, lastDeliveredVersion: number);
416
416
  }
417
+ /**
418
+ * Thrown by `runMutation` / `runMutations` when `mutationConcurrency` is
419
+ * saturated AND the waiting queue is already at `mutationQueueLimit`. The
420
+ * caller sees this immediately (no queue time) so the host can shed load
421
+ * with a clean 429 instead of letting the queue grow unboundedly. Added
422
+ * in 1.20.0.
423
+ */
424
+ export declare class MutationQueueOverflowError extends Error {
425
+ readonly queueLimit: number;
426
+ constructor(queueLimit: number);
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
+ }
417
440
  /**
418
441
  * Serializable snapshot of an engine's change log + monotonic version, returned
419
442
  * by {@link SyncEngine.exportChangeLog} and consumed by
@@ -557,6 +580,59 @@ export type SyncEngineOptions = {
557
580
  * `createSyncEngine` returns. Added in 1.19.0.
558
581
  */
559
582
  initialChangeLog?: ChangeLogSnapshot;
583
+ /**
584
+ * Maximum concurrent in-flight mutations (`runMutation` + `runMutations`).
585
+ * Calls beyond the limit wait in a FIFO queue and run as slots free up;
586
+ * `engine.metrics().mutations.queued` surfaces the queue depth.
587
+ *
588
+ * A single tenant flooding `runMutation` can otherwise drive unbounded
589
+ * memory growth (per-mutation `actions` buffers, retry timers, sandbox
590
+ * invocations queued against the isolate pool). Set this to a value
591
+ * appropriate for the host's tenant tier — e.g. `32` for a free tier,
592
+ * `256` for paid. Without this option the engine is unbounded
593
+ * (matching pre-1.20 behavior).
594
+ *
595
+ * Sandboxed mutations are gated by the same semaphore. If you need
596
+ * finer-grained control (sandbox-only throttling), see
597
+ * `@absolutejs/isolated-jsc`'s pool size — that's the lower layer.
598
+ *
599
+ * Added in 1.20.0.
600
+ */
601
+ mutationConcurrency?: number;
602
+ /**
603
+ * Cap on the queue of waiting mutations once `mutationConcurrency` is
604
+ * saturated. Calls beyond this cap throw {@link MutationQueueOverflowError}
605
+ * immediately instead of queueing — the host can surface a clean 429 or
606
+ * apply a tenant-specific shed policy. Defaults to unbounded (queue
607
+ * never rejects). Only meaningful when `mutationConcurrency` is set.
608
+ *
609
+ * Added in 1.20.0.
610
+ */
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
+ };
560
636
  };
561
637
  /**
562
638
  * The Tier 3 sync engine: a registry of collections plus the view syncer. It is
package/dist/index.js CHANGED
@@ -1152,6 +1152,28 @@ class CdcConsumerSlowError extends Error {
1152
1152
  this.lastDeliveredVersion = lastDeliveredVersion;
1153
1153
  }
1154
1154
  }
1155
+
1156
+ class MutationQueueOverflowError extends Error {
1157
+ queueLimit;
1158
+ constructor(queueLimit) {
1159
+ super(`Mutation queue overflowed (limit ${queueLimit}); the engine is at ` + `its mutationConcurrency cap and the waiting queue is full. ` + `Retry later or shed load at the gateway.`);
1160
+ this.name = "MutationQueueOverflowError";
1161
+ this.queueLimit = queueLimit;
1162
+ }
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
+ }
1155
1177
  var defaultKey = (row) => row.id;
1156
1178
  var shallowEqual3 = (a, b) => {
1157
1179
  if (a === b) {
@@ -1268,6 +1290,65 @@ var createSyncEngine = (options = {}) => {
1268
1290
  let mutationsFailed = 0;
1269
1291
  let mutationsRetried = 0;
1270
1292
  let mutationsInFlight = 0;
1293
+ const mutationWaiters = [];
1294
+ let mutationsQueued = 0;
1295
+ const acquireMutationSlot = async () => {
1296
+ const limit = options.mutationConcurrency;
1297
+ if (limit === undefined) {
1298
+ mutationsInFlight += 1;
1299
+ return;
1300
+ }
1301
+ if (mutationsInFlight < limit && mutationWaiters.length === 0) {
1302
+ mutationsInFlight += 1;
1303
+ return;
1304
+ }
1305
+ const queueLimit = options.mutationQueueLimit;
1306
+ if (queueLimit !== undefined && mutationsQueued >= queueLimit) {
1307
+ throw new MutationQueueOverflowError(queueLimit);
1308
+ }
1309
+ mutationsQueued += 1;
1310
+ try {
1311
+ await new Promise((resolve) => {
1312
+ mutationWaiters.push(resolve);
1313
+ });
1314
+ } finally {
1315
+ mutationsQueued -= 1;
1316
+ }
1317
+ mutationsInFlight += 1;
1318
+ };
1319
+ const releaseMutationSlot = () => {
1320
+ mutationsInFlight -= 1;
1321
+ if (options.mutationConcurrency === undefined)
1322
+ return;
1323
+ const next = mutationWaiters.shift();
1324
+ if (next !== undefined)
1325
+ next();
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
+ };
1271
1352
  const reactiveCacheMax = options.reactiveCache?.max ?? 256;
1272
1353
  const reactiveCacheTtlMs = options.reactiveCache?.ttlMs ?? 60000;
1273
1354
  const cachedReruns = new Map;
@@ -2105,84 +2186,103 @@ var createSyncEngine = (options = {}) => {
2105
2186
  if (registered === undefined) {
2106
2187
  throw new Error(`Unknown collection "${collection}"`);
2107
2188
  }
2108
- const typedOnDiff = onDiff;
2109
- const subscribeSet = subsFor(collection);
2110
- const wrapReturn = (sub) => {
2111
- checkAborted(signal);
2112
- linkAbortToUnsubscribe(signal, sub.unsubscribe);
2113
- return sub;
2114
- };
2115
- const registeredKind = registered.kind;
2116
- if (registeredKind === "join") {
2117
- const joined = await subscribeJoin(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2118
- return wrapReturn(joined);
2119
- }
2120
- if (registeredKind === "graph") {
2121
- const graphed = await subscribeGraph(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2122
- return wrapReturn(graphed);
2123
- }
2124
- if (registeredKind === "reactive") {
2125
- const reactived = await subscribeReactive(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2126
- return wrapReturn(reactived);
2127
- }
2128
- if (registeredKind === "search") {
2129
- const searched = await subscribeSearch(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2130
- return wrapReturn(searched);
2131
- }
2132
- const definition = registered;
2133
- if (definition.authorize !== undefined) {
2134
- const allowed = await definition.authorize(params, ctx);
2135
- if (!allowed) {
2136
- 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
+ });
2137
2274
  }
2138
- }
2139
- const key = definition.key ?? defaultKey;
2140
- const match = definition.match;
2141
- const tables = definition.tables ?? [collection];
2142
- const scopedTable = tables.length === 1 ? tables[0] : undefined;
2143
- const readRule = scopedTable !== undefined ? readRuleFor(scopedTable) : undefined;
2144
- const rehydrate = async () => {
2145
- const raw = [...await definition.hydrate(params, ctx)];
2146
- const rows = scopedTable !== undefined ? raw.map((row) => migrateRow(scopedTable, row)) : raw;
2147
- return readRule ? rows.filter((row) => readRule(ctx, row)) : rows;
2148
- };
2149
- const incremental = match !== undefined && tables.length === 1;
2150
- const boundMatch = incremental ? (row) => match(row, params, ctx) && (readRule ? readRule(ctx, row) : true) : () => true;
2151
- const view = createMaterializedView({
2152
- key,
2153
- match: boundMatch
2154
- });
2155
- const resuming = since !== undefined && canResume(since, incremental);
2156
- view.hydrate([...await rehydrate()]);
2157
- const atVersion = version;
2158
- const subscription = {
2159
- kind: "view",
2160
- collection,
2161
- view,
2162
- incremental,
2163
- rehydrate,
2164
- key,
2165
- onDiff: typedOnDiff
2166
- };
2167
- subscribeSet.add(subscription);
2168
- const unsubscribe = () => {
2169
- subscribeSet.delete(subscription);
2170
- };
2171
- if (resuming) {
2172
2275
  return wrapReturn({
2173
- initial: [],
2174
- catchup: buildCatchup(since, tables, key, boundMatch),
2276
+ initial: view.rows(),
2175
2277
  cursor: currentCursor(),
2176
2278
  version: atVersion,
2177
2279
  unsubscribe
2178
2280
  });
2281
+ } catch (error) {
2282
+ if (!slotHandedOff)
2283
+ releaseSubscriptionSlot(tenantSlot);
2284
+ throw error;
2179
2285
  }
2180
- return wrapReturn({
2181
- initial: view.rows(),
2182
- cursor: currentCursor(),
2183
- version: atVersion,
2184
- unsubscribe
2185
- });
2186
2286
  },
2187
2287
  hydrate: async (collection, params, ctx, options2) => {
2188
2288
  const signal = options2?.signal;
@@ -2295,6 +2395,7 @@ var createSyncEngine = (options = {}) => {
2295
2395
  throw new UnauthorizedError(`run mutation "${name}"`);
2296
2396
  }
2297
2397
  }
2398
+ await acquireMutationSlot();
2298
2399
  const sandboxRunner = sandboxRunners.get(name);
2299
2400
  const invokeHandler = sandboxRunner !== undefined ? sandboxRunner : (a, c, actions) => Promise.resolve(mutation.handler(a, c, actions));
2300
2401
  const runHandler = async (tx) => {
@@ -2310,7 +2411,6 @@ var createSyncEngine = (options = {}) => {
2310
2411
  const startedAt = Date.now();
2311
2412
  let lastError;
2312
2413
  let attemptsMade = 0;
2313
- mutationsInFlight += 1;
2314
2414
  try {
2315
2415
  for (let attempt = 1;attempt <= maxAttempts; attempt++) {
2316
2416
  attemptsMade = attempt;
@@ -2363,7 +2463,7 @@ var createSyncEngine = (options = {}) => {
2363
2463
  }
2364
2464
  throw lastError;
2365
2465
  } finally {
2366
- mutationsInFlight -= 1;
2466
+ releaseMutationSlot();
2367
2467
  }
2368
2468
  },
2369
2469
  runMutations: async (specs, ctx) => {
@@ -2376,6 +2476,7 @@ var createSyncEngine = (options = {}) => {
2376
2476
  }
2377
2477
  return { args: spec.args, mutation, name: spec.name };
2378
2478
  });
2479
+ await acquireMutationSlot();
2379
2480
  const runBatch = async (tx) => {
2380
2481
  const results = [];
2381
2482
  const accumulated = [];
@@ -2413,6 +2514,8 @@ var createSyncEngine = (options = {}) => {
2413
2514
  status: "error"
2414
2515
  });
2415
2516
  throw error;
2517
+ } finally {
2518
+ releaseMutationSlot();
2416
2519
  }
2417
2520
  },
2418
2521
  registerSchedule: (schedule) => {
@@ -2632,6 +2735,7 @@ var createSyncEngine = (options = {}) => {
2632
2735
  completed: mutationsCompleted,
2633
2736
  failed: mutationsFailed,
2634
2737
  inFlight: mutationsInFlight,
2738
+ queued: mutationsQueued,
2635
2739
  retried: mutationsRetried
2636
2740
  },
2637
2741
  reactiveCache: {
@@ -2643,6 +2747,7 @@ var createSyncEngine = (options = {}) => {
2643
2747
  },
2644
2748
  subscriptions: {
2645
2749
  byCollection,
2750
+ byTenant: Object.fromEntries(subscriptionsByTenant),
2646
2751
  total: totalSubscriptions
2647
2752
  },
2648
2753
  uptimeMs: now - engineStartedAt,
@@ -3025,5 +3130,5 @@ export {
3025
3130
  createPresenceHub
3026
3131
  };
3027
3132
 
3028
- //# debugId=EB8C381967D0311A64756E2164756E21
3133
+ //# debugId=27A5DE0DF43D569D64756E2164756E21
3029
3134
  //# sourceMappingURL=index.js.map