@yrest/cli 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +232 -37
- package/dist/cli/index.js +424 -35
- package/dist/cli/index.mjs +424 -35
- package/dist/index.d.mts +137 -5
- package/dist/index.d.ts +137 -5
- package/dist/index.js +399 -72
- package/dist/index.mjs +396 -72
- package/package.json +1 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -698,11 +698,12 @@ function normaliseSseRoute(raw) {
|
|
|
698
698
|
return null;
|
|
699
699
|
}
|
|
700
700
|
const s = sse;
|
|
701
|
+
const handler = typeof s["_handler"] === "string" && s["_handler"].trim() ? s["_handler"].trim() : void 0;
|
|
701
702
|
const rawEvents = s["_events"];
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
if (!events.length) return null;
|
|
703
|
+
const events = Array.isArray(rawEvents) ? rawEvents.map(normaliseEvent).filter((e) => e !== null) : [];
|
|
704
|
+
if (!handler && !events.length) return null;
|
|
705
705
|
return {
|
|
706
|
+
...handler ? { handler } : {},
|
|
706
707
|
path,
|
|
707
708
|
interval: typeof s["_interval"] === "number" ? s["_interval"] : 1e3,
|
|
708
709
|
loop: s["_loop"] !== false,
|
|
@@ -1243,6 +1244,10 @@ function handlersAccordion(handlers, routes, base) {
|
|
|
1243
1244
|
function sseRoutesAccordion(routes, base) {
|
|
1244
1245
|
if (!routes.length) return "";
|
|
1245
1246
|
const rows = routes.map((r) => {
|
|
1247
|
+
if (r.handler) {
|
|
1248
|
+
const desc2 = `SSE stream <span style="color:#0ea5e9;font-size:11px">\u2192 ${escapeHtml(r.handler)}()</span>`;
|
|
1249
|
+
return endpointRow("SSE", `${base}${r.path}`, desc2);
|
|
1250
|
+
}
|
|
1246
1251
|
const tags = [
|
|
1247
1252
|
`<span style="color:#0ea5e9;font-size:11px">interval\xB7${r.interval}ms</span>`,
|
|
1248
1253
|
`<span style="color:var(--text-muted);font-size:11px">${r.events.length} event${r.events.length !== 1 ? "s" : ""}</span>`
|
|
@@ -2541,6 +2546,32 @@ var CollectionRouteCommand = class {
|
|
|
2541
2546
|
}
|
|
2542
2547
|
};
|
|
2543
2548
|
|
|
2549
|
+
// src/api/collection.ts
|
|
2550
|
+
function createCollectionHandle(storage, name, idStrategy = "increment") {
|
|
2551
|
+
if (!storage.getCollection(name)) {
|
|
2552
|
+
const known = Object.keys(storage.getData()).join(", ") || "none";
|
|
2553
|
+
throw new Error(`Collection "${name}" does not exist (known collections: ${known})`);
|
|
2554
|
+
}
|
|
2555
|
+
const items = () => storage.getCollection(name) ?? [];
|
|
2556
|
+
const copy = (item) => item === void 0 ? void 0 : structuredClone(item);
|
|
2557
|
+
return {
|
|
2558
|
+
findAll: () => items().map((i) => copy(i)),
|
|
2559
|
+
findById: (id) => copy(findById(items(), String(id))),
|
|
2560
|
+
findWhere: (predicate) => items().map((i) => copy(i)).filter(predicate),
|
|
2561
|
+
count: () => items().length,
|
|
2562
|
+
create: (data) => copy(createItem(storage, name, { ...data }, idStrategy)),
|
|
2563
|
+
update: (id, patch) => {
|
|
2564
|
+
const rest = { ...patch };
|
|
2565
|
+
delete rest["id"];
|
|
2566
|
+
return copy(patchItem(storage, name, String(id), rest));
|
|
2567
|
+
},
|
|
2568
|
+
delete: (id) => copy(deleteItem(storage, name, String(id)))
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2571
|
+
function collectionAccessor(storage, idStrategy = "increment") {
|
|
2572
|
+
return (name) => createCollectionHandle(storage, name, idStrategy);
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2544
2575
|
// src/utils/conditions.ts
|
|
2545
2576
|
function resolveRequestPath(dotPath, req) {
|
|
2546
2577
|
const [root, ...rest] = dotPath.split(".");
|
|
@@ -2603,15 +2634,20 @@ function resolveBody(body, ctx) {
|
|
|
2603
2634
|
return body ?? null;
|
|
2604
2635
|
}
|
|
2605
2636
|
var CustomRouteCommand = class {
|
|
2606
|
-
constructor(storage, base, handlers = /* @__PURE__ */ new Map()) {
|
|
2637
|
+
constructor(storage, base, handlers = /* @__PURE__ */ new Map(), idStrategy = "increment") {
|
|
2607
2638
|
this.storage = storage;
|
|
2608
2639
|
this.base = base;
|
|
2609
2640
|
this.handlers = handlers;
|
|
2641
|
+
this.idStrategy = idStrategy;
|
|
2610
2642
|
}
|
|
2611
2643
|
storage;
|
|
2612
2644
|
base;
|
|
2613
2645
|
handlers;
|
|
2646
|
+
idStrategy;
|
|
2614
2647
|
register(server) {
|
|
2648
|
+
const handlerCtx = {
|
|
2649
|
+
collection: collectionAccessor(this.storage, this.idStrategy)
|
|
2650
|
+
};
|
|
2615
2651
|
for (const route of this.storage.getRoutes()) {
|
|
2616
2652
|
const method = route.method?.toUpperCase();
|
|
2617
2653
|
const path = route.path;
|
|
@@ -2627,7 +2663,7 @@ var CustomRouteCommand = class {
|
|
|
2627
2663
|
url,
|
|
2628
2664
|
handler: async (req, reply) => {
|
|
2629
2665
|
if (route.delay && route.delay > 0) {
|
|
2630
|
-
await new Promise((
|
|
2666
|
+
await new Promise((resolve7) => setTimeout(resolve7, route.delay));
|
|
2631
2667
|
}
|
|
2632
2668
|
if (route.error) {
|
|
2633
2669
|
const body2 = route.errorBody ?? { error: `Forced error ${route.error}` };
|
|
@@ -2648,7 +2684,7 @@ var CustomRouteCommand = class {
|
|
|
2648
2684
|
body: req.body,
|
|
2649
2685
|
headers: req.headers
|
|
2650
2686
|
};
|
|
2651
|
-
const result = await fn(ctx2);
|
|
2687
|
+
const result = await fn(ctx2, handlerCtx);
|
|
2652
2688
|
const resStatus = result.status ?? 200;
|
|
2653
2689
|
for (const [k, v] of Object.entries(result.headers ?? {})) {
|
|
2654
2690
|
reply.header(k, v);
|
|
@@ -3251,26 +3287,64 @@ var SnapshotRouteCommand = class {
|
|
|
3251
3287
|
|
|
3252
3288
|
// src/router/routes/sse.routes.ts
|
|
3253
3289
|
var SSERouteCommand = class {
|
|
3254
|
-
constructor(storage, base) {
|
|
3290
|
+
constructor(storage, base, handlers = /* @__PURE__ */ new Map(), idStrategy = "increment") {
|
|
3255
3291
|
this.storage = storage;
|
|
3256
3292
|
this.base = base;
|
|
3293
|
+
this.handlers = handlers;
|
|
3294
|
+
this.idStrategy = idStrategy;
|
|
3257
3295
|
}
|
|
3258
3296
|
storage;
|
|
3259
3297
|
base;
|
|
3298
|
+
handlers;
|
|
3299
|
+
idStrategy;
|
|
3260
3300
|
register(server) {
|
|
3301
|
+
const handlerCtx = {
|
|
3302
|
+
collection: collectionAccessor(this.storage, this.idStrategy)
|
|
3303
|
+
};
|
|
3261
3304
|
for (const route of this.storage.getSseRoutes()) {
|
|
3262
3305
|
const url = `${this.base}${route.path}`;
|
|
3263
3306
|
server.route({
|
|
3264
3307
|
method: "GET",
|
|
3265
3308
|
url,
|
|
3266
3309
|
handler: async (req, reply) => {
|
|
3310
|
+
if (route.handler) {
|
|
3311
|
+
const fn = this.handlers.get(route.handler);
|
|
3312
|
+
if (!fn) {
|
|
3313
|
+
return reply.status(501).send({ error: `Handler "${route.handler}" is not defined in the handlers file` });
|
|
3314
|
+
}
|
|
3315
|
+
reply.hijack();
|
|
3316
|
+
const raw2 = reply.raw;
|
|
3317
|
+
openStream(raw2);
|
|
3318
|
+
const keepAlive2 = setInterval(() => {
|
|
3319
|
+
if (!raw2.writableEnded) raw2.write(": ping\n\n");
|
|
3320
|
+
}, 15e3);
|
|
3321
|
+
raw2.on("close", () => clearInterval(keepAlive2));
|
|
3322
|
+
const ctx = {
|
|
3323
|
+
params: req.params,
|
|
3324
|
+
query: req.query,
|
|
3325
|
+
body: null,
|
|
3326
|
+
headers: req.headers
|
|
3327
|
+
};
|
|
3328
|
+
try {
|
|
3329
|
+
await fn(ctx, raw2, handlerCtx);
|
|
3330
|
+
} catch (err) {
|
|
3331
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3332
|
+
clearInterval(keepAlive2);
|
|
3333
|
+
if (!raw2.writableEnded) {
|
|
3334
|
+
raw2.write(
|
|
3335
|
+
`event: error
|
|
3336
|
+
data: ${JSON.stringify({ error: `Handler "${route.handler}" threw an error: ${msg}` })}
|
|
3337
|
+
|
|
3338
|
+
`
|
|
3339
|
+
);
|
|
3340
|
+
raw2.end();
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
return;
|
|
3344
|
+
}
|
|
3267
3345
|
reply.hijack();
|
|
3268
3346
|
const raw = reply.raw;
|
|
3269
|
-
raw
|
|
3270
|
-
raw.setHeader("Cache-Control", "no-cache");
|
|
3271
|
-
raw.setHeader("Connection", "keep-alive");
|
|
3272
|
-
raw.setHeader("Access-Control-Allow-Origin", "*");
|
|
3273
|
-
raw.flushHeaders();
|
|
3347
|
+
openStream(raw);
|
|
3274
3348
|
let alive = true;
|
|
3275
3349
|
req.raw.on("close", () => {
|
|
3276
3350
|
alive = false;
|
|
@@ -3321,6 +3395,13 @@ var SSERouteCommand = class {
|
|
|
3321
3395
|
}
|
|
3322
3396
|
}
|
|
3323
3397
|
};
|
|
3398
|
+
function openStream(raw) {
|
|
3399
|
+
raw.setHeader("Content-Type", "text/event-stream");
|
|
3400
|
+
raw.setHeader("Cache-Control", "no-cache");
|
|
3401
|
+
raw.setHeader("Connection", "keep-alive");
|
|
3402
|
+
raw.setHeader("Access-Control-Allow-Origin", "*");
|
|
3403
|
+
raw.flushHeaders();
|
|
3404
|
+
}
|
|
3324
3405
|
|
|
3325
3406
|
// src/router/resource.router.ts
|
|
3326
3407
|
function buildResourceRouteCommands(storage, options) {
|
|
@@ -3363,8 +3444,8 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
|
|
|
3363
3444
|
new AboutRouteCommand(storage, options, handlers),
|
|
3364
3445
|
new OpenApiRouteCommand(storage, options),
|
|
3365
3446
|
...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
|
|
3366
|
-
new SSERouteCommand(storage, options.base),
|
|
3367
|
-
new CustomRouteCommand(storage, options.base, handlers),
|
|
3447
|
+
new SSERouteCommand(storage, options.base, handlers, options.idStrategy),
|
|
3448
|
+
new CustomRouteCommand(storage, options.base, handlers, options.idStrategy),
|
|
3368
3449
|
...buildResourceRouteCommands(storage, options)
|
|
3369
3450
|
];
|
|
3370
3451
|
for (const command of commands) {
|
|
@@ -3397,6 +3478,9 @@ function createYrestServerFromStorage(storage, options, handlers = /* @__PURE__
|
|
|
3397
3478
|
},
|
|
3398
3479
|
get url() {
|
|
3399
3480
|
return `http://${options.host}:${_port}${options.base}`;
|
|
3481
|
+
},
|
|
3482
|
+
collection(name) {
|
|
3483
|
+
return createCollectionHandle(storage, name, options.idStrategy);
|
|
3400
3484
|
}
|
|
3401
3485
|
};
|
|
3402
3486
|
}
|
|
@@ -3459,12 +3543,12 @@ function loadConfigFile(configPath) {
|
|
|
3459
3543
|
|
|
3460
3544
|
// src/utils/handlers.ts
|
|
3461
3545
|
import { existsSync as existsSync3 } from "fs";
|
|
3462
|
-
var ALLOWED_EXTENSIONS = [".js", ".mjs", ".cjs"];
|
|
3546
|
+
var ALLOWED_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts"];
|
|
3463
3547
|
async function loadHandlers(filePath) {
|
|
3464
3548
|
if (!existsSync3(filePath)) return /* @__PURE__ */ new Map();
|
|
3465
3549
|
if (!ALLOWED_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
|
|
3466
3550
|
console.error(
|
|
3467
|
-
` \x1B[31m[handlers] ${filePath} \u2014 only .js, .mjs and .
|
|
3551
|
+
` \x1B[31m[handlers] ${filePath} \u2014 only .js, .mjs, .cjs, .ts and .mts files are allowed\x1B[0m`
|
|
3468
3552
|
);
|
|
3469
3553
|
return /* @__PURE__ */ new Map();
|
|
3470
3554
|
}
|
|
@@ -3504,7 +3588,7 @@ function registerServe(program2) {
|
|
|
3504
3588
|
"Save a snapshot of the initial database state and expose /_snapshot endpoints"
|
|
3505
3589
|
).option(
|
|
3506
3590
|
"--handlers <file>",
|
|
3507
|
-
"Path to a
|
|
3591
|
+
"Path to a JS or TS file (.js, .mjs, .cjs, .ts, .mts) exporting handler functions"
|
|
3508
3592
|
).option(
|
|
3509
3593
|
"--id-strategy <strategy>",
|
|
3510
3594
|
"Id generation strategy for new items: increment (default) or uuid",
|
|
@@ -3617,26 +3701,59 @@ function registerServe(program2) {
|
|
|
3617
3701
|
import { existsSync as existsSync4, readFileSync as readFileSync4, appendFileSync, writeFileSync as writeFileSync3 } from "fs";
|
|
3618
3702
|
import { join as join3, resolve as resolve4, basename } from "path";
|
|
3619
3703
|
import { parse as parse3, stringify as stringify3 } from "yaml";
|
|
3620
|
-
var
|
|
3621
|
-
// Handler signature: (req: HandlerRequest) => HandlerResponse | Promise<HandlerResponse>
|
|
3622
|
-
// See https://github.com/aggiovato/
|
|
3704
|
+
var JS_HEADER = `// yrest handlers \u2014 loaded via "handlers:" in yrest.config.yml
|
|
3705
|
+
// Handler signature: (req: HandlerRequest, ctx: HandlerContext) => HandlerResponse | Promise<HandlerResponse>
|
|
3706
|
+
// See https://github.com/aggiovato/yRest for full documentation
|
|
3707
|
+
`;
|
|
3708
|
+
var TS_HEADER = `// yrest handlers \u2014 loaded via "handlers:" in yrest.config.yml
|
|
3709
|
+
// TypeScript handlers need Node >= 22.18 (built-in type stripping) or running yrest through tsx.
|
|
3710
|
+
// See https://github.com/aggiovato/yRest for full documentation
|
|
3711
|
+
import { defineHandler } from "@yrest/cli";
|
|
3623
3712
|
`;
|
|
3624
|
-
function
|
|
3713
|
+
function isTypeScriptFile(filePath) {
|
|
3714
|
+
return filePath.endsWith(".ts") || filePath.endsWith(".mts");
|
|
3715
|
+
}
|
|
3716
|
+
function handlersFileHeader(typescript) {
|
|
3717
|
+
return typescript ? TS_HEADER : JS_HEADER;
|
|
3718
|
+
}
|
|
3719
|
+
function hasHandler(content, name) {
|
|
3720
|
+
return content.includes(`function ${name}(`) || new RegExp(`\\bconst ${name}\\s*=`).test(content);
|
|
3721
|
+
}
|
|
3722
|
+
function buildStub(name, method, path, typescript = false) {
|
|
3625
3723
|
const tag = method && path ? `Handler for ${method.toUpperCase()} ${path}` : `yrest handler function`;
|
|
3724
|
+
if (typescript) {
|
|
3725
|
+
return [
|
|
3726
|
+
"",
|
|
3727
|
+
`/** ${tag} */`,
|
|
3728
|
+
`export const ${name} = defineHandler(async (req, ctx) => {`,
|
|
3729
|
+
` // TODO: implement handler logic \u2014 ctx.collection("name") gives typed access to the data`,
|
|
3730
|
+
` return { status: 200, body: null };`,
|
|
3731
|
+
`});`,
|
|
3732
|
+
""
|
|
3733
|
+
].join("\n");
|
|
3734
|
+
}
|
|
3626
3735
|
return [
|
|
3627
3736
|
"",
|
|
3628
3737
|
`/**`,
|
|
3629
3738
|
` * ${tag}`,
|
|
3630
3739
|
` * @param {import('@yrest/cli').HandlerRequest} req`,
|
|
3740
|
+
` * @param {import('@yrest/cli').HandlerContext} ctx`,
|
|
3631
3741
|
` * @returns {Promise<import('@yrest/cli').HandlerResponse>}`,
|
|
3632
3742
|
` */`,
|
|
3633
|
-
`export async function ${name}(req) {`,
|
|
3634
|
-
` // TODO: implement handler logic`,
|
|
3743
|
+
`export async function ${name}(req, ctx) {`,
|
|
3744
|
+
` // TODO: implement handler logic \u2014 ctx.collection("name") gives access to the data`,
|
|
3635
3745
|
` return { status: 200, body: null };`,
|
|
3636
3746
|
`}`,
|
|
3637
3747
|
""
|
|
3638
3748
|
].join("\n");
|
|
3639
3749
|
}
|
|
3750
|
+
function addRouteEntry(raw, name, method, path) {
|
|
3751
|
+
if (!Array.isArray(raw["_routes"])) raw["_routes"] = [];
|
|
3752
|
+
const routes = raw["_routes"];
|
|
3753
|
+
if (routes.some((r) => r["_handler"] === name || r["handler"] === name)) return false;
|
|
3754
|
+
routes.push({ _method: method.toUpperCase(), _path: path, _handler: name });
|
|
3755
|
+
return true;
|
|
3756
|
+
}
|
|
3640
3757
|
function registerHandler(program2) {
|
|
3641
3758
|
program2.command("handler").description("Scaffold a handler function stub in the handlers file").argument("<name>", "Name of the handler function to create").option("-m, --method <method>", "HTTP method hint for the stub JSDoc (e.g. POST)").option("-p, --path <path>", "Route path hint for the stub JSDoc (e.g. /login)").option(
|
|
3642
3759
|
"--register",
|
|
@@ -3647,20 +3764,18 @@ function registerHandler(program2) {
|
|
|
3647
3764
|
fileConfig.handlers ?? "yrest.handlers.js"
|
|
3648
3765
|
);
|
|
3649
3766
|
const dbPath = resolve4(fileConfig.file ?? "db.yml");
|
|
3767
|
+
const typescript = isTypeScriptFile(handlersPath);
|
|
3768
|
+
const stub = buildStub(name, flags.method, flags.path, typescript);
|
|
3650
3769
|
if (!existsSync4(handlersPath)) {
|
|
3651
|
-
writeFileSync3(
|
|
3652
|
-
handlersPath,
|
|
3653
|
-
HANDLERS_FILE_HEADER + buildStub(name, flags.method, flags.path),
|
|
3654
|
-
"utf8"
|
|
3655
|
-
);
|
|
3770
|
+
writeFileSync3(handlersPath, handlersFileHeader(typescript) + stub, "utf8");
|
|
3656
3771
|
console.log(` Created ${basename(handlersPath)}`);
|
|
3657
3772
|
} else {
|
|
3658
3773
|
const existing = readFileSync4(handlersPath, "utf8");
|
|
3659
|
-
if (existing
|
|
3774
|
+
if (hasHandler(existing, name)) {
|
|
3660
3775
|
console.error(` Error: handler "${name}" already exists in ${basename(handlersPath)}`);
|
|
3661
3776
|
process.exit(1);
|
|
3662
3777
|
}
|
|
3663
|
-
appendFileSync(handlersPath,
|
|
3778
|
+
appendFileSync(handlersPath, stub, "utf8");
|
|
3664
3779
|
console.log(` Added handler "${name}" to ${basename(handlersPath)}`);
|
|
3665
3780
|
}
|
|
3666
3781
|
if (flags.register) {
|
|
@@ -3673,11 +3788,7 @@ function registerHandler(program2) {
|
|
|
3673
3788
|
process.exit(1);
|
|
3674
3789
|
}
|
|
3675
3790
|
const raw = parse3(readFileSync4(dbPath, "utf8")) ?? {};
|
|
3676
|
-
if (
|
|
3677
|
-
const routes = raw["_routes"];
|
|
3678
|
-
const alreadyRegistered = routes.some((r) => r["handler"] === name);
|
|
3679
|
-
if (!alreadyRegistered) {
|
|
3680
|
-
routes.push({ method: flags.method.toUpperCase(), path: flags.path, handler: name });
|
|
3791
|
+
if (addRouteEntry(raw, name, flags.method, flags.path)) {
|
|
3681
3792
|
writeFileSync3(dbPath, stringify3(raw), "utf8");
|
|
3682
3793
|
console.log(` Added _routes entry to ${basename(dbPath)}`);
|
|
3683
3794
|
} else {
|
|
@@ -3728,6 +3839,283 @@ function registerOpenApi(program2) {
|
|
|
3728
3839
|
});
|
|
3729
3840
|
}
|
|
3730
3841
|
|
|
3842
|
+
// src/cli/commands/types.ts
|
|
3843
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, watchFile as watchFile2 } from "fs";
|
|
3844
|
+
import { basename as basename2, resolve as resolve6 } from "path";
|
|
3845
|
+
import { parse as parse4 } from "yaml";
|
|
3846
|
+
|
|
3847
|
+
// src/codegen/generateTypes.ts
|
|
3848
|
+
var HEADER = "// Auto-generated by yrest types \u2014 do not edit manually.";
|
|
3849
|
+
var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
3850
|
+
var MAX_DEPTH = 4;
|
|
3851
|
+
function generateTypes(input, options = {}) {
|
|
3852
|
+
const { data, schema = {}, relations = {} } = input;
|
|
3853
|
+
const collections = unique([...Object.keys(data), ...Object.keys(schema)]);
|
|
3854
|
+
const names = resolveInterfaceNames(collections);
|
|
3855
|
+
const out = [HEADER];
|
|
3856
|
+
if (options.source) out.push(`// Source: ${options.source}`);
|
|
3857
|
+
out.push("");
|
|
3858
|
+
for (const collection of collections) {
|
|
3859
|
+
out.push(
|
|
3860
|
+
renderInterface(
|
|
3861
|
+
names.get(collection),
|
|
3862
|
+
collection,
|
|
3863
|
+
data[collection] ?? [],
|
|
3864
|
+
schema[collection] ?? {},
|
|
3865
|
+
relations[collection] ?? {}
|
|
3866
|
+
)
|
|
3867
|
+
);
|
|
3868
|
+
out.push("");
|
|
3869
|
+
}
|
|
3870
|
+
out.push(`/** Union of all collection names in the source file. */`);
|
|
3871
|
+
out.push(
|
|
3872
|
+
collections.length > 0 ? `export type CollectionName = ${collections.map((c) => quote(c)).join(" | ")};` : "export type CollectionName = never;"
|
|
3873
|
+
);
|
|
3874
|
+
out.push("");
|
|
3875
|
+
out.push(`/** Maps each collection name to its item array type. */`);
|
|
3876
|
+
out.push("export interface Collections {");
|
|
3877
|
+
for (const collection of collections) {
|
|
3878
|
+
out.push(` ${propKey(collection)}: ${names.get(collection)}[];`);
|
|
3879
|
+
}
|
|
3880
|
+
out.push("}");
|
|
3881
|
+
return out.join("\n") + "\n";
|
|
3882
|
+
}
|
|
3883
|
+
function renderInterface(name, collection, items, fieldDefs, rels) {
|
|
3884
|
+
const records = items.filter(isPlainObject);
|
|
3885
|
+
const fields = collectFields(records);
|
|
3886
|
+
const fieldNames = unique([...fields.keys(), ...Object.keys(fieldDefs)]);
|
|
3887
|
+
const lines = [`/** Item of the \`${collection}\` collection. */`];
|
|
3888
|
+
lines.push(`export interface ${name} {`);
|
|
3889
|
+
for (const field of fieldNames) {
|
|
3890
|
+
const info = fields.get(field);
|
|
3891
|
+
const def = fieldDefs[field];
|
|
3892
|
+
const inferredRequired = info !== void 0 && info.count === records.length && records.length > 0;
|
|
3893
|
+
const required = def?.required ?? inferredRequired;
|
|
3894
|
+
let type = def ? typeFromDef(def, info) : typeOfValues(info.values, 0);
|
|
3895
|
+
if (def?.nullable === true && !type.split(" | ").includes("null")) type = `${type} | null`;
|
|
3896
|
+
for (const doc of fieldDoc(field, def, rels)) lines.push(` ${doc}`);
|
|
3897
|
+
lines.push(` ${propKey(field)}${required ? "" : "?"}: ${type};`);
|
|
3898
|
+
}
|
|
3899
|
+
if (fieldNames.length === 0) lines.push(" [key: string]: unknown;");
|
|
3900
|
+
lines.push("}");
|
|
3901
|
+
return lines.join("\n");
|
|
3902
|
+
}
|
|
3903
|
+
function fieldDoc(field, def, rels) {
|
|
3904
|
+
const text = [];
|
|
3905
|
+
const tags = [];
|
|
3906
|
+
if (def?.description) text.push(def.description.replace(/\*\//g, "*\\/"));
|
|
3907
|
+
const rel = Object.entries(rels).find(
|
|
3908
|
+
([key, r]) => r.type !== "many2many" && (r.foreignKey ?? key) === field
|
|
3909
|
+
)?.[1];
|
|
3910
|
+
if (rel) text.push(`FK \u2192 ${rel.target}.id`);
|
|
3911
|
+
if (def?.format) tags.push(`@format ${def.format}`);
|
|
3912
|
+
if (def?.deprecated) tags.push("@deprecated");
|
|
3913
|
+
if (text.length === 0 && tags.length === 0) return [];
|
|
3914
|
+
if (tags.length === 0) return [`/** ${text.join(" \u2014 ")} */`];
|
|
3915
|
+
return ["/**", ...[...text, ...tags].map((l) => ` * ${l}`), " */"];
|
|
3916
|
+
}
|
|
3917
|
+
function collectFields(records) {
|
|
3918
|
+
const fields = /* @__PURE__ */ new Map();
|
|
3919
|
+
for (const record of records) {
|
|
3920
|
+
for (const [key, value] of Object.entries(record)) {
|
|
3921
|
+
let info = fields.get(key);
|
|
3922
|
+
if (!info) {
|
|
3923
|
+
info = { values: [], count: 0 };
|
|
3924
|
+
fields.set(key, info);
|
|
3925
|
+
}
|
|
3926
|
+
info.count++;
|
|
3927
|
+
info.values.push(value);
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
return fields;
|
|
3931
|
+
}
|
|
3932
|
+
function typeOfValues(values, depth) {
|
|
3933
|
+
return unionOf(typeSetOf(values, depth));
|
|
3934
|
+
}
|
|
3935
|
+
function typeSetOf(values, depth) {
|
|
3936
|
+
const types = /* @__PURE__ */ new Set();
|
|
3937
|
+
const objects = [];
|
|
3938
|
+
const elements = [];
|
|
3939
|
+
let sawArray = false;
|
|
3940
|
+
for (const value of values) {
|
|
3941
|
+
if (value === null || value === void 0) types.add("null");
|
|
3942
|
+
else if (typeof value === "string") types.add("string");
|
|
3943
|
+
else if (typeof value === "number") types.add("number");
|
|
3944
|
+
else if (typeof value === "boolean") types.add("boolean");
|
|
3945
|
+
else if (Array.isArray(value)) {
|
|
3946
|
+
sawArray = true;
|
|
3947
|
+
elements.push(...value);
|
|
3948
|
+
} else if (isPlainObject(value)) objects.push(value);
|
|
3949
|
+
else types.add("unknown");
|
|
3950
|
+
}
|
|
3951
|
+
if (objects.length > 0) {
|
|
3952
|
+
types.add(depth >= MAX_DEPTH ? "Record<string, unknown>" : inlineObject(objects, depth + 1));
|
|
3953
|
+
}
|
|
3954
|
+
if (sawArray) {
|
|
3955
|
+
types.add(
|
|
3956
|
+
elements.length === 0 ? "unknown[]" : depth >= MAX_DEPTH ? "unknown[]" : arrayOf(typeSetOf(elements, depth + 1))
|
|
3957
|
+
);
|
|
3958
|
+
}
|
|
3959
|
+
return types;
|
|
3960
|
+
}
|
|
3961
|
+
function inlineObject(records, depth) {
|
|
3962
|
+
const fields = collectFields(records);
|
|
3963
|
+
if (fields.size === 0) return "Record<string, unknown>";
|
|
3964
|
+
const props = [...fields].map(([key, info]) => {
|
|
3965
|
+
const optional = info.count < records.length ? "?" : "";
|
|
3966
|
+
return `${propKey(key)}${optional}: ${typeOfValues(info.values, depth)}`;
|
|
3967
|
+
});
|
|
3968
|
+
return `{ ${props.join("; ")} }`;
|
|
3969
|
+
}
|
|
3970
|
+
function unionOf(types) {
|
|
3971
|
+
const list = [...types];
|
|
3972
|
+
if (list.length === 0) return "unknown";
|
|
3973
|
+
if (list.length === 1 && list[0] === "null") return "unknown";
|
|
3974
|
+
const hasConcreteArray = list.some((t) => t.endsWith("[]") && t !== "unknown[]");
|
|
3975
|
+
const filtered = hasConcreteArray ? list.filter((t) => t !== "unknown[]") : list;
|
|
3976
|
+
filtered.sort((a, b) => a === "null" ? 1 : b === "null" ? -1 : 0);
|
|
3977
|
+
return filtered.join(" | ");
|
|
3978
|
+
}
|
|
3979
|
+
function arrayOf(elements) {
|
|
3980
|
+
const element = unionOf(elements);
|
|
3981
|
+
return element.includes(" | ") && elements.size > 1 ? `(${element})[]` : `${element}[]`;
|
|
3982
|
+
}
|
|
3983
|
+
function typeFromDef(def, info) {
|
|
3984
|
+
if (def.enum && def.enum.length > 0) {
|
|
3985
|
+
return unique(def.enum.map((v) => literal(v))).join(" | ");
|
|
3986
|
+
}
|
|
3987
|
+
if (def.type) {
|
|
3988
|
+
if (def.type === "array") {
|
|
3989
|
+
const items = def.items;
|
|
3990
|
+
if (items?.enum && items.enum.length > 0)
|
|
3991
|
+
return arrayOf(new Set(items.enum.map((v) => literal(v))));
|
|
3992
|
+
if (items?.type) return arrayOf(/* @__PURE__ */ new Set([primitiveFor(items.type)]));
|
|
3993
|
+
const inferred = info ? typeOfValues(info.values, 0) : "";
|
|
3994
|
+
return inferred.endsWith("[]") && !inferred.includes(" | ") ? inferred : "unknown[]";
|
|
3995
|
+
}
|
|
3996
|
+
return primitiveFor(def.type);
|
|
3997
|
+
}
|
|
3998
|
+
return info ? typeOfValues(info.values, 0) : "unknown";
|
|
3999
|
+
}
|
|
4000
|
+
function primitiveFor(type) {
|
|
4001
|
+
switch (type) {
|
|
4002
|
+
case "integer":
|
|
4003
|
+
case "number":
|
|
4004
|
+
return "number";
|
|
4005
|
+
case "string":
|
|
4006
|
+
return "string";
|
|
4007
|
+
case "boolean":
|
|
4008
|
+
return "boolean";
|
|
4009
|
+
case "array":
|
|
4010
|
+
return "unknown[]";
|
|
4011
|
+
case "object":
|
|
4012
|
+
return "Record<string, unknown>";
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
function literal(value) {
|
|
4016
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
4017
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
4018
|
+
if (value === null) return "null";
|
|
4019
|
+
return "unknown";
|
|
4020
|
+
}
|
|
4021
|
+
function resolveInterfaceNames(collections) {
|
|
4022
|
+
const names = /* @__PURE__ */ new Map();
|
|
4023
|
+
const used = /* @__PURE__ */ new Set();
|
|
4024
|
+
for (const collection of collections) {
|
|
4025
|
+
const words = splitWords(collection);
|
|
4026
|
+
const candidates = [
|
|
4027
|
+
toPascal([...words.slice(0, -1), singularize(words.at(-1) ?? "")]),
|
|
4028
|
+
toPascal(words)
|
|
4029
|
+
];
|
|
4030
|
+
let name = candidates.find((c) => c && !used.has(c));
|
|
4031
|
+
if (!name) {
|
|
4032
|
+
const base = candidates[1] || "Collection";
|
|
4033
|
+
let i = 2;
|
|
4034
|
+
while (used.has(`${base}${i}`)) i++;
|
|
4035
|
+
name = `${base}${i}`;
|
|
4036
|
+
}
|
|
4037
|
+
if (/^[0-9]/.test(name)) name = `_${name}`;
|
|
4038
|
+
used.add(name);
|
|
4039
|
+
names.set(collection, name);
|
|
4040
|
+
}
|
|
4041
|
+
return names;
|
|
4042
|
+
}
|
|
4043
|
+
function splitWords(name) {
|
|
4044
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
4045
|
+
}
|
|
4046
|
+
function toPascal(words) {
|
|
4047
|
+
return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join("");
|
|
4048
|
+
}
|
|
4049
|
+
function singularize(word) {
|
|
4050
|
+
const lower = word.toLowerCase();
|
|
4051
|
+
if (lower.length <= 2) return word;
|
|
4052
|
+
if (/(ss|us|is)$/.test(lower)) return word;
|
|
4053
|
+
if (/[^aeiou]ies$/.test(lower)) return word.slice(0, -3) + "y";
|
|
4054
|
+
if (/(s|x|z|ch|sh)es$/.test(lower)) return word.slice(0, -2);
|
|
4055
|
+
if (lower.endsWith("s")) return word.slice(0, -1);
|
|
4056
|
+
return word;
|
|
4057
|
+
}
|
|
4058
|
+
function propKey(key) {
|
|
4059
|
+
return IDENT_RE.test(key) ? key : JSON.stringify(key);
|
|
4060
|
+
}
|
|
4061
|
+
function quote(value) {
|
|
4062
|
+
return JSON.stringify(value);
|
|
4063
|
+
}
|
|
4064
|
+
function unique(list) {
|
|
4065
|
+
return [...new Set(list)];
|
|
4066
|
+
}
|
|
4067
|
+
function isPlainObject(value) {
|
|
4068
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
4069
|
+
}
|
|
4070
|
+
|
|
4071
|
+
// src/cli/commands/types.ts
|
|
4072
|
+
function generateTypesFromFile(filePath) {
|
|
4073
|
+
const absPath = resolve6(filePath);
|
|
4074
|
+
const raw = parse4(readFileSync5(absPath, "utf8")) ?? {};
|
|
4075
|
+
return generateTypes(
|
|
4076
|
+
{
|
|
4077
|
+
data: parseData(raw),
|
|
4078
|
+
schema: parseSchema(raw["_schema"]),
|
|
4079
|
+
relations: parseRelations(raw["_rel"])
|
|
4080
|
+
},
|
|
4081
|
+
{ source: basename2(absPath) }
|
|
4082
|
+
);
|
|
4083
|
+
}
|
|
4084
|
+
function registerTypes(program2) {
|
|
4085
|
+
program2.command("types <file>").description("Generate TypeScript interfaces from a db.yml file").option("-o, --output <file>", "Write to a file instead of stdout (e.g. db.types.ts)").option("-w, --watch", "Regenerate on every change of <file> (requires --output)").action((file, opts) => {
|
|
4086
|
+
if (opts.watch && !opts.output) {
|
|
4087
|
+
console.error("\u2717 --watch requires --output");
|
|
4088
|
+
process.exitCode = 1;
|
|
4089
|
+
return;
|
|
4090
|
+
}
|
|
4091
|
+
const run = () => {
|
|
4092
|
+
try {
|
|
4093
|
+
const output = generateTypesFromFile(file);
|
|
4094
|
+
if (!opts.output) {
|
|
4095
|
+
process.stdout.write(output);
|
|
4096
|
+
} else {
|
|
4097
|
+
const outFile = resolve6(opts.output);
|
|
4098
|
+
writeFileSync5(outFile, output, "utf8");
|
|
4099
|
+
console.log(`\u2713 Types written to ${outFile}`);
|
|
4100
|
+
}
|
|
4101
|
+
return true;
|
|
4102
|
+
} catch (err) {
|
|
4103
|
+
console.error(`\u2717 Could not generate types: ${err.message}`);
|
|
4104
|
+
return false;
|
|
4105
|
+
}
|
|
4106
|
+
};
|
|
4107
|
+
const ok2 = run();
|
|
4108
|
+
if (!opts.watch) {
|
|
4109
|
+
if (!ok2) process.exitCode = 1;
|
|
4110
|
+
return;
|
|
4111
|
+
}
|
|
4112
|
+
console.log(`\u{1F440} Watching ${resolve6(file)} \u2014 Ctrl+C to stop`);
|
|
4113
|
+
watchFile2(resolve6(file), { interval: 300 }, (curr, prev) => {
|
|
4114
|
+
if (curr.mtimeMs !== prev.mtimeMs) run();
|
|
4115
|
+
});
|
|
4116
|
+
});
|
|
4117
|
+
}
|
|
4118
|
+
|
|
3731
4119
|
// src/cli/index.ts
|
|
3732
4120
|
var require2 = createRequire(import.meta.url);
|
|
3733
4121
|
var { version } = require2("../../package.json");
|
|
@@ -3736,4 +4124,5 @@ registerInit(program);
|
|
|
3736
4124
|
registerServe(program);
|
|
3737
4125
|
registerHandler(program);
|
|
3738
4126
|
registerOpenApi(program);
|
|
4127
|
+
registerTypes(program);
|
|
3739
4128
|
program.parse();
|