s3db.js 10.0.0 → 10.0.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/README.md +2 -1
- package/dist/s3db.cjs.js +1919 -575
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +1919 -576
- package/dist/s3db.es.js.map +1 -1
- package/package.json +1 -1
- package/src/client.class.js +6 -5
- package/src/plugins/audit.plugin.js +4 -4
- package/src/plugins/backup.plugin.js +380 -105
- package/src/plugins/cache.plugin.js +203 -150
- package/src/plugins/eventual-consistency.plugin.js +609 -206
- package/src/plugins/fulltext.plugin.js +6 -6
- package/src/plugins/index.js +1 -0
- package/src/plugins/metrics.plugin.js +13 -13
- package/src/plugins/replicator.plugin.js +108 -70
- package/src/plugins/replicators/s3db-replicator.class.js +7 -3
- package/src/plugins/replicators/sqs-replicator.class.js +11 -3
- package/src/plugins/s3-queue.plugin.js +776 -0
- package/src/plugins/scheduler.plugin.js +226 -164
- package/src/plugins/state-machine.plugin.js +109 -81
- package/src/resource.class.js +205 -0
package/dist/s3db.es.js
CHANGED
|
@@ -6,11 +6,12 @@ import { pipeline } from 'stream/promises';
|
|
|
6
6
|
import path, { join } from 'path';
|
|
7
7
|
import crypto, { createHash } from 'crypto';
|
|
8
8
|
import zlib from 'node:zlib';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import jsonStableStringify from 'json-stable-stringify';
|
|
9
11
|
import { Transform, Writable } from 'stream';
|
|
10
12
|
import { PromisePool } from '@supercharge/promise-pool';
|
|
11
13
|
import { ReadableStream } from 'node:stream/web';
|
|
12
14
|
import { chunk, merge, isString, isEmpty, invert, uniq, cloneDeep, get, set, isObject, isFunction } from 'lodash-es';
|
|
13
|
-
import jsonStableStringify from 'json-stable-stringify';
|
|
14
15
|
import { Agent } from 'http';
|
|
15
16
|
import { Agent as Agent$1 } from 'https';
|
|
16
17
|
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
|
@@ -838,7 +839,7 @@ class AuditPlugin extends Plugin {
|
|
|
838
839
|
}
|
|
839
840
|
async onSetup() {
|
|
840
841
|
const [ok, err, auditResource] = await tryFn(() => this.database.createResource({
|
|
841
|
-
name: "
|
|
842
|
+
name: "plg_audits",
|
|
842
843
|
attributes: {
|
|
843
844
|
id: "string|required",
|
|
844
845
|
resourceName: "string|required",
|
|
@@ -854,15 +855,15 @@ class AuditPlugin extends Plugin {
|
|
|
854
855
|
},
|
|
855
856
|
behavior: "body-overflow"
|
|
856
857
|
}));
|
|
857
|
-
this.auditResource = ok ? auditResource : this.database.resources.
|
|
858
|
+
this.auditResource = ok ? auditResource : this.database.resources.plg_audits || null;
|
|
858
859
|
if (!ok && !this.auditResource) return;
|
|
859
860
|
this.database.addHook("afterCreateResource", (context) => {
|
|
860
|
-
if (context.resource.name !== "
|
|
861
|
+
if (context.resource.name !== "plg_audits") {
|
|
861
862
|
this.setupResourceAuditing(context.resource);
|
|
862
863
|
}
|
|
863
864
|
});
|
|
864
865
|
for (const resource of Object.values(this.database.resources)) {
|
|
865
|
-
if (resource.name !== "
|
|
866
|
+
if (resource.name !== "plg_audits") {
|
|
866
867
|
this.setupResourceAuditing(resource);
|
|
867
868
|
}
|
|
868
869
|
}
|
|
@@ -1882,11 +1883,10 @@ function validateBackupConfig(driver, config = {}) {
|
|
|
1882
1883
|
class BackupPlugin extends Plugin {
|
|
1883
1884
|
constructor(options = {}) {
|
|
1884
1885
|
super();
|
|
1885
|
-
this.driverName = options.driver || "filesystem";
|
|
1886
|
-
this.driverConfig = options.config || {};
|
|
1887
1886
|
this.config = {
|
|
1888
|
-
//
|
|
1889
|
-
|
|
1887
|
+
// Driver configuration
|
|
1888
|
+
driver: options.driver || "filesystem",
|
|
1889
|
+
driverConfig: options.config || {},
|
|
1890
1890
|
// Scheduling configuration
|
|
1891
1891
|
schedule: options.schedule || {},
|
|
1892
1892
|
// Retention policy (Grandfather-Father-Son)
|
|
@@ -1904,8 +1904,8 @@ class BackupPlugin extends Plugin {
|
|
|
1904
1904
|
parallelism: options.parallelism || 4,
|
|
1905
1905
|
include: options.include || null,
|
|
1906
1906
|
exclude: options.exclude || [],
|
|
1907
|
-
backupMetadataResource: options.backupMetadataResource || "
|
|
1908
|
-
tempDir: options.tempDir || "
|
|
1907
|
+
backupMetadataResource: options.backupMetadataResource || "plg_backup_metadata",
|
|
1908
|
+
tempDir: options.tempDir || path.join(os.tmpdir(), "s3db", "backups"),
|
|
1909
1909
|
verbose: options.verbose || false,
|
|
1910
1910
|
// Hooks
|
|
1911
1911
|
onBackupStart: options.onBackupStart || null,
|
|
@@ -1917,32 +1917,9 @@ class BackupPlugin extends Plugin {
|
|
|
1917
1917
|
};
|
|
1918
1918
|
this.driver = null;
|
|
1919
1919
|
this.activeBackups = /* @__PURE__ */ new Set();
|
|
1920
|
-
this.
|
|
1921
|
-
validateBackupConfig(this.driverName, this.driverConfig);
|
|
1920
|
+
validateBackupConfig(this.config.driver, this.config.driverConfig);
|
|
1922
1921
|
this._validateConfiguration();
|
|
1923
1922
|
}
|
|
1924
|
-
/**
|
|
1925
|
-
* Convert legacy destinations format to multi driver format
|
|
1926
|
-
*/
|
|
1927
|
-
_handleLegacyDestinations() {
|
|
1928
|
-
if (this.config.destinations && Array.isArray(this.config.destinations)) {
|
|
1929
|
-
this.driverName = "multi";
|
|
1930
|
-
this.driverConfig = {
|
|
1931
|
-
strategy: "all",
|
|
1932
|
-
destinations: this.config.destinations.map((dest) => {
|
|
1933
|
-
const { type, ...config } = dest;
|
|
1934
|
-
return {
|
|
1935
|
-
driver: type,
|
|
1936
|
-
config
|
|
1937
|
-
};
|
|
1938
|
-
})
|
|
1939
|
-
};
|
|
1940
|
-
this.config.destinations = null;
|
|
1941
|
-
if (this.config.verbose) {
|
|
1942
|
-
console.log("[BackupPlugin] Converted legacy destinations format to multi driver");
|
|
1943
|
-
}
|
|
1944
|
-
}
|
|
1945
|
-
}
|
|
1946
1923
|
_validateConfiguration() {
|
|
1947
1924
|
if (this.config.encryption && (!this.config.encryption.key || !this.config.encryption.algorithm)) {
|
|
1948
1925
|
throw new Error("BackupPlugin: Encryption requires both key and algorithm");
|
|
@@ -1952,7 +1929,7 @@ class BackupPlugin extends Plugin {
|
|
|
1952
1929
|
}
|
|
1953
1930
|
}
|
|
1954
1931
|
async onSetup() {
|
|
1955
|
-
this.driver = createBackupDriver(this.
|
|
1932
|
+
this.driver = createBackupDriver(this.config.driver, this.config.driverConfig);
|
|
1956
1933
|
await this.driver.setup(this.database);
|
|
1957
1934
|
await mkdir(this.config.tempDir, { recursive: true });
|
|
1958
1935
|
await this._createBackupMetadataResource();
|
|
@@ -2000,6 +1977,9 @@ class BackupPlugin extends Plugin {
|
|
|
2000
1977
|
async backup(type = "full", options = {}) {
|
|
2001
1978
|
const backupId = this._generateBackupId(type);
|
|
2002
1979
|
const startTime = Date.now();
|
|
1980
|
+
if (this.activeBackups.has(backupId)) {
|
|
1981
|
+
throw new Error(`Backup '${backupId}' is already in progress`);
|
|
1982
|
+
}
|
|
2003
1983
|
try {
|
|
2004
1984
|
this.activeBackups.add(backupId);
|
|
2005
1985
|
if (this.config.onBackupStart) {
|
|
@@ -2015,16 +1995,9 @@ class BackupPlugin extends Plugin {
|
|
|
2015
1995
|
if (exportedFiles.length === 0) {
|
|
2016
1996
|
throw new Error("No resources were exported for backup");
|
|
2017
1997
|
}
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
finalPath = path.join(tempBackupDir, `${backupId}.tar.gz`);
|
|
2022
|
-
totalSize = await this._createCompressedArchive(exportedFiles, finalPath);
|
|
2023
|
-
} else {
|
|
2024
|
-
finalPath = exportedFiles[0];
|
|
2025
|
-
const [statOk, , stats] = await tryFn(() => stat(finalPath));
|
|
2026
|
-
totalSize = statOk ? stats.size : 0;
|
|
2027
|
-
}
|
|
1998
|
+
const archiveExtension = this.config.compression !== "none" ? ".tar.gz" : ".json";
|
|
1999
|
+
const finalPath = path.join(tempBackupDir, `${backupId}${archiveExtension}`);
|
|
2000
|
+
const totalSize = await this._createArchive(exportedFiles, finalPath, this.config.compression);
|
|
2028
2001
|
const checksum = await this._generateChecksum(finalPath);
|
|
2029
2002
|
const uploadResult = await this.driver.upload(finalPath, backupId, manifest);
|
|
2030
2003
|
if (this.config.verification) {
|
|
@@ -2133,15 +2106,35 @@ class BackupPlugin extends Plugin {
|
|
|
2133
2106
|
for (const resourceName of resourceNames) {
|
|
2134
2107
|
const resource = this.database.resources[resourceName];
|
|
2135
2108
|
if (!resource) {
|
|
2136
|
-
|
|
2109
|
+
if (this.config.verbose) {
|
|
2110
|
+
console.warn(`[BackupPlugin] Resource '${resourceName}' not found, skipping`);
|
|
2111
|
+
}
|
|
2137
2112
|
continue;
|
|
2138
2113
|
}
|
|
2139
2114
|
const exportPath = path.join(tempDir, `${resourceName}.json`);
|
|
2140
2115
|
let records;
|
|
2141
2116
|
if (type === "incremental") {
|
|
2142
|
-
const
|
|
2117
|
+
const [lastBackupOk, , lastBackups] = await tryFn(
|
|
2118
|
+
() => this.database.resource(this.config.backupMetadataResource).list({
|
|
2119
|
+
filter: {
|
|
2120
|
+
status: "completed",
|
|
2121
|
+
type: { $in: ["full", "incremental"] }
|
|
2122
|
+
},
|
|
2123
|
+
sort: { timestamp: -1 },
|
|
2124
|
+
limit: 1
|
|
2125
|
+
})
|
|
2126
|
+
);
|
|
2127
|
+
let sinceTimestamp;
|
|
2128
|
+
if (lastBackupOk && lastBackups && lastBackups.length > 0) {
|
|
2129
|
+
sinceTimestamp = new Date(lastBackups[0].timestamp);
|
|
2130
|
+
} else {
|
|
2131
|
+
sinceTimestamp = new Date(Date.now() - 24 * 60 * 60 * 1e3);
|
|
2132
|
+
}
|
|
2133
|
+
if (this.config.verbose) {
|
|
2134
|
+
console.log(`[BackupPlugin] Incremental backup for '${resourceName}' since ${sinceTimestamp.toISOString()}`);
|
|
2135
|
+
}
|
|
2143
2136
|
records = await resource.list({
|
|
2144
|
-
filter: { updatedAt: { ">":
|
|
2137
|
+
filter: { updatedAt: { ">": sinceTimestamp.toISOString() } }
|
|
2145
2138
|
});
|
|
2146
2139
|
} else {
|
|
2147
2140
|
records = await resource.list();
|
|
@@ -2161,29 +2154,57 @@ class BackupPlugin extends Plugin {
|
|
|
2161
2154
|
}
|
|
2162
2155
|
return exportedFiles;
|
|
2163
2156
|
}
|
|
2164
|
-
async
|
|
2165
|
-
const
|
|
2166
|
-
|
|
2157
|
+
async _createArchive(files, targetPath, compressionType) {
|
|
2158
|
+
const archive = {
|
|
2159
|
+
version: "1.0",
|
|
2160
|
+
created: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2161
|
+
files: []
|
|
2162
|
+
};
|
|
2167
2163
|
let totalSize = 0;
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
yield content;
|
|
2164
|
+
for (const filePath of files) {
|
|
2165
|
+
const [readOk, readErr, content] = await tryFn(() => readFile(filePath, "utf8"));
|
|
2166
|
+
if (!readOk) {
|
|
2167
|
+
if (this.config.verbose) {
|
|
2168
|
+
console.warn(`[BackupPlugin] Failed to read ${filePath}: ${readErr?.message}`);
|
|
2174
2169
|
}
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2170
|
+
continue;
|
|
2171
|
+
}
|
|
2172
|
+
const fileName = path.basename(filePath);
|
|
2173
|
+
totalSize += content.length;
|
|
2174
|
+
archive.files.push({
|
|
2175
|
+
name: fileName,
|
|
2176
|
+
size: content.length,
|
|
2177
|
+
content
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
const archiveJson = JSON.stringify(archive);
|
|
2181
|
+
if (compressionType === "none") {
|
|
2182
|
+
await writeFile(targetPath, archiveJson, "utf8");
|
|
2183
|
+
} else {
|
|
2184
|
+
const output = createWriteStream(targetPath);
|
|
2185
|
+
const gzip = zlib.createGzip({ level: 6 });
|
|
2186
|
+
await pipeline(
|
|
2187
|
+
async function* () {
|
|
2188
|
+
yield Buffer.from(archiveJson, "utf8");
|
|
2189
|
+
},
|
|
2190
|
+
gzip,
|
|
2191
|
+
output
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2179
2194
|
const [statOk, , stats] = await tryFn(() => stat(targetPath));
|
|
2180
2195
|
return statOk ? stats.size : totalSize;
|
|
2181
2196
|
}
|
|
2182
2197
|
async _generateChecksum(filePath) {
|
|
2183
|
-
const
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2198
|
+
const [ok, err, result] = await tryFn(async () => {
|
|
2199
|
+
const hash = crypto.createHash("sha256");
|
|
2200
|
+
const stream = createReadStream(filePath);
|
|
2201
|
+
await pipeline(stream, hash);
|
|
2202
|
+
return hash.digest("hex");
|
|
2203
|
+
});
|
|
2204
|
+
if (!ok) {
|
|
2205
|
+
throw new Error(`Failed to generate checksum for ${filePath}: ${err?.message}`);
|
|
2206
|
+
}
|
|
2207
|
+
return result;
|
|
2187
2208
|
}
|
|
2188
2209
|
async _cleanupTempFiles(tempDir) {
|
|
2189
2210
|
const [ok] = await tryFn(
|
|
@@ -2245,7 +2266,109 @@ class BackupPlugin extends Plugin {
|
|
|
2245
2266
|
}
|
|
2246
2267
|
async _restoreFromBackup(backupPath, options) {
|
|
2247
2268
|
const restoredResources = [];
|
|
2248
|
-
|
|
2269
|
+
try {
|
|
2270
|
+
let archiveData = "";
|
|
2271
|
+
if (this.config.compression !== "none") {
|
|
2272
|
+
const input = createReadStream(backupPath);
|
|
2273
|
+
const gunzip = zlib.createGunzip();
|
|
2274
|
+
const chunks = [];
|
|
2275
|
+
await new Promise((resolve, reject) => {
|
|
2276
|
+
input.pipe(gunzip).on("data", (chunk) => chunks.push(chunk)).on("end", resolve).on("error", reject);
|
|
2277
|
+
});
|
|
2278
|
+
archiveData = Buffer.concat(chunks).toString("utf8");
|
|
2279
|
+
} else {
|
|
2280
|
+
archiveData = await readFile(backupPath, "utf8");
|
|
2281
|
+
}
|
|
2282
|
+
let archive;
|
|
2283
|
+
try {
|
|
2284
|
+
archive = JSON.parse(archiveData);
|
|
2285
|
+
} catch (parseError) {
|
|
2286
|
+
throw new Error(`Failed to parse backup archive: ${parseError.message}`);
|
|
2287
|
+
}
|
|
2288
|
+
if (!archive || typeof archive !== "object") {
|
|
2289
|
+
throw new Error("Invalid backup archive: not a valid JSON object");
|
|
2290
|
+
}
|
|
2291
|
+
if (!archive.version || !archive.files) {
|
|
2292
|
+
throw new Error("Invalid backup archive format: missing version or files array");
|
|
2293
|
+
}
|
|
2294
|
+
if (this.config.verbose) {
|
|
2295
|
+
console.log(`[BackupPlugin] Restoring ${archive.files.length} files from backup`);
|
|
2296
|
+
}
|
|
2297
|
+
for (const file of archive.files) {
|
|
2298
|
+
try {
|
|
2299
|
+
const resourceData = JSON.parse(file.content);
|
|
2300
|
+
if (!resourceData.resourceName || !resourceData.definition) {
|
|
2301
|
+
if (this.config.verbose) {
|
|
2302
|
+
console.warn(`[BackupPlugin] Skipping invalid file: ${file.name}`);
|
|
2303
|
+
}
|
|
2304
|
+
continue;
|
|
2305
|
+
}
|
|
2306
|
+
const resourceName = resourceData.resourceName;
|
|
2307
|
+
if (options.resources && !options.resources.includes(resourceName)) {
|
|
2308
|
+
continue;
|
|
2309
|
+
}
|
|
2310
|
+
let resource = this.database.resources[resourceName];
|
|
2311
|
+
if (!resource) {
|
|
2312
|
+
if (this.config.verbose) {
|
|
2313
|
+
console.log(`[BackupPlugin] Creating resource '${resourceName}'`);
|
|
2314
|
+
}
|
|
2315
|
+
const [createOk, createErr] = await tryFn(
|
|
2316
|
+
() => this.database.createResource(resourceData.definition)
|
|
2317
|
+
);
|
|
2318
|
+
if (!createOk) {
|
|
2319
|
+
if (this.config.verbose) {
|
|
2320
|
+
console.warn(`[BackupPlugin] Failed to create resource '${resourceName}': ${createErr?.message}`);
|
|
2321
|
+
}
|
|
2322
|
+
continue;
|
|
2323
|
+
}
|
|
2324
|
+
resource = this.database.resources[resourceName];
|
|
2325
|
+
}
|
|
2326
|
+
if (resourceData.records && Array.isArray(resourceData.records)) {
|
|
2327
|
+
const mode = options.mode || "merge";
|
|
2328
|
+
if (mode === "replace") {
|
|
2329
|
+
const ids = await resource.listIds();
|
|
2330
|
+
for (const id of ids) {
|
|
2331
|
+
await resource.delete(id);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
let insertedCount = 0;
|
|
2335
|
+
for (const record of resourceData.records) {
|
|
2336
|
+
const [insertOk] = await tryFn(async () => {
|
|
2337
|
+
if (mode === "skip") {
|
|
2338
|
+
const existing = await resource.get(record.id);
|
|
2339
|
+
if (existing) {
|
|
2340
|
+
return false;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
await resource.insert(record);
|
|
2344
|
+
return true;
|
|
2345
|
+
});
|
|
2346
|
+
if (insertOk) {
|
|
2347
|
+
insertedCount++;
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
restoredResources.push({
|
|
2351
|
+
name: resourceName,
|
|
2352
|
+
recordsRestored: insertedCount,
|
|
2353
|
+
totalRecords: resourceData.records.length
|
|
2354
|
+
});
|
|
2355
|
+
if (this.config.verbose) {
|
|
2356
|
+
console.log(`[BackupPlugin] Restored ${insertedCount}/${resourceData.records.length} records to '${resourceName}'`);
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
} catch (fileError) {
|
|
2360
|
+
if (this.config.verbose) {
|
|
2361
|
+
console.warn(`[BackupPlugin] Error processing file ${file.name}: ${fileError.message}`);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
return restoredResources;
|
|
2366
|
+
} catch (error) {
|
|
2367
|
+
if (this.config.verbose) {
|
|
2368
|
+
console.error(`[BackupPlugin] Error restoring backup: ${error.message}`);
|
|
2369
|
+
}
|
|
2370
|
+
throw new Error(`Failed to restore backup: ${error.message}`);
|
|
2371
|
+
}
|
|
2249
2372
|
}
|
|
2250
2373
|
/**
|
|
2251
2374
|
* List available backups
|
|
@@ -2289,6 +2412,90 @@ class BackupPlugin extends Plugin {
|
|
|
2289
2412
|
return ok ? backup : null;
|
|
2290
2413
|
}
|
|
2291
2414
|
async _cleanupOldBackups() {
|
|
2415
|
+
try {
|
|
2416
|
+
const [listOk, , allBackups] = await tryFn(
|
|
2417
|
+
() => this.database.resource(this.config.backupMetadataResource).list({
|
|
2418
|
+
filter: { status: "completed" },
|
|
2419
|
+
sort: { timestamp: -1 }
|
|
2420
|
+
})
|
|
2421
|
+
);
|
|
2422
|
+
if (!listOk || !allBackups || allBackups.length === 0) {
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
const now = Date.now();
|
|
2426
|
+
const msPerDay = 24 * 60 * 60 * 1e3;
|
|
2427
|
+
const msPerWeek = 7 * msPerDay;
|
|
2428
|
+
const msPerMonth = 30 * msPerDay;
|
|
2429
|
+
const msPerYear = 365 * msPerDay;
|
|
2430
|
+
const categorized = {
|
|
2431
|
+
daily: [],
|
|
2432
|
+
weekly: [],
|
|
2433
|
+
monthly: [],
|
|
2434
|
+
yearly: []
|
|
2435
|
+
};
|
|
2436
|
+
for (const backup of allBackups) {
|
|
2437
|
+
const age = now - backup.timestamp;
|
|
2438
|
+
if (age <= msPerDay * this.config.retention.daily) {
|
|
2439
|
+
categorized.daily.push(backup);
|
|
2440
|
+
} else if (age <= msPerWeek * this.config.retention.weekly) {
|
|
2441
|
+
categorized.weekly.push(backup);
|
|
2442
|
+
} else if (age <= msPerMonth * this.config.retention.monthly) {
|
|
2443
|
+
categorized.monthly.push(backup);
|
|
2444
|
+
} else if (age <= msPerYear * this.config.retention.yearly) {
|
|
2445
|
+
categorized.yearly.push(backup);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
const toKeep = /* @__PURE__ */ new Set();
|
|
2449
|
+
categorized.daily.forEach((b) => toKeep.add(b.id));
|
|
2450
|
+
const weeklyByWeek = /* @__PURE__ */ new Map();
|
|
2451
|
+
for (const backup of categorized.weekly) {
|
|
2452
|
+
const weekNum = Math.floor((now - backup.timestamp) / msPerWeek);
|
|
2453
|
+
if (!weeklyByWeek.has(weekNum)) {
|
|
2454
|
+
weeklyByWeek.set(weekNum, backup);
|
|
2455
|
+
toKeep.add(backup.id);
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
const monthlyByMonth = /* @__PURE__ */ new Map();
|
|
2459
|
+
for (const backup of categorized.monthly) {
|
|
2460
|
+
const monthNum = Math.floor((now - backup.timestamp) / msPerMonth);
|
|
2461
|
+
if (!monthlyByMonth.has(monthNum)) {
|
|
2462
|
+
monthlyByMonth.set(monthNum, backup);
|
|
2463
|
+
toKeep.add(backup.id);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
const yearlyByYear = /* @__PURE__ */ new Map();
|
|
2467
|
+
for (const backup of categorized.yearly) {
|
|
2468
|
+
const yearNum = Math.floor((now - backup.timestamp) / msPerYear);
|
|
2469
|
+
if (!yearlyByYear.has(yearNum)) {
|
|
2470
|
+
yearlyByYear.set(yearNum, backup);
|
|
2471
|
+
toKeep.add(backup.id);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
const backupsToDelete = allBackups.filter((b) => !toKeep.has(b.id));
|
|
2475
|
+
if (backupsToDelete.length === 0) {
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
if (this.config.verbose) {
|
|
2479
|
+
console.log(`[BackupPlugin] Cleaning up ${backupsToDelete.length} old backups (keeping ${toKeep.size})`);
|
|
2480
|
+
}
|
|
2481
|
+
for (const backup of backupsToDelete) {
|
|
2482
|
+
try {
|
|
2483
|
+
await this.driver.delete(backup.id, backup.driverInfo);
|
|
2484
|
+
await this.database.resource(this.config.backupMetadataResource).delete(backup.id);
|
|
2485
|
+
if (this.config.verbose) {
|
|
2486
|
+
console.log(`[BackupPlugin] Deleted old backup: ${backup.id}`);
|
|
2487
|
+
}
|
|
2488
|
+
} catch (deleteError) {
|
|
2489
|
+
if (this.config.verbose) {
|
|
2490
|
+
console.warn(`[BackupPlugin] Failed to delete backup ${backup.id}: ${deleteError.message}`);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
} catch (error) {
|
|
2495
|
+
if (this.config.verbose) {
|
|
2496
|
+
console.warn(`[BackupPlugin] Error during cleanup: ${error.message}`);
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2292
2499
|
}
|
|
2293
2500
|
async _executeHook(hook, ...args) {
|
|
2294
2501
|
if (typeof hook === "function") {
|
|
@@ -3578,81 +3785,58 @@ class PartitionAwareFilesystemCache extends FilesystemCache {
|
|
|
3578
3785
|
class CachePlugin extends Plugin {
|
|
3579
3786
|
constructor(options = {}) {
|
|
3580
3787
|
super(options);
|
|
3581
|
-
this.
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3788
|
+
this.config = {
|
|
3789
|
+
// Driver configuration
|
|
3790
|
+
driver: options.driver || "s3",
|
|
3791
|
+
config: {
|
|
3792
|
+
ttl: options.ttl,
|
|
3793
|
+
maxSize: options.maxSize,
|
|
3794
|
+
...options.config
|
|
3795
|
+
// Driver-specific config (can override ttl/maxSize)
|
|
3796
|
+
},
|
|
3797
|
+
// Resource filtering
|
|
3798
|
+
include: options.include || null,
|
|
3799
|
+
// Array of resource names to cache (null = all)
|
|
3800
|
+
exclude: options.exclude || [],
|
|
3801
|
+
// Array of resource names to exclude
|
|
3802
|
+
// Partition settings
|
|
3803
|
+
includePartitions: options.includePartitions !== false,
|
|
3804
|
+
partitionStrategy: options.partitionStrategy || "hierarchical",
|
|
3805
|
+
partitionAware: options.partitionAware !== false,
|
|
3806
|
+
trackUsage: options.trackUsage !== false,
|
|
3807
|
+
preloadRelated: options.preloadRelated !== false,
|
|
3808
|
+
// Retry configuration
|
|
3809
|
+
retryAttempts: options.retryAttempts || 3,
|
|
3810
|
+
retryDelay: options.retryDelay || 100,
|
|
3811
|
+
// ms
|
|
3812
|
+
// Logging
|
|
3813
|
+
verbose: options.verbose || false
|
|
3595
3814
|
};
|
|
3596
3815
|
}
|
|
3597
3816
|
async setup(database) {
|
|
3598
3817
|
await super.setup(database);
|
|
3599
3818
|
}
|
|
3600
3819
|
async onSetup() {
|
|
3601
|
-
if (this.
|
|
3602
|
-
this.driver = this.
|
|
3603
|
-
} else if (this.
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
...this.config
|
|
3608
|
-
// New config format (medium priority)
|
|
3609
|
-
};
|
|
3610
|
-
if (this.ttl !== void 0) {
|
|
3611
|
-
driverConfig.ttl = this.ttl;
|
|
3612
|
-
}
|
|
3613
|
-
if (this.maxSize !== void 0) {
|
|
3614
|
-
driverConfig.maxSize = this.maxSize;
|
|
3615
|
-
}
|
|
3616
|
-
this.driver = new MemoryCache(driverConfig);
|
|
3617
|
-
} else if (this.driverName === "filesystem") {
|
|
3618
|
-
const driverConfig = {
|
|
3619
|
-
...this.legacyConfig.filesystemOptions,
|
|
3620
|
-
// Legacy support (lowest priority)
|
|
3621
|
-
...this.config
|
|
3622
|
-
// New config format (medium priority)
|
|
3623
|
-
};
|
|
3624
|
-
if (this.ttl !== void 0) {
|
|
3625
|
-
driverConfig.ttl = this.ttl;
|
|
3626
|
-
}
|
|
3627
|
-
if (this.maxSize !== void 0) {
|
|
3628
|
-
driverConfig.maxSize = this.maxSize;
|
|
3629
|
-
}
|
|
3630
|
-
if (this.partitionAware) {
|
|
3820
|
+
if (this.config.driver && typeof this.config.driver === "object") {
|
|
3821
|
+
this.driver = this.config.driver;
|
|
3822
|
+
} else if (this.config.driver === "memory") {
|
|
3823
|
+
this.driver = new MemoryCache(this.config.config);
|
|
3824
|
+
} else if (this.config.driver === "filesystem") {
|
|
3825
|
+
if (this.config.partitionAware) {
|
|
3631
3826
|
this.driver = new PartitionAwareFilesystemCache({
|
|
3632
|
-
partitionStrategy: this.partitionStrategy,
|
|
3633
|
-
trackUsage: this.trackUsage,
|
|
3634
|
-
preloadRelated: this.preloadRelated,
|
|
3635
|
-
...
|
|
3827
|
+
partitionStrategy: this.config.partitionStrategy,
|
|
3828
|
+
trackUsage: this.config.trackUsage,
|
|
3829
|
+
preloadRelated: this.config.preloadRelated,
|
|
3830
|
+
...this.config.config
|
|
3636
3831
|
});
|
|
3637
3832
|
} else {
|
|
3638
|
-
this.driver = new FilesystemCache(
|
|
3833
|
+
this.driver = new FilesystemCache(this.config.config);
|
|
3639
3834
|
}
|
|
3640
3835
|
} else {
|
|
3641
|
-
|
|
3836
|
+
this.driver = new S3Cache({
|
|
3642
3837
|
client: this.database.client,
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
// Legacy support (lowest priority)
|
|
3646
|
-
...this.config
|
|
3647
|
-
// New config format (medium priority)
|
|
3648
|
-
};
|
|
3649
|
-
if (this.ttl !== void 0) {
|
|
3650
|
-
driverConfig.ttl = this.ttl;
|
|
3651
|
-
}
|
|
3652
|
-
if (this.maxSize !== void 0) {
|
|
3653
|
-
driverConfig.maxSize = this.maxSize;
|
|
3654
|
-
}
|
|
3655
|
-
this.driver = new S3Cache(driverConfig);
|
|
3838
|
+
...this.config.config
|
|
3839
|
+
});
|
|
3656
3840
|
}
|
|
3657
3841
|
this.installDatabaseHooks();
|
|
3658
3842
|
this.installResourceHooks();
|
|
@@ -3662,7 +3846,9 @@ class CachePlugin extends Plugin {
|
|
|
3662
3846
|
*/
|
|
3663
3847
|
installDatabaseHooks() {
|
|
3664
3848
|
this.database.addHook("afterCreateResource", async ({ resource }) => {
|
|
3665
|
-
this.
|
|
3849
|
+
if (this.shouldCacheResource(resource.name)) {
|
|
3850
|
+
this.installResourceHooksForResource(resource);
|
|
3851
|
+
}
|
|
3666
3852
|
});
|
|
3667
3853
|
}
|
|
3668
3854
|
async onStart() {
|
|
@@ -3672,9 +3858,24 @@ class CachePlugin extends Plugin {
|
|
|
3672
3858
|
// Remove the old installDatabaseProxy method
|
|
3673
3859
|
installResourceHooks() {
|
|
3674
3860
|
for (const resource of Object.values(this.database.resources)) {
|
|
3861
|
+
if (!this.shouldCacheResource(resource.name)) {
|
|
3862
|
+
continue;
|
|
3863
|
+
}
|
|
3675
3864
|
this.installResourceHooksForResource(resource);
|
|
3676
3865
|
}
|
|
3677
3866
|
}
|
|
3867
|
+
shouldCacheResource(resourceName) {
|
|
3868
|
+
if (resourceName.startsWith("plg_") && !this.config.include) {
|
|
3869
|
+
return false;
|
|
3870
|
+
}
|
|
3871
|
+
if (this.config.exclude.includes(resourceName)) {
|
|
3872
|
+
return false;
|
|
3873
|
+
}
|
|
3874
|
+
if (this.config.include && !this.config.include.includes(resourceName)) {
|
|
3875
|
+
return false;
|
|
3876
|
+
}
|
|
3877
|
+
return true;
|
|
3878
|
+
}
|
|
3678
3879
|
installResourceHooksForResource(resource) {
|
|
3679
3880
|
if (!this.driver) return;
|
|
3680
3881
|
Object.defineProperty(resource, "cache", {
|
|
@@ -3823,37 +4024,74 @@ class CachePlugin extends Plugin {
|
|
|
3823
4024
|
if (data && data.id) {
|
|
3824
4025
|
const itemSpecificMethods = ["get", "exists", "content", "hasContent"];
|
|
3825
4026
|
for (const method of itemSpecificMethods) {
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
4027
|
+
const specificKey = await this.generateCacheKey(resource, method, { id: data.id });
|
|
4028
|
+
const [ok2, err2] = await this.clearCacheWithRetry(resource.cache, specificKey);
|
|
4029
|
+
if (!ok2) {
|
|
4030
|
+
this.emit("cache_clear_error", {
|
|
4031
|
+
resource: resource.name,
|
|
4032
|
+
method,
|
|
4033
|
+
id: data.id,
|
|
4034
|
+
error: err2.message
|
|
4035
|
+
});
|
|
4036
|
+
if (this.config.verbose) {
|
|
4037
|
+
console.warn(`[CachePlugin] Failed to clear ${method} cache for ${resource.name}:${data.id}:`, err2.message);
|
|
4038
|
+
}
|
|
3830
4039
|
}
|
|
3831
4040
|
}
|
|
3832
4041
|
if (this.config.includePartitions === true && resource.config?.partitions && Object.keys(resource.config.partitions).length > 0) {
|
|
3833
4042
|
const partitionValues = this.getPartitionValues(data, resource);
|
|
3834
4043
|
for (const [partitionName, values] of Object.entries(partitionValues)) {
|
|
3835
4044
|
if (values && Object.keys(values).length > 0 && Object.values(values).some((v) => v !== null && v !== void 0)) {
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
4045
|
+
const partitionKeyPrefix = join(keyPrefix, `partition=${partitionName}`);
|
|
4046
|
+
const [ok2, err2] = await this.clearCacheWithRetry(resource.cache, partitionKeyPrefix);
|
|
4047
|
+
if (!ok2) {
|
|
4048
|
+
this.emit("cache_clear_error", {
|
|
4049
|
+
resource: resource.name,
|
|
4050
|
+
partition: partitionName,
|
|
4051
|
+
error: err2.message
|
|
4052
|
+
});
|
|
4053
|
+
if (this.config.verbose) {
|
|
4054
|
+
console.warn(`[CachePlugin] Failed to clear partition cache for ${resource.name}/${partitionName}:`, err2.message);
|
|
4055
|
+
}
|
|
3840
4056
|
}
|
|
3841
4057
|
}
|
|
3842
4058
|
}
|
|
3843
4059
|
}
|
|
3844
4060
|
}
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
4061
|
+
const [ok, err] = await this.clearCacheWithRetry(resource.cache, keyPrefix);
|
|
4062
|
+
if (!ok) {
|
|
4063
|
+
this.emit("cache_clear_error", {
|
|
4064
|
+
resource: resource.name,
|
|
4065
|
+
type: "broad",
|
|
4066
|
+
error: err.message
|
|
4067
|
+
});
|
|
4068
|
+
if (this.config.verbose) {
|
|
4069
|
+
console.warn(`[CachePlugin] Failed to clear broad cache for ${resource.name}, trying specific methods:`, err.message);
|
|
4070
|
+
}
|
|
3848
4071
|
const aggregateMethods = ["count", "list", "listIds", "getAll", "page", "query"];
|
|
3849
4072
|
for (const method of aggregateMethods) {
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
4073
|
+
await this.clearCacheWithRetry(resource.cache, `${keyPrefix}/action=${method}`);
|
|
4074
|
+
await this.clearCacheWithRetry(resource.cache, `resource=${resource.name}/action=${method}`);
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
}
|
|
4078
|
+
async clearCacheWithRetry(cache, key) {
|
|
4079
|
+
let lastError;
|
|
4080
|
+
for (let attempt = 0; attempt < this.config.retryAttempts; attempt++) {
|
|
4081
|
+
const [ok, err] = await tryFn(() => cache.clear(key));
|
|
4082
|
+
if (ok) {
|
|
4083
|
+
return [true, null];
|
|
4084
|
+
}
|
|
4085
|
+
lastError = err;
|
|
4086
|
+
if (err.name === "NoSuchKey" || err.code === "NoSuchKey") {
|
|
4087
|
+
return [true, null];
|
|
4088
|
+
}
|
|
4089
|
+
if (attempt < this.config.retryAttempts - 1) {
|
|
4090
|
+
const delay = this.config.retryDelay * Math.pow(2, attempt);
|
|
4091
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
3855
4092
|
}
|
|
3856
4093
|
}
|
|
4094
|
+
return [false, lastError];
|
|
3857
4095
|
}
|
|
3858
4096
|
async generateCacheKey(resource, action, params = {}, partition = null, partitionValues = null) {
|
|
3859
4097
|
const keyParts = [
|
|
@@ -3869,14 +4107,14 @@ class CachePlugin extends Plugin {
|
|
|
3869
4107
|
}
|
|
3870
4108
|
}
|
|
3871
4109
|
if (Object.keys(params).length > 0) {
|
|
3872
|
-
const paramsHash =
|
|
4110
|
+
const paramsHash = this.hashParams(params);
|
|
3873
4111
|
keyParts.push(paramsHash);
|
|
3874
4112
|
}
|
|
3875
4113
|
return join(...keyParts) + ".json.gz";
|
|
3876
4114
|
}
|
|
3877
|
-
|
|
3878
|
-
const
|
|
3879
|
-
return
|
|
4115
|
+
hashParams(params) {
|
|
4116
|
+
const serialized = jsonStableStringify(params) || "empty";
|
|
4117
|
+
return crypto.createHash("md5").update(serialized).digest("hex").substring(0, 16);
|
|
3880
4118
|
}
|
|
3881
4119
|
// Utility methods
|
|
3882
4120
|
async getCacheStats() {
|
|
@@ -3901,50 +4139,48 @@ class CachePlugin extends Plugin {
|
|
|
3901
4139
|
if (!resource) {
|
|
3902
4140
|
throw new Error(`Resource '${resourceName}' not found`);
|
|
3903
4141
|
}
|
|
3904
|
-
const { includePartitions = true } = options;
|
|
4142
|
+
const { includePartitions = true, sampleSize = 100 } = options;
|
|
3905
4143
|
if (this.driver instanceof PartitionAwareFilesystemCache && resource.warmPartitionCache) {
|
|
3906
4144
|
const partitionNames = resource.config.partitions ? Object.keys(resource.config.partitions) : [];
|
|
3907
4145
|
return await resource.warmPartitionCache(partitionNames, options);
|
|
3908
4146
|
}
|
|
3909
|
-
|
|
3910
|
-
|
|
4147
|
+
let offset = 0;
|
|
4148
|
+
const pageSize = 100;
|
|
4149
|
+
const sampledRecords = [];
|
|
4150
|
+
while (sampledRecords.length < sampleSize) {
|
|
4151
|
+
const [ok, err, pageResult] = await tryFn(() => resource.page({ offset, size: pageSize }));
|
|
4152
|
+
if (!ok || !pageResult) {
|
|
4153
|
+
break;
|
|
4154
|
+
}
|
|
4155
|
+
const pageItems = Array.isArray(pageResult) ? pageResult : pageResult.items || [];
|
|
4156
|
+
if (pageItems.length === 0) {
|
|
4157
|
+
break;
|
|
4158
|
+
}
|
|
4159
|
+
sampledRecords.push(...pageItems);
|
|
4160
|
+
offset += pageSize;
|
|
4161
|
+
}
|
|
4162
|
+
if (includePartitions && resource.config.partitions && sampledRecords.length > 0) {
|
|
3911
4163
|
for (const [partitionName, partitionDef] of Object.entries(resource.config.partitions)) {
|
|
3912
4164
|
if (partitionDef.fields) {
|
|
3913
|
-
const
|
|
3914
|
-
const
|
|
3915
|
-
const partitionValues = /* @__PURE__ */ new Set();
|
|
3916
|
-
for (const record of recordsArray.slice(0, 10)) {
|
|
4165
|
+
const partitionValuesSet = /* @__PURE__ */ new Set();
|
|
4166
|
+
for (const record of sampledRecords) {
|
|
3917
4167
|
const values = this.getPartitionValues(record, resource);
|
|
3918
4168
|
if (values[partitionName]) {
|
|
3919
|
-
|
|
4169
|
+
partitionValuesSet.add(JSON.stringify(values[partitionName]));
|
|
3920
4170
|
}
|
|
3921
4171
|
}
|
|
3922
|
-
for (const partitionValueStr of
|
|
3923
|
-
const
|
|
3924
|
-
await resource.list({ partition: partitionName, partitionValues
|
|
4172
|
+
for (const partitionValueStr of partitionValuesSet) {
|
|
4173
|
+
const partitionValues = JSON.parse(partitionValueStr);
|
|
4174
|
+
await tryFn(() => resource.list({ partition: partitionName, partitionValues }));
|
|
3925
4175
|
}
|
|
3926
4176
|
}
|
|
3927
4177
|
}
|
|
3928
4178
|
}
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
}
|
|
3935
|
-
return await this.driver.getPartitionStats(resourceName, partition);
|
|
3936
|
-
}
|
|
3937
|
-
async getCacheRecommendations(resourceName) {
|
|
3938
|
-
if (!(this.driver instanceof PartitionAwareFilesystemCache)) {
|
|
3939
|
-
throw new Error("Cache recommendations are only available with PartitionAwareFilesystemCache");
|
|
3940
|
-
}
|
|
3941
|
-
return await this.driver.getCacheRecommendations(resourceName);
|
|
3942
|
-
}
|
|
3943
|
-
async clearPartitionCache(resourceName, partition, partitionValues = {}) {
|
|
3944
|
-
if (!(this.driver instanceof PartitionAwareFilesystemCache)) {
|
|
3945
|
-
throw new Error("Partition cache clearing is only available with PartitionAwareFilesystemCache");
|
|
3946
|
-
}
|
|
3947
|
-
return await this.driver.clearPartition(resourceName, partition, partitionValues);
|
|
4179
|
+
return {
|
|
4180
|
+
resourceName,
|
|
4181
|
+
recordsSampled: sampledRecords.length,
|
|
4182
|
+
partitionsWarmed: includePartitions && resource.config.partitions ? Object.keys(resource.config.partitions).length : 0
|
|
4183
|
+
};
|
|
3948
4184
|
}
|
|
3949
4185
|
async analyzeCacheUsage() {
|
|
3950
4186
|
if (!(this.driver instanceof PartitionAwareFilesystemCache)) {
|
|
@@ -3961,6 +4197,9 @@ class CachePlugin extends Plugin {
|
|
|
3961
4197
|
}
|
|
3962
4198
|
};
|
|
3963
4199
|
for (const [resourceName, resource] of Object.entries(this.database.resources)) {
|
|
4200
|
+
if (!this.shouldCacheResource(resourceName)) {
|
|
4201
|
+
continue;
|
|
4202
|
+
}
|
|
3964
4203
|
try {
|
|
3965
4204
|
analysis.resourceStats[resourceName] = await this.driver.getPartitionStats(resourceName);
|
|
3966
4205
|
analysis.recommendations[resourceName] = await this.driver.getCacheRecommendations(resourceName);
|
|
@@ -4061,13 +4300,12 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4061
4300
|
if (!options.field) {
|
|
4062
4301
|
throw new Error("EventualConsistencyPlugin requires 'field' option");
|
|
4063
4302
|
}
|
|
4303
|
+
const detectedTimezone = this._detectTimezone();
|
|
4064
4304
|
this.config = {
|
|
4065
4305
|
resource: options.resource,
|
|
4066
4306
|
field: options.field,
|
|
4067
4307
|
cohort: {
|
|
4068
|
-
|
|
4069
|
-
timezone: options.cohort?.timezone || "UTC",
|
|
4070
|
-
...options.cohort
|
|
4308
|
+
timezone: options.cohort?.timezone || detectedTimezone
|
|
4071
4309
|
},
|
|
4072
4310
|
reducer: options.reducer || ((transactions) => {
|
|
4073
4311
|
let baseValue = 0;
|
|
@@ -4082,19 +4320,42 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4082
4320
|
}
|
|
4083
4321
|
return baseValue;
|
|
4084
4322
|
}),
|
|
4085
|
-
consolidationInterval: options.consolidationInterval
|
|
4086
|
-
//
|
|
4323
|
+
consolidationInterval: options.consolidationInterval ?? 300,
|
|
4324
|
+
// 5 minutes (in seconds)
|
|
4325
|
+
consolidationConcurrency: options.consolidationConcurrency || 5,
|
|
4326
|
+
consolidationWindow: options.consolidationWindow || 24,
|
|
4327
|
+
// Hours to look back for pending transactions (watermark)
|
|
4087
4328
|
autoConsolidate: options.autoConsolidate !== false,
|
|
4329
|
+
lateArrivalStrategy: options.lateArrivalStrategy || "warn",
|
|
4330
|
+
// 'ignore', 'warn', 'process'
|
|
4088
4331
|
batchTransactions: options.batchTransactions || false,
|
|
4332
|
+
// CAUTION: Not safe in distributed environments! Loses data on container crash
|
|
4089
4333
|
batchSize: options.batchSize || 100,
|
|
4090
4334
|
mode: options.mode || "async",
|
|
4091
4335
|
// 'async' or 'sync'
|
|
4092
|
-
|
|
4336
|
+
lockTimeout: options.lockTimeout || 300,
|
|
4337
|
+
// 5 minutes (in seconds, configurable)
|
|
4338
|
+
transactionRetention: options.transactionRetention || 30,
|
|
4339
|
+
// Days to keep applied transactions
|
|
4340
|
+
gcInterval: options.gcInterval || 86400,
|
|
4341
|
+
// 24 hours (in seconds)
|
|
4342
|
+
verbose: options.verbose || false
|
|
4093
4343
|
};
|
|
4094
4344
|
this.transactionResource = null;
|
|
4095
4345
|
this.targetResource = null;
|
|
4096
4346
|
this.consolidationTimer = null;
|
|
4347
|
+
this.gcTimer = null;
|
|
4097
4348
|
this.pendingTransactions = /* @__PURE__ */ new Map();
|
|
4349
|
+
if (this.config.batchTransactions && !this.config.verbose) {
|
|
4350
|
+
console.warn(
|
|
4351
|
+
`[EventualConsistency] WARNING: batchTransactions is enabled. This stores transactions in memory and will lose data if container crashes. Not recommended for distributed/production environments. Set verbose: true to suppress this warning.`
|
|
4352
|
+
);
|
|
4353
|
+
}
|
|
4354
|
+
if (this.config.verbose && !options.cohort?.timezone) {
|
|
4355
|
+
console.log(
|
|
4356
|
+
`[EventualConsistency] Auto-detected timezone: ${this.config.cohort.timezone} (from ${process.env.TZ ? "TZ env var" : "system Intl API"})`
|
|
4357
|
+
);
|
|
4358
|
+
}
|
|
4098
4359
|
}
|
|
4099
4360
|
async onSetup() {
|
|
4100
4361
|
this.targetResource = this.database.resources[this.config.resource];
|
|
@@ -4131,7 +4392,9 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4131
4392
|
// 'set', 'add', or 'sub'
|
|
4132
4393
|
timestamp: "string|required",
|
|
4133
4394
|
cohortDate: "string|required",
|
|
4134
|
-
// For partitioning
|
|
4395
|
+
// For daily partitioning
|
|
4396
|
+
cohortHour: "string|required",
|
|
4397
|
+
// For hourly partitioning
|
|
4135
4398
|
cohortMonth: "string|optional",
|
|
4136
4399
|
// For monthly partitioning
|
|
4137
4400
|
source: "string|optional",
|
|
@@ -4149,10 +4412,28 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4149
4412
|
throw new Error(`Failed to create transaction resource: ${err?.message}`);
|
|
4150
4413
|
}
|
|
4151
4414
|
this.transactionResource = ok ? transactionResource : this.database.resources[transactionResourceName];
|
|
4415
|
+
const lockResourceName = `${this.config.resource}_consolidation_locks_${this.config.field}`;
|
|
4416
|
+
const [lockOk, lockErr, lockResource] = await tryFn(
|
|
4417
|
+
() => this.database.createResource({
|
|
4418
|
+
name: lockResourceName,
|
|
4419
|
+
attributes: {
|
|
4420
|
+
id: "string|required",
|
|
4421
|
+
lockedAt: "number|required",
|
|
4422
|
+
workerId: "string|optional"
|
|
4423
|
+
},
|
|
4424
|
+
behavior: "body-only",
|
|
4425
|
+
timestamps: false
|
|
4426
|
+
})
|
|
4427
|
+
);
|
|
4428
|
+
if (!lockOk && !this.database.resources[lockResourceName]) {
|
|
4429
|
+
throw new Error(`Failed to create lock resource: ${lockErr?.message}`);
|
|
4430
|
+
}
|
|
4431
|
+
this.lockResource = lockOk ? lockResource : this.database.resources[lockResourceName];
|
|
4152
4432
|
this.addHelperMethods();
|
|
4153
4433
|
if (this.config.autoConsolidate) {
|
|
4154
4434
|
this.startConsolidationTimer();
|
|
4155
4435
|
}
|
|
4436
|
+
this.startGarbageCollectionTimer();
|
|
4156
4437
|
}
|
|
4157
4438
|
async onStart() {
|
|
4158
4439
|
if (this.deferredSetup) {
|
|
@@ -4169,6 +4450,10 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4169
4450
|
clearInterval(this.consolidationTimer);
|
|
4170
4451
|
this.consolidationTimer = null;
|
|
4171
4452
|
}
|
|
4453
|
+
if (this.gcTimer) {
|
|
4454
|
+
clearInterval(this.gcTimer);
|
|
4455
|
+
this.gcTimer = null;
|
|
4456
|
+
}
|
|
4172
4457
|
await this.flushPendingTransactions();
|
|
4173
4458
|
this.emit("eventual-consistency.stopped", {
|
|
4174
4459
|
resource: this.config.resource,
|
|
@@ -4177,6 +4462,11 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4177
4462
|
}
|
|
4178
4463
|
createPartitionConfig() {
|
|
4179
4464
|
const partitions = {
|
|
4465
|
+
byHour: {
|
|
4466
|
+
fields: {
|
|
4467
|
+
cohortHour: "string"
|
|
4468
|
+
}
|
|
4469
|
+
},
|
|
4180
4470
|
byDay: {
|
|
4181
4471
|
fields: {
|
|
4182
4472
|
cohortDate: "string"
|
|
@@ -4190,6 +4480,65 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4190
4480
|
};
|
|
4191
4481
|
return partitions;
|
|
4192
4482
|
}
|
|
4483
|
+
/**
|
|
4484
|
+
* Auto-detect timezone from environment or system
|
|
4485
|
+
* @private
|
|
4486
|
+
*/
|
|
4487
|
+
_detectTimezone() {
|
|
4488
|
+
if (process.env.TZ) {
|
|
4489
|
+
return process.env.TZ;
|
|
4490
|
+
}
|
|
4491
|
+
try {
|
|
4492
|
+
const systemTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
4493
|
+
if (systemTimezone) {
|
|
4494
|
+
return systemTimezone;
|
|
4495
|
+
}
|
|
4496
|
+
} catch (err) {
|
|
4497
|
+
}
|
|
4498
|
+
return "UTC";
|
|
4499
|
+
}
|
|
4500
|
+
/**
|
|
4501
|
+
* Helper method to resolve field and plugin from arguments
|
|
4502
|
+
* Supports both single-field (field, value) and multi-field (field, value) signatures
|
|
4503
|
+
* @private
|
|
4504
|
+
*/
|
|
4505
|
+
_resolveFieldAndPlugin(resource, fieldOrValue, value) {
|
|
4506
|
+
const hasMultipleFields = Object.keys(resource._eventualConsistencyPlugins).length > 1;
|
|
4507
|
+
if (hasMultipleFields && value === void 0) {
|
|
4508
|
+
throw new Error(`Multiple fields have eventual consistency. Please specify the field explicitly.`);
|
|
4509
|
+
}
|
|
4510
|
+
const field = value !== void 0 ? fieldOrValue : this.config.field;
|
|
4511
|
+
const actualValue = value !== void 0 ? value : fieldOrValue;
|
|
4512
|
+
const fieldPlugin = resource._eventualConsistencyPlugins[field];
|
|
4513
|
+
if (!fieldPlugin) {
|
|
4514
|
+
throw new Error(`No eventual consistency plugin found for field "${field}"`);
|
|
4515
|
+
}
|
|
4516
|
+
return { field, value: actualValue, plugin: fieldPlugin };
|
|
4517
|
+
}
|
|
4518
|
+
/**
|
|
4519
|
+
* Helper method to perform atomic consolidation in sync mode
|
|
4520
|
+
* @private
|
|
4521
|
+
*/
|
|
4522
|
+
async _syncModeConsolidate(id, field) {
|
|
4523
|
+
const consolidatedValue = await this.consolidateRecord(id);
|
|
4524
|
+
await this.targetResource.update(id, {
|
|
4525
|
+
[field]: consolidatedValue
|
|
4526
|
+
});
|
|
4527
|
+
return consolidatedValue;
|
|
4528
|
+
}
|
|
4529
|
+
/**
|
|
4530
|
+
* Create synthetic 'set' transaction from current value
|
|
4531
|
+
* @private
|
|
4532
|
+
*/
|
|
4533
|
+
_createSyntheticSetTransaction(currentValue) {
|
|
4534
|
+
return {
|
|
4535
|
+
id: "__synthetic__",
|
|
4536
|
+
operation: "set",
|
|
4537
|
+
value: currentValue,
|
|
4538
|
+
timestamp: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
4539
|
+
synthetic: true
|
|
4540
|
+
};
|
|
4541
|
+
}
|
|
4193
4542
|
addHelperMethods() {
|
|
4194
4543
|
const resource = this.targetResource;
|
|
4195
4544
|
const defaultField = this.config.field;
|
|
@@ -4199,16 +4548,7 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4199
4548
|
}
|
|
4200
4549
|
resource._eventualConsistencyPlugins[defaultField] = plugin;
|
|
4201
4550
|
resource.set = async (id, fieldOrValue, value) => {
|
|
4202
|
-
const
|
|
4203
|
-
if (hasMultipleFields && value === void 0) {
|
|
4204
|
-
throw new Error(`Multiple fields have eventual consistency. Please specify the field: set(id, field, value)`);
|
|
4205
|
-
}
|
|
4206
|
-
const field = value !== void 0 ? fieldOrValue : defaultField;
|
|
4207
|
-
const actualValue = value !== void 0 ? value : fieldOrValue;
|
|
4208
|
-
const fieldPlugin = resource._eventualConsistencyPlugins[field];
|
|
4209
|
-
if (!fieldPlugin) {
|
|
4210
|
-
throw new Error(`No eventual consistency plugin found for field "${field}"`);
|
|
4211
|
-
}
|
|
4551
|
+
const { field, value: actualValue, plugin: fieldPlugin } = plugin._resolveFieldAndPlugin(resource, fieldOrValue, value);
|
|
4212
4552
|
await fieldPlugin.createTransaction({
|
|
4213
4553
|
originalId: id,
|
|
4214
4554
|
operation: "set",
|
|
@@ -4216,25 +4556,12 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4216
4556
|
source: "set"
|
|
4217
4557
|
});
|
|
4218
4558
|
if (fieldPlugin.config.mode === "sync") {
|
|
4219
|
-
|
|
4220
|
-
await resource.update(id, {
|
|
4221
|
-
[field]: consolidatedValue
|
|
4222
|
-
});
|
|
4223
|
-
return consolidatedValue;
|
|
4559
|
+
return await fieldPlugin._syncModeConsolidate(id, field);
|
|
4224
4560
|
}
|
|
4225
4561
|
return actualValue;
|
|
4226
4562
|
};
|
|
4227
4563
|
resource.add = async (id, fieldOrAmount, amount) => {
|
|
4228
|
-
const
|
|
4229
|
-
if (hasMultipleFields && amount === void 0) {
|
|
4230
|
-
throw new Error(`Multiple fields have eventual consistency. Please specify the field: add(id, field, amount)`);
|
|
4231
|
-
}
|
|
4232
|
-
const field = amount !== void 0 ? fieldOrAmount : defaultField;
|
|
4233
|
-
const actualAmount = amount !== void 0 ? amount : fieldOrAmount;
|
|
4234
|
-
const fieldPlugin = resource._eventualConsistencyPlugins[field];
|
|
4235
|
-
if (!fieldPlugin) {
|
|
4236
|
-
throw new Error(`No eventual consistency plugin found for field "${field}"`);
|
|
4237
|
-
}
|
|
4564
|
+
const { field, value: actualAmount, plugin: fieldPlugin } = plugin._resolveFieldAndPlugin(resource, fieldOrAmount, amount);
|
|
4238
4565
|
await fieldPlugin.createTransaction({
|
|
4239
4566
|
originalId: id,
|
|
4240
4567
|
operation: "add",
|
|
@@ -4242,26 +4569,13 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4242
4569
|
source: "add"
|
|
4243
4570
|
});
|
|
4244
4571
|
if (fieldPlugin.config.mode === "sync") {
|
|
4245
|
-
|
|
4246
|
-
await resource.update(id, {
|
|
4247
|
-
[field]: consolidatedValue
|
|
4248
|
-
});
|
|
4249
|
-
return consolidatedValue;
|
|
4572
|
+
return await fieldPlugin._syncModeConsolidate(id, field);
|
|
4250
4573
|
}
|
|
4251
4574
|
const currentValue = await fieldPlugin.getConsolidatedValue(id);
|
|
4252
4575
|
return currentValue + actualAmount;
|
|
4253
4576
|
};
|
|
4254
4577
|
resource.sub = async (id, fieldOrAmount, amount) => {
|
|
4255
|
-
const
|
|
4256
|
-
if (hasMultipleFields && amount === void 0) {
|
|
4257
|
-
throw new Error(`Multiple fields have eventual consistency. Please specify the field: sub(id, field, amount)`);
|
|
4258
|
-
}
|
|
4259
|
-
const field = amount !== void 0 ? fieldOrAmount : defaultField;
|
|
4260
|
-
const actualAmount = amount !== void 0 ? amount : fieldOrAmount;
|
|
4261
|
-
const fieldPlugin = resource._eventualConsistencyPlugins[field];
|
|
4262
|
-
if (!fieldPlugin) {
|
|
4263
|
-
throw new Error(`No eventual consistency plugin found for field "${field}"`);
|
|
4264
|
-
}
|
|
4578
|
+
const { field, value: actualAmount, plugin: fieldPlugin } = plugin._resolveFieldAndPlugin(resource, fieldOrAmount, amount);
|
|
4265
4579
|
await fieldPlugin.createTransaction({
|
|
4266
4580
|
originalId: id,
|
|
4267
4581
|
operation: "sub",
|
|
@@ -4269,11 +4583,7 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4269
4583
|
source: "sub"
|
|
4270
4584
|
});
|
|
4271
4585
|
if (fieldPlugin.config.mode === "sync") {
|
|
4272
|
-
|
|
4273
|
-
await resource.update(id, {
|
|
4274
|
-
[field]: consolidatedValue
|
|
4275
|
-
});
|
|
4276
|
-
return consolidatedValue;
|
|
4586
|
+
return await fieldPlugin._syncModeConsolidate(id, field);
|
|
4277
4587
|
}
|
|
4278
4588
|
const currentValue = await fieldPlugin.getConsolidatedValue(id);
|
|
4279
4589
|
return currentValue - actualAmount;
|
|
@@ -4303,14 +4613,34 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4303
4613
|
async createTransaction(data) {
|
|
4304
4614
|
const now = /* @__PURE__ */ new Date();
|
|
4305
4615
|
const cohortInfo = this.getCohortInfo(now);
|
|
4616
|
+
const watermarkMs = this.config.consolidationWindow * 60 * 60 * 1e3;
|
|
4617
|
+
const watermarkTime = now.getTime() - watermarkMs;
|
|
4618
|
+
const cohortHourDate = /* @__PURE__ */ new Date(cohortInfo.hour + ":00:00Z");
|
|
4619
|
+
if (cohortHourDate.getTime() < watermarkTime) {
|
|
4620
|
+
const hoursLate = Math.floor((now.getTime() - cohortHourDate.getTime()) / (60 * 60 * 1e3));
|
|
4621
|
+
if (this.config.lateArrivalStrategy === "ignore") {
|
|
4622
|
+
if (this.config.verbose) {
|
|
4623
|
+
console.warn(
|
|
4624
|
+
`[EventualConsistency] Late arrival ignored: transaction for ${cohortInfo.hour} is ${hoursLate}h late (watermark: ${this.config.consolidationWindow}h)`
|
|
4625
|
+
);
|
|
4626
|
+
}
|
|
4627
|
+
return null;
|
|
4628
|
+
} else if (this.config.lateArrivalStrategy === "warn") {
|
|
4629
|
+
console.warn(
|
|
4630
|
+
`[EventualConsistency] Late arrival detected: transaction for ${cohortInfo.hour} is ${hoursLate}h late (watermark: ${this.config.consolidationWindow}h). Processing anyway, but consolidation may not pick it up.`
|
|
4631
|
+
);
|
|
4632
|
+
}
|
|
4633
|
+
}
|
|
4306
4634
|
const transaction = {
|
|
4307
|
-
id:
|
|
4635
|
+
id: idGenerator(),
|
|
4636
|
+
// Use nanoid for guaranteed uniqueness
|
|
4308
4637
|
originalId: data.originalId,
|
|
4309
4638
|
field: this.config.field,
|
|
4310
4639
|
value: data.value || 0,
|
|
4311
4640
|
operation: data.operation || "set",
|
|
4312
4641
|
timestamp: now.toISOString(),
|
|
4313
4642
|
cohortDate: cohortInfo.date,
|
|
4643
|
+
cohortHour: cohortInfo.hour,
|
|
4314
4644
|
cohortMonth: cohortInfo.month,
|
|
4315
4645
|
source: data.source || "unknown",
|
|
4316
4646
|
applied: false
|
|
@@ -4328,9 +4658,16 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4328
4658
|
async flushPendingTransactions() {
|
|
4329
4659
|
if (this.pendingTransactions.size === 0) return;
|
|
4330
4660
|
const transactions = Array.from(this.pendingTransactions.values());
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4661
|
+
try {
|
|
4662
|
+
await Promise.all(
|
|
4663
|
+
transactions.map(
|
|
4664
|
+
(transaction) => this.transactionResource.insert(transaction)
|
|
4665
|
+
)
|
|
4666
|
+
);
|
|
4667
|
+
this.pendingTransactions.clear();
|
|
4668
|
+
} catch (error) {
|
|
4669
|
+
console.error("Failed to flush pending transactions:", error);
|
|
4670
|
+
throw error;
|
|
4334
4671
|
}
|
|
4335
4672
|
}
|
|
4336
4673
|
getCohortInfo(date) {
|
|
@@ -4340,53 +4677,90 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4340
4677
|
const year = localDate.getFullYear();
|
|
4341
4678
|
const month = String(localDate.getMonth() + 1).padStart(2, "0");
|
|
4342
4679
|
const day = String(localDate.getDate()).padStart(2, "0");
|
|
4680
|
+
const hour = String(localDate.getHours()).padStart(2, "0");
|
|
4343
4681
|
return {
|
|
4344
4682
|
date: `${year}-${month}-${day}`,
|
|
4683
|
+
hour: `${year}-${month}-${day}T${hour}`,
|
|
4684
|
+
// ISO-like format for hour partition
|
|
4345
4685
|
month: `${year}-${month}`
|
|
4346
4686
|
};
|
|
4347
4687
|
}
|
|
4348
4688
|
getTimezoneOffset(timezone) {
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
"
|
|
4352
|
-
"
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4689
|
+
try {
|
|
4690
|
+
const now = /* @__PURE__ */ new Date();
|
|
4691
|
+
const utcDate = new Date(now.toLocaleString("en-US", { timeZone: "UTC" }));
|
|
4692
|
+
const tzDate = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
|
4693
|
+
return tzDate.getTime() - utcDate.getTime();
|
|
4694
|
+
} catch (err) {
|
|
4695
|
+
const offsets = {
|
|
4696
|
+
"UTC": 0,
|
|
4697
|
+
"America/New_York": -5 * 36e5,
|
|
4698
|
+
"America/Chicago": -6 * 36e5,
|
|
4699
|
+
"America/Denver": -7 * 36e5,
|
|
4700
|
+
"America/Los_Angeles": -8 * 36e5,
|
|
4701
|
+
"America/Sao_Paulo": -3 * 36e5,
|
|
4702
|
+
"Europe/London": 0,
|
|
4703
|
+
"Europe/Paris": 1 * 36e5,
|
|
4704
|
+
"Europe/Berlin": 1 * 36e5,
|
|
4705
|
+
"Asia/Tokyo": 9 * 36e5,
|
|
4706
|
+
"Asia/Shanghai": 8 * 36e5,
|
|
4707
|
+
"Australia/Sydney": 10 * 36e5
|
|
4708
|
+
};
|
|
4709
|
+
if (this.config.verbose && !offsets[timezone]) {
|
|
4710
|
+
console.warn(
|
|
4711
|
+
`[EventualConsistency] Unknown timezone '${timezone}', using UTC. Consider using a valid IANA timezone (e.g., 'America/New_York')`
|
|
4712
|
+
);
|
|
4713
|
+
}
|
|
4714
|
+
return offsets[timezone] || 0;
|
|
4715
|
+
}
|
|
4716
|
+
}
|
|
4717
|
+
startConsolidationTimer() {
|
|
4718
|
+
const intervalMs = this.config.consolidationInterval * 1e3;
|
|
4719
|
+
this.consolidationTimer = setInterval(async () => {
|
|
4720
|
+
await this.runConsolidation();
|
|
4721
|
+
}, intervalMs);
|
|
4370
4722
|
}
|
|
4371
4723
|
async runConsolidation() {
|
|
4372
4724
|
try {
|
|
4373
|
-
const
|
|
4374
|
-
|
|
4375
|
-
|
|
4725
|
+
const now = /* @__PURE__ */ new Date();
|
|
4726
|
+
const hoursToCheck = this.config.consolidationWindow || 24;
|
|
4727
|
+
const cohortHours = [];
|
|
4728
|
+
for (let i = 0; i < hoursToCheck; i++) {
|
|
4729
|
+
const date = new Date(now.getTime() - i * 60 * 60 * 1e3);
|
|
4730
|
+
const cohortInfo = this.getCohortInfo(date);
|
|
4731
|
+
cohortHours.push(cohortInfo.hour);
|
|
4732
|
+
}
|
|
4733
|
+
const transactionsByHour = await Promise.all(
|
|
4734
|
+
cohortHours.map(async (cohortHour) => {
|
|
4735
|
+
const [ok, err, txns] = await tryFn(
|
|
4736
|
+
() => this.transactionResource.query({
|
|
4737
|
+
cohortHour,
|
|
4738
|
+
applied: false
|
|
4739
|
+
})
|
|
4740
|
+
);
|
|
4741
|
+
return ok ? txns : [];
|
|
4376
4742
|
})
|
|
4377
4743
|
);
|
|
4378
|
-
|
|
4379
|
-
|
|
4744
|
+
const transactions = transactionsByHour.flat();
|
|
4745
|
+
if (transactions.length === 0) {
|
|
4746
|
+
if (this.config.verbose) {
|
|
4747
|
+
console.log(`[EventualConsistency] No pending transactions to consolidate`);
|
|
4748
|
+
}
|
|
4380
4749
|
return;
|
|
4381
4750
|
}
|
|
4382
4751
|
const uniqueIds = [...new Set(transactions.map((t) => t.originalId))];
|
|
4383
|
-
|
|
4384
|
-
await this.consolidateRecord(id);
|
|
4752
|
+
const { results, errors } = await PromisePool.for(uniqueIds).withConcurrency(this.config.consolidationConcurrency).process(async (id) => {
|
|
4753
|
+
return await this.consolidateRecord(id);
|
|
4754
|
+
});
|
|
4755
|
+
if (errors && errors.length > 0) {
|
|
4756
|
+
console.error(`Consolidation completed with ${errors.length} errors:`, errors);
|
|
4385
4757
|
}
|
|
4386
4758
|
this.emit("eventual-consistency.consolidated", {
|
|
4387
4759
|
resource: this.config.resource,
|
|
4388
4760
|
field: this.config.field,
|
|
4389
|
-
recordCount: uniqueIds.length
|
|
4761
|
+
recordCount: uniqueIds.length,
|
|
4762
|
+
successCount: results.length,
|
|
4763
|
+
errorCount: errors.length
|
|
4390
4764
|
});
|
|
4391
4765
|
} catch (error) {
|
|
4392
4766
|
console.error("Consolidation error:", error);
|
|
@@ -4394,49 +4768,73 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4394
4768
|
}
|
|
4395
4769
|
}
|
|
4396
4770
|
async consolidateRecord(originalId) {
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
applied: false
|
|
4771
|
+
await this.cleanupStaleLocks();
|
|
4772
|
+
const lockId = `lock-${originalId}`;
|
|
4773
|
+
const [lockAcquired, lockErr, lock] = await tryFn(
|
|
4774
|
+
() => this.lockResource.insert({
|
|
4775
|
+
id: lockId,
|
|
4776
|
+
lockedAt: Date.now(),
|
|
4777
|
+
workerId: process.pid ? String(process.pid) : "unknown"
|
|
4405
4778
|
})
|
|
4406
4779
|
);
|
|
4407
|
-
if (!
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
transactions.unshift({
|
|
4416
|
-
id: "__synthetic__",
|
|
4417
|
-
// Synthetic ID that we'll skip when marking as applied
|
|
4418
|
-
operation: "set",
|
|
4419
|
-
value: currentValue,
|
|
4420
|
-
timestamp: (/* @__PURE__ */ new Date(0)).toISOString()
|
|
4421
|
-
// Very old timestamp to ensure it's first
|
|
4422
|
-
});
|
|
4780
|
+
if (!lockAcquired) {
|
|
4781
|
+
if (this.config.verbose) {
|
|
4782
|
+
console.log(`[EventualConsistency] Lock for ${originalId} already held, skipping`);
|
|
4783
|
+
}
|
|
4784
|
+
const [recordOk, recordErr, record] = await tryFn(
|
|
4785
|
+
() => this.targetResource.get(originalId)
|
|
4786
|
+
);
|
|
4787
|
+
return recordOk && record ? record[this.config.field] || 0 : 0;
|
|
4423
4788
|
}
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4789
|
+
try {
|
|
4790
|
+
const [recordOk, recordErr, record] = await tryFn(
|
|
4791
|
+
() => this.targetResource.get(originalId)
|
|
4792
|
+
);
|
|
4793
|
+
const currentValue = recordOk && record ? record[this.config.field] || 0 : 0;
|
|
4794
|
+
const [ok, err, transactions] = await tryFn(
|
|
4795
|
+
() => this.transactionResource.query({
|
|
4796
|
+
originalId,
|
|
4797
|
+
applied: false
|
|
4798
|
+
})
|
|
4799
|
+
);
|
|
4800
|
+
if (!ok || !transactions || transactions.length === 0) {
|
|
4801
|
+
return currentValue;
|
|
4802
|
+
}
|
|
4803
|
+
transactions.sort(
|
|
4804
|
+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
4805
|
+
);
|
|
4806
|
+
const hasSetOperation = transactions.some((t) => t.operation === "set");
|
|
4807
|
+
if (currentValue !== 0 && !hasSetOperation) {
|
|
4808
|
+
transactions.unshift(this._createSyntheticSetTransaction(currentValue));
|
|
4809
|
+
}
|
|
4810
|
+
const consolidatedValue = this.config.reducer(transactions);
|
|
4811
|
+
const [updateOk, updateErr] = await tryFn(
|
|
4812
|
+
() => this.targetResource.update(originalId, {
|
|
4813
|
+
[this.config.field]: consolidatedValue
|
|
4814
|
+
})
|
|
4815
|
+
);
|
|
4816
|
+
if (updateOk) {
|
|
4817
|
+
const transactionsToUpdate = transactions.filter((txn) => txn.id !== "__synthetic__");
|
|
4818
|
+
const { results, errors } = await PromisePool.for(transactionsToUpdate).withConcurrency(10).process(async (txn) => {
|
|
4819
|
+
const [ok2, err2] = await tryFn(
|
|
4820
|
+
() => this.transactionResource.update(txn.id, { applied: true })
|
|
4821
|
+
);
|
|
4822
|
+
if (!ok2 && this.config.verbose) {
|
|
4823
|
+
console.warn(`[EventualConsistency] Failed to mark transaction ${txn.id} as applied:`, err2?.message);
|
|
4824
|
+
}
|
|
4825
|
+
return ok2;
|
|
4826
|
+
});
|
|
4827
|
+
if (errors && errors.length > 0 && this.config.verbose) {
|
|
4828
|
+
console.warn(`[EventualConsistency] ${errors.length} transactions failed to mark as applied`);
|
|
4436
4829
|
}
|
|
4437
4830
|
}
|
|
4831
|
+
return consolidatedValue;
|
|
4832
|
+
} finally {
|
|
4833
|
+
const [lockReleased, lockReleaseErr] = await tryFn(() => this.lockResource.delete(lockId));
|
|
4834
|
+
if (!lockReleased && this.config.verbose) {
|
|
4835
|
+
console.warn(`[EventualConsistency] Failed to release lock ${lockId}:`, lockReleaseErr?.message);
|
|
4836
|
+
}
|
|
4438
4837
|
}
|
|
4439
|
-
return consolidatedValue;
|
|
4440
4838
|
}
|
|
4441
4839
|
async getConsolidatedValue(originalId, options = {}) {
|
|
4442
4840
|
const includeApplied = options.includeApplied || false;
|
|
@@ -4450,11 +4848,11 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4450
4848
|
() => this.transactionResource.query(query)
|
|
4451
4849
|
);
|
|
4452
4850
|
if (!ok || !transactions || transactions.length === 0) {
|
|
4453
|
-
const [
|
|
4851
|
+
const [recordOk2, recordErr2, record2] = await tryFn(
|
|
4454
4852
|
() => this.targetResource.get(originalId)
|
|
4455
4853
|
);
|
|
4456
|
-
if (
|
|
4457
|
-
return
|
|
4854
|
+
if (recordOk2 && record2) {
|
|
4855
|
+
return record2[this.config.field] || 0;
|
|
4458
4856
|
}
|
|
4459
4857
|
return 0;
|
|
4460
4858
|
}
|
|
@@ -4467,6 +4865,14 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4467
4865
|
return true;
|
|
4468
4866
|
});
|
|
4469
4867
|
}
|
|
4868
|
+
const [recordOk, recordErr, record] = await tryFn(
|
|
4869
|
+
() => this.targetResource.get(originalId)
|
|
4870
|
+
);
|
|
4871
|
+
const currentValue = recordOk && record ? record[this.config.field] || 0 : 0;
|
|
4872
|
+
const hasSetOperation = filtered.some((t) => t.operation === "set");
|
|
4873
|
+
if (currentValue !== 0 && !hasSetOperation) {
|
|
4874
|
+
filtered.unshift(this._createSyntheticSetTransaction(currentValue));
|
|
4875
|
+
}
|
|
4470
4876
|
filtered.sort(
|
|
4471
4877
|
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
4472
4878
|
);
|
|
@@ -4501,6 +4907,133 @@ class EventualConsistencyPlugin extends Plugin {
|
|
|
4501
4907
|
}
|
|
4502
4908
|
return stats;
|
|
4503
4909
|
}
|
|
4910
|
+
/**
|
|
4911
|
+
* Clean up stale locks that exceed the configured timeout
|
|
4912
|
+
* Uses distributed locking to prevent multiple containers from cleaning simultaneously
|
|
4913
|
+
*/
|
|
4914
|
+
async cleanupStaleLocks() {
|
|
4915
|
+
const now = Date.now();
|
|
4916
|
+
const lockTimeoutMs = this.config.lockTimeout * 1e3;
|
|
4917
|
+
const cutoffTime = now - lockTimeoutMs;
|
|
4918
|
+
const cleanupLockId = `lock-cleanup-${this.config.resource}-${this.config.field}`;
|
|
4919
|
+
const [lockAcquired] = await tryFn(
|
|
4920
|
+
() => this.lockResource.insert({
|
|
4921
|
+
id: cleanupLockId,
|
|
4922
|
+
lockedAt: Date.now(),
|
|
4923
|
+
workerId: process.pid ? String(process.pid) : "unknown"
|
|
4924
|
+
})
|
|
4925
|
+
);
|
|
4926
|
+
if (!lockAcquired) {
|
|
4927
|
+
if (this.config.verbose) {
|
|
4928
|
+
console.log(`[EventualConsistency] Lock cleanup already running in another container`);
|
|
4929
|
+
}
|
|
4930
|
+
return;
|
|
4931
|
+
}
|
|
4932
|
+
try {
|
|
4933
|
+
const [ok, err, locks] = await tryFn(() => this.lockResource.list());
|
|
4934
|
+
if (!ok || !locks || locks.length === 0) return;
|
|
4935
|
+
const staleLocks = locks.filter(
|
|
4936
|
+
(lock) => lock.id !== cleanupLockId && lock.lockedAt < cutoffTime
|
|
4937
|
+
);
|
|
4938
|
+
if (staleLocks.length === 0) return;
|
|
4939
|
+
if (this.config.verbose) {
|
|
4940
|
+
console.log(`[EventualConsistency] Cleaning up ${staleLocks.length} stale locks`);
|
|
4941
|
+
}
|
|
4942
|
+
const { results, errors } = await PromisePool.for(staleLocks).withConcurrency(5).process(async (lock) => {
|
|
4943
|
+
const [deleted] = await tryFn(() => this.lockResource.delete(lock.id));
|
|
4944
|
+
return deleted;
|
|
4945
|
+
});
|
|
4946
|
+
if (errors && errors.length > 0 && this.config.verbose) {
|
|
4947
|
+
console.warn(`[EventualConsistency] ${errors.length} stale locks failed to delete`);
|
|
4948
|
+
}
|
|
4949
|
+
} catch (error) {
|
|
4950
|
+
if (this.config.verbose) {
|
|
4951
|
+
console.warn(`[EventualConsistency] Error cleaning up stale locks:`, error.message);
|
|
4952
|
+
}
|
|
4953
|
+
} finally {
|
|
4954
|
+
await tryFn(() => this.lockResource.delete(cleanupLockId));
|
|
4955
|
+
}
|
|
4956
|
+
}
|
|
4957
|
+
/**
|
|
4958
|
+
* Start garbage collection timer for old applied transactions
|
|
4959
|
+
*/
|
|
4960
|
+
startGarbageCollectionTimer() {
|
|
4961
|
+
const gcIntervalMs = this.config.gcInterval * 1e3;
|
|
4962
|
+
this.gcTimer = setInterval(async () => {
|
|
4963
|
+
await this.runGarbageCollection();
|
|
4964
|
+
}, gcIntervalMs);
|
|
4965
|
+
}
|
|
4966
|
+
/**
|
|
4967
|
+
* Delete old applied transactions based on retention policy
|
|
4968
|
+
* Uses distributed locking to prevent multiple containers from running GC simultaneously
|
|
4969
|
+
*/
|
|
4970
|
+
async runGarbageCollection() {
|
|
4971
|
+
const gcLockId = `lock-gc-${this.config.resource}-${this.config.field}`;
|
|
4972
|
+
const [lockAcquired] = await tryFn(
|
|
4973
|
+
() => this.lockResource.insert({
|
|
4974
|
+
id: gcLockId,
|
|
4975
|
+
lockedAt: Date.now(),
|
|
4976
|
+
workerId: process.pid ? String(process.pid) : "unknown"
|
|
4977
|
+
})
|
|
4978
|
+
);
|
|
4979
|
+
if (!lockAcquired) {
|
|
4980
|
+
if (this.config.verbose) {
|
|
4981
|
+
console.log(`[EventualConsistency] GC already running in another container`);
|
|
4982
|
+
}
|
|
4983
|
+
return;
|
|
4984
|
+
}
|
|
4985
|
+
try {
|
|
4986
|
+
const now = Date.now();
|
|
4987
|
+
const retentionMs = this.config.transactionRetention * 24 * 60 * 60 * 1e3;
|
|
4988
|
+
const cutoffDate = new Date(now - retentionMs);
|
|
4989
|
+
const cutoffIso = cutoffDate.toISOString();
|
|
4990
|
+
if (this.config.verbose) {
|
|
4991
|
+
console.log(`[EventualConsistency] Running GC for transactions older than ${cutoffIso} (${this.config.transactionRetention} days)`);
|
|
4992
|
+
}
|
|
4993
|
+
const cutoffMonth = cutoffDate.toISOString().substring(0, 7);
|
|
4994
|
+
const [ok, err, oldTransactions] = await tryFn(
|
|
4995
|
+
() => this.transactionResource.query({
|
|
4996
|
+
applied: true,
|
|
4997
|
+
timestamp: { "<": cutoffIso }
|
|
4998
|
+
})
|
|
4999
|
+
);
|
|
5000
|
+
if (!ok) {
|
|
5001
|
+
if (this.config.verbose) {
|
|
5002
|
+
console.warn(`[EventualConsistency] GC failed to query transactions:`, err?.message);
|
|
5003
|
+
}
|
|
5004
|
+
return;
|
|
5005
|
+
}
|
|
5006
|
+
if (!oldTransactions || oldTransactions.length === 0) {
|
|
5007
|
+
if (this.config.verbose) {
|
|
5008
|
+
console.log(`[EventualConsistency] No old transactions to clean up`);
|
|
5009
|
+
}
|
|
5010
|
+
return;
|
|
5011
|
+
}
|
|
5012
|
+
if (this.config.verbose) {
|
|
5013
|
+
console.log(`[EventualConsistency] Deleting ${oldTransactions.length} old transactions`);
|
|
5014
|
+
}
|
|
5015
|
+
const { results, errors } = await PromisePool.for(oldTransactions).withConcurrency(10).process(async (txn) => {
|
|
5016
|
+
const [deleted] = await tryFn(() => this.transactionResource.delete(txn.id));
|
|
5017
|
+
return deleted;
|
|
5018
|
+
});
|
|
5019
|
+
if (this.config.verbose) {
|
|
5020
|
+
console.log(`[EventualConsistency] GC completed: ${results.length} deleted, ${errors.length} errors`);
|
|
5021
|
+
}
|
|
5022
|
+
this.emit("eventual-consistency.gc-completed", {
|
|
5023
|
+
resource: this.config.resource,
|
|
5024
|
+
field: this.config.field,
|
|
5025
|
+
deletedCount: results.length,
|
|
5026
|
+
errorCount: errors.length
|
|
5027
|
+
});
|
|
5028
|
+
} catch (error) {
|
|
5029
|
+
if (this.config.verbose) {
|
|
5030
|
+
console.warn(`[EventualConsistency] GC error:`, error.message);
|
|
5031
|
+
}
|
|
5032
|
+
this.emit("eventual-consistency.gc-error", error);
|
|
5033
|
+
} finally {
|
|
5034
|
+
await tryFn(() => this.lockResource.delete(gcLockId));
|
|
5035
|
+
}
|
|
5036
|
+
}
|
|
4504
5037
|
}
|
|
4505
5038
|
|
|
4506
5039
|
class FullTextPlugin extends Plugin {
|
|
@@ -4517,7 +5050,7 @@ class FullTextPlugin extends Plugin {
|
|
|
4517
5050
|
async setup(database) {
|
|
4518
5051
|
this.database = database;
|
|
4519
5052
|
const [ok, err, indexResource] = await tryFn(() => database.createResource({
|
|
4520
|
-
name: "
|
|
5053
|
+
name: "plg_fulltext_indexes",
|
|
4521
5054
|
attributes: {
|
|
4522
5055
|
id: "string|required",
|
|
4523
5056
|
resourceName: "string|required",
|
|
@@ -4576,7 +5109,7 @@ class FullTextPlugin extends Plugin {
|
|
|
4576
5109
|
}
|
|
4577
5110
|
installDatabaseHooks() {
|
|
4578
5111
|
this.database.addHook("afterCreateResource", (resource) => {
|
|
4579
|
-
if (resource.name !== "
|
|
5112
|
+
if (resource.name !== "plg_fulltext_indexes") {
|
|
4580
5113
|
this.installResourceHooks(resource);
|
|
4581
5114
|
}
|
|
4582
5115
|
});
|
|
@@ -4590,14 +5123,14 @@ class FullTextPlugin extends Plugin {
|
|
|
4590
5123
|
}
|
|
4591
5124
|
this.database.plugins.fulltext = this;
|
|
4592
5125
|
for (const resource of Object.values(this.database.resources)) {
|
|
4593
|
-
if (resource.name === "
|
|
5126
|
+
if (resource.name === "plg_fulltext_indexes") continue;
|
|
4594
5127
|
this.installResourceHooks(resource);
|
|
4595
5128
|
}
|
|
4596
5129
|
if (!this.database._fulltextProxyInstalled) {
|
|
4597
5130
|
this.database._previousCreateResourceForFullText = this.database.createResource;
|
|
4598
5131
|
this.database.createResource = async function(...args) {
|
|
4599
5132
|
const resource = await this._previousCreateResourceForFullText(...args);
|
|
4600
|
-
if (this.plugins?.fulltext && resource.name !== "
|
|
5133
|
+
if (this.plugins?.fulltext && resource.name !== "plg_fulltext_indexes") {
|
|
4601
5134
|
this.plugins.fulltext.installResourceHooks(resource);
|
|
4602
5135
|
}
|
|
4603
5136
|
return resource;
|
|
@@ -4605,7 +5138,7 @@ class FullTextPlugin extends Plugin {
|
|
|
4605
5138
|
this.database._fulltextProxyInstalled = true;
|
|
4606
5139
|
}
|
|
4607
5140
|
for (const resource of Object.values(this.database.resources)) {
|
|
4608
|
-
if (resource.name !== "
|
|
5141
|
+
if (resource.name !== "plg_fulltext_indexes") {
|
|
4609
5142
|
this.installResourceHooks(resource);
|
|
4610
5143
|
}
|
|
4611
5144
|
}
|
|
@@ -4848,7 +5381,7 @@ class FullTextPlugin extends Plugin {
|
|
|
4848
5381
|
return this._rebuildAllIndexesInternal();
|
|
4849
5382
|
}
|
|
4850
5383
|
async _rebuildAllIndexesInternal() {
|
|
4851
|
-
const resourceNames = Object.keys(this.database.resources).filter((name) => name !== "
|
|
5384
|
+
const resourceNames = Object.keys(this.database.resources).filter((name) => name !== "plg_fulltext_indexes");
|
|
4852
5385
|
for (const resourceName of resourceNames) {
|
|
4853
5386
|
const [ok, err] = await tryFn(() => this.rebuildIndex(resourceName));
|
|
4854
5387
|
}
|
|
@@ -4900,7 +5433,7 @@ class MetricsPlugin extends Plugin {
|
|
|
4900
5433
|
if (typeof process !== "undefined" && process.env.NODE_ENV === "test") return;
|
|
4901
5434
|
const [ok, err] = await tryFn(async () => {
|
|
4902
5435
|
const [ok1, err1, metricsResource] = await tryFn(() => database.createResource({
|
|
4903
|
-
name: "
|
|
5436
|
+
name: "plg_metrics",
|
|
4904
5437
|
attributes: {
|
|
4905
5438
|
id: "string|required",
|
|
4906
5439
|
type: "string|required",
|
|
@@ -4915,9 +5448,9 @@ class MetricsPlugin extends Plugin {
|
|
|
4915
5448
|
metadata: "json"
|
|
4916
5449
|
}
|
|
4917
5450
|
}));
|
|
4918
|
-
this.metricsResource = ok1 ? metricsResource : database.resources.
|
|
5451
|
+
this.metricsResource = ok1 ? metricsResource : database.resources.plg_metrics;
|
|
4919
5452
|
const [ok2, err2, errorsResource] = await tryFn(() => database.createResource({
|
|
4920
|
-
name: "
|
|
5453
|
+
name: "plg_error_logs",
|
|
4921
5454
|
attributes: {
|
|
4922
5455
|
id: "string|required",
|
|
4923
5456
|
resourceName: "string|required",
|
|
@@ -4927,9 +5460,9 @@ class MetricsPlugin extends Plugin {
|
|
|
4927
5460
|
metadata: "json"
|
|
4928
5461
|
}
|
|
4929
5462
|
}));
|
|
4930
|
-
this.errorsResource = ok2 ? errorsResource : database.resources.
|
|
5463
|
+
this.errorsResource = ok2 ? errorsResource : database.resources.plg_error_logs;
|
|
4931
5464
|
const [ok3, err3, performanceResource] = await tryFn(() => database.createResource({
|
|
4932
|
-
name: "
|
|
5465
|
+
name: "plg_performance_logs",
|
|
4933
5466
|
attributes: {
|
|
4934
5467
|
id: "string|required",
|
|
4935
5468
|
resourceName: "string|required",
|
|
@@ -4939,12 +5472,12 @@ class MetricsPlugin extends Plugin {
|
|
|
4939
5472
|
metadata: "json"
|
|
4940
5473
|
}
|
|
4941
5474
|
}));
|
|
4942
|
-
this.performanceResource = ok3 ? performanceResource : database.resources.
|
|
5475
|
+
this.performanceResource = ok3 ? performanceResource : database.resources.plg_performance_logs;
|
|
4943
5476
|
});
|
|
4944
5477
|
if (!ok) {
|
|
4945
|
-
this.metricsResource = database.resources.
|
|
4946
|
-
this.errorsResource = database.resources.
|
|
4947
|
-
this.performanceResource = database.resources.
|
|
5478
|
+
this.metricsResource = database.resources.plg_metrics;
|
|
5479
|
+
this.errorsResource = database.resources.plg_error_logs;
|
|
5480
|
+
this.performanceResource = database.resources.plg_performance_logs;
|
|
4948
5481
|
}
|
|
4949
5482
|
this.installDatabaseHooks();
|
|
4950
5483
|
this.installMetricsHooks();
|
|
@@ -4963,7 +5496,7 @@ class MetricsPlugin extends Plugin {
|
|
|
4963
5496
|
}
|
|
4964
5497
|
installDatabaseHooks() {
|
|
4965
5498
|
this.database.addHook("afterCreateResource", (resource) => {
|
|
4966
|
-
if (resource.name !== "
|
|
5499
|
+
if (resource.name !== "plg_metrics" && resource.name !== "plg_error_logs" && resource.name !== "plg_performance_logs") {
|
|
4967
5500
|
this.installResourceHooks(resource);
|
|
4968
5501
|
}
|
|
4969
5502
|
});
|
|
@@ -4973,7 +5506,7 @@ class MetricsPlugin extends Plugin {
|
|
|
4973
5506
|
}
|
|
4974
5507
|
installMetricsHooks() {
|
|
4975
5508
|
for (const resource of Object.values(this.database.resources)) {
|
|
4976
|
-
if (["
|
|
5509
|
+
if (["plg_metrics", "plg_error_logs", "plg_performance_logs"].includes(resource.name)) {
|
|
4977
5510
|
continue;
|
|
4978
5511
|
}
|
|
4979
5512
|
this.installResourceHooks(resource);
|
|
@@ -4981,7 +5514,7 @@ class MetricsPlugin extends Plugin {
|
|
|
4981
5514
|
this.database._createResource = this.database.createResource;
|
|
4982
5515
|
this.database.createResource = async function(...args) {
|
|
4983
5516
|
const resource = await this._createResource(...args);
|
|
4984
|
-
if (this.plugins?.metrics && !["
|
|
5517
|
+
if (this.plugins?.metrics && !["plg_metrics", "plg_error_logs", "plg_performance_logs"].includes(resource.name)) {
|
|
4985
5518
|
this.plugins.metrics.installResourceHooks(resource);
|
|
4986
5519
|
}
|
|
4987
5520
|
return resource;
|
|
@@ -6352,7 +6885,7 @@ class Client extends EventEmitter {
|
|
|
6352
6885
|
this.emit("command.response", command.constructor.name, response, command.input);
|
|
6353
6886
|
return response;
|
|
6354
6887
|
}
|
|
6355
|
-
async putObject({ key, metadata, contentType, body, contentEncoding, contentLength }) {
|
|
6888
|
+
async putObject({ key, metadata, contentType, body, contentEncoding, contentLength, ifMatch }) {
|
|
6356
6889
|
const keyPrefix = typeof this.config.keyPrefix === "string" ? this.config.keyPrefix : "";
|
|
6357
6890
|
keyPrefix ? path.join(keyPrefix, key) : key;
|
|
6358
6891
|
const stringMetadata = {};
|
|
@@ -6372,6 +6905,7 @@ class Client extends EventEmitter {
|
|
|
6372
6905
|
if (contentType !== void 0) options.ContentType = contentType;
|
|
6373
6906
|
if (contentEncoding !== void 0) options.ContentEncoding = contentEncoding;
|
|
6374
6907
|
if (contentLength !== void 0) options.ContentLength = contentLength;
|
|
6908
|
+
if (ifMatch !== void 0) options.IfMatch = ifMatch;
|
|
6375
6909
|
let response, error;
|
|
6376
6910
|
try {
|
|
6377
6911
|
response = await this.sendCommand(new PutObjectCommand(options));
|
|
@@ -8535,6 +9069,7 @@ ${errorDetails}`,
|
|
|
8535
9069
|
data._lastModified = request.LastModified;
|
|
8536
9070
|
data._hasContent = request.ContentLength > 0;
|
|
8537
9071
|
data._mimeType = request.ContentType || null;
|
|
9072
|
+
data._etag = request.ETag;
|
|
8538
9073
|
data._v = objectVersion;
|
|
8539
9074
|
if (request.VersionId) data._versionId = request.VersionId;
|
|
8540
9075
|
if (request.Expiration) data._expiresAt = request.Expiration;
|
|
@@ -8747,65 +9282,231 @@ ${errorDetails}`,
|
|
|
8747
9282
|
}
|
|
8748
9283
|
}
|
|
8749
9284
|
/**
|
|
8750
|
-
*
|
|
9285
|
+
* Update with conditional check (If-Match ETag)
|
|
8751
9286
|
* @param {string} id - Resource ID
|
|
8752
|
-
* @
|
|
9287
|
+
* @param {Object} attributes - Attributes to update
|
|
9288
|
+
* @param {Object} options - Options including ifMatch (ETag)
|
|
9289
|
+
* @returns {Promise<Object>} { success: boolean, data?: Object, etag?: string, error?: string }
|
|
8753
9290
|
* @example
|
|
8754
|
-
* await resource.
|
|
9291
|
+
* const msg = await resource.get('msg-123');
|
|
9292
|
+
* const result = await resource.updateConditional('msg-123', { status: 'processing' }, { ifMatch: msg._etag });
|
|
9293
|
+
* if (!result.success) {
|
|
9294
|
+
* console.log('Update failed - object was modified by another process');
|
|
9295
|
+
* }
|
|
8755
9296
|
*/
|
|
8756
|
-
async
|
|
9297
|
+
async updateConditional(id, attributes, options = {}) {
|
|
8757
9298
|
if (isEmpty(id)) {
|
|
8758
9299
|
throw new Error("id cannot be empty");
|
|
8759
9300
|
}
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
|
|
8763
|
-
if (ok) {
|
|
8764
|
-
objectData = data;
|
|
8765
|
-
} else {
|
|
8766
|
-
objectData = { id };
|
|
8767
|
-
deleteError = err;
|
|
9301
|
+
const { ifMatch } = options;
|
|
9302
|
+
if (!ifMatch) {
|
|
9303
|
+
throw new Error("updateConditional requires ifMatch option with ETag value");
|
|
8768
9304
|
}
|
|
8769
|
-
await this.
|
|
8770
|
-
|
|
8771
|
-
|
|
8772
|
-
|
|
8773
|
-
|
|
8774
|
-
|
|
8775
|
-
|
|
9305
|
+
const exists = await this.exists(id);
|
|
9306
|
+
if (!exists) {
|
|
9307
|
+
return {
|
|
9308
|
+
success: false,
|
|
9309
|
+
error: `Resource with id '${id}' does not exist`
|
|
9310
|
+
};
|
|
9311
|
+
}
|
|
9312
|
+
const originalData = await this.get(id);
|
|
9313
|
+
const attributesClone = cloneDeep(attributes);
|
|
9314
|
+
let mergedData = cloneDeep(originalData);
|
|
9315
|
+
for (const [key2, value] of Object.entries(attributesClone)) {
|
|
9316
|
+
if (key2.includes(".")) {
|
|
9317
|
+
let ref = mergedData;
|
|
9318
|
+
const parts = key2.split(".");
|
|
9319
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
9320
|
+
if (typeof ref[parts[i]] !== "object" || ref[parts[i]] === null) {
|
|
9321
|
+
ref[parts[i]] = {};
|
|
9322
|
+
}
|
|
9323
|
+
ref = ref[parts[i]];
|
|
9324
|
+
}
|
|
9325
|
+
ref[parts[parts.length - 1]] = cloneDeep(value);
|
|
9326
|
+
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
9327
|
+
mergedData[key2] = merge({}, mergedData[key2], value);
|
|
9328
|
+
} else {
|
|
9329
|
+
mergedData[key2] = cloneDeep(value);
|
|
9330
|
+
}
|
|
9331
|
+
}
|
|
9332
|
+
if (this.config.timestamps) {
|
|
9333
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
9334
|
+
mergedData.updatedAt = now;
|
|
9335
|
+
if (!mergedData.metadata) mergedData.metadata = {};
|
|
9336
|
+
mergedData.metadata.updatedAt = now;
|
|
9337
|
+
}
|
|
9338
|
+
const preProcessedData = await this.executeHooks("beforeUpdate", cloneDeep(mergedData));
|
|
9339
|
+
const completeData = { ...originalData, ...preProcessedData, id };
|
|
9340
|
+
const { isValid, errors, data } = await this.validate(cloneDeep(completeData));
|
|
9341
|
+
if (!isValid) {
|
|
9342
|
+
return {
|
|
9343
|
+
success: false,
|
|
9344
|
+
error: "Validation failed: " + (errors && errors.length ? JSON.stringify(errors) : "unknown"),
|
|
9345
|
+
validationErrors: errors
|
|
9346
|
+
};
|
|
9347
|
+
}
|
|
9348
|
+
const { id: validatedId, ...validatedAttributes } = data;
|
|
9349
|
+
const mappedData = await this.schema.mapper(validatedAttributes);
|
|
9350
|
+
mappedData._v = String(this.version);
|
|
9351
|
+
const behaviorImpl = getBehavior(this.behavior);
|
|
9352
|
+
const { mappedData: processedMetadata, body } = await behaviorImpl.handleUpdate({
|
|
9353
|
+
resource: this,
|
|
9354
|
+
id,
|
|
9355
|
+
data: validatedAttributes,
|
|
9356
|
+
mappedData,
|
|
9357
|
+
originalData: { ...attributesClone, id }
|
|
8776
9358
|
});
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
|
|
8784
|
-
|
|
9359
|
+
const key = this.getResourceKey(id);
|
|
9360
|
+
let existingContentType = void 0;
|
|
9361
|
+
let finalBody = body;
|
|
9362
|
+
if (body === "" && this.behavior !== "body-overflow") {
|
|
9363
|
+
const [ok2, err2, existingObject] = await tryFn(() => this.client.getObject(key));
|
|
9364
|
+
if (ok2 && existingObject.ContentLength > 0) {
|
|
9365
|
+
const existingBodyBuffer = Buffer.from(await existingObject.Body.transformToByteArray());
|
|
9366
|
+
const existingBodyString = existingBodyBuffer.toString();
|
|
9367
|
+
const [okParse, errParse] = await tryFn(() => Promise.resolve(JSON.parse(existingBodyString)));
|
|
9368
|
+
if (!okParse) {
|
|
9369
|
+
finalBody = existingBodyBuffer;
|
|
9370
|
+
existingContentType = existingObject.ContentType;
|
|
9371
|
+
}
|
|
9372
|
+
}
|
|
8785
9373
|
}
|
|
8786
|
-
|
|
9374
|
+
let finalContentType = existingContentType;
|
|
9375
|
+
if (finalBody && finalBody !== "" && !finalContentType) {
|
|
9376
|
+
const [okParse, errParse] = await tryFn(() => Promise.resolve(JSON.parse(finalBody)));
|
|
9377
|
+
if (okParse) finalContentType = "application/json";
|
|
9378
|
+
}
|
|
9379
|
+
const [ok, err, response] = await tryFn(() => this.client.putObject({
|
|
8787
9380
|
key,
|
|
8788
|
-
|
|
8789
|
-
|
|
8790
|
-
|
|
9381
|
+
body: finalBody,
|
|
9382
|
+
contentType: finalContentType,
|
|
9383
|
+
metadata: processedMetadata,
|
|
9384
|
+
ifMatch
|
|
9385
|
+
// ← Conditional write with ETag
|
|
9386
|
+
}));
|
|
9387
|
+
if (!ok) {
|
|
9388
|
+
if (err.name === "PreconditionFailed" || err.$metadata?.httpStatusCode === 412) {
|
|
9389
|
+
return {
|
|
9390
|
+
success: false,
|
|
9391
|
+
error: "ETag mismatch - object was modified by another process"
|
|
9392
|
+
};
|
|
9393
|
+
}
|
|
9394
|
+
return {
|
|
9395
|
+
success: false,
|
|
9396
|
+
error: err.message || "Update failed"
|
|
9397
|
+
};
|
|
9398
|
+
}
|
|
9399
|
+
const updatedData = await this.composeFullObjectFromWrite({
|
|
9400
|
+
id,
|
|
9401
|
+
metadata: processedMetadata,
|
|
9402
|
+
body: finalBody,
|
|
9403
|
+
behavior: this.behavior
|
|
8791
9404
|
});
|
|
9405
|
+
const oldData = { ...originalData, id };
|
|
9406
|
+
const newData = { ...validatedAttributes, id };
|
|
8792
9407
|
if (this.config.asyncPartitions && this.config.partitions && Object.keys(this.config.partitions).length > 0) {
|
|
8793
9408
|
setImmediate(() => {
|
|
8794
|
-
this.
|
|
9409
|
+
this.handlePartitionReferenceUpdates(oldData, newData).catch((err2) => {
|
|
8795
9410
|
this.emit("partitionIndexError", {
|
|
8796
|
-
operation: "
|
|
9411
|
+
operation: "updateConditional",
|
|
8797
9412
|
id,
|
|
8798
|
-
error:
|
|
8799
|
-
message:
|
|
9413
|
+
error: err2,
|
|
9414
|
+
message: err2.message
|
|
8800
9415
|
});
|
|
8801
9416
|
});
|
|
8802
9417
|
});
|
|
8803
|
-
const nonPartitionHooks = this.hooks.
|
|
8804
|
-
(hook) => !hook.toString().includes("
|
|
9418
|
+
const nonPartitionHooks = this.hooks.afterUpdate.filter(
|
|
9419
|
+
(hook) => !hook.toString().includes("handlePartitionReferenceUpdates")
|
|
8805
9420
|
);
|
|
8806
|
-
let
|
|
9421
|
+
let finalResult = updatedData;
|
|
8807
9422
|
for (const hook of nonPartitionHooks) {
|
|
8808
|
-
|
|
9423
|
+
finalResult = await hook(finalResult);
|
|
9424
|
+
}
|
|
9425
|
+
this.emit("update", {
|
|
9426
|
+
...updatedData,
|
|
9427
|
+
$before: { ...originalData },
|
|
9428
|
+
$after: { ...finalResult }
|
|
9429
|
+
});
|
|
9430
|
+
return {
|
|
9431
|
+
success: true,
|
|
9432
|
+
data: finalResult,
|
|
9433
|
+
etag: response.ETag
|
|
9434
|
+
};
|
|
9435
|
+
} else {
|
|
9436
|
+
await this.handlePartitionReferenceUpdates(oldData, newData);
|
|
9437
|
+
const finalResult = await this.executeHooks("afterUpdate", updatedData);
|
|
9438
|
+
this.emit("update", {
|
|
9439
|
+
...updatedData,
|
|
9440
|
+
$before: { ...originalData },
|
|
9441
|
+
$after: { ...finalResult }
|
|
9442
|
+
});
|
|
9443
|
+
return {
|
|
9444
|
+
success: true,
|
|
9445
|
+
data: finalResult,
|
|
9446
|
+
etag: response.ETag
|
|
9447
|
+
};
|
|
9448
|
+
}
|
|
9449
|
+
}
|
|
9450
|
+
/**
|
|
9451
|
+
* Delete a resource object by ID
|
|
9452
|
+
* @param {string} id - Resource ID
|
|
9453
|
+
* @returns {Promise<Object>} S3 delete response
|
|
9454
|
+
* @example
|
|
9455
|
+
* await resource.delete('user-123');
|
|
9456
|
+
*/
|
|
9457
|
+
async delete(id) {
|
|
9458
|
+
if (isEmpty(id)) {
|
|
9459
|
+
throw new Error("id cannot be empty");
|
|
9460
|
+
}
|
|
9461
|
+
let objectData;
|
|
9462
|
+
let deleteError = null;
|
|
9463
|
+
const [ok, err, data] = await tryFn(() => this.get(id));
|
|
9464
|
+
if (ok) {
|
|
9465
|
+
objectData = data;
|
|
9466
|
+
} else {
|
|
9467
|
+
objectData = { id };
|
|
9468
|
+
deleteError = err;
|
|
9469
|
+
}
|
|
9470
|
+
await this.executeHooks("beforeDelete", objectData);
|
|
9471
|
+
const key = this.getResourceKey(id);
|
|
9472
|
+
const [ok2, err2, response] = await tryFn(() => this.client.deleteObject(key));
|
|
9473
|
+
this.emit("delete", {
|
|
9474
|
+
...objectData,
|
|
9475
|
+
$before: { ...objectData },
|
|
9476
|
+
$after: null
|
|
9477
|
+
});
|
|
9478
|
+
if (deleteError) {
|
|
9479
|
+
throw mapAwsError(deleteError, {
|
|
9480
|
+
bucket: this.client.config.bucket,
|
|
9481
|
+
key,
|
|
9482
|
+
resourceName: this.name,
|
|
9483
|
+
operation: "delete",
|
|
9484
|
+
id
|
|
9485
|
+
});
|
|
9486
|
+
}
|
|
9487
|
+
if (!ok2) throw mapAwsError(err2, {
|
|
9488
|
+
key,
|
|
9489
|
+
resourceName: this.name,
|
|
9490
|
+
operation: "delete",
|
|
9491
|
+
id
|
|
9492
|
+
});
|
|
9493
|
+
if (this.config.asyncPartitions && this.config.partitions && Object.keys(this.config.partitions).length > 0) {
|
|
9494
|
+
setImmediate(() => {
|
|
9495
|
+
this.deletePartitionReferences(objectData).catch((err3) => {
|
|
9496
|
+
this.emit("partitionIndexError", {
|
|
9497
|
+
operation: "delete",
|
|
9498
|
+
id,
|
|
9499
|
+
error: err3,
|
|
9500
|
+
message: err3.message
|
|
9501
|
+
});
|
|
9502
|
+
});
|
|
9503
|
+
});
|
|
9504
|
+
const nonPartitionHooks = this.hooks.afterDelete.filter(
|
|
9505
|
+
(hook) => !hook.toString().includes("deletePartitionReferences")
|
|
9506
|
+
);
|
|
9507
|
+
let afterDeleteData = objectData;
|
|
9508
|
+
for (const hook of nonPartitionHooks) {
|
|
9509
|
+
afterDeleteData = await hook(afterDeleteData);
|
|
8809
9510
|
}
|
|
8810
9511
|
return response;
|
|
8811
9512
|
} else {
|
|
@@ -10153,7 +10854,7 @@ class Database extends EventEmitter {
|
|
|
10153
10854
|
this.id = idGenerator(7);
|
|
10154
10855
|
this.version = "1";
|
|
10155
10856
|
this.s3dbVersion = (() => {
|
|
10156
|
-
const [ok, err, version] = tryFn(() => true ? "10.0.
|
|
10857
|
+
const [ok, err, version] = tryFn(() => true ? "10.0.1" : "latest");
|
|
10157
10858
|
return ok ? version : "latest";
|
|
10158
10859
|
})();
|
|
10159
10860
|
this.resources = {};
|
|
@@ -11406,16 +12107,20 @@ class S3dbReplicator extends BaseReplicator {
|
|
|
11406
12107
|
return resource;
|
|
11407
12108
|
}
|
|
11408
12109
|
_getDestResourceObj(resource) {
|
|
11409
|
-
const
|
|
12110
|
+
const db = this.targetDatabase || this.client;
|
|
12111
|
+
const available = Object.keys(db.resources || {});
|
|
11410
12112
|
const norm = normalizeResourceName$1(resource);
|
|
11411
12113
|
const found = available.find((r) => normalizeResourceName$1(r) === norm);
|
|
11412
12114
|
if (!found) {
|
|
11413
12115
|
throw new Error(`[S3dbReplicator] Destination resource not found: ${resource}. Available: ${available.join(", ")}`);
|
|
11414
12116
|
}
|
|
11415
|
-
return
|
|
12117
|
+
return db.resources[found];
|
|
11416
12118
|
}
|
|
11417
12119
|
async replicateBatch(resourceName, records) {
|
|
11418
|
-
if (
|
|
12120
|
+
if (this.enabled === false) {
|
|
12121
|
+
return { skipped: true, reason: "replicator_disabled" };
|
|
12122
|
+
}
|
|
12123
|
+
if (!this.shouldReplicateResource(resourceName)) {
|
|
11419
12124
|
return { skipped: true, reason: "resource_not_included" };
|
|
11420
12125
|
}
|
|
11421
12126
|
const results = [];
|
|
@@ -11526,11 +12231,12 @@ class SqsReplicator extends BaseReplicator {
|
|
|
11526
12231
|
this.client = client;
|
|
11527
12232
|
this.queueUrl = config.queueUrl;
|
|
11528
12233
|
this.queues = config.queues || {};
|
|
11529
|
-
this.defaultQueue = config.defaultQueue || config.defaultQueueUrl || config.queueUrlDefault;
|
|
12234
|
+
this.defaultQueue = config.defaultQueue || config.defaultQueueUrl || config.queueUrlDefault || null;
|
|
11530
12235
|
this.region = config.region || "us-east-1";
|
|
11531
12236
|
this.sqsClient = client || null;
|
|
11532
12237
|
this.messageGroupId = config.messageGroupId;
|
|
11533
12238
|
this.deduplicationId = config.deduplicationId;
|
|
12239
|
+
this.resourceQueueMap = config.resourceQueueMap || null;
|
|
11534
12240
|
if (Array.isArray(resources)) {
|
|
11535
12241
|
this.resources = {};
|
|
11536
12242
|
for (const resource of resources) {
|
|
@@ -11661,7 +12367,10 @@ class SqsReplicator extends BaseReplicator {
|
|
|
11661
12367
|
}
|
|
11662
12368
|
}
|
|
11663
12369
|
async replicate(resource, operation, data, id, beforeData = null) {
|
|
11664
|
-
if (
|
|
12370
|
+
if (this.enabled === false) {
|
|
12371
|
+
return { skipped: true, reason: "replicator_disabled" };
|
|
12372
|
+
}
|
|
12373
|
+
if (!this.shouldReplicateResource(resource)) {
|
|
11665
12374
|
return { skipped: true, reason: "resource_not_included" };
|
|
11666
12375
|
}
|
|
11667
12376
|
const [ok, err, result] = await tryFn(async () => {
|
|
@@ -11705,7 +12414,10 @@ class SqsReplicator extends BaseReplicator {
|
|
|
11705
12414
|
return { success: false, error: err.message };
|
|
11706
12415
|
}
|
|
11707
12416
|
async replicateBatch(resource, records) {
|
|
11708
|
-
if (
|
|
12417
|
+
if (this.enabled === false) {
|
|
12418
|
+
return { skipped: true, reason: "replicator_disabled" };
|
|
12419
|
+
}
|
|
12420
|
+
if (!this.shouldReplicateResource(resource)) {
|
|
11709
12421
|
return { skipped: true, reason: "resource_not_included" };
|
|
11710
12422
|
}
|
|
11711
12423
|
const [ok, err, result] = await tryFn(async () => {
|
|
@@ -11859,22 +12571,23 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11859
12571
|
replicators: options.replicators || [],
|
|
11860
12572
|
logErrors: options.logErrors !== false,
|
|
11861
12573
|
replicatorLogResource: options.replicatorLogResource || "replicator_log",
|
|
12574
|
+
persistReplicatorLog: options.persistReplicatorLog || false,
|
|
11862
12575
|
enabled: options.enabled !== false,
|
|
11863
12576
|
batchSize: options.batchSize || 100,
|
|
11864
12577
|
maxRetries: options.maxRetries || 3,
|
|
11865
12578
|
timeout: options.timeout || 3e4,
|
|
11866
|
-
verbose: options.verbose || false
|
|
11867
|
-
...options
|
|
12579
|
+
verbose: options.verbose || false
|
|
11868
12580
|
};
|
|
11869
12581
|
this.replicators = [];
|
|
11870
12582
|
this.database = null;
|
|
11871
12583
|
this.eventListenersInstalled = /* @__PURE__ */ new Set();
|
|
11872
|
-
|
|
11873
|
-
|
|
11874
|
-
|
|
11875
|
-
|
|
11876
|
-
|
|
11877
|
-
|
|
12584
|
+
this.eventHandlers = /* @__PURE__ */ new Map();
|
|
12585
|
+
this.stats = {
|
|
12586
|
+
totalReplications: 0,
|
|
12587
|
+
totalErrors: 0,
|
|
12588
|
+
lastSync: null
|
|
12589
|
+
};
|
|
12590
|
+
this._afterCreateResourceHook = null;
|
|
11878
12591
|
}
|
|
11879
12592
|
// Helper to filter out internal S3DB fields
|
|
11880
12593
|
filterInternalFields(obj) {
|
|
@@ -11895,7 +12608,7 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11895
12608
|
if (!resource || this.eventListenersInstalled.has(resource.name) || resource.name === this.config.replicatorLogResource) {
|
|
11896
12609
|
return;
|
|
11897
12610
|
}
|
|
11898
|
-
|
|
12611
|
+
const insertHandler = async (data) => {
|
|
11899
12612
|
const [ok, error] = await tryFn(async () => {
|
|
11900
12613
|
const completeData = { ...data, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
11901
12614
|
await plugin.processReplicatorEvent("insert", resource.name, completeData.id, completeData);
|
|
@@ -11906,8 +12619,8 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11906
12619
|
}
|
|
11907
12620
|
this.emit("error", { operation: "insert", error: error.message, resource: resource.name });
|
|
11908
12621
|
}
|
|
11909
|
-
}
|
|
11910
|
-
|
|
12622
|
+
};
|
|
12623
|
+
const updateHandler = async (data, beforeData) => {
|
|
11911
12624
|
const [ok, error] = await tryFn(async () => {
|
|
11912
12625
|
const completeData = await plugin.getCompleteData(resource, data);
|
|
11913
12626
|
const dataWithTimestamp = { ...completeData, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
@@ -11919,8 +12632,8 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11919
12632
|
}
|
|
11920
12633
|
this.emit("error", { operation: "update", error: error.message, resource: resource.name });
|
|
11921
12634
|
}
|
|
11922
|
-
}
|
|
11923
|
-
|
|
12635
|
+
};
|
|
12636
|
+
const deleteHandler = async (data) => {
|
|
11924
12637
|
const [ok, error] = await tryFn(async () => {
|
|
11925
12638
|
await plugin.processReplicatorEvent("delete", resource.name, data.id, data);
|
|
11926
12639
|
});
|
|
@@ -11930,14 +12643,22 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11930
12643
|
}
|
|
11931
12644
|
this.emit("error", { operation: "delete", error: error.message, resource: resource.name });
|
|
11932
12645
|
}
|
|
11933
|
-
}
|
|
12646
|
+
};
|
|
12647
|
+
this.eventHandlers.set(resource.name, {
|
|
12648
|
+
insert: insertHandler,
|
|
12649
|
+
update: updateHandler,
|
|
12650
|
+
delete: deleteHandler
|
|
12651
|
+
});
|
|
12652
|
+
resource.on("insert", insertHandler);
|
|
12653
|
+
resource.on("update", updateHandler);
|
|
12654
|
+
resource.on("delete", deleteHandler);
|
|
11934
12655
|
this.eventListenersInstalled.add(resource.name);
|
|
11935
12656
|
}
|
|
11936
12657
|
async setup(database) {
|
|
11937
12658
|
this.database = database;
|
|
11938
12659
|
if (this.config.persistReplicatorLog) {
|
|
11939
12660
|
const [ok, err, logResource] = await tryFn(() => database.createResource({
|
|
11940
|
-
name: this.config.replicatorLogResource || "
|
|
12661
|
+
name: this.config.replicatorLogResource || "plg_replicator_logs",
|
|
11941
12662
|
attributes: {
|
|
11942
12663
|
id: "string|required",
|
|
11943
12664
|
resource: "string|required",
|
|
@@ -11951,13 +12672,13 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11951
12672
|
if (ok) {
|
|
11952
12673
|
this.replicatorLogResource = logResource;
|
|
11953
12674
|
} else {
|
|
11954
|
-
this.replicatorLogResource = database.resources[this.config.replicatorLogResource || "
|
|
12675
|
+
this.replicatorLogResource = database.resources[this.config.replicatorLogResource || "plg_replicator_logs"];
|
|
11955
12676
|
}
|
|
11956
12677
|
}
|
|
11957
12678
|
await this.initializeReplicators(database);
|
|
11958
12679
|
this.installDatabaseHooks();
|
|
11959
12680
|
for (const resource of Object.values(database.resources)) {
|
|
11960
|
-
if (resource.name !== (this.config.replicatorLogResource || "
|
|
12681
|
+
if (resource.name !== (this.config.replicatorLogResource || "plg_replicator_logs")) {
|
|
11961
12682
|
this.installEventListeners(resource, database, this);
|
|
11962
12683
|
}
|
|
11963
12684
|
}
|
|
@@ -11973,14 +12694,18 @@ class ReplicatorPlugin extends Plugin {
|
|
|
11973
12694
|
this.removeDatabaseHooks();
|
|
11974
12695
|
}
|
|
11975
12696
|
installDatabaseHooks() {
|
|
11976
|
-
this.
|
|
11977
|
-
if (resource.name !== (this.config.replicatorLogResource || "
|
|
12697
|
+
this._afterCreateResourceHook = (resource) => {
|
|
12698
|
+
if (resource.name !== (this.config.replicatorLogResource || "plg_replicator_logs")) {
|
|
11978
12699
|
this.installEventListeners(resource, this.database, this);
|
|
11979
12700
|
}
|
|
11980
|
-
}
|
|
12701
|
+
};
|
|
12702
|
+
this.database.addHook("afterCreateResource", this._afterCreateResourceHook);
|
|
11981
12703
|
}
|
|
11982
12704
|
removeDatabaseHooks() {
|
|
11983
|
-
|
|
12705
|
+
if (this._afterCreateResourceHook) {
|
|
12706
|
+
this.database.removeHook("afterCreateResource", this._afterCreateResourceHook);
|
|
12707
|
+
this._afterCreateResourceHook = null;
|
|
12708
|
+
}
|
|
11984
12709
|
}
|
|
11985
12710
|
createReplicator(driver, config, resources, client) {
|
|
11986
12711
|
return createReplicator(driver, config, resources, client);
|
|
@@ -12005,9 +12730,9 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12005
12730
|
async retryWithBackoff(operation, maxRetries = 3) {
|
|
12006
12731
|
let lastError;
|
|
12007
12732
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
12008
|
-
const [ok, error] = await tryFn(operation);
|
|
12733
|
+
const [ok, error, result] = await tryFn(operation);
|
|
12009
12734
|
if (ok) {
|
|
12010
|
-
return
|
|
12735
|
+
return result;
|
|
12011
12736
|
} else {
|
|
12012
12737
|
lastError = error;
|
|
12013
12738
|
if (this.config.verbose) {
|
|
@@ -12102,7 +12827,7 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12102
12827
|
});
|
|
12103
12828
|
return Promise.allSettled(promises);
|
|
12104
12829
|
}
|
|
12105
|
-
async
|
|
12830
|
+
async processReplicatorItem(item) {
|
|
12106
12831
|
const applicableReplicators = this.replicators.filter((replicator) => {
|
|
12107
12832
|
const should = replicator.shouldReplicateResource && replicator.shouldReplicateResource(item.resourceName, item.operation);
|
|
12108
12833
|
return should;
|
|
@@ -12162,12 +12887,9 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12162
12887
|
});
|
|
12163
12888
|
return Promise.allSettled(promises);
|
|
12164
12889
|
}
|
|
12165
|
-
async
|
|
12890
|
+
async logReplicator(item) {
|
|
12166
12891
|
const logRes = this.replicatorLog || this.database.resources[normalizeResourceName(this.config.replicatorLogResource)];
|
|
12167
12892
|
if (!logRes) {
|
|
12168
|
-
if (this.database) {
|
|
12169
|
-
if (this.database.options && this.database.options.connectionString) ;
|
|
12170
|
-
}
|
|
12171
12893
|
this.emit("replicator.log.failed", { error: "replicator log resource not found", item });
|
|
12172
12894
|
return;
|
|
12173
12895
|
}
|
|
@@ -12189,7 +12911,7 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12189
12911
|
this.emit("replicator.log.failed", { error: err, item });
|
|
12190
12912
|
}
|
|
12191
12913
|
}
|
|
12192
|
-
async
|
|
12914
|
+
async updateReplicatorLog(logId, updates) {
|
|
12193
12915
|
if (!this.replicatorLog) return;
|
|
12194
12916
|
const [ok, err] = await tryFn(async () => {
|
|
12195
12917
|
await this.replicatorLog.update(logId, {
|
|
@@ -12202,7 +12924,7 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12202
12924
|
}
|
|
12203
12925
|
}
|
|
12204
12926
|
// Utility methods
|
|
12205
|
-
async
|
|
12927
|
+
async getReplicatorStats() {
|
|
12206
12928
|
const replicatorStats = await Promise.all(
|
|
12207
12929
|
this.replicators.map(async (replicator) => {
|
|
12208
12930
|
const status = await replicator.getStatus();
|
|
@@ -12216,15 +12938,11 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12216
12938
|
);
|
|
12217
12939
|
return {
|
|
12218
12940
|
replicators: replicatorStats,
|
|
12219
|
-
queue: {
|
|
12220
|
-
length: this.queue.length,
|
|
12221
|
-
isProcessing: this.isProcessing
|
|
12222
|
-
},
|
|
12223
12941
|
stats: this.stats,
|
|
12224
12942
|
lastSync: this.stats.lastSync
|
|
12225
12943
|
};
|
|
12226
12944
|
}
|
|
12227
|
-
async
|
|
12945
|
+
async getReplicatorLogs(options = {}) {
|
|
12228
12946
|
if (!this.replicatorLog) {
|
|
12229
12947
|
return [];
|
|
12230
12948
|
}
|
|
@@ -12235,32 +12953,32 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12235
12953
|
limit = 100,
|
|
12236
12954
|
offset = 0
|
|
12237
12955
|
} = options;
|
|
12238
|
-
|
|
12956
|
+
const filter = {};
|
|
12239
12957
|
if (resourceName) {
|
|
12240
|
-
|
|
12958
|
+
filter.resourceName = resourceName;
|
|
12241
12959
|
}
|
|
12242
12960
|
if (operation) {
|
|
12243
|
-
|
|
12961
|
+
filter.operation = operation;
|
|
12244
12962
|
}
|
|
12245
12963
|
if (status) {
|
|
12246
|
-
|
|
12964
|
+
filter.status = status;
|
|
12247
12965
|
}
|
|
12248
|
-
const logs = await this.replicatorLog.
|
|
12249
|
-
return logs
|
|
12966
|
+
const logs = await this.replicatorLog.query(filter, { limit, offset });
|
|
12967
|
+
return logs || [];
|
|
12250
12968
|
}
|
|
12251
|
-
async
|
|
12969
|
+
async retryFailedReplicators() {
|
|
12252
12970
|
if (!this.replicatorLog) {
|
|
12253
12971
|
return { retried: 0 };
|
|
12254
12972
|
}
|
|
12255
|
-
const failedLogs = await this.replicatorLog.
|
|
12973
|
+
const failedLogs = await this.replicatorLog.query({
|
|
12256
12974
|
status: "failed"
|
|
12257
12975
|
});
|
|
12258
12976
|
let retried = 0;
|
|
12259
|
-
for (const log of failedLogs) {
|
|
12977
|
+
for (const log of failedLogs || []) {
|
|
12260
12978
|
const [ok, err] = await tryFn(async () => {
|
|
12261
12979
|
await this.processReplicatorEvent(
|
|
12262
|
-
log.resourceName,
|
|
12263
12980
|
log.operation,
|
|
12981
|
+
log.resourceName,
|
|
12264
12982
|
log.recordId,
|
|
12265
12983
|
log.data
|
|
12266
12984
|
);
|
|
@@ -12278,13 +12996,21 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12278
12996
|
}
|
|
12279
12997
|
this.stats.lastSync = (/* @__PURE__ */ new Date()).toISOString();
|
|
12280
12998
|
for (const resourceName in this.database.resources) {
|
|
12281
|
-
if (normalizeResourceName(resourceName) === normalizeResourceName("
|
|
12999
|
+
if (normalizeResourceName(resourceName) === normalizeResourceName("plg_replicator_logs")) continue;
|
|
12282
13000
|
if (replicator.shouldReplicateResource(resourceName)) {
|
|
12283
13001
|
this.emit("replicator.sync.resource", { resourceName, replicatorId });
|
|
12284
13002
|
const resource = this.database.resources[resourceName];
|
|
12285
|
-
|
|
12286
|
-
|
|
12287
|
-
|
|
13003
|
+
let offset = 0;
|
|
13004
|
+
const pageSize = this.config.batchSize || 100;
|
|
13005
|
+
while (true) {
|
|
13006
|
+
const [ok, err, page] = await tryFn(() => resource.page({ offset, size: pageSize }));
|
|
13007
|
+
if (!ok || !page) break;
|
|
13008
|
+
const records = Array.isArray(page) ? page : page.items || [];
|
|
13009
|
+
if (records.length === 0) break;
|
|
13010
|
+
for (const record of records) {
|
|
13011
|
+
await replicator.replicate(resourceName, "insert", record, record.id);
|
|
13012
|
+
}
|
|
13013
|
+
offset += pageSize;
|
|
12288
13014
|
}
|
|
12289
13015
|
}
|
|
12290
13016
|
}
|
|
@@ -12312,9 +13038,21 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12312
13038
|
});
|
|
12313
13039
|
await Promise.allSettled(cleanupPromises);
|
|
12314
13040
|
}
|
|
13041
|
+
if (this.database && this.database.resources) {
|
|
13042
|
+
for (const resourceName of this.eventListenersInstalled) {
|
|
13043
|
+
const resource = this.database.resources[resourceName];
|
|
13044
|
+
const handlers = this.eventHandlers.get(resourceName);
|
|
13045
|
+
if (resource && handlers) {
|
|
13046
|
+
resource.off("insert", handlers.insert);
|
|
13047
|
+
resource.off("update", handlers.update);
|
|
13048
|
+
resource.off("delete", handlers.delete);
|
|
13049
|
+
}
|
|
13050
|
+
}
|
|
13051
|
+
}
|
|
12315
13052
|
this.replicators = [];
|
|
12316
13053
|
this.database = null;
|
|
12317
13054
|
this.eventListenersInstalled.clear();
|
|
13055
|
+
this.eventHandlers.clear();
|
|
12318
13056
|
this.removeAllListeners();
|
|
12319
13057
|
});
|
|
12320
13058
|
if (!ok) {
|
|
@@ -12328,6 +13066,543 @@ class ReplicatorPlugin extends Plugin {
|
|
|
12328
13066
|
}
|
|
12329
13067
|
}
|
|
12330
13068
|
|
|
13069
|
+
class S3QueuePlugin extends Plugin {
|
|
13070
|
+
constructor(options = {}) {
|
|
13071
|
+
super(options);
|
|
13072
|
+
if (!options.resource) {
|
|
13073
|
+
throw new Error('S3QueuePlugin requires "resource" option');
|
|
13074
|
+
}
|
|
13075
|
+
this.config = {
|
|
13076
|
+
resource: options.resource,
|
|
13077
|
+
visibilityTimeout: options.visibilityTimeout || 3e4,
|
|
13078
|
+
// 30 seconds
|
|
13079
|
+
pollInterval: options.pollInterval || 1e3,
|
|
13080
|
+
// 1 second
|
|
13081
|
+
maxAttempts: options.maxAttempts || 3,
|
|
13082
|
+
concurrency: options.concurrency || 1,
|
|
13083
|
+
deadLetterResource: options.deadLetterResource || null,
|
|
13084
|
+
autoStart: options.autoStart !== false,
|
|
13085
|
+
onMessage: options.onMessage,
|
|
13086
|
+
onError: options.onError,
|
|
13087
|
+
onComplete: options.onComplete,
|
|
13088
|
+
verbose: options.verbose || false,
|
|
13089
|
+
...options
|
|
13090
|
+
};
|
|
13091
|
+
this.queueResource = null;
|
|
13092
|
+
this.targetResource = null;
|
|
13093
|
+
this.deadLetterResourceObj = null;
|
|
13094
|
+
this.workers = [];
|
|
13095
|
+
this.isRunning = false;
|
|
13096
|
+
this.workerId = `worker-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
13097
|
+
this.processedCache = /* @__PURE__ */ new Map();
|
|
13098
|
+
this.cacheCleanupInterval = null;
|
|
13099
|
+
this.lockCleanupInterval = null;
|
|
13100
|
+
}
|
|
13101
|
+
async onSetup() {
|
|
13102
|
+
this.targetResource = this.database.resources[this.config.resource];
|
|
13103
|
+
if (!this.targetResource) {
|
|
13104
|
+
throw new Error(`S3QueuePlugin: resource '${this.config.resource}' not found`);
|
|
13105
|
+
}
|
|
13106
|
+
const queueName = `${this.config.resource}_queue`;
|
|
13107
|
+
const [ok, err] = await tryFn(
|
|
13108
|
+
() => this.database.createResource({
|
|
13109
|
+
name: queueName,
|
|
13110
|
+
attributes: {
|
|
13111
|
+
id: "string|required",
|
|
13112
|
+
originalId: "string|required",
|
|
13113
|
+
// ID do registro original
|
|
13114
|
+
status: "string|required",
|
|
13115
|
+
// pending/processing/completed/failed/dead
|
|
13116
|
+
visibleAt: "number|required",
|
|
13117
|
+
// Timestamp de visibilidade
|
|
13118
|
+
claimedBy: "string|optional",
|
|
13119
|
+
// Worker que claimed
|
|
13120
|
+
claimedAt: "number|optional",
|
|
13121
|
+
// Timestamp do claim
|
|
13122
|
+
attempts: "number|default:0",
|
|
13123
|
+
maxAttempts: "number|default:3",
|
|
13124
|
+
error: "string|optional",
|
|
13125
|
+
result: "json|optional",
|
|
13126
|
+
createdAt: "string|required",
|
|
13127
|
+
completedAt: "number|optional"
|
|
13128
|
+
},
|
|
13129
|
+
behavior: "body-overflow",
|
|
13130
|
+
timestamps: true,
|
|
13131
|
+
asyncPartitions: true,
|
|
13132
|
+
partitions: {
|
|
13133
|
+
byStatus: { fields: { status: "string" } },
|
|
13134
|
+
byDate: { fields: { createdAt: "string|maxlength:10" } }
|
|
13135
|
+
}
|
|
13136
|
+
})
|
|
13137
|
+
);
|
|
13138
|
+
if (!ok && !this.database.resources[queueName]) {
|
|
13139
|
+
throw new Error(`Failed to create queue resource: ${err?.message}`);
|
|
13140
|
+
}
|
|
13141
|
+
this.queueResource = this.database.resources[queueName];
|
|
13142
|
+
const lockName = `${this.config.resource}_locks`;
|
|
13143
|
+
const [okLock, errLock] = await tryFn(
|
|
13144
|
+
() => this.database.createResource({
|
|
13145
|
+
name: lockName,
|
|
13146
|
+
attributes: {
|
|
13147
|
+
id: "string|required",
|
|
13148
|
+
workerId: "string|required",
|
|
13149
|
+
timestamp: "number|required",
|
|
13150
|
+
ttl: "number|default:5000"
|
|
13151
|
+
},
|
|
13152
|
+
behavior: "body-overflow",
|
|
13153
|
+
timestamps: false
|
|
13154
|
+
})
|
|
13155
|
+
);
|
|
13156
|
+
if (okLock || this.database.resources[lockName]) {
|
|
13157
|
+
this.lockResource = this.database.resources[lockName];
|
|
13158
|
+
} else {
|
|
13159
|
+
this.lockResource = null;
|
|
13160
|
+
if (this.config.verbose) {
|
|
13161
|
+
console.log(`[S3QueuePlugin] Lock resource creation failed, locking disabled: ${errLock?.message}`);
|
|
13162
|
+
}
|
|
13163
|
+
}
|
|
13164
|
+
this.addHelperMethods();
|
|
13165
|
+
if (this.config.deadLetterResource) {
|
|
13166
|
+
await this.createDeadLetterResource();
|
|
13167
|
+
}
|
|
13168
|
+
if (this.config.verbose) {
|
|
13169
|
+
console.log(`[S3QueuePlugin] Setup completed for resource '${this.config.resource}'`);
|
|
13170
|
+
}
|
|
13171
|
+
}
|
|
13172
|
+
async onStart() {
|
|
13173
|
+
if (this.config.autoStart && this.config.onMessage) {
|
|
13174
|
+
await this.startProcessing();
|
|
13175
|
+
}
|
|
13176
|
+
}
|
|
13177
|
+
async onStop() {
|
|
13178
|
+
await this.stopProcessing();
|
|
13179
|
+
}
|
|
13180
|
+
addHelperMethods() {
|
|
13181
|
+
const plugin = this;
|
|
13182
|
+
const resource = this.targetResource;
|
|
13183
|
+
resource.enqueue = async function(data, options = {}) {
|
|
13184
|
+
const recordData = {
|
|
13185
|
+
id: data.id || idGenerator(),
|
|
13186
|
+
...data
|
|
13187
|
+
};
|
|
13188
|
+
const record = await resource.insert(recordData);
|
|
13189
|
+
const queueEntry = {
|
|
13190
|
+
id: idGenerator(),
|
|
13191
|
+
originalId: record.id,
|
|
13192
|
+
status: "pending",
|
|
13193
|
+
visibleAt: Date.now(),
|
|
13194
|
+
attempts: 0,
|
|
13195
|
+
maxAttempts: options.maxAttempts || plugin.config.maxAttempts,
|
|
13196
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
|
|
13197
|
+
};
|
|
13198
|
+
await plugin.queueResource.insert(queueEntry);
|
|
13199
|
+
plugin.emit("message.enqueued", { id: record.id, queueId: queueEntry.id });
|
|
13200
|
+
return record;
|
|
13201
|
+
};
|
|
13202
|
+
resource.queueStats = async function() {
|
|
13203
|
+
return await plugin.getStats();
|
|
13204
|
+
};
|
|
13205
|
+
resource.startProcessing = async function(handler, options = {}) {
|
|
13206
|
+
return await plugin.startProcessing(handler, options);
|
|
13207
|
+
};
|
|
13208
|
+
resource.stopProcessing = async function() {
|
|
13209
|
+
return await plugin.stopProcessing();
|
|
13210
|
+
};
|
|
13211
|
+
}
|
|
13212
|
+
async startProcessing(handler = null, options = {}) {
|
|
13213
|
+
if (this.isRunning) {
|
|
13214
|
+
if (this.config.verbose) {
|
|
13215
|
+
console.log("[S3QueuePlugin] Already running");
|
|
13216
|
+
}
|
|
13217
|
+
return;
|
|
13218
|
+
}
|
|
13219
|
+
const messageHandler = handler || this.config.onMessage;
|
|
13220
|
+
if (!messageHandler) {
|
|
13221
|
+
throw new Error("S3QueuePlugin: onMessage handler required");
|
|
13222
|
+
}
|
|
13223
|
+
this.isRunning = true;
|
|
13224
|
+
const concurrency = options.concurrency || this.config.concurrency;
|
|
13225
|
+
this.cacheCleanupInterval = setInterval(() => {
|
|
13226
|
+
const now = Date.now();
|
|
13227
|
+
const maxAge = 3e4;
|
|
13228
|
+
for (const [queueId, timestamp] of this.processedCache.entries()) {
|
|
13229
|
+
if (now - timestamp > maxAge) {
|
|
13230
|
+
this.processedCache.delete(queueId);
|
|
13231
|
+
}
|
|
13232
|
+
}
|
|
13233
|
+
}, 5e3);
|
|
13234
|
+
this.lockCleanupInterval = setInterval(() => {
|
|
13235
|
+
this.cleanupStaleLocks().catch((err) => {
|
|
13236
|
+
if (this.config.verbose) {
|
|
13237
|
+
console.log(`[lockCleanup] Error: ${err.message}`);
|
|
13238
|
+
}
|
|
13239
|
+
});
|
|
13240
|
+
}, 1e4);
|
|
13241
|
+
for (let i = 0; i < concurrency; i++) {
|
|
13242
|
+
const worker = this.createWorker(messageHandler, i);
|
|
13243
|
+
this.workers.push(worker);
|
|
13244
|
+
}
|
|
13245
|
+
if (this.config.verbose) {
|
|
13246
|
+
console.log(`[S3QueuePlugin] Started ${concurrency} workers`);
|
|
13247
|
+
}
|
|
13248
|
+
this.emit("workers.started", { concurrency, workerId: this.workerId });
|
|
13249
|
+
}
|
|
13250
|
+
async stopProcessing() {
|
|
13251
|
+
if (!this.isRunning) return;
|
|
13252
|
+
this.isRunning = false;
|
|
13253
|
+
if (this.cacheCleanupInterval) {
|
|
13254
|
+
clearInterval(this.cacheCleanupInterval);
|
|
13255
|
+
this.cacheCleanupInterval = null;
|
|
13256
|
+
}
|
|
13257
|
+
if (this.lockCleanupInterval) {
|
|
13258
|
+
clearInterval(this.lockCleanupInterval);
|
|
13259
|
+
this.lockCleanupInterval = null;
|
|
13260
|
+
}
|
|
13261
|
+
await Promise.all(this.workers);
|
|
13262
|
+
this.workers = [];
|
|
13263
|
+
this.processedCache.clear();
|
|
13264
|
+
if (this.config.verbose) {
|
|
13265
|
+
console.log("[S3QueuePlugin] Stopped all workers");
|
|
13266
|
+
}
|
|
13267
|
+
this.emit("workers.stopped", { workerId: this.workerId });
|
|
13268
|
+
}
|
|
13269
|
+
createWorker(handler, workerIndex) {
|
|
13270
|
+
return (async () => {
|
|
13271
|
+
while (this.isRunning) {
|
|
13272
|
+
try {
|
|
13273
|
+
const message = await this.claimMessage();
|
|
13274
|
+
if (message) {
|
|
13275
|
+
await this.processMessage(message, handler);
|
|
13276
|
+
} else {
|
|
13277
|
+
await new Promise((resolve) => setTimeout(resolve, this.config.pollInterval));
|
|
13278
|
+
}
|
|
13279
|
+
} catch (error) {
|
|
13280
|
+
if (this.config.verbose) {
|
|
13281
|
+
console.error(`[Worker ${workerIndex}] Error:`, error.message);
|
|
13282
|
+
}
|
|
13283
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
13284
|
+
}
|
|
13285
|
+
}
|
|
13286
|
+
})();
|
|
13287
|
+
}
|
|
13288
|
+
async claimMessage() {
|
|
13289
|
+
const now = Date.now();
|
|
13290
|
+
const [ok, err, messages] = await tryFn(
|
|
13291
|
+
() => this.queueResource.query({
|
|
13292
|
+
status: "pending"
|
|
13293
|
+
})
|
|
13294
|
+
);
|
|
13295
|
+
if (!ok || !messages || messages.length === 0) {
|
|
13296
|
+
return null;
|
|
13297
|
+
}
|
|
13298
|
+
const available = messages.filter((m) => m.visibleAt <= now);
|
|
13299
|
+
if (available.length === 0) {
|
|
13300
|
+
return null;
|
|
13301
|
+
}
|
|
13302
|
+
for (const msg of available) {
|
|
13303
|
+
const claimed = await this.attemptClaim(msg);
|
|
13304
|
+
if (claimed) {
|
|
13305
|
+
return claimed;
|
|
13306
|
+
}
|
|
13307
|
+
}
|
|
13308
|
+
return null;
|
|
13309
|
+
}
|
|
13310
|
+
/**
|
|
13311
|
+
* Acquire a distributed lock using ETag-based conditional updates
|
|
13312
|
+
* This ensures only one worker can claim a message at a time
|
|
13313
|
+
*
|
|
13314
|
+
* Uses a two-step process:
|
|
13315
|
+
* 1. Create lock resource (similar to queue resource) if not exists
|
|
13316
|
+
* 2. Try to claim lock using ETag-based conditional update
|
|
13317
|
+
*/
|
|
13318
|
+
async acquireLock(messageId) {
|
|
13319
|
+
if (!this.lockResource) {
|
|
13320
|
+
return true;
|
|
13321
|
+
}
|
|
13322
|
+
const lockId = `lock-${messageId}`;
|
|
13323
|
+
const now = Date.now();
|
|
13324
|
+
try {
|
|
13325
|
+
const [okGet, errGet, existingLock] = await tryFn(
|
|
13326
|
+
() => this.lockResource.get(lockId)
|
|
13327
|
+
);
|
|
13328
|
+
if (existingLock) {
|
|
13329
|
+
const lockAge = now - existingLock.timestamp;
|
|
13330
|
+
if (lockAge < existingLock.ttl) {
|
|
13331
|
+
return false;
|
|
13332
|
+
}
|
|
13333
|
+
const [ok, err, result] = await tryFn(
|
|
13334
|
+
() => this.lockResource.updateConditional(lockId, {
|
|
13335
|
+
workerId: this.workerId,
|
|
13336
|
+
timestamp: now,
|
|
13337
|
+
ttl: 5e3
|
|
13338
|
+
}, {
|
|
13339
|
+
ifMatch: existingLock._etag
|
|
13340
|
+
})
|
|
13341
|
+
);
|
|
13342
|
+
return ok && result.success;
|
|
13343
|
+
}
|
|
13344
|
+
const [okCreate, errCreate] = await tryFn(
|
|
13345
|
+
() => this.lockResource.insert({
|
|
13346
|
+
id: lockId,
|
|
13347
|
+
workerId: this.workerId,
|
|
13348
|
+
timestamp: now,
|
|
13349
|
+
ttl: 5e3
|
|
13350
|
+
})
|
|
13351
|
+
);
|
|
13352
|
+
return okCreate;
|
|
13353
|
+
} catch (error) {
|
|
13354
|
+
if (this.config.verbose) {
|
|
13355
|
+
console.log(`[acquireLock] Error: ${error.message}`);
|
|
13356
|
+
}
|
|
13357
|
+
return false;
|
|
13358
|
+
}
|
|
13359
|
+
}
|
|
13360
|
+
/**
|
|
13361
|
+
* Release a distributed lock by deleting the lock record
|
|
13362
|
+
*/
|
|
13363
|
+
async releaseLock(messageId) {
|
|
13364
|
+
if (!this.lockResource) {
|
|
13365
|
+
return;
|
|
13366
|
+
}
|
|
13367
|
+
const lockId = `lock-${messageId}`;
|
|
13368
|
+
try {
|
|
13369
|
+
await this.lockResource.delete(lockId);
|
|
13370
|
+
} catch (error) {
|
|
13371
|
+
if (this.config.verbose) {
|
|
13372
|
+
console.log(`[releaseLock] Failed to release lock for ${messageId}: ${error.message}`);
|
|
13373
|
+
}
|
|
13374
|
+
}
|
|
13375
|
+
}
|
|
13376
|
+
/**
|
|
13377
|
+
* Clean up stale locks (older than TTL)
|
|
13378
|
+
* This prevents deadlocks if a worker crashes while holding a lock
|
|
13379
|
+
*/
|
|
13380
|
+
async cleanupStaleLocks() {
|
|
13381
|
+
if (!this.lockResource) {
|
|
13382
|
+
return;
|
|
13383
|
+
}
|
|
13384
|
+
const now = Date.now();
|
|
13385
|
+
try {
|
|
13386
|
+
const locks = await this.lockResource.list();
|
|
13387
|
+
for (const lock of locks) {
|
|
13388
|
+
const lockAge = now - lock.timestamp;
|
|
13389
|
+
if (lockAge > lock.ttl) {
|
|
13390
|
+
await this.lockResource.delete(lock.id);
|
|
13391
|
+
if (this.config.verbose) {
|
|
13392
|
+
console.log(`[cleanupStaleLocks] Removed expired lock: ${lock.id}`);
|
|
13393
|
+
}
|
|
13394
|
+
}
|
|
13395
|
+
}
|
|
13396
|
+
} catch (error) {
|
|
13397
|
+
if (this.config.verbose) {
|
|
13398
|
+
console.log(`[cleanupStaleLocks] Error during cleanup: ${error.message}`);
|
|
13399
|
+
}
|
|
13400
|
+
}
|
|
13401
|
+
}
|
|
13402
|
+
async attemptClaim(msg) {
|
|
13403
|
+
const now = Date.now();
|
|
13404
|
+
const lockAcquired = await this.acquireLock(msg.id);
|
|
13405
|
+
if (!lockAcquired) {
|
|
13406
|
+
return null;
|
|
13407
|
+
}
|
|
13408
|
+
if (this.processedCache.has(msg.id)) {
|
|
13409
|
+
await this.releaseLock(msg.id);
|
|
13410
|
+
if (this.config.verbose) {
|
|
13411
|
+
console.log(`[attemptClaim] Message ${msg.id} already processed (in cache)`);
|
|
13412
|
+
}
|
|
13413
|
+
return null;
|
|
13414
|
+
}
|
|
13415
|
+
this.processedCache.set(msg.id, Date.now());
|
|
13416
|
+
await this.releaseLock(msg.id);
|
|
13417
|
+
const [okGet, errGet, msgWithETag] = await tryFn(
|
|
13418
|
+
() => this.queueResource.get(msg.id)
|
|
13419
|
+
);
|
|
13420
|
+
if (!okGet || !msgWithETag) {
|
|
13421
|
+
this.processedCache.delete(msg.id);
|
|
13422
|
+
if (this.config.verbose) {
|
|
13423
|
+
console.log(`[attemptClaim] Message ${msg.id} not found or error: ${errGet?.message}`);
|
|
13424
|
+
}
|
|
13425
|
+
return null;
|
|
13426
|
+
}
|
|
13427
|
+
if (msgWithETag.status !== "pending" || msgWithETag.visibleAt > now) {
|
|
13428
|
+
this.processedCache.delete(msg.id);
|
|
13429
|
+
if (this.config.verbose) {
|
|
13430
|
+
console.log(`[attemptClaim] Message ${msg.id} not claimable: status=${msgWithETag.status}, visibleAt=${msgWithETag.visibleAt}, now=${now}`);
|
|
13431
|
+
}
|
|
13432
|
+
return null;
|
|
13433
|
+
}
|
|
13434
|
+
if (this.config.verbose) {
|
|
13435
|
+
console.log(`[attemptClaim] Attempting to claim ${msg.id} with ETag: ${msgWithETag._etag}`);
|
|
13436
|
+
}
|
|
13437
|
+
const [ok, err, result] = await tryFn(
|
|
13438
|
+
() => this.queueResource.updateConditional(msgWithETag.id, {
|
|
13439
|
+
status: "processing",
|
|
13440
|
+
claimedBy: this.workerId,
|
|
13441
|
+
claimedAt: now,
|
|
13442
|
+
visibleAt: now + this.config.visibilityTimeout,
|
|
13443
|
+
attempts: msgWithETag.attempts + 1
|
|
13444
|
+
}, {
|
|
13445
|
+
ifMatch: msgWithETag._etag
|
|
13446
|
+
// ← ATOMIC CLAIM using ETag!
|
|
13447
|
+
})
|
|
13448
|
+
);
|
|
13449
|
+
if (!ok || !result.success) {
|
|
13450
|
+
this.processedCache.delete(msg.id);
|
|
13451
|
+
if (this.config.verbose) {
|
|
13452
|
+
console.log(`[attemptClaim] Failed to claim ${msg.id}: ${err?.message || result.error}`);
|
|
13453
|
+
}
|
|
13454
|
+
return null;
|
|
13455
|
+
}
|
|
13456
|
+
if (this.config.verbose) {
|
|
13457
|
+
console.log(`[attemptClaim] Successfully claimed ${msg.id}`);
|
|
13458
|
+
}
|
|
13459
|
+
const [okRecord, errRecord, record] = await tryFn(
|
|
13460
|
+
() => this.targetResource.get(msgWithETag.originalId)
|
|
13461
|
+
);
|
|
13462
|
+
if (!okRecord) {
|
|
13463
|
+
await this.failMessage(msgWithETag.id, "Original record not found");
|
|
13464
|
+
return null;
|
|
13465
|
+
}
|
|
13466
|
+
return {
|
|
13467
|
+
queueId: msgWithETag.id,
|
|
13468
|
+
record,
|
|
13469
|
+
attempts: msgWithETag.attempts + 1,
|
|
13470
|
+
maxAttempts: msgWithETag.maxAttempts
|
|
13471
|
+
};
|
|
13472
|
+
}
|
|
13473
|
+
async processMessage(message, handler) {
|
|
13474
|
+
const startTime = Date.now();
|
|
13475
|
+
try {
|
|
13476
|
+
const result = await handler(message.record, {
|
|
13477
|
+
queueId: message.queueId,
|
|
13478
|
+
attempts: message.attempts,
|
|
13479
|
+
workerId: this.workerId
|
|
13480
|
+
});
|
|
13481
|
+
await this.completeMessage(message.queueId, result);
|
|
13482
|
+
const duration = Date.now() - startTime;
|
|
13483
|
+
this.emit("message.completed", {
|
|
13484
|
+
queueId: message.queueId,
|
|
13485
|
+
originalId: message.record.id,
|
|
13486
|
+
duration,
|
|
13487
|
+
attempts: message.attempts
|
|
13488
|
+
});
|
|
13489
|
+
if (this.config.onComplete) {
|
|
13490
|
+
await this.config.onComplete(message.record, result);
|
|
13491
|
+
}
|
|
13492
|
+
} catch (error) {
|
|
13493
|
+
const shouldRetry = message.attempts < message.maxAttempts;
|
|
13494
|
+
if (shouldRetry) {
|
|
13495
|
+
await this.retryMessage(message.queueId, message.attempts, error.message);
|
|
13496
|
+
this.emit("message.retry", {
|
|
13497
|
+
queueId: message.queueId,
|
|
13498
|
+
originalId: message.record.id,
|
|
13499
|
+
attempts: message.attempts,
|
|
13500
|
+
error: error.message
|
|
13501
|
+
});
|
|
13502
|
+
} else {
|
|
13503
|
+
await this.moveToDeadLetter(message.queueId, message.record, error.message);
|
|
13504
|
+
this.emit("message.dead", {
|
|
13505
|
+
queueId: message.queueId,
|
|
13506
|
+
originalId: message.record.id,
|
|
13507
|
+
error: error.message
|
|
13508
|
+
});
|
|
13509
|
+
}
|
|
13510
|
+
if (this.config.onError) {
|
|
13511
|
+
await this.config.onError(error, message.record);
|
|
13512
|
+
}
|
|
13513
|
+
}
|
|
13514
|
+
}
|
|
13515
|
+
async completeMessage(queueId, result) {
|
|
13516
|
+
await this.queueResource.update(queueId, {
|
|
13517
|
+
status: "completed",
|
|
13518
|
+
completedAt: Date.now(),
|
|
13519
|
+
result
|
|
13520
|
+
});
|
|
13521
|
+
}
|
|
13522
|
+
async failMessage(queueId, error) {
|
|
13523
|
+
await this.queueResource.update(queueId, {
|
|
13524
|
+
status: "failed",
|
|
13525
|
+
error
|
|
13526
|
+
});
|
|
13527
|
+
}
|
|
13528
|
+
async retryMessage(queueId, attempts, error) {
|
|
13529
|
+
const backoff = Math.min(Math.pow(2, attempts) * 1e3, 3e4);
|
|
13530
|
+
await this.queueResource.update(queueId, {
|
|
13531
|
+
status: "pending",
|
|
13532
|
+
visibleAt: Date.now() + backoff,
|
|
13533
|
+
error
|
|
13534
|
+
});
|
|
13535
|
+
this.processedCache.delete(queueId);
|
|
13536
|
+
}
|
|
13537
|
+
async moveToDeadLetter(queueId, record, error) {
|
|
13538
|
+
if (this.config.deadLetterResource && this.deadLetterResourceObj) {
|
|
13539
|
+
const msg = await this.queueResource.get(queueId);
|
|
13540
|
+
await this.deadLetterResourceObj.insert({
|
|
13541
|
+
id: idGenerator(),
|
|
13542
|
+
originalId: record.id,
|
|
13543
|
+
queueId,
|
|
13544
|
+
data: record,
|
|
13545
|
+
error,
|
|
13546
|
+
attempts: msg.attempts,
|
|
13547
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
13548
|
+
});
|
|
13549
|
+
}
|
|
13550
|
+
await this.queueResource.update(queueId, {
|
|
13551
|
+
status: "dead",
|
|
13552
|
+
error
|
|
13553
|
+
});
|
|
13554
|
+
}
|
|
13555
|
+
async getStats() {
|
|
13556
|
+
const [ok, err, allMessages] = await tryFn(
|
|
13557
|
+
() => this.queueResource.list()
|
|
13558
|
+
);
|
|
13559
|
+
if (!ok) {
|
|
13560
|
+
if (this.config.verbose) {
|
|
13561
|
+
console.warn("[S3QueuePlugin] Failed to get stats:", err.message);
|
|
13562
|
+
}
|
|
13563
|
+
return null;
|
|
13564
|
+
}
|
|
13565
|
+
const stats = {
|
|
13566
|
+
total: allMessages.length,
|
|
13567
|
+
pending: 0,
|
|
13568
|
+
processing: 0,
|
|
13569
|
+
completed: 0,
|
|
13570
|
+
failed: 0,
|
|
13571
|
+
dead: 0
|
|
13572
|
+
};
|
|
13573
|
+
for (const msg of allMessages) {
|
|
13574
|
+
if (stats[msg.status] !== void 0) {
|
|
13575
|
+
stats[msg.status]++;
|
|
13576
|
+
}
|
|
13577
|
+
}
|
|
13578
|
+
return stats;
|
|
13579
|
+
}
|
|
13580
|
+
async createDeadLetterResource() {
|
|
13581
|
+
const [ok, err] = await tryFn(
|
|
13582
|
+
() => this.database.createResource({
|
|
13583
|
+
name: this.config.deadLetterResource,
|
|
13584
|
+
attributes: {
|
|
13585
|
+
id: "string|required",
|
|
13586
|
+
originalId: "string|required",
|
|
13587
|
+
queueId: "string|required",
|
|
13588
|
+
data: "json|required",
|
|
13589
|
+
error: "string|required",
|
|
13590
|
+
attempts: "number|required",
|
|
13591
|
+
createdAt: "string|required"
|
|
13592
|
+
},
|
|
13593
|
+
behavior: "body-overflow",
|
|
13594
|
+
timestamps: true
|
|
13595
|
+
})
|
|
13596
|
+
);
|
|
13597
|
+
if (ok || this.database.resources[this.config.deadLetterResource]) {
|
|
13598
|
+
this.deadLetterResourceObj = this.database.resources[this.config.deadLetterResource];
|
|
13599
|
+
if (this.config.verbose) {
|
|
13600
|
+
console.log(`[S3QueuePlugin] Dead letter queue created: ${this.config.deadLetterResource}`);
|
|
13601
|
+
}
|
|
13602
|
+
}
|
|
13603
|
+
}
|
|
13604
|
+
}
|
|
13605
|
+
|
|
12331
13606
|
class SchedulerPlugin extends Plugin {
|
|
12332
13607
|
constructor(options = {}) {
|
|
12333
13608
|
super();
|
|
@@ -12337,7 +13612,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
12337
13612
|
defaultTimeout: options.defaultTimeout || 3e5,
|
|
12338
13613
|
// 5 minutes
|
|
12339
13614
|
defaultRetries: options.defaultRetries || 1,
|
|
12340
|
-
jobHistoryResource: options.jobHistoryResource || "
|
|
13615
|
+
jobHistoryResource: options.jobHistoryResource || "plg_job_executions",
|
|
12341
13616
|
persistJobs: options.persistJobs !== false,
|
|
12342
13617
|
verbose: options.verbose || false,
|
|
12343
13618
|
onJobStart: options.onJobStart || null,
|
|
@@ -12346,12 +13621,20 @@ class SchedulerPlugin extends Plugin {
|
|
|
12346
13621
|
...options
|
|
12347
13622
|
};
|
|
12348
13623
|
this.database = null;
|
|
13624
|
+
this.lockResource = null;
|
|
12349
13625
|
this.jobs = /* @__PURE__ */ new Map();
|
|
12350
13626
|
this.activeJobs = /* @__PURE__ */ new Map();
|
|
12351
13627
|
this.timers = /* @__PURE__ */ new Map();
|
|
12352
13628
|
this.statistics = /* @__PURE__ */ new Map();
|
|
12353
13629
|
this._validateConfiguration();
|
|
12354
13630
|
}
|
|
13631
|
+
/**
|
|
13632
|
+
* Helper to detect test environment
|
|
13633
|
+
* @private
|
|
13634
|
+
*/
|
|
13635
|
+
_isTestEnvironment() {
|
|
13636
|
+
return process.env.NODE_ENV === "test" || process.env.JEST_WORKER_ID !== void 0 || global.expect !== void 0;
|
|
13637
|
+
}
|
|
12355
13638
|
_validateConfiguration() {
|
|
12356
13639
|
if (Object.keys(this.config.jobs).length === 0) {
|
|
12357
13640
|
throw new Error("SchedulerPlugin: At least one job must be defined");
|
|
@@ -12378,6 +13661,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
12378
13661
|
}
|
|
12379
13662
|
async setup(database) {
|
|
12380
13663
|
this.database = database;
|
|
13664
|
+
await this._createLockResource();
|
|
12381
13665
|
if (this.config.persistJobs) {
|
|
12382
13666
|
await this._createJobHistoryResource();
|
|
12383
13667
|
}
|
|
@@ -12406,6 +13690,25 @@ class SchedulerPlugin extends Plugin {
|
|
|
12406
13690
|
await this._startScheduling();
|
|
12407
13691
|
this.emit("initialized", { jobs: this.jobs.size });
|
|
12408
13692
|
}
|
|
13693
|
+
async _createLockResource() {
|
|
13694
|
+
const [ok, err, lockResource] = await tryFn(
|
|
13695
|
+
() => this.database.createResource({
|
|
13696
|
+
name: "plg_scheduler_job_locks",
|
|
13697
|
+
attributes: {
|
|
13698
|
+
id: "string|required",
|
|
13699
|
+
jobName: "string|required",
|
|
13700
|
+
lockedAt: "number|required",
|
|
13701
|
+
instanceId: "string|optional"
|
|
13702
|
+
},
|
|
13703
|
+
behavior: "body-only",
|
|
13704
|
+
timestamps: false
|
|
13705
|
+
})
|
|
13706
|
+
);
|
|
13707
|
+
if (!ok && !this.database.resources.plg_scheduler_job_locks) {
|
|
13708
|
+
throw new Error(`Failed to create lock resource: ${err?.message}`);
|
|
13709
|
+
}
|
|
13710
|
+
this.lockResource = ok ? lockResource : this.database.resources.plg_scheduler_job_locks;
|
|
13711
|
+
}
|
|
12409
13712
|
async _createJobHistoryResource() {
|
|
12410
13713
|
const [ok] = await tryFn(() => this.database.createResource({
|
|
12411
13714
|
name: this.config.jobHistoryResource,
|
|
@@ -12499,18 +13802,37 @@ class SchedulerPlugin extends Plugin {
|
|
|
12499
13802
|
next.setHours(next.getHours() + 1);
|
|
12500
13803
|
}
|
|
12501
13804
|
}
|
|
12502
|
-
|
|
12503
|
-
if (isTestEnvironment) {
|
|
13805
|
+
if (this._isTestEnvironment()) {
|
|
12504
13806
|
next.setTime(next.getTime() + 1e3);
|
|
12505
13807
|
}
|
|
12506
13808
|
return next;
|
|
12507
13809
|
}
|
|
12508
13810
|
async _executeJob(jobName) {
|
|
12509
13811
|
const job = this.jobs.get(jobName);
|
|
12510
|
-
if (!job
|
|
13812
|
+
if (!job) {
|
|
12511
13813
|
return;
|
|
12512
13814
|
}
|
|
12513
|
-
|
|
13815
|
+
if (this.activeJobs.has(jobName)) {
|
|
13816
|
+
return;
|
|
13817
|
+
}
|
|
13818
|
+
this.activeJobs.set(jobName, "acquiring-lock");
|
|
13819
|
+
const lockId = `lock-${jobName}`;
|
|
13820
|
+
const [lockAcquired, lockErr] = await tryFn(
|
|
13821
|
+
() => this.lockResource.insert({
|
|
13822
|
+
id: lockId,
|
|
13823
|
+
jobName,
|
|
13824
|
+
lockedAt: Date.now(),
|
|
13825
|
+
instanceId: process.pid ? String(process.pid) : "unknown"
|
|
13826
|
+
})
|
|
13827
|
+
);
|
|
13828
|
+
if (!lockAcquired) {
|
|
13829
|
+
if (this.config.verbose) {
|
|
13830
|
+
console.log(`[SchedulerPlugin] Job '${jobName}' already running on another instance`);
|
|
13831
|
+
}
|
|
13832
|
+
this.activeJobs.delete(jobName);
|
|
13833
|
+
return;
|
|
13834
|
+
}
|
|
13835
|
+
const executionId = `${jobName}_${idGenerator()}`;
|
|
12514
13836
|
const startTime = Date.now();
|
|
12515
13837
|
const context = {
|
|
12516
13838
|
jobName,
|
|
@@ -12519,91 +13841,95 @@ class SchedulerPlugin extends Plugin {
|
|
|
12519
13841
|
database: this.database
|
|
12520
13842
|
};
|
|
12521
13843
|
this.activeJobs.set(jobName, executionId);
|
|
12522
|
-
|
|
12523
|
-
|
|
12524
|
-
|
|
12525
|
-
|
|
12526
|
-
|
|
12527
|
-
|
|
12528
|
-
|
|
12529
|
-
|
|
12530
|
-
|
|
12531
|
-
|
|
12532
|
-
|
|
12533
|
-
const actualTimeout = isTestEnvironment ? Math.min(job.timeout, 1e3) : job.timeout;
|
|
12534
|
-
let timeoutId;
|
|
12535
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
12536
|
-
timeoutId = setTimeout(() => reject(new Error("Job execution timeout")), actualTimeout);
|
|
12537
|
-
});
|
|
12538
|
-
const jobPromise = job.action(this.database, context, this);
|
|
13844
|
+
try {
|
|
13845
|
+
if (this.config.onJobStart) {
|
|
13846
|
+
await this._executeHook(this.config.onJobStart, jobName, context);
|
|
13847
|
+
}
|
|
13848
|
+
this.emit("job_start", { jobName, executionId, startTime });
|
|
13849
|
+
let attempt = 0;
|
|
13850
|
+
let lastError = null;
|
|
13851
|
+
let result = null;
|
|
13852
|
+
let status = "success";
|
|
13853
|
+
const isTestEnvironment = this._isTestEnvironment();
|
|
13854
|
+
while (attempt <= job.retries) {
|
|
12539
13855
|
try {
|
|
12540
|
-
|
|
12541
|
-
|
|
12542
|
-
|
|
12543
|
-
|
|
12544
|
-
|
|
12545
|
-
|
|
12546
|
-
|
|
12547
|
-
|
|
12548
|
-
|
|
12549
|
-
|
|
12550
|
-
|
|
12551
|
-
|
|
12552
|
-
|
|
12553
|
-
|
|
13856
|
+
const actualTimeout = isTestEnvironment ? Math.min(job.timeout, 1e3) : job.timeout;
|
|
13857
|
+
let timeoutId;
|
|
13858
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
13859
|
+
timeoutId = setTimeout(() => reject(new Error("Job execution timeout")), actualTimeout);
|
|
13860
|
+
});
|
|
13861
|
+
const jobPromise = job.action(this.database, context, this);
|
|
13862
|
+
try {
|
|
13863
|
+
result = await Promise.race([jobPromise, timeoutPromise]);
|
|
13864
|
+
clearTimeout(timeoutId);
|
|
13865
|
+
} catch (raceError) {
|
|
13866
|
+
clearTimeout(timeoutId);
|
|
13867
|
+
throw raceError;
|
|
13868
|
+
}
|
|
13869
|
+
status = "success";
|
|
13870
|
+
break;
|
|
13871
|
+
} catch (error) {
|
|
13872
|
+
lastError = error;
|
|
13873
|
+
attempt++;
|
|
13874
|
+
if (attempt <= job.retries) {
|
|
13875
|
+
if (this.config.verbose) {
|
|
13876
|
+
console.warn(`[SchedulerPlugin] Job '${jobName}' failed (attempt ${attempt + 1}):`, error.message);
|
|
13877
|
+
}
|
|
13878
|
+
const baseDelay = Math.min(Math.pow(2, attempt) * 1e3, 5e3);
|
|
13879
|
+
const delay = isTestEnvironment ? 1 : baseDelay;
|
|
13880
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
12554
13881
|
}
|
|
12555
|
-
const baseDelay = Math.min(Math.pow(2, attempt) * 1e3, 5e3);
|
|
12556
|
-
const delay = isTestEnvironment ? 1 : baseDelay;
|
|
12557
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
12558
13882
|
}
|
|
12559
13883
|
}
|
|
12560
|
-
|
|
12561
|
-
|
|
12562
|
-
|
|
12563
|
-
|
|
12564
|
-
|
|
12565
|
-
|
|
12566
|
-
|
|
12567
|
-
|
|
12568
|
-
|
|
12569
|
-
|
|
12570
|
-
|
|
12571
|
-
|
|
12572
|
-
|
|
12573
|
-
|
|
12574
|
-
|
|
12575
|
-
|
|
12576
|
-
|
|
12577
|
-
|
|
12578
|
-
|
|
12579
|
-
|
|
12580
|
-
|
|
12581
|
-
|
|
12582
|
-
|
|
12583
|
-
|
|
12584
|
-
|
|
12585
|
-
|
|
12586
|
-
|
|
12587
|
-
|
|
12588
|
-
|
|
12589
|
-
|
|
12590
|
-
|
|
12591
|
-
|
|
12592
|
-
|
|
12593
|
-
|
|
12594
|
-
|
|
12595
|
-
|
|
12596
|
-
|
|
12597
|
-
|
|
12598
|
-
|
|
12599
|
-
|
|
12600
|
-
|
|
12601
|
-
|
|
12602
|
-
|
|
12603
|
-
|
|
12604
|
-
|
|
12605
|
-
|
|
12606
|
-
|
|
13884
|
+
const endTime = Date.now();
|
|
13885
|
+
const duration = Math.max(1, endTime - startTime);
|
|
13886
|
+
if (lastError && attempt > job.retries) {
|
|
13887
|
+
status = lastError.message.includes("timeout") ? "timeout" : "error";
|
|
13888
|
+
}
|
|
13889
|
+
job.lastRun = new Date(endTime);
|
|
13890
|
+
job.runCount++;
|
|
13891
|
+
if (status === "success") {
|
|
13892
|
+
job.successCount++;
|
|
13893
|
+
} else {
|
|
13894
|
+
job.errorCount++;
|
|
13895
|
+
}
|
|
13896
|
+
const stats = this.statistics.get(jobName);
|
|
13897
|
+
stats.totalRuns++;
|
|
13898
|
+
stats.lastRun = new Date(endTime);
|
|
13899
|
+
if (status === "success") {
|
|
13900
|
+
stats.totalSuccesses++;
|
|
13901
|
+
stats.lastSuccess = new Date(endTime);
|
|
13902
|
+
} else {
|
|
13903
|
+
stats.totalErrors++;
|
|
13904
|
+
stats.lastError = { time: new Date(endTime), message: lastError?.message };
|
|
13905
|
+
}
|
|
13906
|
+
stats.avgDuration = (stats.avgDuration * (stats.totalRuns - 1) + duration) / stats.totalRuns;
|
|
13907
|
+
if (this.config.persistJobs) {
|
|
13908
|
+
await this._persistJobExecution(jobName, executionId, startTime, endTime, duration, status, result, lastError, attempt);
|
|
13909
|
+
}
|
|
13910
|
+
if (status === "success" && this.config.onJobComplete) {
|
|
13911
|
+
await this._executeHook(this.config.onJobComplete, jobName, result, duration);
|
|
13912
|
+
} else if (status !== "success" && this.config.onJobError) {
|
|
13913
|
+
await this._executeHook(this.config.onJobError, jobName, lastError, attempt);
|
|
13914
|
+
}
|
|
13915
|
+
this.emit("job_complete", {
|
|
13916
|
+
jobName,
|
|
13917
|
+
executionId,
|
|
13918
|
+
status,
|
|
13919
|
+
duration,
|
|
13920
|
+
result,
|
|
13921
|
+
error: lastError?.message,
|
|
13922
|
+
retryCount: attempt
|
|
13923
|
+
});
|
|
13924
|
+
this.activeJobs.delete(jobName);
|
|
13925
|
+
if (job.enabled) {
|
|
13926
|
+
this._scheduleNextExecution(jobName);
|
|
13927
|
+
}
|
|
13928
|
+
if (lastError && status !== "success") {
|
|
13929
|
+
throw lastError;
|
|
13930
|
+
}
|
|
13931
|
+
} finally {
|
|
13932
|
+
await tryFn(() => this.lockResource.delete(lockId));
|
|
12607
13933
|
}
|
|
12608
13934
|
}
|
|
12609
13935
|
async _persistJobExecution(jobName, executionId, startTime, endTime, duration, status, result, error, retryCount) {
|
|
@@ -12635,6 +13961,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
12635
13961
|
}
|
|
12636
13962
|
/**
|
|
12637
13963
|
* Manually trigger a job execution
|
|
13964
|
+
* Note: Race conditions are prevented by distributed locking in _executeJob()
|
|
12638
13965
|
*/
|
|
12639
13966
|
async runJob(jobName, context = {}) {
|
|
12640
13967
|
const job = this.jobs.get(jobName);
|
|
@@ -12720,12 +14047,15 @@ class SchedulerPlugin extends Plugin {
|
|
|
12720
14047
|
return [];
|
|
12721
14048
|
}
|
|
12722
14049
|
const { limit = 50, status = null } = options;
|
|
12723
|
-
const
|
|
12724
|
-
|
|
12725
|
-
|
|
12726
|
-
|
|
12727
|
-
|
|
12728
|
-
|
|
14050
|
+
const queryParams = {
|
|
14051
|
+
jobName
|
|
14052
|
+
// Uses byJob partition for efficient lookup
|
|
14053
|
+
};
|
|
14054
|
+
if (status) {
|
|
14055
|
+
queryParams.status = status;
|
|
14056
|
+
}
|
|
14057
|
+
const [ok, err, history] = await tryFn(
|
|
14058
|
+
() => this.database.resource(this.config.jobHistoryResource).query(queryParams)
|
|
12729
14059
|
);
|
|
12730
14060
|
if (!ok) {
|
|
12731
14061
|
if (this.config.verbose) {
|
|
@@ -12733,11 +14063,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
12733
14063
|
}
|
|
12734
14064
|
return [];
|
|
12735
14065
|
}
|
|
12736
|
-
let filtered =
|
|
12737
|
-
if (status) {
|
|
12738
|
-
filtered = filtered.filter((h) => h.status === status);
|
|
12739
|
-
}
|
|
12740
|
-
filtered = filtered.sort((a, b) => b.startTime - a.startTime).slice(0, limit);
|
|
14066
|
+
let filtered = history.sort((a, b) => b.startTime - a.startTime).slice(0, limit);
|
|
12741
14067
|
return filtered.map((h) => {
|
|
12742
14068
|
let result = null;
|
|
12743
14069
|
if (h.result) {
|
|
@@ -12832,8 +14158,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
12832
14158
|
clearTimeout(timer);
|
|
12833
14159
|
}
|
|
12834
14160
|
this.timers.clear();
|
|
12835
|
-
|
|
12836
|
-
if (!isTestEnvironment && this.activeJobs.size > 0) {
|
|
14161
|
+
if (!this._isTestEnvironment() && this.activeJobs.size > 0) {
|
|
12837
14162
|
if (this.config.verbose) {
|
|
12838
14163
|
console.log(`[SchedulerPlugin] Waiting for ${this.activeJobs.size} active jobs to complete...`);
|
|
12839
14164
|
}
|
|
@@ -12846,7 +14171,7 @@ class SchedulerPlugin extends Plugin {
|
|
|
12846
14171
|
console.warn(`[SchedulerPlugin] ${this.activeJobs.size} jobs still running after timeout`);
|
|
12847
14172
|
}
|
|
12848
14173
|
}
|
|
12849
|
-
if (
|
|
14174
|
+
if (this._isTestEnvironment()) {
|
|
12850
14175
|
this.activeJobs.clear();
|
|
12851
14176
|
}
|
|
12852
14177
|
}
|
|
@@ -12867,14 +14192,14 @@ class StateMachinePlugin extends Plugin {
|
|
|
12867
14192
|
actions: options.actions || {},
|
|
12868
14193
|
guards: options.guards || {},
|
|
12869
14194
|
persistTransitions: options.persistTransitions !== false,
|
|
12870
|
-
transitionLogResource: options.transitionLogResource || "
|
|
12871
|
-
stateResource: options.stateResource || "
|
|
12872
|
-
|
|
12873
|
-
|
|
14195
|
+
transitionLogResource: options.transitionLogResource || "plg_state_transitions",
|
|
14196
|
+
stateResource: options.stateResource || "plg_entity_states",
|
|
14197
|
+
retryAttempts: options.retryAttempts || 3,
|
|
14198
|
+
retryDelay: options.retryDelay || 100,
|
|
14199
|
+
verbose: options.verbose || false
|
|
12874
14200
|
};
|
|
12875
14201
|
this.database = null;
|
|
12876
14202
|
this.machines = /* @__PURE__ */ new Map();
|
|
12877
|
-
this.stateStorage = /* @__PURE__ */ new Map();
|
|
12878
14203
|
this._validateConfiguration();
|
|
12879
14204
|
}
|
|
12880
14205
|
_validateConfiguration() {
|
|
@@ -13015,43 +14340,55 @@ class StateMachinePlugin extends Plugin {
|
|
|
13015
14340
|
machine.currentStates.set(entityId, toState);
|
|
13016
14341
|
if (this.config.persistTransitions) {
|
|
13017
14342
|
const transitionId = `${machineId}_${entityId}_${timestamp}`;
|
|
13018
|
-
|
|
13019
|
-
|
|
13020
|
-
|
|
13021
|
-
|
|
13022
|
-
|
|
13023
|
-
|
|
13024
|
-
|
|
13025
|
-
|
|
13026
|
-
|
|
13027
|
-
|
|
13028
|
-
|
|
13029
|
-
|
|
13030
|
-
|
|
13031
|
-
|
|
14343
|
+
let logOk = false;
|
|
14344
|
+
let lastLogErr;
|
|
14345
|
+
for (let attempt = 0; attempt < this.config.retryAttempts; attempt++) {
|
|
14346
|
+
const [ok, err] = await tryFn(
|
|
14347
|
+
() => this.database.resource(this.config.transitionLogResource).insert({
|
|
14348
|
+
id: transitionId,
|
|
14349
|
+
machineId,
|
|
14350
|
+
entityId,
|
|
14351
|
+
fromState,
|
|
14352
|
+
toState,
|
|
14353
|
+
event,
|
|
14354
|
+
context,
|
|
14355
|
+
timestamp,
|
|
14356
|
+
createdAt: now.slice(0, 10)
|
|
14357
|
+
// YYYY-MM-DD for partitioning
|
|
14358
|
+
})
|
|
14359
|
+
);
|
|
14360
|
+
if (ok) {
|
|
14361
|
+
logOk = true;
|
|
14362
|
+
break;
|
|
14363
|
+
}
|
|
14364
|
+
lastLogErr = err;
|
|
14365
|
+
if (attempt < this.config.retryAttempts - 1) {
|
|
14366
|
+
const delay = this.config.retryDelay * Math.pow(2, attempt);
|
|
14367
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
14368
|
+
}
|
|
14369
|
+
}
|
|
13032
14370
|
if (!logOk && this.config.verbose) {
|
|
13033
|
-
console.warn(`[StateMachinePlugin] Failed to log transition:`,
|
|
14371
|
+
console.warn(`[StateMachinePlugin] Failed to log transition after ${this.config.retryAttempts} attempts:`, lastLogErr.message);
|
|
13034
14372
|
}
|
|
13035
14373
|
const stateId = `${machineId}_${entityId}`;
|
|
13036
|
-
const
|
|
13037
|
-
|
|
13038
|
-
|
|
13039
|
-
|
|
13040
|
-
|
|
13041
|
-
|
|
13042
|
-
|
|
13043
|
-
|
|
13044
|
-
|
|
13045
|
-
|
|
13046
|
-
|
|
13047
|
-
|
|
13048
|
-
|
|
13049
|
-
|
|
13050
|
-
|
|
14374
|
+
const stateData = {
|
|
14375
|
+
machineId,
|
|
14376
|
+
entityId,
|
|
14377
|
+
currentState: toState,
|
|
14378
|
+
context,
|
|
14379
|
+
lastTransition: transitionId,
|
|
14380
|
+
updatedAt: now
|
|
14381
|
+
};
|
|
14382
|
+
const [updateOk] = await tryFn(
|
|
14383
|
+
() => this.database.resource(this.config.stateResource).update(stateId, stateData)
|
|
14384
|
+
);
|
|
14385
|
+
if (!updateOk) {
|
|
14386
|
+
const [insertOk, insertErr] = await tryFn(
|
|
14387
|
+
() => this.database.resource(this.config.stateResource).insert({ id: stateId, ...stateData })
|
|
14388
|
+
);
|
|
14389
|
+
if (!insertOk && this.config.verbose) {
|
|
14390
|
+
console.warn(`[StateMachinePlugin] Failed to upsert state:`, insertErr.message);
|
|
13051
14391
|
}
|
|
13052
|
-
});
|
|
13053
|
-
if (!stateOk && this.config.verbose) {
|
|
13054
|
-
console.warn(`[StateMachinePlugin] Failed to update state:`, stateErr.message);
|
|
13055
14392
|
}
|
|
13056
14393
|
}
|
|
13057
14394
|
}
|
|
@@ -13082,8 +14419,9 @@ class StateMachinePlugin extends Plugin {
|
|
|
13082
14419
|
}
|
|
13083
14420
|
/**
|
|
13084
14421
|
* Get valid events for current state
|
|
14422
|
+
* Can accept either a state name (sync) or entityId (async to fetch latest state)
|
|
13085
14423
|
*/
|
|
13086
|
-
getValidEvents(machineId, stateOrEntityId) {
|
|
14424
|
+
async getValidEvents(machineId, stateOrEntityId) {
|
|
13087
14425
|
const machine = this.machines.get(machineId);
|
|
13088
14426
|
if (!machine) {
|
|
13089
14427
|
throw new Error(`State machine '${machineId}' not found`);
|
|
@@ -13092,7 +14430,7 @@ class StateMachinePlugin extends Plugin {
|
|
|
13092
14430
|
if (machine.config.states[stateOrEntityId]) {
|
|
13093
14431
|
state = stateOrEntityId;
|
|
13094
14432
|
} else {
|
|
13095
|
-
state =
|
|
14433
|
+
state = await this.getState(machineId, stateOrEntityId);
|
|
13096
14434
|
}
|
|
13097
14435
|
const stateConfig = machine.config.states[state];
|
|
13098
14436
|
return stateConfig && stateConfig.on ? Object.keys(stateConfig.on) : [];
|
|
@@ -13106,9 +14444,10 @@ class StateMachinePlugin extends Plugin {
|
|
|
13106
14444
|
}
|
|
13107
14445
|
const { limit = 50, offset = 0 } = options;
|
|
13108
14446
|
const [ok, err, transitions] = await tryFn(
|
|
13109
|
-
() => this.database.resource(this.config.transitionLogResource).
|
|
13110
|
-
|
|
13111
|
-
|
|
14447
|
+
() => this.database.resource(this.config.transitionLogResource).query({
|
|
14448
|
+
machineId,
|
|
14449
|
+
entityId
|
|
14450
|
+
}, {
|
|
13112
14451
|
limit,
|
|
13113
14452
|
offset
|
|
13114
14453
|
})
|
|
@@ -13119,8 +14458,8 @@ class StateMachinePlugin extends Plugin {
|
|
|
13119
14458
|
}
|
|
13120
14459
|
return [];
|
|
13121
14460
|
}
|
|
13122
|
-
const
|
|
13123
|
-
return
|
|
14461
|
+
const sorted = (transitions || []).sort((a, b) => b.timestamp - a.timestamp);
|
|
14462
|
+
return sorted.map((t) => ({
|
|
13124
14463
|
from: t.fromState,
|
|
13125
14464
|
to: t.toState,
|
|
13126
14465
|
event: t.event,
|
|
@@ -13141,15 +14480,20 @@ class StateMachinePlugin extends Plugin {
|
|
|
13141
14480
|
if (this.config.persistTransitions) {
|
|
13142
14481
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
13143
14482
|
const stateId = `${machineId}_${entityId}`;
|
|
13144
|
-
await
|
|
13145
|
-
|
|
13146
|
-
|
|
13147
|
-
|
|
13148
|
-
|
|
13149
|
-
|
|
13150
|
-
|
|
13151
|
-
|
|
13152
|
-
|
|
14483
|
+
const [ok, err] = await tryFn(
|
|
14484
|
+
() => this.database.resource(this.config.stateResource).insert({
|
|
14485
|
+
id: stateId,
|
|
14486
|
+
machineId,
|
|
14487
|
+
entityId,
|
|
14488
|
+
currentState: initialState,
|
|
14489
|
+
context,
|
|
14490
|
+
lastTransition: null,
|
|
14491
|
+
updatedAt: now
|
|
14492
|
+
})
|
|
14493
|
+
);
|
|
14494
|
+
if (!ok && err && !err.message?.includes("already exists")) {
|
|
14495
|
+
throw new Error(`Failed to initialize entity state: ${err.message}`);
|
|
14496
|
+
}
|
|
13153
14497
|
}
|
|
13154
14498
|
const initialStateConfig = machine.config.states[initialState];
|
|
13155
14499
|
if (initialStateConfig && initialStateConfig.entry) {
|
|
@@ -13214,7 +14558,6 @@ class StateMachinePlugin extends Plugin {
|
|
|
13214
14558
|
}
|
|
13215
14559
|
async stop() {
|
|
13216
14560
|
this.machines.clear();
|
|
13217
|
-
this.stateStorage.clear();
|
|
13218
14561
|
}
|
|
13219
14562
|
async cleanup() {
|
|
13220
14563
|
await this.stop();
|
|
@@ -13222,5 +14565,5 @@ class StateMachinePlugin extends Plugin {
|
|
|
13222
14565
|
}
|
|
13223
14566
|
}
|
|
13224
14567
|
|
|
13225
|
-
export { AVAILABLE_BEHAVIORS, AuditPlugin, AuthenticationError, BackupPlugin, BaseError, CachePlugin, Client, ConnectionString, ConnectionStringError, CostsPlugin, CryptoError, DEFAULT_BEHAVIOR, Database, DatabaseError, EncryptionError, ErrorMap, EventualConsistencyPlugin, FullTextPlugin, InvalidResourceItem, MetricsPlugin, MissingMetadata, NoSuchBucket, NoSuchKey, NotFound, PartitionError, PermissionError, Plugin, PluginObject, ReplicatorPlugin, Resource, ResourceError, ResourceIdsPageReader, ResourceIdsReader, ResourceNotFound, ResourceReader, ResourceWriter, Database as S3db, S3dbError, SchedulerPlugin, Schema, SchemaError, StateMachinePlugin, UnknownError, ValidationError, Validator, behaviors, calculateAttributeNamesSize, calculateAttributeSizes, calculateEffectiveLimit, calculateSystemOverhead, calculateTotalSize, calculateUTF8Bytes, clearUTF8Cache, clearUTF8Memo, clearUTF8Memory, decode, decodeDecimal, decrypt, S3db as default, encode, encodeDecimal, encrypt, getBehavior, getSizeBreakdown, idGenerator, mapAwsError, md5, passwordGenerator, sha256, streamToString, transformValue, tryFn, tryFnSync };
|
|
14568
|
+
export { AVAILABLE_BEHAVIORS, AuditPlugin, AuthenticationError, BackupPlugin, BaseError, CachePlugin, Client, ConnectionString, ConnectionStringError, CostsPlugin, CryptoError, DEFAULT_BEHAVIOR, Database, DatabaseError, EncryptionError, ErrorMap, EventualConsistencyPlugin, FullTextPlugin, InvalidResourceItem, MetricsPlugin, MissingMetadata, NoSuchBucket, NoSuchKey, NotFound, PartitionError, PermissionError, Plugin, PluginObject, ReplicatorPlugin, Resource, ResourceError, ResourceIdsPageReader, ResourceIdsReader, ResourceNotFound, ResourceReader, ResourceWriter, S3QueuePlugin, Database as S3db, S3dbError, SchedulerPlugin, Schema, SchemaError, StateMachinePlugin, UnknownError, ValidationError, Validator, behaviors, calculateAttributeNamesSize, calculateAttributeSizes, calculateEffectiveLimit, calculateSystemOverhead, calculateTotalSize, calculateUTF8Bytes, clearUTF8Cache, clearUTF8Memo, clearUTF8Memory, decode, decodeDecimal, decrypt, S3db as default, encode, encodeDecimal, encrypt, getBehavior, getSizeBreakdown, idGenerator, mapAwsError, md5, passwordGenerator, sha256, streamToString, transformValue, tryFn, tryFnSync };
|
|
13226
14569
|
//# sourceMappingURL=s3db.es.js.map
|