@fabrika/app 0.9.0-dev.2 → 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.2",
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.2",
108
- "@fabrika/auth-core": "0.9.0-dev.2",
109
- "@fabrika/db": "0.9.0-dev.2",
110
- "@fabrika/email": "0.9.0-dev.2",
111
- "@fabrika/jwks": "0.9.0-dev.2",
112
- "@fabrika/platform": "0.9.0-dev.2",
113
- "@fabrika/platform-node": "0.9.0-dev.2",
114
- "@fabrika/proxy-core": "0.9.0-dev.2",
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.2
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.2",
23
- "@fabrika/db": "0.9.0-dev.2",
24
- "@fabrika/provider-zerops": "0.9.0-dev.2",
25
- "@fabrika/proxy-core": "0.9.0-dev.2",
26
- "@fabrika/ui": "0.9.0-dev.2",
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.2",
34
- "@fabrika/cli": "0.9.0-dev.2",
35
- "@fabrika/platform": "0.9.0-dev.2",
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,13 +123,23 @@ 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
- | { readonly kind: 'applied'; readonly migrations: boolean; readonly web: boolean }
135
+ | { readonly kind: 'applied'; readonly migrations: boolean; readonly spaFailure: string | null }
131
136
  | { readonly kind: 'refused'; readonly reason: string }
132
137
 
138
+ /** Control answers 409 to the container of a bootstrap run that a newer one replaced. */
139
+ class SupersededError extends Error {}
140
+
141
+ const SUPERSEDED = 'a newer bootstrap run replaced this container; it stops following Control and serves until it is stopped'
142
+
133
143
  const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
134
144
 
135
145
  /** Every value of the environment a log line must never show: the database credentials and every key, secret or token. */
@@ -176,6 +186,8 @@ class Launcher {
176
186
  private crashes = 0
177
187
  private stopped = false
178
188
  private reportFailing = false
189
+ /** A newer bootstrap run replaced this container: it keeps serving until the platform stops it, and asks nothing. */
190
+ private superseded = false
179
191
 
180
192
  constructor(private readonly options: DevLauncherOptions) {
181
193
  this.root = options.root
@@ -225,7 +237,12 @@ class Launcher {
225
237
  while (!this.stopped) {
226
238
  try {
227
239
  return await this.wait(false)
228
- } catch {
240
+ } catch (error) {
241
+ if (error instanceof SupersededError) {
242
+ this.say(SUPERSEDED, 'stderr')
243
+ this.superseded = true
244
+ return null
245
+ }
229
246
  if (Date.now() >= deadline) {
230
247
  this.say('Control did not answer at boot; serving the files this container holds', 'stderr')
231
248
  return null
@@ -238,13 +255,18 @@ class Launcher {
238
255
 
239
256
  private async follow(): Promise<void> {
240
257
  let backoff = 1_000
241
- while (!this.stopped) {
258
+ while (!this.stopped && !this.superseded) {
242
259
  let answer: DevWaitAnswer
243
260
  try {
244
261
  answer = await this.wait(true)
245
262
  backoff = 1_000
246
- } catch {
263
+ } catch (error) {
247
264
  if (this.stopped) return
265
+ if (error instanceof SupersededError) {
266
+ this.say(SUPERSEDED, 'stderr')
267
+ this.superseded = true
268
+ return
269
+ }
248
270
  this.say(`the long poll on Control failed; retrying in ${backoff / 1000} s`, 'stderr')
249
271
  await sleep(backoff)
250
272
  backoff = Math.min(backoff * 2, 15_000)
@@ -255,9 +277,19 @@ class Launcher {
255
277
  }
256
278
 
257
279
  private async wait(hold: boolean): Promise<DevWaitAnswer> {
258
- const body = encodeDevWaitRequest({ head: this.state.head, reset: this.state.reset, checks: this.state.checks, hold })
280
+ const body = encodeDevWaitRequest({
281
+ bootstrap: this.config.head,
282
+ head: this.state.head,
283
+ reset: this.state.reset,
284
+ checks: this.state.checks,
285
+ hold,
286
+ })
259
287
  const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(DEV_SOURCE_WAIT_MS + 15_000)])
260
288
  const response = await this.fetch(this.request(DEV_SOURCE_WAIT_PATH, body, signal))
289
+ if (response.status === 409) {
290
+ await response.body?.cancel()
291
+ throw new SupersededError()
292
+ }
261
293
  if (!response.ok) throw new Error(`the source receiver answered ${response.status}`)
262
294
  const parsed = parseDevWaitAnswer(await response.json())
263
295
  if (!parsed.ok) throw new Error('the source receiver answered a body this release does not read')
@@ -274,7 +306,9 @@ class Launcher {
274
306
  }
275
307
 
276
308
  private async report(): Promise<void> {
309
+ if (this.superseded) return
277
310
  const body = encodeDevReport({
311
+ bootstrap: this.config.head,
278
312
  head: this.state.head,
279
313
  status: this.status,
280
314
  target: this.target,
@@ -299,7 +333,9 @@ class Launcher {
299
333
  if (this.state.checks === null) this.state = { ...this.state, checks: answer.checks }
300
334
  let changed = boot
301
335
  let migrate = boot
302
- let web = false
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()
303
339
  if (answer.sync !== null) {
304
340
  const outcome = await this.writeSync(answer.sync)
305
341
  if (outcome.kind === 'refused') {
@@ -311,16 +347,15 @@ class Launcher {
311
347
  } else {
312
348
  changed = true
313
349
  migrate ||= outcome.migrations
314
- web = outcome.web
350
+ spaFailure = outcome.spaFailure
315
351
  }
316
352
  }
317
- const reset = answer.reset > (this.state.reset ?? answer.reset)
318
- if (changed || reset) await this.converge({ migrate: migrate || reset, reset, web, resetTo: answer.reset })
353
+ if (changed || reset) await this.converge({ migrate: migrate || reset, reset, spaFailure, resetTo: answer.reset })
319
354
  if (answer.checks > (this.state.checks ?? answer.checks)) await this.check(answer.checks)
320
355
  }
321
356
 
322
357
  /** Bring the database, the SPA and the running application to the files on disk, then report. */
323
- private async converge(steps: { migrate: boolean; reset: boolean; web: boolean; resetTo: number }): Promise<void> {
358
+ private async converge(steps: { migrate: boolean; reset: boolean; spaFailure: string | null; resetTo: number }): Promise<void> {
324
359
  this.status = steps.reset ? 'resetting' : 'syncing'
325
360
  void this.report()
326
361
  let failure: string | null = null
@@ -331,7 +366,7 @@ class Launcher {
331
366
  this.state = { ...this.state, reset: steps.resetTo }
332
367
  }
333
368
  if (failure === null && steps.migrate) failure = await this.timed('the migration', () => this.migrate())
334
- if (failure === null && steps.web) failure = await this.timed('the SPA build', () => this.buildSpa())
369
+ if (failure === null) failure = steps.spaFailure
335
370
  const served = await this.timed('the restart until healthy', () => this.restartApplication())
336
371
  failure ??= served
337
372
  this.status = failure === null ? 'serving' : 'failed'
@@ -360,14 +395,21 @@ class Launcher {
360
395
  sync.files.map(async (file) => ({ path: file.path, content: await readFile(join(this.root, file.path)).catch(() => null) })),
361
396
  )
362
397
  await this.writeFiles(sync.files)
363
- const changedContract = await this.contractChange()
398
+ const web = this.config.spaBuild.length > 0 && webSourceChanged(paths)
399
+ // The SPA build runs beside the digest: a refusal is rare, and then the build runs again over the restored files.
400
+ const [changedContract, spaFailure] = await Promise.all([this.contractChange(), web ? this.spaBuild() : null])
364
401
  if (changedContract !== null) {
365
402
  await this.writeFiles(previous)
403
+ if (web) await this.spaBuild()
366
404
  return { kind: 'refused', reason: changedContract }
367
405
  }
368
406
  this.state = { ...this.state, head: sync.to }
369
407
  this.say(`synced ${sync.files.length} files to ${sync.to}`)
370
- return { kind: 'applied', migrations: migrationsChanged(paths), web: this.config.spaBuild.length > 0 && webSourceChanged(paths) }
408
+ return { kind: 'applied', migrations: migrationsChanged(paths), spaFailure }
409
+ }
410
+
411
+ private spaBuild(): Promise<string | null> {
412
+ return this.timed('the SPA build', () => this.buildSpa())
371
413
  }
372
414
 
373
415
  private async writeFiles(files: readonly { readonly path: string; readonly content: string | Uint8Array | null }[]): Promise<void> {
@@ -447,7 +489,12 @@ class Launcher {
447
489
 
448
490
  private async buildSpa(): Promise<string | null> {
449
491
  for (const command of this.config.spaBuild) {
450
- 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
+ )
451
498
  if (result.exitCode !== 0) return `the SPA command \`${command}\` failed: ${lastLine(result)}`
452
499
  }
453
500
  return null
@@ -460,12 +507,17 @@ class Launcher {
460
507
  const results: DevCheckResult[] = []
461
508
  for (const check of this.config.checks) {
462
509
  this.say(`check ${check.name}: ${check.command}`)
510
+ const started = performance.now()
463
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')
464
515
  results.push({
465
516
  name: check.name,
466
517
  ok: result.exitCode === 0,
467
518
  exitCode: result.exitCode,
468
- 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)),
469
521
  })
470
522
  }
471
523
  this.checkResults = { request, head: this.state.head, results }
@@ -477,17 +529,22 @@ class Launcher {
477
529
  }
478
530
 
479
531
  /** Run a command in the root; its output goes to the log, and its lines are returned. */
480
- 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> {
481
538
  const child = Bun.spawn([...argv], { cwd: this.root, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
482
539
  const lines: string[] = []
483
- const collect = (stream: 'stdout' | 'stderr') => (line: string) => {
540
+ const collect = (stream: OutputStream) => (line: string) => {
484
541
  lines.push(line)
485
- this.ring.push(stream, line)
542
+ this.ring.push(classify(stream, line), line)
486
543
  }
487
544
  const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
488
545
  await Promise.all([readLines(child.stdout, collect('stdout')), readLines(child.stderr, collect('stderr')), child.exited])
489
546
  clearTimeout(timer)
490
- return { exitCode: child.exitCode, lines }
547
+ return { exitCode: child.exitCode, signal: child.signalCode === null ? null : String(child.signalCode), lines }
491
548
  }
492
549
 
493
550
  private async restartApplication(): Promise<string | null> {
@@ -571,6 +628,12 @@ class Launcher {
571
628
  }
572
629
  }
573
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
+
574
637
  const lastLine = (result: CommandResult): string => [...result.lines].reverse().find((line) => line.trim() !== '')?.trim() ?? 'no output'
575
638
 
576
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 {
package/src/dev/wire.ts CHANGED
@@ -32,7 +32,14 @@ export function isSourcePath(path: string): boolean {
32
32
 
33
33
  const sourcePath = z.string().refine(isSourcePath, 'not a repository-relative path')
34
34
 
35
+ /**
36
+ * The commit the container's bootstrap run built (`DevLauncherConfig.head`). Control answers only the container of the
37
+ * newest bootstrap run: a container a newer bootstrap replaces still runs for a while, and must not sync or report.
38
+ */
39
+ const bootstrap = commitSha
40
+
35
41
  export const devWaitRequestSchema = z.object({
42
+ bootstrap,
36
43
  /** The commit whose files the container holds now. */
37
44
  head: commitSha,
38
45
  /** The reset generation it applied last; null for a container with no record of one, which adopts the answer's. */
@@ -81,6 +88,7 @@ export const devCheckResultSchema = z.object({
81
88
  name: z.string().min(1).max(64),
82
89
  ok: z.boolean(),
83
90
  exitCode: z.number().int().nullable(),
91
+ durationMs: z.number().int().min(0),
84
92
  /** The last lines of its output. */
85
93
  output: z.array(z.string().max(DEV_REPORT_MAX_LINE_CHARACTERS)).max(DEV_REPORT_MAX_LINES),
86
94
  }).strict()
@@ -88,6 +96,7 @@ export const devCheckResultSchema = z.object({
88
96
  export type DevCheckResult = z.infer<typeof devCheckResultSchema>
89
97
 
90
98
  export const devReportSchema = z.object({
99
+ bootstrap,
91
100
  /** The commit whose files the container holds now. */
92
101
  head: commitSha,
93
102
  status: z.enum(DEV_STATUSES),
@@ -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
  }
@@ -15,6 +15,7 @@ export declare const DEV_REPORT_MAX_MESSAGE_CHARACTERS = 500;
15
15
  */
16
16
  export declare function isSourcePath(path: string): boolean;
17
17
  export declare const devWaitRequestSchema: z.ZodObject<{
18
+ bootstrap: z.ZodString;
18
19
  head: z.ZodString;
19
20
  reset: z.ZodNullable<z.ZodNumber>;
20
21
  checks: z.ZodNullable<z.ZodNumber>;
@@ -70,10 +71,12 @@ export declare const devCheckResultSchema: z.ZodObject<{
70
71
  name: z.ZodString;
71
72
  ok: z.ZodBoolean;
72
73
  exitCode: z.ZodNullable<z.ZodNumber>;
74
+ durationMs: z.ZodNumber;
73
75
  output: z.ZodArray<z.ZodString>;
74
76
  }, z.core.$strict>;
75
77
  export type DevCheckResult = z.infer<typeof devCheckResultSchema>;
76
78
  export declare const devReportSchema: z.ZodObject<{
79
+ bootstrap: z.ZodString;
77
80
  head: z.ZodString;
78
81
  status: z.ZodEnum<{
79
82
  failed: "failed";
@@ -101,6 +104,7 @@ export declare const devReportSchema: z.ZodObject<{
101
104
  name: z.ZodString;
102
105
  ok: z.ZodBoolean;
103
106
  exitCode: z.ZodNullable<z.ZodNumber>;
107
+ durationMs: z.ZodNumber;
104
108
  output: z.ZodArray<z.ZodString>;
105
109
  }, z.core.$strict>>;
106
110
  }, z.core.$strict>>;