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