@warlock.js/cascade 5.21.0 → 5.22.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to `@warlock.js/cascade` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 5.22.1 - 2026-09-25
8
+
9
+ ### Changed
10
+
11
+ - Lockstep patch release; package APIs are unchanged.
12
+
13
+ ## 5.22.0 - 2026-09-25
14
+
15
+ ### Added
16
+
17
+ - PostgreSQL TTL indexes register purge jobs and persist retention rules in index comments so jobs restore after reconnect.
18
+
19
+ ### Fixed
20
+
21
+ - Post-commit sync fan-out retries transient failures without failing committed writes.
22
+
7
23
  ## 5.21.0 - 2026-09-25
8
24
 
9
25
  ### Added
package/cjs/index.cjs CHANGED
@@ -35,6 +35,7 @@ let _mongez_events = require("@mongez/events");
35
35
  _mongez_events = __toESM(_mongez_events, 1);
36
36
  let _warlock_js_seal = require("@warlock.js/seal");
37
37
  let _mongez_copper = require("@mongez/copper");
38
+ let _warlock_js_scheduler = require("@warlock.js/scheduler");
38
39
  let fs = require("fs");
39
40
  fs = __toESM(fs, 1);
40
41
  let path = require("path");
@@ -3500,6 +3501,69 @@ function getBooleanField(model, key, defaultValue) {
3500
3501
  return getFieldValue(model, key, defaultValue);
3501
3502
  }
3502
3503
 
3504
+ //#endregion
3505
+ //#region ../cascade/src/sync/sync-fanout.ts
3506
+ /**
3507
+ * Serializes post-commit sync fan-out and retries transient failures without
3508
+ * making the already-committed write, delete, or restore fail.
3509
+ */
3510
+ var SyncFanoutQueue = class {
3511
+ jobs = [];
3512
+ maxAttempts;
3513
+ retryDelay;
3514
+ reportFailure;
3515
+ draining = false;
3516
+ idleResolvers = [];
3517
+ constructor(options = {}) {
3518
+ this.maxAttempts = options.maxAttempts ?? 3;
3519
+ this.retryDelay = options.retryDelay ?? ((attempt) => attempt * 50);
3520
+ this.reportFailure = options.reportFailure ?? ((job, error) => {
3521
+ _warlock_js_logger.log.error("database", "sync.failed", `[cascade] ${job.operation} sync failed for ${job.sourceModel} after ${this.maxAttempts} attempts: ${error}`);
3522
+ });
3523
+ }
3524
+ /** Add a committed lifecycle fan-out job without awaiting its execution. */
3525
+ enqueue(job) {
3526
+ this.jobs.push(job);
3527
+ if (!this.draining) {
3528
+ this.draining = true;
3529
+ this.drain();
3530
+ }
3531
+ }
3532
+ /** Wait until the currently queued fan-out jobs have settled. */
3533
+ async flush() {
3534
+ if (!this.draining && this.jobs.length === 0) return;
3535
+ await new Promise((resolve) => this.idleResolvers.push(resolve));
3536
+ }
3537
+ async drain() {
3538
+ while (this.jobs.length > 0) {
3539
+ const job = this.jobs.shift();
3540
+ if (job) await this.execute(job);
3541
+ }
3542
+ this.draining = false;
3543
+ this.idleResolvers.splice(0).forEach((resolve) => resolve());
3544
+ }
3545
+ async execute(job) {
3546
+ let error;
3547
+ for (let attempt = 1; attempt <= this.maxAttempts; attempt++) try {
3548
+ await job.execute();
3549
+ return;
3550
+ } catch (caught) {
3551
+ error = caught;
3552
+ if (attempt < this.maxAttempts) await this.wait(this.retryDelay(attempt));
3553
+ }
3554
+ this.reportFailure(job, error);
3555
+ }
3556
+ async wait(milliseconds) {
3557
+ if (milliseconds <= 0) return;
3558
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
3559
+ }
3560
+ };
3561
+ const syncFanoutQueue = new SyncFanoutQueue();
3562
+ /** Queue a lifecycle sync after its database change has committed. */
3563
+ function enqueueSyncFanout(job) {
3564
+ syncFanoutQueue.enqueue(job);
3565
+ }
3566
+
3503
3567
  //#endregion
3504
3568
  //#region ../cascade/src/remover/database-remover.ts
3505
3569
  /**
@@ -3675,12 +3739,12 @@ var DatabaseRemover = class {
3675
3739
  *
3676
3740
  * @private
3677
3741
  */
3678
- async triggerSync() {
3679
- try {
3680
- await triggerModelEvent(this.ctor, "deleted", this.model);
3681
- } catch (error) {
3682
- _warlock_js_logger.log.error("database", "sync.failed", `[cascade] sync failed for ${this.ctor.name}: ${error}`);
3683
- }
3742
+ triggerSync() {
3743
+ enqueueSyncFanout({
3744
+ sourceModel: this.ctor.name,
3745
+ operation: "delete",
3746
+ execute: () => triggerModelEvent(this.ctor, "deleted", this.model)
3747
+ });
3684
3748
  }
3685
3749
  };
3686
3750
 
@@ -4749,12 +4813,12 @@ var DatabaseWriter = class {
4749
4813
  * @param changedFields - Fields that were changed (for filtering)
4750
4814
  * @private
4751
4815
  */
4752
- async triggerSync(changedFields) {
4753
- try {
4754
- await triggerModelEvent(this.ctor, "updated", this.model, changedFields);
4755
- } catch (error) {
4756
- _warlock_js_logger.log.error("database", "sync.failed", `[cascade] sync failed for ${this.ctor.name}: ${error}`);
4757
- }
4816
+ triggerSync(changedFields) {
4817
+ enqueueSyncFanout({
4818
+ sourceModel: this.ctor.name,
4819
+ operation: "update",
4820
+ execute: () => triggerModelEvent(this.ctor, "updated", this.model, changedFields)
4821
+ });
4758
4822
  }
4759
4823
  };
4760
4824
 
@@ -5692,16 +5756,17 @@ var DatabaseRestorer = class {
5692
5756
  return `${this.table}Trash`;
5693
5757
  }
5694
5758
  /**
5695
- * Re-run sync for a restored record so embedded copies come back up to date.
5696
- * Failures are swallowed: the restore itself already succeeded.
5697
- * TODO(5.22, K2:B10): move this fan-out to a retry/queue-backed path.
5759
+ * Queue sync for a restored record so embedded copies come back up to date.
5760
+ * The restore has already succeeded, so queued failures are retried separately.
5698
5761
  *
5699
5762
  * @private
5700
5763
  */
5701
- async triggerSync(model) {
5702
- try {
5703
- await triggerModelEvent(this.ctor, "updated", model, Object.keys(model.data));
5704
- } catch {}
5764
+ triggerSync(model) {
5765
+ enqueueSyncFanout({
5766
+ sourceModel: this.ctor.name,
5767
+ operation: "restore",
5768
+ execute: () => triggerModelEvent(this.ctor, "updated", model, Object.keys(model.data))
5769
+ });
5705
5770
  }
5706
5771
  };
5707
5772
 
@@ -15478,6 +15543,100 @@ function convertPostgresValue(value, dataTypeID) {
15478
15543
  return parseVector(value) ?? value;
15479
15544
  }
15480
15545
 
15546
+ //#endregion
15547
+ //#region ../cascade/src/drivers/postgres/postgres-ttl-purge.ts
15548
+ const TTL_PURGE_INTERVAL_MINUTES = 1;
15549
+ const TTL_COMMENT_PREFIX = "warlock_ttl:";
15550
+ let nextDriverId = 0;
15551
+ const driverIds = /* @__PURE__ */ new WeakMap();
15552
+ const driverJobs = /* @__PURE__ */ new WeakMap();
15553
+ function getDriverId(driver) {
15554
+ let id = driverIds.get(driver);
15555
+ if (id === void 0) {
15556
+ id = nextDriverId++;
15557
+ driverIds.set(driver, id);
15558
+ }
15559
+ return id;
15560
+ }
15561
+ /** Persist the retention rule with the index so a restarted process can restore its job. */
15562
+ function postgresTTLPurgeComment(table, column, expireAfterSeconds) {
15563
+ return TTL_COMMENT_PREFIX + JSON.stringify({
15564
+ table,
15565
+ column,
15566
+ expireAfterSeconds
15567
+ });
15568
+ }
15569
+ /**
15570
+ * Return the scheduler job name for one PostgreSQL TTL index.
15571
+ *
15572
+ * The driver id prevents two data sources with identically named tables from
15573
+ * replacing each other's purge jobs on the shared scheduler singleton.
15574
+ */
15575
+ function postgresTTLPurgeJobName(driver, table, column) {
15576
+ return `cascade:postgres:${getDriverId(driver)}:ttl:${table}:${column}`;
15577
+ }
15578
+ /**
15579
+ * Delete rows which have outlived a PostgreSQL TTL index's retention period.
15580
+ *
15581
+ * Identifiers are produced by the dialect, while the retention interval stays
15582
+ * parameterized so a migration value can never become executable SQL.
15583
+ */
15584
+ async function purgeExpiredPostgresRows(driver, table, column, expireAfterSeconds) {
15585
+ const quotedTable = driver.dialect.quoteIdentifier(table);
15586
+ const quotedColumn = driver.dialect.quoteIdentifier(column);
15587
+ await driver.query(`DELETE FROM ${quotedTable} WHERE ${quotedColumn} < NOW() - ($1 * INTERVAL '1 second')`, [expireAfterSeconds]);
15588
+ }
15589
+ /**
15590
+ * Register the in-process scheduler purge job for a PostgreSQL TTL index.
15591
+ *
15592
+ * The application owns the scheduler lifecycle: callers must start the shared
15593
+ * scheduler during bootstrap. Re-registering replaces a previous job for the
15594
+ * same driver/index so repeated migration setup cannot leak callbacks.
15595
+ */
15596
+ function registerPostgresTTLPurgeJob(driver, table, column, expireAfterSeconds) {
15597
+ const name = postgresTTLPurgeJobName(driver, table, column);
15598
+ _warlock_js_scheduler.scheduler.removeJob(name);
15599
+ _warlock_js_scheduler.scheduler.addJob((0, _warlock_js_scheduler.job)(name, () => purgeExpiredPostgresRows(driver, table, column, expireAfterSeconds)).everyMinutes(TTL_PURGE_INTERVAL_MINUTES));
15600
+ let names = driverJobs.get(driver);
15601
+ if (!names) {
15602
+ names = /* @__PURE__ */ new Set();
15603
+ driverJobs.set(driver, names);
15604
+ }
15605
+ names.add(name);
15606
+ }
15607
+ /** Remove the scheduler callback associated with a dropped PostgreSQL TTL index. */
15608
+ function unregisterPostgresTTLPurgeJob(driver, table, column) {
15609
+ const name = postgresTTLPurgeJobName(driver, table, column);
15610
+ _warlock_js_scheduler.scheduler.removeJob(name);
15611
+ driverJobs.get(driver)?.delete(name);
15612
+ }
15613
+ /** Remove a driver's callbacks before its pool is closed. */
15614
+ function unregisterAllPostgresTTLPurgeJobs(driver) {
15615
+ for (const name of driverJobs.get(driver) ?? []) _warlock_js_scheduler.scheduler.removeJob(name);
15616
+ driverJobs.delete(driver);
15617
+ }
15618
+ /** Restore registered retention rules from index comments on every connection. */
15619
+ async function loadPostgresTTLPurgeJobs(driver) {
15620
+ const result = await driver.query(`SELECT d.description FROM pg_catalog.pg_description d
15621
+ JOIN pg_catalog.pg_class c ON c.oid = d.objoid
15622
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
15623
+ WHERE c.relkind = 'i' AND n.nspname = current_schema()
15624
+ AND d.description LIKE 'warlock_ttl:%'`);
15625
+ for (const row of result.rows ?? []) {
15626
+ if (typeof row.description !== "string" || !row.description.startsWith(TTL_COMMENT_PREFIX)) continue;
15627
+ let rule;
15628
+ try {
15629
+ rule = JSON.parse(row.description.slice(12));
15630
+ } catch {
15631
+ continue;
15632
+ }
15633
+ if (!rule || typeof rule !== "object") continue;
15634
+ const { table, column, expireAfterSeconds } = rule;
15635
+ if (typeof table !== "string" || table.length === 0 || typeof column !== "string" || column.length === 0 || !Number.isSafeInteger(expireAfterSeconds) || expireAfterSeconds < 0) continue;
15636
+ registerPostgresTTLPurgeJob(driver, table, column, expireAfterSeconds);
15637
+ }
15638
+ }
15639
+
15481
15640
  //#endregion
15482
15641
  //#region ../cascade/src/drivers/postgres/postgres-migration-driver.ts
15483
15642
  /**
@@ -15933,7 +16092,7 @@ var PostgresMigrationDriver = class {
15933
16092
  *
15934
16093
  * Note: PostgreSQL doesn't have native TTL indexes like MongoDB.
15935
16094
  * This creates a plain index (a `NOW()` predicate is not IMMUTABLE, so a
15936
- * partial index cannot be built) and requires a scheduled job for cleanup.
16095
+ * partial index cannot be built) and registers a scheduler purge job.
15937
16096
  *
15938
16097
  * @param table - Table name
15939
16098
  * @param column - Date column
@@ -15945,6 +16104,9 @@ var PostgresMigrationDriver = class {
15945
16104
  const indexName = `idx_${table}_ttl_${column}`;
15946
16105
  const quotedIndexName = this.driver.dialect.quoteIdentifier(indexName);
15947
16106
  await this.execute(`CREATE INDEX ${quotedIndexName} ON ${quotedTable} (${quotedColumn})`);
16107
+ const comment = postgresTTLPurgeComment(table, column, expireAfterSeconds).replaceAll("'", "''");
16108
+ await this.execute(`COMMENT ON INDEX ${quotedIndexName} IS '${comment}'`);
16109
+ registerPostgresTTLPurgeJob(this.driver, table, column, expireAfterSeconds);
15948
16110
  }
15949
16111
  /**
15950
16112
  * Drop a TTL index.
@@ -15954,6 +16116,7 @@ var PostgresMigrationDriver = class {
15954
16116
  */
15955
16117
  async dropTTLIndex(table, column) {
15956
16118
  await this.dropIndex(table, `idx_${table}_ttl_${column}`);
16119
+ unregisterPostgresTTLPurgeJob(this.driver, table, column);
15957
16120
  }
15958
16121
  /**
15959
16122
  * List all indexes on a table.
@@ -19055,6 +19218,7 @@ var PostgresDriver = class {
19055
19218
  _warlock_js_logger.log.success("database.postgres", "connection", `Connected to database ${_mongez_copper.colors.bold(_mongez_copper.colors.yellowBright(this.config.database))}`);
19056
19219
  this._isConnected = true;
19057
19220
  await this.loadNativeArrayColumns();
19221
+ await loadPostgresTTLPurgeJobs(this);
19058
19222
  this.emit("connected");
19059
19223
  } catch (error) {
19060
19224
  _warlock_js_logger.log.fatal("database.postgres", "connection", "Failed to connect to database");
@@ -19069,6 +19233,7 @@ var PostgresDriver = class {
19069
19233
  */
19070
19234
  async disconnect() {
19071
19235
  if (!this._isConnected || !this._pool) return;
19236
+ unregisterAllPostgresTTLPurgeJobs(this);
19072
19237
  await this._pool.end();
19073
19238
  this._pool = void 0;
19074
19239
  this._isConnected = false;