@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.
@@ -82,6 +82,14 @@ export type EngineMetrics = {
82
82
  total: number;
83
83
  /** Per-collection breakdown — the values sum to `total`. */
84
84
  byCollection: Record<string, number>;
85
+ /**
86
+ * Per-tenant tally — populated only when
87
+ * `SyncEngineOptions.subscriptionLimit` is set; otherwise `{}`. The
88
+ * key is whatever `subscriptionLimit.key(ctx, args)` returns; the
89
+ * value is the count of active subscriptions for that key. Added
90
+ * in 1.20.1.
91
+ */
92
+ byTenant: Record<string, number>;
85
93
  };
86
94
  reactiveCache: {
87
95
  entries: number;
@@ -96,6 +104,11 @@ export type EngineMetrics = {
96
104
  retried: number;
97
105
  /** Currently running, not yet committed or failed. */
98
106
  inFlight: number;
107
+ /**
108
+ * Waiting in the `mutationConcurrency` queue. Always `0` when
109
+ * `mutationConcurrency` is unset (no semaphore). Added in 1.20.0.
110
+ */
111
+ queued: number;
99
112
  };
100
113
  schedules: {
101
114
  registered: number;
@@ -47,8 +47,8 @@ export type { MutationActions, MutationDefinition, MutationHandler, TableWriter,
47
47
  export type { BridgeFetchConfig, BridgeFetchEndpoint, BridgeFetchResponse, HandlerMetricsHook, HandlerMetricsRecord, SandboxConfig } from './sandbox';
48
48
  export { exponentialBackoff, isSerializationFailure, RetriesExhaustedError } from './retry';
49
49
  export type { ExponentialBackoffOptions, RetryPolicy } from './retry';
50
- export { CdcConsumerSlowError, createSyncEngine, MissedChangesError, SchemaError, UnauthorizedError } from './syncEngine';
51
- export type { CrdtFields, LoggedChange, StreamChangesOptions, SubscribeArgs, Subscription, SyncEngine } from './syncEngine';
50
+ export { CdcConsumerSlowError, createSyncEngine, MissedChangesError, MutationQueueOverflowError, SchemaError, SubscriptionLimitError, UnauthorizedError } from './syncEngine';
51
+ export type { ChangeLogSnapshot, CrdtFields, LoggedChange, StreamChangesOptions, SubscribeArgs, Subscription, SyncEngine, SyncEngineOptions } from './syncEngine';
52
52
  export { syncCdc } from './cdc';
53
53
  export type { SyncCdcOptions } from './cdc';
54
54
  export type { CrdtMergeable } from '../crdt';
@@ -1410,6 +1410,28 @@ class CdcConsumerSlowError extends Error {
1410
1410
  this.lastDeliveredVersion = lastDeliveredVersion;
1411
1411
  }
1412
1412
  }
1413
+
1414
+ class MutationQueueOverflowError extends Error {
1415
+ queueLimit;
1416
+ constructor(queueLimit) {
1417
+ 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.`);
1418
+ this.name = "MutationQueueOverflowError";
1419
+ this.queueLimit = queueLimit;
1420
+ }
1421
+ }
1422
+
1423
+ class SubscriptionLimitError extends Error {
1424
+ tenantKey;
1425
+ limit;
1426
+ active;
1427
+ constructor(tenantKey, limit, active) {
1428
+ super(`Tenant "${tenantKey}" is at the subscription cap ` + `(${active}/${limit}). Close an existing subscription before opening another.`);
1429
+ this.name = "SubscriptionLimitError";
1430
+ this.tenantKey = tenantKey;
1431
+ this.limit = limit;
1432
+ this.active = active;
1433
+ }
1434
+ }
1413
1435
  var defaultKey = (row) => row.id;
1414
1436
  var shallowEqual4 = (a, b) => {
1415
1437
  if (a === b) {
@@ -1526,6 +1548,65 @@ var createSyncEngine = (options = {}) => {
1526
1548
  let mutationsFailed = 0;
1527
1549
  let mutationsRetried = 0;
1528
1550
  let mutationsInFlight = 0;
1551
+ const mutationWaiters = [];
1552
+ let mutationsQueued = 0;
1553
+ const acquireMutationSlot = async () => {
1554
+ const limit = options.mutationConcurrency;
1555
+ if (limit === undefined) {
1556
+ mutationsInFlight += 1;
1557
+ return;
1558
+ }
1559
+ if (mutationsInFlight < limit && mutationWaiters.length === 0) {
1560
+ mutationsInFlight += 1;
1561
+ return;
1562
+ }
1563
+ const queueLimit = options.mutationQueueLimit;
1564
+ if (queueLimit !== undefined && mutationsQueued >= queueLimit) {
1565
+ throw new MutationQueueOverflowError(queueLimit);
1566
+ }
1567
+ mutationsQueued += 1;
1568
+ try {
1569
+ await new Promise((resolve) => {
1570
+ mutationWaiters.push(resolve);
1571
+ });
1572
+ } finally {
1573
+ mutationsQueued -= 1;
1574
+ }
1575
+ mutationsInFlight += 1;
1576
+ };
1577
+ const releaseMutationSlot = () => {
1578
+ mutationsInFlight -= 1;
1579
+ if (options.mutationConcurrency === undefined)
1580
+ return;
1581
+ const next = mutationWaiters.shift();
1582
+ if (next !== undefined)
1583
+ next();
1584
+ };
1585
+ const subscriptionsByTenant = new Map;
1586
+ const acquireSubscriptionSlot = (ctx, args) => {
1587
+ const cap = options.subscriptionLimit;
1588
+ if (cap === undefined)
1589
+ return;
1590
+ const tenantKey = cap.key(ctx, args);
1591
+ if (tenantKey === undefined)
1592
+ return;
1593
+ const active2 = subscriptionsByTenant.get(tenantKey) ?? 0;
1594
+ if (active2 >= cap.max) {
1595
+ throw new SubscriptionLimitError(tenantKey, cap.max, active2);
1596
+ }
1597
+ subscriptionsByTenant.set(tenantKey, active2 + 1);
1598
+ return tenantKey;
1599
+ };
1600
+ const releaseSubscriptionSlot = (tenantKey) => {
1601
+ if (tenantKey === undefined)
1602
+ return;
1603
+ const active2 = subscriptionsByTenant.get(tenantKey);
1604
+ if (active2 === undefined || active2 <= 1) {
1605
+ subscriptionsByTenant.delete(tenantKey);
1606
+ } else {
1607
+ subscriptionsByTenant.set(tenantKey, active2 - 1);
1608
+ }
1609
+ };
1529
1610
  const reactiveCacheMax = options.reactiveCache?.max ?? 256;
1530
1611
  const reactiveCacheTtlMs = options.reactiveCache?.ttlMs ?? 60000;
1531
1612
  const cachedReruns = new Map;
@@ -2363,84 +2444,103 @@ var createSyncEngine = (options = {}) => {
2363
2444
  if (registered === undefined) {
2364
2445
  throw new Error(`Unknown collection "${collection}"`);
2365
2446
  }
2366
- const typedOnDiff = onDiff;
2367
- const subscribeSet = subsFor(collection);
2368
- const wrapReturn = (sub) => {
2369
- checkAborted(signal);
2370
- linkAbortToUnsubscribe(signal, sub.unsubscribe);
2371
- return sub;
2372
- };
2373
- const registeredKind = registered.kind;
2374
- if (registeredKind === "join") {
2375
- const joined = await subscribeJoin(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2376
- return wrapReturn(joined);
2377
- }
2378
- if (registeredKind === "graph") {
2379
- const graphed = await subscribeGraph(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2380
- return wrapReturn(graphed);
2381
- }
2382
- if (registeredKind === "reactive") {
2383
- const reactived = await subscribeReactive(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2384
- return wrapReturn(reactived);
2385
- }
2386
- if (registeredKind === "search") {
2387
- const searched = await subscribeSearch(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2388
- return wrapReturn(searched);
2389
- }
2390
- const definition = registered;
2391
- if (definition.authorize !== undefined) {
2392
- const allowed = await definition.authorize(params, ctx);
2393
- if (!allowed) {
2394
- throw new UnauthorizedError(`subscribe to collection "${collection}"`);
2447
+ const tenantSlot = acquireSubscriptionSlot(ctx, { collection });
2448
+ let slotHandedOff = false;
2449
+ try {
2450
+ const typedOnDiff = onDiff;
2451
+ const subscribeSet = subsFor(collection);
2452
+ const wrapReturn = (sub) => {
2453
+ checkAborted(signal);
2454
+ const innerUnsubscribe = sub.unsubscribe;
2455
+ let released = false;
2456
+ const wrappedUnsubscribe = () => {
2457
+ if (released)
2458
+ return;
2459
+ released = true;
2460
+ releaseSubscriptionSlot(tenantSlot);
2461
+ innerUnsubscribe();
2462
+ };
2463
+ const wrapped = { ...sub, unsubscribe: wrappedUnsubscribe };
2464
+ linkAbortToUnsubscribe(signal, wrappedUnsubscribe);
2465
+ slotHandedOff = true;
2466
+ return wrapped;
2467
+ };
2468
+ const registeredKind = registered.kind;
2469
+ if (registeredKind === "join") {
2470
+ const joined = await subscribeJoin(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2471
+ return wrapReturn(joined);
2472
+ }
2473
+ if (registeredKind === "graph") {
2474
+ const graphed = await subscribeGraph(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2475
+ return wrapReturn(graphed);
2476
+ }
2477
+ if (registeredKind === "reactive") {
2478
+ const reactived = await subscribeReactive(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2479
+ return wrapReturn(reactived);
2480
+ }
2481
+ if (registeredKind === "search") {
2482
+ const searched = await subscribeSearch(collection, registered, params, ctx, typedOnDiff, subscribeSet);
2483
+ return wrapReturn(searched);
2484
+ }
2485
+ const definition = registered;
2486
+ if (definition.authorize !== undefined) {
2487
+ const allowed = await definition.authorize(params, ctx);
2488
+ if (!allowed) {
2489
+ throw new UnauthorizedError(`subscribe to collection "${collection}"`);
2490
+ }
2491
+ }
2492
+ const key = definition.key ?? defaultKey;
2493
+ const match = definition.match;
2494
+ const tables = definition.tables ?? [collection];
2495
+ const scopedTable = tables.length === 1 ? tables[0] : undefined;
2496
+ const readRule = scopedTable !== undefined ? readRuleFor(scopedTable) : undefined;
2497
+ const rehydrate = async () => {
2498
+ const raw = [...await definition.hydrate(params, ctx)];
2499
+ const rows = scopedTable !== undefined ? raw.map((row) => migrateRow(scopedTable, row)) : raw;
2500
+ return readRule ? rows.filter((row) => readRule(ctx, row)) : rows;
2501
+ };
2502
+ const incremental = match !== undefined && tables.length === 1;
2503
+ const boundMatch = incremental ? (row) => match(row, params, ctx) && (readRule ? readRule(ctx, row) : true) : () => true;
2504
+ const view = createMaterializedView({
2505
+ key,
2506
+ match: boundMatch
2507
+ });
2508
+ const resuming = since !== undefined && canResume(since, incremental);
2509
+ view.hydrate([...await rehydrate()]);
2510
+ const atVersion = version;
2511
+ const subscription = {
2512
+ kind: "view",
2513
+ collection,
2514
+ view,
2515
+ incremental,
2516
+ rehydrate,
2517
+ key,
2518
+ onDiff: typedOnDiff
2519
+ };
2520
+ subscribeSet.add(subscription);
2521
+ const unsubscribe = () => {
2522
+ subscribeSet.delete(subscription);
2523
+ };
2524
+ if (resuming) {
2525
+ return wrapReturn({
2526
+ initial: [],
2527
+ catchup: buildCatchup(since, tables, key, boundMatch),
2528
+ cursor: currentCursor(),
2529
+ version: atVersion,
2530
+ unsubscribe
2531
+ });
2395
2532
  }
2396
- }
2397
- const key = definition.key ?? defaultKey;
2398
- const match = definition.match;
2399
- const tables = definition.tables ?? [collection];
2400
- const scopedTable = tables.length === 1 ? tables[0] : undefined;
2401
- const readRule = scopedTable !== undefined ? readRuleFor(scopedTable) : undefined;
2402
- const rehydrate = async () => {
2403
- const raw = [...await definition.hydrate(params, ctx)];
2404
- const rows = scopedTable !== undefined ? raw.map((row) => migrateRow(scopedTable, row)) : raw;
2405
- return readRule ? rows.filter((row) => readRule(ctx, row)) : rows;
2406
- };
2407
- const incremental = match !== undefined && tables.length === 1;
2408
- const boundMatch = incremental ? (row) => match(row, params, ctx) && (readRule ? readRule(ctx, row) : true) : () => true;
2409
- const view = createMaterializedView({
2410
- key,
2411
- match: boundMatch
2412
- });
2413
- const resuming = since !== undefined && canResume(since, incremental);
2414
- view.hydrate([...await rehydrate()]);
2415
- const atVersion = version;
2416
- const subscription = {
2417
- kind: "view",
2418
- collection,
2419
- view,
2420
- incremental,
2421
- rehydrate,
2422
- key,
2423
- onDiff: typedOnDiff
2424
- };
2425
- subscribeSet.add(subscription);
2426
- const unsubscribe = () => {
2427
- subscribeSet.delete(subscription);
2428
- };
2429
- if (resuming) {
2430
2533
  return wrapReturn({
2431
- initial: [],
2432
- catchup: buildCatchup(since, tables, key, boundMatch),
2534
+ initial: view.rows(),
2433
2535
  cursor: currentCursor(),
2434
2536
  version: atVersion,
2435
2537
  unsubscribe
2436
2538
  });
2539
+ } catch (error) {
2540
+ if (!slotHandedOff)
2541
+ releaseSubscriptionSlot(tenantSlot);
2542
+ throw error;
2437
2543
  }
2438
- return wrapReturn({
2439
- initial: view.rows(),
2440
- cursor: currentCursor(),
2441
- version: atVersion,
2442
- unsubscribe
2443
- });
2444
2544
  },
2445
2545
  hydrate: async (collection, params, ctx, options2) => {
2446
2546
  const signal = options2?.signal;
@@ -2553,6 +2653,7 @@ var createSyncEngine = (options = {}) => {
2553
2653
  throw new UnauthorizedError(`run mutation "${name}"`);
2554
2654
  }
2555
2655
  }
2656
+ await acquireMutationSlot();
2556
2657
  const sandboxRunner = sandboxRunners.get(name);
2557
2658
  const invokeHandler = sandboxRunner !== undefined ? sandboxRunner : (a, c, actions) => Promise.resolve(mutation.handler(a, c, actions));
2558
2659
  const runHandler = async (tx) => {
@@ -2568,7 +2669,6 @@ var createSyncEngine = (options = {}) => {
2568
2669
  const startedAt = Date.now();
2569
2670
  let lastError;
2570
2671
  let attemptsMade = 0;
2571
- mutationsInFlight += 1;
2572
2672
  try {
2573
2673
  for (let attempt = 1;attempt <= maxAttempts; attempt++) {
2574
2674
  attemptsMade = attempt;
@@ -2621,7 +2721,7 @@ var createSyncEngine = (options = {}) => {
2621
2721
  }
2622
2722
  throw lastError;
2623
2723
  } finally {
2624
- mutationsInFlight -= 1;
2724
+ releaseMutationSlot();
2625
2725
  }
2626
2726
  },
2627
2727
  runMutations: async (specs, ctx) => {
@@ -2634,6 +2734,7 @@ var createSyncEngine = (options = {}) => {
2634
2734
  }
2635
2735
  return { args: spec.args, mutation, name: spec.name };
2636
2736
  });
2737
+ await acquireMutationSlot();
2637
2738
  const runBatch = async (tx) => {
2638
2739
  const results = [];
2639
2740
  const accumulated = [];
@@ -2671,6 +2772,8 @@ var createSyncEngine = (options = {}) => {
2671
2772
  status: "error"
2672
2773
  });
2673
2774
  throw error;
2775
+ } finally {
2776
+ releaseMutationSlot();
2674
2777
  }
2675
2778
  },
2676
2779
  registerSchedule: (schedule) => {
@@ -2890,6 +2993,7 @@ var createSyncEngine = (options = {}) => {
2890
2993
  completed: mutationsCompleted,
2891
2994
  failed: mutationsFailed,
2892
2995
  inFlight: mutationsInFlight,
2996
+ queued: mutationsQueued,
2893
2997
  retried: mutationsRetried
2894
2998
  },
2895
2999
  reactiveCache: {
@@ -2901,6 +3005,7 @@ var createSyncEngine = (options = {}) => {
2901
3005
  },
2902
3006
  subscriptions: {
2903
3007
  byCollection,
3008
+ byTenant: Object.fromEntries(subscriptionsByTenant),
2904
3009
  total: totalSubscriptions
2905
3010
  },
2906
3011
  uptimeMs: now - engineStartedAt,
@@ -3429,14 +3534,16 @@ export {
3429
3534
  chain,
3430
3535
  aggregateOp,
3431
3536
  UnauthorizedError,
3537
+ SubscriptionLimitError,
3432
3538
  SchemaError,
3433
3539
  SEARCH_SCORE_FIELD,
3434
3540
  RetriesExhaustedError,
3435
3541
  PackTableConflictError,
3436
3542
  PackMissingDependencyError,
3543
+ MutationQueueOverflowError,
3437
3544
  MissedChangesError,
3438
3545
  CdcConsumerSlowError
3439
3546
  };
3440
3547
 
3441
- //# debugId=079CCC84C9743C1064756E2164756E21
3548
+ //# debugId=251EC576E67A0CAF64756E2164756E21
3442
3549
  //# sourceMappingURL=index.js.map