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

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.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -104,14 +104,14 @@
104
104
  "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
105
105
  },
106
106
  "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",
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",
115
115
  "altcha-lib": "2.6.0",
116
116
  "jose": "^5.9.0"
117
117
  },
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.3
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.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",
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.3",
34
+ "@fabrika/cli": "0.9.0-dev.3",
35
+ "@fabrika/platform": "0.9.0-dev.3",
36
36
  "@happy-dom/global-registrator": "^20.11.2",
37
37
  "@types/bun": "latest",
38
38
  "@types/react": "^19.0.0",
@@ -127,9 +127,14 @@ interface CommandResult {
127
127
  }
128
128
 
129
129
  type SyncOutcome =
130
- | { readonly kind: 'applied'; readonly migrations: boolean; readonly web: boolean }
130
+ | { readonly kind: 'applied'; readonly migrations: boolean; readonly spaFailure: string | null }
131
131
  | { readonly kind: 'refused'; readonly reason: string }
132
132
 
133
+ /** Control answers 409 to the container of a bootstrap run that a newer one replaced. */
134
+ class SupersededError extends Error {}
135
+
136
+ const SUPERSEDED = 'a newer bootstrap run replaced this container; it stops following Control and serves until it is stopped'
137
+
133
138
  const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
134
139
 
135
140
  /** Every value of the environment a log line must never show: the database credentials and every key, secret or token. */
@@ -176,6 +181,8 @@ class Launcher {
176
181
  private crashes = 0
177
182
  private stopped = false
178
183
  private reportFailing = false
184
+ /** A newer bootstrap run replaced this container: it keeps serving until the platform stops it, and asks nothing. */
185
+ private superseded = false
179
186
 
180
187
  constructor(private readonly options: DevLauncherOptions) {
181
188
  this.root = options.root
@@ -225,7 +232,12 @@ class Launcher {
225
232
  while (!this.stopped) {
226
233
  try {
227
234
  return await this.wait(false)
228
- } catch {
235
+ } catch (error) {
236
+ if (error instanceof SupersededError) {
237
+ this.say(SUPERSEDED, 'stderr')
238
+ this.superseded = true
239
+ return null
240
+ }
229
241
  if (Date.now() >= deadline) {
230
242
  this.say('Control did not answer at boot; serving the files this container holds', 'stderr')
231
243
  return null
@@ -238,13 +250,18 @@ class Launcher {
238
250
 
239
251
  private async follow(): Promise<void> {
240
252
  let backoff = 1_000
241
- while (!this.stopped) {
253
+ while (!this.stopped && !this.superseded) {
242
254
  let answer: DevWaitAnswer
243
255
  try {
244
256
  answer = await this.wait(true)
245
257
  backoff = 1_000
246
- } catch {
258
+ } catch (error) {
247
259
  if (this.stopped) return
260
+ if (error instanceof SupersededError) {
261
+ this.say(SUPERSEDED, 'stderr')
262
+ this.superseded = true
263
+ return
264
+ }
248
265
  this.say(`the long poll on Control failed; retrying in ${backoff / 1000} s`, 'stderr')
249
266
  await sleep(backoff)
250
267
  backoff = Math.min(backoff * 2, 15_000)
@@ -255,9 +272,19 @@ class Launcher {
255
272
  }
256
273
 
257
274
  private async wait(hold: boolean): Promise<DevWaitAnswer> {
258
- const body = encodeDevWaitRequest({ head: this.state.head, reset: this.state.reset, checks: this.state.checks, hold })
275
+ const body = encodeDevWaitRequest({
276
+ bootstrap: this.config.head,
277
+ head: this.state.head,
278
+ reset: this.state.reset,
279
+ checks: this.state.checks,
280
+ hold,
281
+ })
259
282
  const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(DEV_SOURCE_WAIT_MS + 15_000)])
260
283
  const response = await this.fetch(this.request(DEV_SOURCE_WAIT_PATH, body, signal))
284
+ if (response.status === 409) {
285
+ await response.body?.cancel()
286
+ throw new SupersededError()
287
+ }
261
288
  if (!response.ok) throw new Error(`the source receiver answered ${response.status}`)
262
289
  const parsed = parseDevWaitAnswer(await response.json())
263
290
  if (!parsed.ok) throw new Error('the source receiver answered a body this release does not read')
@@ -274,7 +301,9 @@ class Launcher {
274
301
  }
275
302
 
276
303
  private async report(): Promise<void> {
304
+ if (this.superseded) return
277
305
  const body = encodeDevReport({
306
+ bootstrap: this.config.head,
278
307
  head: this.state.head,
279
308
  status: this.status,
280
309
  target: this.target,
@@ -299,7 +328,7 @@ class Launcher {
299
328
  if (this.state.checks === null) this.state = { ...this.state, checks: answer.checks }
300
329
  let changed = boot
301
330
  let migrate = boot
302
- let web = false
331
+ let spaFailure: string | null = null
303
332
  if (answer.sync !== null) {
304
333
  const outcome = await this.writeSync(answer.sync)
305
334
  if (outcome.kind === 'refused') {
@@ -311,16 +340,16 @@ class Launcher {
311
340
  } else {
312
341
  changed = true
313
342
  migrate ||= outcome.migrations
314
- web = outcome.web
343
+ spaFailure = outcome.spaFailure
315
344
  }
316
345
  }
317
346
  const reset = answer.reset > (this.state.reset ?? answer.reset)
318
- if (changed || reset) await this.converge({ migrate: migrate || reset, reset, web, resetTo: answer.reset })
347
+ if (changed || reset) await this.converge({ migrate: migrate || reset, reset, spaFailure, resetTo: answer.reset })
319
348
  if (answer.checks > (this.state.checks ?? answer.checks)) await this.check(answer.checks)
320
349
  }
321
350
 
322
351
  /** 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> {
352
+ private async converge(steps: { migrate: boolean; reset: boolean; spaFailure: string | null; resetTo: number }): Promise<void> {
324
353
  this.status = steps.reset ? 'resetting' : 'syncing'
325
354
  void this.report()
326
355
  let failure: string | null = null
@@ -331,7 +360,7 @@ class Launcher {
331
360
  this.state = { ...this.state, reset: steps.resetTo }
332
361
  }
333
362
  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())
363
+ if (failure === null) failure = steps.spaFailure
335
364
  const served = await this.timed('the restart until healthy', () => this.restartApplication())
336
365
  failure ??= served
337
366
  this.status = failure === null ? 'serving' : 'failed'
@@ -360,14 +389,21 @@ class Launcher {
360
389
  sync.files.map(async (file) => ({ path: file.path, content: await readFile(join(this.root, file.path)).catch(() => null) })),
361
390
  )
362
391
  await this.writeFiles(sync.files)
363
- const changedContract = await this.contractChange()
392
+ const web = this.config.spaBuild.length > 0 && webSourceChanged(paths)
393
+ // The SPA build runs beside the digest: a refusal is rare, and then the build runs again over the restored files.
394
+ const [changedContract, spaFailure] = await Promise.all([this.contractChange(), web ? this.spaBuild() : null])
364
395
  if (changedContract !== null) {
365
396
  await this.writeFiles(previous)
397
+ if (web) await this.spaBuild()
366
398
  return { kind: 'refused', reason: changedContract }
367
399
  }
368
400
  this.state = { ...this.state, head: sync.to }
369
401
  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) }
402
+ return { kind: 'applied', migrations: migrationsChanged(paths), spaFailure }
403
+ }
404
+
405
+ private spaBuild(): Promise<string | null> {
406
+ return this.timed('the SPA build', () => this.buildSpa())
371
407
  }
372
408
 
373
409
  private async writeFiles(files: readonly { readonly path: string; readonly content: string | Uint8Array | null }[]): Promise<void> {
@@ -460,11 +496,13 @@ class Launcher {
460
496
  const results: DevCheckResult[] = []
461
497
  for (const check of this.config.checks) {
462
498
  this.say(`check ${check.name}: ${check.command}`)
499
+ const started = performance.now()
463
500
  const result = await this.run(['sh', '-c', check.command], this.commandEnvironment, CHECK_TIMEOUT_MS)
464
501
  results.push({
465
502
  name: check.name,
466
503
  ok: result.exitCode === 0,
467
504
  exitCode: result.exitCode,
505
+ durationMs: Math.round(performance.now() - started),
468
506
  output: result.lines.slice(-CHECK_OUTPUT_LINES).map((line) => line.slice(0, 2_000)),
469
507
  })
470
508
  }
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),
@@ -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>>;