@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 +16 -0
- package/cjs/index.cjs +185 -20
- package/cjs/index.cjs.map +1 -1
- package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-driver.mjs +3 -0
- package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-migration-driver.d.mts +1 -1
- package/esm/drivers/postgres/postgres-migration-driver.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-migration-driver.mjs +6 -1
- package/esm/drivers/postgres/postgres-migration-driver.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-ttl-purge.mjs +98 -0
- package/esm/drivers/postgres/postgres-ttl-purge.mjs.map +1 -0
- package/esm/remover/database-remover.d.mts.map +1 -1
- package/esm/remover/database-remover.mjs +7 -7
- package/esm/remover/database-remover.mjs.map +1 -1
- package/esm/restorer/database-restorer.d.mts +2 -3
- package/esm/restorer/database-restorer.d.mts.map +1 -1
- package/esm/restorer/database-restorer.mjs +9 -7
- package/esm/restorer/database-restorer.mjs.map +1 -1
- package/esm/sync/sync-fanout.mjs +67 -0
- package/esm/sync/sync-fanout.mjs.map +1 -0
- package/esm/writer/database-writer.d.mts.map +1 -1
- package/esm/writer/database-writer.mjs +7 -7
- package/esm/writer/database-writer.mjs.map +1 -1
- package/package.json +8 -7
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
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
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
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
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
|
-
*
|
|
5696
|
-
*
|
|
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
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
|
|
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
|
|
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;
|