@ultimat3/cli 22.15.0 → 24.0.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.
- package/CLAUDE.md +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
// and the deterministic seed. No business logic — that is the package's own stated boundary, and it
|
|
3
3
|
// is why `example` reaches only the two files describing the slice's table.
|
|
4
4
|
//
|
|
5
|
-
// No migration. `x db gen` is the ONE writer of `packages/db/migrations
|
|
6
|
-
// wrote `0000_initial.sql` was a second one: it
|
|
7
|
-
// diffed, so the first `x db gen` saw a schema
|
|
8
|
-
// about what "initial" meant. `x db gen
|
|
9
|
-
// the `.sql`, the `.snapshot.json
|
|
5
|
+
// No migration and no schema dump. `x db gen` is the ONE writer of `packages/db/migrations` and
|
|
6
|
+
// of `packages/db/schema`, and a scaffold that hand-wrote `0000_initial.sql` was a second one: it
|
|
7
|
+
// declared a `posts` table the generator had never diffed, so the first `x db gen` saw a schema
|
|
8
|
+
// the ledger already claimed and the two disagreed about what "initial" meant. `x db gen
|
|
9
|
+
// "initial"` is the app's first command instead — it writes the `.sql`, the `.snapshot.json`, the
|
|
10
|
+
// `.hash` and the dump together, which no hand-written file can.
|
|
10
11
|
|
|
12
|
+
import { sortedImports } from './imports';
|
|
11
13
|
import type { GeneratedFile, NameSet } from './naming';
|
|
14
|
+
import { dbClientFiles } from './scaffold-db-client';
|
|
12
15
|
import { packageShapeFiles, workspacePackageJson } from './scaffold-package-shape';
|
|
13
16
|
|
|
14
17
|
const DESCRIPTION = 'Entity re-exports and SQL migrations, no business logic';
|
|
@@ -45,10 +48,14 @@ const dbPackage = (app: NameSet, example: boolean): string =>
|
|
|
45
48
|
: workspacePackageJson(app, 'db', DESCRIPTION);
|
|
46
49
|
|
|
47
50
|
const dbIndex =
|
|
48
|
-
(): string => `// Schema and
|
|
49
|
-
//
|
|
51
|
+
(): string => `// Schema, migrations and the typed handle — no business logic lives in this package.
|
|
52
|
+
// \`db\` is the handle \`./client\` builds over the app's entities: \`db.<table>\`, which is what a
|
|
53
|
+
// feature's repo.ts reads through. \`sql\` and \`withTransaction\` are @ultimat3/db's own, and the
|
|
54
|
+
// connection pool behind all three is one, sized by ROLE.
|
|
50
55
|
export type { DbClient, SqlFragment } from '@ultimat3/db';
|
|
51
|
-
export {
|
|
56
|
+
export { sql, withTransaction } from '@ultimat3/db';
|
|
57
|
+
export type { Db } from './client';
|
|
58
|
+
export { db, driver, selectDriver } from './client';
|
|
52
59
|
export * as schema from './schema';
|
|
53
60
|
`;
|
|
54
61
|
|
|
@@ -151,11 +158,86 @@ export const ${app.camel}Seed = defineSeed('${app.kebab}', async () => {
|
|
|
151
158
|
});
|
|
152
159
|
`;
|
|
153
160
|
|
|
161
|
+
const dbSeedTest = (app: NameSet, example: boolean): string =>
|
|
162
|
+
example
|
|
163
|
+
? `// The seed, run against the in-memory driver: the rows it writes, whose they are, and that a
|
|
164
|
+
// second run writes nothing. The org is the point — a row seeded under any other id is one the
|
|
165
|
+
// development viewer's tenant policy refuses, and the dashboard then counts zero.
|
|
166
|
+
${sortedImports([
|
|
167
|
+
`import { DEMO_ORG_ID } from '@${app.kebab}/web/shared/demo-org';`,
|
|
168
|
+
"import { createContext, runWithContext } from '@ultimat3/core';",
|
|
169
|
+
"import { testActor } from '@ultimat3/policy';",
|
|
170
|
+
"import { afterEach, expect, unitTest } from '@ultimat3/testing';",
|
|
171
|
+
])}
|
|
172
|
+
import { db, driver } from './client';
|
|
173
|
+
import { ${app.camel}Seed } from './seed';
|
|
174
|
+
|
|
175
|
+
/** The development viewer's org: every read below runs as an actor inside it. */
|
|
176
|
+
const asViewer = <T>(run: () => Promise<T>): Promise<T> =>
|
|
177
|
+
runWithContext(createContext({ actor: testActor('viewer', { orgId: DEMO_ORG_ID }).actor }), run);
|
|
178
|
+
|
|
179
|
+
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
180
|
+
afterEach(() => {
|
|
181
|
+
driver.reset?.();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
unitTest('the seed writes its posts under the demo org', async () => {
|
|
185
|
+
const run = await ${app.camel}Seed.run({ driver });
|
|
186
|
+
expect(run.metrics).toEqual({ inserted: 2, updated: 0, skipped: 0 });
|
|
187
|
+
const rows = await asViewer(() => db.posts.where({ orgId: DEMO_ORG_ID }).orderBy('title').all());
|
|
188
|
+
expect(rows.map((row) => row.title)).toEqual(['Hello ${app.pascal}', 'Second post']);
|
|
189
|
+
expect(rows.map((row) => row.price.minor)).toEqual([0, 1900]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
unitTest('a replay writes nothing: its ids are derived from labels, never generated', async () => {
|
|
193
|
+
await ${app.camel}Seed.run({ driver });
|
|
194
|
+
const replay = await ${app.camel}Seed.run({ driver });
|
|
195
|
+
expect(replay.metrics).toEqual({ inserted: 0, updated: 0, skipped: 2 });
|
|
196
|
+
});
|
|
197
|
+
`
|
|
198
|
+
: `// The seed as \`x db seed\` finds it: a declared \`defineSeed()\` under this app's name, which
|
|
199
|
+
// writes nothing until an entity exists. The day it inserts a row, the last assertion says so.
|
|
200
|
+
import { isSeed } from '@ultimat3/entity';
|
|
201
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
202
|
+
import { driver } from './client';
|
|
203
|
+
import { ${app.camel}Seed } from './seed';
|
|
204
|
+
|
|
205
|
+
unitTest('the seed is one x db seed discovers, in the dev tier', () => {
|
|
206
|
+
expect(isSeed(${app.camel}Seed)).toBe(true);
|
|
207
|
+
expect(${app.camel}Seed.name).toBe('${app.kebab}');
|
|
208
|
+
// \`dev\`: fixture data, never loaded into production. \`reference\` is the other tier.
|
|
209
|
+
expect(${app.camel}Seed.tier).toBe('dev');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
unitTest('it writes nothing yet', async () => {
|
|
213
|
+
const run = await ${app.camel}Seed.run({ driver });
|
|
214
|
+
expect(run.metrics).toEqual({ inserted: 0, updated: 0, skipped: 0 });
|
|
215
|
+
});
|
|
216
|
+
`;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `packages/db/schema/` is written by `x db gen` and held by the `drift` step, so two things about
|
|
220
|
+
* it are git's to know. `linguist-generated` collapses it in a review — the migration is what a
|
|
221
|
+
* human reads; the dump is its projection. `text eol=lf` is not cosmetic: the gate compares bytes,
|
|
222
|
+
* and a checkout that rewrote line endings would be `X_SCHEMA_DUMP_DRIFT` on a correct tree.
|
|
223
|
+
*
|
|
224
|
+
* The attributes ship; the directory does not. `x new` scaffolds no migration, so there is nothing
|
|
225
|
+
* to dump until the first `x db gen` — which creates it.
|
|
226
|
+
*/
|
|
227
|
+
export const DB_GITATTRIBUTES = `# Generated by \`x db gen\` from the migrations and held equal to them by \`x verify\`'s drift
|
|
228
|
+
# step (X_SCHEMA_DUMP_DRIFT). Review the migration, not this projection of it.
|
|
229
|
+
schema/** linguist-generated=true text eol=lf
|
|
230
|
+
`;
|
|
231
|
+
|
|
154
232
|
/** Every file the `packages/db` workspace ships, in the order `x new` writes them. */
|
|
155
233
|
export const dbPackageFiles = (app: NameSet, example: boolean): readonly GeneratedFile[] => [
|
|
156
234
|
{ path: 'packages/db/package.json', contents: dbPackage(app, example) },
|
|
235
|
+
{ path: 'packages/db/.gitattributes', contents: DB_GITATTRIBUTES },
|
|
157
236
|
...packageShapeFiles(app, 'db', DESCRIPTION),
|
|
158
237
|
{ path: 'packages/db/src/index.ts', contents: dbIndex() },
|
|
238
|
+
// The typed handle and its test — `scaffold-db-client.ts`, shared with `x g entity`.
|
|
239
|
+
...dbClientFiles(app, example),
|
|
159
240
|
{ path: 'packages/db/src/schema.ts', contents: dbSchema(app, example) },
|
|
160
241
|
{ path: 'packages/db/src/seed.ts', contents: dbSeed(app, example) },
|
|
242
|
+
{ path: 'packages/db/src/seed.test.ts', contents: dbSeedTest(app, example) },
|
|
161
243
|
];
|
|
@@ -22,14 +22,23 @@ not exist — five of these had an empty column and were each measured green on
|
|
|
22
22
|
| Rule | Detail | Refused by |
|
|
23
23
|
|---|---|---|
|
|
24
24
|
| One gate | \`bin/check\` — \`x build\` and then \`x verify\`. Green means shippable; never merge red. | the gate itself, and \`.github/workflows/ci.yml\` on every push and pull request |
|
|
25
|
+
| Coverage | the unit suite covers 95% of the lines and functions of this app's own source — every file under \`apps/\` and \`packages/\`, one no test loads counted at 0%. A floor, not the goal: a test added to raise it is proven by mutation — break the source, watch it go red, restore | \`X_COVERAGE_BELOW_FLOOR\` on the \`unit\` step; \`coverage\` in \`x.verify.json\` states the floor, and it only rises |
|
|
25
26
|
| One way | generators, not hand-rolled files: \`x g resource\`, \`x g action\`, \`x g route\` | review |
|
|
26
27
|
| Surfaces | \`site/\` is 0kb JS and may not import \`app/\`; \`shared/\` is a leaf | \`X_BOUNDARY_SITE_TO_APP\` |
|
|
27
28
|
| Data | routes call actions and queries; only \`repo.ts\` touches the database | \`X_BOUNDARY_ROUTE_TO_DB\` |
|
|
29
|
+
| Repos | a \`repo.ts\` reads through the typed handle, never a \`sql\` literal the handle can express | \`guards/repo-raw-sql.ts\` |
|
|
28
30
|
| Errors | never \`throw new Error\` — subclass \`UltimateError\` with a code, a cause and a fix | \`guards/bare-error.ts\` |
|
|
29
31
|
| Money | \`{ minor, currency }\`, never a float — \`money()\` on the column | the type: \`price: 19.99\` is TS2322, \`number\` is not \`MoneyInput\` |
|
|
30
32
|
| Time | store UTC, format with an explicit IANA time zone | \`guards/unzoned-date.ts\` |
|
|
31
33
|
| Strings | every user-facing string goes through \`t()\` | \`guards/untranslated-string.ts\` |
|
|
32
34
|
| Colour | semantic tokens only, never a raw hex | \`guards/raw-colour.ts\` |
|
|
35
|
+
| Lengths | a length is a token or \`rem()\`, never a raw \`px\` | \`guards/raw-length.ts\` |
|
|
36
|
+
| Breakpoints | a viewport query names a breakpoint rung (\`respond-up\`, \`respond-down\`, \`respond-between\`), never a width | \`guards/raw-breakpoint.ts\` |
|
|
37
|
+
| Layers | a \`z-index\` is a named layer, never a number | \`guards/raw-z-index.ts\` |
|
|
38
|
+
| Shadows | a shadow comes off the shadow scale, never a hand-written offset and blur | \`guards/raw-shadow.ts\` |
|
|
39
|
+
| Timing | a duration and an easing curve come off the motion scale, never a literal | \`guards/raw-motion.ts\` |
|
|
40
|
+
| Classes | a class a component reads off its stylesheet is one that sheet compiles | \`guards/undefined-style-class.ts\` |
|
|
41
|
+
| Properties | a custom property a stylesheet reads is one the app declares | \`guards/undeclared-custom-property.ts\` |
|
|
33
42
|
| Interaction | a click is answered by a control — never a \`<div onClick>\`, and never a \`role=\` where the native tag exists | \`guards/semantic-interactive.ts\` |
|
|
34
43
|
| Focus | \`outline: none\` replaces the ring in the same rule or the one beside it, or it does not remove it | \`guards/focus-visible.ts\` |
|
|
35
44
|
| Images | every image carries width + height or an aspect-ratio, and the priority one is never \`loading="lazy"\` | \`guards/image-dimensions.ts\` |
|
|
@@ -51,6 +60,23 @@ unit of review, and \`x verify\` refuses it. The one exemption is a file that is
|
|
|
51
60
|
it has one job by construction, and its length tracks the API's size rather than its complexity;
|
|
52
61
|
one statement of logic in such a file re-arms the ceiling on the same save.
|
|
53
62
|
|
|
63
|
+
Coverage counts execution, not validation: 100% is reachable with zero assertions, and a covered
|
|
64
|
+
branch whose test cannot fail is worse than an uncovered one, because it reads as done. An
|
|
65
|
+
\`exclude\` entry under \`coverage\` is for code a unit test cannot execute — an island's
|
|
66
|
+
browser-only mount, a container entry point — carries its \`why\`, and is printed by \`x doctor\`.
|
|
67
|
+
|
|
68
|
+
Every test the scaffold and the generators write is the idiom to copy. All of them run on the
|
|
69
|
+
\`unit\` step — in process, on the in-memory driver, with no database, browser or server:
|
|
70
|
+
|
|
71
|
+
| What | How its test runs it | Copy from |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| a page | \`renderRoute(page, { url, actor })\` — \`load\`, \`meta\` and the page on one resolution; assert on \`view.text\`, \`view.html\`, \`view.meta\`, \`view.islands\` | \`apps/web/app/dashboard/page.test.ts\` |
|
|
74
|
+
| a component | \`renderView(Component, props)\` | \`apps/web/shared/shell.test.ts\` |
|
|
75
|
+
| an island | \`mountIsland\`, once per state its \`*.island.states.ts\` declares | \`apps/web/shared/theme-toggle.island.test.ts\` |
|
|
76
|
+
| an action or a query | \`target.as(actor, input)\` — the refusal first, then what it answers | \`apps/web/api/health.test.ts\` |
|
|
77
|
+
| a job, a task, a backfill | \`test('…', async ({ runJobs }) => …)\` — a worker in this process; assert on \`trace.steps\` and each run's \`outcome\` | what \`x g job\` writes |
|
|
78
|
+
| a repo, a seed | as an actor, \`runWithContext(createContext({ actor }), …)\`, with \`driver.reset?.()\` after each | \`packages/db/src/seed.test.ts\` |
|
|
79
|
+
|
|
54
80
|
Money is the one row with no guard, deliberately: a float has no static signature a text rule can
|
|
55
81
|
see, and the type already fires — measured, \`price: 19.99\` in a seed is
|
|
56
82
|
\`TS2322: Type 'number' is not assignable to type 'MoneyInput'\`. A guard that pretended to check
|
|
@@ -86,6 +112,9 @@ ${app.kebab} — Ultimate app. Read AGENTS.md first; it is the same content in t
|
|
|
86
112
|
- Scaffold, do not hand-write: \`x g <kind> <name>\` — \`x g --help\` lists every kind, and is the
|
|
87
113
|
only place that list is stated.
|
|
88
114
|
- Destructive DB work goes in a branch: \`x db branch create <name>\`, never the shared dev DB.
|
|
115
|
+
- \`x db gen\` also writes \`packages/db/schema/\` — the whole schema as SQL, one file per table.
|
|
116
|
+
Read it; never edit it. \`x verify\` fails with \`X_SCHEMA_DUMP_DRIFT\` when it is not what the
|
|
117
|
+
migrations produce, and \`x db gen\` rewrites it.
|
|
89
118
|
- \`x doctor\` explains a broken environment and prints the fix command for every finding.
|
|
90
119
|
|
|
91
120
|
\`.claude/\` holds the harness that reads this file: \`/feature\`, \`/planx\`, \`/verify\` and four
|
|
@@ -182,7 +211,12 @@ exec bunx x verify "$@"
|
|
|
182
211
|
|
|
183
212
|
const composeDev = (
|
|
184
213
|
app: NameSet,
|
|
185
|
-
): string => `# Optional: x dev needs none of this. Use it when you want
|
|
214
|
+
): string => `# Optional: x dev needs none of this. Use it when you want a real Postgres, NATS and S3 locally:
|
|
215
|
+
#
|
|
216
|
+
# docker compose -f docker/docker-compose.dev.yml up -d
|
|
217
|
+
# DATABASE_URL=postgres://postgres:${app.kebab}@127.0.0.1:5432/${app.kebab} NATS_URL=nats://127.0.0.1:4222 \\
|
|
218
|
+
# S3_ENDPOINT=http://127.0.0.1:9000 S3_BUCKET=${app.kebab} S3_FORCE_PATH_STYLE=1 \\
|
|
219
|
+
# S3_ACCESS_KEY_ID=${app.kebab} S3_SECRET_ACCESS_KEY=${app.kebab}-dev x dev
|
|
186
220
|
#
|
|
187
221
|
# Every published port binds 127.0.0.1, not 0.0.0.0. This stack ships its credentials in the file,
|
|
188
222
|
# as a dev stack reasonably does — so the short form \`'5432:5432'\` would put an authenticated
|
|
@@ -204,13 +238,24 @@ services:
|
|
|
204
238
|
image: nats:2-alpine
|
|
205
239
|
command: ['-js']
|
|
206
240
|
ports: ['127.0.0.1:4222:4222']
|
|
241
|
+
# Versity S3 Gateway over a directory: a directory under /data is a bucket, so the volume mounted
|
|
242
|
+
# at /data/${app.kebab} IS the bucket — ready on the first \`up\`, no CreateBucket call. It checks
|
|
243
|
+
# the region a request was signed for, and an app that sets no S3_REGION signs for \`auto\` — so
|
|
244
|
+
# that is this gateway's region. A server for a laptop; production is any S3-compatible endpoint.
|
|
207
245
|
s3:
|
|
208
|
-
image:
|
|
209
|
-
command: ['server', '/data']
|
|
246
|
+
image: versity/versitygw:v1.8.0
|
|
210
247
|
environment:
|
|
211
|
-
|
|
212
|
-
|
|
248
|
+
VGW_BACKEND: posix
|
|
249
|
+
VGW_BACKEND_ARG: /data
|
|
250
|
+
VGW_PORT: ':9000'
|
|
251
|
+
VGW_REGION: auto
|
|
252
|
+
ROOT_ACCESS_KEY: ${app.kebab}
|
|
253
|
+
ROOT_SECRET_KEY: ${app.kebab}-dev
|
|
213
254
|
ports: ['127.0.0.1:9000:9000']
|
|
255
|
+
volumes: ['s3bucket:/data/${app.kebab}']
|
|
256
|
+
|
|
257
|
+
volumes:
|
|
258
|
+
s3bucket:
|
|
214
259
|
`;
|
|
215
260
|
|
|
216
261
|
/** Docs, shims and container files for a new app, in the order a reader meets them. */
|
|
@@ -11,31 +11,74 @@
|
|
|
11
11
|
// about: a control a keyboard cannot reach, a focus ring taken away and not replaced, an image with
|
|
12
12
|
// no box, an animation the compositor cannot run, and an island nobody has seen fail. Each is
|
|
13
13
|
// statically decidable from the file alone — the reason those five and not the many that are not.
|
|
14
|
+
//
|
|
15
|
+
// The seven after those are about the STYLESHEET, which Biome does not read at all: a length, a
|
|
16
|
+
// breakpoint, a layer, a shadow and a tempo off their scales, a class the sheet never compiled and
|
|
17
|
+
// a custom property nothing declares. Until they shipped, raw colour was the only one of the
|
|
18
|
+
// design system's rules a scaffolded app's gate held.
|
|
19
|
+
//
|
|
20
|
+
// And one is about DATA: a `repo.ts` reads through the typed handle. It ships because the generator
|
|
21
|
+
// used to emit string SQL, and every app that started from it copied the shape.
|
|
14
22
|
|
|
15
23
|
import { animatedLayoutPropertyGuardFiles } from './guard-animated-layout-property';
|
|
16
24
|
import { bareErrorGuardFiles } from './guard-bare-error';
|
|
17
25
|
import { focusVisibleGuardFiles } from './guard-focus-visible';
|
|
18
26
|
import { imageDimensionsGuardFiles } from './guard-image-dimensions';
|
|
19
27
|
import { islandWithoutStatesGuardFiles } from './guard-island-without-states';
|
|
28
|
+
import { rawBreakpointGuardFiles } from './guard-raw-breakpoint';
|
|
20
29
|
import { rawColourGuardFiles } from './guard-raw-colour';
|
|
30
|
+
import { rawLengthGuardFiles } from './guard-raw-length';
|
|
31
|
+
import { rawMotionGuardFiles } from './guard-raw-motion';
|
|
32
|
+
import { rawShadowGuardFiles } from './guard-raw-shadow';
|
|
33
|
+
import { rawZIndexGuardFiles } from './guard-raw-z-index';
|
|
34
|
+
import { repoRawSqlGuardFiles } from './guard-repo-raw-sql';
|
|
21
35
|
import { semanticInteractiveGuardFiles } from './guard-semantic-interactive';
|
|
36
|
+
import { undeclaredCustomPropertyGuardFiles } from './guard-undeclared-custom-property';
|
|
37
|
+
import { undefinedStyleClassGuardFiles } from './guard-undefined-style-class';
|
|
22
38
|
import { untranslatedStringGuardFiles } from './guard-untranslated-string';
|
|
23
39
|
import { unzonedDateGuardFiles } from './guard-unzoned-date';
|
|
24
40
|
import type { GeneratedFile } from './naming';
|
|
25
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Every guard the framework ships, by the name its file carries — the ONE list. `x new` writes all
|
|
44
|
+
* of them (`scaffoldGuardFiles`), `x g guard <name>` writes the one it is asked for by name
|
|
45
|
+
* (`shippedGuardFiles`), and `x doctor` names the ones an app does not have. Sorted, so the three
|
|
46
|
+
* agree on an order. A new shipped guard is one module beside these and one row here.
|
|
47
|
+
*/
|
|
48
|
+
const SHIPPED: readonly (readonly [string, () => readonly GeneratedFile[]])[] = [
|
|
49
|
+
['animated-layout-property', animatedLayoutPropertyGuardFiles],
|
|
50
|
+
['bare-error', bareErrorGuardFiles],
|
|
51
|
+
['focus-visible', focusVisibleGuardFiles],
|
|
52
|
+
['image-dimensions', imageDimensionsGuardFiles],
|
|
53
|
+
['island-without-states', islandWithoutStatesGuardFiles],
|
|
54
|
+
['raw-breakpoint', rawBreakpointGuardFiles],
|
|
55
|
+
['raw-colour', rawColourGuardFiles],
|
|
56
|
+
['raw-length', rawLengthGuardFiles],
|
|
57
|
+
['raw-motion', rawMotionGuardFiles],
|
|
58
|
+
['raw-shadow', rawShadowGuardFiles],
|
|
59
|
+
['raw-z-index', rawZIndexGuardFiles],
|
|
60
|
+
['repo-raw-sql', repoRawSqlGuardFiles],
|
|
61
|
+
['semantic-interactive', semanticInteractiveGuardFiles],
|
|
62
|
+
['undeclared-custom-property', undeclaredCustomPropertyGuardFiles],
|
|
63
|
+
['undefined-style-class', undefinedStyleClassGuardFiles],
|
|
64
|
+
['untranslated-string', untranslatedStringGuardFiles],
|
|
65
|
+
['unzoned-date', unzonedDateGuardFiles],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** The names, in the order every list of them is shown. */
|
|
69
|
+
export const SHIPPED_GUARD_NAMES: readonly string[] = SHIPPED.map(([name]) => name);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The shipped guard of that name with its test, or `undefined` when the name is not one — which
|
|
73
|
+
* is an app's own convention, and `x g guard` then writes the blank template instead.
|
|
74
|
+
*/
|
|
75
|
+
export const shippedGuardFiles = (name: string): readonly GeneratedFile[] | undefined =>
|
|
76
|
+
SHIPPED.find(([shipped]) => shipped === name)?.[1]();
|
|
77
|
+
|
|
26
78
|
/**
|
|
27
79
|
* Every guard `x new` ships, each with its test. One module per guard, because an app deletes a
|
|
28
80
|
* rule it does not want by deleting one file — and a guard the app then writes for itself is
|
|
29
81
|
* `x g guard <name>`, the same shape.
|
|
30
82
|
*/
|
|
31
|
-
export const scaffoldGuardFiles = (): readonly GeneratedFile[] =>
|
|
32
|
-
|
|
33
|
-
...bareErrorGuardFiles(),
|
|
34
|
-
...focusVisibleGuardFiles(),
|
|
35
|
-
...imageDimensionsGuardFiles(),
|
|
36
|
-
...islandWithoutStatesGuardFiles(),
|
|
37
|
-
...rawColourGuardFiles(),
|
|
38
|
-
...semanticInteractiveGuardFiles(),
|
|
39
|
-
...untranslatedStringGuardFiles(),
|
|
40
|
-
...unzonedDateGuardFiles(),
|
|
41
|
-
];
|
|
83
|
+
export const scaffoldGuardFiles = (): readonly GeneratedFile[] =>
|
|
84
|
+
SHIPPED.flatMap(([, files]) => files());
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// What a compromised pod of the scaffolded chart can reach: its `/tmp`, bounded, and its ports,
|
|
2
|
+
// fenced by one NetworkPolicy per role. The framework chart's own (`docker/helm/`), mirrored —
|
|
3
|
+
// `scaffold-helm-parity.test.ts` holds the two to each other.
|
|
4
|
+
|
|
5
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
6
|
+
|
|
7
|
+
const volumes = (app: NameSet): string => `{{/*
|
|
8
|
+
The writable scratch every pod mounts at /tmp — the root filesystem is read-only. Bounded, because
|
|
9
|
+
an emptyDir with no sizeLimit is node disk: one role filling /tmp evicts its NEIGHBOURS for disk
|
|
10
|
+
pressure, where with a limit the kubelet evicts that pod alone.
|
|
11
|
+
*/}}
|
|
12
|
+
{{- define "${app.kebab}.tmpVolume" -}}
|
|
13
|
+
- name: tmp
|
|
14
|
+
emptyDir:
|
|
15
|
+
sizeLimit: {{ required "tmp.sizeLimit is unset — set it in your values (e.g. 512Mi): an unbounded /tmp is node disk" (default dict .Values.tmp).sizeLimit | quote }}
|
|
16
|
+
{{- end -}}
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const networkPolicy = (app: NameSet): string => `{{/*
|
|
20
|
+
One NetworkPolicy per enabled role: \`http\` (web, sync) from networkPolicy.httpFrom, \`metrics\`
|
|
21
|
+
(every role) from networkPolicy.metricsFrom, nothing else. A \`from\` that renders empty is omitted,
|
|
22
|
+
which Kubernetes reads as "any source". Egress is filtered only when networkPolicy.egress lists
|
|
23
|
+
rules — the database, NATS and object store are your addresses, so list DNS too. Serving roles
|
|
24
|
+
only: the migrate Job is a pre-install hook that runs before this exists, and opens no port.
|
|
25
|
+
*/}}
|
|
26
|
+
{{- if .Values.networkPolicy.enabled }}
|
|
27
|
+
{{- range $role, $cfg := .Values.roles }}
|
|
28
|
+
{{- if $cfg.enabled }}
|
|
29
|
+
---
|
|
30
|
+
apiVersion: networking.k8s.io/v1
|
|
31
|
+
kind: NetworkPolicy
|
|
32
|
+
metadata:
|
|
33
|
+
name: {{ include "${app.kebab}.fullname" $ }}-{{ $role }}
|
|
34
|
+
labels:
|
|
35
|
+
{{- include "${app.kebab}.labels" $ | nindent 4 }}
|
|
36
|
+
app.kubernetes.io/component: {{ $role }}
|
|
37
|
+
spec:
|
|
38
|
+
podSelector:
|
|
39
|
+
matchLabels:
|
|
40
|
+
app.kubernetes.io/instance: {{ $.Release.Name }}
|
|
41
|
+
app.kubernetes.io/component: {{ $role }}
|
|
42
|
+
policyTypes:
|
|
43
|
+
- Ingress
|
|
44
|
+
{{- if $.Values.networkPolicy.egress }}
|
|
45
|
+
- Egress
|
|
46
|
+
{{- end }}
|
|
47
|
+
ingress:
|
|
48
|
+
{{- if $cfg.port }}
|
|
49
|
+
- ports:
|
|
50
|
+
- port: http
|
|
51
|
+
protocol: TCP
|
|
52
|
+
{{- with $.Values.networkPolicy.httpFrom }}
|
|
53
|
+
from: {{- toYaml . | nindent 8 }}
|
|
54
|
+
{{- end }}
|
|
55
|
+
{{- end }}
|
|
56
|
+
- ports:
|
|
57
|
+
- port: metrics
|
|
58
|
+
protocol: TCP
|
|
59
|
+
{{- with $.Values.networkPolicy.metricsFrom }}
|
|
60
|
+
from: {{- toYaml . | nindent 8 }}
|
|
61
|
+
{{- end }}
|
|
62
|
+
{{- with $.Values.networkPolicy.egress }}
|
|
63
|
+
egress: {{- toYaml . | nindent 4 }}
|
|
64
|
+
{{- end }}
|
|
65
|
+
{{- end }}
|
|
66
|
+
{{- end }}
|
|
67
|
+
{{- end }}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
/** The bounded /tmp helper and the per-role NetworkPolicy, as two files of the chart's templates. */
|
|
71
|
+
export function helmFenceFiles(app: NameSet): readonly GeneratedFile[] {
|
|
72
|
+
return [
|
|
73
|
+
{ path: 'docker/helm/templates/_volumes.tpl', contents: volumes(app) },
|
|
74
|
+
{ path: 'docker/helm/templates/networkpolicy.yaml', contents: networkPolicy(app) },
|
|
75
|
+
];
|
|
76
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Split from scaffold-helm.ts, which holds the chart's INPUTS (Chart.yaml, values.yaml).
|
|
4
4
|
|
|
5
5
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
|
+
import { helmFenceFiles } from './scaffold-helm-fences';
|
|
6
7
|
|
|
7
8
|
const helpers = (
|
|
8
9
|
app: NameSet,
|
|
@@ -79,13 +80,28 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
|
|
|
79
80
|
- name: METRICS_PORT
|
|
80
81
|
value: {{ $root.Values.metricsPort | quote }}
|
|
81
82
|
{{- end }}
|
|
83
|
+
{{- /*
|
|
84
|
+
The sync node's page origin, from the one place this chart already knows it. Undeclared, a node
|
|
85
|
+
reached over plain http behind this Ingress admits BOTH spellings of its host — the plain-http
|
|
86
|
+
page included. env.APP_URL wins when set; an APP_URL kept in the Secret is shadowed for this
|
|
87
|
+
role, so pages served from another host than the Ingress's must say so in env.
|
|
88
|
+
*/}}
|
|
89
|
+
{{- if and (eq $role "sync") $root.Values.ingress.enabled (not (hasKey $root.Values.env "APP_URL")) }}
|
|
90
|
+
- name: APP_URL
|
|
91
|
+
value: {{ printf "%s://%s" (ternary "https" "http" $root.Values.ingress.tls) $root.Values.ingress.host | quote }}
|
|
92
|
+
{{- end }}
|
|
82
93
|
{{- range $key, $value := $root.Values.env }}
|
|
83
94
|
- name: {{ $key }}
|
|
84
95
|
value: {{ $value | quote }}
|
|
85
96
|
{{- end }}
|
|
97
|
+
{{- /*
|
|
98
|
+
The Secret a role reads, per role: roles.<role>.existingSecret (or migrate.existingSecret) names a
|
|
99
|
+
narrower one than the release-wide existingSecret, so the migrate Job's schema-owning credential
|
|
100
|
+
need not sit on a web pod. Both unset is a render error, not a pod with no configuration.
|
|
101
|
+
*/}}
|
|
86
102
|
envFrom:
|
|
87
103
|
- secretRef:
|
|
88
|
-
name: {{ $root.Values.existingSecret }}
|
|
104
|
+
name: {{ required (printf "no Secret for role %s — set existingSecret, or roles.%s.existingSecret, in your values" $role $role) (default $root.Values.existingSecret $cfg.existingSecret) }}
|
|
89
105
|
{{- if or $cfg.port $scraped }}
|
|
90
106
|
ports:
|
|
91
107
|
{{- if $cfg.port }}
|
|
@@ -100,8 +116,10 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
|
|
|
100
116
|
{{/*
|
|
101
117
|
Probes follow the role, because the roles do not agree on what they open. web and sync serve
|
|
102
118
|
HTTP and get both. worker, scheduler and replicator open no HTTP socket at all — the scrape
|
|
103
|
-
listener is their only port — so they take liveness on it
|
|
104
|
-
|
|
119
|
+
listener is their only port — so they take liveness on it. Readiness for replicator alone, on
|
|
120
|
+
/readyz of that listener: 503 while the replication stream is down, so the pod shows 0/1 instead
|
|
121
|
+
of a healthy pod feeding nothing. Their headless Service publishes not-ready addresses, so a
|
|
122
|
+
not-ready pod is still scraped.
|
|
105
123
|
|
|
106
124
|
Every one of them takes a startupProbe too, because no listener opens early: the server builds
|
|
107
125
|
the app's islands before any role binds a port, and a liveness probe counting from container
|
|
@@ -134,6 +152,11 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
|
|
|
134
152
|
httpGet: { path: /metrics, port: metrics }
|
|
135
153
|
periodSeconds: 5
|
|
136
154
|
failureThreshold: 30
|
|
155
|
+
{{- if eq $role "replicator" }}
|
|
156
|
+
readinessProbe:
|
|
157
|
+
httpGet: { path: '/readyz?deep=1', port: metrics }
|
|
158
|
+
periodSeconds: 10
|
|
159
|
+
{{- end }}
|
|
137
160
|
livenessProbe:
|
|
138
161
|
httpGet: { path: /metrics, port: metrics }
|
|
139
162
|
periodSeconds: 15
|
|
@@ -194,8 +217,7 @@ spec:
|
|
|
194
217
|
containers:
|
|
195
218
|
{{- include "${app.kebab}.container" (dict "role" $role "cfg" $cfg "root" $) | nindent 8 }}
|
|
196
219
|
volumes:
|
|
197
|
-
-
|
|
198
|
-
emptyDir: {}
|
|
220
|
+
{{- include "${app.kebab}.tmpVolume" $ | nindent 8 }}
|
|
199
221
|
{{- end }}
|
|
200
222
|
{{- end }}
|
|
201
223
|
`;
|
|
@@ -219,6 +241,9 @@ spec:
|
|
|
219
241
|
type: ClusterIP
|
|
220
242
|
{{- if not $cfg.port }}
|
|
221
243
|
clusterIP: None
|
|
244
|
+
# Scraped whether or not ready: a replicator whose stream is down (readiness 503) is the pod
|
|
245
|
+
# whose metrics matter most, and nothing routes to these addresses anyway.
|
|
246
|
+
publishNotReadyAddresses: true
|
|
222
247
|
{{- end }}
|
|
223
248
|
selector:
|
|
224
249
|
app.kubernetes.io/instance: {{ $.Release.Name }}
|
|
@@ -267,8 +292,7 @@ spec:
|
|
|
267
292
|
containers:
|
|
268
293
|
{{- include "${app.kebab}.container" (dict "role" "migrate" "cfg" .Values.migrate "root" .) | nindent 8 }}
|
|
269
294
|
volumes:
|
|
270
|
-
-
|
|
271
|
-
emptyDir: {}
|
|
295
|
+
{{- include "${app.kebab}.tmpVolume" . | nindent 8 }}
|
|
272
296
|
{{- end }}
|
|
273
297
|
`;
|
|
274
298
|
|
|
@@ -382,5 +406,6 @@ export function helmTemplateFiles(app: NameSet): readonly GeneratedFile[] {
|
|
|
382
406
|
{ path: 'docker/helm/templates/migrate-job.yaml', contents: migrateJob(app) },
|
|
383
407
|
{ path: 'docker/helm/templates/ingress.yaml', contents: ingress(app) },
|
|
384
408
|
{ path: 'docker/helm/templates/hpa.yaml', contents: hpa(app) },
|
|
409
|
+
...helmFenceFiles(app),
|
|
385
410
|
];
|
|
386
411
|
}
|
|
@@ -31,11 +31,41 @@ image:
|
|
|
31
31
|
|
|
32
32
|
# Non-secret configuration. Secrets come from an existing Secret, never from this file:
|
|
33
33
|
# kubectl create secret generic ${app.kebab}-secrets --from-literal=DATABASE_URL=...
|
|
34
|
+
#
|
|
35
|
+
# APP_URL — the pages' public origin — is what the sync node admits a websocket from. With the
|
|
36
|
+
# ingress below enabled the chart SETS it on the sync role from ingress.host (https when
|
|
37
|
+
# ingress.tls), because an undeclared node behind a TLS ingress also admits the plain-http page.
|
|
38
|
+
# Set it here when the pages are served from another host or port than that (a CDN domain, a
|
|
39
|
+
# SYNC_URL of its own): this value wins, and without it every websocket from those pages is
|
|
40
|
+
# 403 X_SOCKET_ORIGIN_REFUSED.
|
|
34
41
|
env:
|
|
35
42
|
NODE_ENV: production
|
|
36
43
|
|
|
37
44
|
existingSecret: ${app.kebab}-secrets # DATABASE_URL, NATS_URL, S3_*, AUTH_SECRET, ULTIMATE_CURSOR_SECRET
|
|
38
45
|
|
|
46
|
+
# The release-wide Secret above is every role's DEFAULT: \`existingSecret\` under a role
|
|
47
|
+
# (roles.web.existingSecret, migrate.existingSecret) names that role's own, so the migrate Job's
|
|
48
|
+
# schema-owning DATABASE_URL need not sit on a web pod.
|
|
49
|
+
|
|
50
|
+
# The /tmp every pod mounts (the root filesystem is read-only), bounded: past this the kubelet
|
|
51
|
+
# evicts the pod that filled it, never its neighbours for node disk pressure.
|
|
52
|
+
tmp:
|
|
53
|
+
sizeLimit: 512Mi
|
|
54
|
+
|
|
55
|
+
# One NetworkPolicy per serving role (templates/networkpolicy.yaml): \`http\` from httpFrom,
|
|
56
|
+
# \`metrics\` from metricsFrom, nothing else. Inert on a CNI that does not enforce NetworkPolicy.
|
|
57
|
+
# httpFrom empty = any source; narrow it to your ingress controller's namespace, e.g.
|
|
58
|
+
# [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: ingress-nginx } } }]
|
|
59
|
+
# metricsFrom default: any pod in the cluster and no address outside it
|
|
60
|
+
# egress OFF while empty; a list turns on egress filtering with exactly those rules —
|
|
61
|
+
# the database, NATS, the object store and DNS are your addresses, so list all of them
|
|
62
|
+
networkPolicy:
|
|
63
|
+
enabled: true
|
|
64
|
+
httpFrom: []
|
|
65
|
+
metricsFrom:
|
|
66
|
+
- namespaceSelector: {}
|
|
67
|
+
egress: []
|
|
68
|
+
|
|
39
69
|
# The scrape listener every serving role opens, on its own port and never the app's: the ingress
|
|
40
70
|
# routes / to web, so /metrics beside /healthz on 3000 is /metrics on the internet. This is
|
|
41
71
|
# METRICS_PORT in the container; move one and the other follows.
|
|
@@ -153,7 +183,7 @@ ingress:
|
|
|
153
183
|
`;
|
|
154
184
|
|
|
155
185
|
/**
|
|
156
|
-
* The chart for a new app:
|
|
186
|
+
* The chart for a new app: six kinds of object, all of them core Kubernetes. A ServiceMonitor and
|
|
157
187
|
* a PodDisruptionBudget are deliberately absent — the first needs a CRD `helm install` fails on
|
|
158
188
|
* where no Prometheus operator is installed, and both are cluster policy rather than app topology.
|
|
159
189
|
*/
|
|
@@ -49,27 +49,29 @@ const httpConfigTest =
|
|
|
49
49
|
// only thing that can go wrong is nobody importing the module — which is exactly how a shipped app
|
|
50
50
|
// rendered every string as ⟦key⟧ for a whole release (issue #249), one seam along.
|
|
51
51
|
import { configuredHttp, resetHttpConfig } from '@ultimat3/http';
|
|
52
|
-
import { expect, unitTest } from '@ultimat3/testing';
|
|
52
|
+
import { afterAll, expect, unitTest } from '@ultimat3/testing';
|
|
53
53
|
import './http';
|
|
54
54
|
|
|
55
|
+
// Read ONCE, at module scope: every case asserts on what the import registered, in any order.
|
|
56
|
+
const declared = configuredHttp();
|
|
57
|
+
|
|
55
58
|
unitTest('importing the module IS the registration', () => {
|
|
56
|
-
const declared = configuredHttp();
|
|
57
|
-
expect(declared).toBeDefined();
|
|
58
59
|
// The list is empty on a fresh scaffold and that is the shipped default; what is asserted is
|
|
59
60
|
// that the KEY reaches the boot, so adding an origin to it takes effect.
|
|
60
61
|
expect(declared?.cors?.origins).toEqual([]);
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
unitTest('the boot-owned keys are absent — the boot measures them, an app can only guess', () => {
|
|
64
|
-
|
|
65
|
+
expect(declared).toBeDefined();
|
|
65
66
|
for (const key of ['port', 'hostname', 'dev', 'buildId', 'signInPath']) {
|
|
66
|
-
expect({ key, declared: Object.hasOwn(declared, key) }).toEqual({ key, declared: false });
|
|
67
|
+
expect({ key, declared: Object.hasOwn(declared ?? {}, key) }).toEqual({ key, declared: false });
|
|
67
68
|
}
|
|
68
69
|
});
|
|
69
70
|
|
|
70
71
|
// The registration is process-global, so a suite that left it set would hand the next file this
|
|
71
|
-
// app's config. \`resetHttpConfig()\` is the seam
|
|
72
|
-
|
|
72
|
+
// app's config. \`resetHttpConfig()\` is the seam, and this is the one place it is called — after
|
|
73
|
+
// the cases, so none of them depends on running before it.
|
|
74
|
+
afterAll(() => {
|
|
73
75
|
resetHttpConfig();
|
|
74
76
|
expect(configuredHttp()).toBeUndefined();
|
|
75
77
|
});
|