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