@fabrika/app 0.9.0-dev.1 → 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.
Files changed (134) hide show
  1. package/package.json +27 -26
  2. package/skill/SKILL.md +1 -1
  3. package/skill/references/app-setup.md +8 -8
  4. package/src/dev/changes.ts +5 -43
  5. package/src/dev/contract-digest.ts +17 -0
  6. package/src/dev/index.ts +1 -1
  7. package/src/dev/launcher.ts +116 -31
  8. package/src/dev/wire.ts +11 -1
  9. package/types/src/agent-skill.d.ts +29 -0
  10. package/types/src/build.d.ts +11 -0
  11. package/types/src/bun.d.ts +66 -0
  12. package/types/src/client/caller.d.ts +19 -0
  13. package/types/src/client/contract.d.ts +131 -0
  14. package/types/src/client/error-reporting.d.ts +66 -0
  15. package/types/src/client/index.d.ts +4 -0
  16. package/types/src/client/rpc.d.ts +14 -0
  17. package/types/src/client/share.d.ts +2 -0
  18. package/types/src/client/transport.d.ts +42 -0
  19. package/types/src/cloudflare.d.ts +49 -0
  20. package/types/src/contract/artifact-schema.d.ts +8 -0
  21. package/types/src/contract/attachment.d.ts +41 -0
  22. package/types/src/contract/build.d.ts +12 -0
  23. package/types/src/contract/compile.d.ts +11 -0
  24. package/types/src/contract/connect.d.ts +51 -0
  25. package/types/src/contract/context.d.ts +501 -0
  26. package/types/src/contract/declaration.d.ts +303 -0
  27. package/types/src/contract/defaults.d.ts +208 -0
  28. package/types/src/contract/define.d.ts +359 -0
  29. package/types/src/contract/file.d.ts +44 -0
  30. package/types/src/contract/group.d.ts +274 -0
  31. package/types/src/contract/handled.d.ts +18 -0
  32. package/types/src/contract/index.d.ts +19 -0
  33. package/types/src/contract/kit.d.ts +210 -0
  34. package/types/src/contract/platform.d.ts +9 -0
  35. package/types/src/contract/problems.d.ts +14 -0
  36. package/types/src/contract/redirect.d.ts +25 -0
  37. package/types/src/contract/schema-from-shape.d.ts +10 -0
  38. package/types/src/contract/schema-shape.d.ts +5 -0
  39. package/types/src/contract/snapshot-source.d.ts +15 -0
  40. package/types/src/contract/token.d.ts +58 -0
  41. package/types/src/contract/vocabulary.d.ts +30 -0
  42. package/types/src/contract/webhook-binding.d.ts +24 -0
  43. package/types/src/contract/webhook-scheme.d.ts +23 -0
  44. package/types/src/dev/changes.d.ts +7 -0
  45. package/types/src/dev/contract-digest.d.ts +1 -0
  46. package/types/src/dev/index.d.ts +2 -0
  47. package/types/src/dev/launcher.d.ts +32 -0
  48. package/types/src/dev/output.d.ts +22 -0
  49. package/types/src/dev/wire.d.ts +126 -0
  50. package/types/src/disk-assets.d.ts +3 -0
  51. package/types/src/errors.d.ts +46 -0
  52. package/types/src/fetch.d.ts +7 -0
  53. package/types/src/files/archive.d.ts +26 -0
  54. package/types/src/files/clamscan.d.ts +32 -0
  55. package/types/src/files/handle.d.ts +106 -0
  56. package/types/src/files/kinds.d.ts +105 -0
  57. package/types/src/files/scan.d.ts +80 -0
  58. package/types/src/files/sniff.d.ts +7 -0
  59. package/types/src/files/streams.d.ts +17 -0
  60. package/types/src/files/treatment.d.ts +18 -0
  61. package/types/src/from-row.d.ts +20 -0
  62. package/types/src/honeypot.d.ts +2 -0
  63. package/types/src/implement/anonymous.d.ts +26 -0
  64. package/types/src/implement/attachment.d.ts +14 -0
  65. package/types/src/implement/boot.d.ts +109 -0
  66. package/types/src/implement/bounded.d.ts +22 -0
  67. package/types/src/implement/browser-config.d.ts +19 -0
  68. package/types/src/implement/capability.d.ts +140 -0
  69. package/types/src/implement/challenge.d.ts +75 -0
  70. package/types/src/implement/client-address.d.ts +11 -0
  71. package/types/src/implement/connect.d.ts +67 -0
  72. package/types/src/implement/crashes.d.ts +31 -0
  73. package/types/src/implement/dispatch.d.ts +218 -0
  74. package/types/src/implement/egress.d.ts +14 -0
  75. package/types/src/implement/environment.d.ts +14 -0
  76. package/types/src/implement/failures.d.ts +34 -0
  77. package/types/src/implement/faults.d.ts +44 -0
  78. package/types/src/implement/headers.d.ts +7 -0
  79. package/types/src/implement/implement.d.ts +58 -0
  80. package/types/src/implement/index.d.ts +24 -0
  81. package/types/src/implement/invocation.d.ts +120 -0
  82. package/types/src/implement/membership.d.ts +201 -0
  83. package/types/src/implement/multipart.d.ts +36 -0
  84. package/types/src/implement/params.d.ts +7 -0
  85. package/types/src/implement/principal.d.ts +52 -0
  86. package/types/src/implement/project.d.ts +11 -0
  87. package/types/src/implement/public.d.ts +61 -0
  88. package/types/src/implement/serve.d.ts +25 -0
  89. package/types/src/implement/tokens.d.ts +49 -0
  90. package/types/src/implement/types.d.ts +243 -0
  91. package/types/src/implement/urls.d.ts +15 -0
  92. package/types/src/implement/verifiers.d.ts +19 -0
  93. package/types/src/implement/webhook.d.ts +55 -0
  94. package/types/src/implement/workload.d.ts +56 -0
  95. package/types/src/implement/xlsx.d.ts +6 -0
  96. package/types/src/index.d.ts +12 -0
  97. package/types/src/jobs/exception.d.ts +40 -0
  98. package/types/src/jobs/mail.d.ts +67 -0
  99. package/types/src/jobs/report.d.ts +56 -0
  100. package/types/src/jobs/runner.d.ts +145 -0
  101. package/types/src/jobs/schedule.d.ts +15 -0
  102. package/types/src/jobs/scrub.d.ts +12 -0
  103. package/types/src/jobs/types.d.ts +92 -0
  104. package/types/src/react/caller.d.ts +30 -0
  105. package/types/src/react/errors.d.ts +40 -0
  106. package/types/src/react/hooks.d.ts +110 -0
  107. package/types/src/react/index.d.ts +5 -0
  108. package/types/src/react/provider.d.ts +58 -0
  109. package/types/src/react/store.d.ts +68 -0
  110. package/types/src/react/testing.d.ts +28 -0
  111. package/types/src/react/types.d.ts +33 -0
  112. package/types/src/rpc/builder.d.ts +52 -0
  113. package/types/src/rpc/contract.d.ts +23 -0
  114. package/types/src/rpc/dispatcher.d.ts +11 -0
  115. package/types/src/rpc/types.d.ts +62 -0
  116. package/types/src/service/define.d.ts +42 -0
  117. package/types/src/service/index.d.ts +3 -0
  118. package/types/src/service/serve.d.ts +28 -0
  119. package/types/src/standard-schema.d.ts +55 -0
  120. package/types/src/testing/caller.d.ts +95 -0
  121. package/types/src/testing/challenge.d.ts +11 -0
  122. package/types/src/testing/dev.d.ts +97 -0
  123. package/types/src/testing/endpoint-access.d.ts +99 -0
  124. package/types/src/testing/index.d.ts +12 -0
  125. package/types/src/testing/isolation.d.ts +3 -0
  126. package/types/src/testing/kit.d.ts +182 -0
  127. package/types/src/testing/membership.d.ts +132 -0
  128. package/types/src/testing/policy-isolation.d.ts +113 -0
  129. package/types/src/testing/preload.d.ts +1 -0
  130. package/types/src/testing/proxy.d.ts +40 -0
  131. package/types/src/testing/runner.d.ts +8 -0
  132. package/types/src/testing/sample-file.d.ts +5 -0
  133. package/types/src/testing/status.d.ts +4 -0
  134. package/types/src/undumpable.d.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/app",
3
- "version": "0.9.0-dev.1",
3
+ "version": "0.9.0-dev.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,87 +14,88 @@
14
14
  "files": [
15
15
  "src",
16
16
  "skill",
17
- "!src/**/__tests__/**"
17
+ "!src/**/__tests__/**",
18
+ "types"
18
19
  ],
19
20
  "exports": {
20
21
  ".": {
21
22
  "bun": "./src/index.ts",
22
- "types": "./src/index.ts",
23
+ "types": "./types/src/index.d.ts",
23
24
  "default": "./src/index.ts"
24
25
  },
25
26
  "./bun": {
26
27
  "bun": "./src/bun.ts",
27
- "types": "./src/bun.ts",
28
+ "types": "./types/src/bun.d.ts",
28
29
  "default": "./src/bun.ts"
29
30
  },
30
31
  "./cloudflare": {
31
32
  "bun": "./src/cloudflare.ts",
32
- "types": "./src/cloudflare.ts",
33
+ "types": "./types/src/cloudflare.d.ts",
33
34
  "default": "./src/cloudflare.ts"
34
35
  },
35
36
  "./contract": {
36
37
  "bun": "./src/contract/index.ts",
37
- "types": "./src/contract/index.ts",
38
+ "types": "./types/src/contract/index.d.ts",
38
39
  "default": "./src/contract/index.ts"
39
40
  },
40
41
  "./client": {
41
42
  "bun": "./src/client/index.ts",
42
- "types": "./src/client/index.ts",
43
+ "types": "./types/src/client/index.d.ts",
43
44
  "default": "./src/client/index.ts"
44
45
  },
45
46
  "./react": {
46
47
  "bun": "./src/react/index.ts",
47
- "types": "./src/react/index.ts",
48
+ "types": "./types/src/react/index.d.ts",
48
49
  "default": "./src/react/index.ts"
49
50
  },
50
51
  "./react/testing": {
51
52
  "bun": "./src/react/testing.ts",
52
- "types": "./src/react/testing.ts",
53
+ "types": "./types/src/react/testing.d.ts",
53
54
  "default": "./src/react/testing.ts"
54
55
  },
55
56
  "./implement": {
56
57
  "bun": "./src/implement/index.ts",
57
- "types": "./src/implement/index.ts",
58
+ "types": "./types/src/implement/index.d.ts",
58
59
  "default": "./src/implement/index.ts"
59
60
  },
60
61
  "./service": {
61
62
  "bun": "./src/service/index.ts",
62
- "types": "./src/service/index.ts",
63
+ "types": "./types/src/service/index.d.ts",
63
64
  "default": "./src/service/index.ts"
64
65
  },
65
66
  "./build": {
66
67
  "bun": "./src/build.ts",
67
- "types": "./src/build.ts",
68
+ "types": "./types/src/build.d.ts",
68
69
  "default": "./src/build.ts"
69
70
  },
70
71
  "./testing": {
71
72
  "bun": "./src/testing/index.ts",
72
- "types": "./src/testing/index.ts",
73
+ "types": "./types/src/testing/index.d.ts",
73
74
  "default": "./src/testing/index.ts"
74
75
  },
75
76
  "./testing/isolation": {
76
77
  "bun": "./src/testing/isolation.ts",
77
- "types": "./src/testing/isolation.ts",
78
+ "types": "./types/src/testing/isolation.d.ts",
78
79
  "default": "./src/testing/isolation.ts"
79
80
  },
80
81
  "./testing/preload": {
81
82
  "bun": "./src/testing/preload.ts",
82
- "types": "./src/testing/preload.ts",
83
+ "types": "./types/src/testing/preload.d.ts",
83
84
  "default": "./src/testing/preload.ts"
84
85
  },
85
86
  "./dev": {
86
87
  "bun": "./src/dev/index.ts",
87
- "types": "./src/dev/index.ts",
88
+ "types": "./types/src/dev/index.d.ts",
88
89
  "default": "./src/dev/index.ts"
89
90
  },
90
91
  "./dev/wire": {
91
92
  "bun": "./src/dev/wire.ts",
92
- "types": "./src/dev/wire.ts",
93
+ "types": "./types/src/dev/wire.d.ts",
93
94
  "default": "./src/dev/wire.ts"
94
95
  },
95
96
  "./agent-skill": {
96
97
  "bun": "./src/agent-skill.ts",
97
- "types": "./src/agent-skill.ts",
98
+ "types": "./types/src/agent-skill.d.ts",
98
99
  "default": "./src/agent-skill.ts"
99
100
  }
100
101
  },
@@ -103,14 +104,14 @@
103
104
  "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
104
105
  },
105
106
  "dependencies": {
106
- "@fabrika/auth": "0.9.0-dev.1",
107
- "@fabrika/auth-core": "0.9.0-dev.1",
108
- "@fabrika/db": "0.9.0-dev.1",
109
- "@fabrika/email": "0.9.0-dev.1",
110
- "@fabrika/jwks": "0.9.0-dev.1",
111
- "@fabrika/platform": "0.9.0-dev.1",
112
- "@fabrika/platform-node": "0.9.0-dev.1",
113
- "@fabrika/proxy-core": "0.9.0-dev.1",
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",
114
115
  "altcha-lib": "2.6.0",
115
116
  "jose": "^5.9.0"
116
117
  },
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fabrika-app
3
- version: 0.9.0-dev.1
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.1",
23
- "@fabrika/db": "0.9.0-dev.1",
24
- "@fabrika/provider-zerops": "0.9.0-dev.1",
25
- "@fabrika/proxy-core": "0.9.0-dev.1",
26
- "@fabrika/ui": "0.9.0-dev.1",
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.1",
34
- "@fabrika/cli": "0.9.0-dev.1",
35
- "@fabrika/platform": "0.9.0-dev.1",
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",
@@ -1,64 +1,26 @@
1
1
  // What a sync changes, read off its paths: whether the build owns one (then the commit needs a bootstrap run), whether
2
- // the migrations changed, and whether a web source did. Pure: the caller reads the files.
2
+ // the migrations changed, and whether a web source did. Whether the contract changed is the digest's to say, not a path's.
3
3
 
4
- import { posix } from 'node:path'
5
4
  import { CONTRACT_FILE } from '../build.js'
6
5
 
7
6
  /** The application's migrations, as `fabrika db migrate` reads them. */
8
7
  export const MIGRATIONS_DIRECTORY = 'db/migrations/'
9
8
 
10
- /** Files whose change the build turns into something else: an install, the contract digest and proxy table, a generated file. */
9
+ /** Files whose change the build turns into something else: an install, or the generated run and build descriptors. */
11
10
  const BUILD_OWNED_FILES: ReadonlySet<string> = new Set(['package.json', 'bun.lock', 'bun.lockb', 'bunfig.toml', 'zerops.yaml', 'zerops.yml'])
12
11
  const BUILD_OWNED_DIRECTORIES = ['fabrika/', 'node_modules/'] as const
13
12
 
14
13
  /** Server code and the database: a change only there leaves the SPA as it is. */
15
14
  const SERVER_DIRECTORIES = ['src/', 'db/'] as const
16
15
 
17
- /** Reads a file at the head the sync brings, or null when it does not exist there. */
18
- export type SourceReader = (path: string) => string | null
19
-
20
- const RELATIVE_IMPORT = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)['"](\.{1,2}\/[^'"]+)['"]/g
21
-
22
- /** The repository paths a relative specifier of `from` may name, in the order Bun resolves a TypeScript import. */
23
- function candidates(from: string, specifier: string): string[] {
24
- const base = posix.normalize(posix.join(posix.dirname(from), specifier))
25
- if (base.startsWith('../')) return []
26
- const withoutJs = base.endsWith('.js') ? base.slice(0, -3) : base
27
- return [base, `${withoutJs}.ts`, `${withoutJs}.tsx`, `${withoutJs}/index.ts`]
28
- }
29
-
30
- /** `fabrika.contract.ts` and every module it reaches by a relative import, read through `read`. */
31
- export function contractModules(read: SourceReader): Set<string> {
32
- const modules = new Set<string>()
33
- const pending = [CONTRACT_FILE]
34
- while (pending.length > 0) {
35
- const path = pending.pop() ?? ''
36
- if (modules.has(path)) continue
37
- const text = read(path)
38
- if (text === null) continue
39
- modules.add(path)
40
- for (const match of text.matchAll(RELATIVE_IMPORT)) {
41
- const resolved = candidates(path, match[1] ?? '').find((candidate) => read(candidate) !== null)
42
- if (resolved !== undefined) pending.push(resolved)
43
- }
44
- }
45
- return modules
46
- }
47
-
48
- /**
49
- * Why the change needs a bootstrap run, in one line, or null when the launcher may apply it. `before` reads the
50
- * container's files, `after` the files at the target: a module the contract imports at either counts.
51
- */
52
- export function bootstrapReason(paths: readonly string[], before: SourceReader, after: SourceReader): string | null {
53
- const contract = new Set([...contractModules(before), ...contractModules(after)])
16
+ /** Why the change needs a bootstrap run, in one line, or null when the launcher may apply it. */
17
+ export function bootstrapReason(paths: readonly string[]): string | null {
54
18
  for (const path of paths) {
55
19
  const why = BUILD_OWNED_FILES.has(path)
56
20
  ? 'the dependencies, the lockfile or the build descriptor'
57
21
  : BUILD_OWNED_DIRECTORIES.some((directory) => path.startsWith(directory))
58
22
  ? 'the generated files or the installed packages'
59
- : contract.has(path)
60
- ? 'the contract or a module it imports (its digest and the proxy table change)'
61
- : path.startsWith('fabrika.') && !path.includes('/')
23
+ : path !== CONTRACT_FILE && path.startsWith('fabrika.') && !path.includes('/')
62
24
  ? 'a fabrika.* file'
63
25
  : null
64
26
  if (why !== null) return `${path} changed, which is ${why}; that needs a bootstrap run: call bind_dev again`
@@ -0,0 +1,17 @@
1
+ // The launcher's child that answers whether a commit changed the contract (ADR-0049). It runs in the application root
2
+ // with no credential in its environment, loads the application module the launcher itself never loads, and prints the
3
+ // digest the application's boot compares with `FABRIKA_CONTRACT_DIGEST`.
4
+
5
+ import { contractDigest } from '@fabrika/auth-core'
6
+ import { join } from 'node:path'
7
+ import { IMPLEMENTATION_FILE } from '../build.js'
8
+ import { compileContract } from '../contract/compile.js'
9
+ import { isContractImplementation } from '../implement/implement.js'
10
+
11
+ const module: unknown = await import(join(process.cwd(), IMPLEMENTATION_FILE))
12
+ const app = typeof module === 'object' && module !== null ? Reflect.get(module, 'default') : undefined
13
+ if (!isContractImplementation(app)) {
14
+ console.error(`${IMPLEMENTATION_FILE} does not default-export serve(contract, { … })`)
15
+ process.exit(1)
16
+ }
17
+ console.log(contractDigest(compileContract(app.contract, app.database?.tables ?? {})))
package/src/dev/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // @fabrika/app/dev — the launcher of a `dev` environment, which the generated dev entrypoint runs (Bun only).
2
2
 
3
- export { bootstrapReason, contractModules, migrationsChanged, webSourceChanged } from './changes.js'
3
+ export { bootstrapReason, migrationsChanged, webSourceChanged } from './changes.js'
4
4
  export {
5
5
  type DevLauncher,
6
6
  type DevLauncherOptions,
@@ -4,14 +4,20 @@
4
4
  // files, which it writes, then it migrates when `db/migrations/` changed, runs the SPA commands when a web source
5
5
  // changed, and respawns the application. A change the build owns is refused with one line: it needs a bootstrap run.
6
6
 
7
- import { DEV_LAUNCHER_CONFIG_FILE, DEV_LAUNCHER_STATE_FILE, type DevLauncherConfig, platformTransportVariables } from '@fabrika/auth-core'
7
+ import {
8
+ DEV_LAUNCHER_CONFIG_FILE,
9
+ DEV_LAUNCHER_STATE_FILE,
10
+ type DevLauncherConfig,
11
+ FABRIKA_CONTRACT_DIGEST,
12
+ platformTransportVariables,
13
+ } from '@fabrika/auth-core'
8
14
  import { applicationRuntimeUrl, dropApplicationDatabase } from '@fabrika/platform-node'
9
- import { existsSync, readFileSync } from 'node:fs'
10
15
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
11
16
  import { dirname, join } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
12
18
  import { z } from 'zod/v4'
13
19
  import { makeProcessUndumpable } from '../undumpable.js'
14
- import { bootstrapReason, migrationsChanged, type SourceReader, webSourceChanged } from './changes.js'
20
+ import { bootstrapReason, migrationsChanged, webSourceChanged } from './changes.js'
15
21
  import { OutputRing, type OutputSink, processOutput, readLines } from './output.js'
16
22
  import {
17
23
  DEV_SOURCE_REPORT_PATH,
@@ -41,6 +47,9 @@ const STOP_GRACE_MS = 3_000
41
47
  const CHECK_TIMEOUT_MS = 10 * 60_000
42
48
  const CRASH_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000] as const
43
49
  const FORWARDED_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const
50
+ const DIGEST_TIMEOUT_MS = 60_000
51
+ const CONTRACT_DIGEST = /^sha256:[0-9a-f]{64}$/
52
+ const CONTRACT_DIGEST_CHILD = fileURLToPath(new URL('./contract-digest.ts', import.meta.url))
44
53
 
45
54
  const commitSha = z.string().regex(/^[0-9a-f]{40}$/)
46
55
  /** A literal path of unreserved characters, as the provider's readiness probe requests it. */
@@ -118,9 +127,14 @@ interface CommandResult {
118
127
  }
119
128
 
120
129
  type SyncOutcome =
121
- | { readonly kind: 'applied'; readonly migrations: boolean; readonly web: boolean }
130
+ | { readonly kind: 'applied'; readonly migrations: boolean; readonly spaFailure: string | null }
122
131
  | { readonly kind: 'refused'; readonly reason: string }
123
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
+
124
138
  const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
125
139
 
126
140
  /** Every value of the environment a log line must never show: the database credentials and every key, secret or token. */
@@ -167,6 +181,8 @@ class Launcher {
167
181
  private crashes = 0
168
182
  private stopped = false
169
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
170
186
 
171
187
  constructor(private readonly options: DevLauncherOptions) {
172
188
  this.root = options.root
@@ -216,7 +232,12 @@ class Launcher {
216
232
  while (!this.stopped) {
217
233
  try {
218
234
  return await this.wait(false)
219
- } catch {
235
+ } catch (error) {
236
+ if (error instanceof SupersededError) {
237
+ this.say(SUPERSEDED, 'stderr')
238
+ this.superseded = true
239
+ return null
240
+ }
220
241
  if (Date.now() >= deadline) {
221
242
  this.say('Control did not answer at boot; serving the files this container holds', 'stderr')
222
243
  return null
@@ -229,13 +250,18 @@ class Launcher {
229
250
 
230
251
  private async follow(): Promise<void> {
231
252
  let backoff = 1_000
232
- while (!this.stopped) {
253
+ while (!this.stopped && !this.superseded) {
233
254
  let answer: DevWaitAnswer
234
255
  try {
235
256
  answer = await this.wait(true)
236
257
  backoff = 1_000
237
- } catch {
258
+ } catch (error) {
238
259
  if (this.stopped) return
260
+ if (error instanceof SupersededError) {
261
+ this.say(SUPERSEDED, 'stderr')
262
+ this.superseded = true
263
+ return
264
+ }
239
265
  this.say(`the long poll on Control failed; retrying in ${backoff / 1000} s`, 'stderr')
240
266
  await sleep(backoff)
241
267
  backoff = Math.min(backoff * 2, 15_000)
@@ -246,9 +272,19 @@ class Launcher {
246
272
  }
247
273
 
248
274
  private async wait(hold: boolean): Promise<DevWaitAnswer> {
249
- 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
+ })
250
282
  const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(DEV_SOURCE_WAIT_MS + 15_000)])
251
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
+ }
252
288
  if (!response.ok) throw new Error(`the source receiver answered ${response.status}`)
253
289
  const parsed = parseDevWaitAnswer(await response.json())
254
290
  if (!parsed.ok) throw new Error('the source receiver answered a body this release does not read')
@@ -265,7 +301,9 @@ class Launcher {
265
301
  }
266
302
 
267
303
  private async report(): Promise<void> {
304
+ if (this.superseded) return
268
305
  const body = encodeDevReport({
306
+ bootstrap: this.config.head,
269
307
  head: this.state.head,
270
308
  status: this.status,
271
309
  target: this.target,
@@ -290,7 +328,7 @@ class Launcher {
290
328
  if (this.state.checks === null) this.state = { ...this.state, checks: answer.checks }
291
329
  let changed = boot
292
330
  let migrate = boot
293
- let web = false
331
+ let spaFailure: string | null = null
294
332
  if (answer.sync !== null) {
295
333
  const outcome = await this.writeSync(answer.sync)
296
334
  if (outcome.kind === 'refused') {
@@ -302,28 +340,28 @@ class Launcher {
302
340
  } else {
303
341
  changed = true
304
342
  migrate ||= outcome.migrations
305
- web = outcome.web
343
+ spaFailure = outcome.spaFailure
306
344
  }
307
345
  }
308
346
  const reset = answer.reset > (this.state.reset ?? answer.reset)
309
- 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 })
310
348
  if (answer.checks > (this.state.checks ?? answer.checks)) await this.check(answer.checks)
311
349
  }
312
350
 
313
351
  /** Bring the database, the SPA and the running application to the files on disk, then report. */
314
- 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> {
315
353
  this.status = steps.reset ? 'resetting' : 'syncing'
316
354
  void this.report()
317
355
  let failure: string | null = null
318
356
  // A migration's ALTER would queue behind the live version's locks, and a reset drops the role it connects as.
319
357
  if (this.provisioningUrl !== null && steps.migrate) await this.stopApplication()
320
358
  if (steps.reset) {
321
- failure = await this.resetDatabase()
359
+ failure = await this.timed('the reset', () => this.resetDatabase())
322
360
  this.state = { ...this.state, reset: steps.resetTo }
323
361
  }
324
- if (failure === null && steps.migrate) failure = await this.migrate()
325
- if (failure === null && steps.web) failure = await this.buildSpa()
326
- const served = await this.restartApplication()
362
+ if (failure === null && steps.migrate) failure = await this.timed('the migration', () => this.migrate())
363
+ if (failure === null) failure = steps.spaFailure
364
+ const served = await this.timed('the restart until healthy', () => this.restartApplication())
327
365
  failure ??= served
328
366
  this.status = failure === null ? 'serving' : 'failed'
329
367
  this.target = null
@@ -332,17 +370,44 @@ class Launcher {
332
370
  await this.report()
333
371
  }
334
372
 
373
+ /** Run one step of a sync and log how long it took: what a slow `apply_changes` is read from. */
374
+ private async timed(step: string, run: () => Promise<string | null>): Promise<string | null> {
375
+ const started = performance.now()
376
+ const failure = await run()
377
+ this.say(`${step} ${failure === null ? 'took' : 'failed after'} ${Math.round(performance.now() - started)} ms`)
378
+ return failure
379
+ }
380
+
335
381
  private async writeSync(sync: DevSync): Promise<SyncOutcome> {
336
382
  if (sync.from !== this.state.head) {
337
383
  return { kind: 'refused', reason: `Control sent changes from ${sync.from}, and this container holds ${this.state.head}; call bind_dev again` }
338
384
  }
339
385
  const paths = sync.files.map((file) => file.path)
340
- const incoming = new Map(sync.files.map((file) => [file.path, file.content]))
341
- const before: SourceReader = (path) => this.readLocal(path)
342
- const after: SourceReader = (path) => incoming.has(path) ? incoming.get(path) ?? null : this.readLocal(path)
343
- const reason = bootstrapReason(paths, before, after)
386
+ const reason = bootstrapReason(paths)
344
387
  if (reason !== null) return { kind: 'refused', reason }
345
- for (const file of sync.files) {
388
+ const previous = await Promise.all(
389
+ sync.files.map(async (file) => ({ path: file.path, content: await readFile(join(this.root, file.path)).catch(() => null) })),
390
+ )
391
+ await this.writeFiles(sync.files)
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])
395
+ if (changedContract !== null) {
396
+ await this.writeFiles(previous)
397
+ if (web) await this.spaBuild()
398
+ return { kind: 'refused', reason: changedContract }
399
+ }
400
+ this.state = { ...this.state, head: sync.to }
401
+ this.say(`synced ${sync.files.length} files to ${sync.to}`)
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())
407
+ }
408
+
409
+ private async writeFiles(files: readonly { readonly path: string; readonly content: string | Uint8Array | null }[]): Promise<void> {
410
+ for (const file of files) {
346
411
  const path = join(this.root, file.path)
347
412
  if (file.content === null) {
348
413
  await rm(path, { force: true })
@@ -352,19 +417,37 @@ class Launcher {
352
417
  await writeFile(`${path}.fabrika-dev`, file.content)
353
418
  await rename(`${path}.fabrika-dev`, path)
354
419
  }
355
- this.state = { ...this.state, head: sync.to }
356
- this.say(`synced ${sync.files.length} files to ${sync.to}`)
357
- return { kind: 'applied', migrations: migrationsChanged(paths), web: this.config.spaBuild.length > 0 && webSourceChanged(paths) }
358
420
  }
359
421
 
360
- private readLocal(path: string): string | null {
361
- const absolute = join(this.root, path)
362
- if (!existsSync(absolute)) return null
363
- try {
364
- return readFileSync(absolute, 'utf8')
365
- } catch {
422
+ /**
423
+ * Why the files on disk need a bootstrap run because their contract digest is not the one the bootstrap run deployed,
424
+ * or null. A child computes it: it loads the application module, which the launcher never does, with no credential.
425
+ * A digest it cannot compute is no reason: the respawned application then fails to boot and reports why.
426
+ */
427
+ private async contractChange(): Promise<string | null> {
428
+ const deployed = this.options.environment[FABRIKA_CONTRACT_DIGEST] ?? ''
429
+ if (deployed === '') return null
430
+ const started = performance.now()
431
+ const child = Bun.spawn(['bun', CONTRACT_DIGEST_CHILD], {
432
+ cwd: this.root,
433
+ env: { PATH: this.options.environment['PATH'] ?? '', HOME: this.options.environment['HOME'] ?? '' },
434
+ stdin: 'ignore',
435
+ stdout: 'pipe',
436
+ stderr: 'pipe',
437
+ })
438
+ const timer = setTimeout(() => child.kill('SIGKILL'), DIGEST_TIMEOUT_MS)
439
+ const [stdout, stderr] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
440
+ clearTimeout(timer)
441
+ const digest = stdout.trim()
442
+ if (child.exitCode !== 0 || !CONTRACT_DIGEST.test(digest)) {
443
+ for (const line of stderr.split('\n').filter((text) => text.trim() !== '').slice(-5)) this.ring.push('stderr', line)
444
+ this.say('the contract digest could not be computed; the application is respawned and reports why', 'stderr')
366
445
  return null
367
446
  }
447
+ this.say(`contract digest computed in ${Math.round(performance.now() - started)} ms`)
448
+ if (digest === deployed) return null
449
+ return `the change alters the contract (its digest is ${digest}, and the bootstrap run deployed ${deployed}): the proxy table and `
450
+ + 'the approval follow the contract, so that needs a bootstrap run: call bind_dev again'
368
451
  }
369
452
 
370
453
  private async persist(): Promise<void> {
@@ -413,15 +496,17 @@ class Launcher {
413
496
  const results: DevCheckResult[] = []
414
497
  for (const check of this.config.checks) {
415
498
  this.say(`check ${check.name}: ${check.command}`)
499
+ const started = performance.now()
416
500
  const result = await this.run(['sh', '-c', check.command], this.commandEnvironment, CHECK_TIMEOUT_MS)
417
501
  results.push({
418
502
  name: check.name,
419
503
  ok: result.exitCode === 0,
420
504
  exitCode: result.exitCode,
505
+ durationMs: Math.round(performance.now() - started),
421
506
  output: result.lines.slice(-CHECK_OUTPUT_LINES).map((line) => line.slice(0, 2_000)),
422
507
  })
423
508
  }
424
- this.checkResults = { request, results }
509
+ this.checkResults = { request, head: this.state.head, results }
425
510
  this.state = { ...this.state, checks: request }
426
511
  this.status = before.status
427
512
  this.message = before.message
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),
@@ -98,7 +107,8 @@ export const devReportSchema = z.object({
98
107
  /** The newest lines of the application's and the launcher's output, oldest first. */
99
108
  log: z.array(devLogLineSchema).max(DEV_REPORT_MAX_LINES),
100
109
  reset: generation.nullable(),
101
- checks: z.object({ request: generation, results: z.array(devCheckResultSchema).max(8) }).strict().nullable(),
110
+ /** The newest check run: the request it answers, the commit whose files it checked, and each check's result. */
111
+ checks: z.object({ request: generation, head: commitSha, results: z.array(devCheckResultSchema).max(8) }).strict().nullable(),
102
112
  }).strict()
103
113
 
104
114
  export type DevReport = z.infer<typeof devReportSchema>
@@ -0,0 +1,29 @@
1
+ export declare const APP_AGENT_SKILL_NAME = "fabrika-app";
2
+ export interface AppAgentSkillReference {
3
+ /** The file's path in the skill directory, `references/<topic>.md`: the path `SKILL.md` names it by. */
4
+ readonly path: string;
5
+ /** The topic `get_app_rules` answers it under: its file name without `.md`. */
6
+ readonly topic: string;
7
+ readonly text: string;
8
+ }
9
+ export interface AppAgentSkill {
10
+ /** The `version` of the frontmatter: the version of the `@fabrika/app` that ships it. */
11
+ readonly version: string;
12
+ /** The whole `SKILL.md`, frontmatter included: what a skill directory holds. */
13
+ readonly text: string;
14
+ /** `SKILL.md` without the frontmatter. */
15
+ readonly body: string;
16
+ /** The reference files, by path. */
17
+ readonly references: readonly AppAgentSkillReference[];
18
+ }
19
+ /** The skill of this package. */
20
+ export declare function readAppAgentSkill(): AppAgentSkill;
21
+ /** The skill in `skillDir`, the `skill/` directory of an installed `@fabrika/app`. */
22
+ export declare function readAppAgentSkillAt(skillDir: string): AppAgentSkill;
23
+ /**
24
+ * The skill of the `@fabrika/app` an application installed: the nearest `node_modules/@fabrika/app` from `appRoot`
25
+ * up, as Bun resolves the package. `null` when the application has not installed it.
26
+ */
27
+ export declare function installedAppAgentSkill(appRoot: string): AppAgentSkill | null;
28
+ /** The `AGENTS.md` of an application repository: the skill's body, then each reference file. */
29
+ export declare function renderAgentsFile(skill: AppAgentSkill): string;
@@ -0,0 +1,11 @@
1
+ /** An application root with this file is a contract application. The paths equal `@fabrika/check`'s layout. */
2
+ export declare const CONTRACT_FILE = "fabrika.contract.ts";
3
+ /** Relative to the application root. */
4
+ export declare const GENERATED_ENTRY_FILE = "fabrika/entry.generated.ts";
5
+ /** The module whose default export is `serve(contract, …)`, relative to the application root. */
6
+ export declare const IMPLEMENTATION_FILE = "src/app.ts";
7
+ export type EntryRuntime = 'bun' | 'cloudflare';
8
+ /** The entrypoint source for one runtime. */
9
+ export declare function generatedEntry(runtime: EntryRuntime): string;
10
+ /** Writes the entrypoint under `root` and returns its path. */
11
+ export declare function writeGeneratedEntry(root: string, runtime: EntryRuntime): Promise<string>;