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