s3db.js 12.1.0 → 12.2.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.
Files changed (43) hide show
  1. package/README.md +212 -196
  2. package/dist/s3db.cjs.js +1041 -1941
  3. package/dist/s3db.cjs.js.map +1 -1
  4. package/dist/s3db.es.js +1039 -1941
  5. package/dist/s3db.es.js.map +1 -1
  6. package/package.json +6 -1
  7. package/src/cli/index.js +954 -43
  8. package/src/cli/migration-manager.js +270 -0
  9. package/src/concerns/calculator.js +0 -4
  10. package/src/concerns/metadata-encoding.js +1 -21
  11. package/src/concerns/plugin-storage.js +17 -4
  12. package/src/concerns/typescript-generator.d.ts +171 -0
  13. package/src/concerns/typescript-generator.js +275 -0
  14. package/src/database.class.js +171 -28
  15. package/src/index.js +15 -9
  16. package/src/plugins/api/index.js +5 -2
  17. package/src/plugins/api/routes/resource-routes.js +86 -1
  18. package/src/plugins/api/server.js +79 -3
  19. package/src/plugins/api/utils/openapi-generator.js +195 -5
  20. package/src/plugins/backup/multi-backup-driver.class.js +0 -1
  21. package/src/plugins/backup.plugin.js +7 -14
  22. package/src/plugins/concerns/plugin-dependencies.js +73 -19
  23. package/src/plugins/eventual-consistency/analytics.js +0 -2
  24. package/src/plugins/eventual-consistency/consolidation.js +2 -13
  25. package/src/plugins/eventual-consistency/index.js +0 -1
  26. package/src/plugins/eventual-consistency/install.js +1 -1
  27. package/src/plugins/geo.plugin.js +5 -6
  28. package/src/plugins/importer/index.js +1 -1
  29. package/src/plugins/index.js +2 -1
  30. package/src/plugins/relation.plugin.js +11 -11
  31. package/src/plugins/replicator.plugin.js +12 -21
  32. package/src/plugins/s3-queue.plugin.js +4 -4
  33. package/src/plugins/scheduler.plugin.js +10 -12
  34. package/src/plugins/state-machine.plugin.js +8 -12
  35. package/src/plugins/tfstate/README.md +1 -1
  36. package/src/plugins/tfstate/errors.js +3 -3
  37. package/src/plugins/tfstate/index.js +41 -67
  38. package/src/plugins/ttl.plugin.js +3 -3
  39. package/src/resource.class.js +263 -61
  40. package/src/schema.class.js +0 -2
  41. package/src/testing/factory.class.js +286 -0
  42. package/src/testing/index.js +15 -0
  43. package/src/testing/seeder.class.js +183 -0
package/dist/s3db.es.js CHANGED
@@ -1,13 +1,10 @@
1
1
  import crypto$1, { createHash } from 'crypto';
2
2
  import { customAlphabet, urlAlphabet } from 'nanoid';
3
3
  import EventEmitter from 'events';
4
- import { Hono } from 'hono';
5
- import { serve } from '@hono/node-server';
6
- import { swaggerUI } from '@hono/swagger-ui';
7
4
  import { mkdir, copyFile, unlink, stat, access, readdir, writeFile, readFile, rm, watch } from 'fs/promises';
8
5
  import fs, { createReadStream, createWriteStream, realpathSync as realpathSync$1, readlinkSync, readdirSync, readdir as readdir$2, lstatSync, existsSync } from 'fs';
9
6
  import { pipeline } from 'stream/promises';
10
- import path$1, { join } from 'path';
7
+ import path$1, { join, dirname } from 'path';
11
8
  import { Transform, Writable } from 'stream';
12
9
  import zlib from 'node:zlib';
13
10
  import os from 'os';
@@ -209,8 +206,6 @@ function calculateUTF8Bytes(str) {
209
206
  function clearUTF8Memory() {
210
207
  utf8BytesMemory.clear();
211
208
  }
212
- const clearUTF8Memo = clearUTF8Memory;
213
- const clearUTF8Cache = clearUTF8Memory;
214
209
  function calculateAttributeNamesSize(mappedObject) {
215
210
  let totalSize = 0;
216
211
  for (const key of Object.keys(mappedObject)) {
@@ -1604,18 +1599,6 @@ function metadataDecode(value) {
1604
1599
  }
1605
1600
  }
1606
1601
  }
1607
- const len = value.length;
1608
- if (len > 0 && len % 4 === 0) {
1609
- if (/^[A-Za-z0-9+/]+=*$/.test(value)) {
1610
- try {
1611
- const decoded = Buffer.from(value, "base64").toString("utf8");
1612
- if (/[^\x00-\x7F]/.test(decoded) && Buffer.from(decoded, "utf8").toString("base64") === value) {
1613
- return decoded;
1614
- }
1615
- } catch {
1616
- }
1617
- }
1618
- }
1619
1602
  return value;
1620
1603
  }
1621
1604
 
@@ -1702,11 +1685,22 @@ class PluginStorage {
1702
1685
  }
1703
1686
  }
1704
1687
  /**
1705
- * Alias for set() to maintain backward compatibility
1706
- * @deprecated Use set() instead
1688
+ * Batch set multiple items
1689
+ *
1690
+ * @param {Array<{key: string, data: Object, options?: Object}>} items - Items to save
1691
+ * @returns {Promise<Array<{ok: boolean, key: string, error?: Error}>>} Results
1707
1692
  */
1708
- async put(key, data, options = {}) {
1709
- return this.set(key, data, options);
1693
+ async batchSet(items) {
1694
+ const results = [];
1695
+ for (const item of items) {
1696
+ try {
1697
+ await this.set(item.key, item.data, item.options || {});
1698
+ results.push({ ok: true, key: item.key });
1699
+ } catch (error) {
1700
+ results.push({ ok: false, key: item.key, error });
1701
+ }
1702
+ }
1703
+ return results;
1710
1704
  }
1711
1705
  /**
1712
1706
  * Get data with automatic metadata decoding and TTL check
@@ -2454,1800 +2448,100 @@ const PluginObject = {
2454
2448
  }
2455
2449
  };
2456
2450
 
2457
- function success(data, options = {}) {
2458
- const { status = 200, meta = {} } = options;
2459
- return {
2460
- success: true,
2461
- data,
2462
- meta: {
2463
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2464
- ...meta
2465
- },
2466
- _status: status
2467
- };
2468
- }
2469
- function error(error2, options = {}) {
2470
- const { status = 500, code = "INTERNAL_ERROR", details = {} } = options;
2471
- const errorMessage = error2 instanceof Error ? error2.message : error2;
2472
- const errorStack = error2 instanceof Error && process.env.NODE_ENV !== "production" ? error2.stack : void 0;
2473
- return {
2474
- success: false,
2475
- error: {
2476
- message: errorMessage,
2477
- code,
2478
- details,
2479
- stack: errorStack
2480
- },
2481
- meta: {
2482
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2483
- },
2484
- _status: status
2485
- };
2486
- }
2487
- function list(items, pagination = {}) {
2488
- const { total, page, pageSize, pageCount } = pagination;
2489
- return {
2490
- success: true,
2491
- data: items,
2492
- pagination: {
2493
- total: total || items.length,
2494
- page: page || 1,
2495
- pageSize: pageSize || items.length,
2496
- pageCount: pageCount || 1
2497
- },
2498
- meta: {
2499
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2500
- },
2501
- _status: 200
2502
- };
2503
- }
2504
- function created(data, location) {
2505
- return {
2506
- success: true,
2507
- data,
2508
- meta: {
2509
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2510
- location
2511
- },
2512
- _status: 201
2513
- };
2514
- }
2515
- function noContent() {
2516
- return {
2517
- success: true,
2518
- data: null,
2519
- meta: {
2520
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2521
- },
2522
- _status: 204
2523
- };
2524
- }
2525
- function notFound(resource, id) {
2526
- return error(`${resource} with id '${id}' not found`, {
2527
- status: 404,
2528
- code: "NOT_FOUND",
2529
- details: { resource, id }
2530
- });
2531
- }
2532
- function payloadTooLarge(size, limit) {
2533
- return error("Request payload too large", {
2534
- status: 413,
2535
- code: "PAYLOAD_TOO_LARGE",
2536
- details: {
2537
- receivedSize: size,
2538
- maxSize: limit,
2539
- receivedMB: (size / 1024 / 1024).toFixed(2),
2540
- maxMB: (limit / 1024 / 1024).toFixed(2)
2541
- }
2542
- });
2543
- }
2544
-
2545
- const errorStatusMap = {
2546
- "ValidationError": 400,
2547
- "InvalidResourceItem": 400,
2548
- "ResourceNotFound": 404,
2549
- "NoSuchKey": 404,
2550
- "NoSuchBucket": 404,
2551
- "PartitionError": 400,
2552
- "CryptoError": 500,
2553
- "SchemaError": 400,
2554
- "QueueError": 500,
2555
- "ResourceError": 500
2556
- };
2557
- function getStatusFromError(err) {
2558
- if (err.name && errorStatusMap[err.name]) {
2559
- return errorStatusMap[err.name];
2560
- }
2561
- if (err.constructor && err.constructor.name && errorStatusMap[err.constructor.name]) {
2562
- return errorStatusMap[err.constructor.name];
2563
- }
2564
- if (err.message) {
2565
- if (err.message.includes("not found") || err.message.includes("does not exist")) {
2566
- return 404;
2567
- }
2568
- if (err.message.includes("validation") || err.message.includes("invalid")) {
2569
- return 400;
2570
- }
2571
- if (err.message.includes("unauthorized") || err.message.includes("authentication")) {
2572
- return 401;
2573
- }
2574
- if (err.message.includes("forbidden") || err.message.includes("permission")) {
2575
- return 403;
2576
- }
2577
- }
2578
- return 500;
2579
- }
2580
- function errorHandler(err, c) {
2581
- const status = getStatusFromError(err);
2582
- const code = err.name || "INTERNAL_ERROR";
2583
- const details = {};
2584
- if (err.resource) details.resource = err.resource;
2585
- if (err.bucket) details.bucket = err.bucket;
2586
- if (err.key) details.key = err.key;
2587
- if (err.operation) details.operation = err.operation;
2588
- if (err.suggestion) details.suggestion = err.suggestion;
2589
- if (err.availableResources) details.availableResources = err.availableResources;
2590
- const response = error(err, {
2591
- status,
2592
- code,
2593
- details
2594
- });
2595
- if (status >= 500) {
2596
- console.error("[API Plugin] Error:", {
2597
- message: err.message,
2598
- code,
2599
- status,
2600
- stack: err.stack,
2601
- details
2602
- });
2603
- } else if (status >= 400 && status < 500 && c.get("verbose")) {
2604
- console.warn("[API Plugin] Client error:", {
2605
- message: err.message,
2606
- code,
2607
- status,
2608
- details
2609
- });
2610
- }
2611
- return c.json(response, response._status);
2612
- }
2613
- function asyncHandler(fn) {
2614
- return async (c) => {
2615
- try {
2616
- return await fn(c);
2617
- } catch (err) {
2618
- return errorHandler(err, c);
2619
- }
2620
- };
2621
- }
2622
-
2623
- function createResourceRoutes(resource, version, config = {}) {
2624
- const app = new Hono();
2625
- const {
2626
- methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
2627
- customMiddleware = [],
2628
- enableValidation = true
2629
- } = config;
2630
- const resourceName = resource.name;
2631
- const basePath = `/${version}/${resourceName}`;
2632
- customMiddleware.forEach((middleware) => {
2633
- app.use("*", middleware);
2634
- });
2635
- if (methods.includes("GET")) {
2636
- app.get("/", asyncHandler(async (c) => {
2637
- const query = c.req.query();
2638
- const limit = parseInt(query.limit) || 100;
2639
- const offset = parseInt(query.offset) || 0;
2640
- const partition = query.partition;
2641
- const partitionValues = query.partitionValues ? JSON.parse(query.partitionValues) : void 0;
2642
- const reservedKeys = ["limit", "offset", "partition", "partitionValues", "sort"];
2643
- const filters = {};
2644
- for (const [key, value] of Object.entries(query)) {
2645
- if (!reservedKeys.includes(key)) {
2646
- try {
2647
- filters[key] = JSON.parse(value);
2648
- } catch {
2649
- filters[key] = value;
2650
- }
2651
- }
2652
- }
2653
- let items;
2654
- let total;
2655
- if (Object.keys(filters).length > 0) {
2656
- items = await resource.query(filters, { limit: limit + offset });
2657
- items = items.slice(offset, offset + limit);
2658
- total = items.length;
2659
- } else if (partition && partitionValues) {
2660
- items = await resource.listPartition({
2661
- partition,
2662
- partitionValues,
2663
- limit: limit + offset
2664
- });
2665
- items = items.slice(offset, offset + limit);
2666
- total = items.length;
2667
- } else {
2668
- items = await resource.list({ limit: limit + offset });
2669
- items = items.slice(offset, offset + limit);
2670
- total = items.length;
2671
- }
2672
- const response = list(items, {
2673
- total,
2674
- page: Math.floor(offset / limit) + 1,
2675
- pageSize: limit,
2676
- pageCount: Math.ceil(total / limit)
2677
- });
2678
- c.header("X-Total-Count", total.toString());
2679
- c.header("X-Page-Count", Math.ceil(total / limit).toString());
2680
- return c.json(response, response._status);
2681
- }));
2682
- }
2683
- if (methods.includes("GET")) {
2684
- app.get("/:id", asyncHandler(async (c) => {
2685
- const id = c.req.param("id");
2686
- const query = c.req.query();
2687
- const partition = query.partition;
2688
- const partitionValues = query.partitionValues ? JSON.parse(query.partitionValues) : void 0;
2689
- let item;
2690
- if (partition && partitionValues) {
2691
- item = await resource.getFromPartition({
2692
- id,
2693
- partitionName: partition,
2694
- partitionValues
2695
- });
2696
- } else {
2697
- item = await resource.get(id);
2698
- }
2699
- if (!item) {
2700
- const response2 = notFound(resourceName, id);
2701
- return c.json(response2, response2._status);
2702
- }
2703
- const response = success(item);
2704
- return c.json(response, response._status);
2705
- }));
2706
- }
2707
- if (methods.includes("POST")) {
2708
- app.post("/", asyncHandler(async (c) => {
2709
- const data = await c.req.json();
2710
- const item = await resource.insert(data);
2711
- const location = `${basePath}/${item.id}`;
2712
- const response = created(item, location);
2713
- c.header("Location", location);
2714
- return c.json(response, response._status);
2715
- }));
2716
- }
2717
- if (methods.includes("PUT")) {
2718
- app.put("/:id", asyncHandler(async (c) => {
2719
- const id = c.req.param("id");
2720
- const data = await c.req.json();
2721
- const existing = await resource.get(id);
2722
- if (!existing) {
2723
- const response2 = notFound(resourceName, id);
2724
- return c.json(response2, response2._status);
2725
- }
2726
- const updated = await resource.update(id, data);
2727
- const response = success(updated);
2728
- return c.json(response, response._status);
2729
- }));
2730
- }
2731
- if (methods.includes("PATCH")) {
2732
- app.patch("/:id", asyncHandler(async (c) => {
2733
- const id = c.req.param("id");
2734
- const data = await c.req.json();
2735
- const existing = await resource.get(id);
2736
- if (!existing) {
2737
- const response2 = notFound(resourceName, id);
2738
- return c.json(response2, response2._status);
2739
- }
2740
- const merged = { ...existing, ...data, id };
2741
- const updated = await resource.update(id, merged);
2742
- const response = success(updated);
2743
- return c.json(response, response._status);
2744
- }));
2745
- }
2746
- if (methods.includes("DELETE")) {
2747
- app.delete("/:id", asyncHandler(async (c) => {
2748
- const id = c.req.param("id");
2749
- const existing = await resource.get(id);
2750
- if (!existing) {
2751
- const response2 = notFound(resourceName, id);
2752
- return c.json(response2, response2._status);
2753
- }
2754
- await resource.delete(id);
2755
- const response = noContent();
2756
- return c.json(response, response._status);
2757
- }));
2758
- }
2759
- if (methods.includes("HEAD")) {
2760
- app.on("HEAD", "/", asyncHandler(async (c) => {
2761
- const total = await resource.count();
2762
- const allItems = await resource.list({ limit: 1e3 });
2763
- const stats = {
2764
- total,
2765
- version: resource.config?.currentVersion || resource.version || "v1"
2766
- };
2767
- c.header("X-Total-Count", total.toString());
2768
- c.header("X-Resource-Version", stats.version);
2769
- c.header("X-Schema-Fields", Object.keys(resource.config?.attributes || {}).length.toString());
2770
- return c.body(null, 200);
2771
- }));
2772
- app.on("HEAD", "/:id", asyncHandler(async (c) => {
2773
- const id = c.req.param("id");
2774
- const item = await resource.get(id);
2775
- if (!item) {
2776
- return c.body(null, 404);
2777
- }
2778
- if (item.updatedAt) {
2779
- c.header("Last-Modified", new Date(item.updatedAt).toUTCString());
2780
- }
2781
- return c.body(null, 200);
2782
- }));
2783
- }
2784
- if (methods.includes("OPTIONS")) {
2785
- app.options("/", asyncHandler(async (c) => {
2786
- c.header("Allow", methods.join(", "));
2787
- const total = await resource.count();
2788
- const schema = resource.config?.attributes || {};
2789
- const version2 = resource.config?.currentVersion || resource.version || "v1";
2790
- const metadata = {
2791
- resource: resourceName,
2792
- version: version2,
2793
- totalRecords: total,
2794
- allowedMethods: methods,
2795
- schema: Object.entries(schema).map(([name, def]) => ({
2796
- name,
2797
- type: typeof def === "string" ? def.split("|")[0] : def.type,
2798
- rules: typeof def === "string" ? def.split("|").slice(1) : []
2799
- })),
2800
- endpoints: {
2801
- list: `/${version2}/${resourceName}`,
2802
- get: `/${version2}/${resourceName}/:id`,
2803
- create: `/${version2}/${resourceName}`,
2804
- update: `/${version2}/${resourceName}/:id`,
2805
- delete: `/${version2}/${resourceName}/:id`
2806
- },
2807
- queryParameters: {
2808
- limit: "number (1-1000, default: 100)",
2809
- offset: "number (min: 0, default: 0)",
2810
- partition: "string (partition name)",
2811
- partitionValues: "JSON string",
2812
- "[any field]": "any (filter by field value)"
2813
- }
2814
- };
2815
- return c.json(metadata);
2816
- }));
2817
- app.options("/:id", (c) => {
2818
- c.header("Allow", methods.filter((m) => m !== "POST").join(", "));
2819
- return c.body(null, 204);
2820
- });
2821
- }
2822
- return app;
2823
- }
2824
-
2825
- function mapFieldTypeToOpenAPI(fieldType) {
2826
- const type = fieldType.split("|")[0].trim();
2827
- const typeMap = {
2828
- "string": { type: "string" },
2829
- "number": { type: "number" },
2830
- "integer": { type: "integer" },
2831
- "boolean": { type: "boolean" },
2832
- "array": { type: "array", items: { type: "string" } },
2833
- "object": { type: "object" },
2834
- "json": { type: "object" },
2835
- "secret": { type: "string", format: "password" },
2836
- "email": { type: "string", format: "email" },
2837
- "url": { type: "string", format: "uri" },
2838
- "date": { type: "string", format: "date" },
2839
- "datetime": { type: "string", format: "date-time" },
2840
- "ip4": { type: "string", format: "ipv4", description: "IPv4 address" },
2841
- "ip6": { type: "string", format: "ipv6", description: "IPv6 address" },
2842
- "embedding": { type: "array", items: { type: "number" }, description: "Vector embedding" }
2843
- };
2844
- if (type.startsWith("embedding:")) {
2845
- const length = parseInt(type.split(":")[1]);
2846
- return {
2847
- type: "array",
2848
- items: { type: "number" },
2849
- minItems: length,
2850
- maxItems: length,
2851
- description: `Vector embedding (${length} dimensions)`
2852
- };
2853
- }
2854
- return typeMap[type] || { type: "string" };
2855
- }
2856
- function extractValidationRules(fieldDef) {
2857
- const rules = {};
2858
- const parts = fieldDef.split("|");
2859
- for (const part of parts) {
2860
- const [rule, value] = part.split(":").map((s) => s.trim());
2861
- switch (rule) {
2862
- case "required":
2863
- rules.required = true;
2864
- break;
2865
- case "min":
2866
- rules.minimum = parseFloat(value);
2867
- break;
2868
- case "max":
2869
- rules.maximum = parseFloat(value);
2870
- break;
2871
- case "minlength":
2872
- rules.minLength = parseInt(value);
2873
- break;
2874
- case "maxlength":
2875
- rules.maxLength = parseInt(value);
2876
- break;
2877
- case "pattern":
2878
- rules.pattern = value;
2879
- break;
2880
- case "enum":
2881
- rules.enum = value.split(",").map((v) => v.trim());
2882
- break;
2883
- case "default":
2884
- rules.default = value;
2885
- break;
2886
- }
2887
- }
2888
- return rules;
2889
- }
2890
- function generateResourceSchema(resource) {
2891
- const properties = {};
2892
- const required = [];
2893
- const attributes = resource.config?.attributes || resource.attributes || {};
2894
- properties.id = {
2895
- type: "string",
2896
- description: "Unique identifier for the resource",
2897
- example: "2_gDTpeU6EI0e8B92n_R3Y",
2898
- readOnly: true
2899
- };
2900
- for (const [fieldName, fieldDef] of Object.entries(attributes)) {
2901
- if (typeof fieldDef === "object" && fieldDef.type) {
2902
- const baseType = mapFieldTypeToOpenAPI(fieldDef.type);
2903
- properties[fieldName] = {
2904
- ...baseType,
2905
- description: fieldDef.description || void 0
2906
- };
2907
- if (fieldDef.required) {
2908
- required.push(fieldName);
2909
- }
2910
- if (fieldDef.type === "object" && fieldDef.props) {
2911
- properties[fieldName].properties = {};
2912
- for (const [propName, propDef] of Object.entries(fieldDef.props)) {
2913
- const propType = typeof propDef === "string" ? propDef : propDef.type;
2914
- properties[fieldName].properties[propName] = mapFieldTypeToOpenAPI(propType);
2915
- }
2916
- }
2917
- if (fieldDef.type === "array" && fieldDef.items) {
2918
- properties[fieldName].items = mapFieldTypeToOpenAPI(fieldDef.items);
2919
- }
2920
- } else if (typeof fieldDef === "string") {
2921
- const baseType = mapFieldTypeToOpenAPI(fieldDef);
2922
- const rules = extractValidationRules(fieldDef);
2923
- properties[fieldName] = {
2924
- ...baseType,
2925
- ...rules
2926
- };
2927
- if (rules.required) {
2928
- required.push(fieldName);
2929
- delete properties[fieldName].required;
2930
- }
2931
- }
2932
- }
2933
- return {
2934
- type: "object",
2935
- properties,
2936
- required: required.length > 0 ? required : void 0
2937
- };
2938
- }
2939
- function generateResourcePaths(resource, version, config = {}) {
2940
- const resourceName = resource.name;
2941
- const basePath = `/${version}/${resourceName}`;
2942
- const schema = generateResourceSchema(resource);
2943
- const methods = config.methods || ["GET", "POST", "PUT", "PATCH", "DELETE"];
2944
- const authMethods = config.auth || [];
2945
- const requiresAuth = authMethods && authMethods.length > 0;
2946
- const paths = {};
2947
- const security = [];
2948
- if (requiresAuth) {
2949
- if (authMethods.includes("jwt")) security.push({ bearerAuth: [] });
2950
- if (authMethods.includes("apiKey")) security.push({ apiKeyAuth: [] });
2951
- if (authMethods.includes("basic")) security.push({ basicAuth: [] });
2952
- }
2953
- const partitions = resource.config?.options?.partitions || resource.config?.partitions || resource.partitions || {};
2954
- const partitionNames = Object.keys(partitions);
2955
- const hasPartitions = partitionNames.length > 0;
2956
- let partitionDescription = "Partition name for filtering";
2957
- let partitionValuesDescription = "Partition values as JSON string";
2958
- let partitionExample = void 0;
2959
- let partitionValuesExample = void 0;
2960
- if (hasPartitions) {
2961
- const partitionDocs = partitionNames.map((name) => {
2962
- const partition = partitions[name];
2963
- const fields = Object.keys(partition.fields || {});
2964
- const fieldTypes = Object.entries(partition.fields || {}).map(([field, type]) => `${field}: ${type}`).join(", ");
2965
- return `- **${name}**: Filters by ${fields.join(", ")} (${fieldTypes})`;
2966
- }).join("\n");
2967
- partitionDescription = `Available partitions:
2968
- ${partitionDocs}`;
2969
- const examplePartition = partitionNames[0];
2970
- const exampleFields = partitions[examplePartition]?.fields || {};
2971
- Object.entries(exampleFields).map(([field, type]) => `"${field}": <${type} value>`).join(", ");
2972
- partitionValuesDescription = `Partition field values as JSON string. Must match the structure of the selected partition.
2973
-
2974
- Example for "${examplePartition}" partition: \`{"${Object.keys(exampleFields)[0]}": "value"}\``;
2975
- partitionExample = examplePartition;
2976
- const firstField = Object.keys(exampleFields)[0];
2977
- const firstFieldType = exampleFields[firstField];
2978
- let exampleValue = "example";
2979
- if (firstFieldType === "number" || firstFieldType === "integer") {
2980
- exampleValue = 123;
2981
- } else if (firstFieldType === "boolean") {
2982
- exampleValue = true;
2983
- }
2984
- partitionValuesExample = JSON.stringify({ [firstField]: exampleValue });
2985
- }
2986
- const attributeQueryParams = [];
2987
- if (hasPartitions) {
2988
- const partitionFieldsSet = /* @__PURE__ */ new Set();
2989
- for (const [partitionName, partition] of Object.entries(partitions)) {
2990
- const fields = partition.fields || {};
2991
- for (const fieldName of Object.keys(fields)) {
2992
- partitionFieldsSet.add(fieldName);
2993
- }
2994
- }
2995
- const attributes = resource.config?.attributes || resource.attributes || {};
2996
- for (const fieldName of partitionFieldsSet) {
2997
- const fieldDef = attributes[fieldName];
2998
- if (!fieldDef) continue;
2999
- let fieldType;
3000
- if (typeof fieldDef === "object" && fieldDef.type) {
3001
- fieldType = fieldDef.type;
3002
- } else if (typeof fieldDef === "string") {
3003
- fieldType = fieldDef.split("|")[0].trim();
3004
- } else {
3005
- fieldType = "string";
3006
- }
3007
- const openAPIType = mapFieldTypeToOpenAPI(fieldType);
3008
- attributeQueryParams.push({
3009
- name: fieldName,
3010
- in: "query",
3011
- description: `Filter by ${fieldName} field (indexed via partitions for efficient querying). Value will be parsed as JSON if possible, otherwise treated as string.`,
3012
- required: false,
3013
- schema: openAPIType
3014
- });
3015
- }
3016
- }
3017
- if (methods.includes("GET")) {
3018
- paths[basePath] = {
3019
- get: {
3020
- tags: [resourceName],
3021
- summary: `List ${resourceName}`,
3022
- description: `Retrieve a paginated list of ${resourceName}. Supports filtering by passing any resource field as a query parameter (e.g., ?status=active&year=2024). Values are parsed as JSON if possible, otherwise treated as strings.
3023
-
3024
- **Pagination**: Use \`limit\` and \`offset\` to paginate results. For example:
3025
- - First page (10 items): \`?limit=10&offset=0\`
3026
- - Second page: \`?limit=10&offset=10\`
3027
- - Third page: \`?limit=10&offset=20\`
3028
-
3029
- The response includes pagination metadata in the \`pagination\` object with total count and page information.${hasPartitions ? "\n\n**Partitioning**: This resource supports partitioned queries for optimized filtering. Use the `partition` and `partitionValues` parameters together." : ""}`,
3030
- parameters: [
3031
- {
3032
- name: "limit",
3033
- in: "query",
3034
- description: "Maximum number of items to return per page (page size)",
3035
- schema: { type: "integer", default: 100, minimum: 1, maximum: 1e3 },
3036
- example: 10
3037
- },
3038
- {
3039
- name: "offset",
3040
- in: "query",
3041
- description: "Number of items to skip before starting to return results. Use for pagination: offset = (page - 1) * limit",
3042
- schema: { type: "integer", default: 0, minimum: 0 },
3043
- example: 0
3044
- },
3045
- ...hasPartitions ? [
3046
- {
3047
- name: "partition",
3048
- in: "query",
3049
- description: partitionDescription,
3050
- schema: {
3051
- type: "string",
3052
- enum: partitionNames
3053
- },
3054
- example: partitionExample
3055
- },
3056
- {
3057
- name: "partitionValues",
3058
- in: "query",
3059
- description: partitionValuesDescription,
3060
- schema: { type: "string" },
3061
- example: partitionValuesExample
3062
- }
3063
- ] : [],
3064
- ...attributeQueryParams
3065
- ],
3066
- responses: {
3067
- 200: {
3068
- description: "Successful response",
3069
- content: {
3070
- "application/json": {
3071
- schema: {
3072
- type: "object",
3073
- properties: {
3074
- success: { type: "boolean", example: true },
3075
- data: {
3076
- type: "array",
3077
- items: schema
3078
- },
3079
- pagination: {
3080
- type: "object",
3081
- description: "Pagination metadata for the current request",
3082
- properties: {
3083
- total: {
3084
- type: "integer",
3085
- description: "Total number of items available",
3086
- example: 150
3087
- },
3088
- page: {
3089
- type: "integer",
3090
- description: "Current page number (1-indexed)",
3091
- example: 1
3092
- },
3093
- pageSize: {
3094
- type: "integer",
3095
- description: "Number of items per page (same as limit parameter)",
3096
- example: 10
3097
- },
3098
- pageCount: {
3099
- type: "integer",
3100
- description: "Total number of pages available",
3101
- example: 15
3102
- }
3103
- }
3104
- }
3105
- }
3106
- }
3107
- }
3108
- },
3109
- headers: {
3110
- "X-Total-Count": {
3111
- description: "Total number of records",
3112
- schema: { type: "integer" }
3113
- },
3114
- "X-Page-Count": {
3115
- description: "Total number of pages",
3116
- schema: { type: "integer" }
3117
- }
3118
- }
3119
- }
3120
- },
3121
- security: security.length > 0 ? security : void 0
3122
- }
3123
- };
3124
- }
3125
- if (methods.includes("GET")) {
3126
- paths[`${basePath}/{id}`] = {
3127
- get: {
3128
- tags: [resourceName],
3129
- summary: `Get ${resourceName} by ID`,
3130
- description: `Retrieve a single ${resourceName} by its ID${hasPartitions ? ". Optionally specify a partition for more efficient retrieval." : ""}`,
3131
- parameters: [
3132
- {
3133
- name: "id",
3134
- in: "path",
3135
- required: true,
3136
- description: `${resourceName} ID`,
3137
- schema: { type: "string" }
3138
- },
3139
- ...hasPartitions ? [
3140
- {
3141
- name: "partition",
3142
- in: "query",
3143
- description: partitionDescription,
3144
- schema: {
3145
- type: "string",
3146
- enum: partitionNames
3147
- },
3148
- example: partitionExample
3149
- },
3150
- {
3151
- name: "partitionValues",
3152
- in: "query",
3153
- description: partitionValuesDescription,
3154
- schema: { type: "string" },
3155
- example: partitionValuesExample
3156
- }
3157
- ] : []
3158
- ],
3159
- responses: {
3160
- 200: {
3161
- description: "Successful response",
3162
- content: {
3163
- "application/json": {
3164
- schema: {
3165
- type: "object",
3166
- properties: {
3167
- success: { type: "boolean", example: true },
3168
- data: schema
3169
- }
3170
- }
3171
- }
3172
- }
3173
- },
3174
- 404: {
3175
- description: "Resource not found",
3176
- content: {
3177
- "application/json": {
3178
- schema: { $ref: "#/components/schemas/Error" }
3179
- }
3180
- }
3181
- }
3182
- },
3183
- security: security.length > 0 ? security : void 0
3184
- }
3185
- };
3186
- }
3187
- if (methods.includes("POST")) {
3188
- if (!paths[basePath]) paths[basePath] = {};
3189
- paths[basePath].post = {
3190
- tags: [resourceName],
3191
- summary: `Create ${resourceName}`,
3192
- description: `Create a new ${resourceName}`,
3193
- requestBody: {
3194
- required: true,
3195
- content: {
3196
- "application/json": {
3197
- schema
3198
- }
3199
- }
3200
- },
3201
- responses: {
3202
- 201: {
3203
- description: "Resource created successfully",
3204
- content: {
3205
- "application/json": {
3206
- schema: {
3207
- type: "object",
3208
- properties: {
3209
- success: { type: "boolean", example: true },
3210
- data: schema
3211
- }
3212
- }
3213
- }
3214
- },
3215
- headers: {
3216
- Location: {
3217
- description: "URL of the created resource",
3218
- schema: { type: "string" }
3219
- }
3220
- }
3221
- },
3222
- 400: {
3223
- description: "Validation error",
3224
- content: {
3225
- "application/json": {
3226
- schema: { $ref: "#/components/schemas/ValidationError" }
3227
- }
3228
- }
3229
- }
3230
- },
3231
- security: security.length > 0 ? security : void 0
3232
- };
3233
- }
3234
- if (methods.includes("PUT")) {
3235
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3236
- paths[`${basePath}/{id}`].put = {
3237
- tags: [resourceName],
3238
- summary: `Update ${resourceName} (full)`,
3239
- description: `Fully update a ${resourceName}`,
3240
- parameters: [
3241
- {
3242
- name: "id",
3243
- in: "path",
3244
- required: true,
3245
- schema: { type: "string" }
3246
- }
3247
- ],
3248
- requestBody: {
3249
- required: true,
3250
- content: {
3251
- "application/json": {
3252
- schema
3253
- }
3254
- }
3255
- },
3256
- responses: {
3257
- 200: {
3258
- description: "Resource updated successfully",
3259
- content: {
3260
- "application/json": {
3261
- schema: {
3262
- type: "object",
3263
- properties: {
3264
- success: { type: "boolean", example: true },
3265
- data: schema
3266
- }
3267
- }
3268
- }
3269
- }
3270
- },
3271
- 404: {
3272
- description: "Resource not found",
3273
- content: {
3274
- "application/json": {
3275
- schema: { $ref: "#/components/schemas/Error" }
3276
- }
3277
- }
3278
- }
3279
- },
3280
- security: security.length > 0 ? security : void 0
3281
- };
3282
- }
3283
- if (methods.includes("PATCH")) {
3284
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3285
- paths[`${basePath}/{id}`].patch = {
3286
- tags: [resourceName],
3287
- summary: `Update ${resourceName} (partial)`,
3288
- description: `Partially update a ${resourceName}`,
3289
- parameters: [
3290
- {
3291
- name: "id",
3292
- in: "path",
3293
- required: true,
3294
- schema: { type: "string" }
3295
- }
3296
- ],
3297
- requestBody: {
3298
- required: true,
3299
- content: {
3300
- "application/json": {
3301
- schema: {
3302
- ...schema,
3303
- required: void 0
3304
- // Partial updates don't require all fields
3305
- }
3306
- }
3307
- }
3308
- },
3309
- responses: {
3310
- 200: {
3311
- description: "Resource updated successfully",
3312
- content: {
3313
- "application/json": {
3314
- schema: {
3315
- type: "object",
3316
- properties: {
3317
- success: { type: "boolean", example: true },
3318
- data: schema
3319
- }
3320
- }
3321
- }
3322
- }
3323
- },
3324
- 404: {
3325
- description: "Resource not found",
3326
- content: {
3327
- "application/json": {
3328
- schema: { $ref: "#/components/schemas/Error" }
3329
- }
3330
- }
3331
- }
3332
- },
3333
- security: security.length > 0 ? security : void 0
3334
- };
3335
- }
3336
- if (methods.includes("DELETE")) {
3337
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3338
- paths[`${basePath}/{id}`].delete = {
3339
- tags: [resourceName],
3340
- summary: `Delete ${resourceName}`,
3341
- description: `Delete a ${resourceName} by ID`,
3342
- parameters: [
3343
- {
3344
- name: "id",
3345
- in: "path",
3346
- required: true,
3347
- schema: { type: "string" }
3348
- }
3349
- ],
3350
- responses: {
3351
- 204: {
3352
- description: "Resource deleted successfully"
3353
- },
3354
- 404: {
3355
- description: "Resource not found",
3356
- content: {
3357
- "application/json": {
3358
- schema: { $ref: "#/components/schemas/Error" }
3359
- }
3360
- }
3361
- }
3362
- },
3363
- security: security.length > 0 ? security : void 0
3364
- };
3365
- }
3366
- if (methods.includes("HEAD")) {
3367
- if (!paths[basePath]) paths[basePath] = {};
3368
- paths[basePath].head = {
3369
- tags: [resourceName],
3370
- summary: `Get ${resourceName} statistics`,
3371
- description: `Get statistics about ${resourceName} collection without retrieving data. Returns statistics in response headers.`,
3372
- responses: {
3373
- 200: {
3374
- description: "Statistics retrieved successfully",
3375
- headers: {
3376
- "X-Total-Count": {
3377
- description: "Total number of records",
3378
- schema: { type: "integer" }
3379
- },
3380
- "X-Resource-Version": {
3381
- description: "Current resource version",
3382
- schema: { type: "string" }
3383
- },
3384
- "X-Schema-Fields": {
3385
- description: "Number of schema fields",
3386
- schema: { type: "integer" }
3387
- }
3388
- }
3389
- }
3390
- },
3391
- security: security.length > 0 ? security : void 0
3392
- };
3393
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3394
- paths[`${basePath}/{id}`].head = {
3395
- tags: [resourceName],
3396
- summary: `Check if ${resourceName} exists`,
3397
- description: `Check if a ${resourceName} exists without retrieving its data`,
3398
- parameters: [
3399
- {
3400
- name: "id",
3401
- in: "path",
3402
- required: true,
3403
- schema: { type: "string" }
3404
- }
3405
- ],
3406
- responses: {
3407
- 200: {
3408
- description: "Resource exists",
3409
- headers: {
3410
- "Last-Modified": {
3411
- description: "Last modification date",
3412
- schema: { type: "string", format: "date-time" }
3413
- }
3414
- }
3415
- },
3416
- 404: {
3417
- description: "Resource not found"
3418
- }
3419
- },
3420
- security: security.length > 0 ? security : void 0
3421
- };
3422
- }
3423
- if (methods.includes("OPTIONS")) {
3424
- if (!paths[basePath]) paths[basePath] = {};
3425
- paths[basePath].options = {
3426
- tags: [resourceName],
3427
- summary: `Get ${resourceName} metadata`,
3428
- description: `Get complete metadata about ${resourceName} resource including schema, allowed methods, endpoints, and query parameters`,
3429
- responses: {
3430
- 200: {
3431
- description: "Metadata retrieved successfully",
3432
- headers: {
3433
- "Allow": {
3434
- description: "Allowed HTTP methods",
3435
- schema: { type: "string", example: "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS" }
3436
- }
3437
- },
3438
- content: {
3439
- "application/json": {
3440
- schema: {
3441
- type: "object",
3442
- properties: {
3443
- resource: { type: "string" },
3444
- version: { type: "string" },
3445
- totalRecords: { type: "integer" },
3446
- allowedMethods: {
3447
- type: "array",
3448
- items: { type: "string" }
3449
- },
3450
- schema: {
3451
- type: "array",
3452
- items: {
3453
- type: "object",
3454
- properties: {
3455
- name: { type: "string" },
3456
- type: { type: "string" },
3457
- rules: { type: "array", items: { type: "string" } }
3458
- }
3459
- }
3460
- },
3461
- endpoints: {
3462
- type: "object",
3463
- properties: {
3464
- list: { type: "string" },
3465
- get: { type: "string" },
3466
- create: { type: "string" },
3467
- update: { type: "string" },
3468
- delete: { type: "string" }
3469
- }
3470
- },
3471
- queryParameters: { type: "object" }
3472
- }
3473
- }
3474
- }
3475
- }
3476
- }
3477
- }
3478
- };
3479
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3480
- paths[`${basePath}/{id}`].options = {
3481
- tags: [resourceName],
3482
- summary: `Get allowed methods for ${resourceName} item`,
3483
- description: `Get allowed HTTP methods for individual ${resourceName} operations`,
3484
- parameters: [
3485
- {
3486
- name: "id",
3487
- in: "path",
3488
- required: true,
3489
- schema: { type: "string" }
3490
- }
3491
- ],
3492
- responses: {
3493
- 204: {
3494
- description: "Methods retrieved successfully",
3495
- headers: {
3496
- "Allow": {
3497
- description: "Allowed HTTP methods",
3498
- schema: { type: "string", example: "GET, PUT, PATCH, DELETE, HEAD, OPTIONS" }
3499
- }
3500
- }
3501
- }
3502
- }
3503
- };
3504
- }
3505
- return paths;
3506
- }
3507
- function generateOpenAPISpec(database, config = {}) {
3508
- const {
3509
- title = "s3db.js API",
3510
- version = "1.0.0",
3511
- description = "Auto-generated REST API documentation for s3db.js resources",
3512
- serverUrl = "http://localhost:3000",
3513
- auth = {},
3514
- resources: resourceConfigs = {}
3515
- } = config;
3516
- const spec = {
3517
- openapi: "3.1.0",
3518
- info: {
3519
- title,
3520
- version,
3521
- description,
3522
- contact: {
3523
- name: "s3db.js",
3524
- url: "https://github.com/forattini-dev/s3db.js"
3525
- }
3526
- },
3527
- servers: [
3528
- {
3529
- url: serverUrl,
3530
- description: "API Server"
3531
- }
3532
- ],
3533
- paths: {},
3534
- components: {
3535
- schemas: {
3536
- Error: {
3537
- type: "object",
3538
- properties: {
3539
- success: { type: "boolean", example: false },
3540
- error: {
3541
- type: "object",
3542
- properties: {
3543
- message: { type: "string" },
3544
- code: { type: "string" },
3545
- details: { type: "object" }
3546
- }
3547
- }
3548
- }
3549
- },
3550
- ValidationError: {
3551
- type: "object",
3552
- properties: {
3553
- success: { type: "boolean", example: false },
3554
- error: {
3555
- type: "object",
3556
- properties: {
3557
- message: { type: "string", example: "Validation failed" },
3558
- code: { type: "string", example: "VALIDATION_ERROR" },
3559
- details: {
3560
- type: "object",
3561
- properties: {
3562
- errors: {
3563
- type: "array",
3564
- items: {
3565
- type: "object",
3566
- properties: {
3567
- field: { type: "string" },
3568
- message: { type: "string" },
3569
- expected: { type: "string" },
3570
- actual: {}
3571
- }
3572
- }
3573
- }
3574
- }
3575
- }
3576
- }
3577
- }
3578
- }
3579
- }
3580
- },
3581
- securitySchemes: {}
3582
- },
3583
- tags: []
3584
- };
3585
- if (auth.jwt?.enabled) {
3586
- spec.components.securitySchemes.bearerAuth = {
3587
- type: "http",
3588
- scheme: "bearer",
3589
- bearerFormat: "JWT",
3590
- description: "JWT authentication"
3591
- };
3592
- }
3593
- if (auth.apiKey?.enabled) {
3594
- spec.components.securitySchemes.apiKeyAuth = {
3595
- type: "apiKey",
3596
- in: "header",
3597
- name: auth.apiKey.headerName || "X-API-Key",
3598
- description: "API Key authentication"
3599
- };
3600
- }
3601
- if (auth.basic?.enabled) {
3602
- spec.components.securitySchemes.basicAuth = {
3603
- type: "http",
3604
- scheme: "basic",
3605
- description: "HTTP Basic authentication"
3606
- };
3607
- }
3608
- const resources = database.resources;
3609
- for (const [name, resource] of Object.entries(resources)) {
3610
- if (name.startsWith("plg_") && !resourceConfigs[name]) {
3611
- continue;
3612
- }
3613
- const config2 = resourceConfigs[name] || {
3614
- methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
3615
- auth: false
3616
- };
3617
- const version2 = resource.config?.currentVersion || resource.version || "v1";
3618
- const paths = generateResourcePaths(resource, version2, config2);
3619
- Object.assign(spec.paths, paths);
3620
- spec.tags.push({
3621
- name,
3622
- description: `Operations for ${name} resource`
3623
- });
3624
- spec.components.schemas[name] = generateResourceSchema(resource);
3625
- }
3626
- if (auth.jwt?.enabled || auth.apiKey?.enabled || auth.basic?.enabled) {
3627
- spec.paths["/auth/login"] = {
3628
- post: {
3629
- tags: ["Authentication"],
3630
- summary: "Login",
3631
- description: "Authenticate with username and password",
3632
- requestBody: {
3633
- required: true,
3634
- content: {
3635
- "application/json": {
3636
- schema: {
3637
- type: "object",
3638
- properties: {
3639
- username: { type: "string" },
3640
- password: { type: "string", format: "password" }
3641
- },
3642
- required: ["username", "password"]
3643
- }
3644
- }
3645
- }
3646
- },
3647
- responses: {
3648
- 200: {
3649
- description: "Login successful",
3650
- content: {
3651
- "application/json": {
3652
- schema: {
3653
- type: "object",
3654
- properties: {
3655
- success: { type: "boolean", example: true },
3656
- data: {
3657
- type: "object",
3658
- properties: {
3659
- token: { type: "string" },
3660
- user: { type: "object" }
3661
- }
3662
- }
3663
- }
3664
- }
3665
- }
3666
- }
3667
- },
3668
- 401: {
3669
- description: "Invalid credentials",
3670
- content: {
3671
- "application/json": {
3672
- schema: { $ref: "#/components/schemas/Error" }
3673
- }
3674
- }
3675
- }
3676
- }
3677
- }
3678
- };
3679
- spec.paths["/auth/register"] = {
3680
- post: {
3681
- tags: ["Authentication"],
3682
- summary: "Register",
3683
- description: "Register a new user",
3684
- requestBody: {
3685
- required: true,
3686
- content: {
3687
- "application/json": {
3688
- schema: {
3689
- type: "object",
3690
- properties: {
3691
- username: { type: "string", minLength: 3 },
3692
- password: { type: "string", format: "password", minLength: 8 },
3693
- email: { type: "string", format: "email" }
3694
- },
3695
- required: ["username", "password"]
3696
- }
3697
- }
3698
- }
3699
- },
3700
- responses: {
3701
- 201: {
3702
- description: "User registered successfully",
3703
- content: {
3704
- "application/json": {
3705
- schema: {
3706
- type: "object",
3707
- properties: {
3708
- success: { type: "boolean", example: true },
3709
- data: {
3710
- type: "object",
3711
- properties: {
3712
- token: { type: "string" },
3713
- user: { type: "object" }
3714
- }
3715
- }
3716
- }
3717
- }
3718
- }
3719
- }
3720
- }
3721
- }
3722
- }
3723
- };
3724
- spec.tags.push({
3725
- name: "Authentication",
3726
- description: "Authentication endpoints"
3727
- });
3728
- }
3729
- spec.paths["/health"] = {
3730
- get: {
3731
- tags: ["Health"],
3732
- summary: "Generic Health Check",
3733
- description: "Generic health check endpoint that includes references to liveness and readiness probes",
3734
- responses: {
3735
- 200: {
3736
- description: "API is healthy",
3737
- content: {
3738
- "application/json": {
3739
- schema: {
3740
- type: "object",
3741
- properties: {
3742
- success: { type: "boolean", example: true },
3743
- data: {
3744
- type: "object",
3745
- properties: {
3746
- status: { type: "string", example: "ok" },
3747
- uptime: { type: "number", description: "Process uptime in seconds" },
3748
- timestamp: { type: "string", format: "date-time" },
3749
- checks: {
3750
- type: "object",
3751
- properties: {
3752
- liveness: { type: "string", example: "/health/live" },
3753
- readiness: { type: "string", example: "/health/ready" }
3754
- }
3755
- }
3756
- }
3757
- }
3758
- }
3759
- }
3760
- }
3761
- }
3762
- }
3763
- }
3764
- }
3765
- };
3766
- spec.paths["/health/live"] = {
3767
- get: {
3768
- tags: ["Health"],
3769
- summary: "Liveness Probe",
3770
- description: "Kubernetes liveness probe - checks if the application is alive. If this fails, Kubernetes will restart the pod.",
3771
- responses: {
3772
- 200: {
3773
- description: "Application is alive",
3774
- content: {
3775
- "application/json": {
3776
- schema: {
3777
- type: "object",
3778
- properties: {
3779
- success: { type: "boolean", example: true },
3780
- data: {
3781
- type: "object",
3782
- properties: {
3783
- status: { type: "string", example: "alive" },
3784
- timestamp: { type: "string", format: "date-time" }
3785
- }
3786
- }
3787
- }
3788
- }
3789
- }
3790
- }
3791
- }
3792
- }
3793
- }
3794
- };
3795
- spec.paths["/health/ready"] = {
3796
- get: {
3797
- tags: ["Health"],
3798
- summary: "Readiness Probe",
3799
- description: "Kubernetes readiness probe - checks if the application is ready to receive traffic. If this fails, Kubernetes will remove the pod from service endpoints.",
3800
- responses: {
3801
- 200: {
3802
- description: "Application is ready to receive traffic",
3803
- content: {
3804
- "application/json": {
3805
- schema: {
3806
- type: "object",
3807
- properties: {
3808
- success: { type: "boolean", example: true },
3809
- data: {
3810
- type: "object",
3811
- properties: {
3812
- status: { type: "string", example: "ready" },
3813
- database: {
3814
- type: "object",
3815
- properties: {
3816
- connected: { type: "boolean", example: true },
3817
- resources: { type: "integer", example: 5 }
3818
- }
3819
- },
3820
- timestamp: { type: "string", format: "date-time" }
3821
- }
3822
- }
3823
- }
3824
- }
3825
- }
3826
- }
3827
- },
3828
- 503: {
3829
- description: "Application is not ready",
3830
- content: {
3831
- "application/json": {
3832
- schema: {
3833
- type: "object",
3834
- properties: {
3835
- success: { type: "boolean", example: false },
3836
- error: {
3837
- type: "object",
3838
- properties: {
3839
- message: { type: "string", example: "Service not ready" },
3840
- code: { type: "string", example: "NOT_READY" },
3841
- details: {
3842
- type: "object",
3843
- properties: {
3844
- database: {
3845
- type: "object",
3846
- properties: {
3847
- connected: { type: "boolean", example: false },
3848
- resources: { type: "integer", example: 0 }
3849
- }
3850
- }
3851
- }
3852
- }
3853
- }
3854
- }
3855
- }
3856
- }
3857
- }
3858
- }
3859
- }
3860
- }
3861
- }
3862
- };
3863
- spec.tags.push({
3864
- name: "Health",
3865
- description: "Health check endpoints for monitoring and Kubernetes probes"
3866
- });
3867
- const metricsPlugin = database.plugins?.metrics || database.plugins?.MetricsPlugin;
3868
- if (metricsPlugin && metricsPlugin.config?.prometheus?.enabled) {
3869
- const metricsPath = metricsPlugin.config.prometheus.path || "/metrics";
3870
- const isIntegrated = metricsPlugin.config.prometheus.mode !== "standalone";
3871
- if (isIntegrated) {
3872
- spec.paths[metricsPath] = {
3873
- get: {
3874
- tags: ["Monitoring"],
3875
- summary: "Prometheus Metrics",
3876
- description: "Exposes application metrics in Prometheus text-based exposition format for monitoring and observability. Metrics include operation counts, durations, errors, uptime, and resource statistics.",
3877
- responses: {
3878
- 200: {
3879
- description: "Metrics in Prometheus format",
3880
- content: {
3881
- "text/plain": {
3882
- schema: {
3883
- type: "string",
3884
- example: '# HELP s3db_operations_total Total number of operations by type and resource\n# TYPE s3db_operations_total counter\ns3db_operations_total{operation="insert",resource="cars"} 1523\ns3db_operations_total{operation="update",resource="cars"} 342\n\n# HELP s3db_operation_duration_seconds Average operation duration in seconds\n# TYPE s3db_operation_duration_seconds gauge\ns3db_operation_duration_seconds{operation="insert",resource="cars"} 0.045\n\n# HELP s3db_operation_errors_total Total number of operation errors\n# TYPE s3db_operation_errors_total counter\ns3db_operation_errors_total{operation="insert",resource="cars"} 12\n'
3885
- }
3886
- }
3887
- }
3888
- }
3889
- }
3890
- }
3891
- };
3892
- spec.tags.push({
3893
- name: "Monitoring",
3894
- description: "Monitoring and observability endpoints (Prometheus)"
3895
- });
3896
- }
3897
- }
3898
- return spec;
3899
- }
3900
-
3901
- class ApiServer {
3902
- /**
3903
- * Create API server
3904
- * @param {Object} options - Server options
3905
- * @param {number} options.port - Server port
3906
- * @param {string} options.host - Server host
3907
- * @param {Object} options.database - s3db.js database instance
3908
- * @param {Object} options.resources - Resource configuration
3909
- * @param {Array} options.middlewares - Global middlewares
3910
- */
3911
- constructor(options = {}) {
3912
- this.options = {
3913
- port: options.port || 3e3,
3914
- host: options.host || "0.0.0.0",
3915
- database: options.database,
3916
- resources: options.resources || {},
3917
- middlewares: options.middlewares || [],
3918
- verbose: options.verbose || false,
3919
- auth: options.auth || {},
3920
- docsEnabled: options.docsEnabled !== false,
3921
- // Enable /docs by default
3922
- docsUI: options.docsUI || "redoc",
3923
- // 'swagger' or 'redoc'
3924
- maxBodySize: options.maxBodySize || 10 * 1024 * 1024,
3925
- // 10MB default
3926
- rootHandler: options.rootHandler,
3927
- // Custom handler for root path, if not provided redirects to /docs
3928
- apiInfo: {
3929
- title: options.apiTitle || "s3db.js API",
3930
- version: options.apiVersion || "1.0.0",
3931
- description: options.apiDescription || "Auto-generated REST API for s3db.js resources"
3932
- }
3933
- };
3934
- this.app = new Hono();
3935
- this.server = null;
3936
- this.isRunning = false;
3937
- this.openAPISpec = null;
3938
- this._setupRoutes();
3939
- }
3940
- /**
3941
- * Setup all routes
3942
- * @private
3943
- */
3944
- _setupRoutes() {
3945
- this.options.middlewares.forEach((middleware) => {
3946
- this.app.use("*", middleware);
3947
- });
3948
- this.app.use("*", async (c, next) => {
3949
- const method = c.req.method;
3950
- if (["POST", "PUT", "PATCH"].includes(method)) {
3951
- const contentLength = c.req.header("content-length");
3952
- if (contentLength) {
3953
- const size = parseInt(contentLength);
3954
- if (size > this.options.maxBodySize) {
3955
- const response = payloadTooLarge(size, this.options.maxBodySize);
3956
- c.header("Connection", "close");
3957
- return c.json(response, response._status);
3958
- }
3959
- }
3960
- }
3961
- await next();
3962
- });
3963
- this.app.get("/health/live", (c) => {
3964
- const response = success({
3965
- status: "alive",
3966
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3967
- });
3968
- return c.json(response);
3969
- });
3970
- this.app.get("/health/ready", (c) => {
3971
- const isReady = this.options.database && this.options.database.connected && Object.keys(this.options.database.resources).length > 0;
3972
- if (!isReady) {
3973
- const response2 = error("Service not ready", {
3974
- status: 503,
3975
- code: "NOT_READY",
3976
- details: {
3977
- database: {
3978
- connected: this.options.database?.connected || false,
3979
- resources: Object.keys(this.options.database?.resources || {}).length
3980
- }
3981
- }
3982
- });
3983
- return c.json(response2, 503);
3984
- }
3985
- const response = success({
3986
- status: "ready",
3987
- database: {
3988
- connected: true,
3989
- resources: Object.keys(this.options.database.resources).length
3990
- },
3991
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3992
- });
3993
- return c.json(response);
3994
- });
3995
- this.app.get("/health", (c) => {
3996
- const response = success({
3997
- status: "ok",
3998
- uptime: process.uptime(),
3999
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4000
- checks: {
4001
- liveness: "/health/live",
4002
- readiness: "/health/ready"
4003
- }
4004
- });
4005
- return c.json(response);
4006
- });
4007
- this.app.get("/", (c) => {
4008
- if (this.options.rootHandler) {
4009
- return this.options.rootHandler(c);
4010
- }
4011
- return c.redirect("/docs", 302);
4012
- });
4013
- if (this.options.docsEnabled) {
4014
- this.app.get("/openapi.json", (c) => {
4015
- if (!this.openAPISpec) {
4016
- this.openAPISpec = this._generateOpenAPISpec();
4017
- }
4018
- return c.json(this.openAPISpec);
4019
- });
4020
- if (this.options.docsUI === "swagger") {
4021
- this.app.get("/docs", swaggerUI({
4022
- url: "/openapi.json"
4023
- }));
4024
- } else {
4025
- this.app.get("/docs", (c) => {
4026
- return c.html(`<!DOCTYPE html>
4027
- <html lang="en">
4028
- <head>
4029
- <meta charset="UTF-8">
4030
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
4031
- <title>${this.options.apiInfo.title} - API Documentation</title>
4032
- <style>
4033
- body {
4034
- margin: 0;
4035
- padding: 0;
4036
- }
4037
- </style>
4038
- </head>
4039
- <body>
4040
- <redoc spec-url="/openapi.json"></redoc>
4041
- <script src="https://cdn.redoc.ly/redoc/v2.5.1/bundles/redoc.standalone.js"><\/script>
4042
- </body>
4043
- </html>`);
4044
- });
4045
- }
4046
- }
4047
- this._setupResourceRoutes();
4048
- this.app.onError((err, c) => {
4049
- return errorHandler(err, c);
4050
- });
4051
- this.app.notFound((c) => {
4052
- const response = error("Route not found", {
4053
- status: 404,
4054
- code: "NOT_FOUND",
4055
- details: {
4056
- path: c.req.path,
4057
- method: c.req.method
4058
- }
4059
- });
4060
- return c.json(response, 404);
4061
- });
4062
- }
4063
- /**
4064
- * Setup routes for all resources
4065
- * @private
4066
- */
4067
- _setupResourceRoutes() {
4068
- const { database, resources: resourceConfigs } = this.options;
4069
- const resources = database.resources;
4070
- for (const [name, resource] of Object.entries(resources)) {
4071
- if (name.startsWith("plg_") && !resourceConfigs[name]) {
4072
- continue;
4073
- }
4074
- const config = resourceConfigs[name] || {
4075
- methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]};
4076
- const version = resource.config?.currentVersion || resource.version || "v1";
4077
- const resourceApp = createResourceRoutes(resource, version, {
4078
- methods: config.methods,
4079
- customMiddleware: config.customMiddleware || [],
4080
- enableValidation: config.validation !== false
4081
- });
4082
- this.app.route(`/${version}/${name}`, resourceApp);
4083
- if (this.options.verbose) {
4084
- console.log(`[API Plugin] Mounted routes for resource '${name}' at /${version}/${name}`);
4085
- }
4086
- }
4087
- }
4088
- /**
4089
- * Start the server
4090
- * @returns {Promise<void>}
4091
- */
4092
- async start() {
4093
- if (this.isRunning) {
4094
- console.warn("[API Plugin] Server is already running");
4095
- return;
4096
- }
4097
- const { port, host } = this.options;
4098
- return new Promise((resolve, reject) => {
4099
- try {
4100
- this.server = serve({
4101
- fetch: this.app.fetch,
4102
- port,
4103
- hostname: host
4104
- }, (info) => {
4105
- this.isRunning = true;
4106
- console.log(`[API Plugin] Server listening on http://${info.address}:${info.port}`);
4107
- resolve();
4108
- });
4109
- } catch (err) {
4110
- reject(err);
4111
- }
4112
- });
4113
- }
4114
- /**
4115
- * Stop the server
4116
- * @returns {Promise<void>}
4117
- */
4118
- async stop() {
4119
- if (!this.isRunning) {
4120
- console.warn("[API Plugin] Server is not running");
4121
- return;
4122
- }
4123
- if (this.server && typeof this.server.close === "function") {
4124
- await new Promise((resolve) => {
4125
- this.server.close(() => {
4126
- this.isRunning = false;
4127
- console.log("[API Plugin] Server stopped");
4128
- resolve();
4129
- });
4130
- });
4131
- } else {
4132
- this.isRunning = false;
4133
- console.log("[API Plugin] Server stopped");
4134
- }
4135
- }
4136
- /**
4137
- * Get server info
4138
- * @returns {Object} Server information
4139
- */
4140
- getInfo() {
4141
- return {
4142
- isRunning: this.isRunning,
4143
- port: this.options.port,
4144
- host: this.options.host,
4145
- resources: Object.keys(this.options.database.resources).length
4146
- };
4147
- }
4148
- /**
4149
- * Get Hono app instance
4150
- * @returns {Hono} Hono app
4151
- */
4152
- getApp() {
4153
- return this.app;
4154
- }
4155
- /**
4156
- * Generate OpenAPI specification
4157
- * @private
4158
- * @returns {Object} OpenAPI spec
4159
- */
4160
- _generateOpenAPISpec() {
4161
- const { port, host, database, resources, auth, apiInfo } = this.options;
4162
- return generateOpenAPISpec(database, {
4163
- title: apiInfo.title,
4164
- version: apiInfo.version,
4165
- description: apiInfo.description,
4166
- serverUrl: `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`,
4167
- auth,
4168
- resources
4169
- });
4170
- }
4171
- }
4172
-
4173
2451
  const PLUGIN_DEPENDENCIES = {
4174
2452
  "postgresql-replicator": {
4175
2453
  name: "PostgreSQL Replicator",
2454
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/replicator.md",
4176
2455
  dependencies: {
4177
2456
  "pg": {
4178
2457
  version: "^8.0.0",
4179
2458
  description: "PostgreSQL client for Node.js",
4180
- installCommand: "pnpm add pg"
2459
+ installCommand: "pnpm add pg",
2460
+ npmUrl: "https://www.npmjs.com/package/pg"
4181
2461
  }
4182
2462
  }
4183
2463
  },
4184
2464
  "bigquery-replicator": {
4185
2465
  name: "BigQuery Replicator",
2466
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/replicator.md",
4186
2467
  dependencies: {
4187
2468
  "@google-cloud/bigquery": {
4188
2469
  version: "^7.0.0",
4189
2470
  description: "Google Cloud BigQuery SDK",
4190
- installCommand: "pnpm add @google-cloud/bigquery"
2471
+ installCommand: "pnpm add @google-cloud/bigquery",
2472
+ npmUrl: "https://www.npmjs.com/package/@google-cloud/bigquery"
4191
2473
  }
4192
2474
  }
4193
2475
  },
4194
2476
  "sqs-replicator": {
4195
2477
  name: "SQS Replicator",
2478
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/replicator.md",
4196
2479
  dependencies: {
4197
2480
  "@aws-sdk/client-sqs": {
4198
2481
  version: "^3.0.0",
4199
2482
  description: "AWS SDK for SQS",
4200
- installCommand: "pnpm add @aws-sdk/client-sqs"
2483
+ installCommand: "pnpm add @aws-sdk/client-sqs",
2484
+ npmUrl: "https://www.npmjs.com/package/@aws-sdk/client-sqs"
4201
2485
  }
4202
2486
  }
4203
2487
  },
4204
2488
  "sqs-consumer": {
4205
2489
  name: "SQS Queue Consumer",
2490
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/queue-consumer.md",
4206
2491
  dependencies: {
4207
2492
  "@aws-sdk/client-sqs": {
4208
2493
  version: "^3.0.0",
4209
2494
  description: "AWS SDK for SQS",
4210
- installCommand: "pnpm add @aws-sdk/client-sqs"
2495
+ installCommand: "pnpm add @aws-sdk/client-sqs",
2496
+ npmUrl: "https://www.npmjs.com/package/@aws-sdk/client-sqs"
4211
2497
  }
4212
2498
  }
4213
2499
  },
4214
2500
  "rabbitmq-consumer": {
4215
2501
  name: "RabbitMQ Queue Consumer",
2502
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/queue-consumer.md",
4216
2503
  dependencies: {
4217
2504
  "amqplib": {
4218
2505
  version: "^0.10.0",
4219
2506
  description: "AMQP 0-9-1 library for RabbitMQ",
4220
- installCommand: "pnpm add amqplib"
2507
+ installCommand: "pnpm add amqplib",
2508
+ npmUrl: "https://www.npmjs.com/package/amqplib"
4221
2509
  }
4222
2510
  }
4223
2511
  },
4224
2512
  "tfstate-plugin": {
4225
- name: "Terraform State Plugin",
2513
+ name: "Tfstate Plugin",
2514
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/tfstate.md",
4226
2515
  dependencies: {
4227
2516
  "node-cron": {
4228
2517
  version: "^4.0.0",
4229
2518
  description: "Cron job scheduler for auto-sync functionality",
4230
- installCommand: "pnpm add node-cron"
2519
+ installCommand: "pnpm add node-cron",
2520
+ npmUrl: "https://www.npmjs.com/package/node-cron"
4231
2521
  }
4232
2522
  }
4233
2523
  },
4234
2524
  "api-plugin": {
4235
2525
  name: "API Plugin",
2526
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/api.md",
4236
2527
  dependencies: {
4237
2528
  "hono": {
4238
2529
  version: "^4.0.0",
4239
2530
  description: "Ultra-light HTTP server framework",
4240
- installCommand: "pnpm add hono"
2531
+ installCommand: "pnpm add hono",
2532
+ npmUrl: "https://www.npmjs.com/package/hono"
4241
2533
  },
4242
2534
  "@hono/node-server": {
4243
2535
  version: "^1.0.0",
4244
2536
  description: "Node.js adapter for Hono",
4245
- installCommand: "pnpm add @hono/node-server"
2537
+ installCommand: "pnpm add @hono/node-server",
2538
+ npmUrl: "https://www.npmjs.com/package/@hono/node-server"
4246
2539
  },
4247
2540
  "@hono/swagger-ui": {
4248
2541
  version: "^0.4.0",
4249
2542
  description: "Swagger UI integration for Hono",
4250
- installCommand: "pnpm add @hono/swagger-ui"
2543
+ installCommand: "pnpm add @hono/swagger-ui",
2544
+ npmUrl: "https://www.npmjs.com/package/@hono/swagger-ui"
4251
2545
  }
4252
2546
  }
4253
2547
  }
@@ -4333,21 +2627,55 @@ async function requirePluginDependency(pluginId, options = {}) {
4333
2627
  }
4334
2628
  const valid = missing.length === 0 && incompatible.length === 0;
4335
2629
  if (!valid && throwOnError) {
2630
+ const depCount = Object.keys(pluginDef.dependencies).length;
2631
+ const missingCount = missing.length;
2632
+ const incompatCount = incompatible.length;
4336
2633
  const errorMsg = [
4337
- `
4338
- ${pluginDef.name} - Missing dependencies detected!
4339
- `,
4340
- `Plugin ID: ${pluginId}`,
4341
2634
  "",
2635
+ "\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
2636
+ `\u2551 \u274C ${pluginDef.name} - Missing Dependencies \u2551`,
2637
+ "\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
2638
+ "",
2639
+ `\u{1F4E6} Plugin: ${pluginId}`,
2640
+ `\u{1F4CA} Status: ${depCount - missingCount - incompatCount}/${depCount} dependencies satisfied`,
2641
+ "",
2642
+ "\u{1F50D} Dependency Status:",
2643
+ "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
4342
2644
  ...messages,
4343
2645
  "",
4344
- "Quick fix - Run all install commands:",
4345
- Object.values(pluginDef.dependencies).map((dep) => ` ${dep.installCommand}`).join("\n"),
2646
+ "\u{1F680} Quick Fix - Install Missing Dependencies:",
2647
+ "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
2648
+ "",
2649
+ " Option 1: Install individually",
2650
+ ...Object.entries(pluginDef.dependencies).filter(([pkg]) => missing.includes(pkg) || incompatible.includes(pkg)).map(([pkg, info]) => ` ${info.installCommand}`),
2651
+ "",
2652
+ " Option 2: Install all at once",
2653
+ ` pnpm add ${Object.keys(pluginDef.dependencies).join(" ")}`,
4346
2654
  "",
4347
- "Or install all peer dependencies at once:",
4348
- ` pnpm add ${Object.keys(pluginDef.dependencies).join(" ")}`
2655
+ "\u{1F4DA} Documentation:",
2656
+ ` ${pluginDef.docsUrl}`,
2657
+ "",
2658
+ "\u{1F4A1} Troubleshooting:",
2659
+ " \u2022 If packages are installed but not detected, try:",
2660
+ " 1. Delete node_modules and reinstall: rm -rf node_modules && pnpm install",
2661
+ " 2. Check Node.js version: node --version (requires Node 18+)",
2662
+ " 3. Verify pnpm version: pnpm --version (requires pnpm 8+)",
2663
+ "",
2664
+ " \u2022 Still having issues? Check:",
2665
+ " - Package.json has correct dependencies listed",
2666
+ " - No conflicting versions in pnpm-lock.yaml",
2667
+ " - File permissions (especially in node_modules/)",
2668
+ "",
2669
+ "\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550",
2670
+ ""
4349
2671
  ].join("\n");
4350
- throw new Error(errorMsg);
2672
+ const error = new Error(errorMsg);
2673
+ error.pluginId = pluginId;
2674
+ error.pluginName = pluginDef.name;
2675
+ error.missing = missing;
2676
+ error.incompatible = incompatible;
2677
+ error.docsUrl = pluginDef.docsUrl;
2678
+ throw error;
4351
2679
  }
4352
2680
  return { valid, missing, incompatible, messages };
4353
2681
  }
@@ -4364,7 +2692,6 @@ class ApiPlugin extends Plugin {
4364
2692
  port: options.port || 3e3,
4365
2693
  host: options.host || "0.0.0.0",
4366
2694
  verbose: options.verbose || false,
4367
- // API Documentation (supports both new and legacy formats)
4368
2695
  docs: {
4369
2696
  enabled: options.docs?.enabled !== false && options.docsEnabled !== false,
4370
2697
  // Enable by default
@@ -4681,6 +3008,11 @@ class ApiPlugin extends Plugin {
4681
3008
  if (this.config.verbose) {
4682
3009
  console.log("[API Plugin] Starting server...");
4683
3010
  }
3011
+ const serverPath = "./server.js";
3012
+ const { ApiServer } = await import(
3013
+ /* @vite-ignore */
3014
+ serverPath
3015
+ );
4684
3016
  this.server = new ApiServer({
4685
3017
  port: this.config.port,
4686
3018
  host: this.config.host,
@@ -5743,8 +4075,6 @@ class MultiBackupDriver extends BaseBackupDriver {
5743
4075
  strategy: "all",
5744
4076
  // 'all', 'any', 'priority'
5745
4077
  concurrency: 3,
5746
- requireAll: true,
5747
- // For backward compatibility
5748
4078
  ...config
5749
4079
  });
5750
4080
  this.drivers = [];
@@ -6381,13 +4711,13 @@ class BackupPlugin extends Plugin {
6381
4711
  createdAt: now.toISOString().slice(0, 10)
6382
4712
  };
6383
4713
  const [ok] = await tryFn(
6384
- () => this.database.resource(this.config.backupMetadataResource).insert(metadata)
4714
+ () => this.database.resources[this.config.backupMetadataResource].insert(metadata)
6385
4715
  );
6386
4716
  return metadata;
6387
4717
  }
6388
4718
  async _updateBackupMetadata(backupId, updates) {
6389
4719
  const [ok] = await tryFn(
6390
- () => this.database.resource(this.config.backupMetadataResource).update(backupId, updates)
4720
+ () => this.database.resources[this.config.backupMetadataResource].update(backupId, updates)
6391
4721
  );
6392
4722
  }
6393
4723
  async _createBackupManifest(type, options) {
@@ -6422,7 +4752,7 @@ class BackupPlugin extends Plugin {
6422
4752
  let sinceTimestamp = null;
6423
4753
  if (type === "incremental") {
6424
4754
  const [lastBackupOk, , lastBackups] = await tryFn(
6425
- () => this.database.resource(this.config.backupMetadataResource).list({
4755
+ () => this.database.resources[this.config.backupMetadataResource].list({
6426
4756
  filter: {
6427
4757
  status: "completed",
6428
4758
  type: { $in: ["full", "incremental"] }
@@ -6730,7 +5060,7 @@ class BackupPlugin extends Plugin {
6730
5060
  try {
6731
5061
  const driverBackups = await this.driver.list(options);
6732
5062
  const [metaOk, , metadataRecords] = await tryFn(
6733
- () => this.database.resource(this.config.backupMetadataResource).list({
5063
+ () => this.database.resources[this.config.backupMetadataResource].list({
6734
5064
  limit: options.limit || 50,
6735
5065
  sort: { timestamp: -1 }
6736
5066
  })
@@ -6758,14 +5088,14 @@ class BackupPlugin extends Plugin {
6758
5088
  */
6759
5089
  async getBackupStatus(backupId) {
6760
5090
  const [ok, , backup] = await tryFn(
6761
- () => this.database.resource(this.config.backupMetadataResource).get(backupId)
5091
+ () => this.database.resources[this.config.backupMetadataResource].get(backupId)
6762
5092
  );
6763
5093
  return ok ? backup : null;
6764
5094
  }
6765
5095
  async _cleanupOldBackups() {
6766
5096
  try {
6767
5097
  const [listOk, , allBackups] = await tryFn(
6768
- () => this.database.resource(this.config.backupMetadataResource).list({
5098
+ () => this.database.resources[this.config.backupMetadataResource].list({
6769
5099
  filter: { status: "completed" },
6770
5100
  sort: { timestamp: -1 }
6771
5101
  })
@@ -6832,7 +5162,7 @@ class BackupPlugin extends Plugin {
6832
5162
  for (const backup of backupsToDelete) {
6833
5163
  try {
6834
5164
  await this.driver.delete(backup.id, backup.driverInfo);
6835
- await this.database.resource(this.config.backupMetadataResource).delete(backup.id);
5165
+ await this.database.resources[this.config.backupMetadataResource].delete(backup.id);
6836
5166
  if (this.config.verbose) {
6837
5167
  console.log(`[BackupPlugin] Deleted old backup: ${backup.id}`);
6838
5168
  }
@@ -6868,12 +5198,6 @@ class BackupPlugin extends Plugin {
6868
5198
  await this.driver.cleanup();
6869
5199
  }
6870
5200
  }
6871
- /**
6872
- * Cleanup plugin resources (alias for stop for backward compatibility)
6873
- */
6874
- async cleanup() {
6875
- await this.stop();
6876
- }
6877
5201
  }
6878
5202
 
6879
5203
  class CacheError extends S3dbError {
@@ -9769,9 +8093,6 @@ async function consolidateRecord(originalId, transactionResource, targetResource
9769
8093
  if (txnWithCohorts.cohortMonth && !txn.cohortMonth) {
9770
8094
  updateData.cohortMonth = txnWithCohorts.cohortMonth;
9771
8095
  }
9772
- if (txn.value === null || txn.value === void 0) {
9773
- updateData.value = 1;
9774
- }
9775
8096
  const [ok2, err2] = await tryFn(
9776
8097
  () => transactionResource.update(txn.id, updateData)
9777
8098
  );
@@ -11095,8 +9416,7 @@ async function completeFieldSetup(handler, database, config, plugin) {
11095
9416
  operation: "string|required",
11096
9417
  timestamp: "string|required",
11097
9418
  cohortDate: "string|required",
11098
- cohortHour: "string|optional",
11099
- // ✅ FIX BUG #2: Changed from required to optional for migration compatibility
9419
+ cohortHour: "string|required",
11100
9420
  cohortWeek: "string|optional",
11101
9421
  cohortMonth: "string|optional",
11102
9422
  source: "string|optional",
@@ -13430,7 +11750,7 @@ class RelationPlugin extends Plugin {
13430
11750
  * @private
13431
11751
  */
13432
11752
  async _setupResourceRelations(resourceName, relationsDef) {
13433
- const resource = this.database.resource(resourceName);
11753
+ const resource = this.database.resources[resourceName];
13434
11754
  if (!resource) {
13435
11755
  if (this.verbose) {
13436
11756
  console.warn(`[RelationPlugin] Resource "${resourceName}" not found, will setup when created`);
@@ -13541,7 +11861,7 @@ class RelationPlugin extends Plugin {
13541
11861
  for (const record of records) {
13542
11862
  const relatedData = record[relationName];
13543
11863
  if (relatedData) {
13544
- const relatedResource = this.database.resource(config.resource);
11864
+ const relatedResource = this.database.resources[config.resource];
13545
11865
  const relatedArray = Array.isArray(relatedData) ? relatedData : [relatedData];
13546
11866
  if (relatedArray.length > 0) {
13547
11867
  await this._eagerLoad(relatedArray, nestedIncludes, relatedResource);
@@ -13592,7 +11912,7 @@ class RelationPlugin extends Plugin {
13592
11912
  * @private
13593
11913
  */
13594
11914
  async _loadHasOne(records, relationName, config, sourceResource) {
13595
- const relatedResource = this.database.resource(config.resource);
11915
+ const relatedResource = this.database.resources[config.resource];
13596
11916
  if (!relatedResource) {
13597
11917
  throw new RelatedResourceNotFoundError(config.resource, {
13598
11918
  sourceResource: sourceResource.name,
@@ -13631,7 +11951,7 @@ class RelationPlugin extends Plugin {
13631
11951
  * @private
13632
11952
  */
13633
11953
  async _loadHasMany(records, relationName, config, sourceResource) {
13634
- const relatedResource = this.database.resource(config.resource);
11954
+ const relatedResource = this.database.resources[config.resource];
13635
11955
  if (!relatedResource) {
13636
11956
  throw new RelatedResourceNotFoundError(config.resource, {
13637
11957
  sourceResource: sourceResource.name,
@@ -13677,7 +11997,7 @@ class RelationPlugin extends Plugin {
13677
11997
  * @private
13678
11998
  */
13679
11999
  async _loadBelongsTo(records, relationName, config, sourceResource) {
13680
- const relatedResource = this.database.resource(config.resource);
12000
+ const relatedResource = this.database.resources[config.resource];
13681
12001
  if (!relatedResource) {
13682
12002
  throw new RelatedResourceNotFoundError(config.resource, {
13683
12003
  sourceResource: sourceResource.name,
@@ -13727,14 +12047,14 @@ class RelationPlugin extends Plugin {
13727
12047
  * @private
13728
12048
  */
13729
12049
  async _loadBelongsToMany(records, relationName, config, sourceResource) {
13730
- const relatedResource = this.database.resource(config.resource);
12050
+ const relatedResource = this.database.resources[config.resource];
13731
12051
  if (!relatedResource) {
13732
12052
  throw new RelatedResourceNotFoundError(config.resource, {
13733
12053
  sourceResource: sourceResource.name,
13734
12054
  relation: relationName
13735
12055
  });
13736
12056
  }
13737
- const junctionResource = this.database.resource(config.through);
12057
+ const junctionResource = this.database.resources[config.through];
13738
12058
  if (!junctionResource) {
13739
12059
  throw new JunctionTableNotFoundError(config.through, {
13740
12060
  sourceResource: sourceResource.name,
@@ -13918,7 +12238,7 @@ class RelationPlugin extends Plugin {
13918
12238
  */
13919
12239
  async _cascadeDelete(record, resource, relationName, config) {
13920
12240
  this.stats.cascadeOperations++;
13921
- const relatedResource = this.database.resource(config.resource);
12241
+ const relatedResource = this.database.resources[config.resource];
13922
12242
  if (!relatedResource) {
13923
12243
  throw new RelatedResourceNotFoundError(config.resource, {
13924
12244
  sourceResource: resource.name,
@@ -13926,7 +12246,7 @@ class RelationPlugin extends Plugin {
13926
12246
  });
13927
12247
  }
13928
12248
  const deletedRecords = [];
13929
- config.type === "belongsToMany" ? this.database.resource(config.through) : null;
12249
+ config.type === "belongsToMany" ? this.database.resources[config.through] : null;
13930
12250
  try {
13931
12251
  if (config.type === "hasMany") {
13932
12252
  let relatedRecords;
@@ -13977,7 +12297,7 @@ class RelationPlugin extends Plugin {
13977
12297
  await relatedResource.delete(relatedRecords[0].id);
13978
12298
  }
13979
12299
  } else if (config.type === "belongsToMany") {
13980
- const junctionResource2 = this.database.resource(config.through);
12300
+ const junctionResource2 = this.database.resources[config.through];
13981
12301
  if (junctionResource2) {
13982
12302
  let junctionRecords;
13983
12303
  const partitionName = this._findPartitionByField(junctionResource2, config.foreignKey);
@@ -14045,7 +12365,7 @@ class RelationPlugin extends Plugin {
14045
12365
  */
14046
12366
  async _cascadeUpdate(record, changes, resource, relationName, config) {
14047
12367
  this.stats.cascadeOperations++;
14048
- const relatedResource = this.database.resource(config.resource);
12368
+ const relatedResource = this.database.resources[config.resource];
14049
12369
  if (!relatedResource) {
14050
12370
  return;
14051
12371
  }
@@ -19330,12 +17650,42 @@ ${errorDetails}`,
19330
17650
  createdBy
19331
17651
  };
19332
17652
  this.hooks = {
17653
+ // Insert hooks
19333
17654
  beforeInsert: [],
19334
17655
  afterInsert: [],
17656
+ // Update hooks
19335
17657
  beforeUpdate: [],
19336
17658
  afterUpdate: [],
17659
+ // Delete hooks
19337
17660
  beforeDelete: [],
19338
- afterDelete: []
17661
+ afterDelete: [],
17662
+ // Get hooks
17663
+ beforeGet: [],
17664
+ afterGet: [],
17665
+ // List hooks
17666
+ beforeList: [],
17667
+ afterList: [],
17668
+ // Query hooks
17669
+ beforeQuery: [],
17670
+ afterQuery: [],
17671
+ // Patch hooks
17672
+ beforePatch: [],
17673
+ afterPatch: [],
17674
+ // Replace hooks
17675
+ beforeReplace: [],
17676
+ afterReplace: [],
17677
+ // Exists hooks
17678
+ beforeExists: [],
17679
+ afterExists: [],
17680
+ // Count hooks
17681
+ beforeCount: [],
17682
+ afterCount: [],
17683
+ // GetMany hooks
17684
+ beforeGetMany: [],
17685
+ afterGetMany: [],
17686
+ // DeleteMany hooks
17687
+ beforeDeleteMany: [],
17688
+ afterDeleteMany: []
19339
17689
  };
19340
17690
  this.attributes = attributes || {};
19341
17691
  this.map = config.map;
@@ -19398,19 +17748,6 @@ ${errorDetails}`,
19398
17748
  }
19399
17749
  return idSize;
19400
17750
  }
19401
- /**
19402
- * Get resource options (for backward compatibility with tests)
19403
- */
19404
- get options() {
19405
- return {
19406
- timestamps: this.config.timestamps,
19407
- partitions: this.config.partitions || {},
19408
- cache: this.config.cache,
19409
- autoDecrypt: this.config.autoDecrypt,
19410
- paranoid: this.config.paranoid,
19411
- allNestedObjectsOptional: this.config.allNestedObjectsOptional
19412
- };
19413
- }
19414
17751
  export() {
19415
17752
  const exported = this.schema.export();
19416
17753
  exported.behavior = this.behavior;
@@ -19537,19 +17874,71 @@ ${errorDetails}`,
19537
17874
  return data;
19538
17875
  });
19539
17876
  }
19540
- async validate(data) {
17877
+ /**
17878
+ * Validate data against resource schema without saving
17879
+ * @param {Object} data - Data to validate
17880
+ * @param {Object} options - Validation options
17881
+ * @param {boolean} options.throwOnError - Throw error if validation fails (default: false)
17882
+ * @param {boolean} options.includeId - Include ID validation (default: false)
17883
+ * @param {boolean} options.mutateOriginal - Allow mutation of original data (default: false)
17884
+ * @returns {Promise<{valid: boolean, isValid: boolean, errors: Array, data: Object, original: Object}>} Validation result
17885
+ * @example
17886
+ * // Validate before insert
17887
+ * const result = await resource.validate({
17888
+ * name: 'John Doe',
17889
+ * email: 'invalid-email' // Will fail email validation
17890
+ * });
17891
+ *
17892
+ * if (!result.valid) {
17893
+ * console.log('Validation errors:', result.errors);
17894
+ * // [{ field: 'email', message: '...', ... }]
17895
+ * }
17896
+ *
17897
+ * // Throw on error
17898
+ * try {
17899
+ * await resource.validate({ email: 'bad' }, { throwOnError: true });
17900
+ * } catch (err) {
17901
+ * console.log('Validation failed:', err.message);
17902
+ * }
17903
+ */
17904
+ async validate(data, options = {}) {
17905
+ const {
17906
+ throwOnError = false,
17907
+ includeId = false,
17908
+ mutateOriginal = false
17909
+ } = options;
17910
+ const dataToValidate = mutateOriginal ? data : cloneDeep(data);
17911
+ if (!includeId && dataToValidate.id) {
17912
+ delete dataToValidate.id;
17913
+ }
19541
17914
  const result = {
19542
17915
  original: cloneDeep(data),
19543
17916
  isValid: false,
19544
- errors: []
17917
+ errors: [],
17918
+ data: dataToValidate
19545
17919
  };
19546
- const check = await this.schema.validate(data, { mutateOriginal: false });
19547
- if (check === true) {
19548
- result.isValid = true;
19549
- } else {
19550
- result.errors = check;
17920
+ try {
17921
+ const check = await this.schema.validate(dataToValidate, { mutateOriginal });
17922
+ if (check === true) {
17923
+ result.isValid = true;
17924
+ } else {
17925
+ result.errors = Array.isArray(check) ? check : [check];
17926
+ result.isValid = false;
17927
+ if (throwOnError) {
17928
+ const error = new Error("Validation failed");
17929
+ error.validationErrors = result.errors;
17930
+ error.invalidData = data;
17931
+ throw error;
17932
+ }
17933
+ }
17934
+ } catch (err) {
17935
+ if (!throwOnError) {
17936
+ result.errors = [{ message: err.message, error: err }];
17937
+ result.isValid = false;
17938
+ } else {
17939
+ throw err;
17940
+ }
19551
17941
  }
19552
- result.data = data;
19553
17942
  return result;
19554
17943
  }
19555
17944
  /**
@@ -19814,12 +18203,12 @@ ${errorDetails}`,
19814
18203
  const exists = await this.exists(id$1);
19815
18204
  if (exists) throw new Error(`Resource with id '${id$1}' already exists`);
19816
18205
  this.getResourceKey(id$1 || "(auto)");
19817
- if (this.options.timestamps) {
18206
+ if (this.config.timestamps) {
19818
18207
  attributes.createdAt = (/* @__PURE__ */ new Date()).toISOString();
19819
18208
  attributes.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
19820
18209
  }
19821
18210
  const attributesWithDefaults = this.applyDefaults(attributes);
19822
- const completeData = { id: id$1, ...attributesWithDefaults };
18211
+ const completeData = id$1 !== void 0 ? { id: id$1, ...attributesWithDefaults } : { ...attributesWithDefaults };
19823
18212
  const preProcessedData = await this.executeHooks("beforeInsert", completeData);
19824
18213
  const extraProps = Object.keys(preProcessedData).filter(
19825
18214
  (k) => !(k in completeData) || preProcessedData[k] !== completeData[k]
@@ -19830,7 +18219,7 @@ ${errorDetails}`,
19830
18219
  errors,
19831
18220
  isValid,
19832
18221
  data: validated
19833
- } = await this.validate(preProcessedData);
18222
+ } = await this.validate(preProcessedData, { includeId: true });
19834
18223
  if (!isValid) {
19835
18224
  const errorMsg = errors && errors.length && errors[0].message ? errors[0].message : "Insert failed";
19836
18225
  throw new InvalidResourceItem({
@@ -19934,6 +18323,7 @@ ${errorDetails}`,
19934
18323
  async get(id) {
19935
18324
  if (isObject(id)) throw new Error(`id cannot be an object`);
19936
18325
  if (isEmpty(id)) throw new Error("id cannot be empty");
18326
+ await this.executeHooks("beforeGet", { id });
19937
18327
  const key = this.getResourceKey(id);
19938
18328
  const [ok, err, request] = await tryFn(() => this.client.getObject(key));
19939
18329
  if (!ok) {
@@ -19982,17 +18372,67 @@ ${errorDetails}`,
19982
18372
  if (objectVersion !== this.version) {
19983
18373
  data = await this.applyVersionMapping(data, objectVersion, this.version);
19984
18374
  }
18375
+ data = await this.executeHooks("afterGet", data);
19985
18376
  this.emit("get", data);
19986
18377
  const value = data;
19987
18378
  return value;
19988
18379
  }
18380
+ /**
18381
+ * Retrieve a resource object by ID, or return null if not found
18382
+ * @param {string} id - Resource ID
18383
+ * @returns {Promise<Object|null>} The resource object or null if not found
18384
+ * @example
18385
+ * const user = await resource.getOrNull('user-123');
18386
+ * if (user) {
18387
+ * console.log('Found user:', user.name);
18388
+ * } else {
18389
+ * console.log('User not found');
18390
+ * }
18391
+ */
18392
+ async getOrNull(id) {
18393
+ const [ok, err, data] = await tryFn(() => this.get(id));
18394
+ if (!ok && err && (err.name === "NoSuchKey" || err.message?.includes("NoSuchKey"))) {
18395
+ return null;
18396
+ }
18397
+ if (!ok) {
18398
+ throw err;
18399
+ }
18400
+ return data;
18401
+ }
18402
+ /**
18403
+ * Retrieve a resource object by ID, or throw ResourceNotFoundError if not found
18404
+ * @param {string} id - Resource ID
18405
+ * @returns {Promise<Object>} The resource object
18406
+ * @throws {ResourceError} If resource does not exist
18407
+ * @example
18408
+ * // Throws error if user doesn't exist (no need for null check)
18409
+ * const user = await resource.getOrThrow('user-123');
18410
+ * console.log('User name:', user.name); // Safe to access
18411
+ */
18412
+ async getOrThrow(id) {
18413
+ const [ok, err, data] = await tryFn(() => this.get(id));
18414
+ if (!ok && err && (err.name === "NoSuchKey" || err.message?.includes("NoSuchKey"))) {
18415
+ throw new ResourceError(`Resource '${this.name}' with id '${id}' not found`, {
18416
+ resourceName: this.name,
18417
+ operation: "getOrThrow",
18418
+ id,
18419
+ code: "RESOURCE_NOT_FOUND"
18420
+ });
18421
+ }
18422
+ if (!ok) {
18423
+ throw err;
18424
+ }
18425
+ return data;
18426
+ }
19989
18427
  /**
19990
18428
  * Check if a resource exists by ID
19991
18429
  * @returns {Promise<boolean>} True if resource exists, false otherwise
19992
18430
  */
19993
18431
  async exists(id) {
18432
+ await this.executeHooks("beforeExists", { id });
19994
18433
  const key = this.getResourceKey(id);
19995
18434
  const [ok, err] = await tryFn(() => this.client.headObject(key));
18435
+ await this.executeHooks("afterExists", { id, exists: ok });
19996
18436
  return ok;
19997
18437
  }
19998
18438
  /**
@@ -20048,7 +18488,7 @@ ${errorDetails}`,
20048
18488
  }
20049
18489
  const preProcessedData = await this.executeHooks("beforeUpdate", cloneDeep(mergedData));
20050
18490
  const completeData = { ...originalData, ...preProcessedData, id };
20051
- const { isValid, errors, data } = await this.validate(cloneDeep(completeData));
18491
+ const { isValid, errors, data } = await this.validate(cloneDeep(completeData), { includeId: true });
20052
18492
  if (!isValid) {
20053
18493
  throw new InvalidResourceItem({
20054
18494
  bucket: this.client.config.bucket,
@@ -20221,12 +18661,17 @@ ${errorDetails}`,
20221
18661
  if (!fields || typeof fields !== "object") {
20222
18662
  throw new Error("fields must be a non-empty object");
20223
18663
  }
18664
+ await this.executeHooks("beforePatch", { id, fields, options });
20224
18665
  const behavior = this.behavior;
20225
18666
  const hasNestedFields = Object.keys(fields).some((key) => key.includes("."));
18667
+ let result;
20226
18668
  if ((behavior === "enforce-limits" || behavior === "truncate-data") && !hasNestedFields) {
20227
- return await this._patchViaCopyObject(id, fields, options);
18669
+ result = await this._patchViaCopyObject(id, fields, options);
18670
+ } else {
18671
+ result = await this.update(id, fields, options);
20228
18672
  }
20229
- return await this.update(id, fields, options);
18673
+ const finalResult = await this.executeHooks("afterPatch", result);
18674
+ return finalResult;
20230
18675
  }
20231
18676
  /**
20232
18677
  * Internal helper: Optimized patch using HeadObject + CopyObject
@@ -20326,6 +18771,7 @@ ${errorDetails}`,
20326
18771
  if (!fullData || typeof fullData !== "object") {
20327
18772
  throw new Error("fullData must be a non-empty object");
20328
18773
  }
18774
+ await this.executeHooks("beforeReplace", { id, fullData, options });
20329
18775
  const { partition, partitionValues } = options;
20330
18776
  const dataClone = cloneDeep(fullData);
20331
18777
  const attributesWithDefaults = this.applyDefaults(dataClone);
@@ -20340,7 +18786,7 @@ ${errorDetails}`,
20340
18786
  errors,
20341
18787
  isValid,
20342
18788
  data: validated
20343
- } = await this.validate(completeData);
18789
+ } = await this.validate(completeData, { includeId: true });
20344
18790
  if (!isValid) {
20345
18791
  const errorMsg = errors && errors.length && errors[0].message ? errors[0].message : "Replace failed";
20346
18792
  throw new InvalidResourceItem({
@@ -20413,7 +18859,8 @@ ${errorDetails}`,
20413
18859
  await this.handlePartitionReferenceUpdates({}, replacedObject);
20414
18860
  }
20415
18861
  }
20416
- return replacedObject;
18862
+ const finalResult = await this.executeHooks("afterReplace", replacedObject);
18863
+ return finalResult;
20417
18864
  }
20418
18865
  /**
20419
18866
  * Update with conditional check (If-Match ETag)
@@ -20471,7 +18918,7 @@ ${errorDetails}`,
20471
18918
  }
20472
18919
  const preProcessedData = await this.executeHooks("beforeUpdate", cloneDeep(mergedData));
20473
18920
  const completeData = { ...originalData, ...preProcessedData, id };
20474
- const { isValid, errors, data } = await this.validate(cloneDeep(completeData));
18921
+ const { isValid, errors, data } = await this.validate(cloneDeep(completeData), { includeId: true });
20475
18922
  if (!isValid) {
20476
18923
  return {
20477
18924
  success: false,
@@ -20692,6 +19139,7 @@ ${errorDetails}`,
20692
19139
  * });
20693
19140
  */
20694
19141
  async count({ partition = null, partitionValues = {} } = {}) {
19142
+ await this.executeHooks("beforeCount", { partition, partitionValues });
20695
19143
  let prefix;
20696
19144
  if (partition && Object.keys(partitionValues).length > 0) {
20697
19145
  const partitionDef = this.config.partitions[partition];
@@ -20716,6 +19164,7 @@ ${errorDetails}`,
20716
19164
  prefix = `resource=${this.name}/data`;
20717
19165
  }
20718
19166
  const count = await this.client.count({ prefix });
19167
+ await this.executeHooks("afterCount", { count, partition, partitionValues });
20719
19168
  this.emit("count", count);
20720
19169
  return count;
20721
19170
  }
@@ -20751,6 +19200,7 @@ ${errorDetails}`,
20751
19200
  * const results = await resource.deleteMany(deletedIds);
20752
19201
  */
20753
19202
  async deleteMany(ids) {
19203
+ await this.executeHooks("beforeDeleteMany", { ids });
20754
19204
  const packages = chunk(
20755
19205
  ids.map((id) => this.getResourceKey(id)),
20756
19206
  1e3
@@ -20772,6 +19222,7 @@ ${errorDetails}`,
20772
19222
  });
20773
19223
  return response;
20774
19224
  });
19225
+ await this.executeHooks("afterDeleteMany", { ids, results });
20775
19226
  this.emit("deleteMany", ids.length);
20776
19227
  return results;
20777
19228
  }
@@ -20893,6 +19344,7 @@ ${errorDetails}`,
20893
19344
  * });
20894
19345
  */
20895
19346
  async list({ partition = null, partitionValues = {}, limit, offset = 0 } = {}) {
19347
+ await this.executeHooks("beforeList", { partition, partitionValues, limit, offset });
20896
19348
  const [ok, err, result] = await tryFn(async () => {
20897
19349
  if (!partition) {
20898
19350
  return await this.listMain({ limit, offset });
@@ -20902,7 +19354,8 @@ ${errorDetails}`,
20902
19354
  if (!ok) {
20903
19355
  return this.handleListError(err, { partition, partitionValues });
20904
19356
  }
20905
- return result;
19357
+ const finalResult = await this.executeHooks("afterList", result);
19358
+ return finalResult;
20906
19359
  }
20907
19360
  async listMain({ limit, offset = 0 }) {
20908
19361
  const [ok, err, ids] = await tryFn(() => this.listIds({ limit, offset }));
@@ -21045,6 +19498,7 @@ ${errorDetails}`,
21045
19498
  * const users = await resource.getMany(['user-1', 'user-2', 'user-3']);
21046
19499
  */
21047
19500
  async getMany(ids) {
19501
+ await this.executeHooks("beforeGetMany", { ids });
21048
19502
  const { results, errors } = await PromisePool.for(ids).withConcurrency(this.client.parallelism).handleError(async (error, id) => {
21049
19503
  this.emit("error", error, content);
21050
19504
  this.observers.map((x) => x.emit("error", this.name, error, content));
@@ -21065,8 +19519,9 @@ ${errorDetails}`,
21065
19519
  }
21066
19520
  throw err;
21067
19521
  });
19522
+ const finalResults = await this.executeHooks("afterGetMany", results);
21068
19523
  this.emit("getMany", ids.length);
21069
- return results;
19524
+ return finalResults;
21070
19525
  }
21071
19526
  /**
21072
19527
  * Get all resources (equivalent to list() without pagination)
@@ -21313,21 +19768,6 @@ ${errorDetails}`,
21313
19768
  * @returns {Object} Schema object for the version
21314
19769
  */
21315
19770
  async getSchemaForVersion(version) {
21316
- if (version === this.version) {
21317
- return this.schema;
21318
- }
21319
- const [ok, err, compatibleSchema] = await tryFn(() => Promise.resolve(new Schema({
21320
- name: this.name,
21321
- attributes: this.attributes,
21322
- passphrase: this.passphrase,
21323
- version,
21324
- options: {
21325
- ...this.config,
21326
- autoDecrypt: true,
21327
- autoEncrypt: true
21328
- }
21329
- })));
21330
- if (ok) return compatibleSchema;
21331
19771
  return this.schema;
21332
19772
  }
21333
19773
  /**
@@ -21423,6 +19863,7 @@ ${errorDetails}`,
21423
19863
  * );
21424
19864
  */
21425
19865
  async query(filter = {}, { limit = 100, offset = 0, partition = null, partitionValues = {} } = {}) {
19866
+ await this.executeHooks("beforeQuery", { filter, limit, offset, partition, partitionValues });
21426
19867
  if (Object.keys(filter).length === 0) {
21427
19868
  return await this.list({ partition, partitionValues, limit, offset });
21428
19869
  }
@@ -21450,7 +19891,8 @@ ${errorDetails}`,
21450
19891
  break;
21451
19892
  }
21452
19893
  }
21453
- return results.slice(0, limit);
19894
+ const finalResults = results.slice(0, limit);
19895
+ return await this.executeHooks("afterQuery", finalResults);
21454
19896
  }
21455
19897
  /**
21456
19898
  * Handle partition reference updates with change detection
@@ -21530,7 +19972,7 @@ ${errorDetails}`,
21530
19972
  }
21531
19973
  }
21532
19974
  /**
21533
- * Update partition objects to keep them in sync (legacy method for backward compatibility)
19975
+ * Update partition objects to keep them in sync
21534
19976
  * @param {Object} data - Updated object data
21535
19977
  */
21536
19978
  async updatePartitionReferences(data) {
@@ -21916,7 +20358,32 @@ function validateResourceConfig(config) {
21916
20358
  if (typeof config.hooks !== "object" || Array.isArray(config.hooks)) {
21917
20359
  errors.push("Resource 'hooks' must be an object");
21918
20360
  } else {
21919
- const validHookEvents = ["beforeInsert", "afterInsert", "beforeUpdate", "afterUpdate", "beforeDelete", "afterDelete"];
20361
+ const validHookEvents = [
20362
+ "beforeInsert",
20363
+ "afterInsert",
20364
+ "beforeUpdate",
20365
+ "afterUpdate",
20366
+ "beforeDelete",
20367
+ "afterDelete",
20368
+ "beforeGet",
20369
+ "afterGet",
20370
+ "beforeList",
20371
+ "afterList",
20372
+ "beforeQuery",
20373
+ "afterQuery",
20374
+ "beforeExists",
20375
+ "afterExists",
20376
+ "beforeCount",
20377
+ "afterCount",
20378
+ "beforePatch",
20379
+ "afterPatch",
20380
+ "beforeReplace",
20381
+ "afterReplace",
20382
+ "beforeGetMany",
20383
+ "afterGetMany",
20384
+ "beforeDeleteMany",
20385
+ "afterDeleteMany"
20386
+ ];
21920
20387
  for (const [event, hooksArr] of Object.entries(config.hooks)) {
21921
20388
  if (!validHookEvents.includes(event)) {
21922
20389
  errors.push(`Invalid hook event '${event}'. Valid events: ${validHookEvents.join(", ")}`);
@@ -21964,17 +20431,35 @@ class Database extends EventEmitter {
21964
20431
  this.id = idGenerator(7);
21965
20432
  this.version = "1";
21966
20433
  this.s3dbVersion = (() => {
21967
- const [ok, err, version] = tryFn(() => true ? "12.1.0" : "latest");
20434
+ const [ok, err, version] = tryFn(() => true ? "12.2.1" : "latest");
21968
20435
  return ok ? version : "latest";
21969
20436
  })();
21970
- this.resources = {};
20437
+ this._resourcesMap = {};
20438
+ this.resources = new Proxy(this._resourcesMap, {
20439
+ get: (target, prop) => {
20440
+ if (typeof prop === "symbol" || prop === "constructor" || prop === "toJSON") {
20441
+ return target[prop];
20442
+ }
20443
+ if (target[prop]) {
20444
+ return target[prop];
20445
+ }
20446
+ return void 0;
20447
+ },
20448
+ // Support Object.keys(), Object.entries(), etc.
20449
+ ownKeys: (target) => {
20450
+ return Object.keys(target);
20451
+ },
20452
+ getOwnPropertyDescriptor: (target, prop) => {
20453
+ return Object.getOwnPropertyDescriptor(target, prop);
20454
+ }
20455
+ });
21971
20456
  this.savedMetadata = null;
21972
20457
  this.options = options;
21973
20458
  this.verbose = options.verbose || false;
21974
20459
  this.parallelism = parseInt(options.parallelism + "") || 10;
21975
- this.plugins = options.plugins || [];
21976
- this.pluginRegistry = {};
21977
20460
  this.pluginList = options.plugins || [];
20461
+ this.pluginRegistry = {};
20462
+ this.plugins = this.pluginRegistry;
21978
20463
  this.cache = options.cache;
21979
20464
  this.passphrase = options.passphrase || "secret";
21980
20465
  this.versioningEnabled = options.versioningEnabled || false;
@@ -22080,7 +20565,7 @@ class Database extends EventEmitter {
22080
20565
  } else {
22081
20566
  restoredIdSize = versionData.idSize || 22;
22082
20567
  }
22083
- this.resources[name] = new Resource({
20568
+ this._resourcesMap[name] = new Resource({
22084
20569
  name,
22085
20570
  client: this.client,
22086
20571
  database: this,
@@ -22149,7 +20634,7 @@ class Database extends EventEmitter {
22149
20634
  }
22150
20635
  }
22151
20636
  for (const [name, savedResource] of Object.entries(savedMetadata.resources || {})) {
22152
- if (!this.resources[name]) {
20637
+ if (!this._resourcesMap[name]) {
22153
20638
  const currentVersion = savedResource.currentVersion || "v1";
22154
20639
  const versionData = savedResource.versions?.[currentVersion];
22155
20640
  changes.push({
@@ -22661,7 +21146,7 @@ class Database extends EventEmitter {
22661
21146
  * @returns {boolean} True if resource exists, false otherwise
22662
21147
  */
22663
21148
  resourceExists(name) {
22664
- return !!this.resources[name];
21149
+ return !!this._resourcesMap[name];
22665
21150
  }
22666
21151
  /**
22667
21152
  * Check if a resource exists with the same definition hash
@@ -22669,14 +21154,13 @@ class Database extends EventEmitter {
22669
21154
  * @param {string} config.name - Resource name
22670
21155
  * @param {Object} config.attributes - Resource attributes
22671
21156
  * @param {string} [config.behavior] - Resource behavior
22672
- * @param {Object} [config.options] - Resource options (deprecated, use root level parameters)
22673
21157
  * @returns {Object} Result with exists and hash information
22674
21158
  */
22675
- resourceExistsWithSameHash({ name, attributes, behavior = "user-managed", partitions = {}, options = {} }) {
22676
- if (!this.resources[name]) {
21159
+ resourceExistsWithSameHash({ name, attributes, behavior = "user-managed", partitions = {} }) {
21160
+ if (!this._resourcesMap[name]) {
22677
21161
  return { exists: false, sameHash: false, hash: null };
22678
21162
  }
22679
- const existingResource = this.resources[name];
21163
+ const existingResource = this._resourcesMap[name];
22680
21164
  const existingHash = this.generateDefinitionHash(existingResource.export());
22681
21165
  const mockResource = new Resource({
22682
21166
  name,
@@ -22686,8 +21170,7 @@ class Database extends EventEmitter {
22686
21170
  client: this.client,
22687
21171
  version: existingResource.version,
22688
21172
  passphrase: this.passphrase,
22689
- versioningEnabled: this.versioningEnabled,
22690
- ...options
21173
+ versioningEnabled: this.versioningEnabled
22691
21174
  });
22692
21175
  const newHash = this.generateDefinitionHash(mockResource.export());
22693
21176
  return {
@@ -22715,12 +21198,49 @@ class Database extends EventEmitter {
22715
21198
  * @param {string} [config.createdBy='user'] - Who created this resource ('user', 'plugin', or plugin name)
22716
21199
  * @returns {Promise<Resource>} The created or updated resource
22717
21200
  */
22718
- async createResource({ name, attributes, behavior = "user-managed", hooks, ...config }) {
22719
- if (this.resources[name]) {
22720
- const existingResource = this.resources[name];
21201
+ /**
21202
+ * Normalize partitions config from array or object format
21203
+ * @param {Array|Object} partitions - Partitions config
21204
+ * @param {Object} attributes - Resource attributes
21205
+ * @returns {Object} Normalized partitions object
21206
+ * @private
21207
+ */
21208
+ _normalizePartitions(partitions, attributes) {
21209
+ if (!Array.isArray(partitions)) {
21210
+ return partitions || {};
21211
+ }
21212
+ const normalized = {};
21213
+ for (const fieldName of partitions) {
21214
+ if (typeof fieldName !== "string") {
21215
+ throw new Error(`Partition field must be a string, got ${typeof fieldName}`);
21216
+ }
21217
+ if (!attributes[fieldName]) {
21218
+ throw new Error(`Partition field '${fieldName}' not found in attributes`);
21219
+ }
21220
+ const partitionName = `by${fieldName.charAt(0).toUpperCase()}${fieldName.slice(1)}`;
21221
+ const fieldDef = attributes[fieldName];
21222
+ let fieldType = "string";
21223
+ if (typeof fieldDef === "string") {
21224
+ fieldType = fieldDef.split("|")[0].trim();
21225
+ } else if (typeof fieldDef === "object" && fieldDef.type) {
21226
+ fieldType = fieldDef.type;
21227
+ }
21228
+ normalized[partitionName] = {
21229
+ fields: {
21230
+ [fieldName]: fieldType
21231
+ }
21232
+ };
21233
+ }
21234
+ return normalized;
21235
+ }
21236
+ async createResource({ name, attributes, behavior = "user-managed", hooks, middlewares, ...config }) {
21237
+ const normalizedPartitions = this._normalizePartitions(config.partitions, attributes);
21238
+ if (this._resourcesMap[name]) {
21239
+ const existingResource = this._resourcesMap[name];
22721
21240
  Object.assign(existingResource.config, {
22722
21241
  cache: this.cache,
22723
- ...config
21242
+ ...config,
21243
+ partitions: normalizedPartitions
22724
21244
  });
22725
21245
  if (behavior) {
22726
21246
  existingResource.behavior = behavior;
@@ -22738,6 +21258,9 @@ class Database extends EventEmitter {
22738
21258
  }
22739
21259
  }
22740
21260
  }
21261
+ if (middlewares) {
21262
+ this._applyMiddlewares(existingResource, middlewares);
21263
+ }
22741
21264
  const newHash = this.generateDefinitionHash(existingResource.export(), existingResource.behavior);
22742
21265
  const existingMetadata2 = this.savedMetadata?.resources?.[name];
22743
21266
  const currentVersion = existingMetadata2?.currentVersion || "v1";
@@ -22761,7 +21284,7 @@ class Database extends EventEmitter {
22761
21284
  observers: [this],
22762
21285
  cache: config.cache !== void 0 ? config.cache : this.cache,
22763
21286
  timestamps: config.timestamps !== void 0 ? config.timestamps : false,
22764
- partitions: config.partitions || {},
21287
+ partitions: normalizedPartitions,
22765
21288
  paranoid: config.paranoid !== void 0 ? config.paranoid : true,
22766
21289
  allNestedObjectsOptional: config.allNestedObjectsOptional !== void 0 ? config.allNestedObjectsOptional : true,
22767
21290
  autoDecrypt: config.autoDecrypt !== void 0 ? config.autoDecrypt : true,
@@ -22777,16 +21300,96 @@ class Database extends EventEmitter {
22777
21300
  createdBy: config.createdBy || "user"
22778
21301
  });
22779
21302
  resource.database = this;
22780
- this.resources[name] = resource;
21303
+ this._resourcesMap[name] = resource;
21304
+ if (middlewares) {
21305
+ this._applyMiddlewares(resource, middlewares);
21306
+ }
22781
21307
  await this.uploadMetadataFile();
22782
21308
  this.emit("s3db.resourceCreated", name);
22783
21309
  return resource;
22784
21310
  }
22785
- resource(name) {
22786
- if (!this.resources[name]) {
22787
- return Promise.reject(`resource ${name} does not exist`);
21311
+ /**
21312
+ * Apply middlewares to a resource
21313
+ * @param {Resource} resource - Resource instance
21314
+ * @param {Array|Object} middlewares - Middlewares config
21315
+ * @private
21316
+ */
21317
+ _applyMiddlewares(resource, middlewares) {
21318
+ if (Array.isArray(middlewares)) {
21319
+ const methods = resource._middlewareMethods || [
21320
+ "get",
21321
+ "list",
21322
+ "listIds",
21323
+ "getAll",
21324
+ "count",
21325
+ "page",
21326
+ "insert",
21327
+ "update",
21328
+ "delete",
21329
+ "deleteMany",
21330
+ "exists",
21331
+ "getMany",
21332
+ "content",
21333
+ "hasContent",
21334
+ "query",
21335
+ "getFromPartition",
21336
+ "setContent",
21337
+ "deleteContent",
21338
+ "replace",
21339
+ "patch"
21340
+ ];
21341
+ for (const method of methods) {
21342
+ for (const middleware of middlewares) {
21343
+ if (typeof middleware === "function") {
21344
+ resource.useMiddleware(method, middleware);
21345
+ }
21346
+ }
21347
+ }
21348
+ return;
21349
+ }
21350
+ if (typeof middlewares === "object" && middlewares !== null) {
21351
+ for (const [method, fns] of Object.entries(middlewares)) {
21352
+ if (method === "*") {
21353
+ const methods = resource._middlewareMethods || [
21354
+ "get",
21355
+ "list",
21356
+ "listIds",
21357
+ "getAll",
21358
+ "count",
21359
+ "page",
21360
+ "insert",
21361
+ "update",
21362
+ "delete",
21363
+ "deleteMany",
21364
+ "exists",
21365
+ "getMany",
21366
+ "content",
21367
+ "hasContent",
21368
+ "query",
21369
+ "getFromPartition",
21370
+ "setContent",
21371
+ "deleteContent",
21372
+ "replace",
21373
+ "patch"
21374
+ ];
21375
+ const middlewareArray = Array.isArray(fns) ? fns : [fns];
21376
+ for (const targetMethod of methods) {
21377
+ for (const middleware of middlewareArray) {
21378
+ if (typeof middleware === "function") {
21379
+ resource.useMiddleware(targetMethod, middleware);
21380
+ }
21381
+ }
21382
+ }
21383
+ } else {
21384
+ const middlewareArray = Array.isArray(fns) ? fns : [fns];
21385
+ for (const middleware of middlewareArray) {
21386
+ if (typeof middleware === "function") {
21387
+ resource.useMiddleware(method, middleware);
21388
+ }
21389
+ }
21390
+ }
21391
+ }
22788
21392
  }
22789
- return this.resources[name];
22790
21393
  }
22791
21394
  /**
22792
21395
  * List all resource names
@@ -22801,14 +21404,14 @@ class Database extends EventEmitter {
22801
21404
  * @returns {Resource} Resource instance
22802
21405
  */
22803
21406
  async getResource(name) {
22804
- if (!this.resources[name]) {
21407
+ if (!this._resourcesMap[name]) {
22805
21408
  throw new ResourceNotFound({
22806
21409
  bucket: this.client.config.bucket,
22807
21410
  resourceName: name,
22808
21411
  id: name
22809
21412
  });
22810
21413
  }
22811
- return this.resources[name];
21414
+ return this._resourcesMap[name];
22812
21415
  }
22813
21416
  /**
22814
21417
  * Get database configuration
@@ -22861,7 +21464,7 @@ class Database extends EventEmitter {
22861
21464
  }
22862
21465
  });
22863
21466
  }
22864
- Object.keys(this.resources).forEach((k) => delete this.resources[k]);
21467
+ Object.keys(this.resources).forEach((k) => delete this._resourcesMap[k]);
22865
21468
  }
22866
21469
  if (this.client && typeof this.client.removeAllListeners === "function") {
22867
21470
  this.client.removeAllListeners();
@@ -24605,14 +23208,6 @@ class ReplicatorPlugin extends Plugin {
24605
23208
  }
24606
23209
  async start() {
24607
23210
  }
24608
- async stop() {
24609
- for (const replicator of this.replicators || []) {
24610
- if (replicator && typeof replicator.cleanup === "function") {
24611
- await replicator.cleanup();
24612
- }
24613
- }
24614
- this.removeDatabaseHooks();
24615
- }
24616
23211
  installDatabaseHooks() {
24617
23212
  this._afterCreateResourceHook = (resource) => {
24618
23213
  if (resource.name !== (this.config.replicatorLogResource || "plg_replicator_logs")) {
@@ -24942,20 +23537,20 @@ class ReplicatorPlugin extends Plugin {
24942
23537
  }
24943
23538
  this.emit("replicator.sync.completed", { replicatorId, stats: this.stats });
24944
23539
  }
24945
- async cleanup() {
23540
+ async stop() {
24946
23541
  const [ok, error] = await tryFn(async () => {
24947
23542
  if (this.replicators && this.replicators.length > 0) {
24948
23543
  const cleanupPromises = this.replicators.map(async (replicator) => {
24949
23544
  const [replicatorOk, replicatorError] = await tryFn(async () => {
24950
- if (replicator && typeof replicator.cleanup === "function") {
24951
- await replicator.cleanup();
23545
+ if (replicator && typeof replicator.stop === "function") {
23546
+ await replicator.stop();
24952
23547
  }
24953
23548
  });
24954
23549
  if (!replicatorOk) {
24955
23550
  if (this.config.verbose) {
24956
- console.warn(`[ReplicatorPlugin] Failed to cleanup replicator ${replicator.name || replicator.id}: ${replicatorError.message}`);
23551
+ console.warn(`[ReplicatorPlugin] Failed to stop replicator ${replicator.name || replicator.id}: ${replicatorError.message}`);
24957
23552
  }
24958
- this.emit("replicator_cleanup_error", {
23553
+ this.emit("replicator_stop_error", {
24959
23554
  replicator: replicator.name || replicator.id || "unknown",
24960
23555
  driver: replicator.driver || "unknown",
24961
23556
  error: replicatorError.message
@@ -24964,6 +23559,7 @@ class ReplicatorPlugin extends Plugin {
24964
23559
  });
24965
23560
  await Promise.allSettled(cleanupPromises);
24966
23561
  }
23562
+ this.removeDatabaseHooks();
24967
23563
  if (this.database && this.database.resources) {
24968
23564
  for (const resourceName of this.eventListenersInstalled) {
24969
23565
  const resource = this.database.resources[resourceName];
@@ -24983,9 +23579,9 @@ class ReplicatorPlugin extends Plugin {
24983
23579
  });
24984
23580
  if (!ok) {
24985
23581
  if (this.config.verbose) {
24986
- console.warn(`[ReplicatorPlugin] Failed to cleanup plugin: ${error.message}`);
23582
+ console.warn(`[ReplicatorPlugin] Failed to stop plugin: ${error.message}`);
24987
23583
  }
24988
- this.emit("replicator_plugin_cleanup_error", {
23584
+ this.emit("replicator_plugin_stop_error", {
24989
23585
  error: error.message
24990
23586
  });
24991
23587
  }
@@ -25805,7 +24401,7 @@ class SchedulerPlugin extends Plugin {
25805
24401
  }
25806
24402
  async _persistJobExecution(jobName, executionId, startTime, endTime, duration, status, result, error, retryCount) {
25807
24403
  const [ok, err] = await tryFn(
25808
- () => this.database.resource(this.config.jobHistoryResource).insert({
24404
+ () => this.database.resources[this.config.jobHistoryResource].insert({
25809
24405
  id: executionId,
25810
24406
  jobName,
25811
24407
  status,
@@ -25946,7 +24542,7 @@ class SchedulerPlugin extends Plugin {
25946
24542
  queryParams.status = status;
25947
24543
  }
25948
24544
  const [ok, err, history] = await tryFn(
25949
- () => this.database.resource(this.config.jobHistoryResource).query(queryParams)
24545
+ () => this.database.resources[this.config.jobHistoryResource].query(queryParams)
25950
24546
  );
25951
24547
  if (!ok) {
25952
24548
  if (this.config.verbose) {
@@ -26085,9 +24681,6 @@ class SchedulerPlugin extends Plugin {
26085
24681
  if (this._isTestEnvironment()) {
26086
24682
  this.activeJobs.clear();
26087
24683
  }
26088
- }
26089
- async cleanup() {
26090
- await this.stop();
26091
24684
  this.jobs.clear();
26092
24685
  this.statistics.clear();
26093
24686
  this.activeJobs.clear();
@@ -26336,7 +24929,7 @@ class StateMachinePlugin extends Plugin {
26336
24929
  let lastLogErr;
26337
24930
  for (let attempt = 0; attempt < this.config.retryAttempts; attempt++) {
26338
24931
  const [ok, err] = await tryFn(
26339
- () => this.database.resource(this.config.transitionLogResource).insert({
24932
+ () => this.database.resources[this.config.transitionLogResource].insert({
26340
24933
  id: transitionId,
26341
24934
  machineId,
26342
24935
  entityId,
@@ -26372,11 +24965,11 @@ class StateMachinePlugin extends Plugin {
26372
24965
  updatedAt: now
26373
24966
  };
26374
24967
  const [updateOk] = await tryFn(
26375
- () => this.database.resource(this.config.stateResource).update(stateId, stateData)
24968
+ () => this.database.resources[this.config.stateResource].update(stateId, stateData)
26376
24969
  );
26377
24970
  if (!updateOk) {
26378
24971
  const [insertOk, insertErr] = await tryFn(
26379
- () => this.database.resource(this.config.stateResource).insert({ id: stateId, ...stateData })
24972
+ () => this.database.resources[this.config.stateResource].insert({ id: stateId, ...stateData })
26380
24973
  );
26381
24974
  if (!insertOk && this.config.verbose) {
26382
24975
  console.warn(`[StateMachinePlugin] Failed to upsert state:`, insertErr.message);
@@ -26439,7 +25032,7 @@ class StateMachinePlugin extends Plugin {
26439
25032
  if (this.config.persistTransitions) {
26440
25033
  const stateId = `${machineId}_${entityId}`;
26441
25034
  const [ok, err, stateRecord] = await tryFn(
26442
- () => this.database.resource(this.config.stateResource).get(stateId)
25035
+ () => this.database.resources[this.config.stateResource].get(stateId)
26443
25036
  );
26444
25037
  if (ok && stateRecord) {
26445
25038
  machine.currentStates.set(entityId, stateRecord.currentState);
@@ -26482,7 +25075,7 @@ class StateMachinePlugin extends Plugin {
26482
25075
  }
26483
25076
  const { limit = 50, offset = 0 } = options;
26484
25077
  const [ok, err, transitions] = await tryFn(
26485
- () => this.database.resource(this.config.transitionLogResource).query({
25078
+ () => this.database.resources[this.config.transitionLogResource].query({
26486
25079
  machineId,
26487
25080
  entityId
26488
25081
  }, {
@@ -26524,7 +25117,7 @@ class StateMachinePlugin extends Plugin {
26524
25117
  const now = (/* @__PURE__ */ new Date()).toISOString();
26525
25118
  const stateId = `${machineId}_${entityId}`;
26526
25119
  const [ok, err] = await tryFn(
26527
- () => this.database.resource(this.config.stateResource).insert({
25120
+ () => this.database.resources[this.config.stateResource].insert({
26528
25121
  id: stateId,
26529
25122
  machineId,
26530
25123
  entityId,
@@ -26613,9 +25206,6 @@ class StateMachinePlugin extends Plugin {
26613
25206
  }
26614
25207
  async stop() {
26615
25208
  this.machines.clear();
26616
- }
26617
- async cleanup() {
26618
- await this.stop();
26619
25209
  this.removeAllListeners();
26620
25210
  }
26621
25211
  }
@@ -26630,7 +25220,7 @@ class TfStateError extends Error {
26630
25220
  }
26631
25221
  class InvalidStateFileError extends TfStateError {
26632
25222
  constructor(filePath, reason, context = {}) {
26633
- super(`Invalid Terraform state file "${filePath}": ${reason}`, context);
25223
+ super(`Invalid Tfstate file "${filePath}": ${reason}`, context);
26634
25224
  this.name = "InvalidStateFileError";
26635
25225
  this.filePath = filePath;
26636
25226
  this.reason = reason;
@@ -26639,7 +25229,7 @@ class InvalidStateFileError extends TfStateError {
26639
25229
  class UnsupportedStateVersionError extends TfStateError {
26640
25230
  constructor(version, supportedVersions, context = {}) {
26641
25231
  super(
26642
- `Terraform state version ${version} is not supported. Supported versions: ${supportedVersions.join(", ")}`,
25232
+ `Tfstate version ${version} is not supported. Supported versions: ${supportedVersions.join(", ")}`,
26643
25233
  context
26644
25234
  );
26645
25235
  this.name = "UnsupportedStateVersionError";
@@ -26649,7 +25239,7 @@ class UnsupportedStateVersionError extends TfStateError {
26649
25239
  }
26650
25240
  class StateFileNotFoundError extends TfStateError {
26651
25241
  constructor(filePath, context = {}) {
26652
- super(`Terraform state file not found: ${filePath}`, context);
25242
+ super(`Tfstate file not found: ${filePath}`, context);
26653
25243
  this.name = "StateFileNotFoundError";
26654
25244
  this.filePath = filePath;
26655
25245
  }
@@ -34872,42 +33462,23 @@ class FilesystemTfStateDriver extends TfStateDriver {
34872
33462
  class TfStatePlugin extends Plugin {
34873
33463
  constructor(config = {}) {
34874
33464
  super(config);
34875
- const isNewFormat = config.driver !== void 0;
34876
- if (isNewFormat) {
34877
- this.driverType = config.driver || "s3";
34878
- this.driverConfig = config.config || {};
34879
- const resources = config.resources || {};
34880
- this.resourceName = resources.resources || "plg_tfstate_resources";
34881
- this.stateFilesName = resources.stateFiles || "plg_tfstate_state_files";
34882
- this.diffsName = resources.diffs || "plg_tfstate_state_diffs";
34883
- const monitor = config.monitor || {};
34884
- this.monitorEnabled = monitor.enabled || false;
34885
- this.monitorCron = monitor.cron || "*/5 * * * *";
34886
- const diffs = config.diffs || {};
34887
- this.trackDiffs = diffs.enabled !== void 0 ? diffs.enabled : true;
34888
- this.diffsLookback = diffs.lookback || 10;
34889
- this.asyncPartitions = config.asyncPartitions !== void 0 ? config.asyncPartitions : true;
34890
- this.autoSync = false;
34891
- this.watchPaths = [];
34892
- this.filters = config.filters || {};
34893
- this.verbose = config.verbose || false;
34894
- } else {
34895
- this.driverType = null;
34896
- this.driverConfig = {};
34897
- this.resourceName = config.resourceName || "plg_tfstate_resources";
34898
- this.stateFilesName = config.stateFilesName || "plg_tfstate_state_files";
34899
- this.diffsName = config.diffsName || config.stateHistoryName || "plg_tfstate_state_diffs";
34900
- this.stateHistoryName = this.diffsName;
34901
- this.autoSync = config.autoSync || false;
34902
- this.watchPaths = Array.isArray(config.watchPaths) ? config.watchPaths : [];
34903
- this.filters = config.filters || {};
34904
- this.trackDiffs = config.trackDiffs !== void 0 ? config.trackDiffs : true;
34905
- this.diffsLookback = 10;
34906
- this.asyncPartitions = config.asyncPartitions !== void 0 ? config.asyncPartitions : true;
34907
- this.verbose = config.verbose || false;
34908
- this.monitorEnabled = false;
34909
- this.monitorCron = "*/5 * * * *";
34910
- }
33465
+ this.driverType = config.driver || null;
33466
+ this.driverConfig = config.config || {};
33467
+ const resources = config.resources || {};
33468
+ this.resourceName = resources.resources || config.resourceName || "plg_tfstate_resources";
33469
+ this.stateFilesName = resources.stateFiles || config.stateFilesName || "plg_tfstate_state_files";
33470
+ this.diffsName = resources.diffs || config.diffsName || "plg_tfstate_state_diffs";
33471
+ const monitor = config.monitor || {};
33472
+ this.monitorEnabled = monitor.enabled || false;
33473
+ this.monitorCron = monitor.cron || "*/5 * * * *";
33474
+ const diffs = config.diffs || {};
33475
+ this.trackDiffs = diffs.enabled !== void 0 ? diffs.enabled : config.trackDiffs !== void 0 ? config.trackDiffs : true;
33476
+ this.diffsLookback = diffs.lookback || 10;
33477
+ this.asyncPartitions = config.asyncPartitions !== void 0 ? config.asyncPartitions : true;
33478
+ this.autoSync = config.autoSync || false;
33479
+ this.watchPaths = config.watchPaths || [];
33480
+ this.filters = config.filters || {};
33481
+ this.verbose = config.verbose || false;
34911
33482
  this.supportedVersions = [3, 4];
34912
33483
  this.driver = null;
34913
33484
  this.resource = null;
@@ -34957,7 +33528,7 @@ class TfStatePlugin extends Plugin {
34957
33528
  name: this.lineagesName,
34958
33529
  attributes: {
34959
33530
  id: "string|required",
34960
- // = lineage UUID from Terraform state
33531
+ // = lineage UUID from Tfstate
34961
33532
  latestSerial: "number",
34962
33533
  // Track latest for quick access
34963
33534
  latestStateId: "string",
@@ -35670,7 +34241,7 @@ class TfStatePlugin extends Plugin {
35670
34241
  return result;
35671
34242
  }
35672
34243
  /**
35673
- * Read and parse Terraform state file
34244
+ * Read and parse Tfstate file
35674
34245
  * @private
35675
34246
  */
35676
34247
  async _readStateFile(filePath) {
@@ -35707,7 +34278,7 @@ class TfStatePlugin extends Plugin {
35707
34278
  }
35708
34279
  }
35709
34280
  /**
35710
- * Validate Terraform state version
34281
+ * Validate Tfstate version
35711
34282
  * @private
35712
34283
  */
35713
34284
  _validateStateVersion(state) {
@@ -35720,7 +34291,7 @@ class TfStatePlugin extends Plugin {
35720
34291
  }
35721
34292
  }
35722
34293
  /**
35723
- * Extract resources from Terraform state
34294
+ * Extract resources from Tfstate
35724
34295
  * @private
35725
34296
  */
35726
34297
  async _extractResources(state, filePath, stateFileId, lineageId) {
@@ -36287,14 +34858,14 @@ class TfStatePlugin extends Plugin {
36287
34858
  }
36288
34859
  }
36289
34860
  /**
36290
- * Export resources to Terraform state format
34861
+ * Export resources to Tfstate format
36291
34862
  * @param {Object} options - Export options
36292
34863
  * @param {number} options.serial - Specific serial to export (default: latest)
36293
34864
  * @param {string[]} options.resourceTypes - Filter by resource types
36294
34865
  * @param {string} options.terraformVersion - Terraform version for output (default: '1.5.0')
36295
34866
  * @param {string} options.lineage - State lineage (default: auto-generated)
36296
34867
  * @param {Object} options.outputs - Terraform outputs to include
36297
- * @returns {Promise<Object>} Terraform state object
34868
+ * @returns {Promise<Object>} Tfstate object
36298
34869
  *
36299
34870
  * @example
36300
34871
  * // Export latest state
@@ -37644,6 +36215,533 @@ class VectorPlugin extends Plugin {
37644
36215
  }
37645
36216
  }
37646
36217
 
36218
+ function mapFieldTypeToTypeScript(fieldType) {
36219
+ const baseType = fieldType.split("|")[0].trim();
36220
+ const typeMap = {
36221
+ "string": "string",
36222
+ "number": "number",
36223
+ "integer": "number",
36224
+ "boolean": "boolean",
36225
+ "array": "any[]",
36226
+ "object": "Record<string, any>",
36227
+ "json": "Record<string, any>",
36228
+ "secret": "string",
36229
+ "email": "string",
36230
+ "url": "string",
36231
+ "date": "string",
36232
+ // ISO date string
36233
+ "datetime": "string",
36234
+ // ISO datetime string
36235
+ "ip4": "string",
36236
+ "ip6": "string"
36237
+ };
36238
+ if (baseType.startsWith("embedding:")) {
36239
+ const dimensions = parseInt(baseType.split(":")[1]);
36240
+ return `number[] /* ${dimensions} dimensions */`;
36241
+ }
36242
+ return typeMap[baseType] || "any";
36243
+ }
36244
+ function isFieldRequired(fieldDef) {
36245
+ if (typeof fieldDef === "string") {
36246
+ return fieldDef.includes("|required");
36247
+ }
36248
+ if (typeof fieldDef === "object" && fieldDef.required) {
36249
+ return true;
36250
+ }
36251
+ return false;
36252
+ }
36253
+ function generateResourceInterface(resourceName, attributes, timestamps = false) {
36254
+ const interfaceName = toPascalCase(resourceName);
36255
+ const lines = [];
36256
+ lines.push(`export interface ${interfaceName} {`);
36257
+ lines.push(` /** Resource ID (auto-generated) */`);
36258
+ lines.push(` id: string;`);
36259
+ lines.push("");
36260
+ for (const [fieldName, fieldDef] of Object.entries(attributes)) {
36261
+ const required = isFieldRequired(fieldDef);
36262
+ const optional = required ? "" : "?";
36263
+ let tsType;
36264
+ if (typeof fieldDef === "string") {
36265
+ tsType = mapFieldTypeToTypeScript(fieldDef);
36266
+ } else if (typeof fieldDef === "object" && fieldDef.type) {
36267
+ tsType = mapFieldTypeToTypeScript(fieldDef.type);
36268
+ if (fieldDef.type === "object" && fieldDef.props) {
36269
+ tsType = "{\n";
36270
+ for (const [propName, propDef] of Object.entries(fieldDef.props)) {
36271
+ const propType = typeof propDef === "string" ? mapFieldTypeToTypeScript(propDef) : mapFieldTypeToTypeScript(propDef.type);
36272
+ const propRequired = isFieldRequired(propDef);
36273
+ tsType += ` ${propName}${propRequired ? "" : "?"}: ${propType};
36274
+ `;
36275
+ }
36276
+ tsType += " }";
36277
+ }
36278
+ if (fieldDef.type === "array" && fieldDef.items) {
36279
+ const itemType = mapFieldTypeToTypeScript(fieldDef.items);
36280
+ tsType = `Array<${itemType}>`;
36281
+ }
36282
+ } else {
36283
+ tsType = "any";
36284
+ }
36285
+ if (fieldDef.description) {
36286
+ lines.push(` /** ${fieldDef.description} */`);
36287
+ }
36288
+ lines.push(` ${fieldName}${optional}: ${tsType};`);
36289
+ }
36290
+ if (timestamps) {
36291
+ lines.push("");
36292
+ lines.push(` /** Creation timestamp (ISO 8601) */`);
36293
+ lines.push(` createdAt: string;`);
36294
+ lines.push(` /** Last update timestamp (ISO 8601) */`);
36295
+ lines.push(` updatedAt: string;`);
36296
+ }
36297
+ lines.push("}");
36298
+ lines.push("");
36299
+ return lines.join("\n");
36300
+ }
36301
+ function toPascalCase(str) {
36302
+ return str.split(/[_-]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
36303
+ }
36304
+ async function generateTypes(database, options = {}) {
36305
+ const {
36306
+ outputPath = "./types/database.d.ts",
36307
+ moduleName = "s3db.js",
36308
+ includeResource = true
36309
+ } = options;
36310
+ const lines = [];
36311
+ lines.push("/**");
36312
+ lines.push(" * Auto-generated TypeScript definitions for s3db.js resources");
36313
+ lines.push(" * Generated at: " + (/* @__PURE__ */ new Date()).toISOString());
36314
+ lines.push(" * DO NOT EDIT - This file is auto-generated");
36315
+ lines.push(" */");
36316
+ lines.push("");
36317
+ if (includeResource) {
36318
+ lines.push(`import { Resource, Database } from '${moduleName}';`);
36319
+ lines.push("");
36320
+ }
36321
+ const resourceInterfaces = [];
36322
+ for (const [name, resource] of Object.entries(database.resources)) {
36323
+ const attributes = resource.config?.attributes || resource.attributes || {};
36324
+ const timestamps = resource.config?.timestamps || false;
36325
+ const interfaceDef = generateResourceInterface(name, attributes, timestamps);
36326
+ lines.push(interfaceDef);
36327
+ resourceInterfaces.push({
36328
+ name,
36329
+ interfaceName: toPascalCase(name),
36330
+ resource
36331
+ });
36332
+ }
36333
+ lines.push("/**");
36334
+ lines.push(" * Typed resource map for property access");
36335
+ lines.push(" * @example");
36336
+ lines.push(" * const users = db.resources.users; // Type-safe!");
36337
+ lines.push(' * const user = await users.get("id"); // Autocomplete works!');
36338
+ lines.push(" */");
36339
+ lines.push("export interface ResourceMap {");
36340
+ for (const { name, interfaceName } of resourceInterfaces) {
36341
+ lines.push(` /** ${interfaceName} resource */`);
36342
+ if (includeResource) {
36343
+ lines.push(` ${name}: Resource<${interfaceName}>;`);
36344
+ } else {
36345
+ lines.push(` ${name}: any;`);
36346
+ }
36347
+ }
36348
+ lines.push("}");
36349
+ lines.push("");
36350
+ if (includeResource) {
36351
+ lines.push("/**");
36352
+ lines.push(" * Extended Database class with typed resources");
36353
+ lines.push(" */");
36354
+ lines.push("declare module 's3db.js' {");
36355
+ lines.push(" interface Database {");
36356
+ lines.push(" resources: ResourceMap;");
36357
+ lines.push(" }");
36358
+ lines.push("");
36359
+ lines.push(" interface Resource<T = any> {");
36360
+ lines.push(" get(id: string): Promise<T>;");
36361
+ lines.push(" getOrNull(id: string): Promise<T | null>;");
36362
+ lines.push(" getOrThrow(id: string): Promise<T>;");
36363
+ lines.push(" insert(data: Partial<T>): Promise<T>;");
36364
+ lines.push(" update(id: string, data: Partial<T>): Promise<T>;");
36365
+ lines.push(" patch(id: string, data: Partial<T>): Promise<T>;");
36366
+ lines.push(" replace(id: string, data: Partial<T>): Promise<T>;");
36367
+ lines.push(" delete(id: string): Promise<void>;");
36368
+ lines.push(" list(options?: any): Promise<T[]>;");
36369
+ lines.push(" query(filters: Partial<T>, options?: any): Promise<T[]>;");
36370
+ lines.push(" validate(data: Partial<T>, options?: any): Promise<{ valid: boolean; errors: any[]; data: T | null }>;");
36371
+ lines.push(" }");
36372
+ lines.push("}");
36373
+ }
36374
+ const content = lines.join("\n");
36375
+ if (outputPath) {
36376
+ await mkdir(dirname(outputPath), { recursive: true });
36377
+ await writeFile(outputPath, content, "utf-8");
36378
+ }
36379
+ return content;
36380
+ }
36381
+ async function printTypes(database, options = {}) {
36382
+ const types = await generateTypes(database, { ...options, outputPath: null });
36383
+ console.log(types);
36384
+ return types;
36385
+ }
36386
+
36387
+ class Factory {
36388
+ /**
36389
+ * Global sequence counter
36390
+ * @private
36391
+ */
36392
+ static _sequences = /* @__PURE__ */ new Map();
36393
+ /**
36394
+ * Registered factories
36395
+ * @private
36396
+ */
36397
+ static _factories = /* @__PURE__ */ new Map();
36398
+ /**
36399
+ * Database instance (set globally)
36400
+ * @private
36401
+ */
36402
+ static _database = null;
36403
+ /**
36404
+ * Create a new factory definition
36405
+ * @param {string} resourceName - Resource name
36406
+ * @param {Object|Function} definition - Field definitions or function
36407
+ * @param {Object} options - Factory options
36408
+ * @returns {Factory} Factory instance
36409
+ */
36410
+ static define(resourceName, definition, options = {}) {
36411
+ const factory = new Factory(resourceName, definition, options);
36412
+ Factory._factories.set(resourceName, factory);
36413
+ return factory;
36414
+ }
36415
+ /**
36416
+ * Set global database instance
36417
+ * @param {Database} database - s3db.js Database instance
36418
+ */
36419
+ static setDatabase(database) {
36420
+ Factory._database = database;
36421
+ }
36422
+ /**
36423
+ * Get factory by resource name
36424
+ * @param {string} resourceName - Resource name
36425
+ * @returns {Factory} Factory instance
36426
+ */
36427
+ static get(resourceName) {
36428
+ return Factory._factories.get(resourceName);
36429
+ }
36430
+ /**
36431
+ * Reset all sequences
36432
+ */
36433
+ static resetSequences() {
36434
+ Factory._sequences.clear();
36435
+ }
36436
+ /**
36437
+ * Reset all factories
36438
+ */
36439
+ static reset() {
36440
+ Factory._sequences.clear();
36441
+ Factory._factories.clear();
36442
+ Factory._database = null;
36443
+ }
36444
+ /**
36445
+ * Constructor
36446
+ * @param {string} resourceName - Resource name
36447
+ * @param {Object|Function} definition - Field definitions
36448
+ * @param {Object} options - Factory options
36449
+ */
36450
+ constructor(resourceName, definition, options = {}) {
36451
+ this.resourceName = resourceName;
36452
+ this.definition = definition;
36453
+ this.options = options;
36454
+ this.traits = /* @__PURE__ */ new Map();
36455
+ this.afterCreateCallbacks = [];
36456
+ this.beforeCreateCallbacks = [];
36457
+ }
36458
+ /**
36459
+ * Get next sequence number
36460
+ * @param {string} name - Sequence name (default: factory name)
36461
+ * @returns {number} Next sequence number
36462
+ */
36463
+ sequence(name = this.resourceName) {
36464
+ const current = Factory._sequences.get(name) || 0;
36465
+ const next = current + 1;
36466
+ Factory._sequences.set(name, next);
36467
+ return next;
36468
+ }
36469
+ /**
36470
+ * Define a trait (state variation)
36471
+ * @param {string} name - Trait name
36472
+ * @param {Object|Function} attributes - Trait attributes
36473
+ * @returns {Factory} This factory (for chaining)
36474
+ */
36475
+ trait(name, attributes) {
36476
+ this.traits.set(name, attributes);
36477
+ return this;
36478
+ }
36479
+ /**
36480
+ * Register after create callback
36481
+ * @param {Function} callback - Callback function
36482
+ * @returns {Factory} This factory (for chaining)
36483
+ */
36484
+ afterCreate(callback) {
36485
+ this.afterCreateCallbacks.push(callback);
36486
+ return this;
36487
+ }
36488
+ /**
36489
+ * Register before create callback
36490
+ * @param {Function} callback - Callback function
36491
+ * @returns {Factory} This factory (for chaining)
36492
+ */
36493
+ beforeCreate(callback) {
36494
+ this.beforeCreateCallbacks.push(callback);
36495
+ return this;
36496
+ }
36497
+ /**
36498
+ * Build attributes without creating in database
36499
+ * @param {Object} overrides - Override attributes
36500
+ * @param {Object} options - Build options
36501
+ * @returns {Promise<Object>} Built attributes
36502
+ */
36503
+ async build(overrides = {}, options = {}) {
36504
+ const { traits = [] } = options;
36505
+ const seq = this.sequence();
36506
+ let attributes = typeof this.definition === "function" ? await this.definition({ seq, factory: this }) : { ...this.definition };
36507
+ for (const traitName of traits) {
36508
+ const trait = this.traits.get(traitName);
36509
+ if (!trait) {
36510
+ throw new Error(`Trait '${traitName}' not found in factory '${this.resourceName}'`);
36511
+ }
36512
+ const traitAttrs = typeof trait === "function" ? await trait({ seq, factory: this }) : trait;
36513
+ attributes = { ...attributes, ...traitAttrs };
36514
+ }
36515
+ attributes = { ...attributes, ...overrides };
36516
+ for (const [key, value] of Object.entries(attributes)) {
36517
+ if (typeof value === "function") {
36518
+ attributes[key] = await value({ seq, factory: this });
36519
+ }
36520
+ }
36521
+ return attributes;
36522
+ }
36523
+ /**
36524
+ * Create resource in database
36525
+ * @param {Object} overrides - Override attributes
36526
+ * @param {Object} options - Create options
36527
+ * @returns {Promise<Object>} Created resource
36528
+ */
36529
+ async create(overrides = {}, options = {}) {
36530
+ const { database = Factory._database } = options;
36531
+ if (!database) {
36532
+ throw new Error("Database not set. Use Factory.setDatabase(db) or pass database option");
36533
+ }
36534
+ let attributes = await this.build(overrides, options);
36535
+ for (const callback of this.beforeCreateCallbacks) {
36536
+ attributes = await callback(attributes) || attributes;
36537
+ }
36538
+ const resource = database.resources[this.resourceName];
36539
+ if (!resource) {
36540
+ throw new Error(`Resource '${this.resourceName}' not found in database`);
36541
+ }
36542
+ let created = await resource.insert(attributes);
36543
+ for (const callback of this.afterCreateCallbacks) {
36544
+ created = await callback(created, { database }) || created;
36545
+ }
36546
+ return created;
36547
+ }
36548
+ /**
36549
+ * Create multiple resources
36550
+ * @param {number} count - Number of resources to create
36551
+ * @param {Object} overrides - Override attributes
36552
+ * @param {Object} options - Create options
36553
+ * @returns {Promise<Object[]>} Created resources
36554
+ */
36555
+ async createMany(count, overrides = {}, options = {}) {
36556
+ const resources = [];
36557
+ for (let i = 0; i < count; i++) {
36558
+ const resource = await this.create(overrides, options);
36559
+ resources.push(resource);
36560
+ }
36561
+ return resources;
36562
+ }
36563
+ /**
36564
+ * Build multiple resources without creating
36565
+ * @param {number} count - Number of resources to build
36566
+ * @param {Object} overrides - Override attributes
36567
+ * @param {Object} options - Build options
36568
+ * @returns {Promise<Object[]>} Built resources
36569
+ */
36570
+ async buildMany(count, overrides = {}, options = {}) {
36571
+ const resources = [];
36572
+ for (let i = 0; i < count; i++) {
36573
+ const resource = await this.build(overrides, options);
36574
+ resources.push(resource);
36575
+ }
36576
+ return resources;
36577
+ }
36578
+ /**
36579
+ * Create with specific traits
36580
+ * @param {string|string[]} traits - Trait name(s)
36581
+ * @param {Object} overrides - Override attributes
36582
+ * @param {Object} options - Create options
36583
+ * @returns {Promise<Object>} Created resource
36584
+ */
36585
+ async createWithTraits(traits, overrides = {}, options = {}) {
36586
+ const traitArray = Array.isArray(traits) ? traits : [traits];
36587
+ return this.create(overrides, { ...options, traits: traitArray });
36588
+ }
36589
+ /**
36590
+ * Build with specific traits
36591
+ * @param {string|string[]} traits - Trait name(s)
36592
+ * @param {Object} overrides - Override attributes
36593
+ * @param {Object} options - Build options
36594
+ * @returns {Promise<Object>} Built resource
36595
+ */
36596
+ async buildWithTraits(traits, overrides = {}, options = {}) {
36597
+ const traitArray = Array.isArray(traits) ? traits : [traits];
36598
+ return this.build(overrides, { ...options, traits: traitArray });
36599
+ }
36600
+ }
36601
+
36602
+ class Seeder {
36603
+ /**
36604
+ * Constructor
36605
+ * @param {Database} database - s3db.js Database instance
36606
+ * @param {Object} options - Seeder options
36607
+ */
36608
+ constructor(database, options = {}) {
36609
+ this.database = database;
36610
+ this.options = options;
36611
+ this.verbose = options.verbose !== false;
36612
+ }
36613
+ /**
36614
+ * Log message (if verbose)
36615
+ * @param {string} message - Message to log
36616
+ * @private
36617
+ */
36618
+ log(message) {
36619
+ if (this.verbose) {
36620
+ console.log(`[Seeder] ${message}`);
36621
+ }
36622
+ }
36623
+ /**
36624
+ * Seed resources using factories
36625
+ * @param {Object} specs - Seed specifications { resourceName: count }
36626
+ * @returns {Promise<Object>} Created resources by resource name
36627
+ *
36628
+ * @example
36629
+ * const created = await seeder.seed({
36630
+ * users: 10,
36631
+ * posts: 50
36632
+ * });
36633
+ */
36634
+ async seed(specs) {
36635
+ const created = {};
36636
+ for (const [resourceName, count] of Object.entries(specs)) {
36637
+ this.log(`Seeding ${count} ${resourceName}...`);
36638
+ const factory = Factory.get(resourceName);
36639
+ if (!factory) {
36640
+ throw new Error(`Factory for '${resourceName}' not found. Define it with Factory.define()`);
36641
+ }
36642
+ created[resourceName] = await factory.createMany(count, {}, { database: this.database });
36643
+ this.log(`\u2705 Created ${count} ${resourceName}`);
36644
+ }
36645
+ return created;
36646
+ }
36647
+ /**
36648
+ * Seed with custom callback
36649
+ * @param {Function} callback - Seeding callback
36650
+ * @returns {Promise<any>} Result of callback
36651
+ *
36652
+ * @example
36653
+ * await seeder.call(async (db) => {
36654
+ * const user = await UserFactory.create();
36655
+ * const posts = await PostFactory.createMany(5, { userId: user.id });
36656
+ * return { user, posts };
36657
+ * });
36658
+ */
36659
+ async call(callback) {
36660
+ this.log("Running custom seeder...");
36661
+ const result = await callback(this.database);
36662
+ this.log("\u2705 Custom seeder completed");
36663
+ return result;
36664
+ }
36665
+ /**
36666
+ * Truncate resources (delete all data)
36667
+ * @param {string[]} resourceNames - Resource names to truncate
36668
+ * @returns {Promise<void>}
36669
+ *
36670
+ * @example
36671
+ * await seeder.truncate(['users', 'posts']);
36672
+ */
36673
+ async truncate(resourceNames) {
36674
+ for (const resourceName of resourceNames) {
36675
+ this.log(`Truncating ${resourceName}...`);
36676
+ const resource = this.database.resources[resourceName];
36677
+ if (!resource) {
36678
+ this.log(`\u26A0\uFE0F Resource '${resourceName}' not found, skipping`);
36679
+ continue;
36680
+ }
36681
+ const ids = await resource.listIds();
36682
+ if (ids.length > 0) {
36683
+ await resource.deleteMany(ids);
36684
+ this.log(`\u2705 Deleted ${ids.length} ${resourceName}`);
36685
+ } else {
36686
+ this.log(`\u2705 ${resourceName} already empty`);
36687
+ }
36688
+ }
36689
+ }
36690
+ /**
36691
+ * Truncate all resources
36692
+ * @returns {Promise<void>}
36693
+ */
36694
+ async truncateAll() {
36695
+ const resourceNames = Object.keys(this.database.resources);
36696
+ await this.truncate(resourceNames);
36697
+ }
36698
+ /**
36699
+ * Run multiple seeders in order
36700
+ * @param {Function[]} seeders - Array of seeder functions
36701
+ * @returns {Promise<Object[]>} Results of each seeder
36702
+ *
36703
+ * @example
36704
+ * await seeder.run([
36705
+ * async (db) => await UserFactory.createMany(10),
36706
+ * async (db) => await PostFactory.createMany(50)
36707
+ * ]);
36708
+ */
36709
+ async run(seeders) {
36710
+ const results = [];
36711
+ for (const seederFn of seeders) {
36712
+ this.log(`Running seeder ${seederFn.name || "anonymous"}...`);
36713
+ const result = await seederFn(this.database);
36714
+ results.push(result);
36715
+ this.log(`\u2705 Completed ${seederFn.name || "anonymous"}`);
36716
+ }
36717
+ return results;
36718
+ }
36719
+ /**
36720
+ * Seed and return specific resources
36721
+ * @param {Object} specs - Seed specifications
36722
+ * @returns {Promise<Object>} Created resources
36723
+ *
36724
+ * @example
36725
+ * const { users, posts } = await seeder.seedAndReturn({
36726
+ * users: 5,
36727
+ * posts: 10
36728
+ * });
36729
+ */
36730
+ async seedAndReturn(specs) {
36731
+ return await this.seed(specs);
36732
+ }
36733
+ /**
36734
+ * Reset database (truncate all and reset sequences)
36735
+ * @returns {Promise<void>}
36736
+ */
36737
+ async reset() {
36738
+ this.log("Resetting database...");
36739
+ await this.truncateAll();
36740
+ Factory.resetSequences();
36741
+ this.log("\u2705 Database reset complete");
36742
+ }
36743
+ }
36744
+
37647
36745
  function sanitizeLabel(value) {
37648
36746
  if (typeof value !== "string") {
37649
36747
  value = String(value);
@@ -38018,5 +37116,5 @@ var metrics = /*#__PURE__*/Object.freeze({
38018
37116
  silhouetteScore: silhouetteScore
38019
37117
  });
38020
37118
 
38021
- export { AVAILABLE_BEHAVIORS, AnalyticsNotEnabledError, ApiPlugin, AuditPlugin, AuthenticationError, BACKUP_DRIVERS, BackupPlugin, BaseBackupDriver, BaseError, BaseReplicator, BehaviorError, BigqueryReplicator, CONSUMER_DRIVERS, Cache, CachePlugin, Client, ConnectionString, ConnectionStringError, CostsPlugin, CryptoError, DEFAULT_BEHAVIOR, Database, DatabaseError, DynamoDBReplicator, EncryptionError, ErrorMap, EventualConsistencyPlugin, FilesystemBackupDriver, FilesystemCache, FullTextPlugin, InvalidResourceItem, MemoryCache, MetadataLimitError, MetricsPlugin, MissingMetadata, MongoDBReplicator, MultiBackupDriver, MySQLReplicator, NoSuchBucket, NoSuchKey, NotFound, PartitionAwareFilesystemCache, PartitionDriverError, PartitionError, PermissionError, PlanetScaleReplicator, Plugin, PluginError, PluginObject, PluginStorageError, PostgresReplicator, QueueConsumerPlugin, REPLICATOR_DRIVERS, RabbitMqConsumer, RelationPlugin, ReplicatorPlugin, Resource, ResourceError, ResourceIdsPageReader, ResourceIdsReader, ResourceNotFound, ResourceReader, ResourceWriter, S3BackupDriver, S3Cache, S3QueuePlugin, Database as S3db, S3dbError, S3dbReplicator, SchedulerPlugin, Schema, SchemaError, SqsConsumer, SqsReplicator, StateMachinePlugin, StreamError, TfStatePlugin, TursoReplicator, UnknownError, ValidationError, Validator, VectorPlugin, WebhookReplicator, behaviors, calculateAttributeNamesSize, calculateAttributeSizes, calculateEffectiveLimit, calculateSystemOverhead, calculateTotalSize, calculateUTF8Bytes, clearUTF8Cache, clearUTF8Memo, clearUTF8Memory, createBackupDriver, createConsumer, createReplicator, decode, decodeDecimal, decodeFixedPoint, decodeFixedPointBatch, decrypt, S3db as default, encode, encodeDecimal, encodeFixedPoint, encodeFixedPointBatch, encrypt, getBehavior, getSizeBreakdown, idGenerator, mapAwsError, md5, passwordGenerator, sha256, streamToString, transformValue, tryFn, tryFnSync, validateBackupConfig, validateReplicatorConfig };
37119
+ export { AVAILABLE_BEHAVIORS, AnalyticsNotEnabledError, ApiPlugin, AuditPlugin, AuthenticationError, BACKUP_DRIVERS, BackupPlugin, BaseBackupDriver, BaseError, BaseReplicator, BehaviorError, BigqueryReplicator, CONSUMER_DRIVERS, Cache, CachePlugin, Client, ConnectionString, ConnectionStringError, CostsPlugin, CryptoError, DEFAULT_BEHAVIOR, Database, DatabaseError, DynamoDBReplicator, EncryptionError, ErrorMap, EventualConsistencyPlugin, Factory, FilesystemBackupDriver, FilesystemCache, FullTextPlugin, InvalidResourceItem, MemoryCache, MetadataLimitError, MetricsPlugin, MissingMetadata, MongoDBReplicator, MultiBackupDriver, MySQLReplicator, NoSuchBucket, NoSuchKey, NotFound, PartitionAwareFilesystemCache, PartitionDriverError, PartitionError, PermissionError, PlanetScaleReplicator, Plugin, PluginError, PluginObject, PluginStorageError, PostgresReplicator, QueueConsumerPlugin, REPLICATOR_DRIVERS, RabbitMqConsumer, RelationPlugin, ReplicatorPlugin, Resource, ResourceError, ResourceIdsPageReader, ResourceIdsReader, ResourceNotFound, ResourceReader, ResourceWriter, S3BackupDriver, S3Cache, S3QueuePlugin, Database as S3db, S3dbError, S3dbReplicator, SchedulerPlugin, Schema, SchemaError, Seeder, SqsConsumer, SqsReplicator, StateMachinePlugin, StreamError, TfStatePlugin, TursoReplicator, UnknownError, ValidationError, Validator, VectorPlugin, WebhookReplicator, behaviors, calculateAttributeNamesSize, calculateAttributeSizes, calculateEffectiveLimit, calculateSystemOverhead, calculateTotalSize, calculateUTF8Bytes, clearUTF8Memory, createBackupDriver, createConsumer, createReplicator, decode, decodeDecimal, decodeFixedPoint, decodeFixedPointBatch, decrypt, S3db as default, encode, encodeDecimal, encodeFixedPoint, encodeFixedPointBatch, encrypt, generateTypes, getBehavior, getSizeBreakdown, idGenerator, mapAwsError, md5, passwordGenerator, printTypes, sha256, streamToString, transformValue, tryFn, tryFnSync, validateBackupConfig, validateReplicatorConfig };
38022
37120
  //# sourceMappingURL=s3db.es.js.map