@shaferllc/keel 0.66.0 → 0.74.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/AGENTS.md +169 -0
- package/README.md +44 -6
- package/bin/keel-mcp.mjs +9 -0
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +8 -2
- package/dist/core/cache.d.ts +81 -5
- package/dist/core/cache.js +188 -23
- package/dist/core/cli/stubs.d.ts +26 -0
- package/dist/core/cli/stubs.js +225 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +9 -3
- package/dist/core/helpers.js +11 -3
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +39 -13
- package/dist/core/index.js +19 -6
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +89 -4
- package/dist/core/logger.js +167 -22
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +325 -14
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +299 -7
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +3206 -0
- package/docs/ai.md +128 -0
- package/docs/api-resources.md +118 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +385 -0
- package/docs/console.md +536 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +631 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +121 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +208 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +166 -0
- package/docs/examples/mail.ts +263 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +215 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/i18n.md +302 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +436 -0
- package/docs/mail.md +751 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +452 -0
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +314 -0
- package/docs/testing.md +376 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +19816 -0
- package/llms.txt +127 -0
- package/package.json +42 -7
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `apiResource(router, Model, options)` — a full CRUD REST API from a Keel model,
|
|
3
|
+
* the Remult idea done the Keel way: explicit, server-side, and composed from
|
|
4
|
+
* pieces you already have. It registers real routes (so the OpenAPI package
|
|
5
|
+
* documents them for free), runs writes through the model's mass-assignment guard
|
|
6
|
+
* and your Zod schema, paginates and allow-list-filters reads, and gates every
|
|
7
|
+
* action.
|
|
8
|
+
*
|
|
9
|
+
* apiResource(router, Post, {
|
|
10
|
+
* filter: ["status", "authorId"],
|
|
11
|
+
* sort: ["createdAt", "title"],
|
|
12
|
+
* body: PostSchema,
|
|
13
|
+
* access: { read: true, write: (c) => isEditor(c) },
|
|
14
|
+
* scope: (q) => q.where("deleted", false),
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* Access is **deny by default**: an action with no `access` rule returns 403.
|
|
18
|
+
* That's the safe default for a generated API — you opt routes open, never shut.
|
|
19
|
+
*/
|
|
20
|
+
import { validate } from "../core/validation.js";
|
|
21
|
+
import { ForbiddenException, NotFoundException } from "../core/exceptions.js";
|
|
22
|
+
import { apiDefaults } from "./config.js";
|
|
23
|
+
import { parseListParams, applyListParams } from "./query.js";
|
|
24
|
+
/**
|
|
25
|
+
* The route-config key `@shaferllc/keel/openapi` reads operation docs from. It's
|
|
26
|
+
* declared here — not imported from openapi — so this package never depends on
|
|
27
|
+
* openapi: install the two together and routes are documented; install only api
|
|
28
|
+
* and the metadata is inert. Keep this value in sync with openapi's `OPENAPI_KEY`.
|
|
29
|
+
*/
|
|
30
|
+
const OPENAPI_CONFIG_KEY = "openapi";
|
|
31
|
+
/** Wrap operation docs for a route's `.config()`, without importing openapi. */
|
|
32
|
+
function openApiConfig(doc) {
|
|
33
|
+
return { [OPENAPI_CONFIG_KEY]: doc };
|
|
34
|
+
}
|
|
35
|
+
const ALL_ACTIONS = ["list", "read", "create", "update", "delete"];
|
|
36
|
+
function resolveActions(options) {
|
|
37
|
+
let actions = options.only ?? ALL_ACTIONS;
|
|
38
|
+
if (options.except)
|
|
39
|
+
actions = actions.filter((a) => !options.except.includes(a));
|
|
40
|
+
return new Set(actions);
|
|
41
|
+
}
|
|
42
|
+
/** Resolve the access rule for an action; undefined (no rule) means deny. */
|
|
43
|
+
function ruleFor(access, action) {
|
|
44
|
+
const a = access ?? {};
|
|
45
|
+
switch (action) {
|
|
46
|
+
case "list":
|
|
47
|
+
return a.list ?? a.read ?? a.all;
|
|
48
|
+
case "read":
|
|
49
|
+
return a.get ?? a.read ?? a.all;
|
|
50
|
+
case "create":
|
|
51
|
+
return a.create ?? a.write ?? a.all;
|
|
52
|
+
case "update":
|
|
53
|
+
return a.update ?? a.write ?? a.all;
|
|
54
|
+
case "delete":
|
|
55
|
+
return a.delete ?? a.write ?? a.all;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Evaluate access; throw 403 if denied. */
|
|
59
|
+
async function authorize(access, action, c) {
|
|
60
|
+
const rule = ruleFor(access, action);
|
|
61
|
+
const ok = rule === undefined ? false : typeof rule === "boolean" ? rule : await rule(c);
|
|
62
|
+
if (!ok)
|
|
63
|
+
throw new ForbiddenException();
|
|
64
|
+
}
|
|
65
|
+
function transformOne(t, model, c) {
|
|
66
|
+
if (!t)
|
|
67
|
+
return model.toJSON();
|
|
68
|
+
return typeof t === "function" ? t(model, c) : t.item(model);
|
|
69
|
+
}
|
|
70
|
+
function transformMany(t, models, c) {
|
|
71
|
+
if (!t)
|
|
72
|
+
return models.map((m) => m.toJSON());
|
|
73
|
+
return typeof t === "function" ? models.map((m) => t(m, c)) : t.collection(models);
|
|
74
|
+
}
|
|
75
|
+
function singular(word) {
|
|
76
|
+
return word.endsWith("s") ? word.slice(0, -1) : word;
|
|
77
|
+
}
|
|
78
|
+
export function apiResource(router, model, options = {}) {
|
|
79
|
+
const defaults = apiDefaults();
|
|
80
|
+
const path = options.path ?? model.table;
|
|
81
|
+
const base = "/" + path.replace(/^\/|\/$/g, "");
|
|
82
|
+
const name = options.name ?? path;
|
|
83
|
+
const pk = model.primaryKey;
|
|
84
|
+
const one = singular(path);
|
|
85
|
+
const tags = options.tags ?? [path];
|
|
86
|
+
const perPage = options.perPage ?? defaults.perPage;
|
|
87
|
+
const maxPerPage = options.maxPerPage ?? defaults.maxPerPage;
|
|
88
|
+
const actions = resolveActions(options);
|
|
89
|
+
/** The base query with any row-level scope applied. */
|
|
90
|
+
const scoped = (c) => {
|
|
91
|
+
const q = model.query();
|
|
92
|
+
if (!options.scope)
|
|
93
|
+
return q;
|
|
94
|
+
return options.scope(q, c) ?? q;
|
|
95
|
+
};
|
|
96
|
+
/** Find one row within scope, or 404. */
|
|
97
|
+
const findScoped = async (c) => {
|
|
98
|
+
const row = await scoped(c).where(pk, c.req.param("id")).first();
|
|
99
|
+
if (!row)
|
|
100
|
+
throw new NotFoundException(`${model.name} not found`);
|
|
101
|
+
return new model(row);
|
|
102
|
+
};
|
|
103
|
+
const readBody = async (c, schema) => {
|
|
104
|
+
const raw = (await c.req.json().catch(() => ({})));
|
|
105
|
+
return schema ? (await validate(schema, raw)) : raw;
|
|
106
|
+
};
|
|
107
|
+
if (actions.has("list")) {
|
|
108
|
+
router
|
|
109
|
+
.get(base, async (c) => {
|
|
110
|
+
await authorize(options.access, "list", c);
|
|
111
|
+
const params = parseListParams(c, {
|
|
112
|
+
filter: options.filter ?? [],
|
|
113
|
+
sort: options.sort ?? [],
|
|
114
|
+
perPage,
|
|
115
|
+
maxPerPage,
|
|
116
|
+
});
|
|
117
|
+
const query = applyListParams(scoped(c), params);
|
|
118
|
+
const result = await query.paginate(params.page, params.perPage);
|
|
119
|
+
const models = result.data.map((row) => new model(row));
|
|
120
|
+
return c.json({
|
|
121
|
+
data: transformMany(options.transform, models, c),
|
|
122
|
+
meta: {
|
|
123
|
+
total: result.total,
|
|
124
|
+
perPage: result.perPage,
|
|
125
|
+
currentPage: result.currentPage,
|
|
126
|
+
lastPage: result.lastPage,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
})
|
|
130
|
+
.name(`${name}.list`)
|
|
131
|
+
.config(openApiConfig({ summary: `List ${path}`, tags }));
|
|
132
|
+
}
|
|
133
|
+
if (actions.has("read")) {
|
|
134
|
+
router
|
|
135
|
+
.get(`${base}/:id`, async (c) => {
|
|
136
|
+
await authorize(options.access, "read", c);
|
|
137
|
+
const found = await findScoped(c);
|
|
138
|
+
return c.json({ data: transformOne(options.transform, found, c) });
|
|
139
|
+
})
|
|
140
|
+
.name(`${name}.read`)
|
|
141
|
+
.config(openApiConfig({ summary: `Fetch a ${one}`, tags }));
|
|
142
|
+
}
|
|
143
|
+
if (actions.has("create")) {
|
|
144
|
+
const schema = options.createBody ?? options.body;
|
|
145
|
+
router
|
|
146
|
+
.post(base, async (c) => {
|
|
147
|
+
await authorize(options.access, "create", c);
|
|
148
|
+
let data = await readBody(c, schema);
|
|
149
|
+
if (options.beforeWrite)
|
|
150
|
+
data = await options.beforeWrite(data, c, "create");
|
|
151
|
+
const created = await model.create(data);
|
|
152
|
+
return c.json({ data: transformOne(options.transform, created, c) }, 201);
|
|
153
|
+
})
|
|
154
|
+
.name(`${name}.create`)
|
|
155
|
+
.config(openApiConfig({
|
|
156
|
+
summary: `Create a ${one}`,
|
|
157
|
+
tags,
|
|
158
|
+
...(schema ? { request: { body: schema } } : {}),
|
|
159
|
+
responses: { 201: { description: `The created ${one}` } },
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
if (actions.has("update")) {
|
|
163
|
+
const schema = options.updateBody ?? options.body;
|
|
164
|
+
router
|
|
165
|
+
.route(["PUT", "PATCH"], `${base}/:id`, async (c) => {
|
|
166
|
+
await authorize(options.access, "update", c);
|
|
167
|
+
const found = await findScoped(c);
|
|
168
|
+
let data = await readBody(c, schema);
|
|
169
|
+
if (options.beforeWrite)
|
|
170
|
+
data = await options.beforeWrite(data, c, "update");
|
|
171
|
+
await found.update(data);
|
|
172
|
+
return c.json({ data: transformOne(options.transform, found, c) });
|
|
173
|
+
})
|
|
174
|
+
.name(`${name}.update`)
|
|
175
|
+
.config(openApiConfig({ summary: `Update a ${one}`, tags, ...(schema ? { request: { body: schema } } : {}) }));
|
|
176
|
+
}
|
|
177
|
+
if (actions.has("delete")) {
|
|
178
|
+
router
|
|
179
|
+
.delete(`${base}/:id`, async (c) => {
|
|
180
|
+
await authorize(options.access, "delete", c);
|
|
181
|
+
const found = await findScoped(c);
|
|
182
|
+
await found.delete();
|
|
183
|
+
return c.body(null, 204);
|
|
184
|
+
})
|
|
185
|
+
.name(`${name}.delete`)
|
|
186
|
+
.config(openApiConfig({ summary: `Delete a ${one}`, tags, responses: { 204: { description: "Deleted" } } }));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -12,7 +12,7 @@ import { Config, type ConfigData } from "./config.js";
|
|
|
12
12
|
import { Router } from "./http/router.js";
|
|
13
13
|
import { View } from "./view.js";
|
|
14
14
|
import { type ProviderClass } from "./provider.js";
|
|
15
|
-
import type { Listener } from "./events.js";
|
|
15
|
+
import type { Listener, EventName, EmitArgs, Resolve } from "./events.js";
|
|
16
16
|
/** A configurator: a plain function that sets the app up and may return anything. */
|
|
17
17
|
export type Configurator = (app: Application) => unknown;
|
|
18
18
|
export interface BootOptions {
|
|
@@ -58,13 +58,13 @@ export declare class Application extends Container {
|
|
|
58
58
|
* `app.on(...)` and the global `listen()` helper reach the same emitter.
|
|
59
59
|
* Returns an unsubscribe function.
|
|
60
60
|
*/
|
|
61
|
-
on<T = unknown>(event:
|
|
61
|
+
on<T = unknown, E extends EventName = EventName>(event: E, listener: Listener<Resolve<T, E>>): () => void;
|
|
62
62
|
/** Subscribe for a single emission. Returns an unsubscribe function. */
|
|
63
|
-
once<T = unknown>(event:
|
|
63
|
+
once<T = unknown, E extends EventName = EventName>(event: E, listener: Listener<Resolve<T, E>>): () => void;
|
|
64
64
|
/** Unsubscribe a listener registered with `on()`/`once()`. */
|
|
65
|
-
off<T = unknown>(event:
|
|
65
|
+
off<T = unknown, E extends EventName = EventName>(event: E, listener: Listener<Resolve<T, E>>): this;
|
|
66
66
|
/** Emit an app event, awaiting every listener in registration order. */
|
|
67
|
-
emit<T = unknown>(event:
|
|
67
|
+
emit<T = unknown, E extends EventName = EventName>(event: E, ...args: EmitArgs<Resolve<T, E>>): Promise<void>;
|
|
68
68
|
/** Merge a config object into the repository under its top-level keys. */
|
|
69
69
|
private mergeConfig;
|
|
70
70
|
/** Load .env via dotenv (Node only; no-op elsewhere). */
|
package/dist/core/application.js
CHANGED
|
@@ -14,6 +14,7 @@ import { View } from "./view.js";
|
|
|
14
14
|
import { Events } from "./events.js";
|
|
15
15
|
import { Cache } from "./cache.js";
|
|
16
16
|
import { Logger } from "./logger.js";
|
|
17
|
+
import { MigrationRegistry, CommandRegistry, PublishRegistry } from "./package.js";
|
|
17
18
|
import { setApplication } from "./helpers.js";
|
|
18
19
|
export class Application extends Container {
|
|
19
20
|
basePath;
|
|
@@ -34,6 +35,11 @@ export class Application extends Container {
|
|
|
34
35
|
this.singleton(View, () => new View());
|
|
35
36
|
this.singleton(Events, () => new Events());
|
|
36
37
|
this.singleton(Cache, () => new Cache());
|
|
38
|
+
// Package-system registries: what providers contribute for the console
|
|
39
|
+
// (`keel migrate`, `keel vendor:publish`) and the CLI to pick up after boot.
|
|
40
|
+
this.singleton(MigrationRegistry, () => new MigrationRegistry());
|
|
41
|
+
this.singleton(CommandRegistry, () => new CommandRegistry());
|
|
42
|
+
this.singleton(PublishRegistry, () => new PublishRegistry());
|
|
37
43
|
this.singleton(Logger, (app) => new Logger({
|
|
38
44
|
level: app.config().get("logger.level", "info"),
|
|
39
45
|
pretty: Boolean(app.config().get("app.debug", false)),
|
|
@@ -95,8 +101,8 @@ export class Application extends Container {
|
|
|
95
101
|
return this;
|
|
96
102
|
}
|
|
97
103
|
/** Emit an app event, awaiting every listener in registration order. */
|
|
98
|
-
async emit(event,
|
|
99
|
-
await this.make(Events).emit(event,
|
|
104
|
+
async emit(event, ...args) {
|
|
105
|
+
await this.make(Events).emit(event, ...args);
|
|
100
106
|
}
|
|
101
107
|
/** Merge a config object into the repository under its top-level keys. */
|
|
102
108
|
mergeConfig(data) {
|
package/dist/core/cache.d.ts
CHANGED
|
@@ -5,6 +5,22 @@
|
|
|
5
5
|
* the global `cache()` helper.
|
|
6
6
|
*
|
|
7
7
|
* const stats = await cache().remember("stats", 60, () => computeStats());
|
|
8
|
+
*
|
|
9
|
+
* The resilience features, kept inside keel's single-store, edge-safe model:
|
|
10
|
+
*
|
|
11
|
+
* - Stampede protection — concurrent `remember()` calls for the same cold key
|
|
12
|
+
* run the factory ONCE and share the result, instead of dog-piling.
|
|
13
|
+
* - Grace / stale-on-error — with a `grace` window, an expired value is kept
|
|
14
|
+
* a little longer and served if the refreshing factory throws.
|
|
15
|
+
* - Tags — group entries and invalidate them together with `deleteByTag()`.
|
|
16
|
+
* - Namespaces — `namespace("users")` scopes keys under a prefix, and its
|
|
17
|
+
* `flush()` clears only that namespace.
|
|
18
|
+
*
|
|
19
|
+
* Tag and namespace invalidation use version stamps, not a key index: each tag
|
|
20
|
+
* carries a counter, every entry records the counter values it was written at,
|
|
21
|
+
* and invalidation just bumps the counter — so an entry whose tag has moved on
|
|
22
|
+
* reads as a miss. O(1) invalidation, no key-set bookkeeping, works on any
|
|
23
|
+
* `CacheStore`.
|
|
8
24
|
*/
|
|
9
25
|
export interface CacheStore {
|
|
10
26
|
get(key: string): Promise<unknown> | unknown;
|
|
@@ -20,22 +36,82 @@ export declare class MemoryStore implements CacheStore {
|
|
|
20
36
|
delete(key: string): void;
|
|
21
37
|
clear(): void;
|
|
22
38
|
}
|
|
39
|
+
/** Options for `put`/`add`. */
|
|
40
|
+
export interface PutOptions {
|
|
41
|
+
/** Tags to associate with this entry, for `deleteByTag` invalidation. */
|
|
42
|
+
tags?: string[];
|
|
43
|
+
}
|
|
44
|
+
/** Options for the `remember` family. */
|
|
45
|
+
export interface RememberOptions extends PutOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Grace window in seconds. After the value's TTL lapses, keep it this much
|
|
48
|
+
* longer and serve it if the refreshing factory throws (stale-on-error).
|
|
49
|
+
*/
|
|
50
|
+
grace?: number;
|
|
51
|
+
}
|
|
23
52
|
export declare class Cache {
|
|
24
53
|
private store;
|
|
25
54
|
constructor(store?: CacheStore);
|
|
55
|
+
private prefix;
|
|
56
|
+
private nsTag?;
|
|
57
|
+
private inflight;
|
|
58
|
+
/** Apply this cache's namespace prefix to a logical key. */
|
|
59
|
+
private k;
|
|
60
|
+
private tagKey;
|
|
61
|
+
private tagVersion;
|
|
62
|
+
/** Snapshot the current version of each tag, to stamp onto a new entry. */
|
|
63
|
+
private stampTags;
|
|
64
|
+
/** True if any recorded tag version is behind the live one (invalidated). */
|
|
65
|
+
private tagsInvalidated;
|
|
66
|
+
/**
|
|
67
|
+
* Read the raw envelope for a key. Returns undefined when absent OR when a tag
|
|
68
|
+
* has invalidated it — tag/namespace invalidation is a hard miss (not grace-
|
|
69
|
+
* eligible). TTL freshness is left to callers, so grace can still see a stale
|
|
70
|
+
* entry.
|
|
71
|
+
*/
|
|
72
|
+
private entry;
|
|
73
|
+
private write;
|
|
26
74
|
get<T = unknown>(key: string, fallback?: T): Promise<T>;
|
|
27
|
-
/** Store a value, optionally expiring after `ttlSeconds
|
|
28
|
-
put(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
|
|
75
|
+
/** Store a value, optionally expiring after `ttlSeconds`, with optional tags. */
|
|
76
|
+
put(key: string, value: unknown, ttlSeconds?: number, options?: PutOptions): Promise<void>;
|
|
77
|
+
/** Store a value only if the key is absent. Returns whether it was written. */
|
|
78
|
+
add(key: string, value: unknown, ttlSeconds?: number, options?: PutOptions): Promise<boolean>;
|
|
29
79
|
has(key: string): Promise<boolean>;
|
|
80
|
+
/** The inverse of `has` — true when the key is absent or expired. */
|
|
81
|
+
missing(key: string): Promise<boolean>;
|
|
30
82
|
forget(key: string): Promise<void>;
|
|
83
|
+
/** Forget several keys at once. */
|
|
84
|
+
forgetMany(keys: string[]): Promise<void>;
|
|
31
85
|
/** Read and remove a value. */
|
|
32
86
|
pull<T = unknown>(key: string, fallback?: T): Promise<T>;
|
|
33
87
|
flush(): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* Invalidate every entry carrying any of these tags by bumping the tag's
|
|
90
|
+
* version — entries stamped with the old version then read as a miss. O(#tags),
|
|
91
|
+
* no key scan; invalidated entries fall out on their own TTL.
|
|
92
|
+
*/
|
|
93
|
+
deleteByTag(tags: string[]): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* A cache scoped under a key prefix. Keys written through it live at
|
|
96
|
+
* `name:key`, and its `flush()` clears only this namespace (via an implicit
|
|
97
|
+
* tag) — the rest of the store is untouched. Namespaces nest.
|
|
98
|
+
*
|
|
99
|
+
* const users = cache().namespace("users");
|
|
100
|
+
* await users.put("1", user); // stored at "users:1"
|
|
101
|
+
* await users.flush(); // clears only the users namespace
|
|
102
|
+
*/
|
|
103
|
+
namespace(name: string): Cache;
|
|
34
104
|
/**
|
|
35
105
|
* Return the cached value, or compute it with `factory`, cache it for
|
|
36
106
|
* `ttlSeconds`, and return it.
|
|
107
|
+
*
|
|
108
|
+
* Concurrent calls for the same cold key share one factory run (stampede
|
|
109
|
+
* protection). With `{ grace }`, an expired value is retained that many extra
|
|
110
|
+
* seconds and served if the refreshing factory throws (stale-on-error). With
|
|
111
|
+
* `{ tags }`, the cached value joins those tags for `deleteByTag`.
|
|
37
112
|
*/
|
|
38
|
-
remember<T>(key: string, ttlSeconds: number, factory: () => T | Promise<T
|
|
39
|
-
/** Like remember(), but cached forever (no TTL). */
|
|
40
|
-
rememberForever<T>(key: string, factory: () => T | Promise<T
|
|
113
|
+
remember<T>(key: string, ttlSeconds: number, factory: () => T | Promise<T>, options?: RememberOptions): Promise<T>;
|
|
114
|
+
/** Like remember(), but cached forever (no TTL). Also stampede-protected. */
|
|
115
|
+
rememberForever<T>(key: string, factory: () => T | Promise<T>, options?: PutOptions): Promise<T>;
|
|
116
|
+
private resolve;
|
|
41
117
|
}
|
package/dist/core/cache.js
CHANGED
|
@@ -5,7 +5,24 @@
|
|
|
5
5
|
* the global `cache()` helper.
|
|
6
6
|
*
|
|
7
7
|
* const stats = await cache().remember("stats", 60, () => computeStats());
|
|
8
|
+
*
|
|
9
|
+
* The resilience features, kept inside keel's single-store, edge-safe model:
|
|
10
|
+
*
|
|
11
|
+
* - Stampede protection — concurrent `remember()` calls for the same cold key
|
|
12
|
+
* run the factory ONCE and share the result, instead of dog-piling.
|
|
13
|
+
* - Grace / stale-on-error — with a `grace` window, an expired value is kept
|
|
14
|
+
* a little longer and served if the refreshing factory throws.
|
|
15
|
+
* - Tags — group entries and invalidate them together with `deleteByTag()`.
|
|
16
|
+
* - Namespaces — `namespace("users")` scopes keys under a prefix, and its
|
|
17
|
+
* `flush()` clears only that namespace.
|
|
18
|
+
*
|
|
19
|
+
* Tag and namespace invalidation use version stamps, not a key index: each tag
|
|
20
|
+
* carries a counter, every entry records the counter values it was written at,
|
|
21
|
+
* and invalidation just bumps the counter — so an entry whose tag has moved on
|
|
22
|
+
* reads as a miss. O(1) invalidation, no key-set bookkeeping, works on any
|
|
23
|
+
* `CacheStore`.
|
|
8
24
|
*/
|
|
25
|
+
import { instrument, currentRequestId } from "./instrumentation.js";
|
|
9
26
|
/** The default in-memory store. */
|
|
10
27
|
export class MemoryStore {
|
|
11
28
|
data = new Map();
|
|
@@ -29,24 +46,116 @@ export class MemoryStore {
|
|
|
29
46
|
this.data.clear();
|
|
30
47
|
}
|
|
31
48
|
}
|
|
49
|
+
function isEntry(x) {
|
|
50
|
+
return typeof x === "object" && x !== null && x.__keelCache === 1;
|
|
51
|
+
}
|
|
52
|
+
// The store key that holds a tag's version counter. Never namespaced, never
|
|
53
|
+
// expired — a bump must outlive every entry that recorded the old version.
|
|
54
|
+
const TAG_VERSION_PREFIX = "\u0000keel:tagv:";
|
|
32
55
|
export class Cache {
|
|
33
56
|
store;
|
|
34
57
|
constructor(store = new MemoryStore()) {
|
|
35
58
|
this.store = store;
|
|
36
59
|
}
|
|
60
|
+
// Key prefix + implicit tag for a namespaced cache (empty on the root).
|
|
61
|
+
prefix = "";
|
|
62
|
+
nsTag;
|
|
63
|
+
// Stampede protection: in-flight factory promises, keyed by prefixed key.
|
|
64
|
+
inflight = new Map();
|
|
65
|
+
/** Apply this cache's namespace prefix to a logical key. */
|
|
66
|
+
k(key) {
|
|
67
|
+
return this.prefix + key;
|
|
68
|
+
}
|
|
69
|
+
tagKey(tag) {
|
|
70
|
+
return TAG_VERSION_PREFIX + tag;
|
|
71
|
+
}
|
|
72
|
+
async tagVersion(tag) {
|
|
73
|
+
const v = await this.store.get(this.tagKey(tag));
|
|
74
|
+
return typeof v === "number" ? v : 0;
|
|
75
|
+
}
|
|
76
|
+
/** Snapshot the current version of each tag, to stamp onto a new entry. */
|
|
77
|
+
async stampTags(tags) {
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const tag of tags)
|
|
80
|
+
out[tag] = await this.tagVersion(tag);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** True if any recorded tag version is behind the live one (invalidated). */
|
|
84
|
+
async tagsInvalidated(recorded) {
|
|
85
|
+
for (const [tag, ver] of Object.entries(recorded)) {
|
|
86
|
+
if ((await this.tagVersion(tag)) > ver)
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Read the raw envelope for a key. Returns undefined when absent OR when a tag
|
|
93
|
+
* has invalidated it — tag/namespace invalidation is a hard miss (not grace-
|
|
94
|
+
* eligible). TTL freshness is left to callers, so grace can still see a stale
|
|
95
|
+
* entry.
|
|
96
|
+
*/
|
|
97
|
+
async entry(key) {
|
|
98
|
+
const raw = await this.store.get(this.k(key));
|
|
99
|
+
if (raw === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
const e = isEntry(raw) ? raw : { __keelCache: 1, v: raw, e: 0 };
|
|
102
|
+
if (e.t && (await this.tagsInvalidated(e.t)))
|
|
103
|
+
return undefined;
|
|
104
|
+
return e;
|
|
105
|
+
}
|
|
106
|
+
async write(key, value, ttlSeconds, graceSeconds = 0, tags = []) {
|
|
107
|
+
const allTags = this.nsTag ? [this.nsTag, ...tags] : tags;
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const e = ttlSeconds ? now + ttlSeconds * 1000 : 0;
|
|
110
|
+
const storeTtlMs = ttlSeconds ? (ttlSeconds + graceSeconds) * 1000 : undefined;
|
|
111
|
+
const t = allTags.length ? await this.stampTags(allTags) : undefined;
|
|
112
|
+
const entry = { __keelCache: 1, v: value, e };
|
|
113
|
+
if (t)
|
|
114
|
+
entry.t = t;
|
|
115
|
+
await this.store.set(this.k(key), entry, storeTtlMs);
|
|
116
|
+
}
|
|
37
117
|
async get(key, fallback) {
|
|
38
|
-
const
|
|
39
|
-
|
|
118
|
+
const entry = await this.entry(key);
|
|
119
|
+
const hit = !!entry && !(entry.e && entry.e < Date.now());
|
|
120
|
+
const requestId = currentRequestId();
|
|
121
|
+
instrument(hit ? "cache.hit" : "cache.miss", {
|
|
122
|
+
key,
|
|
123
|
+
store: this.store.constructor.name,
|
|
124
|
+
...(requestId ? { requestId } : {}),
|
|
125
|
+
});
|
|
126
|
+
if (!entry)
|
|
127
|
+
return fallback;
|
|
128
|
+
if (entry.e && entry.e < Date.now())
|
|
129
|
+
return fallback; // expired (grace-retained)
|
|
130
|
+
return entry.v;
|
|
131
|
+
}
|
|
132
|
+
/** Store a value, optionally expiring after `ttlSeconds`, with optional tags. */
|
|
133
|
+
async put(key, value, ttlSeconds, options) {
|
|
134
|
+
await this.write(key, value, ttlSeconds, 0, options?.tags ?? []);
|
|
40
135
|
}
|
|
41
|
-
/** Store a value
|
|
42
|
-
async
|
|
43
|
-
await this.
|
|
136
|
+
/** Store a value only if the key is absent. Returns whether it was written. */
|
|
137
|
+
async add(key, value, ttlSeconds, options) {
|
|
138
|
+
if (await this.has(key))
|
|
139
|
+
return false;
|
|
140
|
+
await this.put(key, value, ttlSeconds, options);
|
|
141
|
+
return true;
|
|
44
142
|
}
|
|
45
143
|
async has(key) {
|
|
46
|
-
|
|
144
|
+
const entry = await this.entry(key);
|
|
145
|
+
if (!entry)
|
|
146
|
+
return false;
|
|
147
|
+
return !(entry.e && entry.e < Date.now());
|
|
148
|
+
}
|
|
149
|
+
/** The inverse of `has` — true when the key is absent or expired. */
|
|
150
|
+
async missing(key) {
|
|
151
|
+
return !(await this.has(key));
|
|
47
152
|
}
|
|
48
153
|
async forget(key) {
|
|
49
|
-
await this.store.delete(key);
|
|
154
|
+
await this.store.delete(this.k(key));
|
|
155
|
+
}
|
|
156
|
+
/** Forget several keys at once. */
|
|
157
|
+
async forgetMany(keys) {
|
|
158
|
+
await Promise.all(keys.map((k) => this.store.delete(this.k(k))));
|
|
50
159
|
}
|
|
51
160
|
/** Read and remove a value. */
|
|
52
161
|
async pull(key, fallback) {
|
|
@@ -55,27 +164,83 @@ export class Cache {
|
|
|
55
164
|
return value;
|
|
56
165
|
}
|
|
57
166
|
async flush() {
|
|
58
|
-
|
|
167
|
+
// A namespace clears itself by moving its tag version on; the root wipes the
|
|
168
|
+
// whole store.
|
|
169
|
+
if (this.nsTag)
|
|
170
|
+
await this.deleteByTag([this.nsTag]);
|
|
171
|
+
else
|
|
172
|
+
await this.store.clear();
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Invalidate every entry carrying any of these tags by bumping the tag's
|
|
176
|
+
* version — entries stamped with the old version then read as a miss. O(#tags),
|
|
177
|
+
* no key scan; invalidated entries fall out on their own TTL.
|
|
178
|
+
*/
|
|
179
|
+
async deleteByTag(tags) {
|
|
180
|
+
for (const tag of tags) {
|
|
181
|
+
const current = await this.tagVersion(tag);
|
|
182
|
+
await this.store.set(this.tagKey(tag), current + 1); // no TTL — must persist
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A cache scoped under a key prefix. Keys written through it live at
|
|
187
|
+
* `name:key`, and its `flush()` clears only this namespace (via an implicit
|
|
188
|
+
* tag) — the rest of the store is untouched. Namespaces nest.
|
|
189
|
+
*
|
|
190
|
+
* const users = cache().namespace("users");
|
|
191
|
+
* await users.put("1", user); // stored at "users:1"
|
|
192
|
+
* await users.flush(); // clears only the users namespace
|
|
193
|
+
*/
|
|
194
|
+
namespace(name) {
|
|
195
|
+
const child = new Cache(this.store);
|
|
196
|
+
child.prefix = `${this.prefix}${name}:`;
|
|
197
|
+
child.nsTag = `\u0000keel:ns:${this.prefix}${name}`;
|
|
198
|
+
return child;
|
|
59
199
|
}
|
|
60
200
|
/**
|
|
61
201
|
* Return the cached value, or compute it with `factory`, cache it for
|
|
62
202
|
* `ttlSeconds`, and return it.
|
|
203
|
+
*
|
|
204
|
+
* Concurrent calls for the same cold key share one factory run (stampede
|
|
205
|
+
* protection). With `{ grace }`, an expired value is retained that many extra
|
|
206
|
+
* seconds and served if the refreshing factory throws (stale-on-error). With
|
|
207
|
+
* `{ tags }`, the cached value joins those tags for `deleteByTag`.
|
|
63
208
|
*/
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (existing !== undefined)
|
|
67
|
-
return existing;
|
|
68
|
-
const value = await factory();
|
|
69
|
-
await this.put(key, value, ttlSeconds);
|
|
70
|
-
return value;
|
|
209
|
+
remember(key, ttlSeconds, factory, options) {
|
|
210
|
+
return this.resolve(key, ttlSeconds, factory, options?.grace ?? 0, options?.tags ?? []);
|
|
71
211
|
}
|
|
72
|
-
/** Like remember(), but cached forever (no TTL). */
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
212
|
+
/** Like remember(), but cached forever (no TTL). Also stampede-protected. */
|
|
213
|
+
rememberForever(key, factory, options) {
|
|
214
|
+
return this.resolve(key, undefined, factory, 0, options?.tags ?? []);
|
|
215
|
+
}
|
|
216
|
+
async resolve(key, ttlSeconds, factory, graceSeconds, tags) {
|
|
217
|
+
const entry = await this.entry(key);
|
|
218
|
+
const fresh = entry && (!entry.e || entry.e > Date.now());
|
|
219
|
+
if (fresh)
|
|
220
|
+
return entry.v;
|
|
221
|
+
// Stampede protection: join an in-flight computation for this key.
|
|
222
|
+
const pending = this.inflight.get(key);
|
|
223
|
+
if (pending)
|
|
224
|
+
return pending;
|
|
225
|
+
const run = (async () => {
|
|
226
|
+
try {
|
|
227
|
+
// Defer to a microtask so a synchronously-throwing factory rejects the
|
|
228
|
+
// shared promise only after every joiner has attached its handler.
|
|
229
|
+
const value = await Promise.resolve().then(factory);
|
|
230
|
+
await this.write(key, value, ttlSeconds, graceSeconds, tags);
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
// Grace: a stale-but-retained value rescues a failing refresh.
|
|
235
|
+
if (entry && graceSeconds > 0)
|
|
236
|
+
return entry.v;
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
this.inflight.delete(key);
|
|
241
|
+
}
|
|
242
|
+
})();
|
|
243
|
+
this.inflight.set(key, run);
|
|
244
|
+
return run;
|
|
80
245
|
}
|
|
81
246
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Code-generation templates for `keel make:*`. */
|
|
2
|
+
export declare function controllerStub(name: string): string;
|
|
3
|
+
export declare function resourceControllerStub(name: string): string;
|
|
4
|
+
export declare function providerStub(name: string): string;
|
|
5
|
+
export declare function middlewareStub(name: string): string;
|
|
6
|
+
/** `name` is the model class (e.g. "User") the factory builds. */
|
|
7
|
+
export declare function factoryStub(model: string): string;
|
|
8
|
+
export declare function seederStub(name: string): string;
|
|
9
|
+
export declare function jobStub(name: string): string;
|
|
10
|
+
export declare function notificationStub(name: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* A package skeleton: a `PackageProvider` wired for the common jobs — merging
|
|
13
|
+
* config, contributing a migration, mounting routes + assets, and declaring a
|
|
14
|
+
* publishable config stub. `name` is the short package name (e.g. "billing").
|
|
15
|
+
*/
|
|
16
|
+
export declare function packageProviderStub(name: string): string;
|
|
17
|
+
/** `name` is the class (e.g. "UserTransformer"); `model` is the value it maps. */
|
|
18
|
+
export declare function transformerStub(name: string, model: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* A page — `resources/pages/<path>.tsx`. The file's location *is* its URL, so the
|
|
21
|
+
* stub is deliberately bare: a component, and a loader only when the path takes a
|
|
22
|
+
* parameter to load anything by.
|
|
23
|
+
*/
|
|
24
|
+
export declare function pageStub(file: string): string;
|
|
25
|
+
/** A console command — `app/Commands/<name>.ts`. */
|
|
26
|
+
export declare function commandStub(name: string): string;
|