@dunx/create-app 3.3.1 → 3.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,12 @@ There is no flag for choosing features. The command opens a list:
43
43
  two lines under the list update as you go: one shows what your selection drags in,
44
44
  the other shows which of it needs Redis or Postgres running to do anything.
45
45
 
46
+ After the list, one more question: whether to compile the app to a single
47
+ standalone binary. Yes adds a `scripts/build.ts` and a `build` script, so
48
+ `bun run build` writes one executable with `bun build --compile` that a host runs
49
+ with nothing installed. It needs Bun >= 1.4.1, the floor the generated
50
+ `package.json` already declares.
51
+
46
52
  Three more questions appear only when there is something to ask: a directory, when
47
53
  the command line named none; a package name, when the directory's is one npm would
48
54
  reject; and whether to write into a directory that already has files in it.
@@ -152,7 +158,9 @@ const { directory, files } = await scaffold({
152
158
 
153
159
  This is the scripted path the removed `--with` flag used to be. `features` takes
154
160
  the same names the list shows, in any order, and pulls in what they require.
155
- `FEATURES` exports the set. Omitting it writes the minimal template.
161
+ `FEATURES` exports the set. Omitting it writes the minimal template. `binary: true`
162
+ adds the standalone-binary build, and forces the generation path so it works with
163
+ no features too.
156
164
 
157
165
  `scaffold` throws `ScaffoldError` for anything the caller can fix - an unknown
158
166
  template, an unusable package name, a non-empty target without `force` - and lets
@@ -0,0 +1,9 @@
1
+ /** The binary a compiled build writes, from the package name's last segment. */
2
+ export declare const binaryName: (name: string) => string;
3
+ /**
4
+ * The `scripts/build.ts` a `binary` scaffold gets. `Bun.build` takes both the
5
+ * `@dunx/transform` plugin and `compile`, so the constructor-dependency records
6
+ * the container resolves are baked into the executable at build time - a compiled
7
+ * binary has no load-time plugin to record them.
8
+ */
9
+ export declare const buildScript: (name: string) => string;
@@ -456,6 +456,39 @@ var agentFiles = (name, features) => ({
456
456
  "CLAUDE.md": CLAUDE_POINTER
457
457
  });
458
458
 
459
+ // src/build-template.ts
460
+ var binaryName = (name) => name.split("/").at(-1) ?? name;
461
+ var buildScript = (name) => `// Generated by @dunx/create-app for ${name}. Yours to edit.
462
+ import { depsPlugin } from '@dunx/transform';
463
+ import { join, resolve } from 'node:path';
464
+
465
+ const DIR = resolve(import.meta.dir, '..');
466
+ const outfile = join(DIR, 'dist', '${binaryName(name)}');
467
+
468
+ const compiled = await Bun.build({
469
+ entrypoints: [join(DIR, 'src/main.ts')],
470
+ target: 'bun',
471
+ // One self-contained executable: the Bun runtime plus the app in one file, so a
472
+ // host needs nothing installed to run it.
473
+ compile: { outfile },
474
+ plugins: [depsPlugin],
475
+ minify: true,
476
+ // A stack trace is never read off a shipped binary; symbols would only add size.
477
+ sourcemap: 'none',
478
+ tsconfig: join(DIR, 'tsconfig.json'),
479
+ });
480
+
481
+ if (!compiled.success) {
482
+ for (const log of compiled.logs) console.error(log);
483
+ throw new AggregateError(compiled.logs, 'compile failed');
484
+ }
485
+
486
+ const bytes = await Bun.file(outfile).bytes();
487
+ console.log(
488
+ \`Built \${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB -> \${outfile}\`,
489
+ );
490
+ `;
491
+
459
492
  // src/generate.ts
460
493
  var HEADER = (name) => `// Generated by @dunx/create-app for ${name}. Yours to edit.
461
494
  `;
@@ -475,7 +508,7 @@ var dependenciesFor = (features) => uniq([
475
508
  ...features.flatMap((feature) => feature.dependencies)
476
509
  ]).sort();
477
510
  var DUNX = /^@dunx\//;
478
- var manifest = (features) => {
511
+ var manifest = (features, binary = false) => {
479
512
  const deps = dependenciesFor(features);
480
513
  const dependencies = {};
481
514
  for (const dep of deps) {
@@ -484,6 +517,7 @@ var manifest = (features) => {
484
517
  const scripts = {
485
518
  dev: "bun --watch src/main.ts",
486
519
  start: "bun src/main.ts",
520
+ ...binary ? { build: "bun scripts/build.ts" } : {},
487
521
  test: "bun test",
488
522
  typecheck: "tsc --noEmit"
489
523
  };
@@ -746,8 +780,9 @@ ${lines.join(`
746
780
  `)}
747
781
  `;
748
782
  };
749
- var readme = (name, features) => {
783
+ var readme = (name, features, binary = false) => {
750
784
  const services = features.filter((feature) => feature.service !== undefined);
785
+ const bin = binaryName(name);
751
786
  return `# ${name}
752
787
 
753
788
  Scaffolded with \`bunx @dunx/create-app\`.
@@ -757,6 +792,24 @@ bun install
757
792
  bun run dev # restarts on a change
758
793
  bun run start
759
794
  \`\`\`
795
+ ${binary ? `
796
+ ## Compile to a binary
797
+
798
+ \`\`\`bash
799
+ bun run build # -> dist/${bin} (the Bun runtime plus the app, one file)
800
+ \`\`\`
801
+
802
+ \`scripts/build.ts\` hands \`Bun.build\` the \`@dunx/transform\` plugin and \`compile\`
803
+ together, so the constructor-dependency records the container needs are baked into
804
+ the executable. This needs Bun >= 1.4.1, the version \`package.json\` already
805
+ requires: an earlier one dropped the records. Copy \`dist/${bin}\` to a host and run
806
+ it; it needs nothing installed.
807
+
808
+ Run it from a directory without this app's \`bunfig.toml\`. A standalone bun
809
+ executable still reads \`preload\` from the working directory's bunfig and would try
810
+ to load \`@dunx/transform/preload\`, which the binary no longer needs and cannot
811
+ resolve. A deployment host has no such file.
812
+ ` : ""}
760
813
 
761
814
  ## What is wired up
762
815
 
@@ -821,15 +874,16 @@ var packageVersion = async () => {
821
874
  return json.version ?? "0.0.0";
822
875
  };
823
876
  var fill = (contents, name, version) => contents.replaceAll(VERSION_PLACEHOLDER2, version).replaceAll("__DUNX_APP_NAME__", name);
824
- var generated = (name, features) => {
877
+ var generated = (name, features, binary) => {
825
878
  const groups = configGroupsFor(features);
826
879
  return {
827
- "package.json": manifest(features),
828
- "README.md": readme(name, features),
880
+ "package.json": manifest(features, binary),
881
+ "README.md": readme(name, features, binary),
829
882
  ".env.example": envExample(groups),
830
883
  "src/main.ts": main(name, features),
831
884
  "src/app.module.ts": appModule(name, features),
832
- "src/config.ts": config(name, groups)
885
+ "src/config.ts": config(name, groups),
886
+ ...binary ? { "scripts/build.ts": buildScript(name) } : {}
833
887
  };
834
888
  };
835
889
  var scaffold2 = async (options) => {
@@ -844,7 +898,8 @@ var scaffold2 = async (options) => {
844
898
  } catch (error) {
845
899
  throw new ScaffoldError2(error instanceof Error ? error.message : String(error));
846
900
  }
847
- const composing = features.length > 0;
901
+ const binary = options.binary === true;
902
+ const composing = features.length > 0 || binary;
848
903
  const directory = resolve(options.cwd ?? process.cwd(), options.target);
849
904
  const name = options.name ?? basename(directory);
850
905
  if (!isValidPackageName2(name)) {
@@ -892,6 +947,7 @@ var scaffold2 = async (options) => {
892
947
  name,
893
948
  template,
894
949
  features: [],
950
+ binary: false,
895
951
  files: written.sort()
896
952
  };
897
953
  }
@@ -908,7 +964,7 @@ var scaffold2 = async (options) => {
908
964
  await copyTree(from, join("src", feature.source));
909
965
  }
910
966
  await writeAll({
911
- ...generated(name, features),
967
+ ...generated(name, features, binary),
912
968
  ...agentFiles(name, features)
913
969
  });
914
970
  return {
@@ -916,6 +972,7 @@ var scaffold2 = async (options) => {
916
972
  name,
917
973
  template: "composed",
918
974
  features: features.map((feature) => feature.name),
975
+ binary,
919
976
  files: written.sort()
920
977
  };
921
978
  };
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  isValidPackageName2,
10
10
  packageVersion,
11
11
  scaffold2
12
- } from "./chunk-nznx4cv8.js";
12
+ } from "./chunk-akm69d19.js";
13
13
 
14
14
  // src/cli.ts
15
15
  import { parseArgs } from "util";
@@ -673,8 +673,14 @@ class Wizard {
673
673
  const target = defaults.target ?? await this.#runner.ask(new DirectoryPrompt(this.#style, "Directory", DEFAULT_TARGET));
674
674
  const name = await this.#name(defaults, target);
675
675
  const features = await this.#runner.ask(new FeaturePrompt(this.#style, defaults.features));
676
+ const binary = await this.#binary(defaults);
676
677
  const force = await this.#force(defaults, target);
677
- return { target, name, features, force };
678
+ return { target, name, features, binary, force };
679
+ }
680
+ async#binary(defaults) {
681
+ if (defaults.binary !== undefined)
682
+ return defaults.binary;
683
+ return this.#runner.ask(new ConfirmPrompt(this.#style, "Compile to a standalone binary?", false));
678
684
  }
679
685
  async#name(defaults, target) {
680
686
  if (defaults.name !== undefined)
@@ -766,6 +772,7 @@ var byAsking = async () => {
766
772
  target,
767
773
  name: values.name,
768
774
  features: [],
775
+ binary: undefined,
769
776
  force: values.force === true,
770
777
  cwd: process.cwd()
771
778
  });
@@ -802,6 +809,9 @@ Created ${result.name} in ${where}/`);
802
809
  console.log(` cd ${where}`);
803
810
  console.log(" bun install");
804
811
  console.log(` bun run dev ${style.muted("# or `start`, which does not watch")}`);
812
+ if (result.binary) {
813
+ console.log(` bun run build ${style.muted("# compile to one standalone binary")}`);
814
+ }
805
815
  } catch (error) {
806
816
  if (error instanceof CancelledError) {
807
817
  console.log(style.muted(error.message));
@@ -1,7 +1,7 @@
1
1
  import { type Feature } from './features.js';
2
2
  /** Every config group the selection needs, base first, in a stable order. */
3
3
  export declare const configGroupsFor: (features: readonly Feature[]) => readonly string[];
4
- export declare const manifest: (features: readonly Feature[]) => string;
4
+ export declare const manifest: (features: readonly Feature[], binary?: boolean) => string;
5
5
  /**
6
6
  * Third-party versions, exact for the reason dunx's own manifests are, and
7
7
  * written here rather than read off `examples/full` at run time:
@@ -15,4 +15,4 @@ export declare const appModule: (name: string, features: readonly Feature[]) =>
15
15
  export declare const config: (name: string, groups: readonly string[]) => string;
16
16
  export declare const main: (name: string, features: readonly Feature[]) => string;
17
17
  export declare const envExample: (groups: readonly string[]) => string;
18
- export declare const readme: (name: string, features: readonly Feature[]) => string;
18
+ export declare const readme: (name: string, features: readonly Feature[], binary?: boolean) => string;
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  ScaffoldError2,
9
9
  isValidPackageName2,
10
10
  scaffold2
11
- } from "./chunk-nznx4cv8.js";
11
+ } from "./chunk-akm69d19.js";
12
12
  export {
13
13
  FEATURES2 as FEATURES,
14
14
  ScaffoldError2 as ScaffoldError,
@@ -32,6 +32,12 @@ export interface ScaffoldOptions {
32
32
  * what it was.
33
33
  */
34
34
  readonly features?: readonly string[];
35
+ /**
36
+ * Add a `scripts/build.ts` and a `build` script that compile the app to one
37
+ * standalone executable. Forces the generation path, so it works with no
38
+ * features chosen too.
39
+ */
40
+ readonly binary?: boolean;
35
41
  /** Write into a directory that already has files in it. */
36
42
  readonly force?: boolean;
37
43
  readonly cwd?: string;
@@ -44,6 +50,8 @@ export interface ScaffoldResult {
44
50
  readonly template: TemplateName | 'composed';
45
51
  /** Resolved feature names, in import order. Empty for a fixed template. */
46
52
  readonly features: readonly string[];
53
+ /** Whether a standalone-binary build was generated. */
54
+ readonly binary: boolean;
47
55
  readonly files: readonly string[];
48
56
  }
49
57
  export declare class ScaffoldError extends Error {
package/dist/wizard.d.ts CHANGED
@@ -4,6 +4,7 @@ export interface WizardAnswers {
4
4
  readonly target: string;
5
5
  readonly name: string;
6
6
  readonly features: readonly string[];
7
+ readonly binary: boolean;
7
8
  readonly force: boolean;
8
9
  }
9
10
  /** What the flags already settled, so the wizard skips asking again. */
@@ -11,16 +12,19 @@ export interface WizardDefaults {
11
12
  readonly target: string | undefined;
12
13
  readonly name: string | undefined;
13
14
  readonly features: readonly string[];
15
+ /** `undefined` asks; a boolean skips the question, the way `target` does. */
16
+ readonly binary: boolean | undefined;
14
17
  readonly force: boolean;
15
18
  readonly cwd: string;
16
19
  }
17
20
  /**
18
21
  * The questions, in the order the answers are needed.
19
22
  *
20
- * Each one is skipped when there is nothing to ask: a target given on the command
21
- * line, a package name that is already legal, a directory that is already empty.
22
- * Running `bunx @dunx/create-app my-api` in a clean directory therefore asks one
23
- * question, the one nothing else can answer.
23
+ * The directory, package name and force questions are skipped when there is
24
+ * nothing to ask: a target given on the command line, a package name that is
25
+ * already legal, a directory that is already empty. Running
26
+ * `bunx @dunx/create-app my-api` in a clean directory therefore asks the two that
27
+ * nothing else can answer, features and the binary build.
24
28
  */
25
29
  export declare class Wizard {
26
30
  #private;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunx/create-app",
3
- "version": "3.3.1",
3
+ "version": "3.3.2",
4
4
  "description": "Scaffold a new dunx application - bunx @dunx/create-app my-api",
5
5
  "keywords": [
6
6
  "bun",
@@ -42,33 +42,35 @@ export class CacheController {
42
42
  }
43
43
 
44
44
  @Get('/:id', oneSession)
45
- async read(
46
- input: Input<typeof oneSession>,
47
- ): Promise<{ id: string; data: unknown; ttl: number }> {
48
- const found = await this.degrades(() =>
49
- this.sessions.read(input.params.id),
50
- );
45
+ async read({ params }: Input<typeof oneSession>): Promise<{
46
+ id: string;
47
+ data: unknown;
48
+ ttl: number;
49
+ }> {
50
+ const found = await this.degrades(() => this.sessions.read(params.id));
51
51
  if (found === null) {
52
52
  throw new HttpError(
53
53
  HttpStatusCode.NOT_FOUND,
54
- `No session "${input.params.id}"`,
54
+ `No session "${params.id}"`,
55
55
  );
56
56
  }
57
57
  return found;
58
58
  }
59
59
 
60
60
  @Put('/:id', putSession)
61
- store(
62
- input: Input<typeof putSession>,
63
- ): Promise<{ id: string; ttl: number; visits: number }> {
61
+ store({ body, params }: Input<typeof putSession>): Promise<{
62
+ id: string;
63
+ ttl: number;
64
+ visits: number;
65
+ }> {
64
66
  return this.degrades(() =>
65
- this.sessions.store(input.params.id, input.body.data, input.body.ttl),
67
+ this.sessions.store(params.id, body.data, body.ttl),
66
68
  );
67
69
  }
68
70
 
69
71
  @Delete('/:id', oneSession)
70
- remove(input: Input<typeof oneSession>): Promise<{ removed: number }> {
71
- return this.degrades(() => this.sessions.remove(input.params.id));
72
+ remove({ params }: Input<typeof oneSession>): Promise<{ removed: number }> {
73
+ return this.degrades(() => this.sessions.remove(params.id));
72
74
  }
73
75
 
74
76
  private async degrades<T>(run: () => Promise<T>): Promise<T> {
@@ -2,61 +2,10 @@ import { Logger, Module } from '@dunx/core';
2
2
  import { HttpFactory, PubSub, type HttpApp } from '@dunx/http';
3
3
  import { isConnectionError, RedisConnection } from '@dunx/infra/redis';
4
4
  import { RELAY_CHANNEL } from '../config.js';
5
+ import { connect, type Client } from './ws-client.js';
5
6
  import { ChatGateway } from './chat.gateway.js';
6
7
  import { Lobby } from './lobby.service.js';
7
8
 
8
- interface Client {
9
- next(): Promise<string>;
10
- send(event: string, data: unknown): void;
11
- close(): void;
12
- /** Every frame this socket ever received, so a *second* delivery is visible. */
13
- readonly received: readonly string[];
14
- }
15
-
16
- /** A real `new WebSocket()`, with a deadline so a stall fails instead of hanging. */
17
- const connect = async (base: string): Promise<Client> => {
18
- const socket = new WebSocket(
19
- new URL('chat', base).href.replace('http', 'ws'),
20
- );
21
- const frames: string[] = [];
22
- const received: string[] = [];
23
- const waiting: ((frame: string) => void)[] = [];
24
-
25
- socket.addEventListener('message', (event: MessageEvent) => {
26
- const frame = String(event.data);
27
- received.push(frame);
28
- const waiter = waiting.shift();
29
- if (waiter) waiter(frame);
30
- else frames.push(frame);
31
- });
32
- await new Promise<void>((resolve, reject) => {
33
- socket.addEventListener('open', () => resolve(), { once: true });
34
- setTimeout(() => reject(new Error('the socket never opened')), 2000);
35
- });
36
-
37
- return {
38
- next: () =>
39
- new Promise<string>((resolve, reject) => {
40
- const queued = frames.shift();
41
- if (queued !== undefined) {
42
- resolve(queued);
43
- return;
44
- }
45
- const timer = setTimeout(
46
- () => reject(new Error('no frame arrived')),
47
- 2000,
48
- );
49
- waiting.push((frame) => {
50
- clearTimeout(timer);
51
- resolve(frame);
52
- });
53
- }),
54
- send: (event, data) => socket.send(JSON.stringify({ event, data })),
55
- close: () => socket.close(),
56
- received,
57
- };
58
- };
59
-
60
9
  /**
61
10
  * A second node in-process: two `Bun.serve` instances, two containers, two
62
11
  * `PubSub` origin ids. It reuses the same `ChatGateway` and excludes `ChatDemo`,
@@ -1,6 +1,7 @@
1
1
  import { Module } from '@dunx/core';
2
2
  import { CacheModule } from '../cache/cache.module.js';
3
3
  import { ChatDemo } from './chat.demo.js';
4
+ import { PostgresRelayDemo } from './postgres-relay.demo.js';
4
5
  import { ChatGateway } from './chat.gateway.js';
5
6
  import { Lobby } from './lobby.service.js';
6
7
 
@@ -9,7 +10,7 @@ import { Lobby } from './lobby.service.js';
9
10
  @Module({
10
11
  // `RedisConnection`, for cross-process fan-out.
11
12
  imports: [CacheModule],
12
- providers: [ChatGateway, Lobby, ChatDemo],
13
- exports: [Lobby, ChatDemo],
13
+ providers: [ChatGateway, Lobby, ChatDemo, PostgresRelayDemo],
14
+ exports: [Lobby, ChatDemo, PostgresRelayDemo],
14
15
  })
15
16
  export class ChatModule {}
@@ -0,0 +1,171 @@
1
+ import { Logger, Module, provide } from '@dunx/core';
2
+ import {
3
+ HttpFactory,
4
+ HttpOptionsProvider,
5
+ PubSub,
6
+ WsRelay,
7
+ WsRelayModule,
8
+ type PubSubRelay,
9
+ } from '@dunx/http';
10
+ import { RELAY_CHANNEL } from '../config.js';
11
+ import { ChatGateway } from './chat.gateway.js';
12
+ import { Lobby } from './lobby.service.js';
13
+ import { connect } from './ws-client.js';
14
+
15
+ /**
16
+ * Read here rather than injected: the module is declared at file scope, so it
17
+ * cannot reach `ConfigService`. The default matches `compose.yml` and the schema's.
18
+ */
19
+ const POSTGRES_URL =
20
+ Bun.env['POSTGRES_URL'] ?? 'postgres://dunx:dunx@localhost:5432/dunx';
21
+
22
+ /** Host and database only. The url carries a password and a log outlives it. */
23
+ const where = (url: string): string => {
24
+ try {
25
+ const parsed = new URL(url);
26
+ return `${parsed.host}${parsed.pathname}`;
27
+ } catch {
28
+ return 'the configured Postgres';
29
+ }
30
+ };
31
+
32
+ /**
33
+ * The same fan-out as the Redis relay, over `Bun.SQL`'s `LISTEN`/`NOTIFY`, for an
34
+ * app that already has Postgres and would rather not run a broker.
35
+ *
36
+ * Two nodes, each with its own `WsRelayModule.forPostgres` and its own `PubSub`
37
+ * origin. Neither shares a container with the other, which is what makes the
38
+ * delivery real rather than a same-process shortcut.
39
+ */
40
+ /**
41
+ * Binding the relay is not the same as using it. `WsRelayModule` puts a `WsRelay`
42
+ * in the container; what attaches it to `PubSub` is an `HttpOptionsProvider`
43
+ * answering `relay`, exactly as `AppHttpOptions` does for the app itself. Without
44
+ * this the nodes come up, the sockets work, and every publish stays local.
45
+ */
46
+ class NodeHttpOptions extends HttpOptionsProvider {
47
+ constructor(private readonly bus: WsRelay) {
48
+ super();
49
+ }
50
+
51
+ override get relay(): PubSubRelay {
52
+ return this.bus;
53
+ }
54
+
55
+ override readonly relayChannel = RELAY_CHANNEL;
56
+ }
57
+
58
+ @Module({
59
+ imports: [WsRelayModule.forPostgres({ url: POSTGRES_URL })],
60
+ providers: [
61
+ ChatGateway,
62
+ Lobby,
63
+ // Bound to the token `HttpFactory` asks for, not registered as itself:
64
+ // the factory looks up `HttpOptionsProvider` and promotes a default when
65
+ // nothing answers it, so a bare subclass in `providers` is never consulted.
66
+ provide(HttpOptionsProvider, { useClass: NodeHttpOptions }),
67
+ ],
68
+ })
69
+ class PostgresNode {}
70
+
71
+ interface Node {
72
+ readonly app: Awaited<ReturnType<typeof HttpFactory.create>>;
73
+ readonly url: string;
74
+ readonly pubsub: PubSub;
75
+ }
76
+
77
+ /** Postgres caps a `NOTIFY` payload at 7999 bytes, envelope included. */
78
+ const OVER_THE_NOTIFY_CAP = 9000;
79
+
80
+ export class PostgresRelayDemo {
81
+ constructor(private readonly logger: Logger) {}
82
+
83
+ async demonstrate(): Promise<void> {
84
+ const { logger } = this;
85
+ if (!(await this.#postgresUp())) {
86
+ logger.warn(
87
+ `skipping the Postgres relay demo: nothing answering at ${where(POSTGRES_URL)}`,
88
+ );
89
+ logger.info('`bun run services:up` starts it, and CI runs the same file');
90
+ return;
91
+ }
92
+
93
+ // Started one at a time and collected as they come up. `Promise.all` rejects
94
+ // on the first failure and abandons a peer that had already bound a port,
95
+ // which keeps the process alive after the demo has given up.
96
+ const nodes: Node[] = [];
97
+ try {
98
+ nodes.push(await this.#node(), await this.#node());
99
+ const [a, b] = nodes as [Node, Node];
100
+ logger.info(
101
+ `two nodes on LISTEN/NOTIFY, origins …${a.pubsub.origin.slice(-6)} / …${b.pubsub.origin.slice(-6)}`,
102
+ );
103
+
104
+ const [onA, onB] = await Promise.all([connect(a.url), connect(b.url)]);
105
+ await Promise.all([onA.next(), onB.next()]);
106
+
107
+ const said = 'across nodes, over Postgres';
108
+ a.pubsub.publishEvent(Lobby.TOPIC, 'said', said);
109
+ logger.info(
110
+ `node B's client <- ${await onB.next()} (relayed via NOTIFY)`,
111
+ );
112
+
113
+ await Bun.sleep(250);
114
+ const seen = (frames: readonly string[]): number =>
115
+ frames.filter((frame) => frame.includes(said)).length;
116
+ logger.info(
117
+ `deliveries: A ${seen(onA.received)}, B ${seen(onB.received)} ` +
118
+ '(one each, so the publisher did not fan its own frame out twice)',
119
+ );
120
+
121
+ // Postgres refuses the NOTIFY rather than truncating it. The publish is
122
+ // reported and fan-out stays local, which is the documented degradation.
123
+ const huge = 'x'.repeat(OVER_THE_NOTIFY_CAP);
124
+ a.pubsub.publishEvent(Lobby.TOPIC, 'said', huge);
125
+ await Bun.sleep(400);
126
+ const huge_ = (frames: readonly string[]): number =>
127
+ frames.filter((frame) => frame.includes(huge)).length;
128
+ logger.info(
129
+ `a ${OVER_THE_NOTIFY_CAP}-byte frame: A ${huge_(onA.received)}, B ${huge_(onB.received)} ` +
130
+ '(over the 7999-byte NOTIFY cap, so the publishing node still delivers ' +
131
+ 'it and the relay reports one warn)',
132
+ );
133
+
134
+ onA.close();
135
+ onB.close();
136
+ await Bun.sleep(20);
137
+ } finally {
138
+ await Promise.all(nodes.map((node) => node.app.shutdown()));
139
+ }
140
+ }
141
+
142
+ async #node(): Promise<Node> {
143
+ const app = await HttpFactory.create(PostgresNode, {
144
+ requestLogging: false,
145
+ });
146
+ try {
147
+ const url = await app.listen(0);
148
+ return { app, url, pubsub: app.get(PubSub) };
149
+ } catch (error) {
150
+ // The container is up even when the port is not, so it is ours to close.
151
+ await app.shutdown();
152
+ throw error;
153
+ }
154
+ }
155
+
156
+ /** A relay demo needs its backend; an absent one is a skip, not a failure. */
157
+ async #postgresUp(): Promise<boolean> {
158
+ const sql = new Bun.SQL(POSTGRES_URL, {
159
+ max: 1,
160
+ connectionTimeout: 2,
161
+ });
162
+ try {
163
+ await sql`select 1`;
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ } finally {
168
+ await sql.close().catch(() => undefined);
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * A real `new WebSocket()` against the chat gateway, with a deadline so a stall
3
+ * fails instead of hanging.
4
+ *
5
+ * Shared rather than copied: both relay demos and the soak workload open sockets
6
+ * the same way, and a second copy of the frame-queue handling is where the two
7
+ * would drift.
8
+ */
9
+ export interface Client {
10
+ next(): Promise<string>;
11
+ send(event: string, data: unknown): void;
12
+ close(): void;
13
+ /** Every frame this socket ever received, so a *second* delivery is visible. */
14
+ readonly received: readonly string[];
15
+ }
16
+
17
+ /** A real `new WebSocket()`, with a deadline so a stall fails instead of hanging. */
18
+ export const connect = async (base: string): Promise<Client> => {
19
+ const socket = new WebSocket(
20
+ new URL('chat', base).href.replace('http', 'ws'),
21
+ );
22
+ const frames: string[] = [];
23
+ const received: string[] = [];
24
+ const waiting: ((frame: string) => void)[] = [];
25
+
26
+ socket.addEventListener('message', (event: MessageEvent) => {
27
+ const frame = String(event.data);
28
+ received.push(frame);
29
+ const waiter = waiting.shift();
30
+ if (waiter) waiter(frame);
31
+ else frames.push(frame);
32
+ });
33
+ await new Promise<void>((resolve, reject) => {
34
+ socket.addEventListener('open', () => resolve(), { once: true });
35
+ setTimeout(() => reject(new Error('the socket never opened')), 2000);
36
+ });
37
+
38
+ return {
39
+ next: () =>
40
+ new Promise<string>((resolve, reject) => {
41
+ const queued = frames.shift();
42
+ if (queued !== undefined) {
43
+ resolve(queued);
44
+ return;
45
+ }
46
+ const waiter = (frame: string): void => {
47
+ clearTimeout(timer);
48
+ resolve(frame);
49
+ };
50
+ // Dropped from the queue before rejecting, or the next frame is handed to
51
+ // this dead promise and discarded, and every later `next()` waits one
52
+ // frame behind. Only shows up after a timeout, which is when the test is
53
+ // already trying to explain itself.
54
+ const timer = setTimeout(() => {
55
+ const at = waiting.indexOf(waiter);
56
+ if (at !== -1) waiting.splice(at, 1);
57
+ reject(new Error('no frame arrived'));
58
+ }, 2000);
59
+ waiting.push(waiter);
60
+ }),
61
+ send: (event, data) => socket.send(JSON.stringify({ event, data })),
62
+ close: () => socket.close(),
63
+ received,
64
+ };
65
+ };
@@ -7,7 +7,14 @@ import {
7
7
  Post,
8
8
  type Input,
9
9
  } from '@dunx/http';
10
- import { PAGINATION, type Page } from '@dunx/infra/pagination';
10
+ import {
11
+ decodeCursor,
12
+ encodeCursor,
13
+ PAGINATION,
14
+ pageOf,
15
+ parsePageOptions,
16
+ type Page,
17
+ } from '@dunx/infra/pagination';
11
18
  import { z } from 'zod';
12
19
  import { Ledger } from './ledger.service.js';
13
20
  import type { Entry } from './schema.js';
@@ -69,6 +76,18 @@ const pageQuery = z
69
76
  });
70
77
 
71
78
  const pagedEntries = { query: pageQuery } as const;
79
+ const keyset = {
80
+ query: z.object({
81
+ take: z.coerce
82
+ .number()
83
+ .int()
84
+ .min(PAGINATION.MIN_TAKE)
85
+ .max(PAGINATION.MAX_TAKE)
86
+ .optional(),
87
+ cursor: z.string().max(PAGINATION.MAX_CURSOR).optional(),
88
+ }),
89
+ } as const;
90
+
72
91
  const oneEntry = { params: EntryIndex } as const;
73
92
  const createEntry = { body: CreateEntry } as const;
74
93
  const transfer = { body: Transfer } as const;
@@ -78,12 +97,12 @@ export class LedgerController {
78
97
  constructor(private readonly ledger: Ledger) {}
79
98
 
80
99
  @Get('/', listEntries)
81
- list(input: Input<typeof listEntries>): {
100
+ list({ query }: Input<typeof listEntries>): {
82
101
  entries: readonly Entry[];
83
102
  balance: number;
84
103
  } {
85
104
  return {
86
- entries: this.ledger.list(input.query.limit),
105
+ entries: this.ledger.list(query.limit),
87
106
  balance: this.ledger.balance(),
88
107
  };
89
108
  }
@@ -91,34 +110,34 @@ export class LedgerController {
91
110
  /** Walked by cursor. Declared before `/:id` for readability only: `Bun.serve`
92
111
  * matches a static segment ahead of a parameter. */
93
112
  @Get('/page', pagedEntries)
94
- page(input: Input<typeof pagedEntries>): Page<Entry> {
95
- return this.ledger.page(input.query);
113
+ page({ query }: Input<typeof pagedEntries>): Page<Entry> {
114
+ return this.ledger.page(query);
96
115
  }
97
116
 
98
117
  @Get('/:id', oneEntry)
99
- one(input: Input<typeof oneEntry>): Entry {
100
- const entry = this.ledger.find(input.params.id);
118
+ one({ params }: Input<typeof oneEntry>): Entry {
119
+ const entry = this.ledger.find(params.id);
101
120
  if (entry === undefined) {
102
121
  throw new HttpError(
103
122
  HttpStatusCode.NOT_FOUND,
104
- `No ledger entry ${input.params.id}`,
123
+ `No ledger entry ${params.id}`,
105
124
  );
106
125
  }
107
126
  return entry;
108
127
  }
109
128
 
110
129
  @Post('/', createEntry)
111
- create(input: Input<typeof createEntry>): Entry {
112
- return this.ledger.add(input.body.memo, input.body.amount);
130
+ create({ body }: Input<typeof createEntry>): Entry {
131
+ return this.ledger.add(body.memo, body.amount);
113
132
  }
114
133
 
115
134
  /** `"fail": true` throws between the two inserts; the 409's unchanged `rows`
116
135
  * is proof the first leg rolled back. */
117
136
  @Post('/transfer', transfer)
118
- async transfer(
119
- input: Input<typeof transfer>,
120
- ): Promise<{ balance: number; rows: number }> {
121
- const { from, to, amount, fail } = input.body;
137
+ async transfer({
138
+ body,
139
+ }: Input<typeof transfer>): Promise<{ balance: number; rows: number }> {
140
+ const { from, to, amount, fail } = body;
122
141
  try {
123
142
  const balance = await this.ledger.transfer(from, to, amount, fail);
124
143
  return { balance, rows: this.ledger.rows() };
@@ -136,11 +155,11 @@ export class LedgerController {
136
155
  * what allows it - `transactionSync` will not compile against the async handle.
137
156
  */
138
157
  @Post('/transfer-sync', transfer)
139
- transferSync(input: Input<typeof transfer>): {
158
+ transferSync({ body }: Input<typeof transfer>): {
140
159
  balance: number;
141
160
  rows: number;
142
161
  } {
143
- const { from, to, amount, fail } = input.body;
162
+ const { from, to, amount, fail } = body;
144
163
  try {
145
164
  const balance = this.ledger.transferSync(from, to, amount, fail);
146
165
  return { balance, rows: this.ledger.rows() };
@@ -152,13 +171,69 @@ export class LedgerController {
152
171
  }
153
172
  }
154
173
 
174
+ /**
175
+ * Keyset pagination without the service: `parsePageOptions` reads the query the
176
+ * way the module's own docs suggest a schema does, `pageOf` shapes the envelope
177
+ * from rows the caller already has, and a cursor round-trips through
178
+ * `encodeCursor`/`decodeCursor`.
179
+ *
180
+ * The cursor is opaque on purpose and every malformed one collapses to the same
181
+ * `CursorError`, so `?cursor=not-a-cursor` answers 400 rather than telling the
182
+ * caller which layer rejected it.
183
+ */
184
+ @Get('/keyset', keyset)
185
+ keyset({ query }: Input<typeof keyset>): {
186
+ options: { take: number; direction: string; order: string };
187
+ page: Page<Entry>;
188
+ roundTrip: { encoded: string; decoded: { s: string; i: string } } | null;
189
+ } {
190
+ // Throws PageOptionsError on a take outside the range, which the framework
191
+ // renders as a 400 because the class carries the status.
192
+ const options = parsePageOptions(query as Record<string, unknown>);
193
+ // A cursor handed back in is decoded first, which is where a hand-written one
194
+ // fails, and it is what the next page is keyed from.
195
+ const roundTrip =
196
+ query.cursor === undefined
197
+ ? null
198
+ : { encoded: query.cursor, decoded: decodeCursor(query.cursor) };
199
+
200
+ const cursorOf = (row: Entry): string =>
201
+ // No timestamp on this table, so the id is both sort value and tiebreak.
202
+ encodeCursor(row.id, String(row.id));
203
+
204
+ // Descending ids, so the page after a cursor is everything below it. One row
205
+ // more than asked for is what answers `hasNextPage` without a second count.
206
+ const after = roundTrip === null ? undefined : Number(roundTrip.decoded.i);
207
+ const scanned = this.ledger
208
+ .list(PAGINATION.MAX_TAKE)
209
+ .filter((row) => after === undefined || row.id < after);
210
+ const rows = scanned.slice(0, options.take);
211
+
212
+ const page = pageOf(rows, {
213
+ take: options.take,
214
+ hasNextPage: scanned.length > options.take,
215
+ hasPreviousPage: after !== undefined,
216
+ cursorOf,
217
+ });
218
+
219
+ return {
220
+ options: {
221
+ take: options.take,
222
+ direction: options.direction,
223
+ order: options.order,
224
+ },
225
+ page,
226
+ roundTrip,
227
+ };
228
+ }
229
+
155
230
  @Delete('/:id', oneEntry)
156
- remove(input: Input<typeof oneEntry>): { deleted: boolean } {
157
- const deleted = this.ledger.remove(input.params.id);
231
+ remove({ params }: Input<typeof oneEntry>): { deleted: boolean } {
232
+ const deleted = this.ledger.remove(params.id);
158
233
  if (!deleted) {
159
234
  throw new HttpError(
160
235
  HttpStatusCode.NOT_FOUND,
161
- `No ledger entry ${input.params.id}`,
236
+ `No ledger entry ${params.id}`,
162
237
  );
163
238
  }
164
239
  return { deleted };
@@ -55,15 +55,15 @@ export class ReportsController {
55
55
  // Method-scoped guard, reading the class-level @Roles('admin').
56
56
  @UseGuards(RolesGuard)
57
57
  @Post('/', createReport)
58
- create(input: Input<typeof createReport>): readonly string[] {
59
- return this.reports.add(input.body.title);
58
+ create({ body }: Input<typeof createReport>): readonly string[] {
59
+ return this.reports.add(body.title);
60
60
  }
61
61
 
62
62
  // A method-level @Roles wins over the class-level one.
63
63
  @Roles('editor')
64
64
  @UseGuards(RolesGuard)
65
65
  @Patch('/:id', renameReport)
66
- rename(input: Input<typeof renameReport>): readonly string[] {
67
- return this.reports.rename(input.params.id, input.body.title);
66
+ rename({ body, params }: Input<typeof renameReport>): readonly string[] {
67
+ return this.reports.rename(params.id, body.title);
68
68
  }
69
69
  }
@@ -1,9 +1,26 @@
1
1
  import type { BunRequest } from 'bun';
2
2
  import type { Middleware, Next, RouteContext } from '@dunx/http';
3
3
 
4
- /** The observable side effect: whatever the middleware saw is readable after. */
4
+ /**
5
+ * The observable side effect: whatever the middleware saw is readable after.
6
+ *
7
+ * Capped, because this grows by one entry on **every request** and this folder is
8
+ * vendored into `@dunx/create-app`'s `http` feature. Unbounded it put 46 MiB on
9
+ * the heap across 3.1 million requests in the soak run and read as a framework
10
+ * leak until a heap census named the strings. A demo that keeps the last few
11
+ * hundred shows the same thing and survives production traffic.
12
+ */
13
+ const KEEP = 500;
14
+
5
15
  export class RequestTrail {
6
16
  readonly entries: string[] = [];
17
+
18
+ record(entry: string): void {
19
+ this.entries.push(entry);
20
+ if (this.entries.length > KEEP) {
21
+ this.entries.splice(0, this.entries.length - KEEP);
22
+ }
23
+ }
7
24
  }
8
25
 
9
26
  /**
@@ -23,7 +40,7 @@ export class RequestTrailMiddleware implements Middleware {
23
40
  next: Next,
24
41
  ): Promise<Response> {
25
42
  const response = await next();
26
- this.trail.entries.push(
43
+ this.trail.record(
27
44
  `${req.method} ${new URL(req.url).pathname} -> ${response.status} ` +
28
45
  `(${ctx.controller}.${ctx.handler})`,
29
46
  );
@@ -14,7 +14,7 @@ export class TraceController {
14
14
  constructor(private readonly context: RequestContext) {}
15
15
 
16
16
  @Get('/')
17
- current(input: Input<RouteSchemas>): {
17
+ current({ req }: Input<RouteSchemas>): {
18
18
  traceId: string | undefined;
19
19
  spanId: string | undefined;
20
20
  parentSpanId: string | undefined;
@@ -28,7 +28,7 @@ export class TraceController {
28
28
  spanId: spanId as string | undefined,
29
29
  parentSpanId: parentSpanId as string | undefined,
30
30
  traceFlags: traceFlags as string | undefined,
31
- inbound: input.req.headers.get('traceparent'),
31
+ inbound: req.headers.get('traceparent'),
32
32
  };
33
33
  }
34
34
  }
@@ -33,11 +33,13 @@ export class JobsController {
33
33
  constructor(private readonly publisher: JobPublisher) {}
34
34
 
35
35
  @Post('/thumbnails', enqueue)
36
- async enqueue(
37
- input: Input<typeof enqueue>,
38
- ): Promise<{ id: string; queue: string; state: string }> {
36
+ async enqueue({ body }: Input<typeof enqueue>): Promise<{
37
+ id: string;
38
+ queue: string;
39
+ state: string;
40
+ }> {
39
41
  const job = await this.degrades(() =>
40
- this.publisher.publish(THUMBNAIL_QUEUE, 'render', input.body),
42
+ this.publisher.publish(THUMBNAIL_QUEUE, 'render', body),
41
43
  );
42
44
 
43
45
  return {
@@ -50,24 +52,24 @@ export class JobsController {
50
52
  /** `returnvalue` is whatever the handler returned, so this is how the web
51
53
  * process reads a result computed elsewhere. */
52
54
  @Get('/thumbnails/:id', oneJob)
53
- async status(input: Input<typeof oneJob>): Promise<{
55
+ async status({ params }: Input<typeof oneJob>): Promise<{
54
56
  id: string;
55
57
  state: string;
56
58
  result: RenderResult | null;
57
59
  failedReason: string | null;
58
60
  }> {
59
61
  const job = await this.degrades(() =>
60
- this.publisher.queue(THUMBNAIL_QUEUE).getJob(input.params.id),
62
+ this.publisher.queue(THUMBNAIL_QUEUE).getJob(params.id),
61
63
  );
62
64
  if (job === undefined) {
63
65
  throw new HttpError(
64
66
  HttpStatusCode.NOT_FOUND,
65
- `No job ${input.params.id} on "${THUMBNAIL_QUEUE}"`,
67
+ `No job ${params.id} on "${THUMBNAIL_QUEUE}"`,
66
68
  );
67
69
  }
68
70
 
69
71
  return {
70
- id: job.id ?? input.params.id,
72
+ id: job.id ?? params.id,
71
73
  state: await job.getState(),
72
74
  result: (job.returnvalue as RenderResult | null) ?? null,
73
75
  failedReason: job.failedReason ?? null,
@@ -46,7 +46,10 @@ export class NotesController {
46
46
  return this.notes.rows();
47
47
  }
48
48
 
49
- // No schemas declared, so the request is all `input` carries.
49
+ // Destructuring at the parameter is the usual shape, and it is what every other
50
+ // handler here does. The whole object has a name when a handler wants to pass it
51
+ // on: `whoami(input: Input<RouteSchemas>)` types the same. No schemas are
52
+ // declared on this route, so `req` is all it carries.
50
53
  @ApiDoc({
51
54
  summary: 'Echo the caller’s address',
52
55
  description:
@@ -54,12 +57,12 @@ export class NotesController {
54
57
  deprecated: true,
55
58
  })
56
59
  @Get('/whoami')
57
- whoami(input: Input<RouteSchemas>): { ip: string | undefined } {
58
- return { ip: this.address.of(input.req) };
60
+ whoami({ req }: Input<RouteSchemas>): { ip: string | undefined } {
61
+ return { ip: this.address.of(req) };
59
62
  }
60
63
 
61
64
  @Post('/', createNote)
62
- create(input: Input<typeof createNote>): readonly string[] {
63
- return this.notes.add(input.body.text);
65
+ create({ body }: Input<typeof createNote>): readonly string[] {
66
+ return this.notes.add(body.text);
64
67
  }
65
68
  }
@@ -1,6 +1,17 @@
1
1
  import { Logger } from '@dunx/core';
2
2
  import type { OnInit } from '@dunx/core';
3
3
 
4
+ /**
5
+ * The list is capped, and the cap is the point.
6
+ *
7
+ * An in-memory demo store that only ever grows is a leak the moment anyone runs
8
+ * real traffic through it, and this folder is vendored into `@dunx/create-app`'s
9
+ * `notes` feature, so an unbounded array would ship into every scaffold. The soak
10
+ * run found it: 42,000 posts a minute put 250,000 retained strings on the heap and
11
+ * read as a framework leak until the census named them.
12
+ */
13
+ const KEEP = 200;
14
+
4
15
  export class NotesService implements OnInit {
5
16
  readonly #rows = ['read the architecture doc', 'measure before deciding'];
6
17
 
@@ -16,6 +27,9 @@ export class NotesService implements OnInit {
16
27
 
17
28
  add(text: string): readonly string[] {
18
29
  this.#rows.push(text);
30
+ if (this.#rows.length > KEEP) {
31
+ this.#rows.splice(0, this.#rows.length - KEEP);
32
+ }
19
33
  return this.#rows;
20
34
  }
21
35
  }
@@ -33,8 +33,8 @@ export class ImagesController {
33
33
 
34
34
  /** Returns the encoded image itself, so a browser renders it inline. */
35
35
  @Get('/render', render)
36
- async render(input: Input<typeof render>): Promise<Response> {
37
- const encoded = await this.thumbnails.render(input.query);
36
+ async render({ query }: Input<typeof render>): Promise<Response> {
37
+ const encoded = await this.thumbnails.render(query);
38
38
  return new Response(encoded.bytes, {
39
39
  headers: {
40
40
  'content-type': encoded.mimeType,
@@ -45,14 +45,14 @@ export class ImagesController {
45
45
 
46
46
  /** The same render, described rather than returned - easier to read in swagger. */
47
47
  @Get('/metadata', render)
48
- async metadata(input: Input<typeof render>): Promise<{
48
+ async metadata({ query }: Input<typeof render>): Promise<{
49
49
  width: number;
50
50
  height: number;
51
51
  format: string;
52
52
  mimeType: string;
53
53
  bytes: number;
54
54
  }> {
55
- const encoded = await this.thumbnails.render(input.query);
55
+ const encoded = await this.thumbnails.render(query);
56
56
  return {
57
57
  width: encoded.width,
58
58
  height: encoded.height,
@@ -67,11 +67,11 @@ export class ImagesController {
67
67
  * is a header read rather than a decode, so a truncated file still answers.
68
68
  */
69
69
  @Post('/describe', describe)
70
- describe(input: Input<typeof describe>): Promise<{
70
+ describe({ body }: Input<typeof describe>): Promise<{
71
71
  width: number;
72
72
  height: number;
73
73
  format: string;
74
74
  }> {
75
- return this.thumbnails.describe(input.body.base64);
75
+ return this.thumbnails.describe(body.base64);
76
76
  }
77
77
  }
@@ -1,5 +1,5 @@
1
- import { Counter, Logger } from '@dunx/core';
2
- import { Cron, Interval, OnceOnBoot } from '@dunx/infra/schedule';
1
+ import { Counter, Gauge, Logger } from '@dunx/core';
2
+ import { Cron, Interval, OnceOnBoot, Overlap } from '@dunx/infra/schedule';
3
3
 
4
4
  /**
5
5
  * The three schedule decorators on one class, discovered off the prototype chain
@@ -43,6 +43,44 @@ export class Maintenance {
43
43
  return this.#compactions.value;
44
44
  }
45
45
 
46
+ readonly #slow = new Counter();
47
+ /**
48
+ * A `Gauge`, not `#inFlight += 1`. A compound assignment to a private field in
49
+ * a class that also has a decorated member is a `SyntaxError` in Bun's parser,
50
+ * still on 1.4.2 - which is what the note at the top of this class is about,
51
+ * and which this method walked straight into before it was written this way.
52
+ */
53
+ readonly #inFlight = new Gauge();
54
+ readonly #peak = new Gauge();
55
+
56
+ /**
57
+ * `overlap: Overlap.CONCURRENT`, which is the half `skip` hides.
58
+ *
59
+ * The default refuses to start a run while the last one is still going, so a
60
+ * handler that outlives its own cadence quietly runs at the rate it can finish.
61
+ * `concurrent` starts anyway, and `maxInFlight` is how you can tell: triggered
62
+ * twice inside its own sleep it reaches 2, where the sweep above stays at 1.
63
+ */
64
+ @Interval(600_000, {
65
+ name: 'maintenance.overlapping',
66
+ overlap: Overlap.CONCURRENT,
67
+ })
68
+ async overlappingWork(): Promise<number> {
69
+ this.#inFlight.inc();
70
+ this.#peak.set(Math.max(this.#peak.value, this.#inFlight.value));
71
+ try {
72
+ await Bun.sleep(40);
73
+ this.#slow.inc();
74
+ return this.#slow.value;
75
+ } finally {
76
+ this.#inFlight.dec();
77
+ }
78
+ }
79
+
80
+ get overlapping(): { runs: number; maxInFlight: number } {
81
+ return { runs: this.#slow.value, maxInFlight: this.#peak.value };
82
+ }
83
+
46
84
  get counts(): { sweeps: number; compactions: number; warmed: boolean } {
47
85
  return {
48
86
  sweeps: this.#sweeps.value,
@@ -41,6 +41,18 @@ export class ScheduleDemo {
41
41
  'neither waited for a clock',
42
42
  );
43
43
 
44
+ // Two triggers inside one run's own sleep. `concurrent` lets the second start,
45
+ // so both are in flight at once; the default would have skipped it.
46
+ await Promise.all([
47
+ this.registry.trigger('maintenance.overlapping'),
48
+ this.registry.trigger('maintenance.overlapping'),
49
+ ]);
50
+ const overlapping = this.maintenance.overlapping;
51
+ this.logger.info(
52
+ `overlap: concurrent -> ${overlapping.runs} runs, ` +
53
+ `${overlapping.maxInFlight} in flight at once (skip would hold it at 1)`,
54
+ );
55
+
44
56
  const entry = this.registry.get('maintenance.compact');
45
57
  this.logger.info(
46
58
  `runs recorded on the entry -> ${entry?.runs ?? 0}, lastError ` +
@@ -40,13 +40,14 @@ export class FilesController {
40
40
  constructor(private readonly storage: Storage) {}
41
41
 
42
42
  @Get('/', listFiles)
43
- async list(
44
- input: Input<typeof listFiles>,
45
- ): Promise<{ root: string; keys: readonly string[] }> {
43
+ async list({ query }: Input<typeof listFiles>): Promise<{
44
+ root: string;
45
+ keys: readonly string[];
46
+ }> {
46
47
  const keys: string[] = [];
47
48
  for await (const entry of this.storage.list({
48
- prefix: input.query.prefix,
49
- glob: input.query.glob,
49
+ prefix: query.prefix,
50
+ glob: query.glob,
50
51
  })) {
51
52
  keys.push(entry.key);
52
53
  }
@@ -57,10 +58,13 @@ export class FilesController {
57
58
  }
58
59
 
59
60
  @Get('/object', objectKey)
60
- async read(
61
- input: Input<typeof objectKey>,
62
- ): Promise<{ key: string; size: number; type: string; content: string }> {
63
- const { key } = input.query;
61
+ async read({ query }: Input<typeof objectKey>): Promise<{
62
+ key: string;
63
+ size: number;
64
+ type: string;
65
+ content: string;
66
+ }> {
67
+ const { key } = query;
64
68
  await this.present(key);
65
69
  const stat = await this.storage.stat(key);
66
70
  return {
@@ -72,12 +76,13 @@ export class FilesController {
72
76
  }
73
77
 
74
78
  @Put('/object', writeFile)
75
- async write(
76
- input: Input<typeof writeFile>,
77
- ): Promise<{ key: string; bytes: number }> {
78
- const { key } = input.query;
79
+ async write({
80
+ body,
81
+ query,
82
+ }: Input<typeof writeFile>): Promise<{ key: string; bytes: number }> {
83
+ const { key } = query;
79
84
  try {
80
- return { key, bytes: await this.storage.write(key, input.body.content) };
85
+ return { key, bytes: await this.storage.write(key, body.content) };
81
86
  } catch (error) {
82
87
  if (!(error instanceof PathTraversalError)) throw error;
83
88
  throw new HttpError(HttpStatusCode.BAD_REQUEST, error.message);
@@ -85,8 +90,10 @@ export class FilesController {
85
90
  }
86
91
 
87
92
  @Delete('/object', objectKey)
88
- async remove(input: Input<typeof objectKey>): Promise<{ deleted: boolean }> {
89
- const { key } = input.query;
93
+ async remove({
94
+ query,
95
+ }: Input<typeof objectKey>): Promise<{ deleted: boolean }> {
96
+ const { key } = query;
90
97
  await this.present(key);
91
98
  await this.storage.delete(key);
92
99
  return { deleted: true };
@@ -95,8 +102,8 @@ export class FilesController {
95
102
  /** Nothing signs bytes on a local disk, so this refuses rather than hand back
96
103
  * a URL that cannot work. */
97
104
  @Get('/presign', objectKey)
98
- async presign(input: Input<typeof objectKey>): Promise<{ url: string }> {
99
- const { key } = input.query;
105
+ async presign({ query }: Input<typeof objectKey>): Promise<{ url: string }> {
106
+ const { key } = query;
100
107
  await this.present(key);
101
108
  try {
102
109
  return { url: this.storage.presign(key) };
@@ -24,21 +24,18 @@ export class UsersController {
24
24
  // accepted against a schema inferring `User[]` - mutability does not survive
25
25
  // serialisation.
26
26
  @Get('/', listUsers)
27
- list(input: Input<typeof listUsers>): Promise<readonly User[]> {
28
- return this.users.findAll(input.query.limit, input.query.q);
27
+ list({ query }: Input<typeof listUsers>): Promise<readonly User[]> {
28
+ return this.users.findAll(query.limit, query.q);
29
29
  }
30
30
 
31
31
  // Only the success status is checked - the 404 in `oneUser.response` leaves via
32
32
  // a thrown HttpError, which no return type can describe.
33
33
  @Get('/:id', oneUser)
34
- async one(input: Input<typeof oneUser>): Promise<User> {
34
+ async one({ params }: Input<typeof oneUser>): Promise<User> {
35
35
  // Already a number: the params schema coerced it before this ran.
36
- const user = await this.users.find(input.params.id);
36
+ const user = await this.users.find(params.id);
37
37
  if (user === null) {
38
- throw new HttpError(
39
- HttpStatusCode.NOT_FOUND,
40
- `No user ${input.params.id}`,
41
- );
38
+ throw new HttpError(HttpStatusCode.NOT_FOUND, `No user ${params.id}`);
42
39
  }
43
40
  return user;
44
41
  }
@@ -46,10 +43,10 @@ export class UsersController {
46
43
  // No req.json(), no Response.json(), no status - the body arrives validated and
47
44
  // typed, and 201 is the POST default.
48
45
  @Post('/', createUser)
49
- create(input: Input<typeof createUser>): Promise<User> {
46
+ create({ body }: Input<typeof createUser>): Promise<User> {
50
47
  return this.users.create(
51
- input.body.name,
52
- input.body.tags.map((tag) => tag.label),
48
+ body.name,
49
+ body.tags.map((tag) => tag.label),
53
50
  );
54
51
  }
55
52
  }