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.cjs.js CHANGED
@@ -5,9 +5,6 @@ Object.defineProperty(exports, '__esModule', { value: true });
5
5
  var crypto$1 = require('crypto');
6
6
  var nanoid = require('nanoid');
7
7
  var EventEmitter = require('events');
8
- var hono = require('hono');
9
- var nodeServer = require('@hono/node-server');
10
- var swaggerUi = require('@hono/swagger-ui');
11
8
  var promises = require('fs/promises');
12
9
  var fs = require('fs');
13
10
  var promises$1 = require('stream/promises');
@@ -232,8 +229,6 @@ function calculateUTF8Bytes(str) {
232
229
  function clearUTF8Memory() {
233
230
  utf8BytesMemory.clear();
234
231
  }
235
- const clearUTF8Memo = clearUTF8Memory;
236
- const clearUTF8Cache = clearUTF8Memory;
237
232
  function calculateAttributeNamesSize(mappedObject) {
238
233
  let totalSize = 0;
239
234
  for (const key of Object.keys(mappedObject)) {
@@ -1627,18 +1622,6 @@ function metadataDecode(value) {
1627
1622
  }
1628
1623
  }
1629
1624
  }
1630
- const len = value.length;
1631
- if (len > 0 && len % 4 === 0) {
1632
- if (/^[A-Za-z0-9+/]+=*$/.test(value)) {
1633
- try {
1634
- const decoded = Buffer.from(value, "base64").toString("utf8");
1635
- if (/[^\x00-\x7F]/.test(decoded) && Buffer.from(decoded, "utf8").toString("base64") === value) {
1636
- return decoded;
1637
- }
1638
- } catch {
1639
- }
1640
- }
1641
- }
1642
1625
  return value;
1643
1626
  }
1644
1627
 
@@ -1725,11 +1708,22 @@ class PluginStorage {
1725
1708
  }
1726
1709
  }
1727
1710
  /**
1728
- * Alias for set() to maintain backward compatibility
1729
- * @deprecated Use set() instead
1711
+ * Batch set multiple items
1712
+ *
1713
+ * @param {Array<{key: string, data: Object, options?: Object}>} items - Items to save
1714
+ * @returns {Promise<Array<{ok: boolean, key: string, error?: Error}>>} Results
1730
1715
  */
1731
- async put(key, data, options = {}) {
1732
- return this.set(key, data, options);
1716
+ async batchSet(items) {
1717
+ const results = [];
1718
+ for (const item of items) {
1719
+ try {
1720
+ await this.set(item.key, item.data, item.options || {});
1721
+ results.push({ ok: true, key: item.key });
1722
+ } catch (error) {
1723
+ results.push({ ok: false, key: item.key, error });
1724
+ }
1725
+ }
1726
+ return results;
1733
1727
  }
1734
1728
  /**
1735
1729
  * Get data with automatic metadata decoding and TTL check
@@ -2477,1800 +2471,100 @@ const PluginObject = {
2477
2471
  }
2478
2472
  };
2479
2473
 
2480
- function success(data, options = {}) {
2481
- const { status = 200, meta = {} } = options;
2482
- return {
2483
- success: true,
2484
- data,
2485
- meta: {
2486
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2487
- ...meta
2488
- },
2489
- _status: status
2490
- };
2491
- }
2492
- function error(error2, options = {}) {
2493
- const { status = 500, code = "INTERNAL_ERROR", details = {} } = options;
2494
- const errorMessage = error2 instanceof Error ? error2.message : error2;
2495
- const errorStack = error2 instanceof Error && process.env.NODE_ENV !== "production" ? error2.stack : void 0;
2496
- return {
2497
- success: false,
2498
- error: {
2499
- message: errorMessage,
2500
- code,
2501
- details,
2502
- stack: errorStack
2503
- },
2504
- meta: {
2505
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2506
- },
2507
- _status: status
2508
- };
2509
- }
2510
- function list(items, pagination = {}) {
2511
- const { total, page, pageSize, pageCount } = pagination;
2512
- return {
2513
- success: true,
2514
- data: items,
2515
- pagination: {
2516
- total: total || items.length,
2517
- page: page || 1,
2518
- pageSize: pageSize || items.length,
2519
- pageCount: pageCount || 1
2520
- },
2521
- meta: {
2522
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2523
- },
2524
- _status: 200
2525
- };
2526
- }
2527
- function created(data, location) {
2528
- return {
2529
- success: true,
2530
- data,
2531
- meta: {
2532
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2533
- location
2534
- },
2535
- _status: 201
2536
- };
2537
- }
2538
- function noContent() {
2539
- return {
2540
- success: true,
2541
- data: null,
2542
- meta: {
2543
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2544
- },
2545
- _status: 204
2546
- };
2547
- }
2548
- function notFound(resource, id) {
2549
- return error(`${resource} with id '${id}' not found`, {
2550
- status: 404,
2551
- code: "NOT_FOUND",
2552
- details: { resource, id }
2553
- });
2554
- }
2555
- function payloadTooLarge(size, limit) {
2556
- return error("Request payload too large", {
2557
- status: 413,
2558
- code: "PAYLOAD_TOO_LARGE",
2559
- details: {
2560
- receivedSize: size,
2561
- maxSize: limit,
2562
- receivedMB: (size / 1024 / 1024).toFixed(2),
2563
- maxMB: (limit / 1024 / 1024).toFixed(2)
2564
- }
2565
- });
2566
- }
2567
-
2568
- const errorStatusMap = {
2569
- "ValidationError": 400,
2570
- "InvalidResourceItem": 400,
2571
- "ResourceNotFound": 404,
2572
- "NoSuchKey": 404,
2573
- "NoSuchBucket": 404,
2574
- "PartitionError": 400,
2575
- "CryptoError": 500,
2576
- "SchemaError": 400,
2577
- "QueueError": 500,
2578
- "ResourceError": 500
2579
- };
2580
- function getStatusFromError(err) {
2581
- if (err.name && errorStatusMap[err.name]) {
2582
- return errorStatusMap[err.name];
2583
- }
2584
- if (err.constructor && err.constructor.name && errorStatusMap[err.constructor.name]) {
2585
- return errorStatusMap[err.constructor.name];
2586
- }
2587
- if (err.message) {
2588
- if (err.message.includes("not found") || err.message.includes("does not exist")) {
2589
- return 404;
2590
- }
2591
- if (err.message.includes("validation") || err.message.includes("invalid")) {
2592
- return 400;
2593
- }
2594
- if (err.message.includes("unauthorized") || err.message.includes("authentication")) {
2595
- return 401;
2596
- }
2597
- if (err.message.includes("forbidden") || err.message.includes("permission")) {
2598
- return 403;
2599
- }
2600
- }
2601
- return 500;
2602
- }
2603
- function errorHandler(err, c) {
2604
- const status = getStatusFromError(err);
2605
- const code = err.name || "INTERNAL_ERROR";
2606
- const details = {};
2607
- if (err.resource) details.resource = err.resource;
2608
- if (err.bucket) details.bucket = err.bucket;
2609
- if (err.key) details.key = err.key;
2610
- if (err.operation) details.operation = err.operation;
2611
- if (err.suggestion) details.suggestion = err.suggestion;
2612
- if (err.availableResources) details.availableResources = err.availableResources;
2613
- const response = error(err, {
2614
- status,
2615
- code,
2616
- details
2617
- });
2618
- if (status >= 500) {
2619
- console.error("[API Plugin] Error:", {
2620
- message: err.message,
2621
- code,
2622
- status,
2623
- stack: err.stack,
2624
- details
2625
- });
2626
- } else if (status >= 400 && status < 500 && c.get("verbose")) {
2627
- console.warn("[API Plugin] Client error:", {
2628
- message: err.message,
2629
- code,
2630
- status,
2631
- details
2632
- });
2633
- }
2634
- return c.json(response, response._status);
2635
- }
2636
- function asyncHandler(fn) {
2637
- return async (c) => {
2638
- try {
2639
- return await fn(c);
2640
- } catch (err) {
2641
- return errorHandler(err, c);
2642
- }
2643
- };
2644
- }
2645
-
2646
- function createResourceRoutes(resource, version, config = {}) {
2647
- const app = new hono.Hono();
2648
- const {
2649
- methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
2650
- customMiddleware = [],
2651
- enableValidation = true
2652
- } = config;
2653
- const resourceName = resource.name;
2654
- const basePath = `/${version}/${resourceName}`;
2655
- customMiddleware.forEach((middleware) => {
2656
- app.use("*", middleware);
2657
- });
2658
- if (methods.includes("GET")) {
2659
- app.get("/", asyncHandler(async (c) => {
2660
- const query = c.req.query();
2661
- const limit = parseInt(query.limit) || 100;
2662
- const offset = parseInt(query.offset) || 0;
2663
- const partition = query.partition;
2664
- const partitionValues = query.partitionValues ? JSON.parse(query.partitionValues) : void 0;
2665
- const reservedKeys = ["limit", "offset", "partition", "partitionValues", "sort"];
2666
- const filters = {};
2667
- for (const [key, value] of Object.entries(query)) {
2668
- if (!reservedKeys.includes(key)) {
2669
- try {
2670
- filters[key] = JSON.parse(value);
2671
- } catch {
2672
- filters[key] = value;
2673
- }
2674
- }
2675
- }
2676
- let items;
2677
- let total;
2678
- if (Object.keys(filters).length > 0) {
2679
- items = await resource.query(filters, { limit: limit + offset });
2680
- items = items.slice(offset, offset + limit);
2681
- total = items.length;
2682
- } else if (partition && partitionValues) {
2683
- items = await resource.listPartition({
2684
- partition,
2685
- partitionValues,
2686
- limit: limit + offset
2687
- });
2688
- items = items.slice(offset, offset + limit);
2689
- total = items.length;
2690
- } else {
2691
- items = await resource.list({ limit: limit + offset });
2692
- items = items.slice(offset, offset + limit);
2693
- total = items.length;
2694
- }
2695
- const response = list(items, {
2696
- total,
2697
- page: Math.floor(offset / limit) + 1,
2698
- pageSize: limit,
2699
- pageCount: Math.ceil(total / limit)
2700
- });
2701
- c.header("X-Total-Count", total.toString());
2702
- c.header("X-Page-Count", Math.ceil(total / limit).toString());
2703
- return c.json(response, response._status);
2704
- }));
2705
- }
2706
- if (methods.includes("GET")) {
2707
- app.get("/:id", asyncHandler(async (c) => {
2708
- const id = c.req.param("id");
2709
- const query = c.req.query();
2710
- const partition = query.partition;
2711
- const partitionValues = query.partitionValues ? JSON.parse(query.partitionValues) : void 0;
2712
- let item;
2713
- if (partition && partitionValues) {
2714
- item = await resource.getFromPartition({
2715
- id,
2716
- partitionName: partition,
2717
- partitionValues
2718
- });
2719
- } else {
2720
- item = await resource.get(id);
2721
- }
2722
- if (!item) {
2723
- const response2 = notFound(resourceName, id);
2724
- return c.json(response2, response2._status);
2725
- }
2726
- const response = success(item);
2727
- return c.json(response, response._status);
2728
- }));
2729
- }
2730
- if (methods.includes("POST")) {
2731
- app.post("/", asyncHandler(async (c) => {
2732
- const data = await c.req.json();
2733
- const item = await resource.insert(data);
2734
- const location = `${basePath}/${item.id}`;
2735
- const response = created(item, location);
2736
- c.header("Location", location);
2737
- return c.json(response, response._status);
2738
- }));
2739
- }
2740
- if (methods.includes("PUT")) {
2741
- app.put("/:id", asyncHandler(async (c) => {
2742
- const id = c.req.param("id");
2743
- const data = await c.req.json();
2744
- const existing = await resource.get(id);
2745
- if (!existing) {
2746
- const response2 = notFound(resourceName, id);
2747
- return c.json(response2, response2._status);
2748
- }
2749
- const updated = await resource.update(id, data);
2750
- const response = success(updated);
2751
- return c.json(response, response._status);
2752
- }));
2753
- }
2754
- if (methods.includes("PATCH")) {
2755
- app.patch("/:id", asyncHandler(async (c) => {
2756
- const id = c.req.param("id");
2757
- const data = await c.req.json();
2758
- const existing = await resource.get(id);
2759
- if (!existing) {
2760
- const response2 = notFound(resourceName, id);
2761
- return c.json(response2, response2._status);
2762
- }
2763
- const merged = { ...existing, ...data, id };
2764
- const updated = await resource.update(id, merged);
2765
- const response = success(updated);
2766
- return c.json(response, response._status);
2767
- }));
2768
- }
2769
- if (methods.includes("DELETE")) {
2770
- app.delete("/:id", asyncHandler(async (c) => {
2771
- const id = c.req.param("id");
2772
- const existing = await resource.get(id);
2773
- if (!existing) {
2774
- const response2 = notFound(resourceName, id);
2775
- return c.json(response2, response2._status);
2776
- }
2777
- await resource.delete(id);
2778
- const response = noContent();
2779
- return c.json(response, response._status);
2780
- }));
2781
- }
2782
- if (methods.includes("HEAD")) {
2783
- app.on("HEAD", "/", asyncHandler(async (c) => {
2784
- const total = await resource.count();
2785
- const allItems = await resource.list({ limit: 1e3 });
2786
- const stats = {
2787
- total,
2788
- version: resource.config?.currentVersion || resource.version || "v1"
2789
- };
2790
- c.header("X-Total-Count", total.toString());
2791
- c.header("X-Resource-Version", stats.version);
2792
- c.header("X-Schema-Fields", Object.keys(resource.config?.attributes || {}).length.toString());
2793
- return c.body(null, 200);
2794
- }));
2795
- app.on("HEAD", "/:id", asyncHandler(async (c) => {
2796
- const id = c.req.param("id");
2797
- const item = await resource.get(id);
2798
- if (!item) {
2799
- return c.body(null, 404);
2800
- }
2801
- if (item.updatedAt) {
2802
- c.header("Last-Modified", new Date(item.updatedAt).toUTCString());
2803
- }
2804
- return c.body(null, 200);
2805
- }));
2806
- }
2807
- if (methods.includes("OPTIONS")) {
2808
- app.options("/", asyncHandler(async (c) => {
2809
- c.header("Allow", methods.join(", "));
2810
- const total = await resource.count();
2811
- const schema = resource.config?.attributes || {};
2812
- const version2 = resource.config?.currentVersion || resource.version || "v1";
2813
- const metadata = {
2814
- resource: resourceName,
2815
- version: version2,
2816
- totalRecords: total,
2817
- allowedMethods: methods,
2818
- schema: Object.entries(schema).map(([name, def]) => ({
2819
- name,
2820
- type: typeof def === "string" ? def.split("|")[0] : def.type,
2821
- rules: typeof def === "string" ? def.split("|").slice(1) : []
2822
- })),
2823
- endpoints: {
2824
- list: `/${version2}/${resourceName}`,
2825
- get: `/${version2}/${resourceName}/:id`,
2826
- create: `/${version2}/${resourceName}`,
2827
- update: `/${version2}/${resourceName}/:id`,
2828
- delete: `/${version2}/${resourceName}/:id`
2829
- },
2830
- queryParameters: {
2831
- limit: "number (1-1000, default: 100)",
2832
- offset: "number (min: 0, default: 0)",
2833
- partition: "string (partition name)",
2834
- partitionValues: "JSON string",
2835
- "[any field]": "any (filter by field value)"
2836
- }
2837
- };
2838
- return c.json(metadata);
2839
- }));
2840
- app.options("/:id", (c) => {
2841
- c.header("Allow", methods.filter((m) => m !== "POST").join(", "));
2842
- return c.body(null, 204);
2843
- });
2844
- }
2845
- return app;
2846
- }
2847
-
2848
- function mapFieldTypeToOpenAPI(fieldType) {
2849
- const type = fieldType.split("|")[0].trim();
2850
- const typeMap = {
2851
- "string": { type: "string" },
2852
- "number": { type: "number" },
2853
- "integer": { type: "integer" },
2854
- "boolean": { type: "boolean" },
2855
- "array": { type: "array", items: { type: "string" } },
2856
- "object": { type: "object" },
2857
- "json": { type: "object" },
2858
- "secret": { type: "string", format: "password" },
2859
- "email": { type: "string", format: "email" },
2860
- "url": { type: "string", format: "uri" },
2861
- "date": { type: "string", format: "date" },
2862
- "datetime": { type: "string", format: "date-time" },
2863
- "ip4": { type: "string", format: "ipv4", description: "IPv4 address" },
2864
- "ip6": { type: "string", format: "ipv6", description: "IPv6 address" },
2865
- "embedding": { type: "array", items: { type: "number" }, description: "Vector embedding" }
2866
- };
2867
- if (type.startsWith("embedding:")) {
2868
- const length = parseInt(type.split(":")[1]);
2869
- return {
2870
- type: "array",
2871
- items: { type: "number" },
2872
- minItems: length,
2873
- maxItems: length,
2874
- description: `Vector embedding (${length} dimensions)`
2875
- };
2876
- }
2877
- return typeMap[type] || { type: "string" };
2878
- }
2879
- function extractValidationRules(fieldDef) {
2880
- const rules = {};
2881
- const parts = fieldDef.split("|");
2882
- for (const part of parts) {
2883
- const [rule, value] = part.split(":").map((s) => s.trim());
2884
- switch (rule) {
2885
- case "required":
2886
- rules.required = true;
2887
- break;
2888
- case "min":
2889
- rules.minimum = parseFloat(value);
2890
- break;
2891
- case "max":
2892
- rules.maximum = parseFloat(value);
2893
- break;
2894
- case "minlength":
2895
- rules.minLength = parseInt(value);
2896
- break;
2897
- case "maxlength":
2898
- rules.maxLength = parseInt(value);
2899
- break;
2900
- case "pattern":
2901
- rules.pattern = value;
2902
- break;
2903
- case "enum":
2904
- rules.enum = value.split(",").map((v) => v.trim());
2905
- break;
2906
- case "default":
2907
- rules.default = value;
2908
- break;
2909
- }
2910
- }
2911
- return rules;
2912
- }
2913
- function generateResourceSchema(resource) {
2914
- const properties = {};
2915
- const required = [];
2916
- const attributes = resource.config?.attributes || resource.attributes || {};
2917
- properties.id = {
2918
- type: "string",
2919
- description: "Unique identifier for the resource",
2920
- example: "2_gDTpeU6EI0e8B92n_R3Y",
2921
- readOnly: true
2922
- };
2923
- for (const [fieldName, fieldDef] of Object.entries(attributes)) {
2924
- if (typeof fieldDef === "object" && fieldDef.type) {
2925
- const baseType = mapFieldTypeToOpenAPI(fieldDef.type);
2926
- properties[fieldName] = {
2927
- ...baseType,
2928
- description: fieldDef.description || void 0
2929
- };
2930
- if (fieldDef.required) {
2931
- required.push(fieldName);
2932
- }
2933
- if (fieldDef.type === "object" && fieldDef.props) {
2934
- properties[fieldName].properties = {};
2935
- for (const [propName, propDef] of Object.entries(fieldDef.props)) {
2936
- const propType = typeof propDef === "string" ? propDef : propDef.type;
2937
- properties[fieldName].properties[propName] = mapFieldTypeToOpenAPI(propType);
2938
- }
2939
- }
2940
- if (fieldDef.type === "array" && fieldDef.items) {
2941
- properties[fieldName].items = mapFieldTypeToOpenAPI(fieldDef.items);
2942
- }
2943
- } else if (typeof fieldDef === "string") {
2944
- const baseType = mapFieldTypeToOpenAPI(fieldDef);
2945
- const rules = extractValidationRules(fieldDef);
2946
- properties[fieldName] = {
2947
- ...baseType,
2948
- ...rules
2949
- };
2950
- if (rules.required) {
2951
- required.push(fieldName);
2952
- delete properties[fieldName].required;
2953
- }
2954
- }
2955
- }
2956
- return {
2957
- type: "object",
2958
- properties,
2959
- required: required.length > 0 ? required : void 0
2960
- };
2961
- }
2962
- function generateResourcePaths(resource, version, config = {}) {
2963
- const resourceName = resource.name;
2964
- const basePath = `/${version}/${resourceName}`;
2965
- const schema = generateResourceSchema(resource);
2966
- const methods = config.methods || ["GET", "POST", "PUT", "PATCH", "DELETE"];
2967
- const authMethods = config.auth || [];
2968
- const requiresAuth = authMethods && authMethods.length > 0;
2969
- const paths = {};
2970
- const security = [];
2971
- if (requiresAuth) {
2972
- if (authMethods.includes("jwt")) security.push({ bearerAuth: [] });
2973
- if (authMethods.includes("apiKey")) security.push({ apiKeyAuth: [] });
2974
- if (authMethods.includes("basic")) security.push({ basicAuth: [] });
2975
- }
2976
- const partitions = resource.config?.options?.partitions || resource.config?.partitions || resource.partitions || {};
2977
- const partitionNames = Object.keys(partitions);
2978
- const hasPartitions = partitionNames.length > 0;
2979
- let partitionDescription = "Partition name for filtering";
2980
- let partitionValuesDescription = "Partition values as JSON string";
2981
- let partitionExample = void 0;
2982
- let partitionValuesExample = void 0;
2983
- if (hasPartitions) {
2984
- const partitionDocs = partitionNames.map((name) => {
2985
- const partition = partitions[name];
2986
- const fields = Object.keys(partition.fields || {});
2987
- const fieldTypes = Object.entries(partition.fields || {}).map(([field, type]) => `${field}: ${type}`).join(", ");
2988
- return `- **${name}**: Filters by ${fields.join(", ")} (${fieldTypes})`;
2989
- }).join("\n");
2990
- partitionDescription = `Available partitions:
2991
- ${partitionDocs}`;
2992
- const examplePartition = partitionNames[0];
2993
- const exampleFields = partitions[examplePartition]?.fields || {};
2994
- Object.entries(exampleFields).map(([field, type]) => `"${field}": <${type} value>`).join(", ");
2995
- partitionValuesDescription = `Partition field values as JSON string. Must match the structure of the selected partition.
2996
-
2997
- Example for "${examplePartition}" partition: \`{"${Object.keys(exampleFields)[0]}": "value"}\``;
2998
- partitionExample = examplePartition;
2999
- const firstField = Object.keys(exampleFields)[0];
3000
- const firstFieldType = exampleFields[firstField];
3001
- let exampleValue = "example";
3002
- if (firstFieldType === "number" || firstFieldType === "integer") {
3003
- exampleValue = 123;
3004
- } else if (firstFieldType === "boolean") {
3005
- exampleValue = true;
3006
- }
3007
- partitionValuesExample = JSON.stringify({ [firstField]: exampleValue });
3008
- }
3009
- const attributeQueryParams = [];
3010
- if (hasPartitions) {
3011
- const partitionFieldsSet = /* @__PURE__ */ new Set();
3012
- for (const [partitionName, partition] of Object.entries(partitions)) {
3013
- const fields = partition.fields || {};
3014
- for (const fieldName of Object.keys(fields)) {
3015
- partitionFieldsSet.add(fieldName);
3016
- }
3017
- }
3018
- const attributes = resource.config?.attributes || resource.attributes || {};
3019
- for (const fieldName of partitionFieldsSet) {
3020
- const fieldDef = attributes[fieldName];
3021
- if (!fieldDef) continue;
3022
- let fieldType;
3023
- if (typeof fieldDef === "object" && fieldDef.type) {
3024
- fieldType = fieldDef.type;
3025
- } else if (typeof fieldDef === "string") {
3026
- fieldType = fieldDef.split("|")[0].trim();
3027
- } else {
3028
- fieldType = "string";
3029
- }
3030
- const openAPIType = mapFieldTypeToOpenAPI(fieldType);
3031
- attributeQueryParams.push({
3032
- name: fieldName,
3033
- in: "query",
3034
- description: `Filter by ${fieldName} field (indexed via partitions for efficient querying). Value will be parsed as JSON if possible, otherwise treated as string.`,
3035
- required: false,
3036
- schema: openAPIType
3037
- });
3038
- }
3039
- }
3040
- if (methods.includes("GET")) {
3041
- paths[basePath] = {
3042
- get: {
3043
- tags: [resourceName],
3044
- summary: `List ${resourceName}`,
3045
- 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.
3046
-
3047
- **Pagination**: Use \`limit\` and \`offset\` to paginate results. For example:
3048
- - First page (10 items): \`?limit=10&offset=0\`
3049
- - Second page: \`?limit=10&offset=10\`
3050
- - Third page: \`?limit=10&offset=20\`
3051
-
3052
- 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." : ""}`,
3053
- parameters: [
3054
- {
3055
- name: "limit",
3056
- in: "query",
3057
- description: "Maximum number of items to return per page (page size)",
3058
- schema: { type: "integer", default: 100, minimum: 1, maximum: 1e3 },
3059
- example: 10
3060
- },
3061
- {
3062
- name: "offset",
3063
- in: "query",
3064
- description: "Number of items to skip before starting to return results. Use for pagination: offset = (page - 1) * limit",
3065
- schema: { type: "integer", default: 0, minimum: 0 },
3066
- example: 0
3067
- },
3068
- ...hasPartitions ? [
3069
- {
3070
- name: "partition",
3071
- in: "query",
3072
- description: partitionDescription,
3073
- schema: {
3074
- type: "string",
3075
- enum: partitionNames
3076
- },
3077
- example: partitionExample
3078
- },
3079
- {
3080
- name: "partitionValues",
3081
- in: "query",
3082
- description: partitionValuesDescription,
3083
- schema: { type: "string" },
3084
- example: partitionValuesExample
3085
- }
3086
- ] : [],
3087
- ...attributeQueryParams
3088
- ],
3089
- responses: {
3090
- 200: {
3091
- description: "Successful response",
3092
- content: {
3093
- "application/json": {
3094
- schema: {
3095
- type: "object",
3096
- properties: {
3097
- success: { type: "boolean", example: true },
3098
- data: {
3099
- type: "array",
3100
- items: schema
3101
- },
3102
- pagination: {
3103
- type: "object",
3104
- description: "Pagination metadata for the current request",
3105
- properties: {
3106
- total: {
3107
- type: "integer",
3108
- description: "Total number of items available",
3109
- example: 150
3110
- },
3111
- page: {
3112
- type: "integer",
3113
- description: "Current page number (1-indexed)",
3114
- example: 1
3115
- },
3116
- pageSize: {
3117
- type: "integer",
3118
- description: "Number of items per page (same as limit parameter)",
3119
- example: 10
3120
- },
3121
- pageCount: {
3122
- type: "integer",
3123
- description: "Total number of pages available",
3124
- example: 15
3125
- }
3126
- }
3127
- }
3128
- }
3129
- }
3130
- }
3131
- },
3132
- headers: {
3133
- "X-Total-Count": {
3134
- description: "Total number of records",
3135
- schema: { type: "integer" }
3136
- },
3137
- "X-Page-Count": {
3138
- description: "Total number of pages",
3139
- schema: { type: "integer" }
3140
- }
3141
- }
3142
- }
3143
- },
3144
- security: security.length > 0 ? security : void 0
3145
- }
3146
- };
3147
- }
3148
- if (methods.includes("GET")) {
3149
- paths[`${basePath}/{id}`] = {
3150
- get: {
3151
- tags: [resourceName],
3152
- summary: `Get ${resourceName} by ID`,
3153
- description: `Retrieve a single ${resourceName} by its ID${hasPartitions ? ". Optionally specify a partition for more efficient retrieval." : ""}`,
3154
- parameters: [
3155
- {
3156
- name: "id",
3157
- in: "path",
3158
- required: true,
3159
- description: `${resourceName} ID`,
3160
- schema: { type: "string" }
3161
- },
3162
- ...hasPartitions ? [
3163
- {
3164
- name: "partition",
3165
- in: "query",
3166
- description: partitionDescription,
3167
- schema: {
3168
- type: "string",
3169
- enum: partitionNames
3170
- },
3171
- example: partitionExample
3172
- },
3173
- {
3174
- name: "partitionValues",
3175
- in: "query",
3176
- description: partitionValuesDescription,
3177
- schema: { type: "string" },
3178
- example: partitionValuesExample
3179
- }
3180
- ] : []
3181
- ],
3182
- responses: {
3183
- 200: {
3184
- description: "Successful response",
3185
- content: {
3186
- "application/json": {
3187
- schema: {
3188
- type: "object",
3189
- properties: {
3190
- success: { type: "boolean", example: true },
3191
- data: schema
3192
- }
3193
- }
3194
- }
3195
- }
3196
- },
3197
- 404: {
3198
- description: "Resource not found",
3199
- content: {
3200
- "application/json": {
3201
- schema: { $ref: "#/components/schemas/Error" }
3202
- }
3203
- }
3204
- }
3205
- },
3206
- security: security.length > 0 ? security : void 0
3207
- }
3208
- };
3209
- }
3210
- if (methods.includes("POST")) {
3211
- if (!paths[basePath]) paths[basePath] = {};
3212
- paths[basePath].post = {
3213
- tags: [resourceName],
3214
- summary: `Create ${resourceName}`,
3215
- description: `Create a new ${resourceName}`,
3216
- requestBody: {
3217
- required: true,
3218
- content: {
3219
- "application/json": {
3220
- schema
3221
- }
3222
- }
3223
- },
3224
- responses: {
3225
- 201: {
3226
- description: "Resource created successfully",
3227
- content: {
3228
- "application/json": {
3229
- schema: {
3230
- type: "object",
3231
- properties: {
3232
- success: { type: "boolean", example: true },
3233
- data: schema
3234
- }
3235
- }
3236
- }
3237
- },
3238
- headers: {
3239
- Location: {
3240
- description: "URL of the created resource",
3241
- schema: { type: "string" }
3242
- }
3243
- }
3244
- },
3245
- 400: {
3246
- description: "Validation error",
3247
- content: {
3248
- "application/json": {
3249
- schema: { $ref: "#/components/schemas/ValidationError" }
3250
- }
3251
- }
3252
- }
3253
- },
3254
- security: security.length > 0 ? security : void 0
3255
- };
3256
- }
3257
- if (methods.includes("PUT")) {
3258
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3259
- paths[`${basePath}/{id}`].put = {
3260
- tags: [resourceName],
3261
- summary: `Update ${resourceName} (full)`,
3262
- description: `Fully update a ${resourceName}`,
3263
- parameters: [
3264
- {
3265
- name: "id",
3266
- in: "path",
3267
- required: true,
3268
- schema: { type: "string" }
3269
- }
3270
- ],
3271
- requestBody: {
3272
- required: true,
3273
- content: {
3274
- "application/json": {
3275
- schema
3276
- }
3277
- }
3278
- },
3279
- responses: {
3280
- 200: {
3281
- description: "Resource updated successfully",
3282
- content: {
3283
- "application/json": {
3284
- schema: {
3285
- type: "object",
3286
- properties: {
3287
- success: { type: "boolean", example: true },
3288
- data: schema
3289
- }
3290
- }
3291
- }
3292
- }
3293
- },
3294
- 404: {
3295
- description: "Resource not found",
3296
- content: {
3297
- "application/json": {
3298
- schema: { $ref: "#/components/schemas/Error" }
3299
- }
3300
- }
3301
- }
3302
- },
3303
- security: security.length > 0 ? security : void 0
3304
- };
3305
- }
3306
- if (methods.includes("PATCH")) {
3307
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3308
- paths[`${basePath}/{id}`].patch = {
3309
- tags: [resourceName],
3310
- summary: `Update ${resourceName} (partial)`,
3311
- description: `Partially update a ${resourceName}`,
3312
- parameters: [
3313
- {
3314
- name: "id",
3315
- in: "path",
3316
- required: true,
3317
- schema: { type: "string" }
3318
- }
3319
- ],
3320
- requestBody: {
3321
- required: true,
3322
- content: {
3323
- "application/json": {
3324
- schema: {
3325
- ...schema,
3326
- required: void 0
3327
- // Partial updates don't require all fields
3328
- }
3329
- }
3330
- }
3331
- },
3332
- responses: {
3333
- 200: {
3334
- description: "Resource updated successfully",
3335
- content: {
3336
- "application/json": {
3337
- schema: {
3338
- type: "object",
3339
- properties: {
3340
- success: { type: "boolean", example: true },
3341
- data: schema
3342
- }
3343
- }
3344
- }
3345
- }
3346
- },
3347
- 404: {
3348
- description: "Resource not found",
3349
- content: {
3350
- "application/json": {
3351
- schema: { $ref: "#/components/schemas/Error" }
3352
- }
3353
- }
3354
- }
3355
- },
3356
- security: security.length > 0 ? security : void 0
3357
- };
3358
- }
3359
- if (methods.includes("DELETE")) {
3360
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3361
- paths[`${basePath}/{id}`].delete = {
3362
- tags: [resourceName],
3363
- summary: `Delete ${resourceName}`,
3364
- description: `Delete a ${resourceName} by ID`,
3365
- parameters: [
3366
- {
3367
- name: "id",
3368
- in: "path",
3369
- required: true,
3370
- schema: { type: "string" }
3371
- }
3372
- ],
3373
- responses: {
3374
- 204: {
3375
- description: "Resource deleted successfully"
3376
- },
3377
- 404: {
3378
- description: "Resource not found",
3379
- content: {
3380
- "application/json": {
3381
- schema: { $ref: "#/components/schemas/Error" }
3382
- }
3383
- }
3384
- }
3385
- },
3386
- security: security.length > 0 ? security : void 0
3387
- };
3388
- }
3389
- if (methods.includes("HEAD")) {
3390
- if (!paths[basePath]) paths[basePath] = {};
3391
- paths[basePath].head = {
3392
- tags: [resourceName],
3393
- summary: `Get ${resourceName} statistics`,
3394
- description: `Get statistics about ${resourceName} collection without retrieving data. Returns statistics in response headers.`,
3395
- responses: {
3396
- 200: {
3397
- description: "Statistics retrieved successfully",
3398
- headers: {
3399
- "X-Total-Count": {
3400
- description: "Total number of records",
3401
- schema: { type: "integer" }
3402
- },
3403
- "X-Resource-Version": {
3404
- description: "Current resource version",
3405
- schema: { type: "string" }
3406
- },
3407
- "X-Schema-Fields": {
3408
- description: "Number of schema fields",
3409
- schema: { type: "integer" }
3410
- }
3411
- }
3412
- }
3413
- },
3414
- security: security.length > 0 ? security : void 0
3415
- };
3416
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3417
- paths[`${basePath}/{id}`].head = {
3418
- tags: [resourceName],
3419
- summary: `Check if ${resourceName} exists`,
3420
- description: `Check if a ${resourceName} exists without retrieving its data`,
3421
- parameters: [
3422
- {
3423
- name: "id",
3424
- in: "path",
3425
- required: true,
3426
- schema: { type: "string" }
3427
- }
3428
- ],
3429
- responses: {
3430
- 200: {
3431
- description: "Resource exists",
3432
- headers: {
3433
- "Last-Modified": {
3434
- description: "Last modification date",
3435
- schema: { type: "string", format: "date-time" }
3436
- }
3437
- }
3438
- },
3439
- 404: {
3440
- description: "Resource not found"
3441
- }
3442
- },
3443
- security: security.length > 0 ? security : void 0
3444
- };
3445
- }
3446
- if (methods.includes("OPTIONS")) {
3447
- if (!paths[basePath]) paths[basePath] = {};
3448
- paths[basePath].options = {
3449
- tags: [resourceName],
3450
- summary: `Get ${resourceName} metadata`,
3451
- description: `Get complete metadata about ${resourceName} resource including schema, allowed methods, endpoints, and query parameters`,
3452
- responses: {
3453
- 200: {
3454
- description: "Metadata retrieved successfully",
3455
- headers: {
3456
- "Allow": {
3457
- description: "Allowed HTTP methods",
3458
- schema: { type: "string", example: "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS" }
3459
- }
3460
- },
3461
- content: {
3462
- "application/json": {
3463
- schema: {
3464
- type: "object",
3465
- properties: {
3466
- resource: { type: "string" },
3467
- version: { type: "string" },
3468
- totalRecords: { type: "integer" },
3469
- allowedMethods: {
3470
- type: "array",
3471
- items: { type: "string" }
3472
- },
3473
- schema: {
3474
- type: "array",
3475
- items: {
3476
- type: "object",
3477
- properties: {
3478
- name: { type: "string" },
3479
- type: { type: "string" },
3480
- rules: { type: "array", items: { type: "string" } }
3481
- }
3482
- }
3483
- },
3484
- endpoints: {
3485
- type: "object",
3486
- properties: {
3487
- list: { type: "string" },
3488
- get: { type: "string" },
3489
- create: { type: "string" },
3490
- update: { type: "string" },
3491
- delete: { type: "string" }
3492
- }
3493
- },
3494
- queryParameters: { type: "object" }
3495
- }
3496
- }
3497
- }
3498
- }
3499
- }
3500
- }
3501
- };
3502
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3503
- paths[`${basePath}/{id}`].options = {
3504
- tags: [resourceName],
3505
- summary: `Get allowed methods for ${resourceName} item`,
3506
- description: `Get allowed HTTP methods for individual ${resourceName} operations`,
3507
- parameters: [
3508
- {
3509
- name: "id",
3510
- in: "path",
3511
- required: true,
3512
- schema: { type: "string" }
3513
- }
3514
- ],
3515
- responses: {
3516
- 204: {
3517
- description: "Methods retrieved successfully",
3518
- headers: {
3519
- "Allow": {
3520
- description: "Allowed HTTP methods",
3521
- schema: { type: "string", example: "GET, PUT, PATCH, DELETE, HEAD, OPTIONS" }
3522
- }
3523
- }
3524
- }
3525
- }
3526
- };
3527
- }
3528
- return paths;
3529
- }
3530
- function generateOpenAPISpec(database, config = {}) {
3531
- const {
3532
- title = "s3db.js API",
3533
- version = "1.0.0",
3534
- description = "Auto-generated REST API documentation for s3db.js resources",
3535
- serverUrl = "http://localhost:3000",
3536
- auth = {},
3537
- resources: resourceConfigs = {}
3538
- } = config;
3539
- const spec = {
3540
- openapi: "3.1.0",
3541
- info: {
3542
- title,
3543
- version,
3544
- description,
3545
- contact: {
3546
- name: "s3db.js",
3547
- url: "https://github.com/forattini-dev/s3db.js"
3548
- }
3549
- },
3550
- servers: [
3551
- {
3552
- url: serverUrl,
3553
- description: "API Server"
3554
- }
3555
- ],
3556
- paths: {},
3557
- components: {
3558
- schemas: {
3559
- Error: {
3560
- type: "object",
3561
- properties: {
3562
- success: { type: "boolean", example: false },
3563
- error: {
3564
- type: "object",
3565
- properties: {
3566
- message: { type: "string" },
3567
- code: { type: "string" },
3568
- details: { type: "object" }
3569
- }
3570
- }
3571
- }
3572
- },
3573
- ValidationError: {
3574
- type: "object",
3575
- properties: {
3576
- success: { type: "boolean", example: false },
3577
- error: {
3578
- type: "object",
3579
- properties: {
3580
- message: { type: "string", example: "Validation failed" },
3581
- code: { type: "string", example: "VALIDATION_ERROR" },
3582
- details: {
3583
- type: "object",
3584
- properties: {
3585
- errors: {
3586
- type: "array",
3587
- items: {
3588
- type: "object",
3589
- properties: {
3590
- field: { type: "string" },
3591
- message: { type: "string" },
3592
- expected: { type: "string" },
3593
- actual: {}
3594
- }
3595
- }
3596
- }
3597
- }
3598
- }
3599
- }
3600
- }
3601
- }
3602
- }
3603
- },
3604
- securitySchemes: {}
3605
- },
3606
- tags: []
3607
- };
3608
- if (auth.jwt?.enabled) {
3609
- spec.components.securitySchemes.bearerAuth = {
3610
- type: "http",
3611
- scheme: "bearer",
3612
- bearerFormat: "JWT",
3613
- description: "JWT authentication"
3614
- };
3615
- }
3616
- if (auth.apiKey?.enabled) {
3617
- spec.components.securitySchemes.apiKeyAuth = {
3618
- type: "apiKey",
3619
- in: "header",
3620
- name: auth.apiKey.headerName || "X-API-Key",
3621
- description: "API Key authentication"
3622
- };
3623
- }
3624
- if (auth.basic?.enabled) {
3625
- spec.components.securitySchemes.basicAuth = {
3626
- type: "http",
3627
- scheme: "basic",
3628
- description: "HTTP Basic authentication"
3629
- };
3630
- }
3631
- const resources = database.resources;
3632
- for (const [name, resource] of Object.entries(resources)) {
3633
- if (name.startsWith("plg_") && !resourceConfigs[name]) {
3634
- continue;
3635
- }
3636
- const config2 = resourceConfigs[name] || {
3637
- methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
3638
- auth: false
3639
- };
3640
- const version2 = resource.config?.currentVersion || resource.version || "v1";
3641
- const paths = generateResourcePaths(resource, version2, config2);
3642
- Object.assign(spec.paths, paths);
3643
- spec.tags.push({
3644
- name,
3645
- description: `Operations for ${name} resource`
3646
- });
3647
- spec.components.schemas[name] = generateResourceSchema(resource);
3648
- }
3649
- if (auth.jwt?.enabled || auth.apiKey?.enabled || auth.basic?.enabled) {
3650
- spec.paths["/auth/login"] = {
3651
- post: {
3652
- tags: ["Authentication"],
3653
- summary: "Login",
3654
- description: "Authenticate with username and password",
3655
- requestBody: {
3656
- required: true,
3657
- content: {
3658
- "application/json": {
3659
- schema: {
3660
- type: "object",
3661
- properties: {
3662
- username: { type: "string" },
3663
- password: { type: "string", format: "password" }
3664
- },
3665
- required: ["username", "password"]
3666
- }
3667
- }
3668
- }
3669
- },
3670
- responses: {
3671
- 200: {
3672
- description: "Login successful",
3673
- content: {
3674
- "application/json": {
3675
- schema: {
3676
- type: "object",
3677
- properties: {
3678
- success: { type: "boolean", example: true },
3679
- data: {
3680
- type: "object",
3681
- properties: {
3682
- token: { type: "string" },
3683
- user: { type: "object" }
3684
- }
3685
- }
3686
- }
3687
- }
3688
- }
3689
- }
3690
- },
3691
- 401: {
3692
- description: "Invalid credentials",
3693
- content: {
3694
- "application/json": {
3695
- schema: { $ref: "#/components/schemas/Error" }
3696
- }
3697
- }
3698
- }
3699
- }
3700
- }
3701
- };
3702
- spec.paths["/auth/register"] = {
3703
- post: {
3704
- tags: ["Authentication"],
3705
- summary: "Register",
3706
- description: "Register a new user",
3707
- requestBody: {
3708
- required: true,
3709
- content: {
3710
- "application/json": {
3711
- schema: {
3712
- type: "object",
3713
- properties: {
3714
- username: { type: "string", minLength: 3 },
3715
- password: { type: "string", format: "password", minLength: 8 },
3716
- email: { type: "string", format: "email" }
3717
- },
3718
- required: ["username", "password"]
3719
- }
3720
- }
3721
- }
3722
- },
3723
- responses: {
3724
- 201: {
3725
- description: "User registered successfully",
3726
- content: {
3727
- "application/json": {
3728
- schema: {
3729
- type: "object",
3730
- properties: {
3731
- success: { type: "boolean", example: true },
3732
- data: {
3733
- type: "object",
3734
- properties: {
3735
- token: { type: "string" },
3736
- user: { type: "object" }
3737
- }
3738
- }
3739
- }
3740
- }
3741
- }
3742
- }
3743
- }
3744
- }
3745
- }
3746
- };
3747
- spec.tags.push({
3748
- name: "Authentication",
3749
- description: "Authentication endpoints"
3750
- });
3751
- }
3752
- spec.paths["/health"] = {
3753
- get: {
3754
- tags: ["Health"],
3755
- summary: "Generic Health Check",
3756
- description: "Generic health check endpoint that includes references to liveness and readiness probes",
3757
- responses: {
3758
- 200: {
3759
- description: "API is healthy",
3760
- content: {
3761
- "application/json": {
3762
- schema: {
3763
- type: "object",
3764
- properties: {
3765
- success: { type: "boolean", example: true },
3766
- data: {
3767
- type: "object",
3768
- properties: {
3769
- status: { type: "string", example: "ok" },
3770
- uptime: { type: "number", description: "Process uptime in seconds" },
3771
- timestamp: { type: "string", format: "date-time" },
3772
- checks: {
3773
- type: "object",
3774
- properties: {
3775
- liveness: { type: "string", example: "/health/live" },
3776
- readiness: { type: "string", example: "/health/ready" }
3777
- }
3778
- }
3779
- }
3780
- }
3781
- }
3782
- }
3783
- }
3784
- }
3785
- }
3786
- }
3787
- }
3788
- };
3789
- spec.paths["/health/live"] = {
3790
- get: {
3791
- tags: ["Health"],
3792
- summary: "Liveness Probe",
3793
- description: "Kubernetes liveness probe - checks if the application is alive. If this fails, Kubernetes will restart the pod.",
3794
- responses: {
3795
- 200: {
3796
- description: "Application is alive",
3797
- content: {
3798
- "application/json": {
3799
- schema: {
3800
- type: "object",
3801
- properties: {
3802
- success: { type: "boolean", example: true },
3803
- data: {
3804
- type: "object",
3805
- properties: {
3806
- status: { type: "string", example: "alive" },
3807
- timestamp: { type: "string", format: "date-time" }
3808
- }
3809
- }
3810
- }
3811
- }
3812
- }
3813
- }
3814
- }
3815
- }
3816
- }
3817
- };
3818
- spec.paths["/health/ready"] = {
3819
- get: {
3820
- tags: ["Health"],
3821
- summary: "Readiness Probe",
3822
- description: "Kubernetes readiness probe - checks if the application is ready to receive traffic. If this fails, Kubernetes will remove the pod from service endpoints.",
3823
- responses: {
3824
- 200: {
3825
- description: "Application is ready to receive traffic",
3826
- content: {
3827
- "application/json": {
3828
- schema: {
3829
- type: "object",
3830
- properties: {
3831
- success: { type: "boolean", example: true },
3832
- data: {
3833
- type: "object",
3834
- properties: {
3835
- status: { type: "string", example: "ready" },
3836
- database: {
3837
- type: "object",
3838
- properties: {
3839
- connected: { type: "boolean", example: true },
3840
- resources: { type: "integer", example: 5 }
3841
- }
3842
- },
3843
- timestamp: { type: "string", format: "date-time" }
3844
- }
3845
- }
3846
- }
3847
- }
3848
- }
3849
- }
3850
- },
3851
- 503: {
3852
- description: "Application is not ready",
3853
- content: {
3854
- "application/json": {
3855
- schema: {
3856
- type: "object",
3857
- properties: {
3858
- success: { type: "boolean", example: false },
3859
- error: {
3860
- type: "object",
3861
- properties: {
3862
- message: { type: "string", example: "Service not ready" },
3863
- code: { type: "string", example: "NOT_READY" },
3864
- details: {
3865
- type: "object",
3866
- properties: {
3867
- database: {
3868
- type: "object",
3869
- properties: {
3870
- connected: { type: "boolean", example: false },
3871
- resources: { type: "integer", example: 0 }
3872
- }
3873
- }
3874
- }
3875
- }
3876
- }
3877
- }
3878
- }
3879
- }
3880
- }
3881
- }
3882
- }
3883
- }
3884
- }
3885
- };
3886
- spec.tags.push({
3887
- name: "Health",
3888
- description: "Health check endpoints for monitoring and Kubernetes probes"
3889
- });
3890
- const metricsPlugin = database.plugins?.metrics || database.plugins?.MetricsPlugin;
3891
- if (metricsPlugin && metricsPlugin.config?.prometheus?.enabled) {
3892
- const metricsPath = metricsPlugin.config.prometheus.path || "/metrics";
3893
- const isIntegrated = metricsPlugin.config.prometheus.mode !== "standalone";
3894
- if (isIntegrated) {
3895
- spec.paths[metricsPath] = {
3896
- get: {
3897
- tags: ["Monitoring"],
3898
- summary: "Prometheus Metrics",
3899
- description: "Exposes application metrics in Prometheus text-based exposition format for monitoring and observability. Metrics include operation counts, durations, errors, uptime, and resource statistics.",
3900
- responses: {
3901
- 200: {
3902
- description: "Metrics in Prometheus format",
3903
- content: {
3904
- "text/plain": {
3905
- schema: {
3906
- type: "string",
3907
- 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'
3908
- }
3909
- }
3910
- }
3911
- }
3912
- }
3913
- }
3914
- };
3915
- spec.tags.push({
3916
- name: "Monitoring",
3917
- description: "Monitoring and observability endpoints (Prometheus)"
3918
- });
3919
- }
3920
- }
3921
- return spec;
3922
- }
3923
-
3924
- class ApiServer {
3925
- /**
3926
- * Create API server
3927
- * @param {Object} options - Server options
3928
- * @param {number} options.port - Server port
3929
- * @param {string} options.host - Server host
3930
- * @param {Object} options.database - s3db.js database instance
3931
- * @param {Object} options.resources - Resource configuration
3932
- * @param {Array} options.middlewares - Global middlewares
3933
- */
3934
- constructor(options = {}) {
3935
- this.options = {
3936
- port: options.port || 3e3,
3937
- host: options.host || "0.0.0.0",
3938
- database: options.database,
3939
- resources: options.resources || {},
3940
- middlewares: options.middlewares || [],
3941
- verbose: options.verbose || false,
3942
- auth: options.auth || {},
3943
- docsEnabled: options.docsEnabled !== false,
3944
- // Enable /docs by default
3945
- docsUI: options.docsUI || "redoc",
3946
- // 'swagger' or 'redoc'
3947
- maxBodySize: options.maxBodySize || 10 * 1024 * 1024,
3948
- // 10MB default
3949
- rootHandler: options.rootHandler,
3950
- // Custom handler for root path, if not provided redirects to /docs
3951
- apiInfo: {
3952
- title: options.apiTitle || "s3db.js API",
3953
- version: options.apiVersion || "1.0.0",
3954
- description: options.apiDescription || "Auto-generated REST API for s3db.js resources"
3955
- }
3956
- };
3957
- this.app = new hono.Hono();
3958
- this.server = null;
3959
- this.isRunning = false;
3960
- this.openAPISpec = null;
3961
- this._setupRoutes();
3962
- }
3963
- /**
3964
- * Setup all routes
3965
- * @private
3966
- */
3967
- _setupRoutes() {
3968
- this.options.middlewares.forEach((middleware) => {
3969
- this.app.use("*", middleware);
3970
- });
3971
- this.app.use("*", async (c, next) => {
3972
- const method = c.req.method;
3973
- if (["POST", "PUT", "PATCH"].includes(method)) {
3974
- const contentLength = c.req.header("content-length");
3975
- if (contentLength) {
3976
- const size = parseInt(contentLength);
3977
- if (size > this.options.maxBodySize) {
3978
- const response = payloadTooLarge(size, this.options.maxBodySize);
3979
- c.header("Connection", "close");
3980
- return c.json(response, response._status);
3981
- }
3982
- }
3983
- }
3984
- await next();
3985
- });
3986
- this.app.get("/health/live", (c) => {
3987
- const response = success({
3988
- status: "alive",
3989
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3990
- });
3991
- return c.json(response);
3992
- });
3993
- this.app.get("/health/ready", (c) => {
3994
- const isReady = this.options.database && this.options.database.connected && Object.keys(this.options.database.resources).length > 0;
3995
- if (!isReady) {
3996
- const response2 = error("Service not ready", {
3997
- status: 503,
3998
- code: "NOT_READY",
3999
- details: {
4000
- database: {
4001
- connected: this.options.database?.connected || false,
4002
- resources: Object.keys(this.options.database?.resources || {}).length
4003
- }
4004
- }
4005
- });
4006
- return c.json(response2, 503);
4007
- }
4008
- const response = success({
4009
- status: "ready",
4010
- database: {
4011
- connected: true,
4012
- resources: Object.keys(this.options.database.resources).length
4013
- },
4014
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
4015
- });
4016
- return c.json(response);
4017
- });
4018
- this.app.get("/health", (c) => {
4019
- const response = success({
4020
- status: "ok",
4021
- uptime: process.uptime(),
4022
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4023
- checks: {
4024
- liveness: "/health/live",
4025
- readiness: "/health/ready"
4026
- }
4027
- });
4028
- return c.json(response);
4029
- });
4030
- this.app.get("/", (c) => {
4031
- if (this.options.rootHandler) {
4032
- return this.options.rootHandler(c);
4033
- }
4034
- return c.redirect("/docs", 302);
4035
- });
4036
- if (this.options.docsEnabled) {
4037
- this.app.get("/openapi.json", (c) => {
4038
- if (!this.openAPISpec) {
4039
- this.openAPISpec = this._generateOpenAPISpec();
4040
- }
4041
- return c.json(this.openAPISpec);
4042
- });
4043
- if (this.options.docsUI === "swagger") {
4044
- this.app.get("/docs", swaggerUi.swaggerUI({
4045
- url: "/openapi.json"
4046
- }));
4047
- } else {
4048
- this.app.get("/docs", (c) => {
4049
- return c.html(`<!DOCTYPE html>
4050
- <html lang="en">
4051
- <head>
4052
- <meta charset="UTF-8">
4053
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
4054
- <title>${this.options.apiInfo.title} - API Documentation</title>
4055
- <style>
4056
- body {
4057
- margin: 0;
4058
- padding: 0;
4059
- }
4060
- </style>
4061
- </head>
4062
- <body>
4063
- <redoc spec-url="/openapi.json"></redoc>
4064
- <script src="https://cdn.redoc.ly/redoc/v2.5.1/bundles/redoc.standalone.js"><\/script>
4065
- </body>
4066
- </html>`);
4067
- });
4068
- }
4069
- }
4070
- this._setupResourceRoutes();
4071
- this.app.onError((err, c) => {
4072
- return errorHandler(err, c);
4073
- });
4074
- this.app.notFound((c) => {
4075
- const response = error("Route not found", {
4076
- status: 404,
4077
- code: "NOT_FOUND",
4078
- details: {
4079
- path: c.req.path,
4080
- method: c.req.method
4081
- }
4082
- });
4083
- return c.json(response, 404);
4084
- });
4085
- }
4086
- /**
4087
- * Setup routes for all resources
4088
- * @private
4089
- */
4090
- _setupResourceRoutes() {
4091
- const { database, resources: resourceConfigs } = this.options;
4092
- const resources = database.resources;
4093
- for (const [name, resource] of Object.entries(resources)) {
4094
- if (name.startsWith("plg_") && !resourceConfigs[name]) {
4095
- continue;
4096
- }
4097
- const config = resourceConfigs[name] || {
4098
- methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]};
4099
- const version = resource.config?.currentVersion || resource.version || "v1";
4100
- const resourceApp = createResourceRoutes(resource, version, {
4101
- methods: config.methods,
4102
- customMiddleware: config.customMiddleware || [],
4103
- enableValidation: config.validation !== false
4104
- });
4105
- this.app.route(`/${version}/${name}`, resourceApp);
4106
- if (this.options.verbose) {
4107
- console.log(`[API Plugin] Mounted routes for resource '${name}' at /${version}/${name}`);
4108
- }
4109
- }
4110
- }
4111
- /**
4112
- * Start the server
4113
- * @returns {Promise<void>}
4114
- */
4115
- async start() {
4116
- if (this.isRunning) {
4117
- console.warn("[API Plugin] Server is already running");
4118
- return;
4119
- }
4120
- const { port, host } = this.options;
4121
- return new Promise((resolve, reject) => {
4122
- try {
4123
- this.server = nodeServer.serve({
4124
- fetch: this.app.fetch,
4125
- port,
4126
- hostname: host
4127
- }, (info) => {
4128
- this.isRunning = true;
4129
- console.log(`[API Plugin] Server listening on http://${info.address}:${info.port}`);
4130
- resolve();
4131
- });
4132
- } catch (err) {
4133
- reject(err);
4134
- }
4135
- });
4136
- }
4137
- /**
4138
- * Stop the server
4139
- * @returns {Promise<void>}
4140
- */
4141
- async stop() {
4142
- if (!this.isRunning) {
4143
- console.warn("[API Plugin] Server is not running");
4144
- return;
4145
- }
4146
- if (this.server && typeof this.server.close === "function") {
4147
- await new Promise((resolve) => {
4148
- this.server.close(() => {
4149
- this.isRunning = false;
4150
- console.log("[API Plugin] Server stopped");
4151
- resolve();
4152
- });
4153
- });
4154
- } else {
4155
- this.isRunning = false;
4156
- console.log("[API Plugin] Server stopped");
4157
- }
4158
- }
4159
- /**
4160
- * Get server info
4161
- * @returns {Object} Server information
4162
- */
4163
- getInfo() {
4164
- return {
4165
- isRunning: this.isRunning,
4166
- port: this.options.port,
4167
- host: this.options.host,
4168
- resources: Object.keys(this.options.database.resources).length
4169
- };
4170
- }
4171
- /**
4172
- * Get Hono app instance
4173
- * @returns {Hono} Hono app
4174
- */
4175
- getApp() {
4176
- return this.app;
4177
- }
4178
- /**
4179
- * Generate OpenAPI specification
4180
- * @private
4181
- * @returns {Object} OpenAPI spec
4182
- */
4183
- _generateOpenAPISpec() {
4184
- const { port, host, database, resources, auth, apiInfo } = this.options;
4185
- return generateOpenAPISpec(database, {
4186
- title: apiInfo.title,
4187
- version: apiInfo.version,
4188
- description: apiInfo.description,
4189
- serverUrl: `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`,
4190
- auth,
4191
- resources
4192
- });
4193
- }
4194
- }
4195
-
4196
2474
  const PLUGIN_DEPENDENCIES = {
4197
2475
  "postgresql-replicator": {
4198
2476
  name: "PostgreSQL Replicator",
2477
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/replicator.md",
4199
2478
  dependencies: {
4200
2479
  "pg": {
4201
2480
  version: "^8.0.0",
4202
2481
  description: "PostgreSQL client for Node.js",
4203
- installCommand: "pnpm add pg"
2482
+ installCommand: "pnpm add pg",
2483
+ npmUrl: "https://www.npmjs.com/package/pg"
4204
2484
  }
4205
2485
  }
4206
2486
  },
4207
2487
  "bigquery-replicator": {
4208
2488
  name: "BigQuery Replicator",
2489
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/replicator.md",
4209
2490
  dependencies: {
4210
2491
  "@google-cloud/bigquery": {
4211
2492
  version: "^7.0.0",
4212
2493
  description: "Google Cloud BigQuery SDK",
4213
- installCommand: "pnpm add @google-cloud/bigquery"
2494
+ installCommand: "pnpm add @google-cloud/bigquery",
2495
+ npmUrl: "https://www.npmjs.com/package/@google-cloud/bigquery"
4214
2496
  }
4215
2497
  }
4216
2498
  },
4217
2499
  "sqs-replicator": {
4218
2500
  name: "SQS Replicator",
2501
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/replicator.md",
4219
2502
  dependencies: {
4220
2503
  "@aws-sdk/client-sqs": {
4221
2504
  version: "^3.0.0",
4222
2505
  description: "AWS SDK for SQS",
4223
- installCommand: "pnpm add @aws-sdk/client-sqs"
2506
+ installCommand: "pnpm add @aws-sdk/client-sqs",
2507
+ npmUrl: "https://www.npmjs.com/package/@aws-sdk/client-sqs"
4224
2508
  }
4225
2509
  }
4226
2510
  },
4227
2511
  "sqs-consumer": {
4228
2512
  name: "SQS Queue Consumer",
2513
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/queue-consumer.md",
4229
2514
  dependencies: {
4230
2515
  "@aws-sdk/client-sqs": {
4231
2516
  version: "^3.0.0",
4232
2517
  description: "AWS SDK for SQS",
4233
- installCommand: "pnpm add @aws-sdk/client-sqs"
2518
+ installCommand: "pnpm add @aws-sdk/client-sqs",
2519
+ npmUrl: "https://www.npmjs.com/package/@aws-sdk/client-sqs"
4234
2520
  }
4235
2521
  }
4236
2522
  },
4237
2523
  "rabbitmq-consumer": {
4238
2524
  name: "RabbitMQ Queue Consumer",
2525
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/queue-consumer.md",
4239
2526
  dependencies: {
4240
2527
  "amqplib": {
4241
2528
  version: "^0.10.0",
4242
2529
  description: "AMQP 0-9-1 library for RabbitMQ",
4243
- installCommand: "pnpm add amqplib"
2530
+ installCommand: "pnpm add amqplib",
2531
+ npmUrl: "https://www.npmjs.com/package/amqplib"
4244
2532
  }
4245
2533
  }
4246
2534
  },
4247
2535
  "tfstate-plugin": {
4248
- name: "Terraform State Plugin",
2536
+ name: "Tfstate Plugin",
2537
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/tfstate.md",
4249
2538
  dependencies: {
4250
2539
  "node-cron": {
4251
2540
  version: "^4.0.0",
4252
2541
  description: "Cron job scheduler for auto-sync functionality",
4253
- installCommand: "pnpm add node-cron"
2542
+ installCommand: "pnpm add node-cron",
2543
+ npmUrl: "https://www.npmjs.com/package/node-cron"
4254
2544
  }
4255
2545
  }
4256
2546
  },
4257
2547
  "api-plugin": {
4258
2548
  name: "API Plugin",
2549
+ docsUrl: "https://github.com/forattini-dev/s3db.js/blob/main/docs/plugins/api.md",
4259
2550
  dependencies: {
4260
2551
  "hono": {
4261
2552
  version: "^4.0.0",
4262
2553
  description: "Ultra-light HTTP server framework",
4263
- installCommand: "pnpm add hono"
2554
+ installCommand: "pnpm add hono",
2555
+ npmUrl: "https://www.npmjs.com/package/hono"
4264
2556
  },
4265
2557
  "@hono/node-server": {
4266
2558
  version: "^1.0.0",
4267
2559
  description: "Node.js adapter for Hono",
4268
- installCommand: "pnpm add @hono/node-server"
2560
+ installCommand: "pnpm add @hono/node-server",
2561
+ npmUrl: "https://www.npmjs.com/package/@hono/node-server"
4269
2562
  },
4270
2563
  "@hono/swagger-ui": {
4271
2564
  version: "^0.4.0",
4272
2565
  description: "Swagger UI integration for Hono",
4273
- installCommand: "pnpm add @hono/swagger-ui"
2566
+ installCommand: "pnpm add @hono/swagger-ui",
2567
+ npmUrl: "https://www.npmjs.com/package/@hono/swagger-ui"
4274
2568
  }
4275
2569
  }
4276
2570
  }
@@ -4356,21 +2650,55 @@ async function requirePluginDependency(pluginId, options = {}) {
4356
2650
  }
4357
2651
  const valid = missing.length === 0 && incompatible.length === 0;
4358
2652
  if (!valid && throwOnError) {
2653
+ const depCount = Object.keys(pluginDef.dependencies).length;
2654
+ const missingCount = missing.length;
2655
+ const incompatCount = incompatible.length;
4359
2656
  const errorMsg = [
4360
- `
4361
- ${pluginDef.name} - Missing dependencies detected!
4362
- `,
4363
- `Plugin ID: ${pluginId}`,
4364
2657
  "",
2658
+ "\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",
2659
+ `\u2551 \u274C ${pluginDef.name} - Missing Dependencies \u2551`,
2660
+ "\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",
2661
+ "",
2662
+ `\u{1F4E6} Plugin: ${pluginId}`,
2663
+ `\u{1F4CA} Status: ${depCount - missingCount - incompatCount}/${depCount} dependencies satisfied`,
2664
+ "",
2665
+ "\u{1F50D} Dependency Status:",
2666
+ "\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",
4365
2667
  ...messages,
4366
2668
  "",
4367
- "Quick fix - Run all install commands:",
4368
- Object.values(pluginDef.dependencies).map((dep) => ` ${dep.installCommand}`).join("\n"),
2669
+ "\u{1F680} Quick Fix - Install Missing Dependencies:",
2670
+ "\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",
2671
+ "",
2672
+ " Option 1: Install individually",
2673
+ ...Object.entries(pluginDef.dependencies).filter(([pkg]) => missing.includes(pkg) || incompatible.includes(pkg)).map(([pkg, info]) => ` ${info.installCommand}`),
2674
+ "",
2675
+ " Option 2: Install all at once",
2676
+ ` pnpm add ${Object.keys(pluginDef.dependencies).join(" ")}`,
4369
2677
  "",
4370
- "Or install all peer dependencies at once:",
4371
- ` pnpm add ${Object.keys(pluginDef.dependencies).join(" ")}`
2678
+ "\u{1F4DA} Documentation:",
2679
+ ` ${pluginDef.docsUrl}`,
2680
+ "",
2681
+ "\u{1F4A1} Troubleshooting:",
2682
+ " \u2022 If packages are installed but not detected, try:",
2683
+ " 1. Delete node_modules and reinstall: rm -rf node_modules && pnpm install",
2684
+ " 2. Check Node.js version: node --version (requires Node 18+)",
2685
+ " 3. Verify pnpm version: pnpm --version (requires pnpm 8+)",
2686
+ "",
2687
+ " \u2022 Still having issues? Check:",
2688
+ " - Package.json has correct dependencies listed",
2689
+ " - No conflicting versions in pnpm-lock.yaml",
2690
+ " - File permissions (especially in node_modules/)",
2691
+ "",
2692
+ "\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",
2693
+ ""
4372
2694
  ].join("\n");
4373
- throw new Error(errorMsg);
2695
+ const error = new Error(errorMsg);
2696
+ error.pluginId = pluginId;
2697
+ error.pluginName = pluginDef.name;
2698
+ error.missing = missing;
2699
+ error.incompatible = incompatible;
2700
+ error.docsUrl = pluginDef.docsUrl;
2701
+ throw error;
4374
2702
  }
4375
2703
  return { valid, missing, incompatible, messages };
4376
2704
  }
@@ -4387,7 +2715,6 @@ class ApiPlugin extends Plugin {
4387
2715
  port: options.port || 3e3,
4388
2716
  host: options.host || "0.0.0.0",
4389
2717
  verbose: options.verbose || false,
4390
- // API Documentation (supports both new and legacy formats)
4391
2718
  docs: {
4392
2719
  enabled: options.docs?.enabled !== false && options.docsEnabled !== false,
4393
2720
  // Enable by default
@@ -4704,6 +3031,11 @@ class ApiPlugin extends Plugin {
4704
3031
  if (this.config.verbose) {
4705
3032
  console.log("[API Plugin] Starting server...");
4706
3033
  }
3034
+ const serverPath = "./server.js";
3035
+ const { ApiServer } = await import(
3036
+ /* @vite-ignore */
3037
+ serverPath
3038
+ );
4707
3039
  this.server = new ApiServer({
4708
3040
  port: this.config.port,
4709
3041
  host: this.config.host,
@@ -5766,8 +4098,6 @@ class MultiBackupDriver extends BaseBackupDriver {
5766
4098
  strategy: "all",
5767
4099
  // 'all', 'any', 'priority'
5768
4100
  concurrency: 3,
5769
- requireAll: true,
5770
- // For backward compatibility
5771
4101
  ...config
5772
4102
  });
5773
4103
  this.drivers = [];
@@ -6404,13 +4734,13 @@ class BackupPlugin extends Plugin {
6404
4734
  createdAt: now.toISOString().slice(0, 10)
6405
4735
  };
6406
4736
  const [ok] = await tryFn(
6407
- () => this.database.resource(this.config.backupMetadataResource).insert(metadata)
4737
+ () => this.database.resources[this.config.backupMetadataResource].insert(metadata)
6408
4738
  );
6409
4739
  return metadata;
6410
4740
  }
6411
4741
  async _updateBackupMetadata(backupId, updates) {
6412
4742
  const [ok] = await tryFn(
6413
- () => this.database.resource(this.config.backupMetadataResource).update(backupId, updates)
4743
+ () => this.database.resources[this.config.backupMetadataResource].update(backupId, updates)
6414
4744
  );
6415
4745
  }
6416
4746
  async _createBackupManifest(type, options) {
@@ -6445,7 +4775,7 @@ class BackupPlugin extends Plugin {
6445
4775
  let sinceTimestamp = null;
6446
4776
  if (type === "incremental") {
6447
4777
  const [lastBackupOk, , lastBackups] = await tryFn(
6448
- () => this.database.resource(this.config.backupMetadataResource).list({
4778
+ () => this.database.resources[this.config.backupMetadataResource].list({
6449
4779
  filter: {
6450
4780
  status: "completed",
6451
4781
  type: { $in: ["full", "incremental"] }
@@ -6753,7 +5083,7 @@ class BackupPlugin extends Plugin {
6753
5083
  try {
6754
5084
  const driverBackups = await this.driver.list(options);
6755
5085
  const [metaOk, , metadataRecords] = await tryFn(
6756
- () => this.database.resource(this.config.backupMetadataResource).list({
5086
+ () => this.database.resources[this.config.backupMetadataResource].list({
6757
5087
  limit: options.limit || 50,
6758
5088
  sort: { timestamp: -1 }
6759
5089
  })
@@ -6781,14 +5111,14 @@ class BackupPlugin extends Plugin {
6781
5111
  */
6782
5112
  async getBackupStatus(backupId) {
6783
5113
  const [ok, , backup] = await tryFn(
6784
- () => this.database.resource(this.config.backupMetadataResource).get(backupId)
5114
+ () => this.database.resources[this.config.backupMetadataResource].get(backupId)
6785
5115
  );
6786
5116
  return ok ? backup : null;
6787
5117
  }
6788
5118
  async _cleanupOldBackups() {
6789
5119
  try {
6790
5120
  const [listOk, , allBackups] = await tryFn(
6791
- () => this.database.resource(this.config.backupMetadataResource).list({
5121
+ () => this.database.resources[this.config.backupMetadataResource].list({
6792
5122
  filter: { status: "completed" },
6793
5123
  sort: { timestamp: -1 }
6794
5124
  })
@@ -6855,7 +5185,7 @@ class BackupPlugin extends Plugin {
6855
5185
  for (const backup of backupsToDelete) {
6856
5186
  try {
6857
5187
  await this.driver.delete(backup.id, backup.driverInfo);
6858
- await this.database.resource(this.config.backupMetadataResource).delete(backup.id);
5188
+ await this.database.resources[this.config.backupMetadataResource].delete(backup.id);
6859
5189
  if (this.config.verbose) {
6860
5190
  console.log(`[BackupPlugin] Deleted old backup: ${backup.id}`);
6861
5191
  }
@@ -6891,12 +5221,6 @@ class BackupPlugin extends Plugin {
6891
5221
  await this.driver.cleanup();
6892
5222
  }
6893
5223
  }
6894
- /**
6895
- * Cleanup plugin resources (alias for stop for backward compatibility)
6896
- */
6897
- async cleanup() {
6898
- await this.stop();
6899
- }
6900
5224
  }
6901
5225
 
6902
5226
  class CacheError extends S3dbError {
@@ -9792,9 +8116,6 @@ async function consolidateRecord(originalId, transactionResource, targetResource
9792
8116
  if (txnWithCohorts.cohortMonth && !txn.cohortMonth) {
9793
8117
  updateData.cohortMonth = txnWithCohorts.cohortMonth;
9794
8118
  }
9795
- if (txn.value === null || txn.value === void 0) {
9796
- updateData.value = 1;
9797
- }
9798
8119
  const [ok2, err2] = await tryFn(
9799
8120
  () => transactionResource.update(txn.id, updateData)
9800
8121
  );
@@ -11118,8 +9439,7 @@ async function completeFieldSetup(handler, database, config, plugin) {
11118
9439
  operation: "string|required",
11119
9440
  timestamp: "string|required",
11120
9441
  cohortDate: "string|required",
11121
- cohortHour: "string|optional",
11122
- // ✅ FIX BUG #2: Changed from required to optional for migration compatibility
9442
+ cohortHour: "string|required",
11123
9443
  cohortWeek: "string|optional",
11124
9444
  cohortMonth: "string|optional",
11125
9445
  source: "string|optional",
@@ -13453,7 +11773,7 @@ class RelationPlugin extends Plugin {
13453
11773
  * @private
13454
11774
  */
13455
11775
  async _setupResourceRelations(resourceName, relationsDef) {
13456
- const resource = this.database.resource(resourceName);
11776
+ const resource = this.database.resources[resourceName];
13457
11777
  if (!resource) {
13458
11778
  if (this.verbose) {
13459
11779
  console.warn(`[RelationPlugin] Resource "${resourceName}" not found, will setup when created`);
@@ -13564,7 +11884,7 @@ class RelationPlugin extends Plugin {
13564
11884
  for (const record of records) {
13565
11885
  const relatedData = record[relationName];
13566
11886
  if (relatedData) {
13567
- const relatedResource = this.database.resource(config.resource);
11887
+ const relatedResource = this.database.resources[config.resource];
13568
11888
  const relatedArray = Array.isArray(relatedData) ? relatedData : [relatedData];
13569
11889
  if (relatedArray.length > 0) {
13570
11890
  await this._eagerLoad(relatedArray, nestedIncludes, relatedResource);
@@ -13615,7 +11935,7 @@ class RelationPlugin extends Plugin {
13615
11935
  * @private
13616
11936
  */
13617
11937
  async _loadHasOne(records, relationName, config, sourceResource) {
13618
- const relatedResource = this.database.resource(config.resource);
11938
+ const relatedResource = this.database.resources[config.resource];
13619
11939
  if (!relatedResource) {
13620
11940
  throw new RelatedResourceNotFoundError(config.resource, {
13621
11941
  sourceResource: sourceResource.name,
@@ -13654,7 +11974,7 @@ class RelationPlugin extends Plugin {
13654
11974
  * @private
13655
11975
  */
13656
11976
  async _loadHasMany(records, relationName, config, sourceResource) {
13657
- const relatedResource = this.database.resource(config.resource);
11977
+ const relatedResource = this.database.resources[config.resource];
13658
11978
  if (!relatedResource) {
13659
11979
  throw new RelatedResourceNotFoundError(config.resource, {
13660
11980
  sourceResource: sourceResource.name,
@@ -13700,7 +12020,7 @@ class RelationPlugin extends Plugin {
13700
12020
  * @private
13701
12021
  */
13702
12022
  async _loadBelongsTo(records, relationName, config, sourceResource) {
13703
- const relatedResource = this.database.resource(config.resource);
12023
+ const relatedResource = this.database.resources[config.resource];
13704
12024
  if (!relatedResource) {
13705
12025
  throw new RelatedResourceNotFoundError(config.resource, {
13706
12026
  sourceResource: sourceResource.name,
@@ -13750,14 +12070,14 @@ class RelationPlugin extends Plugin {
13750
12070
  * @private
13751
12071
  */
13752
12072
  async _loadBelongsToMany(records, relationName, config, sourceResource) {
13753
- const relatedResource = this.database.resource(config.resource);
12073
+ const relatedResource = this.database.resources[config.resource];
13754
12074
  if (!relatedResource) {
13755
12075
  throw new RelatedResourceNotFoundError(config.resource, {
13756
12076
  sourceResource: sourceResource.name,
13757
12077
  relation: relationName
13758
12078
  });
13759
12079
  }
13760
- const junctionResource = this.database.resource(config.through);
12080
+ const junctionResource = this.database.resources[config.through];
13761
12081
  if (!junctionResource) {
13762
12082
  throw new JunctionTableNotFoundError(config.through, {
13763
12083
  sourceResource: sourceResource.name,
@@ -13941,7 +12261,7 @@ class RelationPlugin extends Plugin {
13941
12261
  */
13942
12262
  async _cascadeDelete(record, resource, relationName, config) {
13943
12263
  this.stats.cascadeOperations++;
13944
- const relatedResource = this.database.resource(config.resource);
12264
+ const relatedResource = this.database.resources[config.resource];
13945
12265
  if (!relatedResource) {
13946
12266
  throw new RelatedResourceNotFoundError(config.resource, {
13947
12267
  sourceResource: resource.name,
@@ -13949,7 +12269,7 @@ class RelationPlugin extends Plugin {
13949
12269
  });
13950
12270
  }
13951
12271
  const deletedRecords = [];
13952
- config.type === "belongsToMany" ? this.database.resource(config.through) : null;
12272
+ config.type === "belongsToMany" ? this.database.resources[config.through] : null;
13953
12273
  try {
13954
12274
  if (config.type === "hasMany") {
13955
12275
  let relatedRecords;
@@ -14000,7 +12320,7 @@ class RelationPlugin extends Plugin {
14000
12320
  await relatedResource.delete(relatedRecords[0].id);
14001
12321
  }
14002
12322
  } else if (config.type === "belongsToMany") {
14003
- const junctionResource2 = this.database.resource(config.through);
12323
+ const junctionResource2 = this.database.resources[config.through];
14004
12324
  if (junctionResource2) {
14005
12325
  let junctionRecords;
14006
12326
  const partitionName = this._findPartitionByField(junctionResource2, config.foreignKey);
@@ -14068,7 +12388,7 @@ class RelationPlugin extends Plugin {
14068
12388
  */
14069
12389
  async _cascadeUpdate(record, changes, resource, relationName, config) {
14070
12390
  this.stats.cascadeOperations++;
14071
- const relatedResource = this.database.resource(config.resource);
12391
+ const relatedResource = this.database.resources[config.resource];
14072
12392
  if (!relatedResource) {
14073
12393
  return;
14074
12394
  }
@@ -19353,12 +17673,42 @@ ${errorDetails}`,
19353
17673
  createdBy
19354
17674
  };
19355
17675
  this.hooks = {
17676
+ // Insert hooks
19356
17677
  beforeInsert: [],
19357
17678
  afterInsert: [],
17679
+ // Update hooks
19358
17680
  beforeUpdate: [],
19359
17681
  afterUpdate: [],
17682
+ // Delete hooks
19360
17683
  beforeDelete: [],
19361
- afterDelete: []
17684
+ afterDelete: [],
17685
+ // Get hooks
17686
+ beforeGet: [],
17687
+ afterGet: [],
17688
+ // List hooks
17689
+ beforeList: [],
17690
+ afterList: [],
17691
+ // Query hooks
17692
+ beforeQuery: [],
17693
+ afterQuery: [],
17694
+ // Patch hooks
17695
+ beforePatch: [],
17696
+ afterPatch: [],
17697
+ // Replace hooks
17698
+ beforeReplace: [],
17699
+ afterReplace: [],
17700
+ // Exists hooks
17701
+ beforeExists: [],
17702
+ afterExists: [],
17703
+ // Count hooks
17704
+ beforeCount: [],
17705
+ afterCount: [],
17706
+ // GetMany hooks
17707
+ beforeGetMany: [],
17708
+ afterGetMany: [],
17709
+ // DeleteMany hooks
17710
+ beforeDeleteMany: [],
17711
+ afterDeleteMany: []
19362
17712
  };
19363
17713
  this.attributes = attributes || {};
19364
17714
  this.map = config.map;
@@ -19421,19 +17771,6 @@ ${errorDetails}`,
19421
17771
  }
19422
17772
  return idSize;
19423
17773
  }
19424
- /**
19425
- * Get resource options (for backward compatibility with tests)
19426
- */
19427
- get options() {
19428
- return {
19429
- timestamps: this.config.timestamps,
19430
- partitions: this.config.partitions || {},
19431
- cache: this.config.cache,
19432
- autoDecrypt: this.config.autoDecrypt,
19433
- paranoid: this.config.paranoid,
19434
- allNestedObjectsOptional: this.config.allNestedObjectsOptional
19435
- };
19436
- }
19437
17774
  export() {
19438
17775
  const exported = this.schema.export();
19439
17776
  exported.behavior = this.behavior;
@@ -19560,19 +17897,71 @@ ${errorDetails}`,
19560
17897
  return data;
19561
17898
  });
19562
17899
  }
19563
- async validate(data) {
17900
+ /**
17901
+ * Validate data against resource schema without saving
17902
+ * @param {Object} data - Data to validate
17903
+ * @param {Object} options - Validation options
17904
+ * @param {boolean} options.throwOnError - Throw error if validation fails (default: false)
17905
+ * @param {boolean} options.includeId - Include ID validation (default: false)
17906
+ * @param {boolean} options.mutateOriginal - Allow mutation of original data (default: false)
17907
+ * @returns {Promise<{valid: boolean, isValid: boolean, errors: Array, data: Object, original: Object}>} Validation result
17908
+ * @example
17909
+ * // Validate before insert
17910
+ * const result = await resource.validate({
17911
+ * name: 'John Doe',
17912
+ * email: 'invalid-email' // Will fail email validation
17913
+ * });
17914
+ *
17915
+ * if (!result.valid) {
17916
+ * console.log('Validation errors:', result.errors);
17917
+ * // [{ field: 'email', message: '...', ... }]
17918
+ * }
17919
+ *
17920
+ * // Throw on error
17921
+ * try {
17922
+ * await resource.validate({ email: 'bad' }, { throwOnError: true });
17923
+ * } catch (err) {
17924
+ * console.log('Validation failed:', err.message);
17925
+ * }
17926
+ */
17927
+ async validate(data, options = {}) {
17928
+ const {
17929
+ throwOnError = false,
17930
+ includeId = false,
17931
+ mutateOriginal = false
17932
+ } = options;
17933
+ const dataToValidate = mutateOriginal ? data : lodashEs.cloneDeep(data);
17934
+ if (!includeId && dataToValidate.id) {
17935
+ delete dataToValidate.id;
17936
+ }
19564
17937
  const result = {
19565
17938
  original: lodashEs.cloneDeep(data),
19566
17939
  isValid: false,
19567
- errors: []
17940
+ errors: [],
17941
+ data: dataToValidate
19568
17942
  };
19569
- const check = await this.schema.validate(data, { mutateOriginal: false });
19570
- if (check === true) {
19571
- result.isValid = true;
19572
- } else {
19573
- result.errors = check;
17943
+ try {
17944
+ const check = await this.schema.validate(dataToValidate, { mutateOriginal });
17945
+ if (check === true) {
17946
+ result.isValid = true;
17947
+ } else {
17948
+ result.errors = Array.isArray(check) ? check : [check];
17949
+ result.isValid = false;
17950
+ if (throwOnError) {
17951
+ const error = new Error("Validation failed");
17952
+ error.validationErrors = result.errors;
17953
+ error.invalidData = data;
17954
+ throw error;
17955
+ }
17956
+ }
17957
+ } catch (err) {
17958
+ if (!throwOnError) {
17959
+ result.errors = [{ message: err.message, error: err }];
17960
+ result.isValid = false;
17961
+ } else {
17962
+ throw err;
17963
+ }
19574
17964
  }
19575
- result.data = data;
19576
17965
  return result;
19577
17966
  }
19578
17967
  /**
@@ -19837,12 +18226,12 @@ ${errorDetails}`,
19837
18226
  const exists = await this.exists(id$1);
19838
18227
  if (exists) throw new Error(`Resource with id '${id$1}' already exists`);
19839
18228
  this.getResourceKey(id$1 || "(auto)");
19840
- if (this.options.timestamps) {
18229
+ if (this.config.timestamps) {
19841
18230
  attributes.createdAt = (/* @__PURE__ */ new Date()).toISOString();
19842
18231
  attributes.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
19843
18232
  }
19844
18233
  const attributesWithDefaults = this.applyDefaults(attributes);
19845
- const completeData = { id: id$1, ...attributesWithDefaults };
18234
+ const completeData = id$1 !== void 0 ? { id: id$1, ...attributesWithDefaults } : { ...attributesWithDefaults };
19846
18235
  const preProcessedData = await this.executeHooks("beforeInsert", completeData);
19847
18236
  const extraProps = Object.keys(preProcessedData).filter(
19848
18237
  (k) => !(k in completeData) || preProcessedData[k] !== completeData[k]
@@ -19853,7 +18242,7 @@ ${errorDetails}`,
19853
18242
  errors,
19854
18243
  isValid,
19855
18244
  data: validated
19856
- } = await this.validate(preProcessedData);
18245
+ } = await this.validate(preProcessedData, { includeId: true });
19857
18246
  if (!isValid) {
19858
18247
  const errorMsg = errors && errors.length && errors[0].message ? errors[0].message : "Insert failed";
19859
18248
  throw new InvalidResourceItem({
@@ -19957,6 +18346,7 @@ ${errorDetails}`,
19957
18346
  async get(id) {
19958
18347
  if (lodashEs.isObject(id)) throw new Error(`id cannot be an object`);
19959
18348
  if (lodashEs.isEmpty(id)) throw new Error("id cannot be empty");
18349
+ await this.executeHooks("beforeGet", { id });
19960
18350
  const key = this.getResourceKey(id);
19961
18351
  const [ok, err, request] = await tryFn(() => this.client.getObject(key));
19962
18352
  if (!ok) {
@@ -20005,17 +18395,67 @@ ${errorDetails}`,
20005
18395
  if (objectVersion !== this.version) {
20006
18396
  data = await this.applyVersionMapping(data, objectVersion, this.version);
20007
18397
  }
18398
+ data = await this.executeHooks("afterGet", data);
20008
18399
  this.emit("get", data);
20009
18400
  const value = data;
20010
18401
  return value;
20011
18402
  }
18403
+ /**
18404
+ * Retrieve a resource object by ID, or return null if not found
18405
+ * @param {string} id - Resource ID
18406
+ * @returns {Promise<Object|null>} The resource object or null if not found
18407
+ * @example
18408
+ * const user = await resource.getOrNull('user-123');
18409
+ * if (user) {
18410
+ * console.log('Found user:', user.name);
18411
+ * } else {
18412
+ * console.log('User not found');
18413
+ * }
18414
+ */
18415
+ async getOrNull(id) {
18416
+ const [ok, err, data] = await tryFn(() => this.get(id));
18417
+ if (!ok && err && (err.name === "NoSuchKey" || err.message?.includes("NoSuchKey"))) {
18418
+ return null;
18419
+ }
18420
+ if (!ok) {
18421
+ throw err;
18422
+ }
18423
+ return data;
18424
+ }
18425
+ /**
18426
+ * Retrieve a resource object by ID, or throw ResourceNotFoundError if not found
18427
+ * @param {string} id - Resource ID
18428
+ * @returns {Promise<Object>} The resource object
18429
+ * @throws {ResourceError} If resource does not exist
18430
+ * @example
18431
+ * // Throws error if user doesn't exist (no need for null check)
18432
+ * const user = await resource.getOrThrow('user-123');
18433
+ * console.log('User name:', user.name); // Safe to access
18434
+ */
18435
+ async getOrThrow(id) {
18436
+ const [ok, err, data] = await tryFn(() => this.get(id));
18437
+ if (!ok && err && (err.name === "NoSuchKey" || err.message?.includes("NoSuchKey"))) {
18438
+ throw new ResourceError(`Resource '${this.name}' with id '${id}' not found`, {
18439
+ resourceName: this.name,
18440
+ operation: "getOrThrow",
18441
+ id,
18442
+ code: "RESOURCE_NOT_FOUND"
18443
+ });
18444
+ }
18445
+ if (!ok) {
18446
+ throw err;
18447
+ }
18448
+ return data;
18449
+ }
20012
18450
  /**
20013
18451
  * Check if a resource exists by ID
20014
18452
  * @returns {Promise<boolean>} True if resource exists, false otherwise
20015
18453
  */
20016
18454
  async exists(id) {
18455
+ await this.executeHooks("beforeExists", { id });
20017
18456
  const key = this.getResourceKey(id);
20018
18457
  const [ok, err] = await tryFn(() => this.client.headObject(key));
18458
+ await this.executeHooks("afterExists", { id, exists: ok });
20019
18459
  return ok;
20020
18460
  }
20021
18461
  /**
@@ -20071,7 +18511,7 @@ ${errorDetails}`,
20071
18511
  }
20072
18512
  const preProcessedData = await this.executeHooks("beforeUpdate", lodashEs.cloneDeep(mergedData));
20073
18513
  const completeData = { ...originalData, ...preProcessedData, id };
20074
- const { isValid, errors, data } = await this.validate(lodashEs.cloneDeep(completeData));
18514
+ const { isValid, errors, data } = await this.validate(lodashEs.cloneDeep(completeData), { includeId: true });
20075
18515
  if (!isValid) {
20076
18516
  throw new InvalidResourceItem({
20077
18517
  bucket: this.client.config.bucket,
@@ -20244,12 +18684,17 @@ ${errorDetails}`,
20244
18684
  if (!fields || typeof fields !== "object") {
20245
18685
  throw new Error("fields must be a non-empty object");
20246
18686
  }
18687
+ await this.executeHooks("beforePatch", { id, fields, options });
20247
18688
  const behavior = this.behavior;
20248
18689
  const hasNestedFields = Object.keys(fields).some((key) => key.includes("."));
18690
+ let result;
20249
18691
  if ((behavior === "enforce-limits" || behavior === "truncate-data") && !hasNestedFields) {
20250
- return await this._patchViaCopyObject(id, fields, options);
18692
+ result = await this._patchViaCopyObject(id, fields, options);
18693
+ } else {
18694
+ result = await this.update(id, fields, options);
20251
18695
  }
20252
- return await this.update(id, fields, options);
18696
+ const finalResult = await this.executeHooks("afterPatch", result);
18697
+ return finalResult;
20253
18698
  }
20254
18699
  /**
20255
18700
  * Internal helper: Optimized patch using HeadObject + CopyObject
@@ -20349,6 +18794,7 @@ ${errorDetails}`,
20349
18794
  if (!fullData || typeof fullData !== "object") {
20350
18795
  throw new Error("fullData must be a non-empty object");
20351
18796
  }
18797
+ await this.executeHooks("beforeReplace", { id, fullData, options });
20352
18798
  const { partition, partitionValues } = options;
20353
18799
  const dataClone = lodashEs.cloneDeep(fullData);
20354
18800
  const attributesWithDefaults = this.applyDefaults(dataClone);
@@ -20363,7 +18809,7 @@ ${errorDetails}`,
20363
18809
  errors,
20364
18810
  isValid,
20365
18811
  data: validated
20366
- } = await this.validate(completeData);
18812
+ } = await this.validate(completeData, { includeId: true });
20367
18813
  if (!isValid) {
20368
18814
  const errorMsg = errors && errors.length && errors[0].message ? errors[0].message : "Replace failed";
20369
18815
  throw new InvalidResourceItem({
@@ -20436,7 +18882,8 @@ ${errorDetails}`,
20436
18882
  await this.handlePartitionReferenceUpdates({}, replacedObject);
20437
18883
  }
20438
18884
  }
20439
- return replacedObject;
18885
+ const finalResult = await this.executeHooks("afterReplace", replacedObject);
18886
+ return finalResult;
20440
18887
  }
20441
18888
  /**
20442
18889
  * Update with conditional check (If-Match ETag)
@@ -20494,7 +18941,7 @@ ${errorDetails}`,
20494
18941
  }
20495
18942
  const preProcessedData = await this.executeHooks("beforeUpdate", lodashEs.cloneDeep(mergedData));
20496
18943
  const completeData = { ...originalData, ...preProcessedData, id };
20497
- const { isValid, errors, data } = await this.validate(lodashEs.cloneDeep(completeData));
18944
+ const { isValid, errors, data } = await this.validate(lodashEs.cloneDeep(completeData), { includeId: true });
20498
18945
  if (!isValid) {
20499
18946
  return {
20500
18947
  success: false,
@@ -20715,6 +19162,7 @@ ${errorDetails}`,
20715
19162
  * });
20716
19163
  */
20717
19164
  async count({ partition = null, partitionValues = {} } = {}) {
19165
+ await this.executeHooks("beforeCount", { partition, partitionValues });
20718
19166
  let prefix;
20719
19167
  if (partition && Object.keys(partitionValues).length > 0) {
20720
19168
  const partitionDef = this.config.partitions[partition];
@@ -20739,6 +19187,7 @@ ${errorDetails}`,
20739
19187
  prefix = `resource=${this.name}/data`;
20740
19188
  }
20741
19189
  const count = await this.client.count({ prefix });
19190
+ await this.executeHooks("afterCount", { count, partition, partitionValues });
20742
19191
  this.emit("count", count);
20743
19192
  return count;
20744
19193
  }
@@ -20774,6 +19223,7 @@ ${errorDetails}`,
20774
19223
  * const results = await resource.deleteMany(deletedIds);
20775
19224
  */
20776
19225
  async deleteMany(ids) {
19226
+ await this.executeHooks("beforeDeleteMany", { ids });
20777
19227
  const packages = lodashEs.chunk(
20778
19228
  ids.map((id) => this.getResourceKey(id)),
20779
19229
  1e3
@@ -20795,6 +19245,7 @@ ${errorDetails}`,
20795
19245
  });
20796
19246
  return response;
20797
19247
  });
19248
+ await this.executeHooks("afterDeleteMany", { ids, results });
20798
19249
  this.emit("deleteMany", ids.length);
20799
19250
  return results;
20800
19251
  }
@@ -20916,6 +19367,7 @@ ${errorDetails}`,
20916
19367
  * });
20917
19368
  */
20918
19369
  async list({ partition = null, partitionValues = {}, limit, offset = 0 } = {}) {
19370
+ await this.executeHooks("beforeList", { partition, partitionValues, limit, offset });
20919
19371
  const [ok, err, result] = await tryFn(async () => {
20920
19372
  if (!partition) {
20921
19373
  return await this.listMain({ limit, offset });
@@ -20925,7 +19377,8 @@ ${errorDetails}`,
20925
19377
  if (!ok) {
20926
19378
  return this.handleListError(err, { partition, partitionValues });
20927
19379
  }
20928
- return result;
19380
+ const finalResult = await this.executeHooks("afterList", result);
19381
+ return finalResult;
20929
19382
  }
20930
19383
  async listMain({ limit, offset = 0 }) {
20931
19384
  const [ok, err, ids] = await tryFn(() => this.listIds({ limit, offset }));
@@ -21068,6 +19521,7 @@ ${errorDetails}`,
21068
19521
  * const users = await resource.getMany(['user-1', 'user-2', 'user-3']);
21069
19522
  */
21070
19523
  async getMany(ids) {
19524
+ await this.executeHooks("beforeGetMany", { ids });
21071
19525
  const { results, errors } = await promisePool.PromisePool.for(ids).withConcurrency(this.client.parallelism).handleError(async (error, id) => {
21072
19526
  this.emit("error", error, content);
21073
19527
  this.observers.map((x) => x.emit("error", this.name, error, content));
@@ -21088,8 +19542,9 @@ ${errorDetails}`,
21088
19542
  }
21089
19543
  throw err;
21090
19544
  });
19545
+ const finalResults = await this.executeHooks("afterGetMany", results);
21091
19546
  this.emit("getMany", ids.length);
21092
- return results;
19547
+ return finalResults;
21093
19548
  }
21094
19549
  /**
21095
19550
  * Get all resources (equivalent to list() without pagination)
@@ -21336,21 +19791,6 @@ ${errorDetails}`,
21336
19791
  * @returns {Object} Schema object for the version
21337
19792
  */
21338
19793
  async getSchemaForVersion(version) {
21339
- if (version === this.version) {
21340
- return this.schema;
21341
- }
21342
- const [ok, err, compatibleSchema] = await tryFn(() => Promise.resolve(new Schema({
21343
- name: this.name,
21344
- attributes: this.attributes,
21345
- passphrase: this.passphrase,
21346
- version,
21347
- options: {
21348
- ...this.config,
21349
- autoDecrypt: true,
21350
- autoEncrypt: true
21351
- }
21352
- })));
21353
- if (ok) return compatibleSchema;
21354
19794
  return this.schema;
21355
19795
  }
21356
19796
  /**
@@ -21446,6 +19886,7 @@ ${errorDetails}`,
21446
19886
  * );
21447
19887
  */
21448
19888
  async query(filter = {}, { limit = 100, offset = 0, partition = null, partitionValues = {} } = {}) {
19889
+ await this.executeHooks("beforeQuery", { filter, limit, offset, partition, partitionValues });
21449
19890
  if (Object.keys(filter).length === 0) {
21450
19891
  return await this.list({ partition, partitionValues, limit, offset });
21451
19892
  }
@@ -21473,7 +19914,8 @@ ${errorDetails}`,
21473
19914
  break;
21474
19915
  }
21475
19916
  }
21476
- return results.slice(0, limit);
19917
+ const finalResults = results.slice(0, limit);
19918
+ return await this.executeHooks("afterQuery", finalResults);
21477
19919
  }
21478
19920
  /**
21479
19921
  * Handle partition reference updates with change detection
@@ -21553,7 +19995,7 @@ ${errorDetails}`,
21553
19995
  }
21554
19996
  }
21555
19997
  /**
21556
- * Update partition objects to keep them in sync (legacy method for backward compatibility)
19998
+ * Update partition objects to keep them in sync
21557
19999
  * @param {Object} data - Updated object data
21558
20000
  */
21559
20001
  async updatePartitionReferences(data) {
@@ -21939,7 +20381,32 @@ function validateResourceConfig(config) {
21939
20381
  if (typeof config.hooks !== "object" || Array.isArray(config.hooks)) {
21940
20382
  errors.push("Resource 'hooks' must be an object");
21941
20383
  } else {
21942
- const validHookEvents = ["beforeInsert", "afterInsert", "beforeUpdate", "afterUpdate", "beforeDelete", "afterDelete"];
20384
+ const validHookEvents = [
20385
+ "beforeInsert",
20386
+ "afterInsert",
20387
+ "beforeUpdate",
20388
+ "afterUpdate",
20389
+ "beforeDelete",
20390
+ "afterDelete",
20391
+ "beforeGet",
20392
+ "afterGet",
20393
+ "beforeList",
20394
+ "afterList",
20395
+ "beforeQuery",
20396
+ "afterQuery",
20397
+ "beforeExists",
20398
+ "afterExists",
20399
+ "beforeCount",
20400
+ "afterCount",
20401
+ "beforePatch",
20402
+ "afterPatch",
20403
+ "beforeReplace",
20404
+ "afterReplace",
20405
+ "beforeGetMany",
20406
+ "afterGetMany",
20407
+ "beforeDeleteMany",
20408
+ "afterDeleteMany"
20409
+ ];
21943
20410
  for (const [event, hooksArr] of Object.entries(config.hooks)) {
21944
20411
  if (!validHookEvents.includes(event)) {
21945
20412
  errors.push(`Invalid hook event '${event}'. Valid events: ${validHookEvents.join(", ")}`);
@@ -21987,17 +20454,35 @@ class Database extends EventEmitter {
21987
20454
  this.id = idGenerator(7);
21988
20455
  this.version = "1";
21989
20456
  this.s3dbVersion = (() => {
21990
- const [ok, err, version] = tryFn(() => true ? "12.1.0" : "latest");
20457
+ const [ok, err, version] = tryFn(() => true ? "12.2.1" : "latest");
21991
20458
  return ok ? version : "latest";
21992
20459
  })();
21993
- this.resources = {};
20460
+ this._resourcesMap = {};
20461
+ this.resources = new Proxy(this._resourcesMap, {
20462
+ get: (target, prop) => {
20463
+ if (typeof prop === "symbol" || prop === "constructor" || prop === "toJSON") {
20464
+ return target[prop];
20465
+ }
20466
+ if (target[prop]) {
20467
+ return target[prop];
20468
+ }
20469
+ return void 0;
20470
+ },
20471
+ // Support Object.keys(), Object.entries(), etc.
20472
+ ownKeys: (target) => {
20473
+ return Object.keys(target);
20474
+ },
20475
+ getOwnPropertyDescriptor: (target, prop) => {
20476
+ return Object.getOwnPropertyDescriptor(target, prop);
20477
+ }
20478
+ });
21994
20479
  this.savedMetadata = null;
21995
20480
  this.options = options;
21996
20481
  this.verbose = options.verbose || false;
21997
20482
  this.parallelism = parseInt(options.parallelism + "") || 10;
21998
- this.plugins = options.plugins || [];
21999
- this.pluginRegistry = {};
22000
20483
  this.pluginList = options.plugins || [];
20484
+ this.pluginRegistry = {};
20485
+ this.plugins = this.pluginRegistry;
22001
20486
  this.cache = options.cache;
22002
20487
  this.passphrase = options.passphrase || "secret";
22003
20488
  this.versioningEnabled = options.versioningEnabled || false;
@@ -22103,7 +20588,7 @@ class Database extends EventEmitter {
22103
20588
  } else {
22104
20589
  restoredIdSize = versionData.idSize || 22;
22105
20590
  }
22106
- this.resources[name] = new Resource({
20591
+ this._resourcesMap[name] = new Resource({
22107
20592
  name,
22108
20593
  client: this.client,
22109
20594
  database: this,
@@ -22172,7 +20657,7 @@ class Database extends EventEmitter {
22172
20657
  }
22173
20658
  }
22174
20659
  for (const [name, savedResource] of Object.entries(savedMetadata.resources || {})) {
22175
- if (!this.resources[name]) {
20660
+ if (!this._resourcesMap[name]) {
22176
20661
  const currentVersion = savedResource.currentVersion || "v1";
22177
20662
  const versionData = savedResource.versions?.[currentVersion];
22178
20663
  changes.push({
@@ -22684,7 +21169,7 @@ class Database extends EventEmitter {
22684
21169
  * @returns {boolean} True if resource exists, false otherwise
22685
21170
  */
22686
21171
  resourceExists(name) {
22687
- return !!this.resources[name];
21172
+ return !!this._resourcesMap[name];
22688
21173
  }
22689
21174
  /**
22690
21175
  * Check if a resource exists with the same definition hash
@@ -22692,14 +21177,13 @@ class Database extends EventEmitter {
22692
21177
  * @param {string} config.name - Resource name
22693
21178
  * @param {Object} config.attributes - Resource attributes
22694
21179
  * @param {string} [config.behavior] - Resource behavior
22695
- * @param {Object} [config.options] - Resource options (deprecated, use root level parameters)
22696
21180
  * @returns {Object} Result with exists and hash information
22697
21181
  */
22698
- resourceExistsWithSameHash({ name, attributes, behavior = "user-managed", partitions = {}, options = {} }) {
22699
- if (!this.resources[name]) {
21182
+ resourceExistsWithSameHash({ name, attributes, behavior = "user-managed", partitions = {} }) {
21183
+ if (!this._resourcesMap[name]) {
22700
21184
  return { exists: false, sameHash: false, hash: null };
22701
21185
  }
22702
- const existingResource = this.resources[name];
21186
+ const existingResource = this._resourcesMap[name];
22703
21187
  const existingHash = this.generateDefinitionHash(existingResource.export());
22704
21188
  const mockResource = new Resource({
22705
21189
  name,
@@ -22709,8 +21193,7 @@ class Database extends EventEmitter {
22709
21193
  client: this.client,
22710
21194
  version: existingResource.version,
22711
21195
  passphrase: this.passphrase,
22712
- versioningEnabled: this.versioningEnabled,
22713
- ...options
21196
+ versioningEnabled: this.versioningEnabled
22714
21197
  });
22715
21198
  const newHash = this.generateDefinitionHash(mockResource.export());
22716
21199
  return {
@@ -22738,12 +21221,49 @@ class Database extends EventEmitter {
22738
21221
  * @param {string} [config.createdBy='user'] - Who created this resource ('user', 'plugin', or plugin name)
22739
21222
  * @returns {Promise<Resource>} The created or updated resource
22740
21223
  */
22741
- async createResource({ name, attributes, behavior = "user-managed", hooks, ...config }) {
22742
- if (this.resources[name]) {
22743
- const existingResource = this.resources[name];
21224
+ /**
21225
+ * Normalize partitions config from array or object format
21226
+ * @param {Array|Object} partitions - Partitions config
21227
+ * @param {Object} attributes - Resource attributes
21228
+ * @returns {Object} Normalized partitions object
21229
+ * @private
21230
+ */
21231
+ _normalizePartitions(partitions, attributes) {
21232
+ if (!Array.isArray(partitions)) {
21233
+ return partitions || {};
21234
+ }
21235
+ const normalized = {};
21236
+ for (const fieldName of partitions) {
21237
+ if (typeof fieldName !== "string") {
21238
+ throw new Error(`Partition field must be a string, got ${typeof fieldName}`);
21239
+ }
21240
+ if (!attributes[fieldName]) {
21241
+ throw new Error(`Partition field '${fieldName}' not found in attributes`);
21242
+ }
21243
+ const partitionName = `by${fieldName.charAt(0).toUpperCase()}${fieldName.slice(1)}`;
21244
+ const fieldDef = attributes[fieldName];
21245
+ let fieldType = "string";
21246
+ if (typeof fieldDef === "string") {
21247
+ fieldType = fieldDef.split("|")[0].trim();
21248
+ } else if (typeof fieldDef === "object" && fieldDef.type) {
21249
+ fieldType = fieldDef.type;
21250
+ }
21251
+ normalized[partitionName] = {
21252
+ fields: {
21253
+ [fieldName]: fieldType
21254
+ }
21255
+ };
21256
+ }
21257
+ return normalized;
21258
+ }
21259
+ async createResource({ name, attributes, behavior = "user-managed", hooks, middlewares, ...config }) {
21260
+ const normalizedPartitions = this._normalizePartitions(config.partitions, attributes);
21261
+ if (this._resourcesMap[name]) {
21262
+ const existingResource = this._resourcesMap[name];
22744
21263
  Object.assign(existingResource.config, {
22745
21264
  cache: this.cache,
22746
- ...config
21265
+ ...config,
21266
+ partitions: normalizedPartitions
22747
21267
  });
22748
21268
  if (behavior) {
22749
21269
  existingResource.behavior = behavior;
@@ -22761,6 +21281,9 @@ class Database extends EventEmitter {
22761
21281
  }
22762
21282
  }
22763
21283
  }
21284
+ if (middlewares) {
21285
+ this._applyMiddlewares(existingResource, middlewares);
21286
+ }
22764
21287
  const newHash = this.generateDefinitionHash(existingResource.export(), existingResource.behavior);
22765
21288
  const existingMetadata2 = this.savedMetadata?.resources?.[name];
22766
21289
  const currentVersion = existingMetadata2?.currentVersion || "v1";
@@ -22784,7 +21307,7 @@ class Database extends EventEmitter {
22784
21307
  observers: [this],
22785
21308
  cache: config.cache !== void 0 ? config.cache : this.cache,
22786
21309
  timestamps: config.timestamps !== void 0 ? config.timestamps : false,
22787
- partitions: config.partitions || {},
21310
+ partitions: normalizedPartitions,
22788
21311
  paranoid: config.paranoid !== void 0 ? config.paranoid : true,
22789
21312
  allNestedObjectsOptional: config.allNestedObjectsOptional !== void 0 ? config.allNestedObjectsOptional : true,
22790
21313
  autoDecrypt: config.autoDecrypt !== void 0 ? config.autoDecrypt : true,
@@ -22800,16 +21323,96 @@ class Database extends EventEmitter {
22800
21323
  createdBy: config.createdBy || "user"
22801
21324
  });
22802
21325
  resource.database = this;
22803
- this.resources[name] = resource;
21326
+ this._resourcesMap[name] = resource;
21327
+ if (middlewares) {
21328
+ this._applyMiddlewares(resource, middlewares);
21329
+ }
22804
21330
  await this.uploadMetadataFile();
22805
21331
  this.emit("s3db.resourceCreated", name);
22806
21332
  return resource;
22807
21333
  }
22808
- resource(name) {
22809
- if (!this.resources[name]) {
22810
- return Promise.reject(`resource ${name} does not exist`);
21334
+ /**
21335
+ * Apply middlewares to a resource
21336
+ * @param {Resource} resource - Resource instance
21337
+ * @param {Array|Object} middlewares - Middlewares config
21338
+ * @private
21339
+ */
21340
+ _applyMiddlewares(resource, middlewares) {
21341
+ if (Array.isArray(middlewares)) {
21342
+ const methods = resource._middlewareMethods || [
21343
+ "get",
21344
+ "list",
21345
+ "listIds",
21346
+ "getAll",
21347
+ "count",
21348
+ "page",
21349
+ "insert",
21350
+ "update",
21351
+ "delete",
21352
+ "deleteMany",
21353
+ "exists",
21354
+ "getMany",
21355
+ "content",
21356
+ "hasContent",
21357
+ "query",
21358
+ "getFromPartition",
21359
+ "setContent",
21360
+ "deleteContent",
21361
+ "replace",
21362
+ "patch"
21363
+ ];
21364
+ for (const method of methods) {
21365
+ for (const middleware of middlewares) {
21366
+ if (typeof middleware === "function") {
21367
+ resource.useMiddleware(method, middleware);
21368
+ }
21369
+ }
21370
+ }
21371
+ return;
21372
+ }
21373
+ if (typeof middlewares === "object" && middlewares !== null) {
21374
+ for (const [method, fns] of Object.entries(middlewares)) {
21375
+ if (method === "*") {
21376
+ const methods = resource._middlewareMethods || [
21377
+ "get",
21378
+ "list",
21379
+ "listIds",
21380
+ "getAll",
21381
+ "count",
21382
+ "page",
21383
+ "insert",
21384
+ "update",
21385
+ "delete",
21386
+ "deleteMany",
21387
+ "exists",
21388
+ "getMany",
21389
+ "content",
21390
+ "hasContent",
21391
+ "query",
21392
+ "getFromPartition",
21393
+ "setContent",
21394
+ "deleteContent",
21395
+ "replace",
21396
+ "patch"
21397
+ ];
21398
+ const middlewareArray = Array.isArray(fns) ? fns : [fns];
21399
+ for (const targetMethod of methods) {
21400
+ for (const middleware of middlewareArray) {
21401
+ if (typeof middleware === "function") {
21402
+ resource.useMiddleware(targetMethod, middleware);
21403
+ }
21404
+ }
21405
+ }
21406
+ } else {
21407
+ const middlewareArray = Array.isArray(fns) ? fns : [fns];
21408
+ for (const middleware of middlewareArray) {
21409
+ if (typeof middleware === "function") {
21410
+ resource.useMiddleware(method, middleware);
21411
+ }
21412
+ }
21413
+ }
21414
+ }
22811
21415
  }
22812
- return this.resources[name];
22813
21416
  }
22814
21417
  /**
22815
21418
  * List all resource names
@@ -22824,14 +21427,14 @@ class Database extends EventEmitter {
22824
21427
  * @returns {Resource} Resource instance
22825
21428
  */
22826
21429
  async getResource(name) {
22827
- if (!this.resources[name]) {
21430
+ if (!this._resourcesMap[name]) {
22828
21431
  throw new ResourceNotFound({
22829
21432
  bucket: this.client.config.bucket,
22830
21433
  resourceName: name,
22831
21434
  id: name
22832
21435
  });
22833
21436
  }
22834
- return this.resources[name];
21437
+ return this._resourcesMap[name];
22835
21438
  }
22836
21439
  /**
22837
21440
  * Get database configuration
@@ -22884,7 +21487,7 @@ class Database extends EventEmitter {
22884
21487
  }
22885
21488
  });
22886
21489
  }
22887
- Object.keys(this.resources).forEach((k) => delete this.resources[k]);
21490
+ Object.keys(this.resources).forEach((k) => delete this._resourcesMap[k]);
22888
21491
  }
22889
21492
  if (this.client && typeof this.client.removeAllListeners === "function") {
22890
21493
  this.client.removeAllListeners();
@@ -24628,14 +23231,6 @@ class ReplicatorPlugin extends Plugin {
24628
23231
  }
24629
23232
  async start() {
24630
23233
  }
24631
- async stop() {
24632
- for (const replicator of this.replicators || []) {
24633
- if (replicator && typeof replicator.cleanup === "function") {
24634
- await replicator.cleanup();
24635
- }
24636
- }
24637
- this.removeDatabaseHooks();
24638
- }
24639
23234
  installDatabaseHooks() {
24640
23235
  this._afterCreateResourceHook = (resource) => {
24641
23236
  if (resource.name !== (this.config.replicatorLogResource || "plg_replicator_logs")) {
@@ -24965,20 +23560,20 @@ class ReplicatorPlugin extends Plugin {
24965
23560
  }
24966
23561
  this.emit("replicator.sync.completed", { replicatorId, stats: this.stats });
24967
23562
  }
24968
- async cleanup() {
23563
+ async stop() {
24969
23564
  const [ok, error] = await tryFn(async () => {
24970
23565
  if (this.replicators && this.replicators.length > 0) {
24971
23566
  const cleanupPromises = this.replicators.map(async (replicator) => {
24972
23567
  const [replicatorOk, replicatorError] = await tryFn(async () => {
24973
- if (replicator && typeof replicator.cleanup === "function") {
24974
- await replicator.cleanup();
23568
+ if (replicator && typeof replicator.stop === "function") {
23569
+ await replicator.stop();
24975
23570
  }
24976
23571
  });
24977
23572
  if (!replicatorOk) {
24978
23573
  if (this.config.verbose) {
24979
- console.warn(`[ReplicatorPlugin] Failed to cleanup replicator ${replicator.name || replicator.id}: ${replicatorError.message}`);
23574
+ console.warn(`[ReplicatorPlugin] Failed to stop replicator ${replicator.name || replicator.id}: ${replicatorError.message}`);
24980
23575
  }
24981
- this.emit("replicator_cleanup_error", {
23576
+ this.emit("replicator_stop_error", {
24982
23577
  replicator: replicator.name || replicator.id || "unknown",
24983
23578
  driver: replicator.driver || "unknown",
24984
23579
  error: replicatorError.message
@@ -24987,6 +23582,7 @@ class ReplicatorPlugin extends Plugin {
24987
23582
  });
24988
23583
  await Promise.allSettled(cleanupPromises);
24989
23584
  }
23585
+ this.removeDatabaseHooks();
24990
23586
  if (this.database && this.database.resources) {
24991
23587
  for (const resourceName of this.eventListenersInstalled) {
24992
23588
  const resource = this.database.resources[resourceName];
@@ -25006,9 +23602,9 @@ class ReplicatorPlugin extends Plugin {
25006
23602
  });
25007
23603
  if (!ok) {
25008
23604
  if (this.config.verbose) {
25009
- console.warn(`[ReplicatorPlugin] Failed to cleanup plugin: ${error.message}`);
23605
+ console.warn(`[ReplicatorPlugin] Failed to stop plugin: ${error.message}`);
25010
23606
  }
25011
- this.emit("replicator_plugin_cleanup_error", {
23607
+ this.emit("replicator_plugin_stop_error", {
25012
23608
  error: error.message
25013
23609
  });
25014
23610
  }
@@ -25828,7 +24424,7 @@ class SchedulerPlugin extends Plugin {
25828
24424
  }
25829
24425
  async _persistJobExecution(jobName, executionId, startTime, endTime, duration, status, result, error, retryCount) {
25830
24426
  const [ok, err] = await tryFn(
25831
- () => this.database.resource(this.config.jobHistoryResource).insert({
24427
+ () => this.database.resources[this.config.jobHistoryResource].insert({
25832
24428
  id: executionId,
25833
24429
  jobName,
25834
24430
  status,
@@ -25969,7 +24565,7 @@ class SchedulerPlugin extends Plugin {
25969
24565
  queryParams.status = status;
25970
24566
  }
25971
24567
  const [ok, err, history] = await tryFn(
25972
- () => this.database.resource(this.config.jobHistoryResource).query(queryParams)
24568
+ () => this.database.resources[this.config.jobHistoryResource].query(queryParams)
25973
24569
  );
25974
24570
  if (!ok) {
25975
24571
  if (this.config.verbose) {
@@ -26108,9 +24704,6 @@ class SchedulerPlugin extends Plugin {
26108
24704
  if (this._isTestEnvironment()) {
26109
24705
  this.activeJobs.clear();
26110
24706
  }
26111
- }
26112
- async cleanup() {
26113
- await this.stop();
26114
24707
  this.jobs.clear();
26115
24708
  this.statistics.clear();
26116
24709
  this.activeJobs.clear();
@@ -26359,7 +24952,7 @@ class StateMachinePlugin extends Plugin {
26359
24952
  let lastLogErr;
26360
24953
  for (let attempt = 0; attempt < this.config.retryAttempts; attempt++) {
26361
24954
  const [ok, err] = await tryFn(
26362
- () => this.database.resource(this.config.transitionLogResource).insert({
24955
+ () => this.database.resources[this.config.transitionLogResource].insert({
26363
24956
  id: transitionId,
26364
24957
  machineId,
26365
24958
  entityId,
@@ -26395,11 +24988,11 @@ class StateMachinePlugin extends Plugin {
26395
24988
  updatedAt: now
26396
24989
  };
26397
24990
  const [updateOk] = await tryFn(
26398
- () => this.database.resource(this.config.stateResource).update(stateId, stateData)
24991
+ () => this.database.resources[this.config.stateResource].update(stateId, stateData)
26399
24992
  );
26400
24993
  if (!updateOk) {
26401
24994
  const [insertOk, insertErr] = await tryFn(
26402
- () => this.database.resource(this.config.stateResource).insert({ id: stateId, ...stateData })
24995
+ () => this.database.resources[this.config.stateResource].insert({ id: stateId, ...stateData })
26403
24996
  );
26404
24997
  if (!insertOk && this.config.verbose) {
26405
24998
  console.warn(`[StateMachinePlugin] Failed to upsert state:`, insertErr.message);
@@ -26462,7 +25055,7 @@ class StateMachinePlugin extends Plugin {
26462
25055
  if (this.config.persistTransitions) {
26463
25056
  const stateId = `${machineId}_${entityId}`;
26464
25057
  const [ok, err, stateRecord] = await tryFn(
26465
- () => this.database.resource(this.config.stateResource).get(stateId)
25058
+ () => this.database.resources[this.config.stateResource].get(stateId)
26466
25059
  );
26467
25060
  if (ok && stateRecord) {
26468
25061
  machine.currentStates.set(entityId, stateRecord.currentState);
@@ -26505,7 +25098,7 @@ class StateMachinePlugin extends Plugin {
26505
25098
  }
26506
25099
  const { limit = 50, offset = 0 } = options;
26507
25100
  const [ok, err, transitions] = await tryFn(
26508
- () => this.database.resource(this.config.transitionLogResource).query({
25101
+ () => this.database.resources[this.config.transitionLogResource].query({
26509
25102
  machineId,
26510
25103
  entityId
26511
25104
  }, {
@@ -26547,7 +25140,7 @@ class StateMachinePlugin extends Plugin {
26547
25140
  const now = (/* @__PURE__ */ new Date()).toISOString();
26548
25141
  const stateId = `${machineId}_${entityId}`;
26549
25142
  const [ok, err] = await tryFn(
26550
- () => this.database.resource(this.config.stateResource).insert({
25143
+ () => this.database.resources[this.config.stateResource].insert({
26551
25144
  id: stateId,
26552
25145
  machineId,
26553
25146
  entityId,
@@ -26636,9 +25229,6 @@ class StateMachinePlugin extends Plugin {
26636
25229
  }
26637
25230
  async stop() {
26638
25231
  this.machines.clear();
26639
- }
26640
- async cleanup() {
26641
- await this.stop();
26642
25232
  this.removeAllListeners();
26643
25233
  }
26644
25234
  }
@@ -26653,7 +25243,7 @@ class TfStateError extends Error {
26653
25243
  }
26654
25244
  class InvalidStateFileError extends TfStateError {
26655
25245
  constructor(filePath, reason, context = {}) {
26656
- super(`Invalid Terraform state file "${filePath}": ${reason}`, context);
25246
+ super(`Invalid Tfstate file "${filePath}": ${reason}`, context);
26657
25247
  this.name = "InvalidStateFileError";
26658
25248
  this.filePath = filePath;
26659
25249
  this.reason = reason;
@@ -26662,7 +25252,7 @@ class InvalidStateFileError extends TfStateError {
26662
25252
  class UnsupportedStateVersionError extends TfStateError {
26663
25253
  constructor(version, supportedVersions, context = {}) {
26664
25254
  super(
26665
- `Terraform state version ${version} is not supported. Supported versions: ${supportedVersions.join(", ")}`,
25255
+ `Tfstate version ${version} is not supported. Supported versions: ${supportedVersions.join(", ")}`,
26666
25256
  context
26667
25257
  );
26668
25258
  this.name = "UnsupportedStateVersionError";
@@ -26672,7 +25262,7 @@ class UnsupportedStateVersionError extends TfStateError {
26672
25262
  }
26673
25263
  class StateFileNotFoundError extends TfStateError {
26674
25264
  constructor(filePath, context = {}) {
26675
- super(`Terraform state file not found: ${filePath}`, context);
25265
+ super(`Tfstate file not found: ${filePath}`, context);
26676
25266
  this.name = "StateFileNotFoundError";
26677
25267
  this.filePath = filePath;
26678
25268
  }
@@ -34895,42 +33485,23 @@ class FilesystemTfStateDriver extends TfStateDriver {
34895
33485
  class TfStatePlugin extends Plugin {
34896
33486
  constructor(config = {}) {
34897
33487
  super(config);
34898
- const isNewFormat = config.driver !== void 0;
34899
- if (isNewFormat) {
34900
- this.driverType = config.driver || "s3";
34901
- this.driverConfig = config.config || {};
34902
- const resources = config.resources || {};
34903
- this.resourceName = resources.resources || "plg_tfstate_resources";
34904
- this.stateFilesName = resources.stateFiles || "plg_tfstate_state_files";
34905
- this.diffsName = resources.diffs || "plg_tfstate_state_diffs";
34906
- const monitor = config.monitor || {};
34907
- this.monitorEnabled = monitor.enabled || false;
34908
- this.monitorCron = monitor.cron || "*/5 * * * *";
34909
- const diffs = config.diffs || {};
34910
- this.trackDiffs = diffs.enabled !== void 0 ? diffs.enabled : true;
34911
- this.diffsLookback = diffs.lookback || 10;
34912
- this.asyncPartitions = config.asyncPartitions !== void 0 ? config.asyncPartitions : true;
34913
- this.autoSync = false;
34914
- this.watchPaths = [];
34915
- this.filters = config.filters || {};
34916
- this.verbose = config.verbose || false;
34917
- } else {
34918
- this.driverType = null;
34919
- this.driverConfig = {};
34920
- this.resourceName = config.resourceName || "plg_tfstate_resources";
34921
- this.stateFilesName = config.stateFilesName || "plg_tfstate_state_files";
34922
- this.diffsName = config.diffsName || config.stateHistoryName || "plg_tfstate_state_diffs";
34923
- this.stateHistoryName = this.diffsName;
34924
- this.autoSync = config.autoSync || false;
34925
- this.watchPaths = Array.isArray(config.watchPaths) ? config.watchPaths : [];
34926
- this.filters = config.filters || {};
34927
- this.trackDiffs = config.trackDiffs !== void 0 ? config.trackDiffs : true;
34928
- this.diffsLookback = 10;
34929
- this.asyncPartitions = config.asyncPartitions !== void 0 ? config.asyncPartitions : true;
34930
- this.verbose = config.verbose || false;
34931
- this.monitorEnabled = false;
34932
- this.monitorCron = "*/5 * * * *";
34933
- }
33488
+ this.driverType = config.driver || null;
33489
+ this.driverConfig = config.config || {};
33490
+ const resources = config.resources || {};
33491
+ this.resourceName = resources.resources || config.resourceName || "plg_tfstate_resources";
33492
+ this.stateFilesName = resources.stateFiles || config.stateFilesName || "plg_tfstate_state_files";
33493
+ this.diffsName = resources.diffs || config.diffsName || "plg_tfstate_state_diffs";
33494
+ const monitor = config.monitor || {};
33495
+ this.monitorEnabled = monitor.enabled || false;
33496
+ this.monitorCron = monitor.cron || "*/5 * * * *";
33497
+ const diffs = config.diffs || {};
33498
+ this.trackDiffs = diffs.enabled !== void 0 ? diffs.enabled : config.trackDiffs !== void 0 ? config.trackDiffs : true;
33499
+ this.diffsLookback = diffs.lookback || 10;
33500
+ this.asyncPartitions = config.asyncPartitions !== void 0 ? config.asyncPartitions : true;
33501
+ this.autoSync = config.autoSync || false;
33502
+ this.watchPaths = config.watchPaths || [];
33503
+ this.filters = config.filters || {};
33504
+ this.verbose = config.verbose || false;
34934
33505
  this.supportedVersions = [3, 4];
34935
33506
  this.driver = null;
34936
33507
  this.resource = null;
@@ -34980,7 +33551,7 @@ class TfStatePlugin extends Plugin {
34980
33551
  name: this.lineagesName,
34981
33552
  attributes: {
34982
33553
  id: "string|required",
34983
- // = lineage UUID from Terraform state
33554
+ // = lineage UUID from Tfstate
34984
33555
  latestSerial: "number",
34985
33556
  // Track latest for quick access
34986
33557
  latestStateId: "string",
@@ -35693,7 +34264,7 @@ class TfStatePlugin extends Plugin {
35693
34264
  return result;
35694
34265
  }
35695
34266
  /**
35696
- * Read and parse Terraform state file
34267
+ * Read and parse Tfstate file
35697
34268
  * @private
35698
34269
  */
35699
34270
  async _readStateFile(filePath) {
@@ -35730,7 +34301,7 @@ class TfStatePlugin extends Plugin {
35730
34301
  }
35731
34302
  }
35732
34303
  /**
35733
- * Validate Terraform state version
34304
+ * Validate Tfstate version
35734
34305
  * @private
35735
34306
  */
35736
34307
  _validateStateVersion(state) {
@@ -35743,7 +34314,7 @@ class TfStatePlugin extends Plugin {
35743
34314
  }
35744
34315
  }
35745
34316
  /**
35746
- * Extract resources from Terraform state
34317
+ * Extract resources from Tfstate
35747
34318
  * @private
35748
34319
  */
35749
34320
  async _extractResources(state, filePath, stateFileId, lineageId) {
@@ -36310,14 +34881,14 @@ class TfStatePlugin extends Plugin {
36310
34881
  }
36311
34882
  }
36312
34883
  /**
36313
- * Export resources to Terraform state format
34884
+ * Export resources to Tfstate format
36314
34885
  * @param {Object} options - Export options
36315
34886
  * @param {number} options.serial - Specific serial to export (default: latest)
36316
34887
  * @param {string[]} options.resourceTypes - Filter by resource types
36317
34888
  * @param {string} options.terraformVersion - Terraform version for output (default: '1.5.0')
36318
34889
  * @param {string} options.lineage - State lineage (default: auto-generated)
36319
34890
  * @param {Object} options.outputs - Terraform outputs to include
36320
- * @returns {Promise<Object>} Terraform state object
34891
+ * @returns {Promise<Object>} Tfstate object
36321
34892
  *
36322
34893
  * @example
36323
34894
  * // Export latest state
@@ -37667,6 +36238,533 @@ class VectorPlugin extends Plugin {
37667
36238
  }
37668
36239
  }
37669
36240
 
36241
+ function mapFieldTypeToTypeScript(fieldType) {
36242
+ const baseType = fieldType.split("|")[0].trim();
36243
+ const typeMap = {
36244
+ "string": "string",
36245
+ "number": "number",
36246
+ "integer": "number",
36247
+ "boolean": "boolean",
36248
+ "array": "any[]",
36249
+ "object": "Record<string, any>",
36250
+ "json": "Record<string, any>",
36251
+ "secret": "string",
36252
+ "email": "string",
36253
+ "url": "string",
36254
+ "date": "string",
36255
+ // ISO date string
36256
+ "datetime": "string",
36257
+ // ISO datetime string
36258
+ "ip4": "string",
36259
+ "ip6": "string"
36260
+ };
36261
+ if (baseType.startsWith("embedding:")) {
36262
+ const dimensions = parseInt(baseType.split(":")[1]);
36263
+ return `number[] /* ${dimensions} dimensions */`;
36264
+ }
36265
+ return typeMap[baseType] || "any";
36266
+ }
36267
+ function isFieldRequired(fieldDef) {
36268
+ if (typeof fieldDef === "string") {
36269
+ return fieldDef.includes("|required");
36270
+ }
36271
+ if (typeof fieldDef === "object" && fieldDef.required) {
36272
+ return true;
36273
+ }
36274
+ return false;
36275
+ }
36276
+ function generateResourceInterface(resourceName, attributes, timestamps = false) {
36277
+ const interfaceName = toPascalCase(resourceName);
36278
+ const lines = [];
36279
+ lines.push(`export interface ${interfaceName} {`);
36280
+ lines.push(` /** Resource ID (auto-generated) */`);
36281
+ lines.push(` id: string;`);
36282
+ lines.push("");
36283
+ for (const [fieldName, fieldDef] of Object.entries(attributes)) {
36284
+ const required = isFieldRequired(fieldDef);
36285
+ const optional = required ? "" : "?";
36286
+ let tsType;
36287
+ if (typeof fieldDef === "string") {
36288
+ tsType = mapFieldTypeToTypeScript(fieldDef);
36289
+ } else if (typeof fieldDef === "object" && fieldDef.type) {
36290
+ tsType = mapFieldTypeToTypeScript(fieldDef.type);
36291
+ if (fieldDef.type === "object" && fieldDef.props) {
36292
+ tsType = "{\n";
36293
+ for (const [propName, propDef] of Object.entries(fieldDef.props)) {
36294
+ const propType = typeof propDef === "string" ? mapFieldTypeToTypeScript(propDef) : mapFieldTypeToTypeScript(propDef.type);
36295
+ const propRequired = isFieldRequired(propDef);
36296
+ tsType += ` ${propName}${propRequired ? "" : "?"}: ${propType};
36297
+ `;
36298
+ }
36299
+ tsType += " }";
36300
+ }
36301
+ if (fieldDef.type === "array" && fieldDef.items) {
36302
+ const itemType = mapFieldTypeToTypeScript(fieldDef.items);
36303
+ tsType = `Array<${itemType}>`;
36304
+ }
36305
+ } else {
36306
+ tsType = "any";
36307
+ }
36308
+ if (fieldDef.description) {
36309
+ lines.push(` /** ${fieldDef.description} */`);
36310
+ }
36311
+ lines.push(` ${fieldName}${optional}: ${tsType};`);
36312
+ }
36313
+ if (timestamps) {
36314
+ lines.push("");
36315
+ lines.push(` /** Creation timestamp (ISO 8601) */`);
36316
+ lines.push(` createdAt: string;`);
36317
+ lines.push(` /** Last update timestamp (ISO 8601) */`);
36318
+ lines.push(` updatedAt: string;`);
36319
+ }
36320
+ lines.push("}");
36321
+ lines.push("");
36322
+ return lines.join("\n");
36323
+ }
36324
+ function toPascalCase(str) {
36325
+ return str.split(/[_-]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
36326
+ }
36327
+ async function generateTypes(database, options = {}) {
36328
+ const {
36329
+ outputPath = "./types/database.d.ts",
36330
+ moduleName = "s3db.js",
36331
+ includeResource = true
36332
+ } = options;
36333
+ const lines = [];
36334
+ lines.push("/**");
36335
+ lines.push(" * Auto-generated TypeScript definitions for s3db.js resources");
36336
+ lines.push(" * Generated at: " + (/* @__PURE__ */ new Date()).toISOString());
36337
+ lines.push(" * DO NOT EDIT - This file is auto-generated");
36338
+ lines.push(" */");
36339
+ lines.push("");
36340
+ if (includeResource) {
36341
+ lines.push(`import { Resource, Database } from '${moduleName}';`);
36342
+ lines.push("");
36343
+ }
36344
+ const resourceInterfaces = [];
36345
+ for (const [name, resource] of Object.entries(database.resources)) {
36346
+ const attributes = resource.config?.attributes || resource.attributes || {};
36347
+ const timestamps = resource.config?.timestamps || false;
36348
+ const interfaceDef = generateResourceInterface(name, attributes, timestamps);
36349
+ lines.push(interfaceDef);
36350
+ resourceInterfaces.push({
36351
+ name,
36352
+ interfaceName: toPascalCase(name),
36353
+ resource
36354
+ });
36355
+ }
36356
+ lines.push("/**");
36357
+ lines.push(" * Typed resource map for property access");
36358
+ lines.push(" * @example");
36359
+ lines.push(" * const users = db.resources.users; // Type-safe!");
36360
+ lines.push(' * const user = await users.get("id"); // Autocomplete works!');
36361
+ lines.push(" */");
36362
+ lines.push("export interface ResourceMap {");
36363
+ for (const { name, interfaceName } of resourceInterfaces) {
36364
+ lines.push(` /** ${interfaceName} resource */`);
36365
+ if (includeResource) {
36366
+ lines.push(` ${name}: Resource<${interfaceName}>;`);
36367
+ } else {
36368
+ lines.push(` ${name}: any;`);
36369
+ }
36370
+ }
36371
+ lines.push("}");
36372
+ lines.push("");
36373
+ if (includeResource) {
36374
+ lines.push("/**");
36375
+ lines.push(" * Extended Database class with typed resources");
36376
+ lines.push(" */");
36377
+ lines.push("declare module 's3db.js' {");
36378
+ lines.push(" interface Database {");
36379
+ lines.push(" resources: ResourceMap;");
36380
+ lines.push(" }");
36381
+ lines.push("");
36382
+ lines.push(" interface Resource<T = any> {");
36383
+ lines.push(" get(id: string): Promise<T>;");
36384
+ lines.push(" getOrNull(id: string): Promise<T | null>;");
36385
+ lines.push(" getOrThrow(id: string): Promise<T>;");
36386
+ lines.push(" insert(data: Partial<T>): Promise<T>;");
36387
+ lines.push(" update(id: string, data: Partial<T>): Promise<T>;");
36388
+ lines.push(" patch(id: string, data: Partial<T>): Promise<T>;");
36389
+ lines.push(" replace(id: string, data: Partial<T>): Promise<T>;");
36390
+ lines.push(" delete(id: string): Promise<void>;");
36391
+ lines.push(" list(options?: any): Promise<T[]>;");
36392
+ lines.push(" query(filters: Partial<T>, options?: any): Promise<T[]>;");
36393
+ lines.push(" validate(data: Partial<T>, options?: any): Promise<{ valid: boolean; errors: any[]; data: T | null }>;");
36394
+ lines.push(" }");
36395
+ lines.push("}");
36396
+ }
36397
+ const content = lines.join("\n");
36398
+ if (outputPath) {
36399
+ await promises.mkdir(path$1.dirname(outputPath), { recursive: true });
36400
+ await promises.writeFile(outputPath, content, "utf-8");
36401
+ }
36402
+ return content;
36403
+ }
36404
+ async function printTypes(database, options = {}) {
36405
+ const types = await generateTypes(database, { ...options, outputPath: null });
36406
+ console.log(types);
36407
+ return types;
36408
+ }
36409
+
36410
+ class Factory {
36411
+ /**
36412
+ * Global sequence counter
36413
+ * @private
36414
+ */
36415
+ static _sequences = /* @__PURE__ */ new Map();
36416
+ /**
36417
+ * Registered factories
36418
+ * @private
36419
+ */
36420
+ static _factories = /* @__PURE__ */ new Map();
36421
+ /**
36422
+ * Database instance (set globally)
36423
+ * @private
36424
+ */
36425
+ static _database = null;
36426
+ /**
36427
+ * Create a new factory definition
36428
+ * @param {string} resourceName - Resource name
36429
+ * @param {Object|Function} definition - Field definitions or function
36430
+ * @param {Object} options - Factory options
36431
+ * @returns {Factory} Factory instance
36432
+ */
36433
+ static define(resourceName, definition, options = {}) {
36434
+ const factory = new Factory(resourceName, definition, options);
36435
+ Factory._factories.set(resourceName, factory);
36436
+ return factory;
36437
+ }
36438
+ /**
36439
+ * Set global database instance
36440
+ * @param {Database} database - s3db.js Database instance
36441
+ */
36442
+ static setDatabase(database) {
36443
+ Factory._database = database;
36444
+ }
36445
+ /**
36446
+ * Get factory by resource name
36447
+ * @param {string} resourceName - Resource name
36448
+ * @returns {Factory} Factory instance
36449
+ */
36450
+ static get(resourceName) {
36451
+ return Factory._factories.get(resourceName);
36452
+ }
36453
+ /**
36454
+ * Reset all sequences
36455
+ */
36456
+ static resetSequences() {
36457
+ Factory._sequences.clear();
36458
+ }
36459
+ /**
36460
+ * Reset all factories
36461
+ */
36462
+ static reset() {
36463
+ Factory._sequences.clear();
36464
+ Factory._factories.clear();
36465
+ Factory._database = null;
36466
+ }
36467
+ /**
36468
+ * Constructor
36469
+ * @param {string} resourceName - Resource name
36470
+ * @param {Object|Function} definition - Field definitions
36471
+ * @param {Object} options - Factory options
36472
+ */
36473
+ constructor(resourceName, definition, options = {}) {
36474
+ this.resourceName = resourceName;
36475
+ this.definition = definition;
36476
+ this.options = options;
36477
+ this.traits = /* @__PURE__ */ new Map();
36478
+ this.afterCreateCallbacks = [];
36479
+ this.beforeCreateCallbacks = [];
36480
+ }
36481
+ /**
36482
+ * Get next sequence number
36483
+ * @param {string} name - Sequence name (default: factory name)
36484
+ * @returns {number} Next sequence number
36485
+ */
36486
+ sequence(name = this.resourceName) {
36487
+ const current = Factory._sequences.get(name) || 0;
36488
+ const next = current + 1;
36489
+ Factory._sequences.set(name, next);
36490
+ return next;
36491
+ }
36492
+ /**
36493
+ * Define a trait (state variation)
36494
+ * @param {string} name - Trait name
36495
+ * @param {Object|Function} attributes - Trait attributes
36496
+ * @returns {Factory} This factory (for chaining)
36497
+ */
36498
+ trait(name, attributes) {
36499
+ this.traits.set(name, attributes);
36500
+ return this;
36501
+ }
36502
+ /**
36503
+ * Register after create callback
36504
+ * @param {Function} callback - Callback function
36505
+ * @returns {Factory} This factory (for chaining)
36506
+ */
36507
+ afterCreate(callback) {
36508
+ this.afterCreateCallbacks.push(callback);
36509
+ return this;
36510
+ }
36511
+ /**
36512
+ * Register before create callback
36513
+ * @param {Function} callback - Callback function
36514
+ * @returns {Factory} This factory (for chaining)
36515
+ */
36516
+ beforeCreate(callback) {
36517
+ this.beforeCreateCallbacks.push(callback);
36518
+ return this;
36519
+ }
36520
+ /**
36521
+ * Build attributes without creating in database
36522
+ * @param {Object} overrides - Override attributes
36523
+ * @param {Object} options - Build options
36524
+ * @returns {Promise<Object>} Built attributes
36525
+ */
36526
+ async build(overrides = {}, options = {}) {
36527
+ const { traits = [] } = options;
36528
+ const seq = this.sequence();
36529
+ let attributes = typeof this.definition === "function" ? await this.definition({ seq, factory: this }) : { ...this.definition };
36530
+ for (const traitName of traits) {
36531
+ const trait = this.traits.get(traitName);
36532
+ if (!trait) {
36533
+ throw new Error(`Trait '${traitName}' not found in factory '${this.resourceName}'`);
36534
+ }
36535
+ const traitAttrs = typeof trait === "function" ? await trait({ seq, factory: this }) : trait;
36536
+ attributes = { ...attributes, ...traitAttrs };
36537
+ }
36538
+ attributes = { ...attributes, ...overrides };
36539
+ for (const [key, value] of Object.entries(attributes)) {
36540
+ if (typeof value === "function") {
36541
+ attributes[key] = await value({ seq, factory: this });
36542
+ }
36543
+ }
36544
+ return attributes;
36545
+ }
36546
+ /**
36547
+ * Create resource in database
36548
+ * @param {Object} overrides - Override attributes
36549
+ * @param {Object} options - Create options
36550
+ * @returns {Promise<Object>} Created resource
36551
+ */
36552
+ async create(overrides = {}, options = {}) {
36553
+ const { database = Factory._database } = options;
36554
+ if (!database) {
36555
+ throw new Error("Database not set. Use Factory.setDatabase(db) or pass database option");
36556
+ }
36557
+ let attributes = await this.build(overrides, options);
36558
+ for (const callback of this.beforeCreateCallbacks) {
36559
+ attributes = await callback(attributes) || attributes;
36560
+ }
36561
+ const resource = database.resources[this.resourceName];
36562
+ if (!resource) {
36563
+ throw new Error(`Resource '${this.resourceName}' not found in database`);
36564
+ }
36565
+ let created = await resource.insert(attributes);
36566
+ for (const callback of this.afterCreateCallbacks) {
36567
+ created = await callback(created, { database }) || created;
36568
+ }
36569
+ return created;
36570
+ }
36571
+ /**
36572
+ * Create multiple resources
36573
+ * @param {number} count - Number of resources to create
36574
+ * @param {Object} overrides - Override attributes
36575
+ * @param {Object} options - Create options
36576
+ * @returns {Promise<Object[]>} Created resources
36577
+ */
36578
+ async createMany(count, overrides = {}, options = {}) {
36579
+ const resources = [];
36580
+ for (let i = 0; i < count; i++) {
36581
+ const resource = await this.create(overrides, options);
36582
+ resources.push(resource);
36583
+ }
36584
+ return resources;
36585
+ }
36586
+ /**
36587
+ * Build multiple resources without creating
36588
+ * @param {number} count - Number of resources to build
36589
+ * @param {Object} overrides - Override attributes
36590
+ * @param {Object} options - Build options
36591
+ * @returns {Promise<Object[]>} Built resources
36592
+ */
36593
+ async buildMany(count, overrides = {}, options = {}) {
36594
+ const resources = [];
36595
+ for (let i = 0; i < count; i++) {
36596
+ const resource = await this.build(overrides, options);
36597
+ resources.push(resource);
36598
+ }
36599
+ return resources;
36600
+ }
36601
+ /**
36602
+ * Create with specific traits
36603
+ * @param {string|string[]} traits - Trait name(s)
36604
+ * @param {Object} overrides - Override attributes
36605
+ * @param {Object} options - Create options
36606
+ * @returns {Promise<Object>} Created resource
36607
+ */
36608
+ async createWithTraits(traits, overrides = {}, options = {}) {
36609
+ const traitArray = Array.isArray(traits) ? traits : [traits];
36610
+ return this.create(overrides, { ...options, traits: traitArray });
36611
+ }
36612
+ /**
36613
+ * Build with specific traits
36614
+ * @param {string|string[]} traits - Trait name(s)
36615
+ * @param {Object} overrides - Override attributes
36616
+ * @param {Object} options - Build options
36617
+ * @returns {Promise<Object>} Built resource
36618
+ */
36619
+ async buildWithTraits(traits, overrides = {}, options = {}) {
36620
+ const traitArray = Array.isArray(traits) ? traits : [traits];
36621
+ return this.build(overrides, { ...options, traits: traitArray });
36622
+ }
36623
+ }
36624
+
36625
+ class Seeder {
36626
+ /**
36627
+ * Constructor
36628
+ * @param {Database} database - s3db.js Database instance
36629
+ * @param {Object} options - Seeder options
36630
+ */
36631
+ constructor(database, options = {}) {
36632
+ this.database = database;
36633
+ this.options = options;
36634
+ this.verbose = options.verbose !== false;
36635
+ }
36636
+ /**
36637
+ * Log message (if verbose)
36638
+ * @param {string} message - Message to log
36639
+ * @private
36640
+ */
36641
+ log(message) {
36642
+ if (this.verbose) {
36643
+ console.log(`[Seeder] ${message}`);
36644
+ }
36645
+ }
36646
+ /**
36647
+ * Seed resources using factories
36648
+ * @param {Object} specs - Seed specifications { resourceName: count }
36649
+ * @returns {Promise<Object>} Created resources by resource name
36650
+ *
36651
+ * @example
36652
+ * const created = await seeder.seed({
36653
+ * users: 10,
36654
+ * posts: 50
36655
+ * });
36656
+ */
36657
+ async seed(specs) {
36658
+ const created = {};
36659
+ for (const [resourceName, count] of Object.entries(specs)) {
36660
+ this.log(`Seeding ${count} ${resourceName}...`);
36661
+ const factory = Factory.get(resourceName);
36662
+ if (!factory) {
36663
+ throw new Error(`Factory for '${resourceName}' not found. Define it with Factory.define()`);
36664
+ }
36665
+ created[resourceName] = await factory.createMany(count, {}, { database: this.database });
36666
+ this.log(`\u2705 Created ${count} ${resourceName}`);
36667
+ }
36668
+ return created;
36669
+ }
36670
+ /**
36671
+ * Seed with custom callback
36672
+ * @param {Function} callback - Seeding callback
36673
+ * @returns {Promise<any>} Result of callback
36674
+ *
36675
+ * @example
36676
+ * await seeder.call(async (db) => {
36677
+ * const user = await UserFactory.create();
36678
+ * const posts = await PostFactory.createMany(5, { userId: user.id });
36679
+ * return { user, posts };
36680
+ * });
36681
+ */
36682
+ async call(callback) {
36683
+ this.log("Running custom seeder...");
36684
+ const result = await callback(this.database);
36685
+ this.log("\u2705 Custom seeder completed");
36686
+ return result;
36687
+ }
36688
+ /**
36689
+ * Truncate resources (delete all data)
36690
+ * @param {string[]} resourceNames - Resource names to truncate
36691
+ * @returns {Promise<void>}
36692
+ *
36693
+ * @example
36694
+ * await seeder.truncate(['users', 'posts']);
36695
+ */
36696
+ async truncate(resourceNames) {
36697
+ for (const resourceName of resourceNames) {
36698
+ this.log(`Truncating ${resourceName}...`);
36699
+ const resource = this.database.resources[resourceName];
36700
+ if (!resource) {
36701
+ this.log(`\u26A0\uFE0F Resource '${resourceName}' not found, skipping`);
36702
+ continue;
36703
+ }
36704
+ const ids = await resource.listIds();
36705
+ if (ids.length > 0) {
36706
+ await resource.deleteMany(ids);
36707
+ this.log(`\u2705 Deleted ${ids.length} ${resourceName}`);
36708
+ } else {
36709
+ this.log(`\u2705 ${resourceName} already empty`);
36710
+ }
36711
+ }
36712
+ }
36713
+ /**
36714
+ * Truncate all resources
36715
+ * @returns {Promise<void>}
36716
+ */
36717
+ async truncateAll() {
36718
+ const resourceNames = Object.keys(this.database.resources);
36719
+ await this.truncate(resourceNames);
36720
+ }
36721
+ /**
36722
+ * Run multiple seeders in order
36723
+ * @param {Function[]} seeders - Array of seeder functions
36724
+ * @returns {Promise<Object[]>} Results of each seeder
36725
+ *
36726
+ * @example
36727
+ * await seeder.run([
36728
+ * async (db) => await UserFactory.createMany(10),
36729
+ * async (db) => await PostFactory.createMany(50)
36730
+ * ]);
36731
+ */
36732
+ async run(seeders) {
36733
+ const results = [];
36734
+ for (const seederFn of seeders) {
36735
+ this.log(`Running seeder ${seederFn.name || "anonymous"}...`);
36736
+ const result = await seederFn(this.database);
36737
+ results.push(result);
36738
+ this.log(`\u2705 Completed ${seederFn.name || "anonymous"}`);
36739
+ }
36740
+ return results;
36741
+ }
36742
+ /**
36743
+ * Seed and return specific resources
36744
+ * @param {Object} specs - Seed specifications
36745
+ * @returns {Promise<Object>} Created resources
36746
+ *
36747
+ * @example
36748
+ * const { users, posts } = await seeder.seedAndReturn({
36749
+ * users: 5,
36750
+ * posts: 10
36751
+ * });
36752
+ */
36753
+ async seedAndReturn(specs) {
36754
+ return await this.seed(specs);
36755
+ }
36756
+ /**
36757
+ * Reset database (truncate all and reset sequences)
36758
+ * @returns {Promise<void>}
36759
+ */
36760
+ async reset() {
36761
+ this.log("Resetting database...");
36762
+ await this.truncateAll();
36763
+ Factory.resetSequences();
36764
+ this.log("\u2705 Database reset complete");
36765
+ }
36766
+ }
36767
+
37670
36768
  function sanitizeLabel(value) {
37671
36769
  if (typeof value !== "string") {
37672
36770
  value = String(value);
@@ -38068,6 +37166,7 @@ exports.DynamoDBReplicator = DynamoDBReplicator;
38068
37166
  exports.EncryptionError = EncryptionError;
38069
37167
  exports.ErrorMap = ErrorMap;
38070
37168
  exports.EventualConsistencyPlugin = EventualConsistencyPlugin;
37169
+ exports.Factory = Factory;
38071
37170
  exports.FilesystemBackupDriver = FilesystemBackupDriver;
38072
37171
  exports.FilesystemCache = FilesystemCache;
38073
37172
  exports.FullTextPlugin = FullTextPlugin;
@@ -38113,6 +37212,7 @@ exports.S3dbReplicator = S3dbReplicator;
38113
37212
  exports.SchedulerPlugin = SchedulerPlugin;
38114
37213
  exports.Schema = Schema;
38115
37214
  exports.SchemaError = SchemaError;
37215
+ exports.Seeder = Seeder;
38116
37216
  exports.SqsConsumer = SqsConsumer;
38117
37217
  exports.SqsReplicator = SqsReplicator;
38118
37218
  exports.StateMachinePlugin = StateMachinePlugin;
@@ -38131,8 +37231,6 @@ exports.calculateEffectiveLimit = calculateEffectiveLimit;
38131
37231
  exports.calculateSystemOverhead = calculateSystemOverhead;
38132
37232
  exports.calculateTotalSize = calculateTotalSize;
38133
37233
  exports.calculateUTF8Bytes = calculateUTF8Bytes;
38134
- exports.clearUTF8Cache = clearUTF8Cache;
38135
- exports.clearUTF8Memo = clearUTF8Memo;
38136
37234
  exports.clearUTF8Memory = clearUTF8Memory;
38137
37235
  exports.createBackupDriver = createBackupDriver;
38138
37236
  exports.createConsumer = createConsumer;
@@ -38148,12 +37246,14 @@ exports.encodeDecimal = encodeDecimal;
38148
37246
  exports.encodeFixedPoint = encodeFixedPoint;
38149
37247
  exports.encodeFixedPointBatch = encodeFixedPointBatch;
38150
37248
  exports.encrypt = encrypt;
37249
+ exports.generateTypes = generateTypes;
38151
37250
  exports.getBehavior = getBehavior;
38152
37251
  exports.getSizeBreakdown = getSizeBreakdown;
38153
37252
  exports.idGenerator = idGenerator;
38154
37253
  exports.mapAwsError = mapAwsError;
38155
37254
  exports.md5 = md5;
38156
37255
  exports.passwordGenerator = passwordGenerator;
37256
+ exports.printTypes = printTypes;
38157
37257
  exports.sha256 = sha256;
38158
37258
  exports.streamToString = streamToString;
38159
37259
  exports.transformValue = transformValue;