@ultimat3/cli 23.0.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.
Files changed (149) hide show
  1. package/CLAUDE.md +15 -7
  2. package/README.md +3 -3
  3. package/package.json +28 -28
  4. package/src/affected.ts +27 -35
  5. package/src/api-registration.ts +89 -0
  6. package/src/app-artifacts.ts +1 -1
  7. package/src/app-auth.ts +4 -27
  8. package/src/app-config-load.ts +51 -0
  9. package/src/app-env.ts +5 -11
  10. package/src/app-manifest.ts +2 -8
  11. package/src/app-mcp.ts +10 -23
  12. package/src/app-permissions-borrowed.ts +129 -0
  13. package/src/app-permissions-site.ts +24 -0
  14. package/src/app-permissions.ts +7 -17
  15. package/src/budgets.ts +4 -35
  16. package/src/cdp-shot-driver.ts +3 -1
  17. package/src/cmd-auth-spec.ts +16 -0
  18. package/src/cmd-auth.ts +65 -0
  19. package/src/cmd-build.ts +64 -15
  20. package/src/cmd-db-backfill.ts +25 -12
  21. package/src/cmd-db.ts +6 -5
  22. package/src/cmd-deploy-helm.ts +7 -6
  23. package/src/cmd-deploy.ts +4 -4
  24. package/src/cmd-dev.ts +9 -8
  25. package/src/cmd-doctor-spec.ts +2 -9
  26. package/src/cmd-doctor.ts +41 -14
  27. package/src/cmd-env.ts +26 -2
  28. package/src/cmd-generate.ts +79 -32
  29. package/src/cmd-help.ts +19 -6
  30. package/src/cmd-i18n.ts +10 -9
  31. package/src/cmd-jobs.ts +18 -6
  32. package/src/cmd-manifest-spec.ts +1 -1
  33. package/src/cmd-manifest.ts +6 -25
  34. package/src/cmd-mcp.ts +46 -15
  35. package/src/cmd-planned.ts +15 -8
  36. package/src/cmd-routes.ts +12 -2
  37. package/src/cmd-secrets.ts +47 -6
  38. package/src/cmd-shot-matrix.ts +1 -3
  39. package/src/cmd-shot.ts +12 -3
  40. package/src/cmd-tasks.ts +2 -2
  41. package/src/cmd-test-spec.ts +1 -7
  42. package/src/cmd-test.ts +13 -29
  43. package/src/cmd-verify.ts +4 -4
  44. package/src/db-subscribes.ts +12 -4
  45. package/src/dev-dashboard-guard.ts +76 -0
  46. package/src/dev-dashboard.ts +51 -29
  47. package/src/dev-lock.ts +4 -7
  48. package/src/dispatch.ts +3 -6
  49. package/src/doctor-app-url.ts +39 -0
  50. package/src/doctor-auth.ts +95 -0
  51. package/src/drift-replica-identity.ts +42 -0
  52. package/src/error-catalog.ts +6 -9
  53. package/src/error-codes.ts +23 -3
  54. package/src/error-unthrown.ts +29 -11
  55. package/src/errors.ts +54 -83
  56. package/src/framework-schema-apply.ts +78 -0
  57. package/src/framework-schema-stamp.ts +96 -0
  58. package/src/framework-schema.ts +13 -2
  59. package/src/generate-files.ts +4 -3
  60. package/src/generate-kinds.ts +33 -1
  61. package/src/generate-shadow.ts +46 -0
  62. package/src/generate-write.ts +47 -13
  63. package/src/gh-target.ts +12 -7
  64. package/src/i18n-audit.ts +4 -1
  65. package/src/index.ts +1 -3
  66. package/src/island-bundle.ts +3 -15
  67. package/src/jobs-drain.ts +82 -15
  68. package/src/jobs-report.ts +4 -4
  69. package/src/loopback-host.ts +13 -0
  70. package/src/manifest-staleness.ts +62 -0
  71. package/src/mcp-errors.ts +12 -3
  72. package/src/mcp-host.ts +16 -9
  73. package/src/mcp-test-run.ts +38 -0
  74. package/src/messages.ts +2 -0
  75. package/src/metrics-endpoint.ts +25 -1
  76. package/src/output.ts +6 -0
  77. package/src/page-navigation.ts +7 -23
  78. package/src/page-speculation.ts +5 -20
  79. package/src/prerender-out.ts +34 -7
  80. package/src/prerender.ts +8 -5
  81. package/src/pwa-artifacts.ts +26 -94
  82. package/src/registry.ts +2 -0
  83. package/src/revalidated-response.ts +4 -3
  84. package/src/role-replicator.ts +16 -0
  85. package/src/role-start.ts +9 -4
  86. package/src/role-sync.ts +55 -20
  87. package/src/runtime-assets.ts +11 -7
  88. package/src/runtime-bindings.ts +4 -4
  89. package/src/runtime-cache.ts +38 -47
  90. package/src/runtime-idempotency-scope.ts +23 -0
  91. package/src/runtime-isr.ts +36 -0
  92. package/src/runtime-jobs.ts +8 -33
  93. package/src/runtime-mfa-warning.ts +29 -0
  94. package/src/runtime-notify-retention.ts +10 -46
  95. package/src/runtime-overrides.ts +1 -1
  96. package/src/runtime-purge.ts +38 -16
  97. package/src/runtime-queue.ts +31 -10
  98. package/src/runtime-realtime.ts +9 -45
  99. package/src/runtime-render.ts +62 -35
  100. package/src/runtime-services.ts +20 -17
  101. package/src/runtime-storage.ts +10 -5
  102. package/src/schema-diff.ts +16 -0
  103. package/src/schema-drift.ts +45 -15
  104. package/src/seo-routes.ts +80 -9
  105. package/src/serve-boot.ts +34 -6
  106. package/src/serve-drain.ts +9 -33
  107. package/src/serve-env.ts +9 -6
  108. package/src/serve-web.ts +12 -7
  109. package/src/shot-locale.ts +5 -23
  110. package/src/site-assets.ts +3 -5
  111. package/src/site-config.ts +9 -48
  112. package/src/site-seo.ts +1 -1
  113. package/src/sitemap-lastmod.ts +5 -0
  114. package/src/solid-loader.ts +12 -3
  115. package/src/source-files.ts +6 -0
  116. package/src/stored-object-headers.ts +43 -0
  117. package/src/sync-url.ts +2 -2
  118. package/src/tasks-facts.ts +3 -29
  119. package/src/templates/action.ts +3 -0
  120. package/src/templates/naming.ts +28 -0
  121. package/src/templates/route.ts +3 -6
  122. package/src/templates/scaffold-app.ts +6 -2
  123. package/src/templates/scaffold-container-compose.ts +10 -1
  124. package/src/templates/scaffold-container.ts +4 -2
  125. package/src/templates/scaffold-db-client.ts +4 -2
  126. package/src/templates/scaffold-helm-fences.ts +76 -0
  127. package/src/templates/scaffold-helm-templates.ts +32 -7
  128. package/src/templates/scaffold-helm.ts +31 -1
  129. package/src/templates/scaffold-repo.ts +9 -1
  130. package/src/templates/scaffold-shell.ts +39 -36
  131. package/src/test-counts.ts +2 -6
  132. package/src/test-ignores.ts +25 -0
  133. package/src/test-passes.ts +0 -15
  134. package/src/test-select.ts +5 -14
  135. package/src/test-shards.ts +41 -93
  136. package/src/test-workers.ts +26 -4
  137. package/src/theme-boot.ts +6 -31
  138. package/src/trusted-hops-error.ts +17 -0
  139. package/src/unthrown-codes.ts +26 -0
  140. package/src/verify-checks.ts +17 -46
  141. package/src/verify-deadline.ts +130 -33
  142. package/src/verify-merge.ts +60 -27
  143. package/src/verify-part-step.ts +90 -0
  144. package/src/verify-run.ts +29 -8
  145. package/src/verify-shard.ts +13 -2
  146. package/src/verify-stalled.ts +152 -0
  147. package/src/verify-tests.ts +5 -2
  148. package/src/build-errors.ts +0 -54
  149. package/src/verify-errors.ts +0 -58
package/src/errors.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // that resolves it — the codes themselves, their titles and their registration are `./error-codes`,
3
3
  // so a package importing a class does not pull the table and vice versa.
4
4
  import { UltimateError } from '@ultimat3/core';
5
+ import type { Finding } from './output';
5
6
  import { quoteArg } from './shell-quote';
6
7
 
7
8
  /** An unknown command or subcommand. Carries a suggestion so the retry is one keystroke away. */
@@ -301,14 +302,59 @@ export class FixTargetUnknownError extends UltimateError {
301
302
  }
302
303
  }
303
304
 
304
- // The build and bundle refusals live in `build-errors.ts` (split at the 500-line ceiling); re-exported
305
- // here so every existing `from './errors'` import keeps resolving.
306
- export type { FrameworkScriptKind } from './build-errors';
307
- export {
308
- BuildEntryMissingError,
309
- FrameworkScriptBuildFailedError,
310
- IslandBuildFailedError,
311
- } from './build-errors';
305
+ /**
306
+ * A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
307
+ * entry point like any other, and the target's own logs are what says which line. The fix builds
308
+ * exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
309
+ */
310
+ export class IslandBuildFailedError extends UltimateError {
311
+ constructor(input: { file: string; logs: string }) {
312
+ super({
313
+ code: 'X_BUILD_FAILED',
314
+ cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
315
+ fix: `bun build --target browser ${input.file}`,
316
+ });
317
+ }
318
+ }
319
+
320
+ /** Which framework script a page ships: the sync worker, the page boot, or the client router. */
321
+ export type FrameworkScriptKind = 'sync worker' | 'page boot' | 'client router';
322
+
323
+ /**
324
+ * One of the page's framework scripts would not bundle. Same code as an island: "a browser entry
325
+ * the framework builds did not build" is one condition, and the entry here is framework code, not
326
+ * the app's — the cause names WHICH script, so the reader knows what the page is now missing.
327
+ */
328
+ export class FrameworkScriptBuildFailedError extends UltimateError {
329
+ constructor(input: { what: FrameworkScriptKind; entry: string; logs: string }) {
330
+ super({
331
+ code: 'X_BUILD_FAILED',
332
+ cause: `the ${input.what} (${input.entry}) would not bundle for the browser: ${input.logs}`,
333
+ fix: `bun build --target browser --format iife ${quoteArg(input.entry)}`,
334
+ });
335
+ }
336
+ }
337
+
338
+ /**
339
+ * A static build over an app whose modules did not all import. The route registry is filled BY the
340
+ * import, so a page whose module threw is not skipped but absent — and an export without it, with
341
+ * `x build` green, is a deploy that deletes a page. The `manifest` step owns load findings
342
+ * (`load-findings.ts`), so the fix is the command that lists every one with its own fix.
343
+ */
344
+ export class PrerenderLoadFailedError extends UltimateError {
345
+ constructor(findings: readonly Finding[]) {
346
+ const listed = findings.map((finding) =>
347
+ finding.at === undefined
348
+ ? `${finding.code} ${finding.cause}`
349
+ : `${finding.at}: ${finding.code} ${finding.cause}`,
350
+ );
351
+ super({
352
+ code: 'X_BUILD_FAILED',
353
+ cause: `the static export renders the routes the app's modules register, and ${findings.length} module load finding(s) left it short: ${listed.join('; ')}`,
354
+ fix: 'x verify --only manifest --json',
355
+ });
356
+ }
357
+ }
312
358
 
313
359
  /**
314
360
  * `ROLE` selects what a container is. One image runs every role, so a typo is a process that would
@@ -363,36 +409,6 @@ export class PortInvalidError extends UltimateError {
363
409
  }
364
410
  }
365
411
 
366
- /**
367
- * `x env` was run in an app whose `app.config.ts` exports no `envSchema`. Not a silent success:
368
- * writing a `.env.example` with no variables in it, or reporting "0 declared variables, all
369
- * present", both read as a working environment declaration to whoever runs the command next.
370
- *
371
- * `X_CONFIG_INVALID` is core's code for "a configuration this process cannot boot on — env or
372
- * `app.config.ts`", which is exactly this; the CLI names it in `CLI_BORROWED_ERROR_CODES` rather
373
- * than minting a synonym.
374
- */
375
- export class EnvSchemaMissingError extends UltimateError {
376
- constructor(input: { subcommand: string }) {
377
- super({
378
- code: 'X_CONFIG_INVALID',
379
- cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
380
- fix: "add to app.config.ts: export const envSchema = { DATABASE_URL: { type: 'url', description: 'Postgres connection URL' } } satisfies EnvSchema; export const env = defineEnv(envSchema);",
381
- });
382
- }
383
- }
384
-
385
- /** An interface-complete command path whose remote/native half is not written yet. */
386
- export class CliNotImplementedError extends UltimateError {
387
- constructor(input: { feature: string; fix: string }) {
388
- super({
389
- code: 'X_NOT_IMPLEMENTED',
390
- cause: `${input.feature} is not implemented in this build`,
391
- fix: input.fix,
392
- });
393
- }
394
- }
395
-
396
412
  /**
397
413
  * The process could not obtain a storage disk to write to.
398
414
  *
@@ -432,48 +448,3 @@ export class LocalDiskUnsafeError extends UltimateError {
432
448
  });
433
449
  }
434
450
  }
435
-
436
- /**
437
- * `x secrets edit` decrypts into a buffer and hands it to `$EDITOR`. There is no fallback editor:
438
- * guessing one and opening a decrypted file in it is the last place a surprise belongs.
439
- */
440
- export class SecretsEditorMissingError extends UltimateError {
441
- constructor(input: { vars: readonly string[] }) {
442
- super({
443
- code: 'X_SECRETS_EDITOR_MISSING',
444
- cause: `x secrets edit opens the decrypted secrets in an editor and none of ${input.vars.join(', ')} is set`,
445
- fix: 'EDITOR=nano x secrets edit',
446
- });
447
- }
448
- }
449
-
450
- /**
451
- * The editor exited non-zero — a crash, or a deliberate abort. The buffer is discarded either way
452
- * and the committed file is left exactly as it was: resealing a buffer whose editor failed would
453
- * commit whatever half-written state the crash left behind.
454
- */
455
- export class SecretsEditFailedError extends UltimateError {
456
- constructor(input: { editor: string; code: number }) {
457
- super({
458
- code: 'X_SECRETS_EDIT_FAILED',
459
- cause: `"${input.editor}" exited ${input.code}, so the decrypted buffer was discarded and the committed secrets file was not rewritten`,
460
- fix: 'x secrets edit',
461
- });
462
- }
463
- }
464
-
465
- /**
466
- * `x secrets init` would overwrite a file that already exists. `X_GENERATE_CONFLICT` is this
467
- * package's own code for exactly that, and a second name for "a generator would clobber something"
468
- * is the duplication the code registry exists to prevent. Losing a master key is unrecoverable —
469
- * the committed file it opens is then ciphertext nobody can read again.
470
- */
471
- export class SecretsExistsError extends UltimateError {
472
- constructor(input: { path: string; fix: string }) {
473
- super({
474
- code: 'X_GENERATE_CONFLICT',
475
- cause: `${input.path} already exists, and x secrets init would replace it`,
476
- fix: input.fix,
477
- });
478
- }
479
- }
@@ -0,0 +1,78 @@
1
+ // The two ways a boot meets the framework schema (plan 101, s1-con #5): `applyLockedSchema` runs the
2
+ // DDL in ONE transaction behind the migration advisory lock with a bounded `lock_timeout`, then
3
+ // stamps the build; `verifySchema` runs no DDL and refuses a database this build was never applied
4
+ // to. Which boot takes which is `runtime-queue.ts`'s `SchemaMode`.
5
+
6
+ import { frameworkVersion, logger } from '@ultimat3/core';
7
+ import type { DbClient } from '@ultimat3/db';
8
+ import {
9
+ literal,
10
+ MIGRATION_LOCK_KEY,
11
+ MIGRATION_LOCK_WAIT_MS,
12
+ poolProfileFor,
13
+ raw,
14
+ withTransaction,
15
+ } from '@ultimat3/db';
16
+ import { applyFrameworkSchema } from './framework-schema';
17
+ import {
18
+ FrameworkSchemaUnappliedError,
19
+ frameworkSchemaHash,
20
+ nextStamp,
21
+ parseStamp,
22
+ STAMP_RELATION,
23
+ type StampEntry,
24
+ stampVerdict,
25
+ } from './framework-schema-stamp';
26
+
27
+ const thisBuild = (): StampEntry => ({
28
+ version: frameworkVersion(),
29
+ schema: frameworkSchemaHash(),
30
+ });
31
+
32
+ /** The stamp as the catalog holds it; `null` when the relation or its comment is absent. */
33
+ async function readStamp(client: DbClient): Promise<string | null> {
34
+ const rows = await client.query<{ readonly stamp: string | null }>(
35
+ raw(`select obj_description(to_regclass('${STAMP_RELATION}'), 'pg_class') as stamp`),
36
+ );
37
+ return rows[0]?.stamp ?? null;
38
+ }
39
+
40
+ /**
41
+ * The migration lock, not a lock of its own: a second migrator — another `ROLE=migrate` pod, an
42
+ * `x db migrate` — waits behind this one instead of racing its `create table`s into `23505`. A
43
+ * transaction-scoped lock (`pg_advisory_xact_lock`), so no pinned session: COMMIT releases it.
44
+ *
45
+ * Two `lock_timeout`s: the lock wait is the migrator's own (`MIGRATION_LOCK_WAIT_MS`), and every
46
+ * DDL statement after it is the migrate pool's (`poolProfileFor('migrate')`), so an `alter table`
47
+ * behind an open transaction fails in seconds rather than queueing every enqueue behind it.
48
+ */
49
+ export async function applyLockedSchema(client: DbClient): Promise<void> {
50
+ await withTransaction(
51
+ async (tx) => {
52
+ await tx.execute(raw(`set local lock_timeout = ${Math.round(MIGRATION_LOCK_WAIT_MS)}`));
53
+ await tx.execute(raw(`select pg_advisory_xact_lock(${MIGRATION_LOCK_KEY})`));
54
+ const ddlTimeoutMs = Math.round(poolProfileFor('migrate').lockTimeoutMs);
55
+ await tx.execute(raw(`set local lock_timeout = ${ddlTimeoutMs}`));
56
+ await applyFrameworkSchema((statement) => tx.execute(raw(statement)));
57
+ const stamp = nextStamp(await readStamp(tx), thisBuild());
58
+ await tx.execute(raw(`comment on table ${STAMP_RELATION} is ${literal(stamp).text}`));
59
+ },
60
+ { client },
61
+ );
62
+ }
63
+
64
+ /**
65
+ * One catalog read. A build absent from the stamp is refused with the command that applies it; a
66
+ * fleet straddling a major is reported once and served — refusing it would crash-loop the old pods
67
+ * of every rolling deploy across a breaking release.
68
+ */
69
+ export async function verifySchema(client: DbClient): Promise<void> {
70
+ const mine = thisBuild();
71
+ const verdict = stampVerdict(parseStamp(await readStamp(client)), mine);
72
+ if (!verdict.applied) throw new FrameworkSchemaUnappliedError({ mine, newest: verdict.newest });
73
+ if (verdict.skew === undefined) return;
74
+ logger.warn('ultimate framework major skew', {
75
+ cause: `this process runs ${mine.version} and the database's framework schema was last applied by ${verdict.skew.version}: a fleet across a major boundary is mid-rollout or half rolled back`,
76
+ fix: 'kubectl rollout status deployment --selector app.kubernetes.io/component=web',
77
+ });
78
+ }
@@ -0,0 +1,96 @@
1
+ // Which builds have applied the framework schema to this database: a stamp `ROLE=migrate` leaves
2
+ // in `x_jobs`' table comment, and the verdict a serving role reads from it instead of running DDL
3
+ // itself (plan 101, s1-con #5). Pure: the statements live in `framework-schema-apply.ts`.
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+ import { FRAMEWORK_SCHEMA } from './framework-schema';
7
+
8
+ /**
9
+ * The relation that carries the stamp. A comment, not a table: a new framework table moves every
10
+ * app's committed schema dump (`packages/db/schema/framework/`), and a comment is in none of them.
11
+ */
12
+ export const STAMP_RELATION = 'x_jobs';
13
+
14
+ /** The first line of a stamp — what tells it apart from a comment someone else wrote. */
15
+ const STAMP_HEADER = 'ultimate-framework-schema';
16
+
17
+ /** Enough for a rolling deploy and its rollback, small enough to stay one readable comment. */
18
+ const STAMP_KEEP = 16;
19
+
20
+ export interface StampEntry {
21
+ readonly version: string;
22
+ readonly schema: string;
23
+ }
24
+
25
+ /** This build's DDL, as one short hash: two builds with the same DDL are the same schema. */
26
+ export function frameworkSchemaHash(): string {
27
+ const hasher = new Bun.CryptoHasher('sha256');
28
+ for (const entry of FRAMEWORK_SCHEMA) for (const ddl of entry.ddl) hasher.update(ddl);
29
+ return hasher.digest('hex').slice(0, 16);
30
+ }
31
+
32
+ /** Every build a stamp records, newest first. Anything that is not a stamp records none. */
33
+ export function parseStamp(comment: string | null | undefined): readonly StampEntry[] {
34
+ if (typeof comment !== 'string') return [];
35
+ const [header, ...lines] = comment.split('\n');
36
+ if (header !== STAMP_HEADER) return [];
37
+ return lines.flatMap((line) => {
38
+ const [version, schema] = line.trim().split(' ');
39
+ return version !== undefined &&
40
+ /^[0-9A-Za-z.+-]{1,64}$/.test(version) &&
41
+ schema !== undefined &&
42
+ /^[0-9a-f]{16}$/.test(schema)
43
+ ? [{ version, schema }]
44
+ : [];
45
+ });
46
+ }
47
+
48
+ /** The stamp after `mine` applied: it first, every other build kept, the oldest dropped. */
49
+ export function nextStamp(previous: string | null | undefined, mine: StampEntry): string {
50
+ const kept = parseStamp(previous).filter((entry) => entry.schema !== mine.schema);
51
+ const entries = [mine, ...kept].slice(0, STAMP_KEEP);
52
+ return [STAMP_HEADER, ...entries.map((entry) => `${entry.version} ${entry.schema}`)].join('\n');
53
+ }
54
+
55
+ const majorOf = (version: string): string => version.split('.')[0] ?? version;
56
+
57
+ export type StampVerdict =
58
+ | { readonly applied: true; readonly skew?: StampEntry }
59
+ | { readonly applied: false; readonly newest?: StampEntry };
60
+
61
+ /**
62
+ * Applied when this build's hash is anywhere in the stamp: an old pod restarting mid-rollout finds
63
+ * its own build there, behind the new one. `skew` is the newest build when its MAJOR differs from
64
+ * this process's — a fleet straddling a breaking release, which a boot reports and never refuses.
65
+ */
66
+ export function stampVerdict(entries: readonly StampEntry[], mine: StampEntry): StampVerdict {
67
+ const newest = entries[0];
68
+ if (!entries.some((entry) => entry.schema === mine.schema)) {
69
+ return newest === undefined ? { applied: false } : { applied: false, newest };
70
+ }
71
+ return newest !== undefined && majorOf(newest.version) !== majorOf(mine.version)
72
+ ? { applied: true, skew: newest }
73
+ : { applied: true };
74
+ }
75
+
76
+ /** A serving role over a database its build's framework tables were never applied to. */
77
+ export class FrameworkSchemaUnappliedError extends UltimateError {
78
+ constructor(input: { readonly mine: StampEntry; readonly newest: StampEntry | undefined }) {
79
+ super({
80
+ code: 'X_FRAMEWORK_SCHEMA_UNAPPLIED',
81
+ cause:
82
+ `this build (${input.mine.version}, framework schema ${input.mine.schema}) has not been applied ` +
83
+ `to this database — ${
84
+ input.newest === undefined
85
+ ? `${STAMP_RELATION} carries no stamp, so no ROLE=migrate of this release ran`
86
+ : `the newest applied build is ${input.newest.version} (${input.newest.schema})`
87
+ }. A serving role verifies and never runs DDL: the migrate step must run first`,
88
+ fix: 'x db migrate',
89
+ meta: {
90
+ version: input.mine.version,
91
+ schema: input.mine.schema,
92
+ ...(input.newest === undefined ? {} : { newest: { ...input.newest } }),
93
+ },
94
+ });
95
+ }
96
+ }
@@ -11,7 +11,11 @@ import { ADMIN_AUDIT_TABLE, SQL_ADMIN_AUDIT_TABLE } from '@ultimat3/admin/schema
11
11
  import { AUTH_TABLE_NAMES, AUTH_TABLES, SQL_AUTH_LIMIT_TABLES } from '@ultimat3/auth';
12
12
  import { SQL_RATE_LIMIT_TABLE } from '@ultimat3/http';
13
13
  import { SQL_JOBS_TABLE } from '@ultimat3/jobs';
14
- import { SQL_NOTIFY_DELIVERIES_TABLE, SQL_NOTIFY_INBOX_TABLE } from '@ultimat3/notify';
14
+ import {
15
+ SQL_NOTIFY_DELIVERIES_TABLE,
16
+ SQL_NOTIFY_DIGESTS_TABLE,
17
+ SQL_NOTIFY_INBOX_TABLE,
18
+ } from '@ultimat3/notify';
15
19
  import { FrameworkSchemaFailedError } from './schema-errors';
16
20
 
17
21
  export interface FrameworkSchema {
@@ -76,7 +80,7 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
76
80
  }),
77
81
  Object.freeze({
78
82
  pkg: '@ultimat3/auth',
79
- tables: Object.freeze(['x_auth_failures', 'x_auth_lockouts']),
83
+ tables: Object.freeze(['x_auth_lockouts']),
80
84
  ddl: Object.freeze([SQL_AUTH_LIMIT_TABLES]),
81
85
  }),
82
86
  /**
@@ -113,6 +117,13 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
113
117
  tables: Object.freeze(['x_notify_inbox']),
114
118
  ddl: Object.freeze([SQL_NOTIFY_INBOX_TABLE]),
115
119
  }),
120
+ // `createPgDigestStore`'s windows. Installed whether or not the app picks that store, for the
121
+ // reason every notify row is: `setNotifyStores` is the app's boot line and runs after this one.
122
+ Object.freeze({
123
+ pkg: '@ultimat3/notify',
124
+ tables: Object.freeze(['x_notify_digests']),
125
+ ddl: Object.freeze([SQL_NOTIFY_DIGESTS_TABLE]),
126
+ }),
116
127
  /**
117
128
  * The admin's durable audit trail — what `postgresAuditLog()` writes and every detail page's
118
129
  * history card reads. Without this row an app that chose the durable log met `42P01` on the
@@ -2,7 +2,8 @@
2
2
  // `cmd-generate.ts` so a generator's output can be asserted on — by the generator tests, the
3
3
  // scaffold fixture and `x new` — without a command line, an app root or a filesystem.
4
4
 
5
- import { BadFlagError, CliNotImplementedError } from './errors';
5
+ import { NotImplementedError } from '@ultimat3/core';
6
+ import { BadFlagError } from './errors';
6
7
  import type { Generator } from './generate-kinds';
7
8
  import { assertLiveSupported, assertSurfaceSupported, GENERATORS } from './generate-kinds';
8
9
  import { dedupe } from './generate-write';
@@ -233,8 +234,8 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
233
234
  }),
234
235
  );
235
236
  default:
236
- throw new CliNotImplementedError({
237
- feature: `generator "${String(options.kind)}"`,
237
+ throw new NotImplementedError({
238
+ cause: `generator "${String(options.kind)}" is not implemented in this build`,
238
239
  fix: `x g ${GENERATORS.join('|')}`,
239
240
  });
240
241
  }
@@ -11,7 +11,7 @@ import {
11
11
  } from './errors';
12
12
  import { quoteArg } from './shell-quote';
13
13
  import type { Surface } from './templates';
14
- import { camel, kebab } from './templates/naming';
14
+ import { camel, kebab, plural, singularOf } from './templates/naming';
15
15
 
16
16
  export const GENERATORS = [
17
17
  'resource',
@@ -137,10 +137,42 @@ export function readName(raw: string | undefined, kind: Generator): string {
137
137
  // directory that serves it. Every other generator's name is one directory segment.
138
138
  if (kind === 'route') return readRoutePath(raw);
139
139
  refusePath(raw, 'name', kind);
140
+ refuseNonAscii(raw, kind);
140
141
  refuseBadIdentifier(raw, kind);
142
+ refusePlural(raw, kind);
141
143
  return raw;
142
144
  }
143
145
 
146
+ /**
147
+ * Every derived spelling keeps `[a-zA-Z0-9]` and drops the rest as a separator, so a letter
148
+ * outside ASCII vanished: `x g entity Über` wrote `ber`. Refused, with the name folded to ASCII.
149
+ */
150
+ function refuseNonAscii(raw: string, kind: Generator): void {
151
+ if (!/[^\p{ASCII}]/u.test(raw)) return;
152
+ const folded = kebab(raw.normalize('NFKD').replace(/[^\p{ASCII}]/gu, ''));
153
+ throw new BadFlagError({
154
+ flag: 'name',
155
+ command: `g ${kind}`,
156
+ reason: `"${raw}" holds a letter outside ASCII, and every file, identifier and table name the generator derives from it keeps ASCII letters and digits only`,
157
+ fix: folded === '' ? exampleFor(kind) : ['x g', kind, folded].join(' '),
158
+ });
159
+ }
160
+
161
+ /** The generators that pluralise their name into a table and a page. */
162
+ const PLURALISED: ReadonlySet<Generator> = new Set<Generator>(['resource', 'entity']);
163
+
164
+ /** `x g resource posts` declared `entity('postses')`: a plural name is pluralised again. */
165
+ function refusePlural(raw: string, kind: Generator): void {
166
+ const singular = PLURALISED.has(kind) ? singularOf(raw) : undefined;
167
+ if (singular === undefined) return;
168
+ throw new BadFlagError({
169
+ flag: 'name',
170
+ command: `g ${kind}`,
171
+ reason: `"${raw}" is already plural, and the generator pluralises the name for its table and page — "${plural(camel(raw))}"`,
172
+ fix: ['x g', kind, singular].join(' '),
173
+ });
174
+ }
175
+
144
176
  /** A static segment, or `[param]` / `[...rest]` — the directory convention `x routes` reads. */
145
177
  const ROUTE_SEGMENT = /^(?:[A-Za-z0-9][A-Za-z0-9_-]*|\[(?:\.\.\.)?[A-Za-z][A-Za-z0-9_-]*\])$/;
146
178
 
@@ -0,0 +1,46 @@
1
+ // A generator name whose type spelling the emitted code ALSO uses as a global or a local alias:
2
+ // `x g resource promise` imports `type { Promise }` beside `Promise<Row>`, and `row` writes
3
+ // `type Row = Row`. Decided on the planned files themselves, so a template that starts using
4
+ // another global is covered the day it does — and the names that clash nowhere stay allowed.
5
+
6
+ import { BadFlagError } from './errors';
7
+ import type { Generator } from './generate-kinds';
8
+ import type { GeneratedFile } from './templates';
9
+ import { kebab, names } from './templates/naming';
10
+
11
+ /** The emitted file that imports `type` from the slice AND uses the same name as its own. */
12
+ function shadowingFile(files: readonly GeneratedFile[], type: string): string | undefined {
13
+ // A pascal spelling is `[A-Za-z0-9]` only (`naming.ts`), so it splices into a pattern as is.
14
+ const imported = new RegExp(
15
+ `import\\s+(?:type\\s+)?\\{[^}]*\\b${type}\\b[^}]*\\}\\s+from\\s+'\\.`,
16
+ );
17
+ const reused = new RegExp(
18
+ `\\b${type}<|^\\s*(?:export\\s+)?(?:type\\s+${type}\\s*=|interface\\s+${type}\\b)`,
19
+ 'm',
20
+ );
21
+ return files.find(
22
+ (file) =>
23
+ typeof file.contents === 'string' &&
24
+ /\.tsx?$/.test(file.path) &&
25
+ imported.test(file.contents) &&
26
+ reused.test(file.contents),
27
+ )?.path;
28
+ }
29
+
30
+ /** Refuses `name` when its type spelling would shadow a name the planned files rely on. */
31
+ export function refuseShadowedTypes(
32
+ files: readonly GeneratedFile[],
33
+ kind: Generator,
34
+ name: string,
35
+ ): void {
36
+ const type = names(name).pascal;
37
+ const file = shadowingFile(files, type);
38
+ if (file === undefined) return;
39
+ const noun = kind.split(':').at(-1) ?? kind;
40
+ throw new BadFlagError({
41
+ flag: 'name',
42
+ command: `g ${kind}`,
43
+ reason: `"${name}" becomes the type ${type}, and ${file} imports it beside its own use of ${type} — the global or local type the template means is shadowed and the file does not compile`,
44
+ fix: ['x g', kind, `${kebab(name)}-${noun}`].join(' '),
45
+ });
46
+ }
@@ -102,7 +102,7 @@ export function containedPath(root: string, path: string): string {
102
102
  dir: base,
103
103
  // Command first, the caveat behind a `#`: the line runs verbatim and the shell drops the
104
104
  // rest. `x g <kind> <name>` pasted into bash is a redirect, not a command.
105
- fix: `x g resource posts --dry-run # name every file relative to the app root, no ".." segment`,
105
+ fix: `x g resource post --dry-run # name every file relative to the app root, no ".." segment`,
106
106
  });
107
107
  return target;
108
108
  }
@@ -198,14 +198,56 @@ function planFile(
198
198
  return { kind: 'write', file, absolute, contents: file.contents };
199
199
  }
200
200
 
201
+ /** Every file's plan, decided without touching the disk — the first of `writeFiles`' two passes. */
202
+ async function planAll(
203
+ root: string,
204
+ files: readonly GeneratedFile[],
205
+ force: boolean,
206
+ invocation: string,
207
+ ): Promise<readonly WritePlan[]> {
208
+ const plans: WritePlan[] = [];
209
+ for (const file of files) {
210
+ const absolute = containedPath(root, file.path);
211
+ plans.push(
212
+ file.merge === 'json'
213
+ ? await planJsonMerge(file, absolute)
214
+ : planFile(file, absolute, force, invocation),
215
+ );
216
+ }
217
+ return plans;
218
+ }
219
+
220
+ const conflictsOf = (plans: readonly WritePlan[]): readonly Finding[] =>
221
+ plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
222
+
223
+ /**
224
+ * What `writeFiles` WOULD do, and nothing else: the paths it would write and the conflicts that
225
+ * would stop it. `x g --dry-run` answers with this — the bare file list said "would write 33"
226
+ * where the real run was `X_GENERATE_CONFLICT`, and listed slice modules the run skips as writes.
227
+ */
228
+ export async function planWrites(
229
+ root: string,
230
+ files: readonly GeneratedFile[],
231
+ force: boolean,
232
+ invocation = 'x g <kind> <name>',
233
+ ): Promise<WriteReport> {
234
+ const plans = await planAll(root, files, force, invocation);
235
+ const conflicts = conflictsOf(plans);
236
+ if (conflicts.length > 0) return { written: [], conflicts };
237
+ return {
238
+ written: plans.flatMap((plan) => (plan.kind === 'write' ? [plan.file.path] : [])),
239
+ conflicts: [],
240
+ };
241
+ }
242
+
201
243
  /**
202
244
  * Never clobbers, and never half-writes. A generator that overwrites is a generator nobody runs
203
245
  * twice; a generator that lands four of seven files and then reports a conflict is worse, because
204
246
  * the next run conflicts on the files the failed one wrote.
205
247
  *
206
248
  * Two passes, and the split is the point: the first decides — containment, existence, whether a
207
- * catalog can be merged into — and touches nothing, the second writes only when the first found
208
- * no conflict at all. Containment was already proven up front and the rest was not.
249
+ * catalog can be merged into — and touches nothing (`planWrites` is that pass alone), the second
250
+ * writes only when the first found no conflict at all.
209
251
  */
210
252
  export async function writeFiles(
211
253
  root: string,
@@ -218,16 +260,8 @@ export async function writeFiles(
218
260
  */
219
261
  invocation = 'x g <kind> <name>',
220
262
  ): Promise<WriteReport> {
221
- const plans: WritePlan[] = [];
222
- for (const file of files) {
223
- const absolute = containedPath(root, file.path);
224
- plans.push(
225
- file.merge === 'json'
226
- ? await planJsonMerge(file, absolute)
227
- : planFile(file, absolute, force, invocation),
228
- );
229
- }
230
- const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
263
+ const plans = await planAll(root, files, force, invocation);
264
+ const conflicts = conflictsOf(plans);
231
265
  if (conflicts.length > 0) return { written: [], conflicts };
232
266
 
233
267
  const written: string[] = [];
package/src/gh-target.ts CHANGED
@@ -76,18 +76,23 @@ const PR_VIEW = t.object({ number: t.number });
76
76
  * The pull request for the current branch. gh exits non-zero when there is none, with a message
77
77
  * that names the branch — so the refusal keeps gh's own sentence and adds the remedy gh has no
78
78
  * opinion about: name the number, or open the PR.
79
+ *
80
+ * No `--repo`: gh refuses `pr view --repo <slug>` without a number, url or branch before any
81
+ * network call, so that argv could never succeed. The checkout-scoped form resolves the branch's
82
+ * tracking remote itself — the same resolution `gh repo view` gave `resolveRepo`. `repo` names the
83
+ * pull request in the refusal only.
79
84
  */
80
85
  export async function resolvePrNumber(host: GhHost, repo: GhRepo): Promise<number> {
81
86
  try {
82
- const viewed = await ghJson(
83
- host,
84
- ['pr', 'view', '--repo', repo.slug, '--json', 'number'],
85
- PR_VIEW,
86
- { label: 'gh pr view', fix: 'x pr review --pr 241 --json' },
87
- );
87
+ const viewed = await ghJson(host, ['pr', 'view', '--json', 'number'], PR_VIEW, {
88
+ label: 'gh pr view',
89
+ fix: 'x pr review --pr 241 --json',
90
+ });
88
91
  return viewed.number;
89
92
  } catch (error) {
90
- if (error instanceof GhFailedError) throw new PrNotFoundError({ detail: error.cause });
93
+ if (error instanceof GhFailedError) {
94
+ throw new PrNotFoundError({ detail: `${repo.slug}: ${error.cause}` });
95
+ }
91
96
  throw error;
92
97
  }
93
98
  }
package/src/i18n-audit.ts CHANGED
@@ -28,13 +28,16 @@ import { CATALOG_ROOT, catalogPath } from './templates/locales';
28
28
  * glob set every text-scanning gate step already shares (`errors`, `filesize`) — a second glob
29
29
  * here would mean this command and `x verify` disagree on what "the app's source" is. Test files
30
30
  * are excluded: a fixture's `t('fixture.key')` is not a gap the shipped catalogs owe an answer to.
31
+ * So are `guards/`: gate tooling renders nothing, and its fix lines quote `t('…')` as an example.
31
32
  * `extractFromFiles` reads by absolute path, so its `file` label is rewritten back to the same
32
33
  * root-relative POSIX shape `app-load.ts` uses for every other CLI-reported path.
33
34
  */
35
+ const isGuard = (file: string): boolean => file.startsWith('guards/');
36
+
34
37
  export async function scanSource(root: string): Promise<Extraction> {
35
38
  const files: string[] = [];
36
39
  for await (const file of eachSourceFile(root)) {
37
- if (!isTest(file)) files.push(file);
40
+ if (!isTest(file) && !isGuard(file)) files.push(file);
38
41
  }
39
42
  const extraction = await extractFromFiles(files.map((file) => join(root, file)));
40
43
  const toRelative = (file: string): string => relative(root, file).split(sep).join('/');
package/src/index.ts CHANGED
@@ -21,7 +21,7 @@ export { appManifest, writeAppManifest } from './app-manifest';
21
21
  export { requireAppRoot } from './app-root';
22
22
  export type { RouteStats } from './budgets';
23
23
  export { checkBudgets, FRAMEWORK_INLINE_SCRIPTS, FRAMEWORK_SCRIPTS } from './budgets';
24
- export { BUILD_ENTRY, BUILD_TARGETS, readTarget } from './cmd-build';
24
+ export { BUILD_ENTRY, BUILD_TARGETS, BuildEntryMissingError, readTarget } from './cmd-build';
25
25
  export { dbCommand } from './cmd-db';
26
26
  export { planDeploy } from './cmd-deploy';
27
27
  export { startDev } from './cmd-dev';
@@ -69,10 +69,8 @@ export {
69
69
  export { checkErrorCodesThrown } from './error-unthrown';
70
70
  export {
71
71
  BadFlagError,
72
- BuildEntryMissingError,
73
72
  BunVersionError,
74
73
  CatalogExistsError,
75
- CliNotImplementedError,
76
74
  DeclarationUnknownError,
77
75
  ErrorCodeUnknownError,
78
76
  FixTargetUnknownError,