s3db.js 13.0.0 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/s3db.es.js CHANGED
@@ -2680,6 +2680,2037 @@ async function requirePluginDependency(pluginId, options = {}) {
2680
2680
  return { valid, missing, incompatible, messages };
2681
2681
  }
2682
2682
 
2683
+ function success(data, options = {}) {
2684
+ const { status = 200, meta = {} } = options;
2685
+ return {
2686
+ success: true,
2687
+ data,
2688
+ meta: {
2689
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2690
+ ...meta
2691
+ },
2692
+ _status: status
2693
+ };
2694
+ }
2695
+ function error(error2, options = {}) {
2696
+ const { status = 500, code = "INTERNAL_ERROR", details = {} } = options;
2697
+ const errorMessage = error2 instanceof Error ? error2.message : error2;
2698
+ const errorStack = error2 instanceof Error && process.env.NODE_ENV !== "production" ? error2.stack : void 0;
2699
+ return {
2700
+ success: false,
2701
+ error: {
2702
+ message: errorMessage,
2703
+ code,
2704
+ details,
2705
+ stack: errorStack
2706
+ },
2707
+ meta: {
2708
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2709
+ },
2710
+ _status: status
2711
+ };
2712
+ }
2713
+ function list(items, pagination = {}) {
2714
+ const { total, page, pageSize, pageCount } = pagination;
2715
+ return {
2716
+ success: true,
2717
+ data: items,
2718
+ pagination: {
2719
+ total: total || items.length,
2720
+ page: page || 1,
2721
+ pageSize: pageSize || items.length,
2722
+ pageCount: pageCount || 1
2723
+ },
2724
+ meta: {
2725
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2726
+ },
2727
+ _status: 200
2728
+ };
2729
+ }
2730
+ function created(data, location) {
2731
+ return {
2732
+ success: true,
2733
+ data,
2734
+ meta: {
2735
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2736
+ location
2737
+ },
2738
+ _status: 201
2739
+ };
2740
+ }
2741
+ function noContent() {
2742
+ return {
2743
+ success: true,
2744
+ data: null,
2745
+ meta: {
2746
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2747
+ },
2748
+ _status: 204
2749
+ };
2750
+ }
2751
+ function notFound(resource, id) {
2752
+ return error(`${resource} with id '${id}' not found`, {
2753
+ status: 404,
2754
+ code: "NOT_FOUND",
2755
+ details: { resource, id }
2756
+ });
2757
+ }
2758
+ function payloadTooLarge(size, limit) {
2759
+ return error("Request payload too large", {
2760
+ status: 413,
2761
+ code: "PAYLOAD_TOO_LARGE",
2762
+ details: {
2763
+ receivedSize: size,
2764
+ maxSize: limit,
2765
+ receivedMB: (size / 1024 / 1024).toFixed(2),
2766
+ maxMB: (limit / 1024 / 1024).toFixed(2)
2767
+ }
2768
+ });
2769
+ }
2770
+
2771
+ const errorStatusMap = {
2772
+ "ValidationError": 400,
2773
+ "InvalidResourceItem": 400,
2774
+ "ResourceNotFound": 404,
2775
+ "NoSuchKey": 404,
2776
+ "NoSuchBucket": 404,
2777
+ "PartitionError": 400,
2778
+ "CryptoError": 500,
2779
+ "SchemaError": 400,
2780
+ "QueueError": 500,
2781
+ "ResourceError": 500
2782
+ };
2783
+ function getStatusFromError(err) {
2784
+ if (err.name && errorStatusMap[err.name]) {
2785
+ return errorStatusMap[err.name];
2786
+ }
2787
+ if (err.constructor && err.constructor.name && errorStatusMap[err.constructor.name]) {
2788
+ return errorStatusMap[err.constructor.name];
2789
+ }
2790
+ if (err.message) {
2791
+ if (err.message.includes("not found") || err.message.includes("does not exist")) {
2792
+ return 404;
2793
+ }
2794
+ if (err.message.includes("validation") || err.message.includes("invalid")) {
2795
+ return 400;
2796
+ }
2797
+ if (err.message.includes("unauthorized") || err.message.includes("authentication")) {
2798
+ return 401;
2799
+ }
2800
+ if (err.message.includes("forbidden") || err.message.includes("permission")) {
2801
+ return 403;
2802
+ }
2803
+ }
2804
+ return 500;
2805
+ }
2806
+ function errorHandler(err, c) {
2807
+ const status = getStatusFromError(err);
2808
+ const code = err.name || "INTERNAL_ERROR";
2809
+ const details = {};
2810
+ if (err.resource) details.resource = err.resource;
2811
+ if (err.bucket) details.bucket = err.bucket;
2812
+ if (err.key) details.key = err.key;
2813
+ if (err.operation) details.operation = err.operation;
2814
+ if (err.suggestion) details.suggestion = err.suggestion;
2815
+ if (err.availableResources) details.availableResources = err.availableResources;
2816
+ const response = error(err, {
2817
+ status,
2818
+ code,
2819
+ details
2820
+ });
2821
+ if (status >= 500) {
2822
+ console.error("[API Plugin] Error:", {
2823
+ message: err.message,
2824
+ code,
2825
+ status,
2826
+ stack: err.stack,
2827
+ details
2828
+ });
2829
+ } else if (status >= 400 && status < 500 && c.get("verbose")) {
2830
+ console.warn("[API Plugin] Client error:", {
2831
+ message: err.message,
2832
+ code,
2833
+ status,
2834
+ details
2835
+ });
2836
+ }
2837
+ return c.json(response, response._status);
2838
+ }
2839
+ function asyncHandler(fn) {
2840
+ return async (c) => {
2841
+ try {
2842
+ return await fn(c);
2843
+ } catch (err) {
2844
+ return errorHandler(err, c);
2845
+ }
2846
+ };
2847
+ }
2848
+
2849
+ function parseCustomRoute(routeDef) {
2850
+ let def = routeDef.trim();
2851
+ const isAsync = def.startsWith("async ");
2852
+ if (isAsync) {
2853
+ def = def.substring(6).trim();
2854
+ }
2855
+ const parts = def.split(/\s+/);
2856
+ if (parts.length < 2) {
2857
+ throw new Error(`Invalid route definition: "${routeDef}". Expected format: "METHOD /path" or "async METHOD /path"`);
2858
+ }
2859
+ const method = parts[0].toUpperCase();
2860
+ const path = parts.slice(1).join(" ").trim();
2861
+ const validMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
2862
+ if (!validMethods.includes(method)) {
2863
+ throw new Error(`Invalid HTTP method: "${method}". Must be one of: ${validMethods.join(", ")}`);
2864
+ }
2865
+ if (!path.startsWith("/")) {
2866
+ throw new Error(`Invalid route path: "${path}". Path must start with "/"`);
2867
+ }
2868
+ return { method, path, isAsync };
2869
+ }
2870
+ function createResourceRoutes(resource, version, config = {}, Hono) {
2871
+ const app = new Hono();
2872
+ const {
2873
+ methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
2874
+ customMiddleware = [],
2875
+ enableValidation = true
2876
+ } = config;
2877
+ const resourceName = resource.name;
2878
+ const basePath = `/${version}/${resourceName}`;
2879
+ customMiddleware.forEach((middleware) => {
2880
+ app.use("*", middleware);
2881
+ });
2882
+ if (resource.config?.api && typeof resource.config.api === "object") {
2883
+ for (const [routeDef, handler] of Object.entries(resource.config.api)) {
2884
+ try {
2885
+ const { method, path } = parseCustomRoute(routeDef);
2886
+ if (typeof handler !== "function") {
2887
+ throw new Error(`Handler for route "${routeDef}" must be a function`);
2888
+ }
2889
+ app.on(method, path, asyncHandler(async (c) => {
2890
+ const result = await handler(c, { resource, database: resource.database });
2891
+ if (result && result.constructor && result.constructor.name === "Response") {
2892
+ return result;
2893
+ }
2894
+ if (result !== void 0 && result !== null) {
2895
+ return c.json(success(result));
2896
+ }
2897
+ return c.json(noContent(), 204);
2898
+ }));
2899
+ if (config.verbose || resource.database?.verbose) {
2900
+ console.log(`[API Plugin] Registered custom route for ${resourceName}: ${method} ${path}`);
2901
+ }
2902
+ } catch (error) {
2903
+ console.error(`[API Plugin] Error registering custom route "${routeDef}" for ${resourceName}:`, error.message);
2904
+ throw error;
2905
+ }
2906
+ }
2907
+ }
2908
+ if (methods.includes("GET")) {
2909
+ app.get("/", asyncHandler(async (c) => {
2910
+ const query = c.req.query();
2911
+ const limit = parseInt(query.limit) || 100;
2912
+ const offset = parseInt(query.offset) || 0;
2913
+ const partition = query.partition;
2914
+ const partitionValues = query.partitionValues ? JSON.parse(query.partitionValues) : void 0;
2915
+ const reservedKeys = ["limit", "offset", "partition", "partitionValues", "sort"];
2916
+ const filters = {};
2917
+ for (const [key, value] of Object.entries(query)) {
2918
+ if (!reservedKeys.includes(key)) {
2919
+ try {
2920
+ filters[key] = JSON.parse(value);
2921
+ } catch {
2922
+ filters[key] = value;
2923
+ }
2924
+ }
2925
+ }
2926
+ let items;
2927
+ let total;
2928
+ if (Object.keys(filters).length > 0) {
2929
+ items = await resource.query(filters, { limit: limit + offset });
2930
+ items = items.slice(offset, offset + limit);
2931
+ total = items.length;
2932
+ } else if (partition && partitionValues) {
2933
+ items = await resource.listPartition({
2934
+ partition,
2935
+ partitionValues,
2936
+ limit: limit + offset
2937
+ });
2938
+ items = items.slice(offset, offset + limit);
2939
+ total = items.length;
2940
+ } else {
2941
+ items = await resource.list({ limit: limit + offset });
2942
+ items = items.slice(offset, offset + limit);
2943
+ total = items.length;
2944
+ }
2945
+ const response = list(items, {
2946
+ total,
2947
+ page: Math.floor(offset / limit) + 1,
2948
+ pageSize: limit,
2949
+ pageCount: Math.ceil(total / limit)
2950
+ });
2951
+ c.header("X-Total-Count", total.toString());
2952
+ c.header("X-Page-Count", Math.ceil(total / limit).toString());
2953
+ return c.json(response, response._status);
2954
+ }));
2955
+ }
2956
+ if (methods.includes("GET")) {
2957
+ app.get("/:id", asyncHandler(async (c) => {
2958
+ const id = c.req.param("id");
2959
+ const query = c.req.query();
2960
+ const partition = query.partition;
2961
+ const partitionValues = query.partitionValues ? JSON.parse(query.partitionValues) : void 0;
2962
+ let item;
2963
+ if (partition && partitionValues) {
2964
+ item = await resource.getFromPartition({
2965
+ id,
2966
+ partitionName: partition,
2967
+ partitionValues
2968
+ });
2969
+ } else {
2970
+ item = await resource.get(id);
2971
+ }
2972
+ if (!item) {
2973
+ const response2 = notFound(resourceName, id);
2974
+ return c.json(response2, response2._status);
2975
+ }
2976
+ const response = success(item);
2977
+ return c.json(response, response._status);
2978
+ }));
2979
+ }
2980
+ if (methods.includes("POST")) {
2981
+ app.post("/", asyncHandler(async (c) => {
2982
+ const data = await c.req.json();
2983
+ const item = await resource.insert(data);
2984
+ const location = `${basePath}/${item.id}`;
2985
+ const response = created(item, location);
2986
+ c.header("Location", location);
2987
+ return c.json(response, response._status);
2988
+ }));
2989
+ }
2990
+ if (methods.includes("PUT")) {
2991
+ app.put("/:id", asyncHandler(async (c) => {
2992
+ const id = c.req.param("id");
2993
+ const data = await c.req.json();
2994
+ const existing = await resource.get(id);
2995
+ if (!existing) {
2996
+ const response2 = notFound(resourceName, id);
2997
+ return c.json(response2, response2._status);
2998
+ }
2999
+ const updated = await resource.update(id, data);
3000
+ const response = success(updated);
3001
+ return c.json(response, response._status);
3002
+ }));
3003
+ }
3004
+ if (methods.includes("PATCH")) {
3005
+ app.patch("/:id", asyncHandler(async (c) => {
3006
+ const id = c.req.param("id");
3007
+ const data = await c.req.json();
3008
+ const existing = await resource.get(id);
3009
+ if (!existing) {
3010
+ const response2 = notFound(resourceName, id);
3011
+ return c.json(response2, response2._status);
3012
+ }
3013
+ const merged = { ...existing, ...data, id };
3014
+ const updated = await resource.update(id, merged);
3015
+ const response = success(updated);
3016
+ return c.json(response, response._status);
3017
+ }));
3018
+ }
3019
+ if (methods.includes("DELETE")) {
3020
+ app.delete("/:id", asyncHandler(async (c) => {
3021
+ const id = c.req.param("id");
3022
+ const existing = await resource.get(id);
3023
+ if (!existing) {
3024
+ const response2 = notFound(resourceName, id);
3025
+ return c.json(response2, response2._status);
3026
+ }
3027
+ await resource.delete(id);
3028
+ const response = noContent();
3029
+ return c.json(response, response._status);
3030
+ }));
3031
+ }
3032
+ if (methods.includes("HEAD")) {
3033
+ app.on("HEAD", "/", asyncHandler(async (c) => {
3034
+ const total = await resource.count();
3035
+ const allItems = await resource.list({ limit: 1e3 });
3036
+ const stats = {
3037
+ total,
3038
+ version: resource.config?.currentVersion || resource.version || "v1"
3039
+ };
3040
+ c.header("X-Total-Count", total.toString());
3041
+ c.header("X-Resource-Version", stats.version);
3042
+ c.header("X-Schema-Fields", Object.keys(resource.config?.attributes || {}).length.toString());
3043
+ return c.body(null, 200);
3044
+ }));
3045
+ app.on("HEAD", "/:id", asyncHandler(async (c) => {
3046
+ const id = c.req.param("id");
3047
+ const item = await resource.get(id);
3048
+ if (!item) {
3049
+ return c.body(null, 404);
3050
+ }
3051
+ if (item.updatedAt) {
3052
+ c.header("Last-Modified", new Date(item.updatedAt).toUTCString());
3053
+ }
3054
+ return c.body(null, 200);
3055
+ }));
3056
+ }
3057
+ if (methods.includes("OPTIONS")) {
3058
+ app.options("/", asyncHandler(async (c) => {
3059
+ c.header("Allow", methods.join(", "));
3060
+ const total = await resource.count();
3061
+ const schema = resource.config?.attributes || {};
3062
+ const version2 = resource.config?.currentVersion || resource.version || "v1";
3063
+ const metadata = {
3064
+ resource: resourceName,
3065
+ version: version2,
3066
+ totalRecords: total,
3067
+ allowedMethods: methods,
3068
+ schema: Object.entries(schema).map(([name, def]) => ({
3069
+ name,
3070
+ type: typeof def === "string" ? def.split("|")[0] : def.type,
3071
+ rules: typeof def === "string" ? def.split("|").slice(1) : []
3072
+ })),
3073
+ endpoints: {
3074
+ list: `/${version2}/${resourceName}`,
3075
+ get: `/${version2}/${resourceName}/:id`,
3076
+ create: `/${version2}/${resourceName}`,
3077
+ update: `/${version2}/${resourceName}/:id`,
3078
+ delete: `/${version2}/${resourceName}/:id`
3079
+ },
3080
+ queryParameters: {
3081
+ limit: "number (1-1000, default: 100)",
3082
+ offset: "number (min: 0, default: 0)",
3083
+ partition: "string (partition name)",
3084
+ partitionValues: "JSON string",
3085
+ "[any field]": "any (filter by field value)"
3086
+ }
3087
+ };
3088
+ return c.json(metadata);
3089
+ }));
3090
+ app.options("/:id", (c) => {
3091
+ c.header("Allow", methods.filter((m) => m !== "POST").join(", "));
3092
+ return c.body(null, 204);
3093
+ });
3094
+ }
3095
+ return app;
3096
+ }
3097
+ function createRelationalRoutes(sourceResource, relationName, relationConfig, version, Hono) {
3098
+ const app = new Hono();
3099
+ const resourceName = sourceResource.name;
3100
+ const relatedResourceName = relationConfig.resource;
3101
+ app.get("/:id", asyncHandler(async (c) => {
3102
+ const id = c.req.param("id");
3103
+ const query = c.req.query();
3104
+ const source = await sourceResource.get(id);
3105
+ if (!source) {
3106
+ const response = notFound(resourceName, id);
3107
+ return c.json(response, response._status);
3108
+ }
3109
+ const result = await sourceResource.get(id, {
3110
+ include: [relationName]
3111
+ });
3112
+ const relatedData = result[relationName];
3113
+ if (!relatedData) {
3114
+ if (relationConfig.type === "hasMany" || relationConfig.type === "belongsToMany") {
3115
+ const response = list([], {
3116
+ total: 0,
3117
+ page: 1,
3118
+ pageSize: 100,
3119
+ pageCount: 0
3120
+ });
3121
+ return c.json(response, response._status);
3122
+ } else {
3123
+ const response = notFound(relatedResourceName, "related resource");
3124
+ return c.json(response, response._status);
3125
+ }
3126
+ }
3127
+ if (relationConfig.type === "hasMany" || relationConfig.type === "belongsToMany") {
3128
+ const items = Array.isArray(relatedData) ? relatedData : [relatedData];
3129
+ const limit = parseInt(query.limit) || 100;
3130
+ const offset = parseInt(query.offset) || 0;
3131
+ const paginatedItems = items.slice(offset, offset + limit);
3132
+ const response = list(paginatedItems, {
3133
+ total: items.length,
3134
+ page: Math.floor(offset / limit) + 1,
3135
+ pageSize: limit,
3136
+ pageCount: Math.ceil(items.length / limit)
3137
+ });
3138
+ c.header("X-Total-Count", items.length.toString());
3139
+ c.header("X-Page-Count", Math.ceil(items.length / limit).toString());
3140
+ return c.json(response, response._status);
3141
+ } else {
3142
+ const response = success(relatedData);
3143
+ return c.json(response, response._status);
3144
+ }
3145
+ }));
3146
+ return app;
3147
+ }
3148
+
3149
+ function mapFieldTypeToOpenAPI(fieldType) {
3150
+ const type = fieldType.split("|")[0].trim();
3151
+ const typeMap = {
3152
+ "string": { type: "string" },
3153
+ "number": { type: "number" },
3154
+ "integer": { type: "integer" },
3155
+ "boolean": { type: "boolean" },
3156
+ "array": { type: "array", items: { type: "string" } },
3157
+ "object": { type: "object" },
3158
+ "json": { type: "object" },
3159
+ "secret": { type: "string", format: "password" },
3160
+ "email": { type: "string", format: "email" },
3161
+ "url": { type: "string", format: "uri" },
3162
+ "date": { type: "string", format: "date" },
3163
+ "datetime": { type: "string", format: "date-time" },
3164
+ "ip4": { type: "string", format: "ipv4", description: "IPv4 address" },
3165
+ "ip6": { type: "string", format: "ipv6", description: "IPv6 address" },
3166
+ "embedding": { type: "array", items: { type: "number" }, description: "Vector embedding" }
3167
+ };
3168
+ if (type.startsWith("embedding:")) {
3169
+ const length = parseInt(type.split(":")[1]);
3170
+ return {
3171
+ type: "array",
3172
+ items: { type: "number" },
3173
+ minItems: length,
3174
+ maxItems: length,
3175
+ description: `Vector embedding (${length} dimensions)`
3176
+ };
3177
+ }
3178
+ return typeMap[type] || { type: "string" };
3179
+ }
3180
+ function extractValidationRules(fieldDef) {
3181
+ const rules = {};
3182
+ const parts = fieldDef.split("|");
3183
+ for (const part of parts) {
3184
+ const [rule, value] = part.split(":").map((s) => s.trim());
3185
+ switch (rule) {
3186
+ case "required":
3187
+ rules.required = true;
3188
+ break;
3189
+ case "min":
3190
+ rules.minimum = parseFloat(value);
3191
+ break;
3192
+ case "max":
3193
+ rules.maximum = parseFloat(value);
3194
+ break;
3195
+ case "minlength":
3196
+ rules.minLength = parseInt(value);
3197
+ break;
3198
+ case "maxlength":
3199
+ rules.maxLength = parseInt(value);
3200
+ break;
3201
+ case "pattern":
3202
+ rules.pattern = value;
3203
+ break;
3204
+ case "enum":
3205
+ rules.enum = value.split(",").map((v) => v.trim());
3206
+ break;
3207
+ case "default":
3208
+ rules.default = value;
3209
+ break;
3210
+ }
3211
+ }
3212
+ return rules;
3213
+ }
3214
+ function generateResourceSchema(resource) {
3215
+ const properties = {};
3216
+ const required = [];
3217
+ const allAttributes = resource.config?.attributes || resource.attributes || {};
3218
+ const pluginAttrNames = resource.schema?._pluginAttributes ? Object.values(resource.schema._pluginAttributes).flat() : [];
3219
+ const attributes = Object.fromEntries(
3220
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
3221
+ );
3222
+ const resourceDescription = resource.config?.description;
3223
+ const attributeDescriptions = typeof resourceDescription === "object" ? resourceDescription.attributes || {} : {};
3224
+ properties.id = {
3225
+ type: "string",
3226
+ description: "Unique identifier for the resource",
3227
+ example: "2_gDTpeU6EI0e8B92n_R3Y",
3228
+ readOnly: true
3229
+ };
3230
+ for (const [fieldName, fieldDef] of Object.entries(attributes)) {
3231
+ if (typeof fieldDef === "object" && fieldDef.type) {
3232
+ const baseType = mapFieldTypeToOpenAPI(fieldDef.type);
3233
+ properties[fieldName] = {
3234
+ ...baseType,
3235
+ description: fieldDef.description || attributeDescriptions[fieldName] || void 0
3236
+ };
3237
+ if (fieldDef.required) {
3238
+ required.push(fieldName);
3239
+ }
3240
+ if (fieldDef.type === "object" && fieldDef.props) {
3241
+ properties[fieldName].properties = {};
3242
+ for (const [propName, propDef] of Object.entries(fieldDef.props)) {
3243
+ const propType = typeof propDef === "string" ? propDef : propDef.type;
3244
+ properties[fieldName].properties[propName] = mapFieldTypeToOpenAPI(propType);
3245
+ }
3246
+ }
3247
+ if (fieldDef.type === "array" && fieldDef.items) {
3248
+ properties[fieldName].items = mapFieldTypeToOpenAPI(fieldDef.items);
3249
+ }
3250
+ } else if (typeof fieldDef === "string") {
3251
+ const baseType = mapFieldTypeToOpenAPI(fieldDef);
3252
+ const rules = extractValidationRules(fieldDef);
3253
+ properties[fieldName] = {
3254
+ ...baseType,
3255
+ ...rules,
3256
+ description: attributeDescriptions[fieldName] || void 0
3257
+ };
3258
+ if (rules.required) {
3259
+ required.push(fieldName);
3260
+ delete properties[fieldName].required;
3261
+ }
3262
+ }
3263
+ }
3264
+ return {
3265
+ type: "object",
3266
+ properties,
3267
+ required: required.length > 0 ? required : void 0
3268
+ };
3269
+ }
3270
+ function generateResourcePaths(resource, version, config = {}) {
3271
+ const resourceName = resource.name;
3272
+ const basePath = `/${version}/${resourceName}`;
3273
+ const schema = generateResourceSchema(resource);
3274
+ const methods = config.methods || ["GET", "POST", "PUT", "PATCH", "DELETE"];
3275
+ const authMethods = config.auth || [];
3276
+ const requiresAuth = authMethods && authMethods.length > 0;
3277
+ const paths = {};
3278
+ const security = [];
3279
+ if (requiresAuth) {
3280
+ if (authMethods.includes("jwt")) security.push({ bearerAuth: [] });
3281
+ if (authMethods.includes("apiKey")) security.push({ apiKeyAuth: [] });
3282
+ if (authMethods.includes("basic")) security.push({ basicAuth: [] });
3283
+ }
3284
+ const partitions = resource.config?.options?.partitions || resource.config?.partitions || resource.partitions || {};
3285
+ const partitionNames = Object.keys(partitions);
3286
+ const hasPartitions = partitionNames.length > 0;
3287
+ let partitionDescription = "Partition name for filtering";
3288
+ let partitionValuesDescription = "Partition values as JSON string";
3289
+ let partitionExample = void 0;
3290
+ let partitionValuesExample = void 0;
3291
+ if (hasPartitions) {
3292
+ const partitionDocs = partitionNames.map((name) => {
3293
+ const partition = partitions[name];
3294
+ const fields = Object.keys(partition.fields || {});
3295
+ const fieldTypes = Object.entries(partition.fields || {}).map(([field, type]) => `${field}: ${type}`).join(", ");
3296
+ return `- **${name}**: Filters by ${fields.join(", ")} (${fieldTypes})`;
3297
+ }).join("\n");
3298
+ partitionDescription = `Available partitions:
3299
+ ${partitionDocs}`;
3300
+ const examplePartition = partitionNames[0];
3301
+ const exampleFields = partitions[examplePartition]?.fields || {};
3302
+ Object.entries(exampleFields).map(([field, type]) => `"${field}": <${type} value>`).join(", ");
3303
+ partitionValuesDescription = `Partition field values as JSON string. Must match the structure of the selected partition.
3304
+
3305
+ Example for "${examplePartition}" partition: \`{"${Object.keys(exampleFields)[0]}": "value"}\``;
3306
+ partitionExample = examplePartition;
3307
+ const firstField = Object.keys(exampleFields)[0];
3308
+ const firstFieldType = exampleFields[firstField];
3309
+ let exampleValue = "example";
3310
+ if (firstFieldType === "number" || firstFieldType === "integer") {
3311
+ exampleValue = 123;
3312
+ } else if (firstFieldType === "boolean") {
3313
+ exampleValue = true;
3314
+ }
3315
+ partitionValuesExample = JSON.stringify({ [firstField]: exampleValue });
3316
+ }
3317
+ const attributeQueryParams = [];
3318
+ if (hasPartitions) {
3319
+ const partitionFieldsSet = /* @__PURE__ */ new Set();
3320
+ for (const [partitionName, partition] of Object.entries(partitions)) {
3321
+ const fields = partition.fields || {};
3322
+ for (const fieldName of Object.keys(fields)) {
3323
+ partitionFieldsSet.add(fieldName);
3324
+ }
3325
+ }
3326
+ const allAttributes = resource.config?.attributes || resource.attributes || {};
3327
+ const pluginAttrNames = resource.schema?._pluginAttributes ? Object.values(resource.schema._pluginAttributes).flat() : [];
3328
+ const attributes = Object.fromEntries(
3329
+ Object.entries(allAttributes).filter(([name]) => !pluginAttrNames.includes(name))
3330
+ );
3331
+ for (const fieldName of partitionFieldsSet) {
3332
+ const fieldDef = attributes[fieldName];
3333
+ if (!fieldDef) continue;
3334
+ let fieldType;
3335
+ if (typeof fieldDef === "object" && fieldDef.type) {
3336
+ fieldType = fieldDef.type;
3337
+ } else if (typeof fieldDef === "string") {
3338
+ fieldType = fieldDef.split("|")[0].trim();
3339
+ } else {
3340
+ fieldType = "string";
3341
+ }
3342
+ const openAPIType = mapFieldTypeToOpenAPI(fieldType);
3343
+ attributeQueryParams.push({
3344
+ name: fieldName,
3345
+ in: "query",
3346
+ description: `Filter by ${fieldName} field (indexed via partitions for efficient querying). Value will be parsed as JSON if possible, otherwise treated as string.`,
3347
+ required: false,
3348
+ schema: openAPIType
3349
+ });
3350
+ }
3351
+ }
3352
+ if (methods.includes("GET")) {
3353
+ paths[basePath] = {
3354
+ get: {
3355
+ tags: [resourceName],
3356
+ summary: `List ${resourceName}`,
3357
+ 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.
3358
+
3359
+ **Pagination**: Use \`limit\` and \`offset\` to paginate results. For example:
3360
+ - First page (10 items): \`?limit=10&offset=0\`
3361
+ - Second page: \`?limit=10&offset=10\`
3362
+ - Third page: \`?limit=10&offset=20\`
3363
+
3364
+ 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." : ""}`,
3365
+ parameters: [
3366
+ {
3367
+ name: "limit",
3368
+ in: "query",
3369
+ description: "Maximum number of items to return per page (page size)",
3370
+ schema: { type: "integer", default: 100, minimum: 1, maximum: 1e3 },
3371
+ example: 10
3372
+ },
3373
+ {
3374
+ name: "offset",
3375
+ in: "query",
3376
+ description: "Number of items to skip before starting to return results. Use for pagination: offset = (page - 1) * limit",
3377
+ schema: { type: "integer", default: 0, minimum: 0 },
3378
+ example: 0
3379
+ },
3380
+ ...hasPartitions ? [
3381
+ {
3382
+ name: "partition",
3383
+ in: "query",
3384
+ description: partitionDescription,
3385
+ schema: {
3386
+ type: "string",
3387
+ enum: partitionNames
3388
+ },
3389
+ example: partitionExample
3390
+ },
3391
+ {
3392
+ name: "partitionValues",
3393
+ in: "query",
3394
+ description: partitionValuesDescription,
3395
+ schema: { type: "string" },
3396
+ example: partitionValuesExample
3397
+ }
3398
+ ] : [],
3399
+ ...attributeQueryParams
3400
+ ],
3401
+ responses: {
3402
+ 200: {
3403
+ description: "Successful response",
3404
+ content: {
3405
+ "application/json": {
3406
+ schema: {
3407
+ type: "object",
3408
+ properties: {
3409
+ success: { type: "boolean", example: true },
3410
+ data: {
3411
+ type: "array",
3412
+ items: schema
3413
+ },
3414
+ pagination: {
3415
+ type: "object",
3416
+ description: "Pagination metadata for the current request",
3417
+ properties: {
3418
+ total: {
3419
+ type: "integer",
3420
+ description: "Total number of items available",
3421
+ example: 150
3422
+ },
3423
+ page: {
3424
+ type: "integer",
3425
+ description: "Current page number (1-indexed)",
3426
+ example: 1
3427
+ },
3428
+ pageSize: {
3429
+ type: "integer",
3430
+ description: "Number of items per page (same as limit parameter)",
3431
+ example: 10
3432
+ },
3433
+ pageCount: {
3434
+ type: "integer",
3435
+ description: "Total number of pages available",
3436
+ example: 15
3437
+ }
3438
+ }
3439
+ }
3440
+ }
3441
+ }
3442
+ }
3443
+ },
3444
+ headers: {
3445
+ "X-Total-Count": {
3446
+ description: "Total number of records",
3447
+ schema: { type: "integer" }
3448
+ },
3449
+ "X-Page-Count": {
3450
+ description: "Total number of pages",
3451
+ schema: { type: "integer" }
3452
+ }
3453
+ }
3454
+ }
3455
+ },
3456
+ security: security.length > 0 ? security : void 0
3457
+ }
3458
+ };
3459
+ }
3460
+ if (methods.includes("GET")) {
3461
+ paths[`${basePath}/{id}`] = {
3462
+ get: {
3463
+ tags: [resourceName],
3464
+ summary: `Get ${resourceName} by ID`,
3465
+ description: `Retrieve a single ${resourceName} by its ID${hasPartitions ? ". Optionally specify a partition for more efficient retrieval." : ""}`,
3466
+ parameters: [
3467
+ {
3468
+ name: "id",
3469
+ in: "path",
3470
+ required: true,
3471
+ description: `${resourceName} ID`,
3472
+ schema: { type: "string" }
3473
+ },
3474
+ ...hasPartitions ? [
3475
+ {
3476
+ name: "partition",
3477
+ in: "query",
3478
+ description: partitionDescription,
3479
+ schema: {
3480
+ type: "string",
3481
+ enum: partitionNames
3482
+ },
3483
+ example: partitionExample
3484
+ },
3485
+ {
3486
+ name: "partitionValues",
3487
+ in: "query",
3488
+ description: partitionValuesDescription,
3489
+ schema: { type: "string" },
3490
+ example: partitionValuesExample
3491
+ }
3492
+ ] : []
3493
+ ],
3494
+ responses: {
3495
+ 200: {
3496
+ description: "Successful response",
3497
+ content: {
3498
+ "application/json": {
3499
+ schema: {
3500
+ type: "object",
3501
+ properties: {
3502
+ success: { type: "boolean", example: true },
3503
+ data: schema
3504
+ }
3505
+ }
3506
+ }
3507
+ }
3508
+ },
3509
+ 404: {
3510
+ description: "Resource not found",
3511
+ content: {
3512
+ "application/json": {
3513
+ schema: { $ref: "#/components/schemas/Error" }
3514
+ }
3515
+ }
3516
+ }
3517
+ },
3518
+ security: security.length > 0 ? security : void 0
3519
+ }
3520
+ };
3521
+ }
3522
+ if (methods.includes("POST")) {
3523
+ if (!paths[basePath]) paths[basePath] = {};
3524
+ paths[basePath].post = {
3525
+ tags: [resourceName],
3526
+ summary: `Create ${resourceName}`,
3527
+ description: `Create a new ${resourceName}`,
3528
+ requestBody: {
3529
+ required: true,
3530
+ content: {
3531
+ "application/json": {
3532
+ schema
3533
+ }
3534
+ }
3535
+ },
3536
+ responses: {
3537
+ 201: {
3538
+ description: "Resource created successfully",
3539
+ content: {
3540
+ "application/json": {
3541
+ schema: {
3542
+ type: "object",
3543
+ properties: {
3544
+ success: { type: "boolean", example: true },
3545
+ data: schema
3546
+ }
3547
+ }
3548
+ }
3549
+ },
3550
+ headers: {
3551
+ Location: {
3552
+ description: "URL of the created resource",
3553
+ schema: { type: "string" }
3554
+ }
3555
+ }
3556
+ },
3557
+ 400: {
3558
+ description: "Validation error",
3559
+ content: {
3560
+ "application/json": {
3561
+ schema: { $ref: "#/components/schemas/ValidationError" }
3562
+ }
3563
+ }
3564
+ }
3565
+ },
3566
+ security: security.length > 0 ? security : void 0
3567
+ };
3568
+ }
3569
+ if (methods.includes("PUT")) {
3570
+ if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3571
+ paths[`${basePath}/{id}`].put = {
3572
+ tags: [resourceName],
3573
+ summary: `Update ${resourceName} (full)`,
3574
+ description: `Fully update a ${resourceName}`,
3575
+ parameters: [
3576
+ {
3577
+ name: "id",
3578
+ in: "path",
3579
+ required: true,
3580
+ schema: { type: "string" }
3581
+ }
3582
+ ],
3583
+ requestBody: {
3584
+ required: true,
3585
+ content: {
3586
+ "application/json": {
3587
+ schema
3588
+ }
3589
+ }
3590
+ },
3591
+ responses: {
3592
+ 200: {
3593
+ description: "Resource updated successfully",
3594
+ content: {
3595
+ "application/json": {
3596
+ schema: {
3597
+ type: "object",
3598
+ properties: {
3599
+ success: { type: "boolean", example: true },
3600
+ data: schema
3601
+ }
3602
+ }
3603
+ }
3604
+ }
3605
+ },
3606
+ 404: {
3607
+ description: "Resource not found",
3608
+ content: {
3609
+ "application/json": {
3610
+ schema: { $ref: "#/components/schemas/Error" }
3611
+ }
3612
+ }
3613
+ }
3614
+ },
3615
+ security: security.length > 0 ? security : void 0
3616
+ };
3617
+ }
3618
+ if (methods.includes("PATCH")) {
3619
+ if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3620
+ paths[`${basePath}/{id}`].patch = {
3621
+ tags: [resourceName],
3622
+ summary: `Update ${resourceName} (partial)`,
3623
+ description: `Partially update a ${resourceName}`,
3624
+ parameters: [
3625
+ {
3626
+ name: "id",
3627
+ in: "path",
3628
+ required: true,
3629
+ schema: { type: "string" }
3630
+ }
3631
+ ],
3632
+ requestBody: {
3633
+ required: true,
3634
+ content: {
3635
+ "application/json": {
3636
+ schema: {
3637
+ ...schema,
3638
+ required: void 0
3639
+ // Partial updates don't require all fields
3640
+ }
3641
+ }
3642
+ }
3643
+ },
3644
+ responses: {
3645
+ 200: {
3646
+ description: "Resource updated successfully",
3647
+ content: {
3648
+ "application/json": {
3649
+ schema: {
3650
+ type: "object",
3651
+ properties: {
3652
+ success: { type: "boolean", example: true },
3653
+ data: schema
3654
+ }
3655
+ }
3656
+ }
3657
+ }
3658
+ },
3659
+ 404: {
3660
+ description: "Resource not found",
3661
+ content: {
3662
+ "application/json": {
3663
+ schema: { $ref: "#/components/schemas/Error" }
3664
+ }
3665
+ }
3666
+ }
3667
+ },
3668
+ security: security.length > 0 ? security : void 0
3669
+ };
3670
+ }
3671
+ if (methods.includes("DELETE")) {
3672
+ if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3673
+ paths[`${basePath}/{id}`].delete = {
3674
+ tags: [resourceName],
3675
+ summary: `Delete ${resourceName}`,
3676
+ description: `Delete a ${resourceName} by ID`,
3677
+ parameters: [
3678
+ {
3679
+ name: "id",
3680
+ in: "path",
3681
+ required: true,
3682
+ schema: { type: "string" }
3683
+ }
3684
+ ],
3685
+ responses: {
3686
+ 204: {
3687
+ description: "Resource deleted successfully"
3688
+ },
3689
+ 404: {
3690
+ description: "Resource not found",
3691
+ content: {
3692
+ "application/json": {
3693
+ schema: { $ref: "#/components/schemas/Error" }
3694
+ }
3695
+ }
3696
+ }
3697
+ },
3698
+ security: security.length > 0 ? security : void 0
3699
+ };
3700
+ }
3701
+ if (methods.includes("HEAD")) {
3702
+ if (!paths[basePath]) paths[basePath] = {};
3703
+ paths[basePath].head = {
3704
+ tags: [resourceName],
3705
+ summary: `Get ${resourceName} statistics`,
3706
+ description: `Get statistics about ${resourceName} collection without retrieving data. Returns statistics in response headers.`,
3707
+ responses: {
3708
+ 200: {
3709
+ description: "Statistics retrieved successfully",
3710
+ headers: {
3711
+ "X-Total-Count": {
3712
+ description: "Total number of records",
3713
+ schema: { type: "integer" }
3714
+ },
3715
+ "X-Resource-Version": {
3716
+ description: "Current resource version",
3717
+ schema: { type: "string" }
3718
+ },
3719
+ "X-Schema-Fields": {
3720
+ description: "Number of schema fields",
3721
+ schema: { type: "integer" }
3722
+ }
3723
+ }
3724
+ }
3725
+ },
3726
+ security: security.length > 0 ? security : void 0
3727
+ };
3728
+ if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3729
+ paths[`${basePath}/{id}`].head = {
3730
+ tags: [resourceName],
3731
+ summary: `Check if ${resourceName} exists`,
3732
+ description: `Check if a ${resourceName} exists without retrieving its data`,
3733
+ parameters: [
3734
+ {
3735
+ name: "id",
3736
+ in: "path",
3737
+ required: true,
3738
+ schema: { type: "string" }
3739
+ }
3740
+ ],
3741
+ responses: {
3742
+ 200: {
3743
+ description: "Resource exists",
3744
+ headers: {
3745
+ "Last-Modified": {
3746
+ description: "Last modification date",
3747
+ schema: { type: "string", format: "date-time" }
3748
+ }
3749
+ }
3750
+ },
3751
+ 404: {
3752
+ description: "Resource not found"
3753
+ }
3754
+ },
3755
+ security: security.length > 0 ? security : void 0
3756
+ };
3757
+ }
3758
+ if (methods.includes("OPTIONS")) {
3759
+ if (!paths[basePath]) paths[basePath] = {};
3760
+ paths[basePath].options = {
3761
+ tags: [resourceName],
3762
+ summary: `Get ${resourceName} metadata`,
3763
+ description: `Get complete metadata about ${resourceName} resource including schema, allowed methods, endpoints, and query parameters`,
3764
+ responses: {
3765
+ 200: {
3766
+ description: "Metadata retrieved successfully",
3767
+ headers: {
3768
+ "Allow": {
3769
+ description: "Allowed HTTP methods",
3770
+ schema: { type: "string", example: "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS" }
3771
+ }
3772
+ },
3773
+ content: {
3774
+ "application/json": {
3775
+ schema: {
3776
+ type: "object",
3777
+ properties: {
3778
+ resource: { type: "string" },
3779
+ version: { type: "string" },
3780
+ totalRecords: { type: "integer" },
3781
+ allowedMethods: {
3782
+ type: "array",
3783
+ items: { type: "string" }
3784
+ },
3785
+ schema: {
3786
+ type: "array",
3787
+ items: {
3788
+ type: "object",
3789
+ properties: {
3790
+ name: { type: "string" },
3791
+ type: { type: "string" },
3792
+ rules: { type: "array", items: { type: "string" } }
3793
+ }
3794
+ }
3795
+ },
3796
+ endpoints: {
3797
+ type: "object",
3798
+ properties: {
3799
+ list: { type: "string" },
3800
+ get: { type: "string" },
3801
+ create: { type: "string" },
3802
+ update: { type: "string" },
3803
+ delete: { type: "string" }
3804
+ }
3805
+ },
3806
+ queryParameters: { type: "object" }
3807
+ }
3808
+ }
3809
+ }
3810
+ }
3811
+ }
3812
+ }
3813
+ };
3814
+ if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3815
+ paths[`${basePath}/{id}`].options = {
3816
+ tags: [resourceName],
3817
+ summary: `Get allowed methods for ${resourceName} item`,
3818
+ description: `Get allowed HTTP methods for individual ${resourceName} operations`,
3819
+ parameters: [
3820
+ {
3821
+ name: "id",
3822
+ in: "path",
3823
+ required: true,
3824
+ schema: { type: "string" }
3825
+ }
3826
+ ],
3827
+ responses: {
3828
+ 204: {
3829
+ description: "Methods retrieved successfully",
3830
+ headers: {
3831
+ "Allow": {
3832
+ description: "Allowed HTTP methods",
3833
+ schema: { type: "string", example: "GET, PUT, PATCH, DELETE, HEAD, OPTIONS" }
3834
+ }
3835
+ }
3836
+ }
3837
+ }
3838
+ };
3839
+ }
3840
+ return paths;
3841
+ }
3842
+ function generateRelationalPaths(resource, relationName, relationConfig, version, relatedSchema) {
3843
+ const resourceName = resource.name;
3844
+ const basePath = `/${version}/${resourceName}/{id}/${relationName}`;
3845
+ relationConfig.resource;
3846
+ const isToMany = relationConfig.type === "hasMany" || relationConfig.type === "belongsToMany";
3847
+ const paths = {};
3848
+ paths[basePath] = {
3849
+ get: {
3850
+ tags: [resourceName],
3851
+ summary: `Get ${relationName} of ${resourceName}`,
3852
+ description: `Retrieve ${relationName} (${relationConfig.type}) associated with this ${resourceName}. This endpoint uses the RelationPlugin to efficiently load related data` + (relationConfig.partitionHint ? ` via the '${relationConfig.partitionHint}' partition.` : "."),
3853
+ parameters: [
3854
+ {
3855
+ name: "id",
3856
+ in: "path",
3857
+ required: true,
3858
+ description: `${resourceName} ID`,
3859
+ schema: { type: "string" }
3860
+ },
3861
+ ...isToMany ? [
3862
+ {
3863
+ name: "limit",
3864
+ in: "query",
3865
+ description: "Maximum number of items to return",
3866
+ schema: { type: "integer", default: 100, minimum: 1, maximum: 1e3 }
3867
+ },
3868
+ {
3869
+ name: "offset",
3870
+ in: "query",
3871
+ description: "Number of items to skip",
3872
+ schema: { type: "integer", default: 0, minimum: 0 }
3873
+ }
3874
+ ] : []
3875
+ ],
3876
+ responses: {
3877
+ 200: {
3878
+ description: "Successful response",
3879
+ content: {
3880
+ "application/json": {
3881
+ schema: isToMany ? {
3882
+ type: "object",
3883
+ properties: {
3884
+ success: { type: "boolean", example: true },
3885
+ data: {
3886
+ type: "array",
3887
+ items: relatedSchema
3888
+ },
3889
+ pagination: {
3890
+ type: "object",
3891
+ properties: {
3892
+ total: { type: "integer" },
3893
+ page: { type: "integer" },
3894
+ pageSize: { type: "integer" },
3895
+ pageCount: { type: "integer" }
3896
+ }
3897
+ }
3898
+ }
3899
+ } : {
3900
+ type: "object",
3901
+ properties: {
3902
+ success: { type: "boolean", example: true },
3903
+ data: relatedSchema
3904
+ }
3905
+ }
3906
+ }
3907
+ },
3908
+ ...isToMany ? {
3909
+ headers: {
3910
+ "X-Total-Count": {
3911
+ description: "Total number of related records",
3912
+ schema: { type: "integer" }
3913
+ },
3914
+ "X-Page-Count": {
3915
+ description: "Total number of pages",
3916
+ schema: { type: "integer" }
3917
+ }
3918
+ }
3919
+ } : {}
3920
+ },
3921
+ 404: {
3922
+ description: `${resourceName} not found` + (isToMany ? "" : " or no related resource exists"),
3923
+ content: {
3924
+ "application/json": {
3925
+ schema: { $ref: "#/components/schemas/Error" }
3926
+ }
3927
+ }
3928
+ }
3929
+ }
3930
+ }
3931
+ };
3932
+ return paths;
3933
+ }
3934
+ function generateOpenAPISpec(database, config = {}) {
3935
+ const {
3936
+ title = "s3db.js API",
3937
+ version = "1.0.0",
3938
+ description = "Auto-generated REST API documentation for s3db.js resources",
3939
+ serverUrl = "http://localhost:3000",
3940
+ auth = {},
3941
+ resources: resourceConfigs = {}
3942
+ } = config;
3943
+ const resourcesTableRows = [];
3944
+ for (const [name, resource] of Object.entries(database.resources)) {
3945
+ if (name.startsWith("plg_") && !resourceConfigs[name]) {
3946
+ continue;
3947
+ }
3948
+ const version2 = resource.config?.currentVersion || resource.version || "v1";
3949
+ const resourceDescription = resource.config?.description;
3950
+ const descText = typeof resourceDescription === "object" ? resourceDescription.resource : resourceDescription || "No description";
3951
+ resourcesTableRows.push(`| ${name} | ${descText} | \`/${version2}/${name}\` |`);
3952
+ }
3953
+ const enhancedDescription = `${description}
3954
+
3955
+ ## Available Resources
3956
+
3957
+ | Resource | Description | Base Path |
3958
+ |----------|-------------|-----------|
3959
+ ${resourcesTableRows.join("\n")}
3960
+
3961
+ ---
3962
+
3963
+ For detailed information about each endpoint, see the sections below.`;
3964
+ const spec = {
3965
+ openapi: "3.1.0",
3966
+ info: {
3967
+ title,
3968
+ version,
3969
+ description: enhancedDescription,
3970
+ contact: {
3971
+ name: "s3db.js",
3972
+ url: "https://github.com/forattini-dev/s3db.js"
3973
+ }
3974
+ },
3975
+ servers: [
3976
+ {
3977
+ url: serverUrl,
3978
+ description: "API Server"
3979
+ }
3980
+ ],
3981
+ paths: {},
3982
+ components: {
3983
+ schemas: {
3984
+ Error: {
3985
+ type: "object",
3986
+ properties: {
3987
+ success: { type: "boolean", example: false },
3988
+ error: {
3989
+ type: "object",
3990
+ properties: {
3991
+ message: { type: "string" },
3992
+ code: { type: "string" },
3993
+ details: { type: "object" }
3994
+ }
3995
+ }
3996
+ }
3997
+ },
3998
+ ValidationError: {
3999
+ type: "object",
4000
+ properties: {
4001
+ success: { type: "boolean", example: false },
4002
+ error: {
4003
+ type: "object",
4004
+ properties: {
4005
+ message: { type: "string", example: "Validation failed" },
4006
+ code: { type: "string", example: "VALIDATION_ERROR" },
4007
+ details: {
4008
+ type: "object",
4009
+ properties: {
4010
+ errors: {
4011
+ type: "array",
4012
+ items: {
4013
+ type: "object",
4014
+ properties: {
4015
+ field: { type: "string" },
4016
+ message: { type: "string" },
4017
+ expected: { type: "string" },
4018
+ actual: {}
4019
+ }
4020
+ }
4021
+ }
4022
+ }
4023
+ }
4024
+ }
4025
+ }
4026
+ }
4027
+ }
4028
+ },
4029
+ securitySchemes: {}
4030
+ },
4031
+ tags: []
4032
+ };
4033
+ if (auth.jwt?.enabled) {
4034
+ spec.components.securitySchemes.bearerAuth = {
4035
+ type: "http",
4036
+ scheme: "bearer",
4037
+ bearerFormat: "JWT",
4038
+ description: "JWT authentication"
4039
+ };
4040
+ }
4041
+ if (auth.apiKey?.enabled) {
4042
+ spec.components.securitySchemes.apiKeyAuth = {
4043
+ type: "apiKey",
4044
+ in: "header",
4045
+ name: auth.apiKey.headerName || "X-API-Key",
4046
+ description: "API Key authentication"
4047
+ };
4048
+ }
4049
+ if (auth.basic?.enabled) {
4050
+ spec.components.securitySchemes.basicAuth = {
4051
+ type: "http",
4052
+ scheme: "basic",
4053
+ description: "HTTP Basic authentication"
4054
+ };
4055
+ }
4056
+ const resources = database.resources;
4057
+ const relationsPlugin = database.plugins?.relation || database.plugins?.RelationPlugin || null;
4058
+ for (const [name, resource] of Object.entries(resources)) {
4059
+ if (name.startsWith("plg_") && !resourceConfigs[name]) {
4060
+ continue;
4061
+ }
4062
+ const config2 = resourceConfigs[name] || {
4063
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
4064
+ auth: false
4065
+ };
4066
+ const version2 = resource.config?.currentVersion || resource.version || "v1";
4067
+ const paths = generateResourcePaths(resource, version2, config2);
4068
+ Object.assign(spec.paths, paths);
4069
+ const resourceDescription = resource.config?.description;
4070
+ const tagDescription = typeof resourceDescription === "object" ? resourceDescription.resource : resourceDescription || `Operations for ${name} resource`;
4071
+ spec.tags.push({
4072
+ name,
4073
+ description: tagDescription
4074
+ });
4075
+ spec.components.schemas[name] = generateResourceSchema(resource);
4076
+ if (relationsPlugin && relationsPlugin.relations && relationsPlugin.relations[name]) {
4077
+ const relationsDef = relationsPlugin.relations[name];
4078
+ for (const [relationName, relationConfig] of Object.entries(relationsDef)) {
4079
+ if (relationConfig.type === "belongsTo") {
4080
+ continue;
4081
+ }
4082
+ const exposeRelation = config2?.relations?.[relationName]?.expose !== false;
4083
+ if (!exposeRelation) {
4084
+ continue;
4085
+ }
4086
+ const relatedResource = database.resources[relationConfig.resource];
4087
+ if (!relatedResource) {
4088
+ continue;
4089
+ }
4090
+ const relatedSchema = generateResourceSchema(relatedResource);
4091
+ const relationalPaths = generateRelationalPaths(
4092
+ resource,
4093
+ relationName,
4094
+ relationConfig,
4095
+ version2,
4096
+ relatedSchema
4097
+ );
4098
+ Object.assign(spec.paths, relationalPaths);
4099
+ }
4100
+ }
4101
+ }
4102
+ if (auth.jwt?.enabled || auth.apiKey?.enabled || auth.basic?.enabled) {
4103
+ spec.paths["/auth/login"] = {
4104
+ post: {
4105
+ tags: ["Authentication"],
4106
+ summary: "Login",
4107
+ description: "Authenticate with username and password",
4108
+ requestBody: {
4109
+ required: true,
4110
+ content: {
4111
+ "application/json": {
4112
+ schema: {
4113
+ type: "object",
4114
+ properties: {
4115
+ username: { type: "string" },
4116
+ password: { type: "string", format: "password" }
4117
+ },
4118
+ required: ["username", "password"]
4119
+ }
4120
+ }
4121
+ }
4122
+ },
4123
+ responses: {
4124
+ 200: {
4125
+ description: "Login successful",
4126
+ content: {
4127
+ "application/json": {
4128
+ schema: {
4129
+ type: "object",
4130
+ properties: {
4131
+ success: { type: "boolean", example: true },
4132
+ data: {
4133
+ type: "object",
4134
+ properties: {
4135
+ token: { type: "string" },
4136
+ user: { type: "object" }
4137
+ }
4138
+ }
4139
+ }
4140
+ }
4141
+ }
4142
+ }
4143
+ },
4144
+ 401: {
4145
+ description: "Invalid credentials",
4146
+ content: {
4147
+ "application/json": {
4148
+ schema: { $ref: "#/components/schemas/Error" }
4149
+ }
4150
+ }
4151
+ }
4152
+ }
4153
+ }
4154
+ };
4155
+ spec.paths["/auth/register"] = {
4156
+ post: {
4157
+ tags: ["Authentication"],
4158
+ summary: "Register",
4159
+ description: "Register a new user",
4160
+ requestBody: {
4161
+ required: true,
4162
+ content: {
4163
+ "application/json": {
4164
+ schema: {
4165
+ type: "object",
4166
+ properties: {
4167
+ username: { type: "string", minLength: 3 },
4168
+ password: { type: "string", format: "password", minLength: 8 },
4169
+ email: { type: "string", format: "email" }
4170
+ },
4171
+ required: ["username", "password"]
4172
+ }
4173
+ }
4174
+ }
4175
+ },
4176
+ responses: {
4177
+ 201: {
4178
+ description: "User registered successfully",
4179
+ content: {
4180
+ "application/json": {
4181
+ schema: {
4182
+ type: "object",
4183
+ properties: {
4184
+ success: { type: "boolean", example: true },
4185
+ data: {
4186
+ type: "object",
4187
+ properties: {
4188
+ token: { type: "string" },
4189
+ user: { type: "object" }
4190
+ }
4191
+ }
4192
+ }
4193
+ }
4194
+ }
4195
+ }
4196
+ }
4197
+ }
4198
+ }
4199
+ };
4200
+ spec.tags.push({
4201
+ name: "Authentication",
4202
+ description: "Authentication endpoints"
4203
+ });
4204
+ }
4205
+ spec.paths["/health"] = {
4206
+ get: {
4207
+ tags: ["Health"],
4208
+ summary: "Generic Health Check",
4209
+ description: "Generic health check endpoint that includes references to liveness and readiness probes",
4210
+ responses: {
4211
+ 200: {
4212
+ description: "API is healthy",
4213
+ content: {
4214
+ "application/json": {
4215
+ schema: {
4216
+ type: "object",
4217
+ properties: {
4218
+ success: { type: "boolean", example: true },
4219
+ data: {
4220
+ type: "object",
4221
+ properties: {
4222
+ status: { type: "string", example: "ok" },
4223
+ uptime: { type: "number", description: "Process uptime in seconds" },
4224
+ timestamp: { type: "string", format: "date-time" },
4225
+ checks: {
4226
+ type: "object",
4227
+ properties: {
4228
+ liveness: { type: "string", example: "/health/live" },
4229
+ readiness: { type: "string", example: "/health/ready" }
4230
+ }
4231
+ }
4232
+ }
4233
+ }
4234
+ }
4235
+ }
4236
+ }
4237
+ }
4238
+ }
4239
+ }
4240
+ }
4241
+ };
4242
+ spec.paths["/health/live"] = {
4243
+ get: {
4244
+ tags: ["Health"],
4245
+ summary: "Liveness Probe",
4246
+ description: "Kubernetes liveness probe - checks if the application is alive. If this fails, Kubernetes will restart the pod.",
4247
+ responses: {
4248
+ 200: {
4249
+ description: "Application is alive",
4250
+ content: {
4251
+ "application/json": {
4252
+ schema: {
4253
+ type: "object",
4254
+ properties: {
4255
+ success: { type: "boolean", example: true },
4256
+ data: {
4257
+ type: "object",
4258
+ properties: {
4259
+ status: { type: "string", example: "alive" },
4260
+ timestamp: { type: "string", format: "date-time" }
4261
+ }
4262
+ }
4263
+ }
4264
+ }
4265
+ }
4266
+ }
4267
+ }
4268
+ }
4269
+ }
4270
+ };
4271
+ spec.paths["/health/ready"] = {
4272
+ get: {
4273
+ tags: ["Health"],
4274
+ summary: "Readiness Probe",
4275
+ description: "Kubernetes readiness probe - checks if the application is ready to receive traffic. If this fails, Kubernetes will remove the pod from service endpoints.",
4276
+ responses: {
4277
+ 200: {
4278
+ description: "Application is ready to receive traffic",
4279
+ content: {
4280
+ "application/json": {
4281
+ schema: {
4282
+ type: "object",
4283
+ properties: {
4284
+ success: { type: "boolean", example: true },
4285
+ data: {
4286
+ type: "object",
4287
+ properties: {
4288
+ status: { type: "string", example: "ready" },
4289
+ database: {
4290
+ type: "object",
4291
+ properties: {
4292
+ connected: { type: "boolean", example: true },
4293
+ resources: { type: "integer", example: 5 }
4294
+ }
4295
+ },
4296
+ timestamp: { type: "string", format: "date-time" }
4297
+ }
4298
+ }
4299
+ }
4300
+ }
4301
+ }
4302
+ }
4303
+ },
4304
+ 503: {
4305
+ description: "Application is not ready",
4306
+ content: {
4307
+ "application/json": {
4308
+ schema: {
4309
+ type: "object",
4310
+ properties: {
4311
+ success: { type: "boolean", example: false },
4312
+ error: {
4313
+ type: "object",
4314
+ properties: {
4315
+ message: { type: "string", example: "Service not ready" },
4316
+ code: { type: "string", example: "NOT_READY" },
4317
+ details: {
4318
+ type: "object",
4319
+ properties: {
4320
+ database: {
4321
+ type: "object",
4322
+ properties: {
4323
+ connected: { type: "boolean", example: false },
4324
+ resources: { type: "integer", example: 0 }
4325
+ }
4326
+ }
4327
+ }
4328
+ }
4329
+ }
4330
+ }
4331
+ }
4332
+ }
4333
+ }
4334
+ }
4335
+ }
4336
+ }
4337
+ }
4338
+ };
4339
+ spec.tags.push({
4340
+ name: "Health",
4341
+ description: "Health check endpoints for monitoring and Kubernetes probes"
4342
+ });
4343
+ const metricsPlugin = database.plugins?.metrics || database.plugins?.MetricsPlugin;
4344
+ if (metricsPlugin && metricsPlugin.config?.prometheus?.enabled) {
4345
+ const metricsPath = metricsPlugin.config.prometheus.path || "/metrics";
4346
+ const isIntegrated = metricsPlugin.config.prometheus.mode !== "standalone";
4347
+ if (isIntegrated) {
4348
+ spec.paths[metricsPath] = {
4349
+ get: {
4350
+ tags: ["Monitoring"],
4351
+ summary: "Prometheus Metrics",
4352
+ description: "Exposes application metrics in Prometheus text-based exposition format for monitoring and observability. Metrics include operation counts, durations, errors, uptime, and resource statistics.",
4353
+ responses: {
4354
+ 200: {
4355
+ description: "Metrics in Prometheus format",
4356
+ content: {
4357
+ "text/plain": {
4358
+ schema: {
4359
+ type: "string",
4360
+ 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'
4361
+ }
4362
+ }
4363
+ }
4364
+ }
4365
+ }
4366
+ }
4367
+ };
4368
+ spec.tags.push({
4369
+ name: "Monitoring",
4370
+ description: "Monitoring and observability endpoints (Prometheus)"
4371
+ });
4372
+ }
4373
+ }
4374
+ return spec;
4375
+ }
4376
+
4377
+ class ApiServer {
4378
+ /**
4379
+ * Create API server
4380
+ * @param {Object} options - Server options
4381
+ * @param {number} options.port - Server port
4382
+ * @param {string} options.host - Server host
4383
+ * @param {Object} options.database - s3db.js database instance
4384
+ * @param {Object} options.resources - Resource configuration
4385
+ * @param {Array} options.middlewares - Global middlewares
4386
+ */
4387
+ constructor(options = {}) {
4388
+ this.options = {
4389
+ port: options.port || 3e3,
4390
+ host: options.host || "0.0.0.0",
4391
+ database: options.database,
4392
+ resources: options.resources || {},
4393
+ middlewares: options.middlewares || [],
4394
+ verbose: options.verbose || false,
4395
+ auth: options.auth || {},
4396
+ docsEnabled: options.docsEnabled !== false,
4397
+ // Enable /docs by default
4398
+ docsUI: options.docsUI || "redoc",
4399
+ // 'swagger' or 'redoc'
4400
+ maxBodySize: options.maxBodySize || 10 * 1024 * 1024,
4401
+ // 10MB default
4402
+ rootHandler: options.rootHandler,
4403
+ // Custom handler for root path, if not provided redirects to /docs
4404
+ apiInfo: {
4405
+ title: options.apiTitle || "s3db.js API",
4406
+ version: options.apiVersion || "1.0.0",
4407
+ description: options.apiDescription || "Auto-generated REST API for s3db.js resources"
4408
+ }
4409
+ };
4410
+ this.app = null;
4411
+ this.server = null;
4412
+ this.isRunning = false;
4413
+ this.openAPISpec = null;
4414
+ this.initialized = false;
4415
+ this.relationsPlugin = this.options.database?.plugins?.relation || this.options.database?.plugins?.RelationPlugin || null;
4416
+ }
4417
+ /**
4418
+ * Setup all routes
4419
+ * @private
4420
+ */
4421
+ _setupRoutes() {
4422
+ this.options.middlewares.forEach((middleware) => {
4423
+ this.app.use("*", middleware);
4424
+ });
4425
+ this.app.use("*", async (c, next) => {
4426
+ const method = c.req.method;
4427
+ if (["POST", "PUT", "PATCH"].includes(method)) {
4428
+ const contentLength = c.req.header("content-length");
4429
+ if (contentLength) {
4430
+ const size = parseInt(contentLength);
4431
+ if (size > this.options.maxBodySize) {
4432
+ const response = payloadTooLarge(size, this.options.maxBodySize);
4433
+ c.header("Connection", "close");
4434
+ return c.json(response, response._status);
4435
+ }
4436
+ }
4437
+ }
4438
+ await next();
4439
+ });
4440
+ this.app.get("/health/live", (c) => {
4441
+ const response = success({
4442
+ status: "alive",
4443
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4444
+ });
4445
+ return c.json(response);
4446
+ });
4447
+ this.app.get("/health/ready", (c) => {
4448
+ const isReady = this.options.database && this.options.database.connected && Object.keys(this.options.database.resources).length > 0;
4449
+ if (!isReady) {
4450
+ const response2 = error("Service not ready", {
4451
+ status: 503,
4452
+ code: "NOT_READY",
4453
+ details: {
4454
+ database: {
4455
+ connected: this.options.database?.connected || false,
4456
+ resources: Object.keys(this.options.database?.resources || {}).length
4457
+ }
4458
+ }
4459
+ });
4460
+ return c.json(response2, 503);
4461
+ }
4462
+ const response = success({
4463
+ status: "ready",
4464
+ database: {
4465
+ connected: true,
4466
+ resources: Object.keys(this.options.database.resources).length
4467
+ },
4468
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4469
+ });
4470
+ return c.json(response);
4471
+ });
4472
+ this.app.get("/health", (c) => {
4473
+ const response = success({
4474
+ status: "ok",
4475
+ uptime: process.uptime(),
4476
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4477
+ checks: {
4478
+ liveness: "/health/live",
4479
+ readiness: "/health/ready"
4480
+ }
4481
+ });
4482
+ return c.json(response);
4483
+ });
4484
+ this.app.get("/", (c) => {
4485
+ if (this.options.rootHandler) {
4486
+ return this.options.rootHandler(c);
4487
+ }
4488
+ return c.redirect("/docs", 302);
4489
+ });
4490
+ if (this.options.docsEnabled) {
4491
+ this.app.get("/openapi.json", (c) => {
4492
+ if (!this.openAPISpec) {
4493
+ this.openAPISpec = this._generateOpenAPISpec();
4494
+ }
4495
+ return c.json(this.openAPISpec);
4496
+ });
4497
+ if (this.options.docsUI === "swagger") {
4498
+ this.app.get("/docs", this.swaggerUI({
4499
+ url: "/openapi.json"
4500
+ }));
4501
+ } else {
4502
+ this.app.get("/docs", (c) => {
4503
+ return c.html(`<!DOCTYPE html>
4504
+ <html lang="en">
4505
+ <head>
4506
+ <meta charset="UTF-8">
4507
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
4508
+ <title>${this.options.apiInfo.title} - API Documentation</title>
4509
+ <style>
4510
+ body {
4511
+ margin: 0;
4512
+ padding: 0;
4513
+ }
4514
+ </style>
4515
+ </head>
4516
+ <body>
4517
+ <redoc spec-url="/openapi.json"></redoc>
4518
+ <script src="https://cdn.redoc.ly/redoc/v2.5.1/bundles/redoc.standalone.js"><\/script>
4519
+ </body>
4520
+ </html>`);
4521
+ });
4522
+ }
4523
+ }
4524
+ this._setupResourceRoutes();
4525
+ if (this.relationsPlugin) {
4526
+ this._setupRelationalRoutes();
4527
+ }
4528
+ this.app.onError((err, c) => {
4529
+ return errorHandler(err, c);
4530
+ });
4531
+ this.app.notFound((c) => {
4532
+ const response = error("Route not found", {
4533
+ status: 404,
4534
+ code: "NOT_FOUND",
4535
+ details: {
4536
+ path: c.req.path,
4537
+ method: c.req.method
4538
+ }
4539
+ });
4540
+ return c.json(response, 404);
4541
+ });
4542
+ }
4543
+ /**
4544
+ * Setup routes for all resources
4545
+ * @private
4546
+ */
4547
+ _setupResourceRoutes() {
4548
+ const { database, resources: resourceConfigs } = this.options;
4549
+ const resources = database.resources;
4550
+ for (const [name, resource] of Object.entries(resources)) {
4551
+ if (name.startsWith("plg_") && !resourceConfigs[name]) {
4552
+ continue;
4553
+ }
4554
+ const config = resourceConfigs[name] || {
4555
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]};
4556
+ const version = resource.config?.currentVersion || resource.version || "v1";
4557
+ const resourceApp = createResourceRoutes(resource, version, {
4558
+ methods: config.methods,
4559
+ customMiddleware: config.customMiddleware || [],
4560
+ enableValidation: config.validation !== false
4561
+ }, this.Hono);
4562
+ this.app.route(`/${version}/${name}`, resourceApp);
4563
+ if (this.options.verbose) {
4564
+ console.log(`[API Plugin] Mounted routes for resource '${name}' at /${version}/${name}`);
4565
+ }
4566
+ }
4567
+ }
4568
+ /**
4569
+ * Setup relational routes (when RelationPlugin is active)
4570
+ * @private
4571
+ */
4572
+ _setupRelationalRoutes() {
4573
+ if (!this.relationsPlugin || !this.relationsPlugin.relations) {
4574
+ return;
4575
+ }
4576
+ const { database } = this.options;
4577
+ const relations = this.relationsPlugin.relations;
4578
+ if (this.options.verbose) {
4579
+ console.log("[API Plugin] Setting up relational routes...");
4580
+ }
4581
+ for (const [resourceName, relationsDef] of Object.entries(relations)) {
4582
+ const resource = database.resources[resourceName];
4583
+ if (!resource) {
4584
+ if (this.options.verbose) {
4585
+ console.warn(`[API Plugin] Resource '${resourceName}' not found for relational routes`);
4586
+ }
4587
+ continue;
4588
+ }
4589
+ if (resourceName.startsWith("plg_") && !this.options.resources[resourceName]) {
4590
+ continue;
4591
+ }
4592
+ const version = resource.config?.currentVersion || resource.version || "v1";
4593
+ for (const [relationName, relationConfig] of Object.entries(relationsDef)) {
4594
+ if (relationConfig.type === "belongsTo") {
4595
+ continue;
4596
+ }
4597
+ const resourceConfig = this.options.resources[resourceName];
4598
+ const exposeRelation = resourceConfig?.relations?.[relationName]?.expose !== false;
4599
+ if (!exposeRelation) {
4600
+ continue;
4601
+ }
4602
+ const relationalApp = createRelationalRoutes(
4603
+ resource,
4604
+ relationName,
4605
+ relationConfig,
4606
+ version,
4607
+ this.Hono
4608
+ );
4609
+ this.app.route(`/${version}/${resourceName}/:id/${relationName}`, relationalApp);
4610
+ if (this.options.verbose) {
4611
+ console.log(
4612
+ `[API Plugin] Mounted relational route: /${version}/${resourceName}/:id/${relationName} (${relationConfig.type} -> ${relationConfig.resource})`
4613
+ );
4614
+ }
4615
+ }
4616
+ }
4617
+ }
4618
+ /**
4619
+ * Start the server
4620
+ * @returns {Promise<void>}
4621
+ */
4622
+ async start() {
4623
+ if (this.isRunning) {
4624
+ console.warn("[API Plugin] Server is already running");
4625
+ return;
4626
+ }
4627
+ if (!this.initialized) {
4628
+ const { Hono } = await import('hono');
4629
+ const { serve } = await import('@hono/node-server');
4630
+ const { swaggerUI } = await import('@hono/swagger-ui');
4631
+ this.Hono = Hono;
4632
+ this.serve = serve;
4633
+ this.swaggerUI = swaggerUI;
4634
+ this.app = new Hono();
4635
+ this._setupRoutes();
4636
+ this.initialized = true;
4637
+ }
4638
+ const { port, host } = this.options;
4639
+ return new Promise((resolve, reject) => {
4640
+ try {
4641
+ this.server = this.serve({
4642
+ fetch: this.app.fetch,
4643
+ port,
4644
+ hostname: host
4645
+ }, (info) => {
4646
+ this.isRunning = true;
4647
+ console.log(`[API Plugin] Server listening on http://${info.address}:${info.port}`);
4648
+ resolve();
4649
+ });
4650
+ } catch (err) {
4651
+ reject(err);
4652
+ }
4653
+ });
4654
+ }
4655
+ /**
4656
+ * Stop the server
4657
+ * @returns {Promise<void>}
4658
+ */
4659
+ async stop() {
4660
+ if (!this.isRunning) {
4661
+ console.warn("[API Plugin] Server is not running");
4662
+ return;
4663
+ }
4664
+ if (this.server && typeof this.server.close === "function") {
4665
+ await new Promise((resolve) => {
4666
+ this.server.close(() => {
4667
+ this.isRunning = false;
4668
+ console.log("[API Plugin] Server stopped");
4669
+ resolve();
4670
+ });
4671
+ });
4672
+ } else {
4673
+ this.isRunning = false;
4674
+ console.log("[API Plugin] Server stopped");
4675
+ }
4676
+ }
4677
+ /**
4678
+ * Get server info
4679
+ * @returns {Object} Server information
4680
+ */
4681
+ getInfo() {
4682
+ return {
4683
+ isRunning: this.isRunning,
4684
+ port: this.options.port,
4685
+ host: this.options.host,
4686
+ resources: Object.keys(this.options.database.resources).length
4687
+ };
4688
+ }
4689
+ /**
4690
+ * Get Hono app instance
4691
+ * @returns {Hono} Hono app
4692
+ */
4693
+ getApp() {
4694
+ return this.app;
4695
+ }
4696
+ /**
4697
+ * Generate OpenAPI specification
4698
+ * @private
4699
+ * @returns {Object} OpenAPI spec
4700
+ */
4701
+ _generateOpenAPISpec() {
4702
+ const { port, host, database, resources, auth, apiInfo } = this.options;
4703
+ return generateOpenAPISpec(database, {
4704
+ title: apiInfo.title,
4705
+ version: apiInfo.version,
4706
+ description: apiInfo.description,
4707
+ serverUrl: `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`,
4708
+ auth,
4709
+ resources
4710
+ });
4711
+ }
4712
+ }
4713
+
2683
4714
  class ApiPlugin extends Plugin {
2684
4715
  /**
2685
4716
  * Create API Plugin instance
@@ -3002,11 +5033,6 @@ class ApiPlugin extends Plugin {
3002
5033
  if (this.config.verbose) {
3003
5034
  console.log("[API Plugin] Starting server...");
3004
5035
  }
3005
- const serverPath = "./server.js";
3006
- const { ApiServer } = await import(
3007
- /* @vite-ignore */
3008
- serverPath
3009
- );
3010
5036
  this.server = new ApiServer({
3011
5037
  port: this.config.port,
3012
5038
  host: this.config.host,
@@ -13204,10 +15230,17 @@ class MLPlugin extends Plugin {
13204
15230
  this.config = {
13205
15231
  models: options.models || {},
13206
15232
  verbose: options.verbose || false,
13207
- minTrainingSamples: options.minTrainingSamples || 10
15233
+ minTrainingSamples: options.minTrainingSamples || 10,
15234
+ saveModel: options.saveModel !== false,
15235
+ // Default true
15236
+ saveTrainingData: options.saveTrainingData || false,
15237
+ enableVersioning: options.enableVersioning !== false
15238
+ // Default true
13208
15239
  };
13209
15240
  requirePluginDependency("@tensorflow/tfjs-node", "MLPlugin");
13210
15241
  this.models = {};
15242
+ this.modelVersions = /* @__PURE__ */ new Map();
15243
+ this.modelCache = /* @__PURE__ */ new Map();
13211
15244
  this.training = /* @__PURE__ */ new Map();
13212
15245
  this.insertCounters = /* @__PURE__ */ new Map();
13213
15246
  this.intervals = [];
@@ -13231,6 +15264,8 @@ class MLPlugin extends Plugin {
13231
15264
  for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
13232
15265
  await this._initializeModel(modelName, modelConfig);
13233
15266
  }
15267
+ this._buildModelCache();
15268
+ this._injectResourceMethods();
13234
15269
  for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
13235
15270
  if (modelConfig.autoTrain) {
13236
15271
  this._setupAutoTraining(modelName, modelConfig);
@@ -13249,6 +15284,11 @@ class MLPlugin extends Plugin {
13249
15284
  * Start the plugin
13250
15285
  */
13251
15286
  async onStart() {
15287
+ if (this.config.enableVersioning) {
15288
+ for (const modelName of Object.keys(this.models)) {
15289
+ await this._initializeVersioning(modelName);
15290
+ }
15291
+ }
13252
15292
  for (const modelName of Object.keys(this.models)) {
13253
15293
  await this._loadModel(modelName);
13254
15294
  }
@@ -13281,11 +15321,134 @@ class MLPlugin extends Plugin {
13281
15321
  if (options.purgeData) {
13282
15322
  for (const modelName of Object.keys(this.models)) {
13283
15323
  await this._deleteModel(modelName);
15324
+ await this._deleteTrainingData(modelName);
15325
+ }
15326
+ if (this.config.verbose) {
15327
+ console.log("[MLPlugin] Purged all model data and training data");
13284
15328
  }
15329
+ }
15330
+ }
15331
+ /**
15332
+ * Build model cache for fast lookup
15333
+ * @private
15334
+ */
15335
+ _buildModelCache() {
15336
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
15337
+ const cacheKey = `${modelConfig.resource}_${modelConfig.target}`;
15338
+ this.modelCache.set(cacheKey, modelName);
13285
15339
  if (this.config.verbose) {
13286
- console.log("[MLPlugin] Purged all model data");
15340
+ console.log(`[MLPlugin] Cached model "${modelName}" for ${modelConfig.resource}.predict(..., '${modelConfig.target}')`);
15341
+ }
15342
+ }
15343
+ }
15344
+ /**
15345
+ * Inject ML methods into Resource instances
15346
+ * @private
15347
+ */
15348
+ _injectResourceMethods() {
15349
+ if (!this.database._mlPlugin) {
15350
+ this.database._mlPlugin = this;
15351
+ }
15352
+ if (!this.database.Resource.prototype.predict) {
15353
+ this.database.Resource.prototype.predict = async function(input, targetAttribute) {
15354
+ const mlPlugin = this.database._mlPlugin;
15355
+ if (!mlPlugin) {
15356
+ throw new Error("MLPlugin not installed");
15357
+ }
15358
+ return await mlPlugin._resourcePredict(this.name, input, targetAttribute);
15359
+ };
15360
+ }
15361
+ if (!this.database.Resource.prototype.trainModel) {
15362
+ this.database.Resource.prototype.trainModel = async function(targetAttribute, options = {}) {
15363
+ const mlPlugin = this.database._mlPlugin;
15364
+ if (!mlPlugin) {
15365
+ throw new Error("MLPlugin not installed");
15366
+ }
15367
+ return await mlPlugin._resourceTrainModel(this.name, targetAttribute, options);
15368
+ };
15369
+ }
15370
+ if (!this.database.Resource.prototype.listModels) {
15371
+ this.database.Resource.prototype.listModels = function() {
15372
+ const mlPlugin = this.database._mlPlugin;
15373
+ if (!mlPlugin) {
15374
+ throw new Error("MLPlugin not installed");
15375
+ }
15376
+ return mlPlugin._resourceListModels(this.name);
15377
+ };
15378
+ }
15379
+ if (this.config.verbose) {
15380
+ console.log("[MLPlugin] Injected ML methods into Resource prototype");
15381
+ }
15382
+ }
15383
+ /**
15384
+ * Find model for a resource and target attribute
15385
+ * @private
15386
+ */
15387
+ _findModelForResource(resourceName, targetAttribute) {
15388
+ const cacheKey = `${resourceName}_${targetAttribute}`;
15389
+ if (this.modelCache.has(cacheKey)) {
15390
+ return this.modelCache.get(cacheKey);
15391
+ }
15392
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
15393
+ if (modelConfig.resource === resourceName && modelConfig.target === targetAttribute) {
15394
+ this.modelCache.set(cacheKey, modelName);
15395
+ return modelName;
13287
15396
  }
13288
15397
  }
15398
+ return null;
15399
+ }
15400
+ /**
15401
+ * Resource predict implementation
15402
+ * @private
15403
+ */
15404
+ async _resourcePredict(resourceName, input, targetAttribute) {
15405
+ const modelName = this._findModelForResource(resourceName, targetAttribute);
15406
+ if (!modelName) {
15407
+ throw new ModelNotFoundError(
15408
+ `No model found for resource "${resourceName}" with target "${targetAttribute}"`,
15409
+ { resourceName, targetAttribute, availableModels: Object.keys(this.models) }
15410
+ );
15411
+ }
15412
+ if (this.config.verbose) {
15413
+ console.log(`[MLPlugin] Resource prediction: ${resourceName}.predict(..., '${targetAttribute}') -> model "${modelName}"`);
15414
+ }
15415
+ return await this.predict(modelName, input);
15416
+ }
15417
+ /**
15418
+ * Resource trainModel implementation
15419
+ * @private
15420
+ */
15421
+ async _resourceTrainModel(resourceName, targetAttribute, options = {}) {
15422
+ const modelName = this._findModelForResource(resourceName, targetAttribute);
15423
+ if (!modelName) {
15424
+ throw new ModelNotFoundError(
15425
+ `No model found for resource "${resourceName}" with target "${targetAttribute}"`,
15426
+ { resourceName, targetAttribute, availableModels: Object.keys(this.models) }
15427
+ );
15428
+ }
15429
+ if (this.config.verbose) {
15430
+ console.log(`[MLPlugin] Resource training: ${resourceName}.trainModel('${targetAttribute}') -> model "${modelName}"`);
15431
+ }
15432
+ return await this.train(modelName, options);
15433
+ }
15434
+ /**
15435
+ * List models for a resource
15436
+ * @private
15437
+ */
15438
+ _resourceListModels(resourceName) {
15439
+ const models = [];
15440
+ for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
15441
+ if (modelConfig.resource === resourceName) {
15442
+ models.push({
15443
+ name: modelName,
15444
+ type: modelConfig.type,
15445
+ target: modelConfig.target,
15446
+ features: modelConfig.features,
15447
+ isTrained: this.models[modelName]?.isTrained || false
15448
+ });
15449
+ }
15450
+ }
15451
+ return models;
13289
15452
  }
13290
15453
  /**
13291
15454
  * Validate model configuration
@@ -13437,12 +15600,31 @@ class MLPlugin extends Plugin {
13437
15600
  if (this.config.verbose) {
13438
15601
  console.log(`[MLPlugin] Fetching training data for "${modelName}"...`);
13439
15602
  }
13440
- const [ok, err, data] = await tryFn(() => resource.list());
13441
- if (!ok) {
13442
- throw new TrainingError(
13443
- `Failed to fetch training data: ${err.message}`,
13444
- { modelName, resource: modelConfig.resource, originalError: err.message }
15603
+ let data;
15604
+ const partition = modelConfig.partition;
15605
+ if (partition && partition.name) {
15606
+ if (this.config.verbose) {
15607
+ console.log(`[MLPlugin] Using partition "${partition.name}" with values:`, partition.values);
15608
+ }
15609
+ const [ok, err, partitionData] = await tryFn(
15610
+ () => resource.listPartition(partition.name, partition.values)
13445
15611
  );
15612
+ if (!ok) {
15613
+ throw new TrainingError(
15614
+ `Failed to fetch training data from partition: ${err.message}`,
15615
+ { modelName, resource: modelConfig.resource, partition: partition.name, originalError: err.message }
15616
+ );
15617
+ }
15618
+ data = partitionData;
15619
+ } else {
15620
+ const [ok, err, allData] = await tryFn(() => resource.list());
15621
+ if (!ok) {
15622
+ throw new TrainingError(
15623
+ `Failed to fetch training data: ${err.message}`,
15624
+ { modelName, resource: modelConfig.resource, originalError: err.message }
15625
+ );
15626
+ }
15627
+ data = allData;
13446
15628
  }
13447
15629
  if (!data || data.length < this.config.minTrainingSamples) {
13448
15630
  throw new TrainingError(
@@ -13453,8 +15635,15 @@ class MLPlugin extends Plugin {
13453
15635
  if (this.config.verbose) {
13454
15636
  console.log(`[MLPlugin] Training "${modelName}" with ${data.length} samples...`);
13455
15637
  }
15638
+ const shouldSaveTrainingData = modelConfig.saveTrainingData !== void 0 ? modelConfig.saveTrainingData : this.config.saveTrainingData;
15639
+ if (shouldSaveTrainingData) {
15640
+ await this._saveTrainingData(modelName, data);
15641
+ }
13456
15642
  const result = await model.train(data);
13457
- await this._saveModel(modelName);
15643
+ const shouldSaveModel = modelConfig.saveModel !== void 0 ? modelConfig.saveModel : this.config.saveModel;
15644
+ if (shouldSaveModel) {
15645
+ await this._saveModel(modelName);
15646
+ }
13458
15647
  this.stats.totalTrainings++;
13459
15648
  if (this.config.verbose) {
13460
15649
  console.log(`[MLPlugin] Training completed for "${modelName}":`, result);
@@ -13603,6 +15792,69 @@ class MLPlugin extends Plugin {
13603
15792
  console.log(`[MLPlugin] Imported model "${modelName}"`);
13604
15793
  }
13605
15794
  }
15795
+ /**
15796
+ * Initialize versioning for a model
15797
+ * @private
15798
+ */
15799
+ async _initializeVersioning(modelName) {
15800
+ try {
15801
+ const storage = this.getStorage();
15802
+ const [ok, err, versionInfo] = await tryFn(() => storage.get(`version_${modelName}`));
15803
+ if (ok && versionInfo) {
15804
+ this.modelVersions.set(modelName, {
15805
+ currentVersion: versionInfo.currentVersion || 1,
15806
+ latestVersion: versionInfo.latestVersion || 1
15807
+ });
15808
+ if (this.config.verbose) {
15809
+ console.log(`[MLPlugin] Loaded version info for "${modelName}": v${versionInfo.currentVersion}`);
15810
+ }
15811
+ } else {
15812
+ this.modelVersions.set(modelName, {
15813
+ currentVersion: 1,
15814
+ latestVersion: 0
15815
+ // No versions yet
15816
+ });
15817
+ if (this.config.verbose) {
15818
+ console.log(`[MLPlugin] Initialized versioning for "${modelName}"`);
15819
+ }
15820
+ }
15821
+ } catch (error) {
15822
+ console.error(`[MLPlugin] Failed to initialize versioning for "${modelName}":`, error.message);
15823
+ this.modelVersions.set(modelName, { currentVersion: 1, latestVersion: 0 });
15824
+ }
15825
+ }
15826
+ /**
15827
+ * Get next version number for a model
15828
+ * @private
15829
+ */
15830
+ _getNextVersion(modelName) {
15831
+ const versionInfo = this.modelVersions.get(modelName) || { latestVersion: 0 };
15832
+ return versionInfo.latestVersion + 1;
15833
+ }
15834
+ /**
15835
+ * Update version info in storage
15836
+ * @private
15837
+ */
15838
+ async _updateVersionInfo(modelName, version) {
15839
+ try {
15840
+ const storage = this.getStorage();
15841
+ const versionInfo = this.modelVersions.get(modelName) || { currentVersion: 1, latestVersion: 0 };
15842
+ versionInfo.latestVersion = Math.max(versionInfo.latestVersion, version);
15843
+ versionInfo.currentVersion = version;
15844
+ this.modelVersions.set(modelName, versionInfo);
15845
+ await storage.patch(`version_${modelName}`, {
15846
+ modelName,
15847
+ currentVersion: versionInfo.currentVersion,
15848
+ latestVersion: versionInfo.latestVersion,
15849
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
15850
+ });
15851
+ if (this.config.verbose) {
15852
+ console.log(`[MLPlugin] Updated version info for "${modelName}": current=v${versionInfo.currentVersion}, latest=v${versionInfo.latestVersion}`);
15853
+ }
15854
+ } catch (error) {
15855
+ console.error(`[MLPlugin] Failed to update version info for "${modelName}":`, error.message);
15856
+ }
15857
+ }
13606
15858
  /**
13607
15859
  * Save model to plugin storage
13608
15860
  * @private
@@ -13617,18 +15869,120 @@ class MLPlugin extends Plugin {
13617
15869
  }
13618
15870
  return;
13619
15871
  }
13620
- await storage.patch(`model_${modelName}`, {
13621
- modelName,
13622
- data: JSON.stringify(exportedModel),
13623
- savedAt: (/* @__PURE__ */ new Date()).toISOString()
13624
- });
13625
- if (this.config.verbose) {
13626
- console.log(`[MLPlugin] Saved model "${modelName}" to plugin storage`);
15872
+ const enableVersioning = this.config.enableVersioning;
15873
+ if (enableVersioning) {
15874
+ const version = this._getNextVersion(modelName);
15875
+ const modelStats = this.models[modelName].getStats();
15876
+ await storage.patch(`model_${modelName}_v${version}`, {
15877
+ modelName,
15878
+ version,
15879
+ type: "model",
15880
+ data: JSON.stringify(exportedModel),
15881
+ metrics: JSON.stringify({
15882
+ loss: modelStats.loss,
15883
+ accuracy: modelStats.accuracy,
15884
+ samples: modelStats.samples
15885
+ }),
15886
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
15887
+ });
15888
+ await this._updateVersionInfo(modelName, version);
15889
+ await storage.patch(`model_${modelName}_active`, {
15890
+ modelName,
15891
+ version,
15892
+ type: "reference",
15893
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
15894
+ });
15895
+ if (this.config.verbose) {
15896
+ console.log(`[MLPlugin] Saved model "${modelName}" v${version} to plugin storage (S3)`);
15897
+ }
15898
+ } else {
15899
+ await storage.patch(`model_${modelName}`, {
15900
+ modelName,
15901
+ type: "model",
15902
+ data: JSON.stringify(exportedModel),
15903
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
15904
+ });
15905
+ if (this.config.verbose) {
15906
+ console.log(`[MLPlugin] Saved model "${modelName}" to plugin storage (S3)`);
15907
+ }
13627
15908
  }
13628
15909
  } catch (error) {
13629
15910
  console.error(`[MLPlugin] Failed to save model "${modelName}":`, error.message);
13630
15911
  }
13631
15912
  }
15913
+ /**
15914
+ * Save intermediate training data to plugin storage (incremental)
15915
+ * @private
15916
+ */
15917
+ async _saveTrainingData(modelName, rawData) {
15918
+ try {
15919
+ const storage = this.getStorage();
15920
+ const model = this.models[modelName];
15921
+ const modelConfig = this.config.models[modelName];
15922
+ const modelStats = model.getStats();
15923
+ const enableVersioning = this.config.enableVersioning;
15924
+ const trainingEntry = {
15925
+ version: enableVersioning ? this.modelVersions.get(modelName)?.latestVersion || 1 : void 0,
15926
+ samples: rawData.length,
15927
+ features: modelConfig.features,
15928
+ target: modelConfig.target,
15929
+ data: rawData.map((item) => {
15930
+ const features = {};
15931
+ modelConfig.features.forEach((feature) => {
15932
+ features[feature] = item[feature];
15933
+ });
15934
+ return {
15935
+ features,
15936
+ target: item[modelConfig.target]
15937
+ };
15938
+ }),
15939
+ metrics: {
15940
+ loss: modelStats.loss,
15941
+ accuracy: modelStats.accuracy,
15942
+ r2: modelStats.r2
15943
+ },
15944
+ trainedAt: (/* @__PURE__ */ new Date()).toISOString()
15945
+ };
15946
+ if (enableVersioning) {
15947
+ const [ok, err, existing] = await tryFn(() => storage.get(`training_history_${modelName}`));
15948
+ let history = [];
15949
+ if (ok && existing && existing.history) {
15950
+ try {
15951
+ history = JSON.parse(existing.history);
15952
+ } catch (e) {
15953
+ history = [];
15954
+ }
15955
+ }
15956
+ history.push(trainingEntry);
15957
+ await storage.patch(`training_history_${modelName}`, {
15958
+ modelName,
15959
+ type: "training_history",
15960
+ totalTrainings: history.length,
15961
+ latestVersion: trainingEntry.version,
15962
+ history: JSON.stringify(history),
15963
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
15964
+ });
15965
+ if (this.config.verbose) {
15966
+ console.log(`[MLPlugin] Appended training data for "${modelName}" v${trainingEntry.version} (${trainingEntry.samples} samples, total: ${history.length} trainings)`);
15967
+ }
15968
+ } else {
15969
+ await storage.patch(`training_data_${modelName}`, {
15970
+ modelName,
15971
+ type: "training_data",
15972
+ samples: trainingEntry.samples,
15973
+ features: JSON.stringify(trainingEntry.features),
15974
+ target: trainingEntry.target,
15975
+ data: JSON.stringify(trainingEntry.data),
15976
+ savedAt: trainingEntry.trainedAt
15977
+ });
15978
+ if (this.config.verbose) {
15979
+ console.log(`[MLPlugin] Saved training data for "${modelName}" (${trainingEntry.samples} samples) to plugin storage (S3)`);
15980
+ }
15981
+ }
15982
+ } catch (error) {
15983
+ console.error(`[MLPlugin] Failed to save training data for "${modelName}":`, error.message);
15984
+ }
15985
+ }
13632
15986
  /**
13633
15987
  * Load model from plugin storage
13634
15988
  * @private
@@ -13636,22 +15990,83 @@ class MLPlugin extends Plugin {
13636
15990
  async _loadModel(modelName) {
13637
15991
  try {
13638
15992
  const storage = this.getStorage();
13639
- const [ok, err, record] = await tryFn(() => storage.get(`model_${modelName}`));
13640
- if (!ok || !record) {
15993
+ const enableVersioning = this.config.enableVersioning;
15994
+ if (enableVersioning) {
15995
+ const [okRef, errRef, activeRef] = await tryFn(() => storage.get(`model_${modelName}_active`));
15996
+ if (okRef && activeRef && activeRef.version) {
15997
+ const version = activeRef.version;
15998
+ const [ok, err, versionData] = await tryFn(() => storage.get(`model_${modelName}_v${version}`));
15999
+ if (ok && versionData) {
16000
+ const modelData = JSON.parse(versionData.data);
16001
+ await this.models[modelName].import(modelData);
16002
+ if (this.config.verbose) {
16003
+ console.log(`[MLPlugin] Loaded model "${modelName}" v${version} (active) from plugin storage (S3)`);
16004
+ }
16005
+ return;
16006
+ }
16007
+ }
16008
+ const versionInfo = this.modelVersions.get(modelName);
16009
+ if (versionInfo && versionInfo.latestVersion > 0) {
16010
+ const version = versionInfo.latestVersion;
16011
+ const [ok, err, versionData] = await tryFn(() => storage.get(`model_${modelName}_v${version}`));
16012
+ if (ok && versionData) {
16013
+ const modelData = JSON.parse(versionData.data);
16014
+ await this.models[modelName].import(modelData);
16015
+ if (this.config.verbose) {
16016
+ console.log(`[MLPlugin] Loaded model "${modelName}" v${version} (latest) from plugin storage (S3)`);
16017
+ }
16018
+ return;
16019
+ }
16020
+ }
13641
16021
  if (this.config.verbose) {
13642
- console.log(`[MLPlugin] No saved model found for "${modelName}"`);
16022
+ console.log(`[MLPlugin] No saved model versions found for "${modelName}"`);
16023
+ }
16024
+ } else {
16025
+ const [ok, err, record] = await tryFn(() => storage.get(`model_${modelName}`));
16026
+ if (!ok || !record) {
16027
+ if (this.config.verbose) {
16028
+ console.log(`[MLPlugin] No saved model found for "${modelName}"`);
16029
+ }
16030
+ return;
16031
+ }
16032
+ const modelData = JSON.parse(record.data);
16033
+ await this.models[modelName].import(modelData);
16034
+ if (this.config.verbose) {
16035
+ console.log(`[MLPlugin] Loaded model "${modelName}" from plugin storage (S3)`);
13643
16036
  }
13644
- return;
13645
- }
13646
- const modelData = JSON.parse(record.data);
13647
- await this.models[modelName].import(modelData);
13648
- if (this.config.verbose) {
13649
- console.log(`[MLPlugin] Loaded model "${modelName}" from plugin storage`);
13650
16037
  }
13651
16038
  } catch (error) {
13652
16039
  console.error(`[MLPlugin] Failed to load model "${modelName}":`, error.message);
13653
16040
  }
13654
16041
  }
16042
+ /**
16043
+ * Load training data from plugin storage
16044
+ * @param {string} modelName - Model name
16045
+ * @returns {Object|null} Training data or null if not found
16046
+ */
16047
+ async getTrainingData(modelName) {
16048
+ try {
16049
+ const storage = this.getStorage();
16050
+ const [ok, err, record] = await tryFn(() => storage.get(`training_data_${modelName}`));
16051
+ if (!ok || !record) {
16052
+ if (this.config.verbose) {
16053
+ console.log(`[MLPlugin] No saved training data found for "${modelName}"`);
16054
+ }
16055
+ return null;
16056
+ }
16057
+ return {
16058
+ modelName: record.modelName,
16059
+ samples: record.samples,
16060
+ features: JSON.parse(record.features),
16061
+ target: record.target,
16062
+ data: JSON.parse(record.data),
16063
+ savedAt: record.savedAt
16064
+ };
16065
+ } catch (error) {
16066
+ console.error(`[MLPlugin] Failed to load training data for "${modelName}":`, error.message);
16067
+ return null;
16068
+ }
16069
+ }
13655
16070
  /**
13656
16071
  * Delete model from plugin storage
13657
16072
  * @private
@@ -13669,6 +16084,219 @@ class MLPlugin extends Plugin {
13669
16084
  }
13670
16085
  }
13671
16086
  }
16087
+ /**
16088
+ * Delete training data from plugin storage
16089
+ * @private
16090
+ */
16091
+ async _deleteTrainingData(modelName) {
16092
+ try {
16093
+ const storage = this.getStorage();
16094
+ await storage.delete(`training_data_${modelName}`);
16095
+ if (this.config.verbose) {
16096
+ console.log(`[MLPlugin] Deleted training data for "${modelName}" from plugin storage`);
16097
+ }
16098
+ } catch (error) {
16099
+ if (this.config.verbose) {
16100
+ console.log(`[MLPlugin] Could not delete training data "${modelName}": ${error.message}`);
16101
+ }
16102
+ }
16103
+ }
16104
+ /**
16105
+ * List all versions of a model
16106
+ * @param {string} modelName - Model name
16107
+ * @returns {Array} List of version info
16108
+ */
16109
+ async listModelVersions(modelName) {
16110
+ if (!this.config.enableVersioning) {
16111
+ throw new MLError("Versioning is not enabled", { modelName });
16112
+ }
16113
+ try {
16114
+ const storage = this.getStorage();
16115
+ const versionInfo = this.modelVersions.get(modelName) || { latestVersion: 0 };
16116
+ const versions = [];
16117
+ for (let v = 1; v <= versionInfo.latestVersion; v++) {
16118
+ const [ok, err, versionData] = await tryFn(() => storage.get(`model_${modelName}_v${v}`));
16119
+ if (ok && versionData) {
16120
+ const metrics = versionData.metrics ? JSON.parse(versionData.metrics) : {};
16121
+ versions.push({
16122
+ version: v,
16123
+ savedAt: versionData.savedAt,
16124
+ isCurrent: v === versionInfo.currentVersion,
16125
+ metrics
16126
+ });
16127
+ }
16128
+ }
16129
+ return versions;
16130
+ } catch (error) {
16131
+ console.error(`[MLPlugin] Failed to list versions for "${modelName}":`, error.message);
16132
+ return [];
16133
+ }
16134
+ }
16135
+ /**
16136
+ * Load a specific version of a model
16137
+ * @param {string} modelName - Model name
16138
+ * @param {number} version - Version number
16139
+ */
16140
+ async loadModelVersion(modelName, version) {
16141
+ if (!this.config.enableVersioning) {
16142
+ throw new MLError("Versioning is not enabled", { modelName });
16143
+ }
16144
+ if (!this.models[modelName]) {
16145
+ throw new ModelNotFoundError(`Model "${modelName}" not found`, { modelName });
16146
+ }
16147
+ try {
16148
+ const storage = this.getStorage();
16149
+ const [ok, err, versionData] = await tryFn(() => storage.get(`model_${modelName}_v${version}`));
16150
+ if (!ok || !versionData) {
16151
+ throw new MLError(`Version ${version} not found for model "${modelName}"`, { modelName, version });
16152
+ }
16153
+ const modelData = JSON.parse(versionData.data);
16154
+ await this.models[modelName].import(modelData);
16155
+ const versionInfo = this.modelVersions.get(modelName);
16156
+ if (versionInfo) {
16157
+ versionInfo.currentVersion = version;
16158
+ this.modelVersions.set(modelName, versionInfo);
16159
+ }
16160
+ if (this.config.verbose) {
16161
+ console.log(`[MLPlugin] Loaded model "${modelName}" v${version}`);
16162
+ }
16163
+ return {
16164
+ version,
16165
+ metrics: versionData.metrics ? JSON.parse(versionData.metrics) : {},
16166
+ savedAt: versionData.savedAt
16167
+ };
16168
+ } catch (error) {
16169
+ console.error(`[MLPlugin] Failed to load version ${version} for "${modelName}":`, error.message);
16170
+ throw error;
16171
+ }
16172
+ }
16173
+ /**
16174
+ * Set active version for a model (used for predictions)
16175
+ * @param {string} modelName - Model name
16176
+ * @param {number} version - Version number
16177
+ */
16178
+ async setActiveVersion(modelName, version) {
16179
+ if (!this.config.enableVersioning) {
16180
+ throw new MLError("Versioning is not enabled", { modelName });
16181
+ }
16182
+ await this.loadModelVersion(modelName, version);
16183
+ await this._updateVersionInfo(modelName, version);
16184
+ const storage = this.getStorage();
16185
+ await storage.patch(`model_${modelName}_active`, {
16186
+ modelName,
16187
+ version,
16188
+ type: "reference",
16189
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
16190
+ });
16191
+ if (this.config.verbose) {
16192
+ console.log(`[MLPlugin] Set model "${modelName}" active version to v${version}`);
16193
+ }
16194
+ return { modelName, version };
16195
+ }
16196
+ /**
16197
+ * Get training history for a model
16198
+ * @param {string} modelName - Model name
16199
+ * @returns {Array} Training history
16200
+ */
16201
+ async getTrainingHistory(modelName) {
16202
+ if (!this.config.enableVersioning) {
16203
+ return await this.getTrainingData(modelName);
16204
+ }
16205
+ try {
16206
+ const storage = this.getStorage();
16207
+ const [ok, err, historyData] = await tryFn(() => storage.get(`training_history_${modelName}`));
16208
+ if (!ok || !historyData) {
16209
+ return null;
16210
+ }
16211
+ return {
16212
+ modelName: historyData.modelName,
16213
+ totalTrainings: historyData.totalTrainings,
16214
+ latestVersion: historyData.latestVersion,
16215
+ history: JSON.parse(historyData.history),
16216
+ updatedAt: historyData.updatedAt
16217
+ };
16218
+ } catch (error) {
16219
+ console.error(`[MLPlugin] Failed to load training history for "${modelName}":`, error.message);
16220
+ return null;
16221
+ }
16222
+ }
16223
+ /**
16224
+ * Compare metrics between two versions
16225
+ * @param {string} modelName - Model name
16226
+ * @param {number} version1 - First version
16227
+ * @param {number} version2 - Second version
16228
+ * @returns {Object} Comparison results
16229
+ */
16230
+ async compareVersions(modelName, version1, version2) {
16231
+ if (!this.config.enableVersioning) {
16232
+ throw new MLError("Versioning is not enabled", { modelName });
16233
+ }
16234
+ try {
16235
+ const storage = this.getStorage();
16236
+ const [ok1, err1, v1Data] = await tryFn(() => storage.get(`model_${modelName}_v${version1}`));
16237
+ const [ok2, err2, v2Data] = await tryFn(() => storage.get(`model_${modelName}_v${version2}`));
16238
+ if (!ok1 || !v1Data) {
16239
+ throw new MLError(`Version ${version1} not found`, { modelName, version: version1 });
16240
+ }
16241
+ if (!ok2 || !v2Data) {
16242
+ throw new MLError(`Version ${version2} not found`, { modelName, version: version2 });
16243
+ }
16244
+ const metrics1 = v1Data.metrics ? JSON.parse(v1Data.metrics) : {};
16245
+ const metrics2 = v2Data.metrics ? JSON.parse(v2Data.metrics) : {};
16246
+ return {
16247
+ modelName,
16248
+ version1: {
16249
+ version: version1,
16250
+ savedAt: v1Data.savedAt,
16251
+ metrics: metrics1
16252
+ },
16253
+ version2: {
16254
+ version: version2,
16255
+ savedAt: v2Data.savedAt,
16256
+ metrics: metrics2
16257
+ },
16258
+ improvement: {
16259
+ loss: metrics1.loss && metrics2.loss ? ((metrics1.loss - metrics2.loss) / metrics1.loss * 100).toFixed(2) + "%" : "N/A",
16260
+ accuracy: metrics1.accuracy && metrics2.accuracy ? ((metrics2.accuracy - metrics1.accuracy) / metrics1.accuracy * 100).toFixed(2) + "%" : "N/A"
16261
+ }
16262
+ };
16263
+ } catch (error) {
16264
+ console.error(`[MLPlugin] Failed to compare versions for "${modelName}":`, error.message);
16265
+ throw error;
16266
+ }
16267
+ }
16268
+ /**
16269
+ * Rollback to a previous version
16270
+ * @param {string} modelName - Model name
16271
+ * @param {number} version - Version to rollback to (defaults to previous version)
16272
+ * @returns {Object} Rollback info
16273
+ */
16274
+ async rollbackVersion(modelName, version = null) {
16275
+ if (!this.config.enableVersioning) {
16276
+ throw new MLError("Versioning is not enabled", { modelName });
16277
+ }
16278
+ const versionInfo = this.modelVersions.get(modelName);
16279
+ if (!versionInfo) {
16280
+ throw new MLError(`No version info found for model "${modelName}"`, { modelName });
16281
+ }
16282
+ const targetVersion = version !== null ? version : Math.max(1, versionInfo.currentVersion - 1);
16283
+ if (targetVersion === versionInfo.currentVersion) {
16284
+ throw new MLError("Cannot rollback to the same version", { modelName, version: targetVersion });
16285
+ }
16286
+ if (targetVersion < 1 || targetVersion > versionInfo.latestVersion) {
16287
+ throw new MLError(`Invalid version ${targetVersion}`, { modelName, version: targetVersion, latestVersion: versionInfo.latestVersion });
16288
+ }
16289
+ const result = await this.setActiveVersion(modelName, targetVersion);
16290
+ if (this.config.verbose) {
16291
+ console.log(`[MLPlugin] Rolled back model "${modelName}" from v${versionInfo.currentVersion} to v${targetVersion}`);
16292
+ }
16293
+ return {
16294
+ modelName,
16295
+ previousVersion: versionInfo.currentVersion,
16296
+ currentVersion: targetVersion,
16297
+ ...result
16298
+ };
16299
+ }
13672
16300
  }
13673
16301
 
13674
16302
  class SqsConsumer {
@@ -23218,7 +25846,7 @@ class Database extends EventEmitter {
23218
25846
  })();
23219
25847
  this.version = "1";
23220
25848
  this.s3dbVersion = (() => {
23221
- const [ok, err, version] = tryFn(() => true ? "13.0.0" : "latest");
25849
+ const [ok, err, version] = tryFn(() => true ? "13.1.0" : "latest");
23222
25850
  return ok ? version : "latest";
23223
25851
  })();
23224
25852
  this._resourcesMap = {};
@@ -38157,11 +40785,21 @@ class TfStatePlugin extends Plugin {
38157
40785
  }
38158
40786
  }
38159
40787
 
40788
+ const ONE_HOUR_SEC = 3600;
40789
+ const ONE_DAY_SEC = 86400;
40790
+ const THIRTY_DAYS_SEC = 2592e3;
40791
+ const TEN_SECONDS_MS = 1e4;
40792
+ const ONE_MINUTE_MS = 6e4;
40793
+ const TEN_MINUTES_MS = 6e5;
40794
+ const ONE_HOUR_MS = 36e5;
40795
+ const ONE_DAY_MS = 864e5;
40796
+ const ONE_WEEK_MS = 6048e5;
40797
+ const SECONDS_TO_MS = 1e3;
38160
40798
  const GRANULARITIES = {
38161
40799
  minute: {
38162
- threshold: 3600,
40800
+ threshold: ONE_HOUR_SEC,
38163
40801
  // TTL < 1 hour
38164
- interval: 1e4,
40802
+ interval: TEN_SECONDS_MS,
38165
40803
  // Check every 10 seconds
38166
40804
  cohortsToCheck: 3,
38167
40805
  // Check last 3 minutes
@@ -38169,9 +40807,9 @@ const GRANULARITIES = {
38169
40807
  // '2024-10-25T14:30'
38170
40808
  },
38171
40809
  hour: {
38172
- threshold: 86400,
40810
+ threshold: ONE_DAY_SEC,
38173
40811
  // TTL < 24 hours
38174
- interval: 6e5,
40812
+ interval: TEN_MINUTES_MS,
38175
40813
  // Check every 10 minutes
38176
40814
  cohortsToCheck: 2,
38177
40815
  // Check last 2 hours
@@ -38179,9 +40817,9 @@ const GRANULARITIES = {
38179
40817
  // '2024-10-25T14'
38180
40818
  },
38181
40819
  day: {
38182
- threshold: 2592e3,
40820
+ threshold: THIRTY_DAYS_SEC,
38183
40821
  // TTL < 30 days
38184
- interval: 36e5,
40822
+ interval: ONE_HOUR_MS,
38185
40823
  // Check every 1 hour
38186
40824
  cohortsToCheck: 2,
38187
40825
  // Check last 2 days
@@ -38191,7 +40829,7 @@ const GRANULARITIES = {
38191
40829
  week: {
38192
40830
  threshold: Infinity,
38193
40831
  // TTL >= 30 days
38194
- interval: 864e5,
40832
+ interval: ONE_DAY_MS,
38195
40833
  // Check every 24 hours
38196
40834
  cohortsToCheck: 2,
38197
40835
  // Check last 2 weeks
@@ -38207,7 +40845,7 @@ function getWeekNumber(date) {
38207
40845
  const dayNum = d.getUTCDay() || 7;
38208
40846
  d.setUTCDate(d.getUTCDate() + 4 - dayNum);
38209
40847
  const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
38210
- return Math.ceil(((d - yearStart) / 864e5 + 1) / 7);
40848
+ return Math.ceil(((d - yearStart) / ONE_DAY_MS + 1) / 7);
38211
40849
  }
38212
40850
  function detectGranularity(ttl) {
38213
40851
  if (!ttl) return "day";
@@ -38224,16 +40862,16 @@ function getExpiredCohorts(granularity, count) {
38224
40862
  let checkDate;
38225
40863
  switch (granularity) {
38226
40864
  case "minute":
38227
- checkDate = new Date(now.getTime() - i * 6e4);
40865
+ checkDate = new Date(now.getTime() - i * ONE_MINUTE_MS);
38228
40866
  break;
38229
40867
  case "hour":
38230
- checkDate = new Date(now.getTime() - i * 36e5);
40868
+ checkDate = new Date(now.getTime() - i * ONE_HOUR_MS);
38231
40869
  break;
38232
40870
  case "day":
38233
- checkDate = new Date(now.getTime() - i * 864e5);
40871
+ checkDate = new Date(now.getTime() - i * ONE_DAY_MS);
38234
40872
  break;
38235
40873
  case "week":
38236
- checkDate = new Date(now.getTime() - i * 6048e5);
40874
+ checkDate = new Date(now.getTime() - i * ONE_WEEK_MS);
38237
40875
  break;
38238
40876
  }
38239
40877
  cohorts.push(config.cohortFormat(checkDate));
@@ -38399,7 +41037,7 @@ class TTLPlugin extends Plugin {
38399
41037
  return;
38400
41038
  }
38401
41039
  const baseTimestamp = typeof baseTime === "number" ? baseTime : new Date(baseTime).getTime();
38402
- const expiresAt = config.ttl ? new Date(baseTimestamp + config.ttl * 1e3) : new Date(baseTimestamp);
41040
+ const expiresAt = config.ttl ? new Date(baseTimestamp + config.ttl * SECONDS_TO_MS) : new Date(baseTimestamp);
38403
41041
  const cohortConfig = GRANULARITIES[config.granularity];
38404
41042
  const cohort = cohortConfig.cohortFormat(expiresAt);
38405
41043
  const indexId = `${resourceName}:${record.id}`;