@fabrika/app 0.9.0-dev.3 → 0.9.0-dev.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/app",
3
- "version": "0.9.0-dev.3",
3
+ "version": "0.9.0-dev.4",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -93,6 +93,11 @@
93
93
  "types": "./types/src/dev/wire.d.ts",
94
94
  "default": "./src/dev/wire.ts"
95
95
  },
96
+ "./dev/changes": {
97
+ "bun": "./src/dev/changes.ts",
98
+ "types": "./types/src/dev/changes.d.ts",
99
+ "default": "./src/dev/changes.ts"
100
+ },
96
101
  "./agent-skill": {
97
102
  "bun": "./src/agent-skill.ts",
98
103
  "types": "./types/src/agent-skill.d.ts",
@@ -104,14 +109,14 @@
104
109
  "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
105
110
  },
106
111
  "dependencies": {
107
- "@fabrika/auth": "0.9.0-dev.3",
108
- "@fabrika/auth-core": "0.9.0-dev.3",
109
- "@fabrika/db": "0.9.0-dev.3",
110
- "@fabrika/email": "0.9.0-dev.3",
111
- "@fabrika/jwks": "0.9.0-dev.3",
112
- "@fabrika/platform": "0.9.0-dev.3",
113
- "@fabrika/platform-node": "0.9.0-dev.3",
114
- "@fabrika/proxy-core": "0.9.0-dev.3",
112
+ "@fabrika/auth": "0.9.0-dev.4",
113
+ "@fabrika/auth-core": "0.9.0-dev.4",
114
+ "@fabrika/db": "0.9.0-dev.4",
115
+ "@fabrika/email": "0.9.0-dev.4",
116
+ "@fabrika/jwks": "0.9.0-dev.4",
117
+ "@fabrika/platform": "0.9.0-dev.4",
118
+ "@fabrika/platform-node": "0.9.0-dev.4",
119
+ "@fabrika/proxy-core": "0.9.0-dev.4",
115
120
  "altcha-lib": "2.6.0",
116
121
  "jose": "^5.9.0"
117
122
  },
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fabrika-app
3
- version: 0.9.0-dev.3
3
+ version: 0.9.0-dev.4
4
4
  description: >
5
5
  How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
6
6
  slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
@@ -19,20 +19,20 @@ the SPA is added with it.
19
19
  "test": "bun test --isolate"
20
20
  },
21
21
  "dependencies": {
22
- "@fabrika/app": "0.9.0-dev.3",
23
- "@fabrika/db": "0.9.0-dev.3",
24
- "@fabrika/provider-zerops": "0.9.0-dev.3",
25
- "@fabrika/proxy-core": "0.9.0-dev.3",
26
- "@fabrika/ui": "0.9.0-dev.3",
22
+ "@fabrika/app": "0.9.0-dev.4",
23
+ "@fabrika/db": "0.9.0-dev.4",
24
+ "@fabrika/provider-zerops": "0.9.0-dev.4",
25
+ "@fabrika/proxy-core": "0.9.0-dev.4",
26
+ "@fabrika/ui": "0.9.0-dev.4",
27
27
  "kysely": "0.29.6",
28
28
  "react": "^19.0.0",
29
29
  "react-dom": "^19.0.0",
30
30
  "zod": "^3.25.76"
31
31
  },
32
32
  "devDependencies": {
33
- "@fabrika/check": "0.9.0-dev.3",
34
- "@fabrika/cli": "0.9.0-dev.3",
35
- "@fabrika/platform": "0.9.0-dev.3",
33
+ "@fabrika/check": "0.9.0-dev.4",
34
+ "@fabrika/cli": "0.9.0-dev.4",
35
+ "@fabrika/platform": "0.9.0-dev.4",
36
36
  "@happy-dom/global-registrator": "^20.11.2",
37
37
  "@types/bun": "latest",
38
38
  "@types/react": "^19.0.0",
@@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url'
18
18
  import { z } from 'zod/v4'
19
19
  import { makeProcessUndumpable } from '../undumpable.js'
20
20
  import { bootstrapReason, migrationsChanged, webSourceChanged } from './changes.js'
21
- import { OutputRing, type OutputSink, processOutput, readLines } from './output.js'
21
+ import { OutputRing, type OutputSink, type OutputStream, processOutput, readLines } from './output.js'
22
22
  import {
23
23
  DEV_SOURCE_REPORT_PATH,
24
24
  DEV_SOURCE_WAIT_MS,
@@ -123,9 +123,14 @@ interface RunningApplication {
123
123
 
124
124
  interface CommandResult {
125
125
  readonly exitCode: number | null
126
+ /** The signal that ended the command, when one did: it never exited on its own. */
127
+ readonly signal: string | null
126
128
  readonly lines: readonly string[]
127
129
  }
128
130
 
131
+ /** Bun echoes the script it runs (`$ bun build-web.ts`) on stderr; it names the command, not a failure. */
132
+ const BUN_SCRIPT_ECHO = /^\$ \S/
133
+
129
134
  type SyncOutcome =
130
135
  | { readonly kind: 'applied'; readonly migrations: boolean; readonly spaFailure: string | null }
131
136
  | { readonly kind: 'refused'; readonly reason: string }
@@ -329,6 +334,8 @@ class Launcher {
329
334
  let changed = boot
330
335
  let migrate = boot
331
336
  let spaFailure: string | null = null
337
+ const reset = answer.reset > (this.state.reset ?? answer.reset)
338
+ if (answer.sync !== null || reset) this.ring.mark()
332
339
  if (answer.sync !== null) {
333
340
  const outcome = await this.writeSync(answer.sync)
334
341
  if (outcome.kind === 'refused') {
@@ -343,7 +350,6 @@ class Launcher {
343
350
  spaFailure = outcome.spaFailure
344
351
  }
345
352
  }
346
- const reset = answer.reset > (this.state.reset ?? answer.reset)
347
353
  if (changed || reset) await this.converge({ migrate: migrate || reset, reset, spaFailure, resetTo: answer.reset })
348
354
  if (answer.checks > (this.state.checks ?? answer.checks)) await this.check(answer.checks)
349
355
  }
@@ -483,7 +489,12 @@ class Launcher {
483
489
 
484
490
  private async buildSpa(): Promise<string | null> {
485
491
  for (const command of this.config.spaBuild) {
486
- const result = await this.run(['sh', '-c', command], this.commandEnvironment)
492
+ const result = await this.run(
493
+ ['sh', '-c', command],
494
+ this.commandEnvironment,
495
+ CHECK_TIMEOUT_MS,
496
+ (stream, line) => stream === 'stderr' && BUN_SCRIPT_ECHO.test(line) ? 'stdout' : stream,
497
+ )
487
498
  if (result.exitCode !== 0) return `the SPA command \`${command}\` failed: ${lastLine(result)}`
488
499
  }
489
500
  return null
@@ -498,12 +509,15 @@ class Launcher {
498
509
  this.say(`check ${check.name}: ${check.command}`)
499
510
  const started = performance.now()
500
511
  const result = await this.run(['sh', '-c', check.command], this.commandEnvironment, CHECK_TIMEOUT_MS)
512
+ const durationMs = Math.round(performance.now() - started)
513
+ const killed = result.signal === null ? [] : [killedLine(check.name, result.signal, durationMs >= CHECK_TIMEOUT_MS)]
514
+ for (const line of killed) this.say(line, 'stderr')
501
515
  results.push({
502
516
  name: check.name,
503
517
  ok: result.exitCode === 0,
504
518
  exitCode: result.exitCode,
505
- durationMs: Math.round(performance.now() - started),
506
- output: result.lines.slice(-CHECK_OUTPUT_LINES).map((line) => line.slice(0, 2_000)),
519
+ durationMs,
520
+ output: [...result.lines.slice(-(CHECK_OUTPUT_LINES - killed.length)), ...killed].map((line) => line.slice(0, 2_000)),
507
521
  })
508
522
  }
509
523
  this.checkResults = { request, head: this.state.head, results }
@@ -515,17 +529,22 @@ class Launcher {
515
529
  }
516
530
 
517
531
  /** Run a command in the root; its output goes to the log, and its lines are returned. */
518
- private async run(argv: readonly string[], env: Record<string, string>, timeoutMs = CHECK_TIMEOUT_MS): Promise<CommandResult> {
532
+ private async run(
533
+ argv: readonly string[],
534
+ env: Record<string, string>,
535
+ timeoutMs = CHECK_TIMEOUT_MS,
536
+ classify: (stream: OutputStream, line: string) => OutputStream = (stream) => stream,
537
+ ): Promise<CommandResult> {
519
538
  const child = Bun.spawn([...argv], { cwd: this.root, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
520
539
  const lines: string[] = []
521
- const collect = (stream: 'stdout' | 'stderr') => (line: string) => {
540
+ const collect = (stream: OutputStream) => (line: string) => {
522
541
  lines.push(line)
523
- this.ring.push(stream, line)
542
+ this.ring.push(classify(stream, line), line)
524
543
  }
525
544
  const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
526
545
  await Promise.all([readLines(child.stdout, collect('stdout')), readLines(child.stderr, collect('stderr')), child.exited])
527
546
  clearTimeout(timer)
528
- return { exitCode: child.exitCode, lines }
547
+ return { exitCode: child.exitCode, signal: child.signalCode === null ? null : String(child.signalCode), lines }
529
548
  }
530
549
 
531
550
  private async restartApplication(): Promise<string | null> {
@@ -609,6 +628,12 @@ class Launcher {
609
628
  }
610
629
  }
611
630
 
631
+ /** Why a check has no exit code: the launcher's timeout, or a signal from outside, most often the out-of-memory killer. */
632
+ const killedLine = (name: string, signal: string, timedOut: boolean): string =>
633
+ timedOut
634
+ ? `check ${name} was killed (${signal}) after the ${CHECK_TIMEOUT_MS / 60_000}-minute timeout; it failed`
635
+ : `check ${name} was killed (${signal}) before it finished, most likely because the container ran out of memory; it failed`
636
+
612
637
  const lastLine = (result: CommandResult): string => [...result.lines].reverse().find((line) => line.trim() !== '')?.trim() ?? 'no output'
613
638
 
614
639
  /** The process environment with its string values only. */
package/src/dev/output.ts CHANGED
@@ -16,10 +16,14 @@ export const processOutput: OutputSink = {
16
16
  },
17
17
  }
18
18
 
19
- /** The newest `capacity` lines, numbered from 0 since the launcher started, with every known secret value replaced. */
19
+ /**
20
+ * The newest `capacity` lines, numbered from 0 since the launcher started, with every known secret value replaced. A
21
+ * mark starts the lines a report carries: those of the newest sync or reset, and the output after it.
22
+ */
20
23
  export class OutputRing {
21
24
  private readonly lines: DevLogLine[] = []
22
25
  private next = 0
26
+ private marked = 0
23
27
 
24
28
  constructor(private readonly capacity: number, private readonly secrets: readonly string[], private readonly sink: OutputSink) {}
25
29
 
@@ -31,9 +35,15 @@ export class OutputRing {
31
35
  if (this.lines.length > this.capacity) this.lines.shift()
32
36
  }
33
37
 
34
- /** The newest `count` lines, oldest first. */
38
+ /** Report from the next line on: the lines before it belong to an older head. */
39
+ mark(): void {
40
+ this.marked = this.next
41
+ }
42
+
43
+ /** The newest `count` lines since the mark, oldest first. */
35
44
  tail(count: number): DevLogLine[] {
36
- return this.lines.slice(Math.max(0, this.lines.length - count))
45
+ const since = this.lines.filter((line) => line.n >= this.marked)
46
+ return since.slice(Math.max(0, since.length - count))
37
47
  }
38
48
 
39
49
  private redact(text: string): string {
@@ -5,16 +5,22 @@ export interface OutputSink {
5
5
  }
6
6
  /** The launcher process's own stdout and stderr. */
7
7
  export declare const processOutput: OutputSink;
8
- /** The newest `capacity` lines, numbered from 0 since the launcher started, with every known secret value replaced. */
8
+ /**
9
+ * The newest `capacity` lines, numbered from 0 since the launcher started, with every known secret value replaced. A
10
+ * mark starts the lines a report carries: those of the newest sync or reset, and the output after it.
11
+ */
9
12
  export declare class OutputRing {
10
13
  private readonly capacity;
11
14
  private readonly secrets;
12
15
  private readonly sink;
13
16
  private readonly lines;
14
17
  private next;
18
+ private marked;
15
19
  constructor(capacity: number, secrets: readonly string[], sink: OutputSink);
16
20
  push(stream: OutputStream, text: string): void;
17
- /** The newest `count` lines, oldest first. */
21
+ /** Report from the next line on: the lines before it belong to an older head. */
22
+ mark(): void;
23
+ /** The newest `count` lines since the mark, oldest first. */
18
24
  tail(count: number): DevLogLine[];
19
25
  private redact;
20
26
  }