@cosmicdrift/kumiko-dev-server 0.413.1 → 0.414.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-server-bundle.js +1 -2
- package/dist/create-kumiko-server-html.d.ts +2 -0
- package/dist/create-kumiko-server-html.js +2 -0
- package/dist/create-kumiko-server-reload.js +6 -1
- package/dist/create-kumiko-server-routes.js +17 -1
- package/dist/create-kumiko-server-stack.js +5 -1
- package/dist/create-kumiko-server-stylesheet.js +2 -1
- package/dist/create-kumiko-server-watcher.d.ts +1 -0
- package/dist/create-kumiko-server-watcher.js +22 -4
- package/dist/create-kumiko-server.d.ts +6 -0
- package/dist/create-kumiko-server.js +70 -58
- package/dist/dev-server-runtime.js +0 -2
- package/dist/kebab.js +0 -1
- package/dist/managed-files/bundled-lists-wiring.d.ts +6 -0
- package/dist/managed-files/bundled-lists-wiring.js +67 -0
- package/dist/managed-files/registry.js +2 -0
- package/dist/run-dev-app-server-options.js +1 -0
- package/dist/run-dev-app.d.ts +2 -0
- package/dist/scaffold-app.js +5 -3
- package/dist/scaffold-blank-starter.js +2 -1
- package/dist/scaffold-demo-tasks.js +15 -10
- package/dist/scaffold-render-helpers.js +0 -1
- package/package.json +10 -10
- package/src/changes.json +30 -0
- package/templates/deploy/Dockerfile.template +1 -1
|
@@ -283,7 +283,7 @@ export function readExtraRuntimeExternals(cwd) {
|
|
|
283
283
|
// (require becomes __require(...)).
|
|
284
284
|
function importsBarePackage(code, pkg) {
|
|
285
285
|
const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
286
|
-
return new RegExp(`(?:\\bfrom\\s*|\\bimport\\s*\\(?\\s*|\\w*[rR]equire\\w*\\s*\\(\\s*)["'\`]${escaped}["'\`]`).test(code);
|
|
286
|
+
return new RegExp(`(?:\\bfrom\\s*|\\bimport\\s*\\(?\\s*|\\w*[rR]equire\\w*\\s*\\(\\s*)["'\`]${escaped}(?:/[^"'\`]*)?["'\`]`).test(code);
|
|
287
287
|
}
|
|
288
288
|
function readDeclaredAppDependencies(cwd) {
|
|
289
289
|
const appPkg = join(cwd, "package.json");
|
|
@@ -364,7 +364,6 @@ export function formatServerBuildResult(result, durationMs) {
|
|
|
364
364
|
const dim = "\x1b[2m";
|
|
365
365
|
const green = "\x1b[32m";
|
|
366
366
|
const reset = "\x1b[0m";
|
|
367
|
-
// @wrapper-known semantic-alias
|
|
368
367
|
const mb = (bytes) => (bytes / 1024 / 1024).toFixed(2);
|
|
369
368
|
const lines = [];
|
|
370
369
|
lines.push("");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type PageHeadResolver, type PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
|
|
2
|
+
import type { PwaShell } from "@cosmicdrift/kumiko-server-runtime/pwa";
|
|
2
3
|
import type { TestStack } from "@cosmicdrift/kumiko-testing/stack";
|
|
3
4
|
import type { NormalizedEntry } from "./create-kumiko-server-client-bundles.ts";
|
|
4
5
|
export declare const DEFAULT_HTML = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,interactive-widget=resizes-content\" />\n <title>Kumiko</title>\n </head>\n <body>\n <div id=\"root\"></div>\n <script type=\"module\" src=\"/client.js\"></script>\n </body>\n</html>\n";
|
|
@@ -16,6 +17,7 @@ export type DevHtmlDeps = {
|
|
|
16
17
|
readonly stylesheetPath: string | undefined;
|
|
17
18
|
readonly resolvePageHead: PageHeadResolver | undefined;
|
|
18
19
|
readonly buildSystemQuery: DevSystemQueryBuilder;
|
|
20
|
+
readonly pwa: PwaShell | undefined;
|
|
19
21
|
};
|
|
20
22
|
export declare function respondWithHtmlShell(deps: DevHtmlDeps, entryName: string, req: Request, socketAddress?: string, status?: number): Promise<Response>;
|
|
21
23
|
export declare function respondWithStaticHtmlFile(file: string, status: number): Promise<Response>;
|
|
@@ -109,6 +109,8 @@ export async function respondWithHtmlShell(deps, entryName, req, socketAddress,
|
|
|
109
109
|
let html = injectReload(template);
|
|
110
110
|
if (deps.stylesheetPath !== undefined)
|
|
111
111
|
html = injectStylesheet(html);
|
|
112
|
+
if (deps.pwa !== undefined)
|
|
113
|
+
html = deps.pwa.injectHeadTags(html);
|
|
112
114
|
if (deps.resolvePageHead !== undefined) {
|
|
113
115
|
html = await injectDevPageHead(html, deps.resolvePageHead, req, socketAddress, deps.buildSystemQuery);
|
|
114
116
|
}
|
|
@@ -49,14 +49,19 @@ export function broadcastReload(hub, changedFile) {
|
|
|
49
49
|
}
|
|
50
50
|
export function openReloadStream(hub) {
|
|
51
51
|
const encoder = new TextEncoder();
|
|
52
|
+
let entry;
|
|
52
53
|
const stream = new ReadableStream({
|
|
53
54
|
start(controller) {
|
|
54
|
-
|
|
55
|
+
entry = { controller, encoder, closed: false };
|
|
55
56
|
hub.clients.add(entry);
|
|
56
57
|
controller.enqueue(encoder.encode(": connected\n\n"));
|
|
57
58
|
controller.enqueue(encoder.encode(`event: boot\ndata: ${hub.bootId}\n\n`));
|
|
58
59
|
},
|
|
59
60
|
cancel() {
|
|
61
|
+
if (entry !== undefined) {
|
|
62
|
+
entry.closed = true;
|
|
63
|
+
hub.clients.delete(entry);
|
|
64
|
+
}
|
|
60
65
|
for (const c of hub.clients) {
|
|
61
66
|
if (c.closed)
|
|
62
67
|
hub.clients.delete(c);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/// <reference types="bun-types" preserve="true" />
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { createPwaShell } from "@cosmicdrift/kumiko-server-runtime/pwa";
|
|
4
5
|
import { RENDERER_WEB_FONT_FILE_PATTERN, RENDERER_WEB_FONTS_URL_PREFIX, resolveRendererWebFontsDir, } from "@cosmicdrift/kumiko-server-runtime/renderer-web-fonts";
|
|
5
6
|
import { stripNoRouteMatchHeader, tryHonoFirst, } from "@cosmicdrift/kumiko-server-runtime/try-hono-first";
|
|
6
7
|
import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
@@ -77,6 +78,9 @@ async function serveStylesheet({ req, url }, deps) {
|
|
|
77
78
|
const css = await readFile(stylesheetPath, "utf-8");
|
|
78
79
|
return new Response(css, { headers: { "Content-Type": "text/css; charset=utf-8" } });
|
|
79
80
|
}
|
|
81
|
+
function servePwaFile({ req, url }, deps) {
|
|
82
|
+
return deps.html.pwa?.respond(req, url.pathname);
|
|
83
|
+
}
|
|
80
84
|
function serveReloadStream({ req, url }, deps) {
|
|
81
85
|
if (url.pathname !== "/_reload" || req.method !== "GET")
|
|
82
86
|
return undefined;
|
|
@@ -161,6 +165,7 @@ const DEV_ROUTES = [
|
|
|
161
165
|
serveBundleSourceMap,
|
|
162
166
|
serveRendererWebFont,
|
|
163
167
|
serveStylesheet,
|
|
168
|
+
servePwaFile,
|
|
164
169
|
serveReloadStream,
|
|
165
170
|
serveSpaShell,
|
|
166
171
|
servePublicAsset,
|
|
@@ -180,13 +185,23 @@ function createDevRouteFetch(deps) {
|
|
|
180
185
|
return stripNoRouteMatchHeader(await deps.app.fetch(req, socketAddress));
|
|
181
186
|
};
|
|
182
187
|
}
|
|
188
|
+
// Dev bundles keep their names across rebuilds, so the token changes per boot instead.
|
|
189
|
+
function createDevPwaShell(options, bundleByAssetPath, stylesheetPath) {
|
|
190
|
+
if (options.pwa === undefined)
|
|
191
|
+
return undefined;
|
|
192
|
+
return createPwaShell(options.pwa, {
|
|
193
|
+
appShellUrls: [...bundleByAssetPath.keys(), ...(stylesheetPath ? ["/styles.css"] : [])],
|
|
194
|
+
buildToken: `dev-${Date.now()}`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
183
197
|
export function createDevFetchHandler(input) {
|
|
184
198
|
const { options, stack } = input;
|
|
185
199
|
const buildSystemQuery = createDevSystemQueryBuilder(stack.dispatcher, options.trustedProxyHops);
|
|
200
|
+
const bundleByAssetPath = indexBundlesByAssetPath(input.entries);
|
|
186
201
|
const routeFetch = createDevRouteFetch({
|
|
187
202
|
app: stack.app,
|
|
188
203
|
clientBundles: input.clientBundles,
|
|
189
|
-
bundleByAssetPath
|
|
204
|
+
bundleByAssetPath,
|
|
190
205
|
publicDir: resolveDevPublicDir(),
|
|
191
206
|
// The app's cwd first, this package's own location as fallback (the cwd
|
|
192
207
|
// may not have renderer/web installed, e.g. a fixture dir).
|
|
@@ -200,6 +215,7 @@ export function createDevFetchHandler(input) {
|
|
|
200
215
|
stylesheetPath: input.stylesheetPath,
|
|
201
216
|
resolvePageHead: options.resolvePageHead,
|
|
202
217
|
buildSystemQuery,
|
|
218
|
+
pwa: createDevPwaShell(options, bundleByAssetPath, input.stylesheetPath),
|
|
203
219
|
},
|
|
204
220
|
});
|
|
205
221
|
return options.wrapFetch ? options.wrapFetch(routeFetch) : routeFetch;
|
|
@@ -25,7 +25,11 @@ export function resolveDevDbMode(processEnv) {
|
|
|
25
25
|
: `kumiko-dev-${crypto.randomUUID()}`;
|
|
26
26
|
// Pub/Sub ignores the DB index, so the SSE channels need the same per-boot
|
|
27
27
|
// isolation as the queues; an explicit env value wins so a worker can be pointed at it.
|
|
28
|
-
|
|
28
|
+
// KUMIKO_REDIS_PREFIX (per-app ACL namespace) is next: redisNamespaceFromEnv throws when a channel
|
|
29
|
+
// prefix differs from it, so the per-boot default would stop every dev boot in such an env.
|
|
30
|
+
const sseChannelPrefix = processEnv["KUMIKO_REDIS_CHANNEL_PREFIX"]?.trim() ||
|
|
31
|
+
processEnv["KUMIKO_REDIS_PREFIX"]?.trim() ||
|
|
32
|
+
jobQueueNamePrefix;
|
|
29
33
|
return { devDbName, persistentDb, jobQueueNamePrefix, sseChannelPrefix };
|
|
30
34
|
}
|
|
31
35
|
function buildDevStackOptions(options, dbMode, sseBroker) {
|
|
@@ -78,7 +78,8 @@ function runTailwindOneShotBuild(bunPath, cliPath, entryCss, outPath) {
|
|
|
78
78
|
child.on("error", rejectPromise);
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
|
-
// unref()
|
|
81
|
+
// unref() only keeps the child from holding the parent's event loop open; it does not end it,
|
|
82
|
+
// so a failed boot must call the returned kill.
|
|
82
83
|
function spawnTailwindWatcher(bunPath, cliPath, entryCss, outPath) {
|
|
83
84
|
const watcher = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath, "--watch"], {
|
|
84
85
|
stdio: "inherit",
|
|
@@ -19,5 +19,6 @@ export type DevFileWatcherDeps = {
|
|
|
19
19
|
readonly reloadHub: DevReloadHub;
|
|
20
20
|
readonly stop: () => Promise<void>;
|
|
21
21
|
};
|
|
22
|
+
export declare function probeFilesystemClockMs(probeParentDir?: string): number;
|
|
22
23
|
export declare function isUnchangedSinceWatchStart(absolutePath: string, watchStartedAtMs: number): boolean;
|
|
23
24
|
export declare function startDevFileWatchers(deps: DevFileWatcherDeps): void;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/// <reference types="bun-types" preserve="true" />
|
|
2
|
-
import { statSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { watch } from "node:fs/promises";
|
|
4
|
-
import {
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
5
6
|
import { rebuildClientBundles, } from "./create-kumiko-server-client-bundles.js";
|
|
6
7
|
import { broadcastReload } from "./create-kumiko-server-reload.js";
|
|
7
8
|
import { logInfo } from "./dev-server-runtime.js";
|
|
@@ -112,6 +113,19 @@ function expandWatchPatterns(patterns) {
|
|
|
112
113
|
}
|
|
113
114
|
return out;
|
|
114
115
|
}
|
|
116
|
+
// mtimes come from the filesystem clock at its granularity, not from Date.now(); a baseline taken
|
|
117
|
+
// from the same clock keeps a coarse-mtime or skewed filesystem from swallowing real edits.
|
|
118
|
+
export function probeFilesystemClockMs(probeParentDir = tmpdir()) {
|
|
119
|
+
const probeDir = mkdtempSync(join(probeParentDir, "kumiko-watch-start-"));
|
|
120
|
+
try {
|
|
121
|
+
const probeFile = join(probeDir, "probe");
|
|
122
|
+
writeFileSync(probeFile, "");
|
|
123
|
+
return statSync(probeFile).mtimeMs;
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
rmSync(probeDir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
115
129
|
// A deleted or renamed file is a real change, so a failing stat counts as changed.
|
|
116
130
|
export function isUnchangedSinceWatchStart(absolutePath, watchStartedAtMs) {
|
|
117
131
|
try {
|
|
@@ -161,9 +175,13 @@ export function startDevFileWatchers(deps) {
|
|
|
161
175
|
for (const e of deps.entries)
|
|
162
176
|
entryDirs.add(resolve(e.sourceFile, ".."));
|
|
163
177
|
const entrySourceFiles = new Set(deps.entries.map((e) => resolve(e.sourceFile)));
|
|
164
|
-
const watchStartedAtMs = Date.now();
|
|
165
178
|
const dirs = [...entryDirs, ...expandWatchPatterns(deps.watchDirs ?? [])];
|
|
179
|
+
// The probe sits on the watched filesystem: tmpdir() may be another mount with another clock.
|
|
180
|
+
// It is removed before the watchers start, so it raises no change event.
|
|
181
|
+
const watchStartedAtMs = probeFilesystemClockMs(dirs[0]);
|
|
166
182
|
for (const dir of dirs) {
|
|
167
|
-
void watchDir(dir, (filename) =>
|
|
183
|
+
void watchDir(dir, (filename) => {
|
|
184
|
+
void handleWatchedChange(deps, dir, filename, entrySourceFiles, watchStartedAtMs);
|
|
185
|
+
}, deps.signal, deps.watchDirectory ?? watch);
|
|
168
186
|
}
|
|
169
187
|
}
|
|
@@ -5,7 +5,9 @@ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
5
5
|
import type { ErrorDocsConfig } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
6
|
import type { PageHeadResolver, PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
|
|
7
7
|
import type { SystemWireDeps } from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps";
|
|
8
|
+
import type { PwaOptions } from "@cosmicdrift/kumiko-server-runtime/pwa";
|
|
8
9
|
import type { TestStack, TestStackOptions } from "@cosmicdrift/kumiko-testing/stack";
|
|
10
|
+
import { startDevStylesheet } from "./create-kumiko-server-stylesheet.ts";
|
|
9
11
|
import { type DirectoryWatchEvents } from "./create-kumiko-server-watcher.ts";
|
|
10
12
|
import { type BunServer } from "./dev-server-runtime.ts";
|
|
11
13
|
/** Multi-entry mode for apps that ship several separate bundles (e.g.
|
|
@@ -96,6 +98,8 @@ export type CreateKumikoServerOptions = {
|
|
|
96
98
|
}>;
|
|
97
99
|
/** @internal — replaces the native directory watcher in tests. */
|
|
98
100
|
readonly _watchDirectory?: DirectoryWatchEvents;
|
|
101
|
+
/** @internal — replaces the Tailwind pipeline in tests. */
|
|
102
|
+
readonly _startStylesheet?: typeof startDevStylesheet;
|
|
99
103
|
/** Absolute path to the CSS entry (typically styles.css with
|
|
100
104
|
* @import "tailwindcss"). The dev-server builds it with the Tailwind
|
|
101
105
|
* CLI and serves the result at /styles.css; `stylesheetWatch` decides
|
|
@@ -185,6 +189,8 @@ export type CreateKumikoServerOptions = {
|
|
|
185
189
|
* ships unchanged with status 200 — never a 500. `systemQuery` inside
|
|
186
190
|
* the resolver runs as the anonymous role, same as prod. */
|
|
187
191
|
readonly resolvePageHead?: PageHeadResolver;
|
|
192
|
+
/** Installable web app, same option and same served files as `runProdApp`'s `pwa`. */
|
|
193
|
+
readonly pwa?: PwaOptions;
|
|
188
194
|
/** Wraps the request handler before it reaches Bun.serve and `handle.fetch`, e.g. the demo read-only gate. */
|
|
189
195
|
readonly wrapFetch?: (handler: (req: Request, socketAddress?: string) => Promise<Response>) => (req: Request, socketAddress?: string) => Promise<Response>;
|
|
190
196
|
};
|
|
@@ -44,63 +44,75 @@ export async function createKumikoServer(options) {
|
|
|
44
44
|
const entries = normalizeEntries(options);
|
|
45
45
|
const buildBundle = options._buildBundle ?? buildClient;
|
|
46
46
|
const clientBundles = await buildClientBundles(entries, buildBundle);
|
|
47
|
-
const stylesheet = await startDevStylesheet(options);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
watcherAbort
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
47
|
+
const stylesheet = await (options._startStylesheet ?? startDevStylesheet)(options);
|
|
48
|
+
let stop;
|
|
49
|
+
try {
|
|
50
|
+
const templates = await loadHtmlTemplates(entries, options.htmlPath);
|
|
51
|
+
const dbMode = resolveDevDbMode(process.env);
|
|
52
|
+
const { stack, ownedRedisSseBroker } = await bootDevStack(options, dbMode);
|
|
53
|
+
const devJobRunners = await startDevBackgroundRunners(stack, dbMode.jobQueueNamePrefix);
|
|
54
|
+
// Built once purely to surface authoring warnings (settings-hub placement
|
|
55
|
+
// etc.) at the same point prod would; clients fetch the schema themselves
|
|
56
|
+
// from GET /api/schema, so the result is discarded.
|
|
57
|
+
buildAppSchema(stack.registry, {
|
|
58
|
+
authoringWarnings: true,
|
|
59
|
+
searchAdapterMissing: !stack.context.searchAdapter,
|
|
60
|
+
});
|
|
61
|
+
const reloadHub = createReloadHub();
|
|
62
|
+
const handleFetch = createDevFetchHandler({
|
|
63
|
+
options,
|
|
64
|
+
stack,
|
|
65
|
+
entries,
|
|
66
|
+
clientBundles,
|
|
67
|
+
templates,
|
|
68
|
+
stylesheetPath: stylesheet?.outPath,
|
|
69
|
+
reloadHub,
|
|
70
|
+
});
|
|
71
|
+
const server = startDevHttpServer(port, handleFetch, stack);
|
|
72
|
+
const watcherAbort = new AbortController();
|
|
73
|
+
stop = createDevServerStop({
|
|
74
|
+
watcherAbort,
|
|
75
|
+
killTailwind: stylesheet?.kill,
|
|
76
|
+
server,
|
|
77
|
+
stack,
|
|
78
|
+
devJobRunners,
|
|
79
|
+
dbMode,
|
|
80
|
+
ownedRedisSseBroker,
|
|
81
|
+
});
|
|
82
|
+
startDevFileWatchers({
|
|
83
|
+
entries,
|
|
84
|
+
watchDirs: options.watchDirs,
|
|
85
|
+
watchDirectory: options._watchDirectory,
|
|
86
|
+
signal: watcherAbort.signal,
|
|
87
|
+
buildBundle,
|
|
88
|
+
clientBundles,
|
|
89
|
+
reloadHub,
|
|
90
|
+
stop,
|
|
91
|
+
});
|
|
92
|
+
if (options.installSignalHandlers ?? true)
|
|
93
|
+
installDevSignalHandlers(stop);
|
|
94
|
+
if (server !== undefined) {
|
|
95
|
+
logInfo(`[kumiko-server] listening on http://localhost:${port}` +
|
|
96
|
+
(entries.length > 0
|
|
97
|
+
? ` (hot reload on ${entries.length === 1 ? "client entry" : `${entries.length} entries`})`
|
|
98
|
+
: ""));
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
fetch: handleFetch,
|
|
102
|
+
server,
|
|
103
|
+
stack,
|
|
104
|
+
stop,
|
|
105
|
+
jobQueueNamePrefix: dbMode.jobQueueNamePrefix,
|
|
106
|
+
sseChannelPrefix: dbMode.sseChannelPrefix,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
// Before stop() exists only the --watch child needs killing; once it exists, it tears down
|
|
111
|
+
// the server, stack, runners and watcher that a late failure would otherwise leak.
|
|
112
|
+
if (stop)
|
|
113
|
+
await stop();
|
|
114
|
+
else
|
|
115
|
+
stylesheet?.kill();
|
|
116
|
+
throw err;
|
|
97
117
|
}
|
|
98
|
-
return {
|
|
99
|
-
fetch: handleFetch,
|
|
100
|
-
server,
|
|
101
|
-
stack,
|
|
102
|
-
stop,
|
|
103
|
-
jobQueueNamePrefix: dbMode.jobQueueNamePrefix,
|
|
104
|
-
sseChannelPrefix: dbMode.sseChannelPrefix,
|
|
105
|
-
};
|
|
106
118
|
}
|
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
// load without it: every Bun.* call is gated, and tests drive the fetch
|
|
4
4
|
// handler directly instead of going through Bun.serve and real sockets.
|
|
5
5
|
export const hasBun = typeof Bun !== "undefined";
|
|
6
|
-
// @wrapper-known semantic-alias
|
|
7
6
|
// biome-ignore lint/suspicious/noConsole: dev-server status logging
|
|
8
7
|
export const logInfo = (msg) => console.log(msg);
|
|
9
|
-
// @wrapper-known semantic-alias
|
|
10
8
|
// biome-ignore lint/suspicious/noConsole: dev-server error logging
|
|
11
9
|
export const logError = (...args) => console.error(...args);
|
package/dist/kebab.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Segment-strict: rejects trailing/double hyphen so the name is a valid
|
|
2
2
|
// package-name + folder (`my-shop`, not `my-` or `my--shop`).
|
|
3
3
|
const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
4
|
-
// @wrapper-known semantic-alias
|
|
5
4
|
export function isKebabSegment(value) {
|
|
6
5
|
return KEBAB_RE.test(value);
|
|
7
6
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bundledLists` lives in kumiko.config.ts for the schema tooling and in the runner options for
|
|
3
|
+
* the running app. A runner call in bin/ that leaves it out would serve other screens than
|
|
4
|
+
* `kumiko check` validates.
|
|
5
|
+
*/
|
|
6
|
+
export declare function findBundledListsWiringErrors(appRoot: string): readonly string[];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { APP_CONFIG_FILE } from "@cosmicdrift/kumiko-framework/config";
|
|
4
|
+
import { Project, SyntaxKind } from "ts-morph";
|
|
5
|
+
const RUNNER_NAMES = new Set(["runProdApp", "runDevApp", "runWorkerApp"]);
|
|
6
|
+
const BUNDLED_LISTS = "bundledLists";
|
|
7
|
+
function parseSource(fileName, source) {
|
|
8
|
+
const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
|
|
9
|
+
return project.createSourceFile(fileName, source);
|
|
10
|
+
}
|
|
11
|
+
function declaresProperty(object, name) {
|
|
12
|
+
return object.getProperties().some((property) => {
|
|
13
|
+
if (property.isKind(SyntaxKind.ShorthandPropertyAssignment))
|
|
14
|
+
return property.getName() === name;
|
|
15
|
+
if (property.isKind(SyntaxKind.PropertyAssignment)) {
|
|
16
|
+
return property.getName().replace(/^["']|["']$/g, "") === name;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function hasSpread(object) {
|
|
22
|
+
return object.getProperties().some((property) => property.isKind(SyntaxKind.SpreadAssignment));
|
|
23
|
+
}
|
|
24
|
+
function firstObjectArgument(args) {
|
|
25
|
+
const first = args[0];
|
|
26
|
+
return first?.isKind(SyntaxKind.ObjectLiteralExpression) ? first : undefined;
|
|
27
|
+
}
|
|
28
|
+
function configDeclaresBundledLists(source) {
|
|
29
|
+
const call = parseSource(APP_CONFIG_FILE, source)
|
|
30
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
31
|
+
.find((candidate) => candidate.getExpression().getText() === "defineAppConfig");
|
|
32
|
+
const config = call === undefined ? undefined : firstObjectArgument(call.getArguments());
|
|
33
|
+
return config !== undefined && declaresProperty(config, BUNDLED_LISTS);
|
|
34
|
+
}
|
|
35
|
+
function runnersWithoutBundledLists(fileName, source) {
|
|
36
|
+
return parseSource(fileName, source)
|
|
37
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
38
|
+
.flatMap((call) => {
|
|
39
|
+
const runner = call.getExpression().getText();
|
|
40
|
+
if (!RUNNER_NAMES.has(runner))
|
|
41
|
+
return [];
|
|
42
|
+
const options = firstObjectArgument(call.getArguments());
|
|
43
|
+
// A spread may carry the key; the static check cannot see into it.
|
|
44
|
+
if (options === undefined || declaresProperty(options, BUNDLED_LISTS) || hasSpread(options)) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return [runner];
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* `bundledLists` lives in kumiko.config.ts for the schema tooling and in the runner options for
|
|
52
|
+
* the running app. A runner call in bin/ that leaves it out would serve other screens than
|
|
53
|
+
* `kumiko check` validates.
|
|
54
|
+
*/
|
|
55
|
+
export function findBundledListsWiringErrors(appRoot) {
|
|
56
|
+
const configPath = join(appRoot, APP_CONFIG_FILE);
|
|
57
|
+
if (!existsSync(configPath) || !configDeclaresBundledLists(readFileSync(configPath, "utf-8"))) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const binDir = join(appRoot, "bin");
|
|
61
|
+
if (!existsSync(binDir))
|
|
62
|
+
return [];
|
|
63
|
+
return readdirSync(binDir)
|
|
64
|
+
.filter((name) => name.endsWith(".ts"))
|
|
65
|
+
.sort()
|
|
66
|
+
.flatMap((name) => runnersWithoutBundledLists(name, readFileSync(join(binDir, name), "utf-8")).map((runner) => `bin/${name}: ${runner}() does not pass bundledLists although ${APP_CONFIG_FILE} sets it. Add \`bundledLists: appConfig.bundledLists\` to the runner options, otherwise the app serves other screens than \`kumiko check\` validates.`));
|
|
67
|
+
}
|
|
@@ -9,6 +9,7 @@ import { readStaticAppConfig, } from "@cosmicdrift/kumiko-guards/repo-manifest/a
|
|
|
9
9
|
import { BUNFIG_FILES, renderBunfig } from "@cosmicdrift/kumiko-testing/bunfig";
|
|
10
10
|
import { isKebabSegment } from "../kebab.js";
|
|
11
11
|
import { readReleaseTarget, render, renderDeployFiles, } from "../scaffold-deploy.js";
|
|
12
|
+
import { findBundledListsWiringErrors } from "./bundled-lists-wiring.js";
|
|
12
13
|
import { addManagedMarker } from "./marker.js";
|
|
13
14
|
import { findKumikoPinErrors } from "./pins.js";
|
|
14
15
|
export const BIOME_PRESET = "@cosmicdrift/kumiko-framework/biome";
|
|
@@ -60,6 +61,7 @@ export function listManagedErrors(facts) {
|
|
|
60
61
|
".");
|
|
61
62
|
}
|
|
62
63
|
errors.push(...findKumikoPinErrors(readPackageJsonOrEmpty(facts.appRoot)));
|
|
64
|
+
errors.push(...findBundledListsWiringErrors(facts.appRoot));
|
|
63
65
|
return errors;
|
|
64
66
|
}
|
|
65
67
|
function marked(path, body, style, executable = false) {
|
|
@@ -22,6 +22,7 @@ function pickDevServiceOptions(options) {
|
|
|
22
22
|
...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
|
|
23
23
|
...(options.files !== undefined && { files: options.files }),
|
|
24
24
|
...(options.wire !== undefined && { wire: options.wire }),
|
|
25
|
+
...(options.pwa !== undefined && { pwa: options.pwa }),
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
export function buildDevServerOptions(deps) {
|
package/dist/run-dev-app.d.ts
CHANGED
|
@@ -102,6 +102,8 @@ export type RunDevAppOptions = {
|
|
|
102
102
|
* through `runDevApp` show the same resolved head prod does. See
|
|
103
103
|
* `CreateKumikoServerOptions.resolvePageHead` for the full contract. */
|
|
104
104
|
readonly resolvePageHead?: PageHeadResolver;
|
|
105
|
+
/** Installable web app, same option as `runProdApp`'s `pwa`; pass the same value to both. */
|
|
106
|
+
readonly pwa?: CreateKumikoServerOptions["pwa"];
|
|
105
107
|
/** CSS entry. Defaults to the package export
|
|
106
108
|
* `@cosmicdrift/kumiko-renderer/web/styles.css` when a client entry is set.
|
|
107
109
|
* `false` disables the CSS pipeline. */
|
package/dist/scaffold-app.js
CHANGED
|
@@ -146,7 +146,6 @@ export async function scaffoldApp(options) {
|
|
|
146
146
|
files.push("CLAUDE.md");
|
|
147
147
|
return { destination, files, appName: options.name };
|
|
148
148
|
}
|
|
149
|
-
// @wrapper-known semantic-alias
|
|
150
149
|
function write(path, content) {
|
|
151
150
|
writeFileSync(path, content);
|
|
152
151
|
}
|
|
@@ -156,7 +155,7 @@ function appScripts(testSetup) {
|
|
|
156
155
|
build: "bun kumiko-build",
|
|
157
156
|
start: "bun run bin/main.ts",
|
|
158
157
|
boot: "KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts",
|
|
159
|
-
typecheck: "
|
|
158
|
+
typecheck: "bun check --no-script",
|
|
160
159
|
lint: "biome check .",
|
|
161
160
|
check: "kumiko check",
|
|
162
161
|
...(testSetup?.scripts ?? { test: "bun --config=bunfig.toml test --dots" }),
|
|
@@ -171,6 +170,8 @@ function renderPackageJson(name, version, testSetup, infraRef) {
|
|
|
171
170
|
version: "0.1.0",
|
|
172
171
|
private: true,
|
|
173
172
|
type: "module",
|
|
173
|
+
// `bun check` is built in from 1.4.4; older Bun resolves it to the app's `check` script.
|
|
174
|
+
engines: { bun: ">=1.4.4" },
|
|
174
175
|
kumiko: { deploy: { infraRef } },
|
|
175
176
|
scripts: appScripts(testSetup),
|
|
176
177
|
dependencies: {
|
|
@@ -355,7 +356,8 @@ function renderMain(appName) {
|
|
|
355
356
|
]);
|
|
356
357
|
// Subject-key KMS is optional locally (plaintext + loud warning) but required
|
|
357
358
|
// under NODE_ENV=production.
|
|
358
|
-
return `//
|
|
359
|
+
return `// biome-ignore-all lint/style/noProcessEnv: KMS wiring and the demo admin password are read before runProdApp parses the env contract, and the scaffold has no bin/env.ts
|
|
360
|
+
// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after
|
|
359
361
|
// composeFeatures + validateBoot + createRegistry without DB/Redis-connect
|
|
360
362
|
// (see @cosmicdrift/kumiko-server-runtime runProdApp).
|
|
361
363
|
|
|
@@ -45,7 +45,8 @@ export function renderBlankMain(appName) {
|
|
|
45
45
|
{ specifier: "../kumiko.config", names: [], defaultName: "appConfig" },
|
|
46
46
|
{ specifier: "./env-schema", names: ["envSchema"] },
|
|
47
47
|
]);
|
|
48
|
-
return `//
|
|
48
|
+
return `// biome-ignore-all lint/style/noProcessEnv: KMS wiring and the demo admin password are read before runProdApp parses the env contract, and the scaffold has no bin/env.ts
|
|
49
|
+
// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after
|
|
49
50
|
// composeFeatures + validateBoot + createRegistry without DB/Redis-connect
|
|
50
51
|
// (see @cosmicdrift/kumiko-server-runtime runProdApp).
|
|
51
52
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// functions emit the same shape into the user's repo.
|
|
4
4
|
import { createBooleanField, createEntity, createNumberField, createTextField, defineEntityCreateHandler, defineEntityDeleteHandler, defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateHandler, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
const taskEntity = createEntity({
|
|
6
|
+
description: "A to-do item with a title, status, priority and urgency flag.",
|
|
6
7
|
fields: {
|
|
7
8
|
title: createTextField({
|
|
8
9
|
required: true,
|
|
@@ -38,6 +39,7 @@ const open = {
|
|
|
38
39
|
},
|
|
39
40
|
},
|
|
40
41
|
};
|
|
42
|
+
const described = (description) => ({ ...open, description });
|
|
41
43
|
const TASKS_I18N = {
|
|
42
44
|
"screen:task-list.title": { de: "Aufgaben", en: "Tasks" },
|
|
43
45
|
"screen:task-edit.title": { de: "Aufgabe", en: "Task" },
|
|
@@ -53,11 +55,11 @@ export function createDemoTasksFeature() {
|
|
|
53
55
|
return defineFeature("tasks", (r) => {
|
|
54
56
|
r.translations({ keys: TASKS_I18N });
|
|
55
57
|
r.entity({ name: "task", ...taskEntity });
|
|
56
|
-
r.writeHandler(defineEntityCreateHandler("task", taskEntity,
|
|
57
|
-
r.writeHandler(defineEntityUpdateHandler("task", taskEntity,
|
|
58
|
-
r.writeHandler(defineEntityDeleteHandler("task", taskEntity,
|
|
59
|
-
r.queryHandler(defineEntityListHandler("task", taskEntity,
|
|
60
|
-
r.queryHandler(defineEntityDetailHandler("task", taskEntity,
|
|
58
|
+
r.writeHandler(defineEntityCreateHandler("task", taskEntity, described("Creates a task.")));
|
|
59
|
+
r.writeHandler(defineEntityUpdateHandler("task", taskEntity, described("Updates a task.")));
|
|
60
|
+
r.writeHandler(defineEntityDeleteHandler("task", taskEntity, described("Deletes a task.")));
|
|
61
|
+
r.queryHandler(defineEntityListHandler("task", taskEntity, described("Lists all tasks.")));
|
|
62
|
+
r.queryHandler(defineEntityDetailHandler("task", taskEntity, described("Returns one task.")));
|
|
61
63
|
r.screen(listScreen);
|
|
62
64
|
r.screen(editScreen);
|
|
63
65
|
r.nav({ id: "tasks", label: "tasks:nav.tasks", order: 10, screen: "tasks:screen:task-list" });
|
|
@@ -93,6 +95,7 @@ import type {
|
|
|
93
95
|
import { tasksTranslationKeys } from "./i18n";
|
|
94
96
|
|
|
95
97
|
const taskEntity = createEntity({
|
|
98
|
+
description: "A to-do item with a title, status, priority and urgency flag.",
|
|
96
99
|
fields: {
|
|
97
100
|
title: createTextField({
|
|
98
101
|
required: true,
|
|
@@ -133,14 +136,16 @@ const open = {
|
|
|
133
136
|
},
|
|
134
137
|
} as const;
|
|
135
138
|
|
|
139
|
+
const described = (description: string) => ({ ...open, description });
|
|
140
|
+
|
|
136
141
|
export const tasksFeature = defineFeature("tasks", (r) => {
|
|
137
142
|
r.translations({ keys: tasksTranslationKeys });
|
|
138
143
|
r.entity({ name: "task", ...taskEntity });
|
|
139
|
-
r.writeHandler(defineEntityCreateHandler("task", taskEntity,
|
|
140
|
-
r.writeHandler(defineEntityUpdateHandler("task", taskEntity,
|
|
141
|
-
r.writeHandler(defineEntityDeleteHandler("task", taskEntity,
|
|
142
|
-
r.queryHandler(defineEntityListHandler("task", taskEntity,
|
|
143
|
-
r.queryHandler(defineEntityDetailHandler("task", taskEntity,
|
|
144
|
+
r.writeHandler(defineEntityCreateHandler("task", taskEntity, described("Creates a task.")));
|
|
145
|
+
r.writeHandler(defineEntityUpdateHandler("task", taskEntity, described("Updates a task.")));
|
|
146
|
+
r.writeHandler(defineEntityDeleteHandler("task", taskEntity, described("Deletes a task.")));
|
|
147
|
+
r.queryHandler(defineEntityListHandler("task", taskEntity, described("Lists all tasks.")));
|
|
148
|
+
r.queryHandler(defineEntityDetailHandler("task", taskEntity, described("Returns one task.")));
|
|
144
149
|
r.screen(listScreen);
|
|
145
150
|
r.screen(editScreen);
|
|
146
151
|
r.nav({ id: "tasks", label: "tasks:nav.tasks", order: 10, screen: "tasks:screen:task-list" });
|
|
@@ -72,7 +72,6 @@ export function deriveTenantId(name) {
|
|
|
72
72
|
state ^= ch.charCodeAt(0);
|
|
73
73
|
state = Math.imul(state, 16777619) >>> 0;
|
|
74
74
|
}
|
|
75
|
-
// @wrapper-known semantic-alias
|
|
76
75
|
const hex = (n, len) => n.toString(16).padStart(len, "0").slice(0, len);
|
|
77
76
|
const a = hex(state, 8);
|
|
78
77
|
state ^= state << 13;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.414.0",
|
|
4
4
|
"description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding. Its compose-stacks/env-schema subpaths are consumed at prod boot too — see @cosmicdrift/kumiko-server-runtime for the prod runner itself.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -77,15 +77,15 @@
|
|
|
77
77
|
"kumiko-upgrade": "./bin/kumiko-upgrade.ts"
|
|
78
78
|
},
|
|
79
79
|
"dependencies": {
|
|
80
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
81
|
-
"@cosmicdrift/kumiko-feature-ast": "0.
|
|
82
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
83
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
84
|
-
"@cosmicdrift/kumiko-renderer-mail-html": "0.
|
|
85
|
-
"@cosmicdrift/kumiko-guards": "0.
|
|
86
|
-
"@cosmicdrift/kumiko-server-runtime": "0.
|
|
87
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
88
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
80
|
+
"@cosmicdrift/kumiko-bundled-features": "0.414.0",
|
|
81
|
+
"@cosmicdrift/kumiko-feature-ast": "0.414.0",
|
|
82
|
+
"@cosmicdrift/kumiko-framework": "0.414.0",
|
|
83
|
+
"@cosmicdrift/kumiko-renderer": "0.414.0",
|
|
84
|
+
"@cosmicdrift/kumiko-renderer-mail-html": "0.414.0",
|
|
85
|
+
"@cosmicdrift/kumiko-guards": "0.414.0",
|
|
86
|
+
"@cosmicdrift/kumiko-server-runtime": "0.414.0",
|
|
87
|
+
"@cosmicdrift/kumiko-testing": "0.414.0",
|
|
88
|
+
"@cosmicdrift/kumiko-types": "0.414.0",
|
|
89
89
|
"ts-morph": "^28.0.0",
|
|
90
90
|
"zod": "^4.6.7"
|
|
91
91
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.414.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Scaffolded apps typecheck with bun check",
|
|
6
|
+
"detail": "The typecheck script of newly scaffolded apps and of the framework workspace now runs `bun check --no-script` instead of `tsc --noEmit`, which needs Bun 1.4.4 or newer. Existing apps keep their script until they change it."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.414.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Deploy Dockerfile template no longer mentions migrate-step.sh",
|
|
12
|
+
"detail": "The comment in `deploy/Dockerfile.template` named a compose-host migrate step that the scaffold does not generate. It now only names the migrate-db initContainer of the k3s deployment."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.414.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "managed check reports a bin entry that does not pass bundledLists",
|
|
18
|
+
"detail": "When `kumiko.config.ts` sets `bundledLists`, every `runProdApp`, `runDevApp` and `runWorkerApp` call in `bin/*.ts` must pass `bundledLists` too; otherwise the managed-files check reports an error instead of the app drifting silently from its config."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.414.0",
|
|
22
|
+
"type": "fix",
|
|
23
|
+
"title": "subpath imports of an optional runtime external count as an import",
|
|
24
|
+
"detail": "The server bundle only detected `import \"meilisearch\"`, so an app that imported just `meilisearch/token` and did not list `meilisearch` in its dependencies shipped a `dist-server/package.json` without it and crashed in production with `Cannot find package 'meilisearch'`. Subpath imports are detected now."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.414.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "dev server boots when KUMIKO_REDIS_PREFIX is set",
|
|
30
|
+
"detail": "The SSE channel prefix defaulted to a per-boot value that differs from `KUMIKO_REDIS_PREFIX`, which `redisNamespaceFromEnv` rejects. The prefix now falls back to `KUMIKO_REDIS_PREFIX` before the per-boot default."
|
|
31
|
+
},
|
|
2
32
|
{
|
|
3
33
|
"version": "0.411.0",
|
|
4
34
|
"type": "improvement",
|
|
@@ -64,7 +64,7 @@ RUN bun install --production --frozen-lockfile
|
|
|
64
64
|
COPY --from=build --chown=app:app /app/dist ./dist
|
|
65
65
|
|
|
66
66
|
# Checked-in SQL migrations for the pre-deploy `kumiko schema apply` step
|
|
67
|
-
# (migrate-db initContainer of the infra _app-deploy.yml on k3s
|
|
67
|
+
# (migrate-db initContainer of the infra _app-deploy.yml on k3s).
|
|
68
68
|
# The shipped `kumiko-schema` bin reads `${INIT_CWD}/kumiko/migrations/*.sql`
|
|
69
69
|
# (see ENV INIT_CWD=/app below). kumiko/schema.ts is NOT needed at runtime
|
|
70
70
|
# (only generate reads it; apply is checksum-tracked SQL only).
|