@absolutejs/sync 1.18.2 → 1.20.0

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.
@@ -266,6 +266,36 @@ export type SyncEngine = {
266
266
  * Added in 1.13.0.
267
267
  */
268
268
  metrics: () => EngineMetrics;
269
+ /**
270
+ * Capture the engine's change log + version as a serializable
271
+ * {@link ChangeLogSnapshot} the host can persist (disk, S3, the cluster
272
+ * bus) and restore on the next boot via
273
+ * {@link SyncEngineOptions.initialChangeLog} or
274
+ * {@link SyncEngine.importChangeLog}. The receiving engine MUST share this
275
+ * engine's `instanceId` — otherwise the resume contract silently breaks.
276
+ *
277
+ * Cheap: the snapshot's `entries` is a shallow copy of the bounded log
278
+ * (capped by `changeLogSize` / `changeLogRetainMs`). Call on a timer or on
279
+ * graceful shutdown — both are fine; the snapshot is monotonic in commit
280
+ * order, so a partial roll-forward (apply entries newer than the snapshot
281
+ * from another source) is safe.
282
+ *
283
+ * Added in 1.19.0.
284
+ */
285
+ exportChangeLog: () => ChangeLogSnapshot;
286
+ /**
287
+ * Adopt a {@link ChangeLogSnapshot} into a running engine that has not yet
288
+ * committed any local changes (its `version` is 0). The snapshot's
289
+ * `instanceId` MUST match this engine's `instanceId`. Throws otherwise.
290
+ *
291
+ * Convenience for hosts that want to set up the engine, register surfaces,
292
+ * AND THEN restore. Equivalent to passing the snapshot via
293
+ * `createSyncEngine({ initialChangeLog })` if you have it at construction
294
+ * time. Returns the number of entries imported.
295
+ *
296
+ * Added in 1.19.0.
297
+ */
298
+ importChangeLog: (snapshot: ChangeLogSnapshot) => number;
269
299
  /**
270
300
  * Subscribe to the live engine activity stream (changes, mutation outcomes,
271
301
  * subscribe/unsubscribe). Returns an unsubscribe. Powers the devtools feed.
@@ -384,6 +414,46 @@ export declare class CdcConsumerSlowError extends Error {
384
414
  readonly lastDeliveredVersion: number;
385
415
  constructor(maxBuffer: number, lastDeliveredVersion: number);
386
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
+ * Serializable snapshot of an engine's change log + monotonic version, returned
430
+ * by {@link SyncEngine.exportChangeLog} and consumed by
431
+ * {@link SyncEngineOptions.initialChangeLog} or
432
+ * {@link SyncEngine.importChangeLog}.
433
+ *
434
+ * The PaaS host persists this on shard rotation (every N seconds or on graceful
435
+ * shutdown) and hands it back to the replacement engine so resume cursors
436
+ * referencing this `instanceId` keep working past the restart. Bounded by the
437
+ * receiving engine's `changeLogSize` + `changeLogRetainMs` policies — entries
438
+ * that exceed either cap on import are trimmed exactly as if they had been
439
+ * logged live.
440
+ *
441
+ * Added in 1.19.0.
442
+ */
443
+ export type ChangeLogSnapshot = {
444
+ /** The exporting engine's `instanceId`. Receiver MUST match. */
445
+ instanceId: string;
446
+ /** The exporting engine's monotonic version at snapshot time. */
447
+ version: number;
448
+ /** Every retained log entry, in commit order (oldest first). */
449
+ entries: ReadonlyArray<LoggedChange>;
450
+ /**
451
+ * Optional version-stamp the host may use to compare snapshots without
452
+ * deserializing the entries (e.g. for incremental persistence). Set to
453
+ * `Date.now()` at export time. Receivers ignore this field.
454
+ */
455
+ exportedAt?: number;
456
+ };
387
457
  export type SyncEngineOptions = {
388
458
  /**
389
459
  * Stable identifier for this engine instance. Defaults to a per-process
@@ -482,6 +552,51 @@ export type SyncEngineOptions = {
482
552
  * @see {@link BridgeFetchConfig}
483
553
  */
484
554
  bridgeFetch?: BridgeFetchConfig;
555
+ /**
556
+ * Seed the engine's change log on boot from a prior snapshot — produced by
557
+ * {@link SyncEngine.exportChangeLog} on the previous instance, persisted by
558
+ * the host across a shard reboot, then handed back here. Cursors that
559
+ * referenced this engine's `instanceId` stay resumable past the restart
560
+ * (provided their last-seen point still lives in the retained log).
561
+ *
562
+ * The snapshot's `instanceId` MUST match `options.instanceId` (otherwise
563
+ * `createSyncEngine` throws — a wrong-id restore would silently break the
564
+ * resume contract). Snapshot `version` becomes this engine's local
565
+ * monotonic version; entries are inserted in version order. Subscribers,
566
+ * permissions, schemas, schedules, packs, mutations, and the reactive
567
+ * cache are NOT in the snapshot — re-register them as normal after
568
+ * `createSyncEngine` returns. Added in 1.19.0.
569
+ */
570
+ initialChangeLog?: ChangeLogSnapshot;
571
+ /**
572
+ * Maximum concurrent in-flight mutations (`runMutation` + `runMutations`).
573
+ * Calls beyond the limit wait in a FIFO queue and run as slots free up;
574
+ * `engine.metrics().mutations.queued` surfaces the queue depth.
575
+ *
576
+ * A single tenant flooding `runMutation` can otherwise drive unbounded
577
+ * memory growth (per-mutation `actions` buffers, retry timers, sandbox
578
+ * invocations queued against the isolate pool). Set this to a value
579
+ * appropriate for the host's tenant tier — e.g. `32` for a free tier,
580
+ * `256` for paid. Without this option the engine is unbounded
581
+ * (matching pre-1.20 behavior).
582
+ *
583
+ * Sandboxed mutations are gated by the same semaphore. If you need
584
+ * finer-grained control (sandbox-only throttling), see
585
+ * `@absolutejs/isolated-jsc`'s pool size — that's the lower layer.
586
+ *
587
+ * Added in 1.20.0.
588
+ */
589
+ mutationConcurrency?: number;
590
+ /**
591
+ * Cap on the queue of waiting mutations once `mutationConcurrency` is
592
+ * saturated. Calls beyond this cap throw {@link MutationQueueOverflowError}
593
+ * immediately instead of queueing — the host can surface a clean 429 or
594
+ * apply a tenant-specific shed policy. Defaults to unbounded (queue
595
+ * never rejects). Only meaningful when `mutationConcurrency` is set.
596
+ *
597
+ * Added in 1.20.0.
598
+ */
599
+ mutationQueueLimit?: number;
485
600
  };
486
601
  /**
487
602
  * The Tier 3 sync engine: a registry of collections plus the view syncer. It is
package/dist/index.js CHANGED
@@ -1152,6 +1152,15 @@ 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
+ }
1155
1164
  var defaultKey = (row) => row.id;
1156
1165
  var shallowEqual3 = (a, b) => {
1157
1166
  if (a === b) {
@@ -1268,6 +1277,40 @@ var createSyncEngine = (options = {}) => {
1268
1277
  let mutationsFailed = 0;
1269
1278
  let mutationsRetried = 0;
1270
1279
  let mutationsInFlight = 0;
1280
+ const mutationWaiters = [];
1281
+ let mutationsQueued = 0;
1282
+ const acquireMutationSlot = async () => {
1283
+ const limit = options.mutationConcurrency;
1284
+ if (limit === undefined) {
1285
+ mutationsInFlight += 1;
1286
+ return;
1287
+ }
1288
+ if (mutationsInFlight < limit && mutationWaiters.length === 0) {
1289
+ mutationsInFlight += 1;
1290
+ return;
1291
+ }
1292
+ const queueLimit = options.mutationQueueLimit;
1293
+ if (queueLimit !== undefined && mutationsQueued >= queueLimit) {
1294
+ throw new MutationQueueOverflowError(queueLimit);
1295
+ }
1296
+ mutationsQueued += 1;
1297
+ try {
1298
+ await new Promise((resolve) => {
1299
+ mutationWaiters.push(resolve);
1300
+ });
1301
+ } finally {
1302
+ mutationsQueued -= 1;
1303
+ }
1304
+ mutationsInFlight += 1;
1305
+ };
1306
+ const releaseMutationSlot = () => {
1307
+ mutationsInFlight -= 1;
1308
+ if (options.mutationConcurrency === undefined)
1309
+ return;
1310
+ const next = mutationWaiters.shift();
1311
+ if (next !== undefined)
1312
+ next();
1313
+ };
1271
1314
  const reactiveCacheMax = options.reactiveCache?.max ?? 256;
1272
1315
  const reactiveCacheTtlMs = options.reactiveCache?.ttlMs ?? 60000;
1273
1316
  const cachedReruns = new Map;
@@ -1309,6 +1352,31 @@ var createSyncEngine = (options = {}) => {
1309
1352
  const runInTransaction = options.transaction;
1310
1353
  const instanceId = options.instanceId ?? globalThis.crypto?.randomUUID?.() ?? `i${Math.random()}`;
1311
1354
  let clusterBus;
1355
+ const importChangeLog = (snapshot) => {
1356
+ if (version !== 0) {
1357
+ throw new Error(`[sync] importChangeLog: engine already has version ${version}; ` + `restore must happen before any local writes commit.`);
1358
+ }
1359
+ if (snapshot.instanceId !== instanceId) {
1360
+ throw new Error(`[sync] importChangeLog: snapshot instanceId "${snapshot.instanceId}" ` + `does not match this engine's instanceId "${instanceId}". ` + `Pass options.instanceId = "${snapshot.instanceId}" to createSyncEngine.`);
1361
+ }
1362
+ version = snapshot.version;
1363
+ for (const entry of snapshot.entries) {
1364
+ changeLog.push(entry);
1365
+ }
1366
+ while (changeLog.length > changeLogSize) {
1367
+ changeLog.shift();
1368
+ }
1369
+ if (changeLogRetainMs !== null && changeLogRetainMs > 0) {
1370
+ const cutoff = Date.now() - changeLogRetainMs;
1371
+ while (changeLog.length > 0 && changeLog[0].at < cutoff) {
1372
+ changeLog.shift();
1373
+ }
1374
+ }
1375
+ return snapshot.entries.length;
1376
+ };
1377
+ if (options.initialChangeLog !== undefined) {
1378
+ importChangeLog(options.initialChangeLog);
1379
+ }
1312
1380
  const broadcast = (changes, originVersion) => {
1313
1381
  if (clusterBus !== undefined && changes.length > 0) {
1314
1382
  clusterBus.publish({ changes, origin: instanceId, originVersion });
@@ -2270,6 +2338,7 @@ var createSyncEngine = (options = {}) => {
2270
2338
  throw new UnauthorizedError(`run mutation "${name}"`);
2271
2339
  }
2272
2340
  }
2341
+ await acquireMutationSlot();
2273
2342
  const sandboxRunner = sandboxRunners.get(name);
2274
2343
  const invokeHandler = sandboxRunner !== undefined ? sandboxRunner : (a, c, actions) => Promise.resolve(mutation.handler(a, c, actions));
2275
2344
  const runHandler = async (tx) => {
@@ -2285,7 +2354,6 @@ var createSyncEngine = (options = {}) => {
2285
2354
  const startedAt = Date.now();
2286
2355
  let lastError;
2287
2356
  let attemptsMade = 0;
2288
- mutationsInFlight += 1;
2289
2357
  try {
2290
2358
  for (let attempt = 1;attempt <= maxAttempts; attempt++) {
2291
2359
  attemptsMade = attempt;
@@ -2338,7 +2406,7 @@ var createSyncEngine = (options = {}) => {
2338
2406
  }
2339
2407
  throw lastError;
2340
2408
  } finally {
2341
- mutationsInFlight -= 1;
2409
+ releaseMutationSlot();
2342
2410
  }
2343
2411
  },
2344
2412
  runMutations: async (specs, ctx) => {
@@ -2351,6 +2419,7 @@ var createSyncEngine = (options = {}) => {
2351
2419
  }
2352
2420
  return { args: spec.args, mutation, name: spec.name };
2353
2421
  });
2422
+ await acquireMutationSlot();
2354
2423
  const runBatch = async (tx) => {
2355
2424
  const results = [];
2356
2425
  const accumulated = [];
@@ -2388,6 +2457,8 @@ var createSyncEngine = (options = {}) => {
2388
2457
  status: "error"
2389
2458
  });
2390
2459
  throw error;
2460
+ } finally {
2461
+ releaseMutationSlot();
2391
2462
  }
2392
2463
  },
2393
2464
  registerSchedule: (schedule) => {
@@ -2578,6 +2649,13 @@ var createSyncEngine = (options = {}) => {
2578
2649
  }))
2579
2650
  };
2580
2651
  },
2652
+ exportChangeLog: () => ({
2653
+ entries: changeLog.slice(),
2654
+ exportedAt: Date.now(),
2655
+ instanceId,
2656
+ version
2657
+ }),
2658
+ importChangeLog,
2581
2659
  metrics: () => {
2582
2660
  const now = Date.now();
2583
2661
  const byCollection = {};
@@ -2600,6 +2678,7 @@ var createSyncEngine = (options = {}) => {
2600
2678
  completed: mutationsCompleted,
2601
2679
  failed: mutationsFailed,
2602
2680
  inFlight: mutationsInFlight,
2681
+ queued: mutationsQueued,
2603
2682
  retried: mutationsRetried
2604
2683
  },
2605
2684
  reactiveCache: {
@@ -2993,5 +3072,5 @@ export {
2993
3072
  createPresenceHub
2994
3073
  };
2995
3074
 
2996
- //# debugId=34EB2D2EE376D7EC64756E2164756E21
3075
+ //# debugId=9DD57DF563EB9CB564756E2164756E21
2997
3076
  //# sourceMappingURL=index.js.map