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