@fabrika/builder 0.2.0

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 (112) hide show
  1. package/BUILD-CONTRACT.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +100 -0
  4. package/cli/src/bin.ts +66 -0
  5. package/cli/src/build-status.ts +109 -0
  6. package/cli/src/catalog.ts +42 -0
  7. package/cli/src/commands/advance.ts +73 -0
  8. package/cli/src/commands/build.ts +215 -0
  9. package/cli/src/commands/ensure-daemon.ts +25 -0
  10. package/cli/src/commands/gate.ts +165 -0
  11. package/cli/src/commands/induce.ts +61 -0
  12. package/cli/src/commands/init.ts +91 -0
  13. package/cli/src/commands/next.ts +31 -0
  14. package/cli/src/config.ts +47 -0
  15. package/cli/src/context.ts +28 -0
  16. package/cli/src/daemon-client.ts +280 -0
  17. package/cli/src/daemon-lifecycle.ts +171 -0
  18. package/cli/src/derive.ts +109 -0
  19. package/cli/src/index.ts +38 -0
  20. package/cli/src/induce.ts +151 -0
  21. package/cli/src/inject-skills.ts +313 -0
  22. package/cli/src/issues.ts +75 -0
  23. package/cli/src/layout.ts +124 -0
  24. package/cli/src/packet.ts +108 -0
  25. package/cli/src/phase-state.ts +19 -0
  26. package/cli/src/phases.ts +131 -0
  27. package/cli/src/skeletons.ts +104 -0
  28. package/daemon/src/bin.ts +70 -0
  29. package/daemon/src/build-stream.ts +38 -0
  30. package/daemon/src/handlers.ts +472 -0
  31. package/daemon/src/index.ts +123 -0
  32. package/daemon/src/paths.ts +30 -0
  33. package/daemon/src/phases.ts +221 -0
  34. package/daemon/src/static.ts +72 -0
  35. package/decisions/README.md +133 -0
  36. package/decisions/patterns/app-deploy.md +55 -0
  37. package/decisions/patterns/auth-password-kv.md +45 -0
  38. package/decisions/patterns/count-store.md +44 -0
  39. package/decisions/patterns/di-container.md +47 -0
  40. package/decisions/patterns/feature-architecture.md +48 -0
  41. package/decisions/patterns/lib-publish.md +46 -0
  42. package/decisions/patterns/monorepo.md +47 -0
  43. package/decisions/patterns/named-seeds.md +45 -0
  44. package/decisions/patterns/observability.md +45 -0
  45. package/decisions/patterns/single-worker-spa.md +74 -0
  46. package/decisions/patterns/structured-logging.md +49 -0
  47. package/decisions/patterns/typed-worker-rpc.md +87 -0
  48. package/decisions/patterns/vozka-app-deploy.md +74 -0
  49. package/decisions/patterns/write-split.md +47 -0
  50. package/decisions/technologies/analytics-engine.md +44 -0
  51. package/decisions/technologies/bindx.md +55 -0
  52. package/decisions/technologies/bun.md +51 -0
  53. package/decisions/technologies/buzola.md +80 -0
  54. package/decisions/technologies/cloudflare-access.md +50 -0
  55. package/decisions/technologies/cloudflare-vite-plugin.md +43 -0
  56. package/decisions/technologies/cloudflare.md +50 -0
  57. package/decisions/technologies/contember.md +65 -0
  58. package/decisions/technologies/cron-triggers.md +46 -0
  59. package/decisions/technologies/d1.md +51 -0
  60. package/decisions/technologies/durable-objects.md +44 -0
  61. package/decisions/technologies/kv.md +42 -0
  62. package/decisions/technologies/lopata.md +55 -0
  63. package/decisions/technologies/nuasite.md +50 -0
  64. package/decisions/technologies/oblaka.md +48 -0
  65. package/decisions/technologies/opice.md +40 -0
  66. package/decisions/technologies/pletivo.md +53 -0
  67. package/decisions/technologies/postgres.md +47 -0
  68. package/decisions/technologies/preact.md +44 -0
  69. package/decisions/technologies/propustka.md +137 -0
  70. package/decisions/technologies/queue.md +55 -0
  71. package/decisions/technologies/r2.md +41 -0
  72. package/decisions/technologies/react-router.md +47 -0
  73. package/decisions/technologies/react.md +37 -0
  74. package/decisions/technologies/roj.md +51 -0
  75. package/decisions/technologies/sentry.md +49 -0
  76. package/decisions/technologies/tailwind.md +45 -0
  77. package/decisions/technologies/vite.md +47 -0
  78. package/decisions/technologies/vozka.md +122 -0
  79. package/decisions/technologies/workers-ai.md +59 -0
  80. package/decisions/technologies/wrangler.md +41 -0
  81. package/decisions/tooling/deptective.md +45 -0
  82. package/decisions/tooling/edvabe.md +45 -0
  83. package/index.ts +2 -0
  84. package/package.json +51 -0
  85. package/skill/SKILL.md +76 -0
  86. package/skill/agents/build-orchestrator.md +170 -0
  87. package/skill/agents/flows-author.md +65 -0
  88. package/skill/agents/implementer.md +37 -0
  89. package/skill/agents/openai.yaml +4 -0
  90. package/skill/agents/opice-skeleton-author.md +68 -0
  91. package/skill/gates/brief.jsx +20 -0
  92. package/skill/gates/build.jsx +20 -0
  93. package/skill/gates/design.jsx +22 -0
  94. package/skill/gates/flows.jsx +14 -0
  95. package/skill/gates/review-grid.jsx +32 -0
  96. package/skill/gates/tech.jsx +17 -0
  97. package/skill/gates/vision.jsx +27 -0
  98. package/stacks/README.md +44 -0
  99. package/stacks/cf-fat.md +64 -0
  100. package/stacks/cf-internal-app.md +125 -0
  101. package/stacks/cf-lightweight.md +54 -0
  102. package/stacks/contember-app.md +53 -0
  103. package/stacks/content-site.md +45 -0
  104. package/ui/dist/assets/annotations-runtime.js +1 -0
  105. package/ui/dist/assets/client.css +2 -0
  106. package/ui/dist/assets/client.fhjppkvr.js +214 -0
  107. package/ui/dist/assets/components.js +63 -0
  108. package/ui/dist/assets/preact-compat.js +1 -0
  109. package/ui/dist/assets/react-jsx-dev-runtime.js +3 -0
  110. package/ui/dist/assets/react-jsx-runtime.js +3 -0
  111. package/ui/dist/assets/runtime.js +1 -0
  112. package/ui/dist/index.html +36 -0
@@ -0,0 +1,221 @@
1
+ import { existsSync, readdirSync, statSync } from 'fs'
2
+ import { basename, dirname, join } from 'path'
3
+
4
+ export type PhaseId =
5
+ | 'intake'
6
+ | 'vision'
7
+ | 'flows'
8
+ | 'tech'
9
+ | 'design'
10
+ | 'skeletons'
11
+ | 'issues'
12
+ | 'build'
13
+
14
+ export interface Phase {
15
+ id: PhaseId
16
+ index: number
17
+ title: string
18
+ reads: string[]
19
+ produces: string[]
20
+ gate: boolean
21
+ gateName?: string
22
+ }
23
+
24
+ export const PHASES: Phase[] = [
25
+ {
26
+ id: 'intake',
27
+ index: 1,
28
+ title: 'Intake (brief → classification)',
29
+ reads: ['intent/brief.md'],
30
+ produces: ['intent/brief.md'],
31
+ gate: true,
32
+ gateName: 'intake',
33
+ },
34
+ {
35
+ id: 'vision',
36
+ index: 2,
37
+ title: 'Vision + scope + principles',
38
+ reads: ['intent/brief.md'],
39
+ produces: ['intent/glossary.md', 'principles/principles.md'],
40
+ gate: true,
41
+ gateName: 'vision',
42
+ },
43
+ {
44
+ id: 'flows',
45
+ index: 3,
46
+ title: 'User flows',
47
+ reads: ['intent/*', 'principles/*'],
48
+ produces: ['architecture/flows/*.flow.md'],
49
+ gate: true,
50
+ gateName: 'flows',
51
+ },
52
+ {
53
+ id: 'tech',
54
+ index: 4,
55
+ title: 'Tech + scaffold decision',
56
+ reads: ['architecture/flows/*'],
57
+ produces: ['architecture/tech.md'],
58
+ gate: true,
59
+ gateName: 'tech',
60
+ },
61
+ {
62
+ id: 'design',
63
+ index: 5,
64
+ title: 'Design tokens + mockups',
65
+ reads: ['architecture/flows/*', 'architecture/tech.md'],
66
+ produces: ['architecture/design.md', 'pages/*.jsx'],
67
+ gate: true,
68
+ gateName: 'design',
69
+ },
70
+ {
71
+ id: 'skeletons',
72
+ index: 6,
73
+ title: 'Opice skeletons',
74
+ reads: ['architecture/flows/*', 'architecture/spec/*'],
75
+ produces: ['../tests/browser/*.test.ts'],
76
+ gate: true,
77
+ gateName: 'skeletons',
78
+ },
79
+ {
80
+ id: 'issues',
81
+ index: 7,
82
+ title: 'Induce issues (compile step, not a real phase — SPEC §6.4)',
83
+ reads: ['../tests/browser/*'],
84
+ produces: ['issues/ISSUE-*.md'],
85
+ gate: false,
86
+ },
87
+ {
88
+ id: 'build',
89
+ index: 8,
90
+ title: 'Build (agent-assisted, supervised subagents)',
91
+ reads: ['issues/*'],
92
+ produces: [],
93
+ gate: true,
94
+ gateName: 'build',
95
+ },
96
+ ]
97
+
98
+ export interface PhaseState {
99
+ current: PhaseId
100
+ index: number
101
+ completed: PhaseId[]
102
+ reads: string[]
103
+ produces: string[]
104
+ gate: boolean
105
+ gateName?: string
106
+ satisfied: boolean
107
+ }
108
+
109
+ export interface GateOracle {
110
+ getLatestUnconsumedFeedback(sessionId: string): { revision: number; feedback: string } | null
111
+ hasGateRevision?(sessionId: string, gateName: string): boolean
112
+ }
113
+
114
+ function expandGlob(fabrikaDir: string, glob: string): string[] {
115
+ const abs = join(fabrikaDir, glob)
116
+ if (!abs.includes('*')) {
117
+ return existsSync(abs) ? [abs] : []
118
+ }
119
+ const dir = dirname(abs)
120
+ const pattern = basename(abs) // e.g. "*.flow.md", "ISSUE-*.md", "*"
121
+ if (!existsSync(dir)) return []
122
+
123
+ const re = globToRegExp(pattern)
124
+ const out: string[] = []
125
+ for (const name of readdirSync(dir)) {
126
+ if (re.test(name)) out.push(join(dir, name))
127
+ }
128
+ return out
129
+ }
130
+
131
+ function globToRegExp(pattern: string): RegExp {
132
+ const escaped = pattern
133
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
134
+ .replace(/\*/g, '.*')
135
+ return new RegExp(`^${escaped}$`)
136
+ }
137
+
138
+ function isNonEmptyFile(p: string): boolean {
139
+ try {
140
+ const s = statSync(p)
141
+ return s.isFile() && s.size > 0
142
+ } catch {
143
+ return false
144
+ }
145
+ }
146
+
147
+ export function producesSatisfied(phase: Phase, fabrikaDir: string): boolean {
148
+ if (phase.produces.length === 0) return false
149
+ for (const glob of phase.produces) {
150
+ const matches = expandGlob(fabrikaDir, glob).filter(isNonEmptyFile)
151
+ if (matches.length === 0) return false
152
+ }
153
+ return true
154
+ }
155
+
156
+ export function gateSatisfied(
157
+ phase: Phase,
158
+ sessionId: string,
159
+ oracle?: GateOracle,
160
+ ): boolean {
161
+ if (!phase.gate) return true
162
+ if (!oracle) return true
163
+ if (oracle.getLatestUnconsumedFeedback(sessionId) !== null) return false
164
+ if (oracle.hasGateRevision && phase.gateName) {
165
+ return oracle.hasGateRevision(sessionId, phase.gateName)
166
+ }
167
+ return true
168
+ }
169
+
170
+ function resolveReads(phase: Phase, fabrikaDir: string): string[] {
171
+ const out: string[] = []
172
+ for (const glob of phase.reads) {
173
+ out.push(
174
+ ...expandGlob(fabrikaDir, glob).filter(p => {
175
+ try {
176
+ return statSync(p).size > 0 || statSync(p).isDirectory()
177
+ } catch {
178
+ return false
179
+ }
180
+ }),
181
+ )
182
+ }
183
+ return out
184
+ }
185
+
186
+ export function derivePhaseState(
187
+ fabrikaDir: string,
188
+ sessionId = '',
189
+ oracle?: GateOracle,
190
+ ): PhaseState {
191
+ const completed: PhaseId[] = []
192
+
193
+ for (const phase of PHASES) {
194
+ if (producesSatisfied(phase, fabrikaDir) && gateSatisfied(phase, sessionId, oracle)) {
195
+ completed.push(phase.id)
196
+ continue
197
+ }
198
+ return {
199
+ current: phase.id,
200
+ index: phase.index,
201
+ completed,
202
+ reads: resolveReads(phase, fabrikaDir),
203
+ produces: phase.produces,
204
+ gate: phase.gate,
205
+ ...(phase.gateName ? { gateName: phase.gateName } : {}),
206
+ satisfied: producesSatisfied(phase, fabrikaDir),
207
+ }
208
+ }
209
+
210
+ const build = PHASES[PHASES.length - 1]!
211
+ return {
212
+ current: build.id,
213
+ index: build.index,
214
+ completed: completed.filter(id => id !== build.id),
215
+ reads: resolveReads(build, fabrikaDir),
216
+ produces: build.produces,
217
+ gate: build.gate,
218
+ ...(build.gateName ? { gateName: build.gateName } : {}),
219
+ satisfied: false,
220
+ }
221
+ }
@@ -0,0 +1,72 @@
1
+ import type { Route } from '@fabrika/daemon-kit'
2
+ import { dirname, join } from 'path'
3
+
4
+ const MIME_TYPES: Record<string, string> = {
5
+ js: 'application/javascript',
6
+ mjs: 'application/javascript',
7
+ css: 'text/css',
8
+ html: 'text/html',
9
+ svg: 'image/svg+xml',
10
+ png: 'image/png',
11
+ jpg: 'image/jpeg',
12
+ jpeg: 'image/jpeg',
13
+ gif: 'image/gif',
14
+ ico: 'image/x-icon',
15
+ woff: 'font/woff',
16
+ woff2: 'font/woff2',
17
+ json: 'application/json',
18
+ map: 'application/json',
19
+ }
20
+
21
+ function resolveDistDir(): string {
22
+ const override = process.env['FABRIKA_UI_DIST']
23
+ if (override) return override
24
+ return join(dirname(import.meta.dir), '..', 'ui', 'dist')
25
+ }
26
+
27
+ function mimeFor(path: string): string {
28
+ const ext = path.split('.').pop()?.toLowerCase() ?? ''
29
+ return MIME_TYPES[ext] ?? 'application/octet-stream'
30
+ }
31
+
32
+ export function createStaticHandlers(): Route[] {
33
+ const distDir = resolveDistDir()
34
+
35
+ async function serveSpaIndex(): Promise<Response> {
36
+ const file = Bun.file(join(distDir, 'index.html'))
37
+ if (await file.exists()) {
38
+ return new Response(file, {
39
+ headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' },
40
+ })
41
+ }
42
+ return new Response(
43
+ 'Builder dashboard not found. Reinstall @fabrika/builder or run its build script.',
44
+ { status: 503, headers: { 'Content-Type': 'text/plain' } },
45
+ )
46
+ }
47
+
48
+ async function serveAsset(_req: Request, url: URL): Promise<Response> {
49
+ const rel = url.pathname.replace(/^\//, '')
50
+ const assetPath = join(distDir, rel)
51
+ if (!assetPath.startsWith(distDir)) {
52
+ return new Response('Forbidden', { status: 403 })
53
+ }
54
+ const file = Bun.file(assetPath)
55
+ if (!(await file.exists())) return new Response('Not found', { status: 404 })
56
+ const basename = assetPath.slice(assetPath.lastIndexOf('/') + 1)
57
+ const isHashed = /\.[a-z0-9]{8,}\.[a-z0-9]+$/i.test(basename)
58
+ return new Response(file, {
59
+ headers: {
60
+ 'Content-Type': mimeFor(url.pathname),
61
+ 'Cache-Control': isHashed
62
+ ? 'public, max-age=31536000, immutable'
63
+ : 'no-cache',
64
+ },
65
+ })
66
+ }
67
+
68
+ return [
69
+ { method: 'GET', pattern: new URLPattern({ pathname: '/s/:id' }), handler: serveSpaIndex },
70
+ { method: 'GET', pattern: new URLPattern({ pathname: '/assets/*' }), handler: serveAsset },
71
+ ]
72
+ }
@@ -0,0 +1,133 @@
1
+ # Decisions catalog
2
+
3
+ A **catalog of atomic, self-contained stack decisions** plus thin **stack presets**
4
+ (`../stacks/`) that compose them. Replaces the old monolithic `profiles/*/profile.ts`
5
+ (where cf-fat had to copy ~80% of cf-lightweight): each choice is one referenceable
6
+ `.md`, and a stack is just a list of decision ids.
7
+
8
+ fabrika core stays **stack-neutral** and consumes this as data — a **curated
9
+ reference, not a code generator** (no templates). There is **no resolver code**: the
10
+ graph is consumed _pure-reference_ by the **tech phase** (`phases.ts` phase 4) — the
11
+ agent reads the selected stack's decisions, resolves the graph by reading, composes
12
+ the concrete stack into `.fabrika/architecture/tech.md`, and the human edits it at the gate.
13
+
14
+ This catalog is **not copied** into the target repo at `init`. It lives here in the
15
+ fabrika tree and is delivered by **absolute path**: `fabrika builder next` (tech) and `fabrika
16
+ build` resolve the active `profile` to `stacks/<profile>.md` + this `decisions/` dir
17
+ and emit them as a `Stack:` pointer in the packet (see `cli/src/catalog.ts`).
18
+
19
+ > **Team preference: Bun, lopata, non-vite.** When composing a stack, default to
20
+ > `bun` (runtime/toolchain) and `lopata` (local CF runtime, over `cloudflare-vite-plugin`),
21
+ > and prefer Bun-native bundling (`@buzola/bun-plugin`, pletivo's Bun build) over `vite`
22
+ > wherever a decision offers it. `vite` stays a scoped exception for libs that mandate it
23
+ > (React Router v7).
24
+
25
+ ## Two orthogonal axes: `kind` and `status`
26
+
27
+ Every decision is classified on two independent axes.
28
+
29
+ **`kind`** — _what sort of thing it is_ (and which subfolder it lives in):
30
+
31
+ | kind | folder | what it is | has a package? |
32
+ | ------------ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
33
+ | `technology` | `technologies/` | a concrete lib / service / framework you **adopt & install** (cloudflare, react, contember, pletivo, opice…) | yes (`npm`/binary) |
34
+ | `pattern` | `patterns/` | an architectural recipe you **apply** _over_ technologies — no package; it `requires` the technologies it composes (count-store, write-split, named-seeds, auth-password-kv) | no |
35
+ | `tooling` | `tooling/` | an **additive** dev tool outside the runtime stack (deptective, edvabe) — never part of a preset's closure | yes |
36
+
37
+ `kind` is **orthogonal to `slot`**: a pattern can still fill a slot (`named-seeds`→`seed-strategy`, `auth-password-kv`→`authentication`).
38
+
39
+ **`status`** — _how prod-ready it is_: `stable` | `wip` | `experimental`.
40
+
41
+ > **Selection policy.** The tech phase **defaults to `stable`**. `wip`/`experimental`
42
+ > are opt-in: surface them only when the goal specifically calls for one, flag them as
43
+ > not-prod-ready, and **never auto-recommend** them. A preset that includes a non-stable
44
+ > decision must say so.
45
+
46
+ ## A decision file
47
+
48
+ `decisions/<kind>/<id>.md` — ids are **globally unique** across the subfolders (a stack
49
+ references a decision by bare id, never by path). Frontmatter is the machine-readable
50
+ graph; the body is the human/agent reference (what it is, when/why, tradeoffs, impacts,
51
+ gotchas, sources). Patterns use the same schema minus `npm`, and their body says what
52
+ they **apply to** rather than how to install. Schema:
53
+
54
+ ```yaml
55
+ ---
56
+ id: <kebab-case> # == filename; globally unique
57
+ kind: technology|pattern|tooling
58
+ title: <human title>
59
+ slot: <slot|null> # exclusive slot it fills (router, platform, relational-db…).
60
+ # two decisions in the same slot conflict implicitly.
61
+ provides: [capability] # capability tags this decision fulfills
62
+ requires: [id, …] # HARD deps — must also be in the stack
63
+ recommends: [id, …] # SOFT suggestions — offer, don't force
64
+ conflicts: [id, …] # explicit incompatibilities (beyond same-slot)
65
+ npm: [pkg@^baseline, …] # technologies/tooling only; REFERENCE baseline (verify against installed)
66
+ status: stable|wip|experimental
67
+ sources: # live truth to READ before scaffolding (anti-staleness)
68
+ - where: <repo path / npm pkg / URL>
69
+ read: <what to read it for>
70
+ ---
71
+ ```
72
+
73
+ ## The three edge types (everything the graph needs)
74
+
75
+ - **`requires`** — hard dependency. Pick A ⇒ B must be in the stack. `vozka →
76
+ {cloudflare, oblaka, wrangler, propustka}`, `buzola → react`, `write-split → analytics-engine`.
77
+ - **`recommends`** — soft suggestion. Pick A ⇒ offer B. `cloudflare → d1, durable-objects`.
78
+ - **`conflicts`** — declared incompatibility. `buzola ✕ react-router` (both routers).
79
+ Often _implied_ by two decisions sharing an exclusive **`slot`**, so `conflicts` is
80
+ only written where same-slot detection isn't enough.
81
+
82
+ `provides` is the capability vocabulary the edges talk about; `slot` is the
83
+ single-occupancy version of a capability (a stack has one `router`, one `platform`).
84
+
85
+ ## Dangling references are fine
86
+
87
+ A `requires`/`recommends`/`conflicts` id that has no file yet is a **TODO marker**,
88
+ not an error — same convention as memory `[[links]]`.
89
+
90
+ ## Stack presets (`../stacks/`)
91
+
92
+ A preset is **thin**: a one-line goal + a list of decision ids (technologies + patterns;
93
+ tooling is additive, not part of closure). All gotchas/conventions live on the decisions.
94
+ See `../stacks/README.md`.
95
+
96
+ ## Catalog
97
+
98
+ ### Technologies (`technologies/`)
99
+
100
+ | slot / area | decisions |
101
+ | ---------------------------------- | -------------------------------------------------------------------------------- |
102
+ | platform / edge auth | `cloudflare`, `cloudflare-access` (legacy alternative to native Propustka) |
103
+ | relational store (`relational-db`) | `d1`, `postgres` |
104
+ | storage / state | `kv`, `r2`, `durable-objects`, `analytics-engine` |
105
+ | messaging / jobs | `queue`, `cron-triggers` |
106
+ | AI runtime | `workers-ai` |
107
+ | runtime / build / iac / deploy | `bun`, `vite`, `lopata`, `cloudflare-vite-plugin`, `oblaka`, `wrangler`, `vozka` |
108
+ | ui framework | `react`, `preact` |
109
+ | router | `react-router`, `buzola` |
110
+ | styling | `tailwind` |
111
+ | backend / data API (`backend`) | `contember`, `bindx` |
112
+ | site / content (`site-framework`) | `pletivo`, `nuasite` |
113
+ | agents (`agent-sdk`) | `roj` |
114
+ | native authn/authz | `propustka` |
115
+ | error tracking (`error-tracking`) | `sentry` |
116
+ | testing | `opice` |
117
+
118
+ ### Patterns (`patterns/`)
119
+
120
+ Applied _over_ the technologies they `require` (no package; `kind: pattern`).
121
+
122
+ | theme | patterns |
123
+ | ----------------------- | ------------------------------------------------------------------------------------------- |
124
+ | architecture / delivery | `monorepo`, `di-container`, `feature-architecture`, `single-worker-spa`, `typed-worker-rpc` |
125
+ | data / seeds | `named-seeds` |
126
+ | CF write patterns | `count-store`, `write-split` |
127
+ | auth | `auth-password-kv` |
128
+ | CI / CD | `app-deploy` (direct), `vozka-app-deploy` (control plane), `lib-publish` |
129
+ | observability | `structured-logging`, `observability` |
130
+
131
+ ### Tooling (`tooling/`)
132
+
133
+ `deptective`, `edvabe` — additive dev tools (`slot: null`), not part of preset closure.
@@ -0,0 +1,55 @@
1
+ ---
2
+ id: app-deploy
3
+ kind: pattern
4
+ title: Direct app deploy — push-to-branch → oblaka → wrangler (Cloudflare)
5
+ slot: null
6
+ provides: [ci-deploy, cf-deploy, infra-provisioning]
7
+ requires: [cloudflare, oblaka, wrangler]
8
+ recommends: []
9
+ conflicts: [vozka-app-deploy]
10
+ status: stable
11
+ sources:
12
+ - where: "opice — .github/workflows/deploy.yml (the canonical app-deploy workflow)"
13
+ read: "The branch triggers, oblaka --remote provisioning, D1 migrate, and wrangler deploy steps."
14
+ - where: "oblaka — README / CLAUDE.md (the `bunx oblaka <file> --remote --env=…` deploy story)"
15
+ read: "How oblaka provisions bindings + emits wrangler.jsonc, idempotently, keyed by a state namespace."
16
+ ---
17
+
18
+ # App deploy (Cloudflare)
19
+
20
+ ## What it is
21
+
22
+ A Cloudflare app deploys via **GitHub Actions on a branch push**: `main` → **stage**,
23
+ `deploy/prod` (or `prod`) → **production**, plus `workflow_dispatch` for manual runs.
24
+ The job runs **`bunx oblaka oblaka.ts --remote --env=<stage|prod>`** to provision
25
+ bindings (D1/KV/R2/DO/AE) via the CF API and emit `wrangler.jsonc`, then
26
+ **`wrangler d1 migrations apply DB --remote`**, then **`wrangler deploy`**. Auth is
27
+ `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` secrets; the GitHub `environment` is
28
+ chosen from the branch, with a concurrency lock per environment.
29
+
30
+ This is the **direct per-repository alternative** for accounts/apps not managed by
31
+ Vozka. Do not scaffold it alongside `vozka-app-deploy`.
32
+
33
+ ## When & why to use
34
+
35
+ - A Cloudflare app whose target account is **not** managed by Vozka — deploy is a
36
+ git push to the env branch, with infra-as-code and no dashboard clicking.
37
+ - Prefer `vozka-app-deploy` when the target account already has Vozka + Propustka.
38
+
39
+ ## When NOT to use
40
+
41
+ - Non-CF deploy targets (a Node/Postgres backend like `contember` deploys differently — container/VPS, not wrangler).
42
+
43
+ ## Gotchas
44
+
45
+ - **oblaka is idempotent via a state namespace** — it tracks resource IDs across runs; don't hand-create the same resources or you'll drift.
46
+ - **Migrations run at deploy time** (`--remote`), not before — a failed migration fails the deploy; keep them idempotent.
47
+ - **Per-env from the branch** — `main`→stage, `deploy/prod`→prod; the GitHub `environment` gates secrets + serializes concurrent deploys.
48
+ - Never hand-edit the generated `wrangler.jsonc` — change `oblaka.ts`.
49
+ - Never run this workflow and Vozka against the same target during normal
50
+ operation; their locks do not coordinate.
51
+
52
+ ## Relationships — why
53
+
54
+ - **requires `cloudflare` + `oblaka` + `wrangler`** — it's exactly the oblaka→wrangler.jsonc→wrangler-deploy chain on CF. Pairs with `lib-publish` (the other half of CI) in a monorepo.
55
+ - **conflicts `vozka-app-deploy`** — one app/environment must have one deploy owner.
@@ -0,0 +1,45 @@
1
+ ---
2
+ id: auth-password-kv
3
+ kind: pattern
4
+ title: Password + session-in-KV auth (app-owned)
5
+ slot: authentication
6
+ provides: [authentication, sessions, invites]
7
+ requires: [kv]
8
+ recommends: [d1]
9
+ conflicts: [cloudflare-access]
10
+ npm: []
11
+ status: stable
12
+ sources:
13
+ - where: "Pattern. Reference: the cf-fat stack (app/auth.ts) + WebCrypto PBKDF2/scrypt."
14
+ read: "Password hashing with WebCrypto, session token in a cookie backed by KV, invite tokens, route guards."
15
+ ---
16
+
17
+ # Password + session-in-KV auth
18
+
19
+ ## What it is
20
+
21
+ App-owned authentication: **password hashing** (WebCrypto PBKDF2/scrypt) + a
22
+ **session token in a cookie backed by KV** + admin bootstrap + invite tokens + route
23
+ guards. The simplest correct self-hosted login when you're not behind Cloudflare
24
+ Access.
25
+
26
+ ## When & why to use
27
+
28
+ - Self-hosted single-team tool with admin bootstrap + invites, no IdP/SSO needed in M1.
29
+ - You can't put users behind Access (e.g. external invitees) but don't need full OAuth.
30
+
31
+ ## When NOT to use
32
+
33
+ - You have an IdP / want managed SSO → `cloudflare-access`.
34
+ - Consumer product needing OAuth / magic-link at scale → a dedicated auth provider.
35
+
36
+ ## Impacts & gotchas
37
+
38
+ - **Never store plaintext** — hash with WebCrypto (PBKDF2/scrypt); compare in constant time.
39
+ - Session token in an HttpOnly cookie, validated against `kv` (with TTL); guard protected routes server-side.
40
+ - User records / membership in `d1`; session/invite ephemera in `kv`.
41
+
42
+ ## Relationships — why
43
+
44
+ - **requires `kv`** — session + invite tokens live there. **recommends `d1`** — user/membership records.
45
+ - **conflicts `cloudflare-access`** — both fill the `authentication` slot; app-owned password vs managed SSO. Pick one.
@@ -0,0 +1,44 @@
1
+ ---
2
+ id: count-store
3
+ kind: pattern
4
+ title: CountStore abstraction (counts: local vs Analytics Engine)
5
+ slot: null
6
+ provides: [count-abstraction]
7
+ requires: [d1]
8
+ recommends: [analytics-engine]
9
+ conflicts: []
10
+ npm: []
11
+ status: stable
12
+ sources:
13
+ - where: "Pattern (no library). Reference: the cf-fat stack + app/lib/count-store.ts it prescribes."
14
+ read: "The interface shape and the two impls (local/in-memory/D1 vs Analytics Engine)."
15
+ ---
16
+
17
+ # CountStore abstraction
18
+
19
+ ## What it is
20
+
21
+ A pattern, not a library: a `CountStore` interface — `record(event)` +
22
+ `query(filters) -> {fingerprint, count, first, last}` — with **two impls selected
23
+ by env**: a local one (in-memory in tests, D1 aggregation in dev) and an
24
+ **Analytics Engine** one (writes `env.AE.writeDataPoint`, queries the AE SQL HTTP
25
+ API with `SUM(_sample_interval)`) for prod.
26
+
27
+ ## When & why to use
28
+
29
+ - You chose `analytics-engine` for counts but need build + opice tests to run **locally with no CF account** (AE has no local runtime).
30
+ - Any "swap the metrics backend by env" requirement.
31
+
32
+ ## When NOT to use
33
+
34
+ - Counts are exact, low-volume, and fine to keep in D1 → just use `d1`, skip the abstraction.
35
+
36
+ ## Impacts & gotchas
37
+
38
+ - The interface is the contract; loaders/handlers depend on it, **never** call `env.AE` directly so dev/test never need a token.
39
+ - Local impl must produce the same shape the AE impl does (count + first/last seen), so tests assert behavior, not backend.
40
+
41
+ ## Relationships — why
42
+
43
+ - **requires `d1`** — the local/dev impl aggregates counts from a D1 table.
44
+ - **recommends `analytics-engine`** — the production backend this abstraction exists to hide. (It's also `analytics-engine`'s hard requirement, reciprocally.)
@@ -0,0 +1,47 @@
1
+ ---
2
+ id: di-container
3
+ kind: pattern
4
+ title: DI container / IoC (Proxy-based lazy-memoized container)
5
+ slot: null
6
+ provides: [dependency-injection, ioc, service-composition]
7
+ requires: []
8
+ recommends: []
9
+ conflicts: []
10
+ status: stable
11
+ sources:
12
+ - where: "MDN Proxy documentation"
13
+ read: "Language semantics for lazy getters, memoization, and circular-dependency detection."
14
+ - where: "Martin Fowler — Inversion of Control Containers and the Dependency Injection pattern"
15
+ read: "Composition-root ownership and dependency lifetimes."
16
+ ---
17
+
18
+ # DI container / IoC
19
+
20
+ ## What it is
21
+
22
+ A lightweight **dependency-injection container**: a class whose services are **getter
23
+ properties**; the constructor `return createContainer(this)` wraps it in a Proxy that
24
+ **memoizes each getter on first access** and **detects circular dependencies**. No
25
+ external lib, no decorators/reflection. Scope = container lifetime (one per request,
26
+ one per Durable Object). Eliminates `new Service(db, logger, clock, …)` chains — you
27
+ write `this.serviceA` and lazy resolution does the rest.
28
+
29
+ ## When & why to use
30
+
31
+ - Tightly-coupled services sharing resources (db, logger, config, clock) that should init once per request/actor.
32
+ - You want lazy instantiation (only used services are built) + cheap cycle detection, without a heavy framework.
33
+
34
+ ## When NOT to use
35
+
36
+ - You need constructor factory params, lifecycle hooks (`onDestroy`), or reflective metadata → a full container (inversify/awilix/tsyringe).
37
+
38
+ ## Gotchas
39
+
40
+ - **Constructor must be only `return createContainer(this)`** — no field assignments.
41
+ - **Use getters, not class fields** — an arrow-function field captures `this` before the Proxy wraps it and bypasses memoization/cycle detection.
42
+ - **Mocks via `setService()`** (test API) pin a service for the container's life.
43
+ - **Cycle errors are lazy** — thrown at access time, not container creation; an unused cycle stays hidden until that path runs.
44
+
45
+ ## Relationships — why
46
+
47
+ - A pure TS pattern (no tech deps). Pairs with `feature-architecture` (the container is where adapters get wired into domain services).
@@ -0,0 +1,48 @@
1
+ ---
2
+ id: feature-architecture
3
+ kind: pattern
4
+ title: Feature-split + light hexagonal (ports & adapters)
5
+ slot: null
6
+ provides: [feature-modules, hexagonal, ports-adapters, testable-domain]
7
+ requires: []
8
+ recommends: [di-container, opice]
9
+ conflicts: []
10
+ status: stable
11
+ sources:
12
+ - where: "AWS Prescriptive Guidance — hexagonal architecture pattern"
13
+ read: "Domain/application core, ports, adapters, and composition boundaries."
14
+ - where: "roj — packages/sdk/src/ports/ (session/event-store/runtime ports) + adapters"
15
+ read: "The same ports/adapters split for an agent SDK that runs on Bun, E2B, or local."
16
+ ---
17
+
18
+ # Feature-split + light hexagonal
19
+
20
+ ## What it is
21
+
22
+ The app is organized **by feature/domain** (`model/<feature>/` — pure logic, no
23
+ platform imports), platform concerns sit behind **ports** (`ports/` — interfaces:
24
+ storage, llm, actors, observability, schedule), implemented by **adapters**
25
+ (`adapters/cloudflare/` for prod, `adapters/inmemory/` for tests), and an **app/**
26
+ composition layer wires adapters into services (via the DI container) and exposes
27
+ transport (HTTP/RPC/cron). Dependency direction is enforced: **domain → ports;
28
+ adapters implement ports; nothing in `model/` imports an adapter or a CF value**.
29
+
30
+ ## When & why to use
31
+
32
+ - Multiple targets/backends (cloud vs local, CF vs in-process, swappable LLM/storage providers).
33
+ - You want the domain **testable without the platform SDK** — unit tests run on in-memory adapters (no CF account, fast).
34
+
35
+ ## When NOT to use
36
+
37
+ - Single platform forever, no local testing — the indirection isn't worth it. Only pays off with ≥2 implementations of a port.
38
+
39
+ ## Gotchas
40
+
41
+ - **Ports are interfaces** — don't add classes/subclasses; if it doesn't fit, add a port or an adapter method.
42
+ - **Adapters are implementation details** — tests know only the port; `instanceof MapObjectStore` is a leak.
43
+ - **Enforce the dependency direction in lint** — `model/**` may only `import type` from `cloudflare:workers`; never a value import or an `adapters/` import.
44
+ - **Port signatures are a contract** — once multiple adapters + call sites depend on one, changing it is a breaking change; lock it with contract tests early.
45
+
46
+ ## Relationships — why
47
+
48
+ - **recommends `di-container`** (wires adapters → domain services) and **`opice`** (the in-memory adapters make the running app cheap to drive in tests).