@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 +9 -9
- package/skill/SKILL.md +1 -1
- package/skill/references/app-setup.md +8 -8
- package/src/dev/launcher.ts +50 -12
- package/src/dev/wire.ts +9 -0
- package/types/src/dev/wire.d.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/app",
|
|
3
|
-
"version": "0.9.0-dev.
|
|
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.
|
|
108
|
-
"@fabrika/auth-core": "0.9.0-dev.
|
|
109
|
-
"@fabrika/db": "0.9.0-dev.
|
|
110
|
-
"@fabrika/email": "0.9.0-dev.
|
|
111
|
-
"@fabrika/jwks": "0.9.0-dev.
|
|
112
|
-
"@fabrika/platform": "0.9.0-dev.
|
|
113
|
-
"@fabrika/platform-node": "0.9.0-dev.
|
|
114
|
-
"@fabrika/proxy-core": "0.9.0-dev.
|
|
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.
|
|
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.
|
|
23
|
-
"@fabrika/db": "0.9.0-dev.
|
|
24
|
-
"@fabrika/provider-zerops": "0.9.0-dev.
|
|
25
|
-
"@fabrika/proxy-core": "0.9.0-dev.
|
|
26
|
-
"@fabrika/ui": "0.9.0-dev.
|
|
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.
|
|
34
|
-
"@fabrika/cli": "0.9.0-dev.
|
|
35
|
-
"@fabrika/platform": "0.9.0-dev.
|
|
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",
|
package/src/dev/launcher.ts
CHANGED
|
@@ -127,9 +127,14 @@ interface CommandResult {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
type SyncOutcome =
|
|
130
|
-
| { readonly kind: 'applied'; readonly migrations: boolean; readonly
|
|
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({
|
|
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
|
|
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
|
-
|
|
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,
|
|
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;
|
|
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
|
|
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
|
|
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),
|
|
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),
|
package/types/src/dev/wire.d.ts
CHANGED
|
@@ -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>>;
|