s3db.js 12.2.0 → 12.2.2
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 +1133 -1966
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +1132 -1967
- package/dist/s3db.es.js.map +1 -1
- package/package.json +1 -1
- package/src/plugins/api/index.js +5 -1
- package/src/plugins/geo.plugin.js +1 -1
- package/src/plugins/index.js +2 -1
- package/src/plugins/relation.plugin.js +1 -2
- package/src/plugins/ttl.plugin.js +1 -1
package/dist/s3db.cjs.js
CHANGED
|
@@ -5,9 +5,6 @@ Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
5
5
|
var crypto$1 = require('crypto');
|
|
6
6
|
var nanoid = require('nanoid');
|
|
7
7
|
var EventEmitter = require('events');
|
|
8
|
-
var hono = require('hono');
|
|
9
|
-
var nodeServer = require('@hono/node-server');
|
|
10
|
-
var swaggerUi = require('@hono/swagger-ui');
|
|
11
8
|
var promises = require('fs/promises');
|
|
12
9
|
var fs = require('fs');
|
|
13
10
|
var promises$1 = require('stream/promises');
|
|
@@ -2474,1968 +2471,6 @@ const PluginObject = {
|
|
|
2474
2471
|
}
|
|
2475
2472
|
};
|
|
2476
2473
|
|
|
2477
|
-
function success(data, options = {}) {
|
|
2478
|
-
const { status = 200, meta = {} } = options;
|
|
2479
|
-
return {
|
|
2480
|
-
success: true,
|
|
2481
|
-
data,
|
|
2482
|
-
meta: {
|
|
2483
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2484
|
-
...meta
|
|
2485
|
-
},
|
|
2486
|
-
_status: status
|
|
2487
|
-
};
|
|
2488
|
-
}
|
|
2489
|
-
function error(error2, options = {}) {
|
|
2490
|
-
const { status = 500, code = "INTERNAL_ERROR", details = {} } = options;
|
|
2491
|
-
const errorMessage = error2 instanceof Error ? error2.message : error2;
|
|
2492
|
-
const errorStack = error2 instanceof Error && process.env.NODE_ENV !== "production" ? error2.stack : void 0;
|
|
2493
|
-
return {
|
|
2494
|
-
success: false,
|
|
2495
|
-
error: {
|
|
2496
|
-
message: errorMessage,
|
|
2497
|
-
code,
|
|
2498
|
-
details,
|
|
2499
|
-
stack: errorStack
|
|
2500
|
-
},
|
|
2501
|
-
meta: {
|
|
2502
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2503
|
-
},
|
|
2504
|
-
_status: status
|
|
2505
|
-
};
|
|
2506
|
-
}
|
|
2507
|
-
function list(items, pagination = {}) {
|
|
2508
|
-
const { total, page, pageSize, pageCount } = pagination;
|
|
2509
|
-
return {
|
|
2510
|
-
success: true,
|
|
2511
|
-
data: items,
|
|
2512
|
-
pagination: {
|
|
2513
|
-
total: total || items.length,
|
|
2514
|
-
page: page || 1,
|
|
2515
|
-
pageSize: pageSize || items.length,
|
|
2516
|
-
pageCount: pageCount || 1
|
|
2517
|
-
},
|
|
2518
|
-
meta: {
|
|
2519
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2520
|
-
},
|
|
2521
|
-
_status: 200
|
|
2522
|
-
};
|
|
2523
|
-
}
|
|
2524
|
-
function created(data, location) {
|
|
2525
|
-
return {
|
|
2526
|
-
success: true,
|
|
2527
|
-
data,
|
|
2528
|
-
meta: {
|
|
2529
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2530
|
-
location
|
|
2531
|
-
},
|
|
2532
|
-
_status: 201
|
|
2533
|
-
};
|
|
2534
|
-
}
|
|
2535
|
-
function noContent() {
|
|
2536
|
-
return {
|
|
2537
|
-
success: true,
|
|
2538
|
-
data: null,
|
|
2539
|
-
meta: {
|
|
2540
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2541
|
-
},
|
|
2542
|
-
_status: 204
|
|
2543
|
-
};
|
|
2544
|
-
}
|
|
2545
|
-
function notFound(resource, id) {
|
|
2546
|
-
return error(`${resource} with id '${id}' not found`, {
|
|
2547
|
-
status: 404,
|
|
2548
|
-
code: "NOT_FOUND",
|
|
2549
|
-
details: { resource, id }
|
|
2550
|
-
});
|
|
2551
|
-
}
|
|
2552
|
-
function payloadTooLarge(size, limit) {
|
|
2553
|
-
return error("Request payload too large", {
|
|
2554
|
-
status: 413,
|
|
2555
|
-
code: "PAYLOAD_TOO_LARGE",
|
|
2556
|
-
details: {
|
|
2557
|
-
receivedSize: size,
|
|
2558
|
-
maxSize: limit,
|
|
2559
|
-
receivedMB: (size / 1024 / 1024).toFixed(2),
|
|
2560
|
-
maxMB: (limit / 1024 / 1024).toFixed(2)
|
|
2561
|
-
}
|
|
2562
|
-
});
|
|
2563
|
-
}
|
|
2564
|
-
|
|
2565
|
-
const errorStatusMap = {
|
|
2566
|
-
"ValidationError": 400,
|
|
2567
|
-
"InvalidResourceItem": 400,
|
|
2568
|
-
"ResourceNotFound": 404,
|
|
2569
|
-
"NoSuchKey": 404,
|
|
2570
|
-
"NoSuchBucket": 404,
|
|
2571
|
-
"PartitionError": 400,
|
|
2572
|
-
"CryptoError": 500,
|
|
2573
|
-
"SchemaError": 400,
|
|
2574
|
-
"QueueError": 500,
|
|
2575
|
-
"ResourceError": 500
|
|
2576
|
-
};
|
|
2577
|
-
function getStatusFromError(err) {
|
|
2578
|
-
if (err.name && errorStatusMap[err.name]) {
|
|
2579
|
-
return errorStatusMap[err.name];
|
|
2580
|
-
}
|
|
2581
|
-
if (err.constructor && err.constructor.name && errorStatusMap[err.constructor.name]) {
|
|
2582
|
-
return errorStatusMap[err.constructor.name];
|
|
2583
|
-
}
|
|
2584
|
-
if (err.message) {
|
|
2585
|
-
if (err.message.includes("not found") || err.message.includes("does not exist")) {
|
|
2586
|
-
return 404;
|
|
2587
|
-
}
|
|
2588
|
-
if (err.message.includes("validation") || err.message.includes("invalid")) {
|
|
2589
|
-
return 400;
|
|
2590
|
-
}
|
|
2591
|
-
if (err.message.includes("unauthorized") || err.message.includes("authentication")) {
|
|
2592
|
-
return 401;
|
|
2593
|
-
}
|
|
2594
|
-
if (err.message.includes("forbidden") || err.message.includes("permission")) {
|
|
2595
|
-
return 403;
|
|
2596
|
-
}
|
|
2597
|
-
}
|
|
2598
|
-
return 500;
|
|
2599
|
-
}
|
|
2600
|
-
function errorHandler(err, c) {
|
|
2601
|
-
const status = getStatusFromError(err);
|
|
2602
|
-
const code = err.name || "INTERNAL_ERROR";
|
|
2603
|
-
const details = {};
|
|
2604
|
-
if (err.resource) details.resource = err.resource;
|
|
2605
|
-
if (err.bucket) details.bucket = err.bucket;
|
|
2606
|
-
if (err.key) details.key = err.key;
|
|
2607
|
-
if (err.operation) details.operation = err.operation;
|
|
2608
|
-
if (err.suggestion) details.suggestion = err.suggestion;
|
|
2609
|
-
if (err.availableResources) details.availableResources = err.availableResources;
|
|
2610
|
-
const response = error(err, {
|
|
2611
|
-
status,
|
|
2612
|
-
code,
|
|
2613
|
-
details
|
|
2614
|
-
});
|
|
2615
|
-
if (status >= 500) {
|
|
2616
|
-
console.error("[API Plugin] Error:", {
|
|
2617
|
-
message: err.message,
|
|
2618
|
-
code,
|
|
2619
|
-
status,
|
|
2620
|
-
stack: err.stack,
|
|
2621
|
-
details
|
|
2622
|
-
});
|
|
2623
|
-
} else if (status >= 400 && status < 500 && c.get("verbose")) {
|
|
2624
|
-
console.warn("[API Plugin] Client error:", {
|
|
2625
|
-
message: err.message,
|
|
2626
|
-
code,
|
|
2627
|
-
status,
|
|
2628
|
-
details
|
|
2629
|
-
});
|
|
2630
|
-
}
|
|
2631
|
-
return c.json(response, response._status);
|
|
2632
|
-
}
|
|
2633
|
-
function asyncHandler(fn) {
|
|
2634
|
-
return async (c) => {
|
|
2635
|
-
try {
|
|
2636
|
-
return await fn(c);
|
|
2637
|
-
} catch (err) {
|
|
2638
|
-
return errorHandler(err, c);
|
|
2639
|
-
}
|
|
2640
|
-
};
|
|
2641
|
-
}
|
|
2642
|
-
|
|
2643
|
-
function createResourceRoutes(resource, version, config = {}) {
|
|
2644
|
-
const app = new hono.Hono();
|
|
2645
|
-
const {
|
|
2646
|
-
methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
2647
|
-
customMiddleware = [],
|
|
2648
|
-
enableValidation = true
|
|
2649
|
-
} = config;
|
|
2650
|
-
const resourceName = resource.name;
|
|
2651
|
-
const basePath = `/${version}/${resourceName}`;
|
|
2652
|
-
customMiddleware.forEach((middleware) => {
|
|
2653
|
-
app.use("*", middleware);
|
|
2654
|
-
});
|
|
2655
|
-
if (methods.includes("GET")) {
|
|
2656
|
-
app.get("/", asyncHandler(async (c) => {
|
|
2657
|
-
const query = c.req.query();
|
|
2658
|
-
const limit = parseInt(query.limit) || 100;
|
|
2659
|
-
const offset = parseInt(query.offset) || 0;
|
|
2660
|
-
const partition = query.partition;
|
|
2661
|
-
const partitionValues = query.partitionValues ? JSON.parse(query.partitionValues) : void 0;
|
|
2662
|
-
const reservedKeys = ["limit", "offset", "partition", "partitionValues", "sort"];
|
|
2663
|
-
const filters = {};
|
|
2664
|
-
for (const [key, value] of Object.entries(query)) {
|
|
2665
|
-
if (!reservedKeys.includes(key)) {
|
|
2666
|
-
try {
|
|
2667
|
-
filters[key] = JSON.parse(value);
|
|
2668
|
-
} catch {
|
|
2669
|
-
filters[key] = value;
|
|
2670
|
-
}
|
|
2671
|
-
}
|
|
2672
|
-
}
|
|
2673
|
-
let items;
|
|
2674
|
-
let total;
|
|
2675
|
-
if (Object.keys(filters).length > 0) {
|
|
2676
|
-
items = await resource.query(filters, { limit: limit + offset });
|
|
2677
|
-
items = items.slice(offset, offset + limit);
|
|
2678
|
-
total = items.length;
|
|
2679
|
-
} else if (partition && partitionValues) {
|
|
2680
|
-
items = await resource.listPartition({
|
|
2681
|
-
partition,
|
|
2682
|
-
partitionValues,
|
|
2683
|
-
limit: limit + offset
|
|
2684
|
-
});
|
|
2685
|
-
items = items.slice(offset, offset + limit);
|
|
2686
|
-
total = items.length;
|
|
2687
|
-
} else {
|
|
2688
|
-
items = await resource.list({ limit: limit + offset });
|
|
2689
|
-
items = items.slice(offset, offset + limit);
|
|
2690
|
-
total = items.length;
|
|
2691
|
-
}
|
|
2692
|
-
const response = list(items, {
|
|
2693
|
-
total,
|
|
2694
|
-
page: Math.floor(offset / limit) + 1,
|
|
2695
|
-
pageSize: limit,
|
|
2696
|
-
pageCount: Math.ceil(total / limit)
|
|
2697
|
-
});
|
|
2698
|
-
c.header("X-Total-Count", total.toString());
|
|
2699
|
-
c.header("X-Page-Count", Math.ceil(total / limit).toString());
|
|
2700
|
-
return c.json(response, response._status);
|
|
2701
|
-
}));
|
|
2702
|
-
}
|
|
2703
|
-
if (methods.includes("GET")) {
|
|
2704
|
-
app.get("/:id", asyncHandler(async (c) => {
|
|
2705
|
-
const id = c.req.param("id");
|
|
2706
|
-
const query = c.req.query();
|
|
2707
|
-
const partition = query.partition;
|
|
2708
|
-
const partitionValues = query.partitionValues ? JSON.parse(query.partitionValues) : void 0;
|
|
2709
|
-
let item;
|
|
2710
|
-
if (partition && partitionValues) {
|
|
2711
|
-
item = await resource.getFromPartition({
|
|
2712
|
-
id,
|
|
2713
|
-
partitionName: partition,
|
|
2714
|
-
partitionValues
|
|
2715
|
-
});
|
|
2716
|
-
} else {
|
|
2717
|
-
item = await resource.get(id);
|
|
2718
|
-
}
|
|
2719
|
-
if (!item) {
|
|
2720
|
-
const response2 = notFound(resourceName, id);
|
|
2721
|
-
return c.json(response2, response2._status);
|
|
2722
|
-
}
|
|
2723
|
-
const response = success(item);
|
|
2724
|
-
return c.json(response, response._status);
|
|
2725
|
-
}));
|
|
2726
|
-
}
|
|
2727
|
-
if (methods.includes("POST")) {
|
|
2728
|
-
app.post("/", asyncHandler(async (c) => {
|
|
2729
|
-
const data = await c.req.json();
|
|
2730
|
-
const item = await resource.insert(data);
|
|
2731
|
-
const location = `${basePath}/${item.id}`;
|
|
2732
|
-
const response = created(item, location);
|
|
2733
|
-
c.header("Location", location);
|
|
2734
|
-
return c.json(response, response._status);
|
|
2735
|
-
}));
|
|
2736
|
-
}
|
|
2737
|
-
if (methods.includes("PUT")) {
|
|
2738
|
-
app.put("/:id", asyncHandler(async (c) => {
|
|
2739
|
-
const id = c.req.param("id");
|
|
2740
|
-
const data = await c.req.json();
|
|
2741
|
-
const existing = await resource.get(id);
|
|
2742
|
-
if (!existing) {
|
|
2743
|
-
const response2 = notFound(resourceName, id);
|
|
2744
|
-
return c.json(response2, response2._status);
|
|
2745
|
-
}
|
|
2746
|
-
const updated = await resource.update(id, data);
|
|
2747
|
-
const response = success(updated);
|
|
2748
|
-
return c.json(response, response._status);
|
|
2749
|
-
}));
|
|
2750
|
-
}
|
|
2751
|
-
if (methods.includes("PATCH")) {
|
|
2752
|
-
app.patch("/:id", asyncHandler(async (c) => {
|
|
2753
|
-
const id = c.req.param("id");
|
|
2754
|
-
const data = await c.req.json();
|
|
2755
|
-
const existing = await resource.get(id);
|
|
2756
|
-
if (!existing) {
|
|
2757
|
-
const response2 = notFound(resourceName, id);
|
|
2758
|
-
return c.json(response2, response2._status);
|
|
2759
|
-
}
|
|
2760
|
-
const merged = { ...existing, ...data, id };
|
|
2761
|
-
const updated = await resource.update(id, merged);
|
|
2762
|
-
const response = success(updated);
|
|
2763
|
-
return c.json(response, response._status);
|
|
2764
|
-
}));
|
|
2765
|
-
}
|
|
2766
|
-
if (methods.includes("DELETE")) {
|
|
2767
|
-
app.delete("/:id", asyncHandler(async (c) => {
|
|
2768
|
-
const id = c.req.param("id");
|
|
2769
|
-
const existing = await resource.get(id);
|
|
2770
|
-
if (!existing) {
|
|
2771
|
-
const response2 = notFound(resourceName, id);
|
|
2772
|
-
return c.json(response2, response2._status);
|
|
2773
|
-
}
|
|
2774
|
-
await resource.delete(id);
|
|
2775
|
-
const response = noContent();
|
|
2776
|
-
return c.json(response, response._status);
|
|
2777
|
-
}));
|
|
2778
|
-
}
|
|
2779
|
-
if (methods.includes("HEAD")) {
|
|
2780
|
-
app.on("HEAD", "/", asyncHandler(async (c) => {
|
|
2781
|
-
const total = await resource.count();
|
|
2782
|
-
const allItems = await resource.list({ limit: 1e3 });
|
|
2783
|
-
const stats = {
|
|
2784
|
-
total,
|
|
2785
|
-
version: resource.config?.currentVersion || resource.version || "v1"
|
|
2786
|
-
};
|
|
2787
|
-
c.header("X-Total-Count", total.toString());
|
|
2788
|
-
c.header("X-Resource-Version", stats.version);
|
|
2789
|
-
c.header("X-Schema-Fields", Object.keys(resource.config?.attributes || {}).length.toString());
|
|
2790
|
-
return c.body(null, 200);
|
|
2791
|
-
}));
|
|
2792
|
-
app.on("HEAD", "/:id", asyncHandler(async (c) => {
|
|
2793
|
-
const id = c.req.param("id");
|
|
2794
|
-
const item = await resource.get(id);
|
|
2795
|
-
if (!item) {
|
|
2796
|
-
return c.body(null, 404);
|
|
2797
|
-
}
|
|
2798
|
-
if (item.updatedAt) {
|
|
2799
|
-
c.header("Last-Modified", new Date(item.updatedAt).toUTCString());
|
|
2800
|
-
}
|
|
2801
|
-
return c.body(null, 200);
|
|
2802
|
-
}));
|
|
2803
|
-
}
|
|
2804
|
-
if (methods.includes("OPTIONS")) {
|
|
2805
|
-
app.options("/", asyncHandler(async (c) => {
|
|
2806
|
-
c.header("Allow", methods.join(", "));
|
|
2807
|
-
const total = await resource.count();
|
|
2808
|
-
const schema = resource.config?.attributes || {};
|
|
2809
|
-
const version2 = resource.config?.currentVersion || resource.version || "v1";
|
|
2810
|
-
const metadata = {
|
|
2811
|
-
resource: resourceName,
|
|
2812
|
-
version: version2,
|
|
2813
|
-
totalRecords: total,
|
|
2814
|
-
allowedMethods: methods,
|
|
2815
|
-
schema: Object.entries(schema).map(([name, def]) => ({
|
|
2816
|
-
name,
|
|
2817
|
-
type: typeof def === "string" ? def.split("|")[0] : def.type,
|
|
2818
|
-
rules: typeof def === "string" ? def.split("|").slice(1) : []
|
|
2819
|
-
})),
|
|
2820
|
-
endpoints: {
|
|
2821
|
-
list: `/${version2}/${resourceName}`,
|
|
2822
|
-
get: `/${version2}/${resourceName}/:id`,
|
|
2823
|
-
create: `/${version2}/${resourceName}`,
|
|
2824
|
-
update: `/${version2}/${resourceName}/:id`,
|
|
2825
|
-
delete: `/${version2}/${resourceName}/:id`
|
|
2826
|
-
},
|
|
2827
|
-
queryParameters: {
|
|
2828
|
-
limit: "number (1-1000, default: 100)",
|
|
2829
|
-
offset: "number (min: 0, default: 0)",
|
|
2830
|
-
partition: "string (partition name)",
|
|
2831
|
-
partitionValues: "JSON string",
|
|
2832
|
-
"[any field]": "any (filter by field value)"
|
|
2833
|
-
}
|
|
2834
|
-
};
|
|
2835
|
-
return c.json(metadata);
|
|
2836
|
-
}));
|
|
2837
|
-
app.options("/:id", (c) => {
|
|
2838
|
-
c.header("Allow", methods.filter((m) => m !== "POST").join(", "));
|
|
2839
|
-
return c.body(null, 204);
|
|
2840
|
-
});
|
|
2841
|
-
}
|
|
2842
|
-
return app;
|
|
2843
|
-
}
|
|
2844
|
-
function createRelationalRoutes(sourceResource, relationName, relationConfig, version) {
|
|
2845
|
-
const app = new hono.Hono();
|
|
2846
|
-
const resourceName = sourceResource.name;
|
|
2847
|
-
const relatedResourceName = relationConfig.resource;
|
|
2848
|
-
app.get("/:id", asyncHandler(async (c) => {
|
|
2849
|
-
const id = c.req.param("id");
|
|
2850
|
-
const query = c.req.query();
|
|
2851
|
-
const source = await sourceResource.get(id);
|
|
2852
|
-
if (!source) {
|
|
2853
|
-
const response = notFound(resourceName, id);
|
|
2854
|
-
return c.json(response, response._status);
|
|
2855
|
-
}
|
|
2856
|
-
const result = await sourceResource.get(id, {
|
|
2857
|
-
include: [relationName]
|
|
2858
|
-
});
|
|
2859
|
-
const relatedData = result[relationName];
|
|
2860
|
-
if (!relatedData) {
|
|
2861
|
-
if (relationConfig.type === "hasMany" || relationConfig.type === "belongsToMany") {
|
|
2862
|
-
const response = list([], {
|
|
2863
|
-
total: 0,
|
|
2864
|
-
page: 1,
|
|
2865
|
-
pageSize: 100,
|
|
2866
|
-
pageCount: 0
|
|
2867
|
-
});
|
|
2868
|
-
return c.json(response, response._status);
|
|
2869
|
-
} else {
|
|
2870
|
-
const response = notFound(relatedResourceName, "related resource");
|
|
2871
|
-
return c.json(response, response._status);
|
|
2872
|
-
}
|
|
2873
|
-
}
|
|
2874
|
-
if (relationConfig.type === "hasMany" || relationConfig.type === "belongsToMany") {
|
|
2875
|
-
const items = Array.isArray(relatedData) ? relatedData : [relatedData];
|
|
2876
|
-
const limit = parseInt(query.limit) || 100;
|
|
2877
|
-
const offset = parseInt(query.offset) || 0;
|
|
2878
|
-
const paginatedItems = items.slice(offset, offset + limit);
|
|
2879
|
-
const response = list(paginatedItems, {
|
|
2880
|
-
total: items.length,
|
|
2881
|
-
page: Math.floor(offset / limit) + 1,
|
|
2882
|
-
pageSize: limit,
|
|
2883
|
-
pageCount: Math.ceil(items.length / limit)
|
|
2884
|
-
});
|
|
2885
|
-
c.header("X-Total-Count", items.length.toString());
|
|
2886
|
-
c.header("X-Page-Count", Math.ceil(items.length / limit).toString());
|
|
2887
|
-
return c.json(response, response._status);
|
|
2888
|
-
} else {
|
|
2889
|
-
const response = success(relatedData);
|
|
2890
|
-
return c.json(response, response._status);
|
|
2891
|
-
}
|
|
2892
|
-
}));
|
|
2893
|
-
return app;
|
|
2894
|
-
}
|
|
2895
|
-
|
|
2896
|
-
function mapFieldTypeToOpenAPI(fieldType) {
|
|
2897
|
-
const type = fieldType.split("|")[0].trim();
|
|
2898
|
-
const typeMap = {
|
|
2899
|
-
"string": { type: "string" },
|
|
2900
|
-
"number": { type: "number" },
|
|
2901
|
-
"integer": { type: "integer" },
|
|
2902
|
-
"boolean": { type: "boolean" },
|
|
2903
|
-
"array": { type: "array", items: { type: "string" } },
|
|
2904
|
-
"object": { type: "object" },
|
|
2905
|
-
"json": { type: "object" },
|
|
2906
|
-
"secret": { type: "string", format: "password" },
|
|
2907
|
-
"email": { type: "string", format: "email" },
|
|
2908
|
-
"url": { type: "string", format: "uri" },
|
|
2909
|
-
"date": { type: "string", format: "date" },
|
|
2910
|
-
"datetime": { type: "string", format: "date-time" },
|
|
2911
|
-
"ip4": { type: "string", format: "ipv4", description: "IPv4 address" },
|
|
2912
|
-
"ip6": { type: "string", format: "ipv6", description: "IPv6 address" },
|
|
2913
|
-
"embedding": { type: "array", items: { type: "number" }, description: "Vector embedding" }
|
|
2914
|
-
};
|
|
2915
|
-
if (type.startsWith("embedding:")) {
|
|
2916
|
-
const length = parseInt(type.split(":")[1]);
|
|
2917
|
-
return {
|
|
2918
|
-
type: "array",
|
|
2919
|
-
items: { type: "number" },
|
|
2920
|
-
minItems: length,
|
|
2921
|
-
maxItems: length,
|
|
2922
|
-
description: `Vector embedding (${length} dimensions)`
|
|
2923
|
-
};
|
|
2924
|
-
}
|
|
2925
|
-
return typeMap[type] || { type: "string" };
|
|
2926
|
-
}
|
|
2927
|
-
function extractValidationRules(fieldDef) {
|
|
2928
|
-
const rules = {};
|
|
2929
|
-
const parts = fieldDef.split("|");
|
|
2930
|
-
for (const part of parts) {
|
|
2931
|
-
const [rule, value] = part.split(":").map((s) => s.trim());
|
|
2932
|
-
switch (rule) {
|
|
2933
|
-
case "required":
|
|
2934
|
-
rules.required = true;
|
|
2935
|
-
break;
|
|
2936
|
-
case "min":
|
|
2937
|
-
rules.minimum = parseFloat(value);
|
|
2938
|
-
break;
|
|
2939
|
-
case "max":
|
|
2940
|
-
rules.maximum = parseFloat(value);
|
|
2941
|
-
break;
|
|
2942
|
-
case "minlength":
|
|
2943
|
-
rules.minLength = parseInt(value);
|
|
2944
|
-
break;
|
|
2945
|
-
case "maxlength":
|
|
2946
|
-
rules.maxLength = parseInt(value);
|
|
2947
|
-
break;
|
|
2948
|
-
case "pattern":
|
|
2949
|
-
rules.pattern = value;
|
|
2950
|
-
break;
|
|
2951
|
-
case "enum":
|
|
2952
|
-
rules.enum = value.split(",").map((v) => v.trim());
|
|
2953
|
-
break;
|
|
2954
|
-
case "default":
|
|
2955
|
-
rules.default = value;
|
|
2956
|
-
break;
|
|
2957
|
-
}
|
|
2958
|
-
}
|
|
2959
|
-
return rules;
|
|
2960
|
-
}
|
|
2961
|
-
function generateResourceSchema(resource) {
|
|
2962
|
-
const properties = {};
|
|
2963
|
-
const required = [];
|
|
2964
|
-
const attributes = resource.config?.attributes || resource.attributes || {};
|
|
2965
|
-
const resourceDescription = resource.config?.description;
|
|
2966
|
-
const attributeDescriptions = typeof resourceDescription === "object" ? resourceDescription.attributes || {} : {};
|
|
2967
|
-
properties.id = {
|
|
2968
|
-
type: "string",
|
|
2969
|
-
description: "Unique identifier for the resource",
|
|
2970
|
-
example: "2_gDTpeU6EI0e8B92n_R3Y",
|
|
2971
|
-
readOnly: true
|
|
2972
|
-
};
|
|
2973
|
-
for (const [fieldName, fieldDef] of Object.entries(attributes)) {
|
|
2974
|
-
if (typeof fieldDef === "object" && fieldDef.type) {
|
|
2975
|
-
const baseType = mapFieldTypeToOpenAPI(fieldDef.type);
|
|
2976
|
-
properties[fieldName] = {
|
|
2977
|
-
...baseType,
|
|
2978
|
-
description: fieldDef.description || attributeDescriptions[fieldName] || void 0
|
|
2979
|
-
};
|
|
2980
|
-
if (fieldDef.required) {
|
|
2981
|
-
required.push(fieldName);
|
|
2982
|
-
}
|
|
2983
|
-
if (fieldDef.type === "object" && fieldDef.props) {
|
|
2984
|
-
properties[fieldName].properties = {};
|
|
2985
|
-
for (const [propName, propDef] of Object.entries(fieldDef.props)) {
|
|
2986
|
-
const propType = typeof propDef === "string" ? propDef : propDef.type;
|
|
2987
|
-
properties[fieldName].properties[propName] = mapFieldTypeToOpenAPI(propType);
|
|
2988
|
-
}
|
|
2989
|
-
}
|
|
2990
|
-
if (fieldDef.type === "array" && fieldDef.items) {
|
|
2991
|
-
properties[fieldName].items = mapFieldTypeToOpenAPI(fieldDef.items);
|
|
2992
|
-
}
|
|
2993
|
-
} else if (typeof fieldDef === "string") {
|
|
2994
|
-
const baseType = mapFieldTypeToOpenAPI(fieldDef);
|
|
2995
|
-
const rules = extractValidationRules(fieldDef);
|
|
2996
|
-
properties[fieldName] = {
|
|
2997
|
-
...baseType,
|
|
2998
|
-
...rules,
|
|
2999
|
-
description: attributeDescriptions[fieldName] || void 0
|
|
3000
|
-
};
|
|
3001
|
-
if (rules.required) {
|
|
3002
|
-
required.push(fieldName);
|
|
3003
|
-
delete properties[fieldName].required;
|
|
3004
|
-
}
|
|
3005
|
-
}
|
|
3006
|
-
}
|
|
3007
|
-
return {
|
|
3008
|
-
type: "object",
|
|
3009
|
-
properties,
|
|
3010
|
-
required: required.length > 0 ? required : void 0
|
|
3011
|
-
};
|
|
3012
|
-
}
|
|
3013
|
-
function generateResourcePaths(resource, version, config = {}) {
|
|
3014
|
-
const resourceName = resource.name;
|
|
3015
|
-
const basePath = `/${version}/${resourceName}`;
|
|
3016
|
-
const schema = generateResourceSchema(resource);
|
|
3017
|
-
const methods = config.methods || ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
3018
|
-
const authMethods = config.auth || [];
|
|
3019
|
-
const requiresAuth = authMethods && authMethods.length > 0;
|
|
3020
|
-
const paths = {};
|
|
3021
|
-
const security = [];
|
|
3022
|
-
if (requiresAuth) {
|
|
3023
|
-
if (authMethods.includes("jwt")) security.push({ bearerAuth: [] });
|
|
3024
|
-
if (authMethods.includes("apiKey")) security.push({ apiKeyAuth: [] });
|
|
3025
|
-
if (authMethods.includes("basic")) security.push({ basicAuth: [] });
|
|
3026
|
-
}
|
|
3027
|
-
const partitions = resource.config?.options?.partitions || resource.config?.partitions || resource.partitions || {};
|
|
3028
|
-
const partitionNames = Object.keys(partitions);
|
|
3029
|
-
const hasPartitions = partitionNames.length > 0;
|
|
3030
|
-
let partitionDescription = "Partition name for filtering";
|
|
3031
|
-
let partitionValuesDescription = "Partition values as JSON string";
|
|
3032
|
-
let partitionExample = void 0;
|
|
3033
|
-
let partitionValuesExample = void 0;
|
|
3034
|
-
if (hasPartitions) {
|
|
3035
|
-
const partitionDocs = partitionNames.map((name) => {
|
|
3036
|
-
const partition = partitions[name];
|
|
3037
|
-
const fields = Object.keys(partition.fields || {});
|
|
3038
|
-
const fieldTypes = Object.entries(partition.fields || {}).map(([field, type]) => `${field}: ${type}`).join(", ");
|
|
3039
|
-
return `- **${name}**: Filters by ${fields.join(", ")} (${fieldTypes})`;
|
|
3040
|
-
}).join("\n");
|
|
3041
|
-
partitionDescription = `Available partitions:
|
|
3042
|
-
${partitionDocs}`;
|
|
3043
|
-
const examplePartition = partitionNames[0];
|
|
3044
|
-
const exampleFields = partitions[examplePartition]?.fields || {};
|
|
3045
|
-
Object.entries(exampleFields).map(([field, type]) => `"${field}": <${type} value>`).join(", ");
|
|
3046
|
-
partitionValuesDescription = `Partition field values as JSON string. Must match the structure of the selected partition.
|
|
3047
|
-
|
|
3048
|
-
Example for "${examplePartition}" partition: \`{"${Object.keys(exampleFields)[0]}": "value"}\``;
|
|
3049
|
-
partitionExample = examplePartition;
|
|
3050
|
-
const firstField = Object.keys(exampleFields)[0];
|
|
3051
|
-
const firstFieldType = exampleFields[firstField];
|
|
3052
|
-
let exampleValue = "example";
|
|
3053
|
-
if (firstFieldType === "number" || firstFieldType === "integer") {
|
|
3054
|
-
exampleValue = 123;
|
|
3055
|
-
} else if (firstFieldType === "boolean") {
|
|
3056
|
-
exampleValue = true;
|
|
3057
|
-
}
|
|
3058
|
-
partitionValuesExample = JSON.stringify({ [firstField]: exampleValue });
|
|
3059
|
-
}
|
|
3060
|
-
const attributeQueryParams = [];
|
|
3061
|
-
if (hasPartitions) {
|
|
3062
|
-
const partitionFieldsSet = /* @__PURE__ */ new Set();
|
|
3063
|
-
for (const [partitionName, partition] of Object.entries(partitions)) {
|
|
3064
|
-
const fields = partition.fields || {};
|
|
3065
|
-
for (const fieldName of Object.keys(fields)) {
|
|
3066
|
-
partitionFieldsSet.add(fieldName);
|
|
3067
|
-
}
|
|
3068
|
-
}
|
|
3069
|
-
const attributes = resource.config?.attributes || resource.attributes || {};
|
|
3070
|
-
for (const fieldName of partitionFieldsSet) {
|
|
3071
|
-
const fieldDef = attributes[fieldName];
|
|
3072
|
-
if (!fieldDef) continue;
|
|
3073
|
-
let fieldType;
|
|
3074
|
-
if (typeof fieldDef === "object" && fieldDef.type) {
|
|
3075
|
-
fieldType = fieldDef.type;
|
|
3076
|
-
} else if (typeof fieldDef === "string") {
|
|
3077
|
-
fieldType = fieldDef.split("|")[0].trim();
|
|
3078
|
-
} else {
|
|
3079
|
-
fieldType = "string";
|
|
3080
|
-
}
|
|
3081
|
-
const openAPIType = mapFieldTypeToOpenAPI(fieldType);
|
|
3082
|
-
attributeQueryParams.push({
|
|
3083
|
-
name: fieldName,
|
|
3084
|
-
in: "query",
|
|
3085
|
-
description: `Filter by ${fieldName} field (indexed via partitions for efficient querying). Value will be parsed as JSON if possible, otherwise treated as string.`,
|
|
3086
|
-
required: false,
|
|
3087
|
-
schema: openAPIType
|
|
3088
|
-
});
|
|
3089
|
-
}
|
|
3090
|
-
}
|
|
3091
|
-
if (methods.includes("GET")) {
|
|
3092
|
-
paths[basePath] = {
|
|
3093
|
-
get: {
|
|
3094
|
-
tags: [resourceName],
|
|
3095
|
-
summary: `List ${resourceName}`,
|
|
3096
|
-
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.
|
|
3097
|
-
|
|
3098
|
-
**Pagination**: Use \`limit\` and \`offset\` to paginate results. For example:
|
|
3099
|
-
- First page (10 items): \`?limit=10&offset=0\`
|
|
3100
|
-
- Second page: \`?limit=10&offset=10\`
|
|
3101
|
-
- Third page: \`?limit=10&offset=20\`
|
|
3102
|
-
|
|
3103
|
-
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." : ""}`,
|
|
3104
|
-
parameters: [
|
|
3105
|
-
{
|
|
3106
|
-
name: "limit",
|
|
3107
|
-
in: "query",
|
|
3108
|
-
description: "Maximum number of items to return per page (page size)",
|
|
3109
|
-
schema: { type: "integer", default: 100, minimum: 1, maximum: 1e3 },
|
|
3110
|
-
example: 10
|
|
3111
|
-
},
|
|
3112
|
-
{
|
|
3113
|
-
name: "offset",
|
|
3114
|
-
in: "query",
|
|
3115
|
-
description: "Number of items to skip before starting to return results. Use for pagination: offset = (page - 1) * limit",
|
|
3116
|
-
schema: { type: "integer", default: 0, minimum: 0 },
|
|
3117
|
-
example: 0
|
|
3118
|
-
},
|
|
3119
|
-
...hasPartitions ? [
|
|
3120
|
-
{
|
|
3121
|
-
name: "partition",
|
|
3122
|
-
in: "query",
|
|
3123
|
-
description: partitionDescription,
|
|
3124
|
-
schema: {
|
|
3125
|
-
type: "string",
|
|
3126
|
-
enum: partitionNames
|
|
3127
|
-
},
|
|
3128
|
-
example: partitionExample
|
|
3129
|
-
},
|
|
3130
|
-
{
|
|
3131
|
-
name: "partitionValues",
|
|
3132
|
-
in: "query",
|
|
3133
|
-
description: partitionValuesDescription,
|
|
3134
|
-
schema: { type: "string" },
|
|
3135
|
-
example: partitionValuesExample
|
|
3136
|
-
}
|
|
3137
|
-
] : [],
|
|
3138
|
-
...attributeQueryParams
|
|
3139
|
-
],
|
|
3140
|
-
responses: {
|
|
3141
|
-
200: {
|
|
3142
|
-
description: "Successful response",
|
|
3143
|
-
content: {
|
|
3144
|
-
"application/json": {
|
|
3145
|
-
schema: {
|
|
3146
|
-
type: "object",
|
|
3147
|
-
properties: {
|
|
3148
|
-
success: { type: "boolean", example: true },
|
|
3149
|
-
data: {
|
|
3150
|
-
type: "array",
|
|
3151
|
-
items: schema
|
|
3152
|
-
},
|
|
3153
|
-
pagination: {
|
|
3154
|
-
type: "object",
|
|
3155
|
-
description: "Pagination metadata for the current request",
|
|
3156
|
-
properties: {
|
|
3157
|
-
total: {
|
|
3158
|
-
type: "integer",
|
|
3159
|
-
description: "Total number of items available",
|
|
3160
|
-
example: 150
|
|
3161
|
-
},
|
|
3162
|
-
page: {
|
|
3163
|
-
type: "integer",
|
|
3164
|
-
description: "Current page number (1-indexed)",
|
|
3165
|
-
example: 1
|
|
3166
|
-
},
|
|
3167
|
-
pageSize: {
|
|
3168
|
-
type: "integer",
|
|
3169
|
-
description: "Number of items per page (same as limit parameter)",
|
|
3170
|
-
example: 10
|
|
3171
|
-
},
|
|
3172
|
-
pageCount: {
|
|
3173
|
-
type: "integer",
|
|
3174
|
-
description: "Total number of pages available",
|
|
3175
|
-
example: 15
|
|
3176
|
-
}
|
|
3177
|
-
}
|
|
3178
|
-
}
|
|
3179
|
-
}
|
|
3180
|
-
}
|
|
3181
|
-
}
|
|
3182
|
-
},
|
|
3183
|
-
headers: {
|
|
3184
|
-
"X-Total-Count": {
|
|
3185
|
-
description: "Total number of records",
|
|
3186
|
-
schema: { type: "integer" }
|
|
3187
|
-
},
|
|
3188
|
-
"X-Page-Count": {
|
|
3189
|
-
description: "Total number of pages",
|
|
3190
|
-
schema: { type: "integer" }
|
|
3191
|
-
}
|
|
3192
|
-
}
|
|
3193
|
-
}
|
|
3194
|
-
},
|
|
3195
|
-
security: security.length > 0 ? security : void 0
|
|
3196
|
-
}
|
|
3197
|
-
};
|
|
3198
|
-
}
|
|
3199
|
-
if (methods.includes("GET")) {
|
|
3200
|
-
paths[`${basePath}/{id}`] = {
|
|
3201
|
-
get: {
|
|
3202
|
-
tags: [resourceName],
|
|
3203
|
-
summary: `Get ${resourceName} by ID`,
|
|
3204
|
-
description: `Retrieve a single ${resourceName} by its ID${hasPartitions ? ". Optionally specify a partition for more efficient retrieval." : ""}`,
|
|
3205
|
-
parameters: [
|
|
3206
|
-
{
|
|
3207
|
-
name: "id",
|
|
3208
|
-
in: "path",
|
|
3209
|
-
required: true,
|
|
3210
|
-
description: `${resourceName} ID`,
|
|
3211
|
-
schema: { type: "string" }
|
|
3212
|
-
},
|
|
3213
|
-
...hasPartitions ? [
|
|
3214
|
-
{
|
|
3215
|
-
name: "partition",
|
|
3216
|
-
in: "query",
|
|
3217
|
-
description: partitionDescription,
|
|
3218
|
-
schema: {
|
|
3219
|
-
type: "string",
|
|
3220
|
-
enum: partitionNames
|
|
3221
|
-
},
|
|
3222
|
-
example: partitionExample
|
|
3223
|
-
},
|
|
3224
|
-
{
|
|
3225
|
-
name: "partitionValues",
|
|
3226
|
-
in: "query",
|
|
3227
|
-
description: partitionValuesDescription,
|
|
3228
|
-
schema: { type: "string" },
|
|
3229
|
-
example: partitionValuesExample
|
|
3230
|
-
}
|
|
3231
|
-
] : []
|
|
3232
|
-
],
|
|
3233
|
-
responses: {
|
|
3234
|
-
200: {
|
|
3235
|
-
description: "Successful response",
|
|
3236
|
-
content: {
|
|
3237
|
-
"application/json": {
|
|
3238
|
-
schema: {
|
|
3239
|
-
type: "object",
|
|
3240
|
-
properties: {
|
|
3241
|
-
success: { type: "boolean", example: true },
|
|
3242
|
-
data: schema
|
|
3243
|
-
}
|
|
3244
|
-
}
|
|
3245
|
-
}
|
|
3246
|
-
}
|
|
3247
|
-
},
|
|
3248
|
-
404: {
|
|
3249
|
-
description: "Resource not found",
|
|
3250
|
-
content: {
|
|
3251
|
-
"application/json": {
|
|
3252
|
-
schema: { $ref: "#/components/schemas/Error" }
|
|
3253
|
-
}
|
|
3254
|
-
}
|
|
3255
|
-
}
|
|
3256
|
-
},
|
|
3257
|
-
security: security.length > 0 ? security : void 0
|
|
3258
|
-
}
|
|
3259
|
-
};
|
|
3260
|
-
}
|
|
3261
|
-
if (methods.includes("POST")) {
|
|
3262
|
-
if (!paths[basePath]) paths[basePath] = {};
|
|
3263
|
-
paths[basePath].post = {
|
|
3264
|
-
tags: [resourceName],
|
|
3265
|
-
summary: `Create ${resourceName}`,
|
|
3266
|
-
description: `Create a new ${resourceName}`,
|
|
3267
|
-
requestBody: {
|
|
3268
|
-
required: true,
|
|
3269
|
-
content: {
|
|
3270
|
-
"application/json": {
|
|
3271
|
-
schema
|
|
3272
|
-
}
|
|
3273
|
-
}
|
|
3274
|
-
},
|
|
3275
|
-
responses: {
|
|
3276
|
-
201: {
|
|
3277
|
-
description: "Resource created successfully",
|
|
3278
|
-
content: {
|
|
3279
|
-
"application/json": {
|
|
3280
|
-
schema: {
|
|
3281
|
-
type: "object",
|
|
3282
|
-
properties: {
|
|
3283
|
-
success: { type: "boolean", example: true },
|
|
3284
|
-
data: schema
|
|
3285
|
-
}
|
|
3286
|
-
}
|
|
3287
|
-
}
|
|
3288
|
-
},
|
|
3289
|
-
headers: {
|
|
3290
|
-
Location: {
|
|
3291
|
-
description: "URL of the created resource",
|
|
3292
|
-
schema: { type: "string" }
|
|
3293
|
-
}
|
|
3294
|
-
}
|
|
3295
|
-
},
|
|
3296
|
-
400: {
|
|
3297
|
-
description: "Validation error",
|
|
3298
|
-
content: {
|
|
3299
|
-
"application/json": {
|
|
3300
|
-
schema: { $ref: "#/components/schemas/ValidationError" }
|
|
3301
|
-
}
|
|
3302
|
-
}
|
|
3303
|
-
}
|
|
3304
|
-
},
|
|
3305
|
-
security: security.length > 0 ? security : void 0
|
|
3306
|
-
};
|
|
3307
|
-
}
|
|
3308
|
-
if (methods.includes("PUT")) {
|
|
3309
|
-
if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
|
|
3310
|
-
paths[`${basePath}/{id}`].put = {
|
|
3311
|
-
tags: [resourceName],
|
|
3312
|
-
summary: `Update ${resourceName} (full)`,
|
|
3313
|
-
description: `Fully update a ${resourceName}`,
|
|
3314
|
-
parameters: [
|
|
3315
|
-
{
|
|
3316
|
-
name: "id",
|
|
3317
|
-
in: "path",
|
|
3318
|
-
required: true,
|
|
3319
|
-
schema: { type: "string" }
|
|
3320
|
-
}
|
|
3321
|
-
],
|
|
3322
|
-
requestBody: {
|
|
3323
|
-
required: true,
|
|
3324
|
-
content: {
|
|
3325
|
-
"application/json": {
|
|
3326
|
-
schema
|
|
3327
|
-
}
|
|
3328
|
-
}
|
|
3329
|
-
},
|
|
3330
|
-
responses: {
|
|
3331
|
-
200: {
|
|
3332
|
-
description: "Resource updated successfully",
|
|
3333
|
-
content: {
|
|
3334
|
-
"application/json": {
|
|
3335
|
-
schema: {
|
|
3336
|
-
type: "object",
|
|
3337
|
-
properties: {
|
|
3338
|
-
success: { type: "boolean", example: true },
|
|
3339
|
-
data: schema
|
|
3340
|
-
}
|
|
3341
|
-
}
|
|
3342
|
-
}
|
|
3343
|
-
}
|
|
3344
|
-
},
|
|
3345
|
-
404: {
|
|
3346
|
-
description: "Resource not found",
|
|
3347
|
-
content: {
|
|
3348
|
-
"application/json": {
|
|
3349
|
-
schema: { $ref: "#/components/schemas/Error" }
|
|
3350
|
-
}
|
|
3351
|
-
}
|
|
3352
|
-
}
|
|
3353
|
-
},
|
|
3354
|
-
security: security.length > 0 ? security : void 0
|
|
3355
|
-
};
|
|
3356
|
-
}
|
|
3357
|
-
if (methods.includes("PATCH")) {
|
|
3358
|
-
if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
|
|
3359
|
-
paths[`${basePath}/{id}`].patch = {
|
|
3360
|
-
tags: [resourceName],
|
|
3361
|
-
summary: `Update ${resourceName} (partial)`,
|
|
3362
|
-
description: `Partially update a ${resourceName}`,
|
|
3363
|
-
parameters: [
|
|
3364
|
-
{
|
|
3365
|
-
name: "id",
|
|
3366
|
-
in: "path",
|
|
3367
|
-
required: true,
|
|
3368
|
-
schema: { type: "string" }
|
|
3369
|
-
}
|
|
3370
|
-
],
|
|
3371
|
-
requestBody: {
|
|
3372
|
-
required: true,
|
|
3373
|
-
content: {
|
|
3374
|
-
"application/json": {
|
|
3375
|
-
schema: {
|
|
3376
|
-
...schema,
|
|
3377
|
-
required: void 0
|
|
3378
|
-
// Partial updates don't require all fields
|
|
3379
|
-
}
|
|
3380
|
-
}
|
|
3381
|
-
}
|
|
3382
|
-
},
|
|
3383
|
-
responses: {
|
|
3384
|
-
200: {
|
|
3385
|
-
description: "Resource updated successfully",
|
|
3386
|
-
content: {
|
|
3387
|
-
"application/json": {
|
|
3388
|
-
schema: {
|
|
3389
|
-
type: "object",
|
|
3390
|
-
properties: {
|
|
3391
|
-
success: { type: "boolean", example: true },
|
|
3392
|
-
data: schema
|
|
3393
|
-
}
|
|
3394
|
-
}
|
|
3395
|
-
}
|
|
3396
|
-
}
|
|
3397
|
-
},
|
|
3398
|
-
404: {
|
|
3399
|
-
description: "Resource not found",
|
|
3400
|
-
content: {
|
|
3401
|
-
"application/json": {
|
|
3402
|
-
schema: { $ref: "#/components/schemas/Error" }
|
|
3403
|
-
}
|
|
3404
|
-
}
|
|
3405
|
-
}
|
|
3406
|
-
},
|
|
3407
|
-
security: security.length > 0 ? security : void 0
|
|
3408
|
-
};
|
|
3409
|
-
}
|
|
3410
|
-
if (methods.includes("DELETE")) {
|
|
3411
|
-
if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
|
|
3412
|
-
paths[`${basePath}/{id}`].delete = {
|
|
3413
|
-
tags: [resourceName],
|
|
3414
|
-
summary: `Delete ${resourceName}`,
|
|
3415
|
-
description: `Delete a ${resourceName} by ID`,
|
|
3416
|
-
parameters: [
|
|
3417
|
-
{
|
|
3418
|
-
name: "id",
|
|
3419
|
-
in: "path",
|
|
3420
|
-
required: true,
|
|
3421
|
-
schema: { type: "string" }
|
|
3422
|
-
}
|
|
3423
|
-
],
|
|
3424
|
-
responses: {
|
|
3425
|
-
204: {
|
|
3426
|
-
description: "Resource deleted successfully"
|
|
3427
|
-
},
|
|
3428
|
-
404: {
|
|
3429
|
-
description: "Resource not found",
|
|
3430
|
-
content: {
|
|
3431
|
-
"application/json": {
|
|
3432
|
-
schema: { $ref: "#/components/schemas/Error" }
|
|
3433
|
-
}
|
|
3434
|
-
}
|
|
3435
|
-
}
|
|
3436
|
-
},
|
|
3437
|
-
security: security.length > 0 ? security : void 0
|
|
3438
|
-
};
|
|
3439
|
-
}
|
|
3440
|
-
if (methods.includes("HEAD")) {
|
|
3441
|
-
if (!paths[basePath]) paths[basePath] = {};
|
|
3442
|
-
paths[basePath].head = {
|
|
3443
|
-
tags: [resourceName],
|
|
3444
|
-
summary: `Get ${resourceName} statistics`,
|
|
3445
|
-
description: `Get statistics about ${resourceName} collection without retrieving data. Returns statistics in response headers.`,
|
|
3446
|
-
responses: {
|
|
3447
|
-
200: {
|
|
3448
|
-
description: "Statistics retrieved successfully",
|
|
3449
|
-
headers: {
|
|
3450
|
-
"X-Total-Count": {
|
|
3451
|
-
description: "Total number of records",
|
|
3452
|
-
schema: { type: "integer" }
|
|
3453
|
-
},
|
|
3454
|
-
"X-Resource-Version": {
|
|
3455
|
-
description: "Current resource version",
|
|
3456
|
-
schema: { type: "string" }
|
|
3457
|
-
},
|
|
3458
|
-
"X-Schema-Fields": {
|
|
3459
|
-
description: "Number of schema fields",
|
|
3460
|
-
schema: { type: "integer" }
|
|
3461
|
-
}
|
|
3462
|
-
}
|
|
3463
|
-
}
|
|
3464
|
-
},
|
|
3465
|
-
security: security.length > 0 ? security : void 0
|
|
3466
|
-
};
|
|
3467
|
-
if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
|
|
3468
|
-
paths[`${basePath}/{id}`].head = {
|
|
3469
|
-
tags: [resourceName],
|
|
3470
|
-
summary: `Check if ${resourceName} exists`,
|
|
3471
|
-
description: `Check if a ${resourceName} exists without retrieving its data`,
|
|
3472
|
-
parameters: [
|
|
3473
|
-
{
|
|
3474
|
-
name: "id",
|
|
3475
|
-
in: "path",
|
|
3476
|
-
required: true,
|
|
3477
|
-
schema: { type: "string" }
|
|
3478
|
-
}
|
|
3479
|
-
],
|
|
3480
|
-
responses: {
|
|
3481
|
-
200: {
|
|
3482
|
-
description: "Resource exists",
|
|
3483
|
-
headers: {
|
|
3484
|
-
"Last-Modified": {
|
|
3485
|
-
description: "Last modification date",
|
|
3486
|
-
schema: { type: "string", format: "date-time" }
|
|
3487
|
-
}
|
|
3488
|
-
}
|
|
3489
|
-
},
|
|
3490
|
-
404: {
|
|
3491
|
-
description: "Resource not found"
|
|
3492
|
-
}
|
|
3493
|
-
},
|
|
3494
|
-
security: security.length > 0 ? security : void 0
|
|
3495
|
-
};
|
|
3496
|
-
}
|
|
3497
|
-
if (methods.includes("OPTIONS")) {
|
|
3498
|
-
if (!paths[basePath]) paths[basePath] = {};
|
|
3499
|
-
paths[basePath].options = {
|
|
3500
|
-
tags: [resourceName],
|
|
3501
|
-
summary: `Get ${resourceName} metadata`,
|
|
3502
|
-
description: `Get complete metadata about ${resourceName} resource including schema, allowed methods, endpoints, and query parameters`,
|
|
3503
|
-
responses: {
|
|
3504
|
-
200: {
|
|
3505
|
-
description: "Metadata retrieved successfully",
|
|
3506
|
-
headers: {
|
|
3507
|
-
"Allow": {
|
|
3508
|
-
description: "Allowed HTTP methods",
|
|
3509
|
-
schema: { type: "string", example: "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS" }
|
|
3510
|
-
}
|
|
3511
|
-
},
|
|
3512
|
-
content: {
|
|
3513
|
-
"application/json": {
|
|
3514
|
-
schema: {
|
|
3515
|
-
type: "object",
|
|
3516
|
-
properties: {
|
|
3517
|
-
resource: { type: "string" },
|
|
3518
|
-
version: { type: "string" },
|
|
3519
|
-
totalRecords: { type: "integer" },
|
|
3520
|
-
allowedMethods: {
|
|
3521
|
-
type: "array",
|
|
3522
|
-
items: { type: "string" }
|
|
3523
|
-
},
|
|
3524
|
-
schema: {
|
|
3525
|
-
type: "array",
|
|
3526
|
-
items: {
|
|
3527
|
-
type: "object",
|
|
3528
|
-
properties: {
|
|
3529
|
-
name: { type: "string" },
|
|
3530
|
-
type: { type: "string" },
|
|
3531
|
-
rules: { type: "array", items: { type: "string" } }
|
|
3532
|
-
}
|
|
3533
|
-
}
|
|
3534
|
-
},
|
|
3535
|
-
endpoints: {
|
|
3536
|
-
type: "object",
|
|
3537
|
-
properties: {
|
|
3538
|
-
list: { type: "string" },
|
|
3539
|
-
get: { type: "string" },
|
|
3540
|
-
create: { type: "string" },
|
|
3541
|
-
update: { type: "string" },
|
|
3542
|
-
delete: { type: "string" }
|
|
3543
|
-
}
|
|
3544
|
-
},
|
|
3545
|
-
queryParameters: { type: "object" }
|
|
3546
|
-
}
|
|
3547
|
-
}
|
|
3548
|
-
}
|
|
3549
|
-
}
|
|
3550
|
-
}
|
|
3551
|
-
}
|
|
3552
|
-
};
|
|
3553
|
-
if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
|
|
3554
|
-
paths[`${basePath}/{id}`].options = {
|
|
3555
|
-
tags: [resourceName],
|
|
3556
|
-
summary: `Get allowed methods for ${resourceName} item`,
|
|
3557
|
-
description: `Get allowed HTTP methods for individual ${resourceName} operations`,
|
|
3558
|
-
parameters: [
|
|
3559
|
-
{
|
|
3560
|
-
name: "id",
|
|
3561
|
-
in: "path",
|
|
3562
|
-
required: true,
|
|
3563
|
-
schema: { type: "string" }
|
|
3564
|
-
}
|
|
3565
|
-
],
|
|
3566
|
-
responses: {
|
|
3567
|
-
204: {
|
|
3568
|
-
description: "Methods retrieved successfully",
|
|
3569
|
-
headers: {
|
|
3570
|
-
"Allow": {
|
|
3571
|
-
description: "Allowed HTTP methods",
|
|
3572
|
-
schema: { type: "string", example: "GET, PUT, PATCH, DELETE, HEAD, OPTIONS" }
|
|
3573
|
-
}
|
|
3574
|
-
}
|
|
3575
|
-
}
|
|
3576
|
-
}
|
|
3577
|
-
};
|
|
3578
|
-
}
|
|
3579
|
-
return paths;
|
|
3580
|
-
}
|
|
3581
|
-
function generateRelationalPaths(resource, relationName, relationConfig, version, relatedSchema) {
|
|
3582
|
-
const resourceName = resource.name;
|
|
3583
|
-
const basePath = `/${version}/${resourceName}/{id}/${relationName}`;
|
|
3584
|
-
relationConfig.resource;
|
|
3585
|
-
const isToMany = relationConfig.type === "hasMany" || relationConfig.type === "belongsToMany";
|
|
3586
|
-
const paths = {};
|
|
3587
|
-
paths[basePath] = {
|
|
3588
|
-
get: {
|
|
3589
|
-
tags: [resourceName],
|
|
3590
|
-
summary: `Get ${relationName} of ${resourceName}`,
|
|
3591
|
-
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.` : "."),
|
|
3592
|
-
parameters: [
|
|
3593
|
-
{
|
|
3594
|
-
name: "id",
|
|
3595
|
-
in: "path",
|
|
3596
|
-
required: true,
|
|
3597
|
-
description: `${resourceName} ID`,
|
|
3598
|
-
schema: { type: "string" }
|
|
3599
|
-
},
|
|
3600
|
-
...isToMany ? [
|
|
3601
|
-
{
|
|
3602
|
-
name: "limit",
|
|
3603
|
-
in: "query",
|
|
3604
|
-
description: "Maximum number of items to return",
|
|
3605
|
-
schema: { type: "integer", default: 100, minimum: 1, maximum: 1e3 }
|
|
3606
|
-
},
|
|
3607
|
-
{
|
|
3608
|
-
name: "offset",
|
|
3609
|
-
in: "query",
|
|
3610
|
-
description: "Number of items to skip",
|
|
3611
|
-
schema: { type: "integer", default: 0, minimum: 0 }
|
|
3612
|
-
}
|
|
3613
|
-
] : []
|
|
3614
|
-
],
|
|
3615
|
-
responses: {
|
|
3616
|
-
200: {
|
|
3617
|
-
description: "Successful response",
|
|
3618
|
-
content: {
|
|
3619
|
-
"application/json": {
|
|
3620
|
-
schema: isToMany ? {
|
|
3621
|
-
type: "object",
|
|
3622
|
-
properties: {
|
|
3623
|
-
success: { type: "boolean", example: true },
|
|
3624
|
-
data: {
|
|
3625
|
-
type: "array",
|
|
3626
|
-
items: relatedSchema
|
|
3627
|
-
},
|
|
3628
|
-
pagination: {
|
|
3629
|
-
type: "object",
|
|
3630
|
-
properties: {
|
|
3631
|
-
total: { type: "integer" },
|
|
3632
|
-
page: { type: "integer" },
|
|
3633
|
-
pageSize: { type: "integer" },
|
|
3634
|
-
pageCount: { type: "integer" }
|
|
3635
|
-
}
|
|
3636
|
-
}
|
|
3637
|
-
}
|
|
3638
|
-
} : {
|
|
3639
|
-
type: "object",
|
|
3640
|
-
properties: {
|
|
3641
|
-
success: { type: "boolean", example: true },
|
|
3642
|
-
data: relatedSchema
|
|
3643
|
-
}
|
|
3644
|
-
}
|
|
3645
|
-
}
|
|
3646
|
-
},
|
|
3647
|
-
...isToMany ? {
|
|
3648
|
-
headers: {
|
|
3649
|
-
"X-Total-Count": {
|
|
3650
|
-
description: "Total number of related records",
|
|
3651
|
-
schema: { type: "integer" }
|
|
3652
|
-
},
|
|
3653
|
-
"X-Page-Count": {
|
|
3654
|
-
description: "Total number of pages",
|
|
3655
|
-
schema: { type: "integer" }
|
|
3656
|
-
}
|
|
3657
|
-
}
|
|
3658
|
-
} : {}
|
|
3659
|
-
},
|
|
3660
|
-
404: {
|
|
3661
|
-
description: `${resourceName} not found` + (isToMany ? "" : " or no related resource exists"),
|
|
3662
|
-
content: {
|
|
3663
|
-
"application/json": {
|
|
3664
|
-
schema: { $ref: "#/components/schemas/Error" }
|
|
3665
|
-
}
|
|
3666
|
-
}
|
|
3667
|
-
}
|
|
3668
|
-
}
|
|
3669
|
-
}
|
|
3670
|
-
};
|
|
3671
|
-
return paths;
|
|
3672
|
-
}
|
|
3673
|
-
function generateOpenAPISpec(database, config = {}) {
|
|
3674
|
-
const {
|
|
3675
|
-
title = "s3db.js API",
|
|
3676
|
-
version = "1.0.0",
|
|
3677
|
-
description = "Auto-generated REST API documentation for s3db.js resources",
|
|
3678
|
-
serverUrl = "http://localhost:3000",
|
|
3679
|
-
auth = {},
|
|
3680
|
-
resources: resourceConfigs = {}
|
|
3681
|
-
} = config;
|
|
3682
|
-
const resourcesTableRows = [];
|
|
3683
|
-
for (const [name, resource] of Object.entries(database.resources)) {
|
|
3684
|
-
if (name.startsWith("plg_") && !resourceConfigs[name]) {
|
|
3685
|
-
continue;
|
|
3686
|
-
}
|
|
3687
|
-
const version2 = resource.config?.currentVersion || resource.version || "v1";
|
|
3688
|
-
const resourceDescription = resource.config?.description;
|
|
3689
|
-
const descText = typeof resourceDescription === "object" ? resourceDescription.resource : resourceDescription || "No description";
|
|
3690
|
-
resourcesTableRows.push(`| ${name} | ${descText} | \`/${version2}/${name}\` |`);
|
|
3691
|
-
}
|
|
3692
|
-
const enhancedDescription = `${description}
|
|
3693
|
-
|
|
3694
|
-
## Available Resources
|
|
3695
|
-
|
|
3696
|
-
| Resource | Description | Base Path |
|
|
3697
|
-
|----------|-------------|-----------|
|
|
3698
|
-
${resourcesTableRows.join("\n")}
|
|
3699
|
-
|
|
3700
|
-
---
|
|
3701
|
-
|
|
3702
|
-
For detailed information about each endpoint, see the sections below.`;
|
|
3703
|
-
const spec = {
|
|
3704
|
-
openapi: "3.1.0",
|
|
3705
|
-
info: {
|
|
3706
|
-
title,
|
|
3707
|
-
version,
|
|
3708
|
-
description: enhancedDescription,
|
|
3709
|
-
contact: {
|
|
3710
|
-
name: "s3db.js",
|
|
3711
|
-
url: "https://github.com/forattini-dev/s3db.js"
|
|
3712
|
-
}
|
|
3713
|
-
},
|
|
3714
|
-
servers: [
|
|
3715
|
-
{
|
|
3716
|
-
url: serverUrl,
|
|
3717
|
-
description: "API Server"
|
|
3718
|
-
}
|
|
3719
|
-
],
|
|
3720
|
-
paths: {},
|
|
3721
|
-
components: {
|
|
3722
|
-
schemas: {
|
|
3723
|
-
Error: {
|
|
3724
|
-
type: "object",
|
|
3725
|
-
properties: {
|
|
3726
|
-
success: { type: "boolean", example: false },
|
|
3727
|
-
error: {
|
|
3728
|
-
type: "object",
|
|
3729
|
-
properties: {
|
|
3730
|
-
message: { type: "string" },
|
|
3731
|
-
code: { type: "string" },
|
|
3732
|
-
details: { type: "object" }
|
|
3733
|
-
}
|
|
3734
|
-
}
|
|
3735
|
-
}
|
|
3736
|
-
},
|
|
3737
|
-
ValidationError: {
|
|
3738
|
-
type: "object",
|
|
3739
|
-
properties: {
|
|
3740
|
-
success: { type: "boolean", example: false },
|
|
3741
|
-
error: {
|
|
3742
|
-
type: "object",
|
|
3743
|
-
properties: {
|
|
3744
|
-
message: { type: "string", example: "Validation failed" },
|
|
3745
|
-
code: { type: "string", example: "VALIDATION_ERROR" },
|
|
3746
|
-
details: {
|
|
3747
|
-
type: "object",
|
|
3748
|
-
properties: {
|
|
3749
|
-
errors: {
|
|
3750
|
-
type: "array",
|
|
3751
|
-
items: {
|
|
3752
|
-
type: "object",
|
|
3753
|
-
properties: {
|
|
3754
|
-
field: { type: "string" },
|
|
3755
|
-
message: { type: "string" },
|
|
3756
|
-
expected: { type: "string" },
|
|
3757
|
-
actual: {}
|
|
3758
|
-
}
|
|
3759
|
-
}
|
|
3760
|
-
}
|
|
3761
|
-
}
|
|
3762
|
-
}
|
|
3763
|
-
}
|
|
3764
|
-
}
|
|
3765
|
-
}
|
|
3766
|
-
}
|
|
3767
|
-
},
|
|
3768
|
-
securitySchemes: {}
|
|
3769
|
-
},
|
|
3770
|
-
tags: []
|
|
3771
|
-
};
|
|
3772
|
-
if (auth.jwt?.enabled) {
|
|
3773
|
-
spec.components.securitySchemes.bearerAuth = {
|
|
3774
|
-
type: "http",
|
|
3775
|
-
scheme: "bearer",
|
|
3776
|
-
bearerFormat: "JWT",
|
|
3777
|
-
description: "JWT authentication"
|
|
3778
|
-
};
|
|
3779
|
-
}
|
|
3780
|
-
if (auth.apiKey?.enabled) {
|
|
3781
|
-
spec.components.securitySchemes.apiKeyAuth = {
|
|
3782
|
-
type: "apiKey",
|
|
3783
|
-
in: "header",
|
|
3784
|
-
name: auth.apiKey.headerName || "X-API-Key",
|
|
3785
|
-
description: "API Key authentication"
|
|
3786
|
-
};
|
|
3787
|
-
}
|
|
3788
|
-
if (auth.basic?.enabled) {
|
|
3789
|
-
spec.components.securitySchemes.basicAuth = {
|
|
3790
|
-
type: "http",
|
|
3791
|
-
scheme: "basic",
|
|
3792
|
-
description: "HTTP Basic authentication"
|
|
3793
|
-
};
|
|
3794
|
-
}
|
|
3795
|
-
const resources = database.resources;
|
|
3796
|
-
const relationsPlugin = database.plugins?.relation || database.plugins?.RelationPlugin || null;
|
|
3797
|
-
for (const [name, resource] of Object.entries(resources)) {
|
|
3798
|
-
if (name.startsWith("plg_") && !resourceConfigs[name]) {
|
|
3799
|
-
continue;
|
|
3800
|
-
}
|
|
3801
|
-
const config2 = resourceConfigs[name] || {
|
|
3802
|
-
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
3803
|
-
auth: false
|
|
3804
|
-
};
|
|
3805
|
-
const version2 = resource.config?.currentVersion || resource.version || "v1";
|
|
3806
|
-
const paths = generateResourcePaths(resource, version2, config2);
|
|
3807
|
-
Object.assign(spec.paths, paths);
|
|
3808
|
-
const resourceDescription = resource.config?.description;
|
|
3809
|
-
const tagDescription = typeof resourceDescription === "object" ? resourceDescription.resource : resourceDescription || `Operations for ${name} resource`;
|
|
3810
|
-
spec.tags.push({
|
|
3811
|
-
name,
|
|
3812
|
-
description: tagDescription
|
|
3813
|
-
});
|
|
3814
|
-
spec.components.schemas[name] = generateResourceSchema(resource);
|
|
3815
|
-
if (relationsPlugin && relationsPlugin.relations && relationsPlugin.relations[name]) {
|
|
3816
|
-
const relationsDef = relationsPlugin.relations[name];
|
|
3817
|
-
for (const [relationName, relationConfig] of Object.entries(relationsDef)) {
|
|
3818
|
-
if (relationConfig.type === "belongsTo") {
|
|
3819
|
-
continue;
|
|
3820
|
-
}
|
|
3821
|
-
const exposeRelation = config2?.relations?.[relationName]?.expose !== false;
|
|
3822
|
-
if (!exposeRelation) {
|
|
3823
|
-
continue;
|
|
3824
|
-
}
|
|
3825
|
-
const relatedResource = database.resources[relationConfig.resource];
|
|
3826
|
-
if (!relatedResource) {
|
|
3827
|
-
continue;
|
|
3828
|
-
}
|
|
3829
|
-
const relatedSchema = generateResourceSchema(relatedResource);
|
|
3830
|
-
const relationalPaths = generateRelationalPaths(
|
|
3831
|
-
resource,
|
|
3832
|
-
relationName,
|
|
3833
|
-
relationConfig,
|
|
3834
|
-
version2,
|
|
3835
|
-
relatedSchema
|
|
3836
|
-
);
|
|
3837
|
-
Object.assign(spec.paths, relationalPaths);
|
|
3838
|
-
}
|
|
3839
|
-
}
|
|
3840
|
-
}
|
|
3841
|
-
if (auth.jwt?.enabled || auth.apiKey?.enabled || auth.basic?.enabled) {
|
|
3842
|
-
spec.paths["/auth/login"] = {
|
|
3843
|
-
post: {
|
|
3844
|
-
tags: ["Authentication"],
|
|
3845
|
-
summary: "Login",
|
|
3846
|
-
description: "Authenticate with username and password",
|
|
3847
|
-
requestBody: {
|
|
3848
|
-
required: true,
|
|
3849
|
-
content: {
|
|
3850
|
-
"application/json": {
|
|
3851
|
-
schema: {
|
|
3852
|
-
type: "object",
|
|
3853
|
-
properties: {
|
|
3854
|
-
username: { type: "string" },
|
|
3855
|
-
password: { type: "string", format: "password" }
|
|
3856
|
-
},
|
|
3857
|
-
required: ["username", "password"]
|
|
3858
|
-
}
|
|
3859
|
-
}
|
|
3860
|
-
}
|
|
3861
|
-
},
|
|
3862
|
-
responses: {
|
|
3863
|
-
200: {
|
|
3864
|
-
description: "Login successful",
|
|
3865
|
-
content: {
|
|
3866
|
-
"application/json": {
|
|
3867
|
-
schema: {
|
|
3868
|
-
type: "object",
|
|
3869
|
-
properties: {
|
|
3870
|
-
success: { type: "boolean", example: true },
|
|
3871
|
-
data: {
|
|
3872
|
-
type: "object",
|
|
3873
|
-
properties: {
|
|
3874
|
-
token: { type: "string" },
|
|
3875
|
-
user: { type: "object" }
|
|
3876
|
-
}
|
|
3877
|
-
}
|
|
3878
|
-
}
|
|
3879
|
-
}
|
|
3880
|
-
}
|
|
3881
|
-
}
|
|
3882
|
-
},
|
|
3883
|
-
401: {
|
|
3884
|
-
description: "Invalid credentials",
|
|
3885
|
-
content: {
|
|
3886
|
-
"application/json": {
|
|
3887
|
-
schema: { $ref: "#/components/schemas/Error" }
|
|
3888
|
-
}
|
|
3889
|
-
}
|
|
3890
|
-
}
|
|
3891
|
-
}
|
|
3892
|
-
}
|
|
3893
|
-
};
|
|
3894
|
-
spec.paths["/auth/register"] = {
|
|
3895
|
-
post: {
|
|
3896
|
-
tags: ["Authentication"],
|
|
3897
|
-
summary: "Register",
|
|
3898
|
-
description: "Register a new user",
|
|
3899
|
-
requestBody: {
|
|
3900
|
-
required: true,
|
|
3901
|
-
content: {
|
|
3902
|
-
"application/json": {
|
|
3903
|
-
schema: {
|
|
3904
|
-
type: "object",
|
|
3905
|
-
properties: {
|
|
3906
|
-
username: { type: "string", minLength: 3 },
|
|
3907
|
-
password: { type: "string", format: "password", minLength: 8 },
|
|
3908
|
-
email: { type: "string", format: "email" }
|
|
3909
|
-
},
|
|
3910
|
-
required: ["username", "password"]
|
|
3911
|
-
}
|
|
3912
|
-
}
|
|
3913
|
-
}
|
|
3914
|
-
},
|
|
3915
|
-
responses: {
|
|
3916
|
-
201: {
|
|
3917
|
-
description: "User registered successfully",
|
|
3918
|
-
content: {
|
|
3919
|
-
"application/json": {
|
|
3920
|
-
schema: {
|
|
3921
|
-
type: "object",
|
|
3922
|
-
properties: {
|
|
3923
|
-
success: { type: "boolean", example: true },
|
|
3924
|
-
data: {
|
|
3925
|
-
type: "object",
|
|
3926
|
-
properties: {
|
|
3927
|
-
token: { type: "string" },
|
|
3928
|
-
user: { type: "object" }
|
|
3929
|
-
}
|
|
3930
|
-
}
|
|
3931
|
-
}
|
|
3932
|
-
}
|
|
3933
|
-
}
|
|
3934
|
-
}
|
|
3935
|
-
}
|
|
3936
|
-
}
|
|
3937
|
-
}
|
|
3938
|
-
};
|
|
3939
|
-
spec.tags.push({
|
|
3940
|
-
name: "Authentication",
|
|
3941
|
-
description: "Authentication endpoints"
|
|
3942
|
-
});
|
|
3943
|
-
}
|
|
3944
|
-
spec.paths["/health"] = {
|
|
3945
|
-
get: {
|
|
3946
|
-
tags: ["Health"],
|
|
3947
|
-
summary: "Generic Health Check",
|
|
3948
|
-
description: "Generic health check endpoint that includes references to liveness and readiness probes",
|
|
3949
|
-
responses: {
|
|
3950
|
-
200: {
|
|
3951
|
-
description: "API is healthy",
|
|
3952
|
-
content: {
|
|
3953
|
-
"application/json": {
|
|
3954
|
-
schema: {
|
|
3955
|
-
type: "object",
|
|
3956
|
-
properties: {
|
|
3957
|
-
success: { type: "boolean", example: true },
|
|
3958
|
-
data: {
|
|
3959
|
-
type: "object",
|
|
3960
|
-
properties: {
|
|
3961
|
-
status: { type: "string", example: "ok" },
|
|
3962
|
-
uptime: { type: "number", description: "Process uptime in seconds" },
|
|
3963
|
-
timestamp: { type: "string", format: "date-time" },
|
|
3964
|
-
checks: {
|
|
3965
|
-
type: "object",
|
|
3966
|
-
properties: {
|
|
3967
|
-
liveness: { type: "string", example: "/health/live" },
|
|
3968
|
-
readiness: { type: "string", example: "/health/ready" }
|
|
3969
|
-
}
|
|
3970
|
-
}
|
|
3971
|
-
}
|
|
3972
|
-
}
|
|
3973
|
-
}
|
|
3974
|
-
}
|
|
3975
|
-
}
|
|
3976
|
-
}
|
|
3977
|
-
}
|
|
3978
|
-
}
|
|
3979
|
-
}
|
|
3980
|
-
};
|
|
3981
|
-
spec.paths["/health/live"] = {
|
|
3982
|
-
get: {
|
|
3983
|
-
tags: ["Health"],
|
|
3984
|
-
summary: "Liveness Probe",
|
|
3985
|
-
description: "Kubernetes liveness probe - checks if the application is alive. If this fails, Kubernetes will restart the pod.",
|
|
3986
|
-
responses: {
|
|
3987
|
-
200: {
|
|
3988
|
-
description: "Application is alive",
|
|
3989
|
-
content: {
|
|
3990
|
-
"application/json": {
|
|
3991
|
-
schema: {
|
|
3992
|
-
type: "object",
|
|
3993
|
-
properties: {
|
|
3994
|
-
success: { type: "boolean", example: true },
|
|
3995
|
-
data: {
|
|
3996
|
-
type: "object",
|
|
3997
|
-
properties: {
|
|
3998
|
-
status: { type: "string", example: "alive" },
|
|
3999
|
-
timestamp: { type: "string", format: "date-time" }
|
|
4000
|
-
}
|
|
4001
|
-
}
|
|
4002
|
-
}
|
|
4003
|
-
}
|
|
4004
|
-
}
|
|
4005
|
-
}
|
|
4006
|
-
}
|
|
4007
|
-
}
|
|
4008
|
-
}
|
|
4009
|
-
};
|
|
4010
|
-
spec.paths["/health/ready"] = {
|
|
4011
|
-
get: {
|
|
4012
|
-
tags: ["Health"],
|
|
4013
|
-
summary: "Readiness Probe",
|
|
4014
|
-
description: "Kubernetes readiness probe - checks if the application is ready to receive traffic. If this fails, Kubernetes will remove the pod from service endpoints.",
|
|
4015
|
-
responses: {
|
|
4016
|
-
200: {
|
|
4017
|
-
description: "Application is ready to receive traffic",
|
|
4018
|
-
content: {
|
|
4019
|
-
"application/json": {
|
|
4020
|
-
schema: {
|
|
4021
|
-
type: "object",
|
|
4022
|
-
properties: {
|
|
4023
|
-
success: { type: "boolean", example: true },
|
|
4024
|
-
data: {
|
|
4025
|
-
type: "object",
|
|
4026
|
-
properties: {
|
|
4027
|
-
status: { type: "string", example: "ready" },
|
|
4028
|
-
database: {
|
|
4029
|
-
type: "object",
|
|
4030
|
-
properties: {
|
|
4031
|
-
connected: { type: "boolean", example: true },
|
|
4032
|
-
resources: { type: "integer", example: 5 }
|
|
4033
|
-
}
|
|
4034
|
-
},
|
|
4035
|
-
timestamp: { type: "string", format: "date-time" }
|
|
4036
|
-
}
|
|
4037
|
-
}
|
|
4038
|
-
}
|
|
4039
|
-
}
|
|
4040
|
-
}
|
|
4041
|
-
}
|
|
4042
|
-
},
|
|
4043
|
-
503: {
|
|
4044
|
-
description: "Application is not ready",
|
|
4045
|
-
content: {
|
|
4046
|
-
"application/json": {
|
|
4047
|
-
schema: {
|
|
4048
|
-
type: "object",
|
|
4049
|
-
properties: {
|
|
4050
|
-
success: { type: "boolean", example: false },
|
|
4051
|
-
error: {
|
|
4052
|
-
type: "object",
|
|
4053
|
-
properties: {
|
|
4054
|
-
message: { type: "string", example: "Service not ready" },
|
|
4055
|
-
code: { type: "string", example: "NOT_READY" },
|
|
4056
|
-
details: {
|
|
4057
|
-
type: "object",
|
|
4058
|
-
properties: {
|
|
4059
|
-
database: {
|
|
4060
|
-
type: "object",
|
|
4061
|
-
properties: {
|
|
4062
|
-
connected: { type: "boolean", example: false },
|
|
4063
|
-
resources: { type: "integer", example: 0 }
|
|
4064
|
-
}
|
|
4065
|
-
}
|
|
4066
|
-
}
|
|
4067
|
-
}
|
|
4068
|
-
}
|
|
4069
|
-
}
|
|
4070
|
-
}
|
|
4071
|
-
}
|
|
4072
|
-
}
|
|
4073
|
-
}
|
|
4074
|
-
}
|
|
4075
|
-
}
|
|
4076
|
-
}
|
|
4077
|
-
};
|
|
4078
|
-
spec.tags.push({
|
|
4079
|
-
name: "Health",
|
|
4080
|
-
description: "Health check endpoints for monitoring and Kubernetes probes"
|
|
4081
|
-
});
|
|
4082
|
-
const metricsPlugin = database.plugins?.metrics || database.plugins?.MetricsPlugin;
|
|
4083
|
-
if (metricsPlugin && metricsPlugin.config?.prometheus?.enabled) {
|
|
4084
|
-
const metricsPath = metricsPlugin.config.prometheus.path || "/metrics";
|
|
4085
|
-
const isIntegrated = metricsPlugin.config.prometheus.mode !== "standalone";
|
|
4086
|
-
if (isIntegrated) {
|
|
4087
|
-
spec.paths[metricsPath] = {
|
|
4088
|
-
get: {
|
|
4089
|
-
tags: ["Monitoring"],
|
|
4090
|
-
summary: "Prometheus Metrics",
|
|
4091
|
-
description: "Exposes application metrics in Prometheus text-based exposition format for monitoring and observability. Metrics include operation counts, durations, errors, uptime, and resource statistics.",
|
|
4092
|
-
responses: {
|
|
4093
|
-
200: {
|
|
4094
|
-
description: "Metrics in Prometheus format",
|
|
4095
|
-
content: {
|
|
4096
|
-
"text/plain": {
|
|
4097
|
-
schema: {
|
|
4098
|
-
type: "string",
|
|
4099
|
-
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'
|
|
4100
|
-
}
|
|
4101
|
-
}
|
|
4102
|
-
}
|
|
4103
|
-
}
|
|
4104
|
-
}
|
|
4105
|
-
}
|
|
4106
|
-
};
|
|
4107
|
-
spec.tags.push({
|
|
4108
|
-
name: "Monitoring",
|
|
4109
|
-
description: "Monitoring and observability endpoints (Prometheus)"
|
|
4110
|
-
});
|
|
4111
|
-
}
|
|
4112
|
-
}
|
|
4113
|
-
return spec;
|
|
4114
|
-
}
|
|
4115
|
-
|
|
4116
|
-
class ApiServer {
|
|
4117
|
-
/**
|
|
4118
|
-
* Create API server
|
|
4119
|
-
* @param {Object} options - Server options
|
|
4120
|
-
* @param {number} options.port - Server port
|
|
4121
|
-
* @param {string} options.host - Server host
|
|
4122
|
-
* @param {Object} options.database - s3db.js database instance
|
|
4123
|
-
* @param {Object} options.resources - Resource configuration
|
|
4124
|
-
* @param {Array} options.middlewares - Global middlewares
|
|
4125
|
-
*/
|
|
4126
|
-
constructor(options = {}) {
|
|
4127
|
-
this.options = {
|
|
4128
|
-
port: options.port || 3e3,
|
|
4129
|
-
host: options.host || "0.0.0.0",
|
|
4130
|
-
database: options.database,
|
|
4131
|
-
resources: options.resources || {},
|
|
4132
|
-
middlewares: options.middlewares || [],
|
|
4133
|
-
verbose: options.verbose || false,
|
|
4134
|
-
auth: options.auth || {},
|
|
4135
|
-
docsEnabled: options.docsEnabled !== false,
|
|
4136
|
-
// Enable /docs by default
|
|
4137
|
-
docsUI: options.docsUI || "redoc",
|
|
4138
|
-
// 'swagger' or 'redoc'
|
|
4139
|
-
maxBodySize: options.maxBodySize || 10 * 1024 * 1024,
|
|
4140
|
-
// 10MB default
|
|
4141
|
-
rootHandler: options.rootHandler,
|
|
4142
|
-
// Custom handler for root path, if not provided redirects to /docs
|
|
4143
|
-
apiInfo: {
|
|
4144
|
-
title: options.apiTitle || "s3db.js API",
|
|
4145
|
-
version: options.apiVersion || "1.0.0",
|
|
4146
|
-
description: options.apiDescription || "Auto-generated REST API for s3db.js resources"
|
|
4147
|
-
}
|
|
4148
|
-
};
|
|
4149
|
-
this.app = new hono.Hono();
|
|
4150
|
-
this.server = null;
|
|
4151
|
-
this.isRunning = false;
|
|
4152
|
-
this.openAPISpec = null;
|
|
4153
|
-
this.relationsPlugin = this.options.database?.plugins?.relation || this.options.database?.plugins?.RelationPlugin || null;
|
|
4154
|
-
this._setupRoutes();
|
|
4155
|
-
}
|
|
4156
|
-
/**
|
|
4157
|
-
* Setup all routes
|
|
4158
|
-
* @private
|
|
4159
|
-
*/
|
|
4160
|
-
_setupRoutes() {
|
|
4161
|
-
this.options.middlewares.forEach((middleware) => {
|
|
4162
|
-
this.app.use("*", middleware);
|
|
4163
|
-
});
|
|
4164
|
-
this.app.use("*", async (c, next) => {
|
|
4165
|
-
const method = c.req.method;
|
|
4166
|
-
if (["POST", "PUT", "PATCH"].includes(method)) {
|
|
4167
|
-
const contentLength = c.req.header("content-length");
|
|
4168
|
-
if (contentLength) {
|
|
4169
|
-
const size = parseInt(contentLength);
|
|
4170
|
-
if (size > this.options.maxBodySize) {
|
|
4171
|
-
const response = payloadTooLarge(size, this.options.maxBodySize);
|
|
4172
|
-
c.header("Connection", "close");
|
|
4173
|
-
return c.json(response, response._status);
|
|
4174
|
-
}
|
|
4175
|
-
}
|
|
4176
|
-
}
|
|
4177
|
-
await next();
|
|
4178
|
-
});
|
|
4179
|
-
this.app.get("/health/live", (c) => {
|
|
4180
|
-
const response = success({
|
|
4181
|
-
status: "alive",
|
|
4182
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
4183
|
-
});
|
|
4184
|
-
return c.json(response);
|
|
4185
|
-
});
|
|
4186
|
-
this.app.get("/health/ready", (c) => {
|
|
4187
|
-
const isReady = this.options.database && this.options.database.connected && Object.keys(this.options.database.resources).length > 0;
|
|
4188
|
-
if (!isReady) {
|
|
4189
|
-
const response2 = error("Service not ready", {
|
|
4190
|
-
status: 503,
|
|
4191
|
-
code: "NOT_READY",
|
|
4192
|
-
details: {
|
|
4193
|
-
database: {
|
|
4194
|
-
connected: this.options.database?.connected || false,
|
|
4195
|
-
resources: Object.keys(this.options.database?.resources || {}).length
|
|
4196
|
-
}
|
|
4197
|
-
}
|
|
4198
|
-
});
|
|
4199
|
-
return c.json(response2, 503);
|
|
4200
|
-
}
|
|
4201
|
-
const response = success({
|
|
4202
|
-
status: "ready",
|
|
4203
|
-
database: {
|
|
4204
|
-
connected: true,
|
|
4205
|
-
resources: Object.keys(this.options.database.resources).length
|
|
4206
|
-
},
|
|
4207
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
4208
|
-
});
|
|
4209
|
-
return c.json(response);
|
|
4210
|
-
});
|
|
4211
|
-
this.app.get("/health", (c) => {
|
|
4212
|
-
const response = success({
|
|
4213
|
-
status: "ok",
|
|
4214
|
-
uptime: process.uptime(),
|
|
4215
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4216
|
-
checks: {
|
|
4217
|
-
liveness: "/health/live",
|
|
4218
|
-
readiness: "/health/ready"
|
|
4219
|
-
}
|
|
4220
|
-
});
|
|
4221
|
-
return c.json(response);
|
|
4222
|
-
});
|
|
4223
|
-
this.app.get("/", (c) => {
|
|
4224
|
-
if (this.options.rootHandler) {
|
|
4225
|
-
return this.options.rootHandler(c);
|
|
4226
|
-
}
|
|
4227
|
-
return c.redirect("/docs", 302);
|
|
4228
|
-
});
|
|
4229
|
-
if (this.options.docsEnabled) {
|
|
4230
|
-
this.app.get("/openapi.json", (c) => {
|
|
4231
|
-
if (!this.openAPISpec) {
|
|
4232
|
-
this.openAPISpec = this._generateOpenAPISpec();
|
|
4233
|
-
}
|
|
4234
|
-
return c.json(this.openAPISpec);
|
|
4235
|
-
});
|
|
4236
|
-
if (this.options.docsUI === "swagger") {
|
|
4237
|
-
this.app.get("/docs", swaggerUi.swaggerUI({
|
|
4238
|
-
url: "/openapi.json"
|
|
4239
|
-
}));
|
|
4240
|
-
} else {
|
|
4241
|
-
this.app.get("/docs", (c) => {
|
|
4242
|
-
return c.html(`<!DOCTYPE html>
|
|
4243
|
-
<html lang="en">
|
|
4244
|
-
<head>
|
|
4245
|
-
<meta charset="UTF-8">
|
|
4246
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
4247
|
-
<title>${this.options.apiInfo.title} - API Documentation</title>
|
|
4248
|
-
<style>
|
|
4249
|
-
body {
|
|
4250
|
-
margin: 0;
|
|
4251
|
-
padding: 0;
|
|
4252
|
-
}
|
|
4253
|
-
</style>
|
|
4254
|
-
</head>
|
|
4255
|
-
<body>
|
|
4256
|
-
<redoc spec-url="/openapi.json"></redoc>
|
|
4257
|
-
<script src="https://cdn.redoc.ly/redoc/v2.5.1/bundles/redoc.standalone.js"><\/script>
|
|
4258
|
-
</body>
|
|
4259
|
-
</html>`);
|
|
4260
|
-
});
|
|
4261
|
-
}
|
|
4262
|
-
}
|
|
4263
|
-
this._setupResourceRoutes();
|
|
4264
|
-
if (this.relationsPlugin) {
|
|
4265
|
-
this._setupRelationalRoutes();
|
|
4266
|
-
}
|
|
4267
|
-
this.app.onError((err, c) => {
|
|
4268
|
-
return errorHandler(err, c);
|
|
4269
|
-
});
|
|
4270
|
-
this.app.notFound((c) => {
|
|
4271
|
-
const response = error("Route not found", {
|
|
4272
|
-
status: 404,
|
|
4273
|
-
code: "NOT_FOUND",
|
|
4274
|
-
details: {
|
|
4275
|
-
path: c.req.path,
|
|
4276
|
-
method: c.req.method
|
|
4277
|
-
}
|
|
4278
|
-
});
|
|
4279
|
-
return c.json(response, 404);
|
|
4280
|
-
});
|
|
4281
|
-
}
|
|
4282
|
-
/**
|
|
4283
|
-
* Setup routes for all resources
|
|
4284
|
-
* @private
|
|
4285
|
-
*/
|
|
4286
|
-
_setupResourceRoutes() {
|
|
4287
|
-
const { database, resources: resourceConfigs } = this.options;
|
|
4288
|
-
const resources = database.resources;
|
|
4289
|
-
for (const [name, resource] of Object.entries(resources)) {
|
|
4290
|
-
if (name.startsWith("plg_") && !resourceConfigs[name]) {
|
|
4291
|
-
continue;
|
|
4292
|
-
}
|
|
4293
|
-
const config = resourceConfigs[name] || {
|
|
4294
|
-
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]};
|
|
4295
|
-
const version = resource.config?.currentVersion || resource.version || "v1";
|
|
4296
|
-
const resourceApp = createResourceRoutes(resource, version, {
|
|
4297
|
-
methods: config.methods,
|
|
4298
|
-
customMiddleware: config.customMiddleware || [],
|
|
4299
|
-
enableValidation: config.validation !== false
|
|
4300
|
-
});
|
|
4301
|
-
this.app.route(`/${version}/${name}`, resourceApp);
|
|
4302
|
-
if (this.options.verbose) {
|
|
4303
|
-
console.log(`[API Plugin] Mounted routes for resource '${name}' at /${version}/${name}`);
|
|
4304
|
-
}
|
|
4305
|
-
}
|
|
4306
|
-
}
|
|
4307
|
-
/**
|
|
4308
|
-
* Setup relational routes (when RelationPlugin is active)
|
|
4309
|
-
* @private
|
|
4310
|
-
*/
|
|
4311
|
-
_setupRelationalRoutes() {
|
|
4312
|
-
if (!this.relationsPlugin || !this.relationsPlugin.relations) {
|
|
4313
|
-
return;
|
|
4314
|
-
}
|
|
4315
|
-
const { database } = this.options;
|
|
4316
|
-
const relations = this.relationsPlugin.relations;
|
|
4317
|
-
if (this.options.verbose) {
|
|
4318
|
-
console.log("[API Plugin] Setting up relational routes...");
|
|
4319
|
-
}
|
|
4320
|
-
for (const [resourceName, relationsDef] of Object.entries(relations)) {
|
|
4321
|
-
const resource = database.resources[resourceName];
|
|
4322
|
-
if (!resource) {
|
|
4323
|
-
if (this.options.verbose) {
|
|
4324
|
-
console.warn(`[API Plugin] Resource '${resourceName}' not found for relational routes`);
|
|
4325
|
-
}
|
|
4326
|
-
continue;
|
|
4327
|
-
}
|
|
4328
|
-
if (resourceName.startsWith("plg_") && !this.options.resources[resourceName]) {
|
|
4329
|
-
continue;
|
|
4330
|
-
}
|
|
4331
|
-
const version = resource.config?.currentVersion || resource.version || "v1";
|
|
4332
|
-
for (const [relationName, relationConfig] of Object.entries(relationsDef)) {
|
|
4333
|
-
if (relationConfig.type === "belongsTo") {
|
|
4334
|
-
continue;
|
|
4335
|
-
}
|
|
4336
|
-
const resourceConfig = this.options.resources[resourceName];
|
|
4337
|
-
const exposeRelation = resourceConfig?.relations?.[relationName]?.expose !== false;
|
|
4338
|
-
if (!exposeRelation) {
|
|
4339
|
-
continue;
|
|
4340
|
-
}
|
|
4341
|
-
const relationalApp = createRelationalRoutes(
|
|
4342
|
-
resource,
|
|
4343
|
-
relationName,
|
|
4344
|
-
relationConfig);
|
|
4345
|
-
this.app.route(`/${version}/${resourceName}/:id/${relationName}`, relationalApp);
|
|
4346
|
-
if (this.options.verbose) {
|
|
4347
|
-
console.log(
|
|
4348
|
-
`[API Plugin] Mounted relational route: /${version}/${resourceName}/:id/${relationName} (${relationConfig.type} -> ${relationConfig.resource})`
|
|
4349
|
-
);
|
|
4350
|
-
}
|
|
4351
|
-
}
|
|
4352
|
-
}
|
|
4353
|
-
}
|
|
4354
|
-
/**
|
|
4355
|
-
* Start the server
|
|
4356
|
-
* @returns {Promise<void>}
|
|
4357
|
-
*/
|
|
4358
|
-
async start() {
|
|
4359
|
-
if (this.isRunning) {
|
|
4360
|
-
console.warn("[API Plugin] Server is already running");
|
|
4361
|
-
return;
|
|
4362
|
-
}
|
|
4363
|
-
const { port, host } = this.options;
|
|
4364
|
-
return new Promise((resolve, reject) => {
|
|
4365
|
-
try {
|
|
4366
|
-
this.server = nodeServer.serve({
|
|
4367
|
-
fetch: this.app.fetch,
|
|
4368
|
-
port,
|
|
4369
|
-
hostname: host
|
|
4370
|
-
}, (info) => {
|
|
4371
|
-
this.isRunning = true;
|
|
4372
|
-
console.log(`[API Plugin] Server listening on http://${info.address}:${info.port}`);
|
|
4373
|
-
resolve();
|
|
4374
|
-
});
|
|
4375
|
-
} catch (err) {
|
|
4376
|
-
reject(err);
|
|
4377
|
-
}
|
|
4378
|
-
});
|
|
4379
|
-
}
|
|
4380
|
-
/**
|
|
4381
|
-
* Stop the server
|
|
4382
|
-
* @returns {Promise<void>}
|
|
4383
|
-
*/
|
|
4384
|
-
async stop() {
|
|
4385
|
-
if (!this.isRunning) {
|
|
4386
|
-
console.warn("[API Plugin] Server is not running");
|
|
4387
|
-
return;
|
|
4388
|
-
}
|
|
4389
|
-
if (this.server && typeof this.server.close === "function") {
|
|
4390
|
-
await new Promise((resolve) => {
|
|
4391
|
-
this.server.close(() => {
|
|
4392
|
-
this.isRunning = false;
|
|
4393
|
-
console.log("[API Plugin] Server stopped");
|
|
4394
|
-
resolve();
|
|
4395
|
-
});
|
|
4396
|
-
});
|
|
4397
|
-
} else {
|
|
4398
|
-
this.isRunning = false;
|
|
4399
|
-
console.log("[API Plugin] Server stopped");
|
|
4400
|
-
}
|
|
4401
|
-
}
|
|
4402
|
-
/**
|
|
4403
|
-
* Get server info
|
|
4404
|
-
* @returns {Object} Server information
|
|
4405
|
-
*/
|
|
4406
|
-
getInfo() {
|
|
4407
|
-
return {
|
|
4408
|
-
isRunning: this.isRunning,
|
|
4409
|
-
port: this.options.port,
|
|
4410
|
-
host: this.options.host,
|
|
4411
|
-
resources: Object.keys(this.options.database.resources).length
|
|
4412
|
-
};
|
|
4413
|
-
}
|
|
4414
|
-
/**
|
|
4415
|
-
* Get Hono app instance
|
|
4416
|
-
* @returns {Hono} Hono app
|
|
4417
|
-
*/
|
|
4418
|
-
getApp() {
|
|
4419
|
-
return this.app;
|
|
4420
|
-
}
|
|
4421
|
-
/**
|
|
4422
|
-
* Generate OpenAPI specification
|
|
4423
|
-
* @private
|
|
4424
|
-
* @returns {Object} OpenAPI spec
|
|
4425
|
-
*/
|
|
4426
|
-
_generateOpenAPISpec() {
|
|
4427
|
-
const { port, host, database, resources, auth, apiInfo } = this.options;
|
|
4428
|
-
return generateOpenAPISpec(database, {
|
|
4429
|
-
title: apiInfo.title,
|
|
4430
|
-
version: apiInfo.version,
|
|
4431
|
-
description: apiInfo.description,
|
|
4432
|
-
serverUrl: `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`,
|
|
4433
|
-
auth,
|
|
4434
|
-
resources
|
|
4435
|
-
});
|
|
4436
|
-
}
|
|
4437
|
-
}
|
|
4438
|
-
|
|
4439
2474
|
const PLUGIN_DEPENDENCIES = {
|
|
4440
2475
|
"postgresql-replicator": {
|
|
4441
2476
|
name: "PostgreSQL Replicator",
|
|
@@ -4996,6 +3031,11 @@ class ApiPlugin extends Plugin {
|
|
|
4996
3031
|
if (this.config.verbose) {
|
|
4997
3032
|
console.log("[API Plugin] Starting server...");
|
|
4998
3033
|
}
|
|
3034
|
+
const serverPath = "./server.js";
|
|
3035
|
+
const { ApiServer } = await import(
|
|
3036
|
+
/* @vite-ignore */
|
|
3037
|
+
serverPath
|
|
3038
|
+
);
|
|
4999
3039
|
this.server = new ApiServer({
|
|
5000
3040
|
port: this.config.port,
|
|
5001
3041
|
host: this.config.host,
|
|
@@ -12605,6 +10645,605 @@ class FullTextPlugin extends Plugin {
|
|
|
12605
10645
|
}
|
|
12606
10646
|
}
|
|
12607
10647
|
|
|
10648
|
+
class GeoPlugin extends Plugin {
|
|
10649
|
+
constructor(config = {}) {
|
|
10650
|
+
super(config);
|
|
10651
|
+
this.resources = config.resources || {};
|
|
10652
|
+
this.verbose = config.verbose !== void 0 ? config.verbose : false;
|
|
10653
|
+
this.base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
|
|
10654
|
+
}
|
|
10655
|
+
/**
|
|
10656
|
+
* Install the plugin
|
|
10657
|
+
*/
|
|
10658
|
+
async install(database) {
|
|
10659
|
+
await super.install(database);
|
|
10660
|
+
for (const [resourceName, config] of Object.entries(this.resources)) {
|
|
10661
|
+
await this._setupResource(resourceName, config);
|
|
10662
|
+
}
|
|
10663
|
+
this.database.addHook("afterCreateResource", async (context) => {
|
|
10664
|
+
const { resource, config: resourceConfig } = context;
|
|
10665
|
+
const geoConfig = this.resources[resource.name];
|
|
10666
|
+
if (geoConfig) {
|
|
10667
|
+
await this._setupResource(resource.name, geoConfig);
|
|
10668
|
+
}
|
|
10669
|
+
});
|
|
10670
|
+
if (this.verbose) {
|
|
10671
|
+
console.log(`[GeoPlugin] Installed with ${Object.keys(this.resources).length} resources`);
|
|
10672
|
+
}
|
|
10673
|
+
this.emit("installed", {
|
|
10674
|
+
plugin: "GeoPlugin",
|
|
10675
|
+
resources: Object.keys(this.resources)
|
|
10676
|
+
});
|
|
10677
|
+
}
|
|
10678
|
+
/**
|
|
10679
|
+
* Setup a resource with geo capabilities
|
|
10680
|
+
*/
|
|
10681
|
+
async _setupResource(resourceName, config) {
|
|
10682
|
+
if (!this.database.resources[resourceName]) {
|
|
10683
|
+
if (this.verbose) {
|
|
10684
|
+
console.warn(`[GeoPlugin] Resource "${resourceName}" not found, will setup when created`);
|
|
10685
|
+
}
|
|
10686
|
+
return;
|
|
10687
|
+
}
|
|
10688
|
+
const resource = this.database.resources[resourceName];
|
|
10689
|
+
if (!resource || typeof resource.addHook !== "function") {
|
|
10690
|
+
if (this.verbose) {
|
|
10691
|
+
console.warn(`[GeoPlugin] Resource "${resourceName}" not found or invalid`);
|
|
10692
|
+
}
|
|
10693
|
+
return;
|
|
10694
|
+
}
|
|
10695
|
+
if (!config.latField || !config.lonField) {
|
|
10696
|
+
throw new Error(
|
|
10697
|
+
`[GeoPlugin] Resource "${resourceName}" must have "latField" and "lonField" configured`
|
|
10698
|
+
);
|
|
10699
|
+
}
|
|
10700
|
+
if (!config.precision || config.precision < 1 || config.precision > 12) {
|
|
10701
|
+
config.precision = 5;
|
|
10702
|
+
}
|
|
10703
|
+
resource._geoConfig = config;
|
|
10704
|
+
const latField = resource.attributes[config.latField];
|
|
10705
|
+
const lonField = resource.attributes[config.lonField];
|
|
10706
|
+
const isLatOptional = typeof latField === "object" && latField.optional === true;
|
|
10707
|
+
const isLonOptional = typeof lonField === "object" && lonField.optional === true;
|
|
10708
|
+
const areCoordinatesOptional = isLatOptional || isLonOptional;
|
|
10709
|
+
const geohashType = areCoordinatesOptional ? "string|optional" : "string";
|
|
10710
|
+
let needsUpdate = false;
|
|
10711
|
+
const newAttributes = { ...resource.attributes };
|
|
10712
|
+
if (config.addGeohash && !newAttributes.geohash) {
|
|
10713
|
+
newAttributes.geohash = geohashType;
|
|
10714
|
+
needsUpdate = true;
|
|
10715
|
+
}
|
|
10716
|
+
if (!newAttributes._geohash) {
|
|
10717
|
+
newAttributes._geohash = geohashType;
|
|
10718
|
+
needsUpdate = true;
|
|
10719
|
+
}
|
|
10720
|
+
if (config.zoomLevels && Array.isArray(config.zoomLevels)) {
|
|
10721
|
+
for (const zoom of config.zoomLevels) {
|
|
10722
|
+
const fieldName = `_geohash_zoom${zoom}`;
|
|
10723
|
+
if (!newAttributes[fieldName]) {
|
|
10724
|
+
newAttributes[fieldName] = geohashType;
|
|
10725
|
+
needsUpdate = true;
|
|
10726
|
+
}
|
|
10727
|
+
}
|
|
10728
|
+
}
|
|
10729
|
+
if (needsUpdate) {
|
|
10730
|
+
resource.updateAttributes(newAttributes);
|
|
10731
|
+
if (this.database.uploadMetadataFile) {
|
|
10732
|
+
await this.database.uploadMetadataFile();
|
|
10733
|
+
}
|
|
10734
|
+
}
|
|
10735
|
+
if (config.usePartitions) {
|
|
10736
|
+
await this._setupPartitions(resource, config);
|
|
10737
|
+
}
|
|
10738
|
+
this._addHooks(resource, config);
|
|
10739
|
+
this._addHelperMethods(resource, config);
|
|
10740
|
+
if (this.verbose) {
|
|
10741
|
+
console.log(
|
|
10742
|
+
`[GeoPlugin] Setup resource "${resourceName}" with precision ${config.precision} (~${this._getPrecisionDistance(config.precision)}km cells)` + (config.usePartitions ? " [Partitions enabled]" : "")
|
|
10743
|
+
);
|
|
10744
|
+
}
|
|
10745
|
+
}
|
|
10746
|
+
/**
|
|
10747
|
+
* Setup geohash partitions for efficient spatial queries
|
|
10748
|
+
* Creates multiple zoom-level partitions if zoomLevels configured
|
|
10749
|
+
*/
|
|
10750
|
+
async _setupPartitions(resource, config) {
|
|
10751
|
+
const updatedConfig = { ...resource.config };
|
|
10752
|
+
updatedConfig.partitions = updatedConfig.partitions || {};
|
|
10753
|
+
let partitionsCreated = 0;
|
|
10754
|
+
if (config.zoomLevels && Array.isArray(config.zoomLevels)) {
|
|
10755
|
+
for (const zoom of config.zoomLevels) {
|
|
10756
|
+
const partitionName = `byGeohashZoom${zoom}`;
|
|
10757
|
+
const fieldName = `_geohash_zoom${zoom}`;
|
|
10758
|
+
if (!updatedConfig.partitions[partitionName]) {
|
|
10759
|
+
updatedConfig.partitions[partitionName] = {
|
|
10760
|
+
fields: {
|
|
10761
|
+
[fieldName]: "string"
|
|
10762
|
+
}
|
|
10763
|
+
};
|
|
10764
|
+
partitionsCreated++;
|
|
10765
|
+
if (this.verbose) {
|
|
10766
|
+
console.log(
|
|
10767
|
+
`[GeoPlugin] Created ${partitionName} partition for "${resource.name}" (precision ${zoom}, ~${this._getPrecisionDistance(zoom)}km cells)`
|
|
10768
|
+
);
|
|
10769
|
+
}
|
|
10770
|
+
}
|
|
10771
|
+
}
|
|
10772
|
+
} else {
|
|
10773
|
+
const hasGeohashPartition = resource.config.partitions && resource.config.partitions.byGeohash;
|
|
10774
|
+
if (!hasGeohashPartition) {
|
|
10775
|
+
updatedConfig.partitions.byGeohash = {
|
|
10776
|
+
fields: {
|
|
10777
|
+
_geohash: "string"
|
|
10778
|
+
}
|
|
10779
|
+
};
|
|
10780
|
+
partitionsCreated++;
|
|
10781
|
+
if (this.verbose) {
|
|
10782
|
+
console.log(`[GeoPlugin] Created byGeohash partition for "${resource.name}"`);
|
|
10783
|
+
}
|
|
10784
|
+
}
|
|
10785
|
+
}
|
|
10786
|
+
if (partitionsCreated > 0) {
|
|
10787
|
+
resource.config = updatedConfig;
|
|
10788
|
+
resource.setupPartitionHooks();
|
|
10789
|
+
if (this.database.uploadMetadataFile) {
|
|
10790
|
+
await this.database.uploadMetadataFile();
|
|
10791
|
+
}
|
|
10792
|
+
}
|
|
10793
|
+
}
|
|
10794
|
+
/**
|
|
10795
|
+
* Add hooks to automatically calculate geohash at all zoom levels
|
|
10796
|
+
*/
|
|
10797
|
+
_addHooks(resource, config) {
|
|
10798
|
+
const calculateGeohash = async (data) => {
|
|
10799
|
+
const lat = data[config.latField];
|
|
10800
|
+
const lon = data[config.lonField];
|
|
10801
|
+
if (lat !== void 0 && lon !== void 0) {
|
|
10802
|
+
const geohash = this.encodeGeohash(lat, lon, config.precision);
|
|
10803
|
+
if (config.addGeohash) {
|
|
10804
|
+
data.geohash = geohash;
|
|
10805
|
+
}
|
|
10806
|
+
data._geohash = geohash;
|
|
10807
|
+
if (config.zoomLevels && Array.isArray(config.zoomLevels)) {
|
|
10808
|
+
for (const zoom of config.zoomLevels) {
|
|
10809
|
+
const zoomGeohash = this.encodeGeohash(lat, lon, zoom);
|
|
10810
|
+
data[`_geohash_zoom${zoom}`] = zoomGeohash;
|
|
10811
|
+
}
|
|
10812
|
+
}
|
|
10813
|
+
}
|
|
10814
|
+
return data;
|
|
10815
|
+
};
|
|
10816
|
+
resource.addHook("beforeInsert", calculateGeohash);
|
|
10817
|
+
resource.addHook("beforeUpdate", calculateGeohash);
|
|
10818
|
+
}
|
|
10819
|
+
/**
|
|
10820
|
+
* Add helper methods to resource
|
|
10821
|
+
*/
|
|
10822
|
+
_addHelperMethods(resource, config) {
|
|
10823
|
+
const plugin = this;
|
|
10824
|
+
resource.findNearby = async function({ lat, lon, radius = 10, limit = 100 }) {
|
|
10825
|
+
if (lat === void 0 || lon === void 0) {
|
|
10826
|
+
throw new Error("lat and lon are required for findNearby");
|
|
10827
|
+
}
|
|
10828
|
+
const longitude = lon;
|
|
10829
|
+
let allRecords = [];
|
|
10830
|
+
if (config.usePartitions) {
|
|
10831
|
+
let partitionName, fieldName, precision;
|
|
10832
|
+
if (config.zoomLevels && config.zoomLevels.length > 0) {
|
|
10833
|
+
const optimalZoom = plugin._selectOptimalZoom(config.zoomLevels, radius);
|
|
10834
|
+
partitionName = `byGeohashZoom${optimalZoom}`;
|
|
10835
|
+
fieldName = `_geohash_zoom${optimalZoom}`;
|
|
10836
|
+
precision = optimalZoom;
|
|
10837
|
+
if (plugin.verbose) {
|
|
10838
|
+
console.log(
|
|
10839
|
+
`[GeoPlugin] Auto-selected zoom${optimalZoom} (${plugin._getPrecisionDistance(optimalZoom)}km cells) for ${radius}km radius query`
|
|
10840
|
+
);
|
|
10841
|
+
}
|
|
10842
|
+
} else {
|
|
10843
|
+
partitionName = "byGeohash";
|
|
10844
|
+
fieldName = "_geohash";
|
|
10845
|
+
precision = config.precision;
|
|
10846
|
+
}
|
|
10847
|
+
if (this.config.partitions?.[partitionName]) {
|
|
10848
|
+
const centerGeohash = plugin.encodeGeohash(lat, longitude, precision);
|
|
10849
|
+
const neighbors = plugin.getNeighbors(centerGeohash);
|
|
10850
|
+
const geohashesToSearch = [centerGeohash, ...neighbors];
|
|
10851
|
+
const partitionResults = await Promise.all(
|
|
10852
|
+
geohashesToSearch.map(async (geohash) => {
|
|
10853
|
+
const [ok, err, records] = await tryFn(async () => {
|
|
10854
|
+
return await this.listPartition({
|
|
10855
|
+
partition: partitionName,
|
|
10856
|
+
partitionValues: { [fieldName]: geohash },
|
|
10857
|
+
limit: limit * 2
|
|
10858
|
+
});
|
|
10859
|
+
});
|
|
10860
|
+
return ok ? records : [];
|
|
10861
|
+
})
|
|
10862
|
+
);
|
|
10863
|
+
allRecords = partitionResults.flat();
|
|
10864
|
+
if (plugin.verbose) {
|
|
10865
|
+
console.log(
|
|
10866
|
+
`[GeoPlugin] findNearby searched ${geohashesToSearch.length} ${partitionName} partitions, found ${allRecords.length} candidates`
|
|
10867
|
+
);
|
|
10868
|
+
}
|
|
10869
|
+
} else {
|
|
10870
|
+
allRecords = await this.list({ limit: limit * 10 });
|
|
10871
|
+
}
|
|
10872
|
+
} else {
|
|
10873
|
+
allRecords = await this.list({ limit: limit * 10 });
|
|
10874
|
+
}
|
|
10875
|
+
const withDistances = allRecords.map((record) => {
|
|
10876
|
+
const recordLat = record[config.latField];
|
|
10877
|
+
const recordLon = record[config.lonField];
|
|
10878
|
+
if (recordLat === void 0 || recordLon === void 0) {
|
|
10879
|
+
return null;
|
|
10880
|
+
}
|
|
10881
|
+
const distance = plugin.calculateDistance(lat, longitude, recordLat, recordLon);
|
|
10882
|
+
return {
|
|
10883
|
+
...record,
|
|
10884
|
+
_distance: distance
|
|
10885
|
+
};
|
|
10886
|
+
}).filter((record) => record !== null && record._distance <= radius).sort((a, b) => a._distance - b._distance).slice(0, limit);
|
|
10887
|
+
return withDistances;
|
|
10888
|
+
};
|
|
10889
|
+
resource.findInBounds = async function({ north, south, east, west, limit = 100 }) {
|
|
10890
|
+
if (north === void 0 || south === void 0 || east === void 0 || west === void 0) {
|
|
10891
|
+
throw new Error("north, south, east, west are required for findInBounds");
|
|
10892
|
+
}
|
|
10893
|
+
let allRecords = [];
|
|
10894
|
+
if (config.usePartitions) {
|
|
10895
|
+
let partitionName, precision;
|
|
10896
|
+
if (config.zoomLevels && config.zoomLevels.length > 0) {
|
|
10897
|
+
const centerLat = (north + south) / 2;
|
|
10898
|
+
const centerLon = (east + west) / 2;
|
|
10899
|
+
const latRadius = plugin.calculateDistance(centerLat, centerLon, north, centerLon);
|
|
10900
|
+
const lonRadius = plugin.calculateDistance(centerLat, centerLon, centerLat, east);
|
|
10901
|
+
const approximateRadius = Math.max(latRadius, lonRadius);
|
|
10902
|
+
const optimalZoom = plugin._selectOptimalZoom(config.zoomLevels, approximateRadius);
|
|
10903
|
+
partitionName = `byGeohashZoom${optimalZoom}`;
|
|
10904
|
+
precision = optimalZoom;
|
|
10905
|
+
if (plugin.verbose) {
|
|
10906
|
+
console.log(
|
|
10907
|
+
`[GeoPlugin] Auto-selected zoom${optimalZoom} (${plugin._getPrecisionDistance(optimalZoom)}km cells) for ${approximateRadius.toFixed(1)}km bounding box`
|
|
10908
|
+
);
|
|
10909
|
+
}
|
|
10910
|
+
} else {
|
|
10911
|
+
partitionName = "byGeohash";
|
|
10912
|
+
precision = config.precision;
|
|
10913
|
+
}
|
|
10914
|
+
if (this.config.partitions?.[partitionName]) {
|
|
10915
|
+
const geohashesToSearch = plugin._getGeohashesInBounds({
|
|
10916
|
+
north,
|
|
10917
|
+
south,
|
|
10918
|
+
east,
|
|
10919
|
+
west,
|
|
10920
|
+
precision
|
|
10921
|
+
});
|
|
10922
|
+
const partitionResults = await Promise.all(
|
|
10923
|
+
geohashesToSearch.map(async (geohash) => {
|
|
10924
|
+
const [ok, err, records] = await tryFn(async () => {
|
|
10925
|
+
const fieldName = config.zoomLevels ? `_geohash_zoom${precision}` : "_geohash";
|
|
10926
|
+
return await this.listPartition({
|
|
10927
|
+
partition: partitionName,
|
|
10928
|
+
partitionValues: { [fieldName]: geohash },
|
|
10929
|
+
limit: limit * 2
|
|
10930
|
+
});
|
|
10931
|
+
});
|
|
10932
|
+
return ok ? records : [];
|
|
10933
|
+
})
|
|
10934
|
+
);
|
|
10935
|
+
allRecords = partitionResults.flat();
|
|
10936
|
+
if (plugin.verbose) {
|
|
10937
|
+
console.log(
|
|
10938
|
+
`[GeoPlugin] findInBounds searched ${geohashesToSearch.length} ${partitionName} partitions, found ${allRecords.length} candidates`
|
|
10939
|
+
);
|
|
10940
|
+
}
|
|
10941
|
+
} else {
|
|
10942
|
+
allRecords = await this.list({ limit: limit * 10 });
|
|
10943
|
+
}
|
|
10944
|
+
} else {
|
|
10945
|
+
allRecords = await this.list({ limit: limit * 10 });
|
|
10946
|
+
}
|
|
10947
|
+
const inBounds = allRecords.filter((record) => {
|
|
10948
|
+
const lat = record[config.latField];
|
|
10949
|
+
const lon = record[config.lonField];
|
|
10950
|
+
if (lat === void 0 || lon === void 0) {
|
|
10951
|
+
return false;
|
|
10952
|
+
}
|
|
10953
|
+
return lat <= north && lat >= south && lon <= east && lon >= west;
|
|
10954
|
+
}).slice(0, limit);
|
|
10955
|
+
return inBounds;
|
|
10956
|
+
};
|
|
10957
|
+
resource.getDistance = async function(id1, id2) {
|
|
10958
|
+
let record1, record2;
|
|
10959
|
+
try {
|
|
10960
|
+
[record1, record2] = await Promise.all([
|
|
10961
|
+
this.get(id1),
|
|
10962
|
+
this.get(id2)
|
|
10963
|
+
]);
|
|
10964
|
+
} catch (err) {
|
|
10965
|
+
if (err.name === "NoSuchKey" || err.message?.includes("No such key")) {
|
|
10966
|
+
throw new Error("One or both records not found");
|
|
10967
|
+
}
|
|
10968
|
+
throw err;
|
|
10969
|
+
}
|
|
10970
|
+
if (!record1 || !record2) {
|
|
10971
|
+
throw new Error("One or both records not found");
|
|
10972
|
+
}
|
|
10973
|
+
const lat1 = record1[config.latField];
|
|
10974
|
+
const lon1 = record1[config.lonField];
|
|
10975
|
+
const lat2 = record2[config.latField];
|
|
10976
|
+
const lon2 = record2[config.lonField];
|
|
10977
|
+
if (lat1 === void 0 || lon1 === void 0 || lat2 === void 0 || lon2 === void 0) {
|
|
10978
|
+
throw new Error("One or both records missing coordinates");
|
|
10979
|
+
}
|
|
10980
|
+
const distance = plugin.calculateDistance(lat1, lon1, lat2, lon2);
|
|
10981
|
+
return {
|
|
10982
|
+
distance,
|
|
10983
|
+
unit: "km",
|
|
10984
|
+
from: id1,
|
|
10985
|
+
to: id2
|
|
10986
|
+
};
|
|
10987
|
+
};
|
|
10988
|
+
}
|
|
10989
|
+
/**
|
|
10990
|
+
* Encode coordinates to geohash
|
|
10991
|
+
* @param {number} latitude - Latitude (-90 to 90)
|
|
10992
|
+
* @param {number} longitude - Longitude (-180 to 180)
|
|
10993
|
+
* @param {number} precision - Number of characters in geohash
|
|
10994
|
+
* @returns {string} Geohash string
|
|
10995
|
+
*/
|
|
10996
|
+
encodeGeohash(latitude, longitude, precision = 5) {
|
|
10997
|
+
let idx = 0;
|
|
10998
|
+
let bit = 0;
|
|
10999
|
+
let evenBit = true;
|
|
11000
|
+
let geohash = "";
|
|
11001
|
+
let latMin = -90;
|
|
11002
|
+
let latMax = 90;
|
|
11003
|
+
let lonMin = -180;
|
|
11004
|
+
let lonMax = 180;
|
|
11005
|
+
while (geohash.length < precision) {
|
|
11006
|
+
if (evenBit) {
|
|
11007
|
+
const lonMid = (lonMin + lonMax) / 2;
|
|
11008
|
+
if (longitude > lonMid) {
|
|
11009
|
+
idx |= 1 << 4 - bit;
|
|
11010
|
+
lonMin = lonMid;
|
|
11011
|
+
} else {
|
|
11012
|
+
lonMax = lonMid;
|
|
11013
|
+
}
|
|
11014
|
+
} else {
|
|
11015
|
+
const latMid = (latMin + latMax) / 2;
|
|
11016
|
+
if (latitude > latMid) {
|
|
11017
|
+
idx |= 1 << 4 - bit;
|
|
11018
|
+
latMin = latMid;
|
|
11019
|
+
} else {
|
|
11020
|
+
latMax = latMid;
|
|
11021
|
+
}
|
|
11022
|
+
}
|
|
11023
|
+
evenBit = !evenBit;
|
|
11024
|
+
if (bit < 4) {
|
|
11025
|
+
bit++;
|
|
11026
|
+
} else {
|
|
11027
|
+
geohash += this.base32[idx];
|
|
11028
|
+
bit = 0;
|
|
11029
|
+
idx = 0;
|
|
11030
|
+
}
|
|
11031
|
+
}
|
|
11032
|
+
return geohash;
|
|
11033
|
+
}
|
|
11034
|
+
/**
|
|
11035
|
+
* Decode geohash to coordinates
|
|
11036
|
+
* @param {string} geohash - Geohash string
|
|
11037
|
+
* @returns {Object} { latitude, longitude, error }
|
|
11038
|
+
*/
|
|
11039
|
+
decodeGeohash(geohash) {
|
|
11040
|
+
let evenBit = true;
|
|
11041
|
+
let latMin = -90;
|
|
11042
|
+
let latMax = 90;
|
|
11043
|
+
let lonMin = -180;
|
|
11044
|
+
let lonMax = 180;
|
|
11045
|
+
for (let i = 0; i < geohash.length; i++) {
|
|
11046
|
+
const chr = geohash[i];
|
|
11047
|
+
const idx = this.base32.indexOf(chr);
|
|
11048
|
+
if (idx === -1) {
|
|
11049
|
+
throw new Error(`Invalid geohash character: ${chr}`);
|
|
11050
|
+
}
|
|
11051
|
+
for (let n = 4; n >= 0; n--) {
|
|
11052
|
+
const bitN = idx >> n & 1;
|
|
11053
|
+
if (evenBit) {
|
|
11054
|
+
const lonMid = (lonMin + lonMax) / 2;
|
|
11055
|
+
if (bitN === 1) {
|
|
11056
|
+
lonMin = lonMid;
|
|
11057
|
+
} else {
|
|
11058
|
+
lonMax = lonMid;
|
|
11059
|
+
}
|
|
11060
|
+
} else {
|
|
11061
|
+
const latMid = (latMin + latMax) / 2;
|
|
11062
|
+
if (bitN === 1) {
|
|
11063
|
+
latMin = latMid;
|
|
11064
|
+
} else {
|
|
11065
|
+
latMax = latMid;
|
|
11066
|
+
}
|
|
11067
|
+
}
|
|
11068
|
+
evenBit = !evenBit;
|
|
11069
|
+
}
|
|
11070
|
+
}
|
|
11071
|
+
const latitude = (latMin + latMax) / 2;
|
|
11072
|
+
const longitude = (lonMin + lonMax) / 2;
|
|
11073
|
+
return {
|
|
11074
|
+
latitude,
|
|
11075
|
+
longitude,
|
|
11076
|
+
error: {
|
|
11077
|
+
latitude: latMax - latMin,
|
|
11078
|
+
longitude: lonMax - lonMin
|
|
11079
|
+
}
|
|
11080
|
+
};
|
|
11081
|
+
}
|
|
11082
|
+
/**
|
|
11083
|
+
* Calculate distance between two coordinates using Haversine formula
|
|
11084
|
+
* @param {number} lat1 - Latitude of point 1
|
|
11085
|
+
* @param {number} lon1 - Longitude of point 1
|
|
11086
|
+
* @param {number} lat2 - Latitude of point 2
|
|
11087
|
+
* @param {number} lon2 - Longitude of point 2
|
|
11088
|
+
* @returns {number} Distance in kilometers
|
|
11089
|
+
*/
|
|
11090
|
+
calculateDistance(lat1, lon1, lat2, lon2) {
|
|
11091
|
+
const R = 6371;
|
|
11092
|
+
const dLat = this._toRadians(lat2 - lat1);
|
|
11093
|
+
const dLon = this._toRadians(lon2 - lon1);
|
|
11094
|
+
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(this._toRadians(lat1)) * Math.cos(this._toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
11095
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
11096
|
+
return R * c;
|
|
11097
|
+
}
|
|
11098
|
+
/**
|
|
11099
|
+
* Get geohash neighbors (8 surrounding cells)
|
|
11100
|
+
* @param {string} geohash - Center geohash
|
|
11101
|
+
* @returns {Array<string>} Array of 8 neighboring geohashes
|
|
11102
|
+
*/
|
|
11103
|
+
getNeighbors(geohash) {
|
|
11104
|
+
const decoded = this.decodeGeohash(geohash);
|
|
11105
|
+
const { latitude, longitude, error } = decoded;
|
|
11106
|
+
const latStep = error.latitude;
|
|
11107
|
+
const lonStep = error.longitude;
|
|
11108
|
+
const neighbors = [];
|
|
11109
|
+
const directions = [
|
|
11110
|
+
[-latStep, -lonStep],
|
|
11111
|
+
// SW
|
|
11112
|
+
[-latStep, 0],
|
|
11113
|
+
// S
|
|
11114
|
+
[-latStep, lonStep],
|
|
11115
|
+
// SE
|
|
11116
|
+
[0, -lonStep],
|
|
11117
|
+
// W
|
|
11118
|
+
[0, lonStep],
|
|
11119
|
+
// E
|
|
11120
|
+
[latStep, -lonStep],
|
|
11121
|
+
// NW
|
|
11122
|
+
[latStep, 0],
|
|
11123
|
+
// N
|
|
11124
|
+
[latStep, lonStep]
|
|
11125
|
+
// NE
|
|
11126
|
+
];
|
|
11127
|
+
for (const [latDelta, lonDelta] of directions) {
|
|
11128
|
+
const neighborHash = this.encodeGeohash(
|
|
11129
|
+
latitude + latDelta,
|
|
11130
|
+
longitude + lonDelta,
|
|
11131
|
+
geohash.length
|
|
11132
|
+
);
|
|
11133
|
+
neighbors.push(neighborHash);
|
|
11134
|
+
}
|
|
11135
|
+
return neighbors;
|
|
11136
|
+
}
|
|
11137
|
+
/**
|
|
11138
|
+
* Get all geohashes that cover a bounding box
|
|
11139
|
+
* @param {Object} bounds - Bounding box { north, south, east, west, precision }
|
|
11140
|
+
* @returns {Array<string>} Array of unique geohashes covering the area
|
|
11141
|
+
*/
|
|
11142
|
+
_getGeohashesInBounds({ north, south, east, west, precision }) {
|
|
11143
|
+
const geohashes = /* @__PURE__ */ new Set();
|
|
11144
|
+
const cellSize = this._getPrecisionDistance(precision);
|
|
11145
|
+
const latStep = cellSize / 111;
|
|
11146
|
+
const lonStep = cellSize / (111 * Math.cos(this._toRadians((north + south) / 2)));
|
|
11147
|
+
for (let lat = south; lat <= north; lat += latStep) {
|
|
11148
|
+
for (let lon = west; lon <= east; lon += lonStep) {
|
|
11149
|
+
const geohash = this.encodeGeohash(lat, lon, precision);
|
|
11150
|
+
geohashes.add(geohash);
|
|
11151
|
+
}
|
|
11152
|
+
}
|
|
11153
|
+
const corners = [
|
|
11154
|
+
[north, west],
|
|
11155
|
+
[north, east],
|
|
11156
|
+
[south, west],
|
|
11157
|
+
[south, east],
|
|
11158
|
+
[(north + south) / 2, west],
|
|
11159
|
+
[(north + south) / 2, east],
|
|
11160
|
+
[north, (east + west) / 2],
|
|
11161
|
+
[south, (east + west) / 2]
|
|
11162
|
+
];
|
|
11163
|
+
for (const [lat, lon] of corners) {
|
|
11164
|
+
const geohash = this.encodeGeohash(lat, lon, precision);
|
|
11165
|
+
geohashes.add(geohash);
|
|
11166
|
+
}
|
|
11167
|
+
return Array.from(geohashes);
|
|
11168
|
+
}
|
|
11169
|
+
/**
|
|
11170
|
+
* Convert degrees to radians
|
|
11171
|
+
*/
|
|
11172
|
+
_toRadians(degrees) {
|
|
11173
|
+
return degrees * (Math.PI / 180);
|
|
11174
|
+
}
|
|
11175
|
+
/**
|
|
11176
|
+
* Get approximate cell size for precision level
|
|
11177
|
+
*/
|
|
11178
|
+
_getPrecisionDistance(precision) {
|
|
11179
|
+
const distances = {
|
|
11180
|
+
1: 5e3,
|
|
11181
|
+
2: 1250,
|
|
11182
|
+
3: 156,
|
|
11183
|
+
4: 39,
|
|
11184
|
+
5: 4.9,
|
|
11185
|
+
6: 1.2,
|
|
11186
|
+
7: 0.15,
|
|
11187
|
+
8: 0.038,
|
|
11188
|
+
9: 47e-4,
|
|
11189
|
+
10: 12e-4,
|
|
11190
|
+
11: 15e-5,
|
|
11191
|
+
12: 37e-6
|
|
11192
|
+
};
|
|
11193
|
+
return distances[precision] || 5;
|
|
11194
|
+
}
|
|
11195
|
+
/**
|
|
11196
|
+
* Select optimal zoom level based on search radius
|
|
11197
|
+
* @param {Array<number>} zoomLevels - Available zoom levels
|
|
11198
|
+
* @param {number} radiusKm - Search radius in kilometers
|
|
11199
|
+
* @returns {number} Optimal zoom precision
|
|
11200
|
+
*/
|
|
11201
|
+
_selectOptimalZoom(zoomLevels, radiusKm) {
|
|
11202
|
+
if (!zoomLevels || zoomLevels.length === 0) {
|
|
11203
|
+
return null;
|
|
11204
|
+
}
|
|
11205
|
+
const targetCellSize = radiusKm / 2.5;
|
|
11206
|
+
let bestZoom = zoomLevels[0];
|
|
11207
|
+
let bestDiff = Math.abs(this._getPrecisionDistance(bestZoom) - targetCellSize);
|
|
11208
|
+
for (const zoom of zoomLevels) {
|
|
11209
|
+
const cellSize = this._getPrecisionDistance(zoom);
|
|
11210
|
+
const diff = Math.abs(cellSize - targetCellSize);
|
|
11211
|
+
if (diff < bestDiff) {
|
|
11212
|
+
bestDiff = diff;
|
|
11213
|
+
bestZoom = zoom;
|
|
11214
|
+
}
|
|
11215
|
+
}
|
|
11216
|
+
return bestZoom;
|
|
11217
|
+
}
|
|
11218
|
+
/**
|
|
11219
|
+
* Get plugin statistics
|
|
11220
|
+
*/
|
|
11221
|
+
getStats() {
|
|
11222
|
+
return {
|
|
11223
|
+
resources: Object.keys(this.resources).length,
|
|
11224
|
+
configurations: Object.entries(this.resources).map(([name, config]) => ({
|
|
11225
|
+
resource: name,
|
|
11226
|
+
latField: config.latField,
|
|
11227
|
+
lonField: config.lonField,
|
|
11228
|
+
precision: config.precision,
|
|
11229
|
+
cellSize: `~${this._getPrecisionDistance(config.precision)}km`
|
|
11230
|
+
}))
|
|
11231
|
+
};
|
|
11232
|
+
}
|
|
11233
|
+
/**
|
|
11234
|
+
* Uninstall the plugin
|
|
11235
|
+
*/
|
|
11236
|
+
async uninstall() {
|
|
11237
|
+
if (this.verbose) {
|
|
11238
|
+
console.log("[GeoPlugin] Uninstalled");
|
|
11239
|
+
}
|
|
11240
|
+
this.emit("uninstalled", {
|
|
11241
|
+
plugin: "GeoPlugin"
|
|
11242
|
+
});
|
|
11243
|
+
await super.uninstall();
|
|
11244
|
+
}
|
|
11245
|
+
}
|
|
11246
|
+
|
|
12608
11247
|
class MetricsPlugin extends Plugin {
|
|
12609
11248
|
constructor(options = {}) {
|
|
12610
11249
|
super();
|
|
@@ -22414,7 +21053,7 @@ class Database extends EventEmitter {
|
|
|
22414
21053
|
this.id = idGenerator(7);
|
|
22415
21054
|
this.version = "1";
|
|
22416
21055
|
this.s3dbVersion = (() => {
|
|
22417
|
-
const [ok, err, version] = tryFn(() => true ? "12.2.
|
|
21056
|
+
const [ok, err, version] = tryFn(() => true ? "12.2.2" : "latest");
|
|
22418
21057
|
return ok ? version : "latest";
|
|
22419
21058
|
})();
|
|
22420
21059
|
this._resourcesMap = {};
|
|
@@ -37329,6 +35968,532 @@ class TfStatePlugin extends Plugin {
|
|
|
37329
35968
|
}
|
|
37330
35969
|
}
|
|
37331
35970
|
|
|
35971
|
+
const GRANULARITIES = {
|
|
35972
|
+
minute: {
|
|
35973
|
+
threshold: 3600,
|
|
35974
|
+
// TTL < 1 hour
|
|
35975
|
+
interval: 1e4,
|
|
35976
|
+
// Check every 10 seconds
|
|
35977
|
+
cohortsToCheck: 3,
|
|
35978
|
+
// Check last 3 minutes
|
|
35979
|
+
cohortFormat: (date) => date.toISOString().substring(0, 16)
|
|
35980
|
+
// '2024-10-25T14:30'
|
|
35981
|
+
},
|
|
35982
|
+
hour: {
|
|
35983
|
+
threshold: 86400,
|
|
35984
|
+
// TTL < 24 hours
|
|
35985
|
+
interval: 6e5,
|
|
35986
|
+
// Check every 10 minutes
|
|
35987
|
+
cohortsToCheck: 2,
|
|
35988
|
+
// Check last 2 hours
|
|
35989
|
+
cohortFormat: (date) => date.toISOString().substring(0, 13)
|
|
35990
|
+
// '2024-10-25T14'
|
|
35991
|
+
},
|
|
35992
|
+
day: {
|
|
35993
|
+
threshold: 2592e3,
|
|
35994
|
+
// TTL < 30 days
|
|
35995
|
+
interval: 36e5,
|
|
35996
|
+
// Check every 1 hour
|
|
35997
|
+
cohortsToCheck: 2,
|
|
35998
|
+
// Check last 2 days
|
|
35999
|
+
cohortFormat: (date) => date.toISOString().substring(0, 10)
|
|
36000
|
+
// '2024-10-25'
|
|
36001
|
+
},
|
|
36002
|
+
week: {
|
|
36003
|
+
threshold: Infinity,
|
|
36004
|
+
// TTL >= 30 days
|
|
36005
|
+
interval: 864e5,
|
|
36006
|
+
// Check every 24 hours
|
|
36007
|
+
cohortsToCheck: 2,
|
|
36008
|
+
// Check last 2 weeks
|
|
36009
|
+
cohortFormat: (date) => {
|
|
36010
|
+
const year = date.getUTCFullYear();
|
|
36011
|
+
const week = getWeekNumber(date);
|
|
36012
|
+
return `${year}-W${String(week).padStart(2, "0")}`;
|
|
36013
|
+
}
|
|
36014
|
+
}
|
|
36015
|
+
};
|
|
36016
|
+
function getWeekNumber(date) {
|
|
36017
|
+
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
36018
|
+
const dayNum = d.getUTCDay() || 7;
|
|
36019
|
+
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
36020
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
36021
|
+
return Math.ceil(((d - yearStart) / 864e5 + 1) / 7);
|
|
36022
|
+
}
|
|
36023
|
+
function detectGranularity(ttl) {
|
|
36024
|
+
if (!ttl) return "day";
|
|
36025
|
+
if (ttl < GRANULARITIES.minute.threshold) return "minute";
|
|
36026
|
+
if (ttl < GRANULARITIES.hour.threshold) return "hour";
|
|
36027
|
+
if (ttl < GRANULARITIES.day.threshold) return "day";
|
|
36028
|
+
return "week";
|
|
36029
|
+
}
|
|
36030
|
+
function getExpiredCohorts(granularity, count) {
|
|
36031
|
+
const config = GRANULARITIES[granularity];
|
|
36032
|
+
const cohorts = [];
|
|
36033
|
+
const now = /* @__PURE__ */ new Date();
|
|
36034
|
+
for (let i = 0; i < count; i++) {
|
|
36035
|
+
let checkDate;
|
|
36036
|
+
switch (granularity) {
|
|
36037
|
+
case "minute":
|
|
36038
|
+
checkDate = new Date(now.getTime() - i * 6e4);
|
|
36039
|
+
break;
|
|
36040
|
+
case "hour":
|
|
36041
|
+
checkDate = new Date(now.getTime() - i * 36e5);
|
|
36042
|
+
break;
|
|
36043
|
+
case "day":
|
|
36044
|
+
checkDate = new Date(now.getTime() - i * 864e5);
|
|
36045
|
+
break;
|
|
36046
|
+
case "week":
|
|
36047
|
+
checkDate = new Date(now.getTime() - i * 6048e5);
|
|
36048
|
+
break;
|
|
36049
|
+
}
|
|
36050
|
+
cohorts.push(config.cohortFormat(checkDate));
|
|
36051
|
+
}
|
|
36052
|
+
return cohorts;
|
|
36053
|
+
}
|
|
36054
|
+
class TTLPlugin extends Plugin {
|
|
36055
|
+
constructor(config = {}) {
|
|
36056
|
+
super(config);
|
|
36057
|
+
this.verbose = config.verbose !== void 0 ? config.verbose : false;
|
|
36058
|
+
this.resources = config.resources || {};
|
|
36059
|
+
this.batchSize = config.batchSize || 100;
|
|
36060
|
+
this.stats = {
|
|
36061
|
+
totalScans: 0,
|
|
36062
|
+
totalExpired: 0,
|
|
36063
|
+
totalDeleted: 0,
|
|
36064
|
+
totalArchived: 0,
|
|
36065
|
+
totalSoftDeleted: 0,
|
|
36066
|
+
totalCallbacks: 0,
|
|
36067
|
+
totalErrors: 0,
|
|
36068
|
+
lastScanAt: null,
|
|
36069
|
+
lastScanDuration: 0
|
|
36070
|
+
};
|
|
36071
|
+
this.intervals = [];
|
|
36072
|
+
this.isRunning = false;
|
|
36073
|
+
this.expirationIndex = null;
|
|
36074
|
+
}
|
|
36075
|
+
/**
|
|
36076
|
+
* Install the plugin
|
|
36077
|
+
*/
|
|
36078
|
+
async install(database) {
|
|
36079
|
+
await super.install(database);
|
|
36080
|
+
for (const [resourceName, config] of Object.entries(this.resources)) {
|
|
36081
|
+
this._validateResourceConfig(resourceName, config);
|
|
36082
|
+
}
|
|
36083
|
+
await this._createExpirationIndex();
|
|
36084
|
+
for (const [resourceName, config] of Object.entries(this.resources)) {
|
|
36085
|
+
this._setupResourceHooks(resourceName, config);
|
|
36086
|
+
}
|
|
36087
|
+
this._startIntervals();
|
|
36088
|
+
if (this.verbose) {
|
|
36089
|
+
console.log(`[TTLPlugin] Installed with ${Object.keys(this.resources).length} resources`);
|
|
36090
|
+
}
|
|
36091
|
+
this.emit("installed", {
|
|
36092
|
+
plugin: "TTLPlugin",
|
|
36093
|
+
resources: Object.keys(this.resources)
|
|
36094
|
+
});
|
|
36095
|
+
}
|
|
36096
|
+
/**
|
|
36097
|
+
* Validate resource configuration
|
|
36098
|
+
*/
|
|
36099
|
+
_validateResourceConfig(resourceName, config) {
|
|
36100
|
+
if (!config.ttl && !config.field) {
|
|
36101
|
+
throw new Error(
|
|
36102
|
+
`[TTLPlugin] Resource "${resourceName}" must have either "ttl" (seconds) or "field" (timestamp field name)`
|
|
36103
|
+
);
|
|
36104
|
+
}
|
|
36105
|
+
const validStrategies = ["soft-delete", "hard-delete", "archive", "callback"];
|
|
36106
|
+
if (!config.onExpire || !validStrategies.includes(config.onExpire)) {
|
|
36107
|
+
throw new Error(
|
|
36108
|
+
`[TTLPlugin] Resource "${resourceName}" must have an "onExpire" value. Valid options: ${validStrategies.join(", ")}`
|
|
36109
|
+
);
|
|
36110
|
+
}
|
|
36111
|
+
if (config.onExpire === "soft-delete" && !config.deleteField) {
|
|
36112
|
+
config.deleteField = "deletedat";
|
|
36113
|
+
}
|
|
36114
|
+
if (config.onExpire === "archive" && !config.archiveResource) {
|
|
36115
|
+
throw new Error(
|
|
36116
|
+
`[TTLPlugin] Resource "${resourceName}" with onExpire="archive" must have an "archiveResource" specified`
|
|
36117
|
+
);
|
|
36118
|
+
}
|
|
36119
|
+
if (config.onExpire === "callback" && typeof config.callback !== "function") {
|
|
36120
|
+
throw new Error(
|
|
36121
|
+
`[TTLPlugin] Resource "${resourceName}" with onExpire="callback" must have a "callback" function`
|
|
36122
|
+
);
|
|
36123
|
+
}
|
|
36124
|
+
if (!config.field) {
|
|
36125
|
+
config.field = "_createdAt";
|
|
36126
|
+
}
|
|
36127
|
+
if (config.field === "_createdAt" && this.database) {
|
|
36128
|
+
const resource = this.database.resources[resourceName];
|
|
36129
|
+
if (resource && resource.config && resource.config.timestamps === false) {
|
|
36130
|
+
console.warn(
|
|
36131
|
+
`[TTLPlugin] WARNING: Resource "${resourceName}" uses TTL with field "_createdAt" but timestamps are disabled. TTL will be calculated from indexing time, not creation time.`
|
|
36132
|
+
);
|
|
36133
|
+
}
|
|
36134
|
+
}
|
|
36135
|
+
config.granularity = detectGranularity(config.ttl);
|
|
36136
|
+
}
|
|
36137
|
+
/**
|
|
36138
|
+
* Create expiration index (plugin resource)
|
|
36139
|
+
*/
|
|
36140
|
+
async _createExpirationIndex() {
|
|
36141
|
+
this.expirationIndex = await this.database.createResource({
|
|
36142
|
+
name: "plg_ttl_expiration_index",
|
|
36143
|
+
attributes: {
|
|
36144
|
+
resourceName: "string|required",
|
|
36145
|
+
recordId: "string|required",
|
|
36146
|
+
expiresAtCohort: "string|required",
|
|
36147
|
+
expiresAtTimestamp: "number|required",
|
|
36148
|
+
// Exact expiration timestamp for precise checking
|
|
36149
|
+
granularity: "string|required",
|
|
36150
|
+
createdAt: "number"
|
|
36151
|
+
},
|
|
36152
|
+
partitions: {
|
|
36153
|
+
byExpiresAtCohort: {
|
|
36154
|
+
fields: { expiresAtCohort: "string" }
|
|
36155
|
+
}
|
|
36156
|
+
},
|
|
36157
|
+
asyncPartitions: false
|
|
36158
|
+
// Sync partitions for deterministic behavior
|
|
36159
|
+
});
|
|
36160
|
+
if (this.verbose) {
|
|
36161
|
+
console.log("[TTLPlugin] Created expiration index with partition");
|
|
36162
|
+
}
|
|
36163
|
+
}
|
|
36164
|
+
/**
|
|
36165
|
+
* Setup hooks for a resource
|
|
36166
|
+
*/
|
|
36167
|
+
_setupResourceHooks(resourceName, config) {
|
|
36168
|
+
if (!this.database.resources[resourceName]) {
|
|
36169
|
+
if (this.verbose) {
|
|
36170
|
+
console.warn(`[TTLPlugin] Resource "${resourceName}" not found, skipping hooks`);
|
|
36171
|
+
}
|
|
36172
|
+
return;
|
|
36173
|
+
}
|
|
36174
|
+
const resource = this.database.resources[resourceName];
|
|
36175
|
+
if (typeof resource.insert !== "function" || typeof resource.delete !== "function") {
|
|
36176
|
+
if (this.verbose) {
|
|
36177
|
+
console.warn(`[TTLPlugin] Resource "${resourceName}" missing insert/delete methods, skipping hooks`);
|
|
36178
|
+
}
|
|
36179
|
+
return;
|
|
36180
|
+
}
|
|
36181
|
+
this.addMiddleware(resource, "insert", async (next, data, options) => {
|
|
36182
|
+
const result = await next(data, options);
|
|
36183
|
+
await this._addToIndex(resourceName, result, config);
|
|
36184
|
+
return result;
|
|
36185
|
+
});
|
|
36186
|
+
this.addMiddleware(resource, "delete", async (next, id, options) => {
|
|
36187
|
+
const result = await next(id, options);
|
|
36188
|
+
await this._removeFromIndex(resourceName, id);
|
|
36189
|
+
return result;
|
|
36190
|
+
});
|
|
36191
|
+
if (this.verbose) {
|
|
36192
|
+
console.log(`[TTLPlugin] Setup hooks for resource "${resourceName}"`);
|
|
36193
|
+
}
|
|
36194
|
+
}
|
|
36195
|
+
/**
|
|
36196
|
+
* Add record to expiration index
|
|
36197
|
+
*/
|
|
36198
|
+
async _addToIndex(resourceName, record, config) {
|
|
36199
|
+
try {
|
|
36200
|
+
let baseTime = record[config.field];
|
|
36201
|
+
if (!baseTime && config.field === "_createdAt") {
|
|
36202
|
+
baseTime = Date.now();
|
|
36203
|
+
}
|
|
36204
|
+
if (!baseTime) {
|
|
36205
|
+
if (this.verbose) {
|
|
36206
|
+
console.warn(
|
|
36207
|
+
`[TTLPlugin] Record ${record.id} in ${resourceName} missing field "${config.field}", skipping index`
|
|
36208
|
+
);
|
|
36209
|
+
}
|
|
36210
|
+
return;
|
|
36211
|
+
}
|
|
36212
|
+
const baseTimestamp = typeof baseTime === "number" ? baseTime : new Date(baseTime).getTime();
|
|
36213
|
+
const expiresAt = config.ttl ? new Date(baseTimestamp + config.ttl * 1e3) : new Date(baseTimestamp);
|
|
36214
|
+
const cohortConfig = GRANULARITIES[config.granularity];
|
|
36215
|
+
const cohort = cohortConfig.cohortFormat(expiresAt);
|
|
36216
|
+
const indexId = `${resourceName}:${record.id}`;
|
|
36217
|
+
await this.expirationIndex.insert({
|
|
36218
|
+
id: indexId,
|
|
36219
|
+
resourceName,
|
|
36220
|
+
recordId: record.id,
|
|
36221
|
+
expiresAtCohort: cohort,
|
|
36222
|
+
expiresAtTimestamp: expiresAt.getTime(),
|
|
36223
|
+
// Store exact timestamp for precise checking
|
|
36224
|
+
granularity: config.granularity,
|
|
36225
|
+
createdAt: Date.now()
|
|
36226
|
+
});
|
|
36227
|
+
if (this.verbose) {
|
|
36228
|
+
console.log(
|
|
36229
|
+
`[TTLPlugin] Added ${resourceName}:${record.id} to index (cohort: ${cohort}, granularity: ${config.granularity})`
|
|
36230
|
+
);
|
|
36231
|
+
}
|
|
36232
|
+
} catch (error) {
|
|
36233
|
+
console.error(`[TTLPlugin] Error adding to index:`, error);
|
|
36234
|
+
this.stats.totalErrors++;
|
|
36235
|
+
}
|
|
36236
|
+
}
|
|
36237
|
+
/**
|
|
36238
|
+
* Remove record from expiration index (O(1) using deterministic ID)
|
|
36239
|
+
*/
|
|
36240
|
+
async _removeFromIndex(resourceName, recordId) {
|
|
36241
|
+
try {
|
|
36242
|
+
const indexId = `${resourceName}:${recordId}`;
|
|
36243
|
+
const [ok, err] = await tryFn(() => this.expirationIndex.delete(indexId));
|
|
36244
|
+
if (this.verbose && ok) {
|
|
36245
|
+
console.log(`[TTLPlugin] Removed index entry for ${resourceName}:${recordId}`);
|
|
36246
|
+
}
|
|
36247
|
+
if (!ok && err?.code !== "NoSuchKey") {
|
|
36248
|
+
throw err;
|
|
36249
|
+
}
|
|
36250
|
+
} catch (error) {
|
|
36251
|
+
console.error(`[TTLPlugin] Error removing from index:`, error);
|
|
36252
|
+
}
|
|
36253
|
+
}
|
|
36254
|
+
/**
|
|
36255
|
+
* Start interval-based cleanup for each granularity
|
|
36256
|
+
*/
|
|
36257
|
+
_startIntervals() {
|
|
36258
|
+
const byGranularity = {
|
|
36259
|
+
minute: [],
|
|
36260
|
+
hour: [],
|
|
36261
|
+
day: [],
|
|
36262
|
+
week: []
|
|
36263
|
+
};
|
|
36264
|
+
for (const [name, config] of Object.entries(this.resources)) {
|
|
36265
|
+
byGranularity[config.granularity].push({ name, config });
|
|
36266
|
+
}
|
|
36267
|
+
for (const [granularity, resources] of Object.entries(byGranularity)) {
|
|
36268
|
+
if (resources.length === 0) continue;
|
|
36269
|
+
const granularityConfig = GRANULARITIES[granularity];
|
|
36270
|
+
const handle = setInterval(
|
|
36271
|
+
() => this._cleanupGranularity(granularity, resources),
|
|
36272
|
+
granularityConfig.interval
|
|
36273
|
+
);
|
|
36274
|
+
this.intervals.push(handle);
|
|
36275
|
+
if (this.verbose) {
|
|
36276
|
+
console.log(
|
|
36277
|
+
`[TTLPlugin] Started ${granularity} interval (${granularityConfig.interval}ms) for ${resources.length} resources`
|
|
36278
|
+
);
|
|
36279
|
+
}
|
|
36280
|
+
}
|
|
36281
|
+
this.isRunning = true;
|
|
36282
|
+
}
|
|
36283
|
+
/**
|
|
36284
|
+
* Stop all intervals
|
|
36285
|
+
*/
|
|
36286
|
+
_stopIntervals() {
|
|
36287
|
+
for (const handle of this.intervals) {
|
|
36288
|
+
clearInterval(handle);
|
|
36289
|
+
}
|
|
36290
|
+
this.intervals = [];
|
|
36291
|
+
this.isRunning = false;
|
|
36292
|
+
if (this.verbose) {
|
|
36293
|
+
console.log("[TTLPlugin] Stopped all intervals");
|
|
36294
|
+
}
|
|
36295
|
+
}
|
|
36296
|
+
/**
|
|
36297
|
+
* Cleanup expired records for a specific granularity
|
|
36298
|
+
*/
|
|
36299
|
+
async _cleanupGranularity(granularity, resources) {
|
|
36300
|
+
const startTime = Date.now();
|
|
36301
|
+
this.stats.totalScans++;
|
|
36302
|
+
try {
|
|
36303
|
+
const granularityConfig = GRANULARITIES[granularity];
|
|
36304
|
+
const cohorts = getExpiredCohorts(granularity, granularityConfig.cohortsToCheck);
|
|
36305
|
+
if (this.verbose) {
|
|
36306
|
+
console.log(`[TTLPlugin] Cleaning ${granularity} granularity, checking cohorts:`, cohorts);
|
|
36307
|
+
}
|
|
36308
|
+
for (const cohort of cohorts) {
|
|
36309
|
+
const expired = await this.expirationIndex.listPartition({
|
|
36310
|
+
partition: "byExpiresAtCohort",
|
|
36311
|
+
partitionValues: { expiresAtCohort: cohort }
|
|
36312
|
+
});
|
|
36313
|
+
const resourceNames = new Set(resources.map((r) => r.name));
|
|
36314
|
+
const filtered = expired.filter((e) => resourceNames.has(e.resourceName));
|
|
36315
|
+
if (this.verbose && filtered.length > 0) {
|
|
36316
|
+
console.log(`[TTLPlugin] Found ${filtered.length} expired records in cohort ${cohort}`);
|
|
36317
|
+
}
|
|
36318
|
+
for (let i = 0; i < filtered.length; i += this.batchSize) {
|
|
36319
|
+
const batch = filtered.slice(i, i + this.batchSize);
|
|
36320
|
+
for (const entry of batch) {
|
|
36321
|
+
const config = this.resources[entry.resourceName];
|
|
36322
|
+
await this._processExpiredEntry(entry, config);
|
|
36323
|
+
}
|
|
36324
|
+
}
|
|
36325
|
+
}
|
|
36326
|
+
this.stats.lastScanAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
36327
|
+
this.stats.lastScanDuration = Date.now() - startTime;
|
|
36328
|
+
this.emit("scanCompleted", {
|
|
36329
|
+
granularity,
|
|
36330
|
+
duration: this.stats.lastScanDuration,
|
|
36331
|
+
cohorts
|
|
36332
|
+
});
|
|
36333
|
+
} catch (error) {
|
|
36334
|
+
console.error(`[TTLPlugin] Error in ${granularity} cleanup:`, error);
|
|
36335
|
+
this.stats.totalErrors++;
|
|
36336
|
+
this.emit("cleanupError", { granularity, error });
|
|
36337
|
+
}
|
|
36338
|
+
}
|
|
36339
|
+
/**
|
|
36340
|
+
* Process a single expired index entry
|
|
36341
|
+
*/
|
|
36342
|
+
async _processExpiredEntry(entry, config) {
|
|
36343
|
+
try {
|
|
36344
|
+
if (!this.database.resources[entry.resourceName]) {
|
|
36345
|
+
if (this.verbose) {
|
|
36346
|
+
console.warn(`[TTLPlugin] Resource "${entry.resourceName}" not found during cleanup, skipping`);
|
|
36347
|
+
}
|
|
36348
|
+
return;
|
|
36349
|
+
}
|
|
36350
|
+
const resource = this.database.resources[entry.resourceName];
|
|
36351
|
+
const [ok, err, record] = await tryFn(() => resource.get(entry.recordId));
|
|
36352
|
+
if (!ok || !record) {
|
|
36353
|
+
await this.expirationIndex.delete(entry.id);
|
|
36354
|
+
return;
|
|
36355
|
+
}
|
|
36356
|
+
if (entry.expiresAtTimestamp && Date.now() < entry.expiresAtTimestamp) {
|
|
36357
|
+
return;
|
|
36358
|
+
}
|
|
36359
|
+
switch (config.onExpire) {
|
|
36360
|
+
case "soft-delete":
|
|
36361
|
+
await this._softDelete(resource, record, config);
|
|
36362
|
+
this.stats.totalSoftDeleted++;
|
|
36363
|
+
break;
|
|
36364
|
+
case "hard-delete":
|
|
36365
|
+
await this._hardDelete(resource, record);
|
|
36366
|
+
this.stats.totalDeleted++;
|
|
36367
|
+
break;
|
|
36368
|
+
case "archive":
|
|
36369
|
+
await this._archive(resource, record, config);
|
|
36370
|
+
this.stats.totalArchived++;
|
|
36371
|
+
this.stats.totalDeleted++;
|
|
36372
|
+
break;
|
|
36373
|
+
case "callback":
|
|
36374
|
+
const shouldDelete = await config.callback(record, resource);
|
|
36375
|
+
this.stats.totalCallbacks++;
|
|
36376
|
+
if (shouldDelete) {
|
|
36377
|
+
await this._hardDelete(resource, record);
|
|
36378
|
+
this.stats.totalDeleted++;
|
|
36379
|
+
}
|
|
36380
|
+
break;
|
|
36381
|
+
}
|
|
36382
|
+
await this.expirationIndex.delete(entry.id);
|
|
36383
|
+
this.stats.totalExpired++;
|
|
36384
|
+
this.emit("recordExpired", { resource: entry.resourceName, record });
|
|
36385
|
+
} catch (error) {
|
|
36386
|
+
console.error(`[TTLPlugin] Error processing expired entry:`, error);
|
|
36387
|
+
this.stats.totalErrors++;
|
|
36388
|
+
}
|
|
36389
|
+
}
|
|
36390
|
+
/**
|
|
36391
|
+
* Soft delete: Mark record as deleted
|
|
36392
|
+
*/
|
|
36393
|
+
async _softDelete(resource, record, config) {
|
|
36394
|
+
const deleteField = config.deleteField || "deletedat";
|
|
36395
|
+
const updates = {
|
|
36396
|
+
[deleteField]: (/* @__PURE__ */ new Date()).toISOString(),
|
|
36397
|
+
isdeleted: "true"
|
|
36398
|
+
// Add isdeleted field for partition compatibility
|
|
36399
|
+
};
|
|
36400
|
+
await resource.update(record.id, updates);
|
|
36401
|
+
if (this.verbose) {
|
|
36402
|
+
console.log(`[TTLPlugin] Soft-deleted record ${record.id} in ${resource.name}`);
|
|
36403
|
+
}
|
|
36404
|
+
}
|
|
36405
|
+
/**
|
|
36406
|
+
* Hard delete: Remove record from S3
|
|
36407
|
+
*/
|
|
36408
|
+
async _hardDelete(resource, record) {
|
|
36409
|
+
await resource.delete(record.id);
|
|
36410
|
+
if (this.verbose) {
|
|
36411
|
+
console.log(`[TTLPlugin] Hard-deleted record ${record.id} in ${resource.name}`);
|
|
36412
|
+
}
|
|
36413
|
+
}
|
|
36414
|
+
/**
|
|
36415
|
+
* Archive: Copy to another resource then delete
|
|
36416
|
+
*/
|
|
36417
|
+
async _archive(resource, record, config) {
|
|
36418
|
+
if (!this.database.resources[config.archiveResource]) {
|
|
36419
|
+
throw new Error(`Archive resource "${config.archiveResource}" not found`);
|
|
36420
|
+
}
|
|
36421
|
+
const archiveResource = this.database.resources[config.archiveResource];
|
|
36422
|
+
const archiveData = {};
|
|
36423
|
+
for (const [key, value] of Object.entries(record)) {
|
|
36424
|
+
if (!key.startsWith("_")) {
|
|
36425
|
+
archiveData[key] = value;
|
|
36426
|
+
}
|
|
36427
|
+
}
|
|
36428
|
+
archiveData.archivedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
36429
|
+
archiveData.archivedFrom = resource.name;
|
|
36430
|
+
archiveData.originalId = record.id;
|
|
36431
|
+
if (!config.keepOriginalId) {
|
|
36432
|
+
delete archiveData.id;
|
|
36433
|
+
}
|
|
36434
|
+
await archiveResource.insert(archiveData);
|
|
36435
|
+
await resource.delete(record.id);
|
|
36436
|
+
if (this.verbose) {
|
|
36437
|
+
console.log(`[TTLPlugin] Archived record ${record.id} from ${resource.name} to ${config.archiveResource}`);
|
|
36438
|
+
}
|
|
36439
|
+
}
|
|
36440
|
+
/**
|
|
36441
|
+
* Manual cleanup of a specific resource
|
|
36442
|
+
*/
|
|
36443
|
+
async cleanupResource(resourceName) {
|
|
36444
|
+
const config = this.resources[resourceName];
|
|
36445
|
+
if (!config) {
|
|
36446
|
+
throw new Error(`Resource "${resourceName}" not configured in TTLPlugin`);
|
|
36447
|
+
}
|
|
36448
|
+
const granularity = config.granularity;
|
|
36449
|
+
await this._cleanupGranularity(granularity, [{ name: resourceName, config }]);
|
|
36450
|
+
return {
|
|
36451
|
+
resource: resourceName,
|
|
36452
|
+
granularity
|
|
36453
|
+
};
|
|
36454
|
+
}
|
|
36455
|
+
/**
|
|
36456
|
+
* Manual cleanup of all resources
|
|
36457
|
+
*/
|
|
36458
|
+
async runCleanup() {
|
|
36459
|
+
const byGranularity = {
|
|
36460
|
+
minute: [],
|
|
36461
|
+
hour: [],
|
|
36462
|
+
day: [],
|
|
36463
|
+
week: []
|
|
36464
|
+
};
|
|
36465
|
+
for (const [name, config] of Object.entries(this.resources)) {
|
|
36466
|
+
byGranularity[config.granularity].push({ name, config });
|
|
36467
|
+
}
|
|
36468
|
+
for (const [granularity, resources] of Object.entries(byGranularity)) {
|
|
36469
|
+
if (resources.length > 0) {
|
|
36470
|
+
await this._cleanupGranularity(granularity, resources);
|
|
36471
|
+
}
|
|
36472
|
+
}
|
|
36473
|
+
}
|
|
36474
|
+
/**
|
|
36475
|
+
* Get plugin statistics
|
|
36476
|
+
*/
|
|
36477
|
+
getStats() {
|
|
36478
|
+
return {
|
|
36479
|
+
...this.stats,
|
|
36480
|
+
resources: Object.keys(this.resources).length,
|
|
36481
|
+
isRunning: this.isRunning,
|
|
36482
|
+
intervals: this.intervals.length
|
|
36483
|
+
};
|
|
36484
|
+
}
|
|
36485
|
+
/**
|
|
36486
|
+
* Uninstall the plugin
|
|
36487
|
+
*/
|
|
36488
|
+
async uninstall() {
|
|
36489
|
+
this._stopIntervals();
|
|
36490
|
+
await super.uninstall();
|
|
36491
|
+
if (this.verbose) {
|
|
36492
|
+
console.log("[TTLPlugin] Uninstalled");
|
|
36493
|
+
}
|
|
36494
|
+
}
|
|
36495
|
+
}
|
|
36496
|
+
|
|
37332
36497
|
function cosineDistance(a, b) {
|
|
37333
36498
|
if (a.length !== b.length) {
|
|
37334
36499
|
throw new Error(`Dimension mismatch: ${a.length} vs ${b.length}`);
|
|
@@ -39130,6 +38295,7 @@ exports.Factory = Factory;
|
|
|
39130
38295
|
exports.FilesystemBackupDriver = FilesystemBackupDriver;
|
|
39131
38296
|
exports.FilesystemCache = FilesystemCache;
|
|
39132
38297
|
exports.FullTextPlugin = FullTextPlugin;
|
|
38298
|
+
exports.GeoPlugin = GeoPlugin;
|
|
39133
38299
|
exports.InvalidResourceItem = InvalidResourceItem;
|
|
39134
38300
|
exports.MemoryCache = MemoryCache;
|
|
39135
38301
|
exports.MetadataLimitError = MetadataLimitError;
|
|
@@ -39177,6 +38343,7 @@ exports.SqsConsumer = SqsConsumer;
|
|
|
39177
38343
|
exports.SqsReplicator = SqsReplicator;
|
|
39178
38344
|
exports.StateMachinePlugin = StateMachinePlugin;
|
|
39179
38345
|
exports.StreamError = StreamError;
|
|
38346
|
+
exports.TTLPlugin = TTLPlugin;
|
|
39180
38347
|
exports.TfStatePlugin = TfStatePlugin;
|
|
39181
38348
|
exports.TursoReplicator = TursoReplicator;
|
|
39182
38349
|
exports.UnknownError = UnknownError;
|