@fjall/util 20.0.0 → 21.1.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 (47) hide show
  1. package/dist/.build-source-hash +25 -14
  2. package/dist/.minified +1 -1
  3. package/dist/atomicTempNaming.d.ts +26 -0
  4. package/dist/atomicTempNaming.js +1 -0
  5. package/dist/aws/costAllocationTags.d.ts +18 -0
  6. package/dist/aws/costAllocationTags.js +1 -1
  7. package/dist/aws/index.d.ts +1 -1
  8. package/dist/aws/index.js +1 -1
  9. package/dist/config.d.ts +28 -14
  10. package/dist/config.js +2 -2
  11. package/dist/configPaths.d.ts +22 -0
  12. package/dist/configPaths.js +1 -0
  13. package/dist/fjallHome.d.ts +78 -0
  14. package/dist/fjallHome.js +1 -0
  15. package/dist/fsHelpers.d.ts +9 -0
  16. package/dist/fsHelpers.js +1 -1
  17. package/dist/index.d.ts +7 -1
  18. package/dist/index.js +1 -1
  19. package/dist/logRotation.d.ts +42 -0
  20. package/dist/logRotation.js +1 -0
  21. package/dist/migration/clickhouseFrameworkUsers.d.ts +80 -0
  22. package/dist/migration/clickhouseFrameworkUsers.js +1 -0
  23. package/dist/migration/clickhouseFrameworkUsers.test.d.ts +1 -0
  24. package/dist/migration/clickhouseFrameworkUsers.test.js +1 -0
  25. package/dist/migration/constants.d.ts +29 -7
  26. package/dist/migration/constants.js +1 -1
  27. package/dist/migration/controlPlaneActivity.d.ts +58 -0
  28. package/dist/migration/controlPlaneActivity.js +2 -0
  29. package/dist/migration/index.d.ts +5 -1
  30. package/dist/migration/index.js +1 -1
  31. package/dist/migration/schemaGateExit.d.ts +63 -0
  32. package/dist/migration/schemaGateExit.js +1 -0
  33. package/dist/migration/sleepAbortable.d.ts +10 -0
  34. package/dist/migration/sleepAbortable.js +1 -0
  35. package/dist/patterns/frameworkPatterns.d.ts +151 -0
  36. package/dist/patterns/frameworkPatterns.js +1 -0
  37. package/dist/patterns/index.d.ts +2 -1
  38. package/dist/patterns/index.js +1 -1
  39. package/dist/patterns/patternTypes.d.ts +29 -0
  40. package/dist/patterns/patternTypes.js +1 -1
  41. package/dist/scaffold/ensureGitignore.d.ts +18 -0
  42. package/dist/scaffold/ensureGitignore.js +9 -0
  43. package/dist/scaffold/gitignore.d.ts +47 -0
  44. package/dist/scaffold/gitignore.js +35 -0
  45. package/dist/securityHelpers.d.ts +1 -1
  46. package/dist/securityHelpers.js +1 -1
  47. package/package.json +9 -1
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Cross-package contract for the framework-owned ClickHouse identities —
3
+ * one SQL user per control-plane workload class, so no two classes ever
4
+ * contend for the same per-user concurrency cap.
5
+ *
6
+ * | identity | holder(s) | rights |
7
+ * | -------------------- | -------------------------------------- | ---------------------------------------- |
8
+ * | `schemaAdmin.name` | migration task (XML, `ddl_admin`) | full DDL; cap 1 = DDL single-flight |
9
+ * | `fjall_schema_gate` | materialised gate container | `readonly=2`, SELECT on `_schema_migrations` only; NO per-user cap |
10
+ * | `fjall_maintenance` | backup + optimise sidecars | today's `ddl_admin` numbers (cap 1); OPTIMIZE / BACKUP / RESTORE / scratch DROP |
11
+ *
12
+ * Producer: `ClickHouseDatabase` mints a Secrets-Manager password per
13
+ * framework identity (OUTSIDE the customer `managedPasswords` set — these
14
+ * secrets are never injected into the ClickHouse service container, so
15
+ * adopting them changes no ClickHouse task definition) and
16
+ * `getMigrationContributions()` emits `CLICKHOUSE_FRAMEWORK_USERS` (a
17
+ * JSON-stringified array of the names below) plus a `USER_<NAME>_PASSWORD`
18
+ * import per identity on the migration task.
19
+ *
20
+ * Consumer: `@fjall/clickhouse § provisionFrameworkUsersFromEnv` runs as
21
+ * `schemaAdmin` from the pre-scale-up lifecycle hook — ordered ahead of every
22
+ * gate and sidecar — and issues the idempotent CREATE / ALTER / GRANT set for
23
+ * each identity, so the identities exist before anything authenticates as
24
+ * them. SQL-provisioned (not `users.xml`) deliberately: no ClickHouse
25
+ * restart, no `SYSTEM RELOAD USERS` race, zero outage on adoption.
26
+ *
27
+ * Coupled values — must move together per
28
+ * `.claude/rules/code-quality.md § "Coupled values: shared source at 2
29
+ * occurrences"`.
30
+ */
31
+ import { z } from "zod";
32
+ /** Read-only identity the materialised schema gate authenticates as. */
33
+ export declare const FJALL_SCHEMA_GATE_USER: "fjall_schema_gate";
34
+ /** Identity the backup + optimise maintenance sidecars authenticate as. */
35
+ export declare const FJALL_MAINTENANCE_USER: "fjall_maintenance";
36
+ /**
37
+ * Every framework-owned identity, in provisioning order. Derive from this —
38
+ * never re-list the names — so a new workload class lands in the manifest,
39
+ * the reserved-name validator, and the provisioning loop at once.
40
+ */
41
+ export declare const FJALL_CLICKHOUSE_USERS: readonly ["fjall_schema_gate", "fjall_maintenance"];
42
+ export type FjallClickHouseUser = (typeof FJALL_CLICKHOUSE_USERS)[number];
43
+ /**
44
+ * Prefix reserved for framework identities. `ClickHouseDatabase` throws at
45
+ * synth when a customer `schemaAdmin.name` or `managedPasswords` entry
46
+ * starts with it, so a customer user can never collide with (or be silently
47
+ * re-provisioned as) a framework identity.
48
+ */
49
+ export declare const FJALL_CLICKHOUSE_USER_PREFIX: "fjall_";
50
+ export declare function isReservedClickHouseUserName(name: string): boolean;
51
+ /**
52
+ * Container env var name carrying the JSON-stringified manifest of framework
53
+ * identities to provision. Sibling to `CLICKHOUSE_MANAGED_USERS` (customer
54
+ * users); kept separate because the two sets differ in ownership — the
55
+ * framework decides its identities' rights, customer SQL decides the managed
56
+ * users' profiles.
57
+ */
58
+ export declare const CLICKHOUSE_FRAMEWORK_USERS_ENV: "CLICKHOUSE_FRAMEWORK_USERS";
59
+ /**
60
+ * Container env var name carrying the settings-profile name the maintenance
61
+ * identity binds to — the construct contributes `schemaAdmin.profile` (the
62
+ * `ddl_admin` profile at the resolved instance's scale), so
63
+ * `fjall_maintenance` inherits today's memory / execution caps and tracks
64
+ * instance resizes without the runner knowing any numbers. The runner pins
65
+ * `max_concurrent_queries_for_user = 1 CONST` on top: a bare setting is a
66
+ * default the session can override, only `CONST` makes the cap binding.
67
+ */
68
+ export declare const CLICKHOUSE_MAINTENANCE_PROFILE_ENV: "CLICKHOUSE_MAINTENANCE_PROFILE";
69
+ export declare const FrameworkUserNameSchema: z.ZodString & z.ZodType<"fjall_schema_gate" | "fjall_maintenance", string, z.core.$ZodTypeInternals<"fjall_schema_gate" | "fjall_maintenance", string>>;
70
+ export type FrameworkUserName = z.infer<typeof FrameworkUserNameSchema>;
71
+ export declare const FrameworkUserNamesSchema: z.ZodArray<z.ZodString & z.ZodType<"fjall_schema_gate" | "fjall_maintenance", string, z.core.$ZodTypeInternals<"fjall_schema_gate" | "fjall_maintenance", string>>>;
72
+ export type FrameworkUserNames = z.infer<typeof FrameworkUserNamesSchema>;
73
+ /**
74
+ * Scratch database the backup sidecar restores into to verify a backup, and
75
+ * the only database `fjall_maintenance` may DROP. Coupled across the
76
+ * construct (backup script, IAM) and `@fjall/clickhouse` (the GRANT set) —
77
+ * a drift would grant DROP on a database the script never touches while the
78
+ * one it does touch fails with `ACCESS_DENIED`.
79
+ */
80
+ export declare const CLICKHOUSE_BACKUP_SCRATCH_DATABASE: "fjall_backup_verify";
@@ -0,0 +1 @@
1
+ var r=Object.defineProperty;var E=(e,o)=>r(e,"name",{value:o,configurable:!0});import{z as _}from"zod";import{MANAGED_USER_NAME_PATTERN as s}from"./clickhouseSqlUsers.js";const S="fjall_schema_gate",a="fjall_maintenance",t=[S,a],A="fjall_";function L(e){return e.startsWith(A)}E(L,"isReservedClickHouseUserName");const N="CLICKHOUSE_FRAMEWORK_USERS",m="CLICKHOUSE_MAINTENANCE_PROFILE",n=new Set(t),C=_.string().regex(s,"Must be lowercase snake_case").refine(e=>n.has(e),{message:`Must be one of ${t.join(", ")}`}),p=_.array(C),I="fjall_backup_verify";export{I as CLICKHOUSE_BACKUP_SCRATCH_DATABASE,N as CLICKHOUSE_FRAMEWORK_USERS_ENV,m as CLICKHOUSE_MAINTENANCE_PROFILE_ENV,t as FJALL_CLICKHOUSE_USERS,A as FJALL_CLICKHOUSE_USER_PREFIX,a as FJALL_MAINTENANCE_USER,S as FJALL_SCHEMA_GATE_USER,C as FrameworkUserNameSchema,p as FrameworkUserNamesSchema,L as isReservedClickHouseUserName};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ import{describe as a,expect as e,it as s}from"vitest";import{CLICKHOUSE_FRAMEWORK_USERS_ENV as f,FJALL_CLICKHOUSE_USER_PREFIX as C,FJALL_CLICKHOUSE_USERS as c,FJALL_MAINTENANCE_USER as l,FJALL_SCHEMA_GATE_USER as h,FrameworkUserNamesSchema as d,isReservedClickHouseUserName as n}from"./clickhouseFrameworkUsers.js";import{MANAGED_USER_NAME_PATTERN as A}from"./clickhouseSqlUsers.js";import{CONTROL_PLANE_ACTIVITY_RECENCY_SECONDS as S,buildControlPlaneActivityQuery as r}from"./controlPlaneActivity.js";import{SCHEMA_GATE_EXIT as i,SCHEMA_GATE_EXIT_HINTS as y,schemaGateExitKind as m}from"./schemaGateExit.js";import{sleepAbortable as E}from"./sleepAbortable.js";a("framework ClickHouse identities",()=>{s("every framework identity carries the reserved prefix and is a valid SQL name",()=>{for(const t of c)e(n(t)).toBe(!0),e(t).toMatch(A);e(c).toEqual([h,l])}),s("customer names outside the prefix are not reserved",()=>{e(n("schema_admin")).toBe(!1),e(n("backup_reader")).toBe(!1),e(n(`${C}x`)).toBe(!0)}),s("the manifest schema accepts only framework identities",()=>{e(d.safeParse([...c]).success).toBe(!0),e(d.safeParse(["fjall_other"]).success).toBe(!1),e(d.safeParse(["schema_admin"]).success).toBe(!1),e(f).toBe("CLICKHOUSE_FRAMEWORK_USERS")})}),a("schema gate exit taxonomy",()=>{s("codes are distinct and every kind has a hint",()=>{const t=Object.values(i);e(new Set(t).size).toBe(t.length);for(const o of Object.keys(i))e(y[o].length).toBeGreaterThan(0),e(m(i[o])).toBe(o)}),s("pins the wire values consumers already depend on",()=>{e(i).toEqual({pass:0,refused:1,configError:2,connectionError:3,busy:4,denied:5})}),s("returns undefined for codes the gate never emits",()=>{e(m(143)).toBeUndefined(),e(m(137)).toBeUndefined()})}),a("buildControlPlaneActivityQuery",()=>{s("probes both live processes and recent query_log for the named users",()=>{const t=r({users:["schema_admin",l]});e(t).toContain("FROM system.processes WHERE user IN ('schema_admin', 'fjall_maintenance')"),e(t).toContain("FROM system.query_log WHERE user IN ('schema_admin', 'fjall_maintenance')"),e(t).toContain(`INTERVAL ${S} SECOND`),e(t).toContain("type != 'QueryStart'"),e(t).toContain("event_date >= yesterday()"),e(t.trim().endsWith("AS active")).toBe(!0)}),s("honours a recency override",()=>{e(r({users:["schema_admin"],recencySeconds:5})).toContain("INTERVAL 5 SECOND")}),s("rejects names that could smuggle quoting, empty user lists, and bad recency",()=>{e(()=>r({users:["x' OR 1=1 --"]})).toThrow(/Invalid ClickHouse user name/),e(()=>r({users:[]})).toThrow(/at least one user/),e(()=>r({users:["a"],recencySeconds:0})).toThrow(/positive integer/)})}),a("sleepAbortable",()=>{s("resolves immediately on an already-aborted signal and on abort mid-sleep",async()=>{const t=new AbortController;t.abort(),await e(E(6e4,t.signal)).resolves.toBeUndefined();const o=new AbortController,u=Date.now(),_=E(6e4,o.signal);o.abort(),await _,e(Date.now()-u).toBeLessThan(1e3)})});
@@ -36,13 +36,13 @@ export declare const EXPECTED_SCHEMA_VERSION_TOOL_ENV: "EXPECTED_SCHEMA_VERSION_
36
36
  export declare const EXPECTED_CH_SCHEMA_VERSION_ENV: "EXPECTED_CH_SCHEMA_VERSION";
37
37
  /**
38
38
  * Container env / secret names for the ClickHouse schema-admin user contributed
39
- * by `ClickHouseDatabase`. Both the migration task AND every connected service
40
- * whose `connections:` includes a migration-declaring CH database receive
41
- * these, so the boot-time schema gate can authenticate against
42
- * `_schema_migrations`. Drift between the framework injection site, the
43
- * migration runner, and the boot gate would mask a missing-credential failure
44
- * as a generic "schema version mismatch" hoisted to one site so all three
45
- * always read from the same name.
39
+ * by `ClickHouseDatabase`. Injected ONLY into the migration task (the sole
40
+ * DDL principal) never into app containers, the gate container, or the
41
+ * maintenance sidecars, each of which authenticates as its own workload-class
42
+ * identity (see `clickhouseFrameworkUsers.ts`). One XML principal shared by
43
+ * six holders under a `max_concurrent_queries_for_user = 1` cap was the
44
+ * 2026-08-25 deploy-rollback incident: a gate probe lost the slot to the
45
+ * scheduled OPTIMIZE and reported the server "unreachable".
46
46
  */
47
47
  export declare const SCHEMA_ADMIN_USER_ENV: "SCHEMA_ADMIN_USER";
48
48
  export declare const SCHEMA_ADMIN_PASSWORD_ENV: "SCHEMA_ADMIN_PASSWORD";
@@ -76,6 +76,28 @@ export declare const SCHEMA_GATE_DB_PASSWORD_ENV: "FJALL_SCHEMA_GATE_DB_PASSWORD
76
76
  export declare const SCHEMA_GATE_CH_URL_ENV: "CLICKHOUSE_URL";
77
77
  export declare const SCHEMA_GATE_CH_DATABASE_ENV: "CLICKHOUSE_DATABASE";
78
78
  export declare const SCHEMA_GATE_CH_CA_CERT_ENV: "CLICKHOUSE_CA_CERT";
79
+ /**
80
+ * ClickHouse credential env names for the gate container's CH half. The
81
+ * value of `SCHEMA_GATE_CH_USER_ENV` is always `FJALL_SCHEMA_GATE_USER` (the
82
+ * read-only gate identity), and `SCHEMA_GATE_CH_PASSWORD_ENV` is a
83
+ * Secrets-Manager import of that identity's password. Named in the
84
+ * `FJALL_SCHEMA_GATE_*` family alongside the Postgres trio so a reader of the
85
+ * task definition sees one gate contract, not a gate half and a borrowed
86
+ * admin half.
87
+ */
88
+ export declare const SCHEMA_GATE_CH_USER_ENV: "FJALL_SCHEMA_GATE_CH_USER";
89
+ export declare const SCHEMA_GATE_CH_PASSWORD_ENV: "FJALL_SCHEMA_GATE_CH_PASSWORD";
90
+ /**
91
+ * ECS `startTimeout` for the synthetic gate container — the ceiling ECS
92
+ * allows the gate to reach its exit before the task is stopped as a failed
93
+ * start. The gate runner derives its own retry budget FROM this value (it
94
+ * must finish, with a verdict, comfortably inside it), so the two are coupled
95
+ * across the construct↔runner boundary: a construct that raised the timeout
96
+ * without the runner widening its budget would leave headroom unused; a
97
+ * runner budget exceeding the timeout would be cut off mid-retry with no
98
+ * verdict at all. Hoisted so both read one number.
99
+ */
100
+ export declare const SCHEMA_GATE_START_TIMEOUT_SECONDS: 120;
79
101
  /**
80
102
  * The synthetic gate container's reserved name. Shared between the construct
81
103
  * (container synthesis + the name-collision validator in `validateEcsProps`)
@@ -1 +1 @@
1
- const E="fjall-premigrate",_="EXPECTED_SCHEMA_VERSION",A="EXPECTED_SCHEMA_VERSION_TOOL",S="EXPECTED_CH_SCHEMA_VERSION",C="SCHEMA_ADMIN_USER",t="SCHEMA_ADMIN_PASSWORD",o="FJALL_SCHEMA_GATE_DB_URL_BASE",M="FJALL_SCHEMA_GATE_DB_USER",R="FJALL_SCHEMA_GATE_DB_PASSWORD",H="CLICKHOUSE_URL",I="CLICKHOUSE_DATABASE",N="CLICKHOUSE_CA_CERT",T="fjall-schema-gate",e="fjall/schema-gate",O="public.ecr.aws/fjall/schema-gate",s=/^\d{14}_/,c=/\.dev\.sql$/,D=/\.sql$/;export{D as CLICKHOUSE_MIGRATION_FILE_RE,c as CLICKHOUSE_MIGRATION_SKIP_RE,S as EXPECTED_CH_SCHEMA_VERSION_ENV,_ as EXPECTED_SCHEMA_VERSION_ENV,A as EXPECTED_SCHEMA_VERSION_TOOL_ENV,E as MIGRATION_SNAPSHOT_NAME_PREFIX,s as PRISMA_MIGRATION_DIR_RE,t as SCHEMA_ADMIN_PASSWORD_ENV,C as SCHEMA_ADMIN_USER_ENV,N as SCHEMA_GATE_CH_CA_CERT_ENV,I as SCHEMA_GATE_CH_DATABASE_ENV,H as SCHEMA_GATE_CH_URL_ENV,T as SCHEMA_GATE_CONTAINER_NAME,R as SCHEMA_GATE_DB_PASSWORD_ENV,o as SCHEMA_GATE_DB_URL_BASE_ENV,M as SCHEMA_GATE_DB_USER_ENV,e as SCHEMA_GATE_ECR_REPO_NAME,O as SCHEMA_GATE_PUBLIC_IMAGE_REPO};
1
+ const _="fjall-premigrate",E="EXPECTED_SCHEMA_VERSION",A="EXPECTED_SCHEMA_VERSION_TOOL",S="EXPECTED_CH_SCHEMA_VERSION",C="SCHEMA_ADMIN_USER",t="SCHEMA_ADMIN_PASSWORD",o="FJALL_SCHEMA_GATE_DB_URL_BASE",H="FJALL_SCHEMA_GATE_DB_USER",M="FJALL_SCHEMA_GATE_DB_PASSWORD",R="CLICKHOUSE_URL",T="CLICKHOUSE_DATABASE",N="CLICKHOUSE_CA_CERT",I="FJALL_SCHEMA_GATE_CH_USER",e="FJALL_SCHEMA_GATE_CH_PASSWORD",O=120,s="fjall-schema-gate",c="fjall/schema-gate",D="public.ecr.aws/fjall/schema-gate",r=/^\d{14}_/,p=/\.dev\.sql$/,L=/\.sql$/;export{L as CLICKHOUSE_MIGRATION_FILE_RE,p as CLICKHOUSE_MIGRATION_SKIP_RE,S as EXPECTED_CH_SCHEMA_VERSION_ENV,E as EXPECTED_SCHEMA_VERSION_ENV,A as EXPECTED_SCHEMA_VERSION_TOOL_ENV,_ as MIGRATION_SNAPSHOT_NAME_PREFIX,r as PRISMA_MIGRATION_DIR_RE,t as SCHEMA_ADMIN_PASSWORD_ENV,C as SCHEMA_ADMIN_USER_ENV,N as SCHEMA_GATE_CH_CA_CERT_ENV,T as SCHEMA_GATE_CH_DATABASE_ENV,e as SCHEMA_GATE_CH_PASSWORD_ENV,R as SCHEMA_GATE_CH_URL_ENV,I as SCHEMA_GATE_CH_USER_ENV,s as SCHEMA_GATE_CONTAINER_NAME,M as SCHEMA_GATE_DB_PASSWORD_ENV,o as SCHEMA_GATE_DB_URL_BASE_ENV,H as SCHEMA_GATE_DB_USER_ENV,c as SCHEMA_GATE_ECR_REPO_NAME,D as SCHEMA_GATE_PUBLIC_IMAGE_REPO,O as SCHEMA_GATE_START_TIMEOUT_SECONDS};
@@ -0,0 +1,58 @@
1
+ /**
2
+ * ClickHouse-native activity probe for the migrate ↔ maintenance mutex.
3
+ *
4
+ * The migration task (Compute stack) and the maintenance sidecars (Database
5
+ * stack) cannot see each other through ECS — the Database stack is deployed
6
+ * first and cannot reference the Compute stack — so the mutual exclusion is
7
+ * observed through the server both already talk to: `system.processes`
8
+ * (queries running right now) plus `system.query_log` recency (a query that
9
+ * finished within the last `recencySeconds`). Each side asks about the OTHER
10
+ * control-plane identity and defers while it is active:
11
+ *
12
+ * - the maintenance wrapper asks about `schemaAdmin.name` before OPTIMIZE /
13
+ * BACKUP and exits `deferred` (exit 0, metric) when a migration is under way;
14
+ * - the migration runner asks about `fjall_maintenance` before its ClickHouse
15
+ * phase and waits (bounded, abort-aware) for the sidecar to go idle.
16
+ *
17
+ * The recency window closes the gap between one statement of a multi-
18
+ * statement job finishing and the next one starting (there is no process
19
+ * row in between). Both sides read the same builder so the two halves of
20
+ * the mutex cannot drift in what "active" means.
21
+ *
22
+ * `system.query_log` is flushed on an interval (7500 ms in the constructs'
23
+ * server config), so the recency signal lands up to one flush late; the
24
+ * residual window is the sub-second gap between a process row vanishing and
25
+ * its finish row landing, and the two identities carry separate concurrency
26
+ * caps, so a probe that slips through it costs contention, never a 202. Two
27
+ * server-side preconditions keep the recency half honest: the table only
28
+ * exists after the first flush (a brand-new server answers `Code: 60` until
29
+ * then — the probe's own queries create it inside one interval, which is why
30
+ * `awaitControlPlaneIdle` keeps polling through non-denied errors), and
31
+ * `log_queries_min_query_duration_ms` must stay 0 (see `clickhouseTuning`),
32
+ * else short control-plane statements never reach the log at all.
33
+ *
34
+ * The caller's own identity is never in `users`, so no self-exclusion is
35
+ * needed; the probe is a plain SELECT the gate/maintenance/admin identities
36
+ * are all granted on `system.processes` and `system.query_log`.
37
+ */
38
+ /**
39
+ * How long after a control-plane query completes the identity is still
40
+ * considered active. Long enough to bridge a sidecar's per-statement gaps
41
+ * (the backup script runs DROP → BACKUP → RESTORE back-to-back), short
42
+ * enough that a finished job releases the mutex well inside one deploy.
43
+ */
44
+ export declare const CONTROL_PLANE_ACTIVITY_RECENCY_SECONDS: 120;
45
+ export interface ControlPlaneActivityQueryOpts {
46
+ /** Identities to test for activity — the OTHER side of the mutex. */
47
+ users: readonly string[];
48
+ /** Overrides `CONTROL_PLANE_ACTIVITY_RECENCY_SECONDS`. */
49
+ recencySeconds?: number;
50
+ }
51
+ /**
52
+ * Builds a single-row `SELECT active` returning `1` when any of `users` has
53
+ * a running query or finished one within the recency window, else `0`.
54
+ * Names are validated against the managed-user pattern before interpolation
55
+ * — the only quoting the SQL needs — so a name that could carry a quote
56
+ * never reaches the string.
57
+ */
58
+ export declare function buildControlPlaneActivityQuery(opts: ControlPlaneActivityQueryOpts): string;
@@ -0,0 +1,2 @@
1
+ var s=Object.defineProperty;var o=(e,r)=>s(e,"name",{value:r,configurable:!0});import{MANAGED_USER_NAME_PATTERN as i}from"./clickhouseSqlUsers.js";const E=120;function y(e){const r=e.recencySeconds??E;if(!Number.isInteger(r)||r<=0)throw new Error(`recencySeconds must be a positive integer, got ${String(r)}`);if(e.users.length===0)throw new Error("buildControlPlaneActivityQuery needs at least one user");for(const t of e.users)if(!i.test(t))throw new Error(`Invalid ClickHouse user name for activity probe: ${t}`);const n=e.users.map(t=>`'${t}'`).join(", ");return["SELECT toUInt8(",` (SELECT count() FROM system.processes WHERE user IN (${n})) > 0`," OR",` (SELECT count() FROM system.query_log WHERE user IN (${n})`," AND event_date >= yesterday()"," AND type != 'QueryStart'",` AND event_time >= now() - INTERVAL ${r} SECOND) > 0`,") AS active"].join(`
2
+ `)}o(y,"buildControlPlaneActivityQuery");export{E as CONTROL_PLANE_ACTIVITY_RECENCY_SECONDS,y as buildControlPlaneActivityQuery};
@@ -1,6 +1,10 @@
1
- export { MIGRATION_SNAPSHOT_NAME_PREFIX, EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, EXPECTED_CH_SCHEMA_VERSION_ENV, SCHEMA_ADMIN_USER_ENV, SCHEMA_ADMIN_PASSWORD_ENV, SCHEMA_GATE_DB_URL_BASE_ENV, SCHEMA_GATE_DB_USER_ENV, SCHEMA_GATE_DB_PASSWORD_ENV, SCHEMA_GATE_CH_URL_ENV, SCHEMA_GATE_CH_DATABASE_ENV, SCHEMA_GATE_CH_CA_CERT_ENV, SCHEMA_GATE_CONTAINER_NAME, SCHEMA_GATE_ECR_REPO_NAME, SCHEMA_GATE_PUBLIC_IMAGE_REPO, PRISMA_MIGRATION_DIR_RE, CLICKHOUSE_MIGRATION_SKIP_RE } from "./constants.js";
1
+ export { MIGRATION_SNAPSHOT_NAME_PREFIX, EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, EXPECTED_CH_SCHEMA_VERSION_ENV, SCHEMA_ADMIN_USER_ENV, SCHEMA_ADMIN_PASSWORD_ENV, SCHEMA_GATE_DB_URL_BASE_ENV, SCHEMA_GATE_DB_USER_ENV, SCHEMA_GATE_DB_PASSWORD_ENV, SCHEMA_GATE_CH_URL_ENV, SCHEMA_GATE_CH_DATABASE_ENV, SCHEMA_GATE_CH_CA_CERT_ENV, SCHEMA_GATE_CH_USER_ENV, SCHEMA_GATE_CH_PASSWORD_ENV, SCHEMA_GATE_START_TIMEOUT_SECONDS, SCHEMA_GATE_CONTAINER_NAME, SCHEMA_GATE_ECR_REPO_NAME, SCHEMA_GATE_PUBLIC_IMAGE_REPO, PRISMA_MIGRATION_DIR_RE, CLICKHOUSE_MIGRATION_SKIP_RE } from "./constants.js";
2
2
  export { pickLatestPrismaMigration } from "./pickLatestPrismaMigration.js";
3
3
  export { pickLatestClickHouseMigration } from "./pickLatestClickHouseMigration.js";
4
4
  export { isOrderableSchemaVersion, isSchemaVersionSatisfied } from "./compareSchemaVersion.js";
5
5
  export { type MigrationsSqlClient, type VerifyExpectedSchemaVersionOpts, type VerifyExpectedSchemaVersionResult, verifyExpectedSchemaVersion } from "./verifyExpectedSchemaVersion.js";
6
6
  export { CLICKHOUSE_MANAGED_USERS_ENV, MANAGED_USER_NAME_PATTERN, userPasswordEnvName, ManagedUserNameSchema, ManagedUserNamesSchema, type ManagedUserName, type ManagedUserNames } from "./clickhouseSqlUsers.js";
7
+ export { FJALL_SCHEMA_GATE_USER, FJALL_MAINTENANCE_USER, FJALL_CLICKHOUSE_USERS, FJALL_CLICKHOUSE_USER_PREFIX, isReservedClickHouseUserName, CLICKHOUSE_FRAMEWORK_USERS_ENV, CLICKHOUSE_MAINTENANCE_PROFILE_ENV, CLICKHOUSE_BACKUP_SCRATCH_DATABASE, FrameworkUserNameSchema, FrameworkUserNamesSchema, type FjallClickHouseUser, type FrameworkUserName, type FrameworkUserNames } from "./clickhouseFrameworkUsers.js";
8
+ export { SCHEMA_GATE_EXIT, SCHEMA_GATE_EXIT_HINTS, schemaGateExitKind, type SchemaGateExitKind, type SchemaGateExitCode } from "./schemaGateExit.js";
9
+ export { CONTROL_PLANE_ACTIVITY_RECENCY_SECONDS, buildControlPlaneActivityQuery, type ControlPlaneActivityQueryOpts } from "./controlPlaneActivity.js";
10
+ export { sleepAbortable } from "./sleepAbortable.js";
@@ -1 +1 @@
1
- import{MIGRATION_SNAPSHOT_NAME_PREFIX as A,EXPECTED_SCHEMA_VERSION_ENV as S,EXPECTED_SCHEMA_VERSION_TOOL_ENV as N,EXPECTED_CH_SCHEMA_VERSION_ENV as e,SCHEMA_ADMIN_USER_ENV as C,SCHEMA_ADMIN_PASSWORD_ENV as M,SCHEMA_GATE_DB_URL_BASE_ENV as r,SCHEMA_GATE_DB_USER_ENV as R,SCHEMA_GATE_DB_PASSWORD_ENV as H,SCHEMA_GATE_CH_URL_ENV as T,SCHEMA_GATE_CH_DATABASE_ENV as a,SCHEMA_GATE_CH_CA_CERT_ENV as I,SCHEMA_GATE_CONTAINER_NAME as o,SCHEMA_GATE_ECR_REPO_NAME as V,SCHEMA_GATE_PUBLIC_IMAGE_REPO as O,PRISMA_MIGRATION_DIR_RE as i,CLICKHOUSE_MIGRATION_SKIP_RE as s}from"./constants.js";import{pickLatestPrismaMigration as G}from"./pickLatestPrismaMigration.js";import{pickLatestClickHouseMigration as t}from"./pickLatestClickHouseMigration.js";import{isOrderableSchemaVersion as U,isSchemaVersionSatisfied as c}from"./compareSchemaVersion.js";import{verifyExpectedSchemaVersion as f}from"./verifyExpectedSchemaVersion.js";import{CLICKHOUSE_MANAGED_USERS_ENV as L,MANAGED_USER_NAME_PATTERN as x,userPasswordEnvName as d,ManagedUserNameSchema as B,ManagedUserNamesSchema as h}from"./clickhouseSqlUsers.js";export{L as CLICKHOUSE_MANAGED_USERS_ENV,s as CLICKHOUSE_MIGRATION_SKIP_RE,e as EXPECTED_CH_SCHEMA_VERSION_ENV,S as EXPECTED_SCHEMA_VERSION_ENV,N as EXPECTED_SCHEMA_VERSION_TOOL_ENV,x as MANAGED_USER_NAME_PATTERN,A as MIGRATION_SNAPSHOT_NAME_PREFIX,B as ManagedUserNameSchema,h as ManagedUserNamesSchema,i as PRISMA_MIGRATION_DIR_RE,M as SCHEMA_ADMIN_PASSWORD_ENV,C as SCHEMA_ADMIN_USER_ENV,I as SCHEMA_GATE_CH_CA_CERT_ENV,a as SCHEMA_GATE_CH_DATABASE_ENV,T as SCHEMA_GATE_CH_URL_ENV,o as SCHEMA_GATE_CONTAINER_NAME,H as SCHEMA_GATE_DB_PASSWORD_ENV,r as SCHEMA_GATE_DB_URL_BASE_ENV,R as SCHEMA_GATE_DB_USER_ENV,V as SCHEMA_GATE_ECR_REPO_NAME,O as SCHEMA_GATE_PUBLIC_IMAGE_REPO,U as isOrderableSchemaVersion,c as isSchemaVersionSatisfied,t as pickLatestClickHouseMigration,G as pickLatestPrismaMigration,d as userPasswordEnvName,f as verifyExpectedSchemaVersion};
1
+ import{MIGRATION_SNAPSHOT_NAME_PREFIX as A,EXPECTED_SCHEMA_VERSION_ENV as S,EXPECTED_SCHEMA_VERSION_TOOL_ENV as C,EXPECTED_CH_SCHEMA_VERSION_ENV as e,SCHEMA_ADMIN_USER_ENV as N,SCHEMA_ADMIN_PASSWORD_ENV as r,SCHEMA_GATE_DB_URL_BASE_ENV as T,SCHEMA_GATE_DB_USER_ENV as M,SCHEMA_GATE_DB_PASSWORD_ENV as R,SCHEMA_GATE_CH_URL_ENV as H,SCHEMA_GATE_CH_DATABASE_ENV as I,SCHEMA_GATE_CH_CA_CERT_ENV as o,SCHEMA_GATE_CH_USER_ENV as a,SCHEMA_GATE_CH_PASSWORD_ENV as O,SCHEMA_GATE_START_TIMEOUT_SECONDS as m,SCHEMA_GATE_CONTAINER_NAME as U,SCHEMA_GATE_ECR_REPO_NAME as s,SCHEMA_GATE_PUBLIC_IMAGE_REPO as t,PRISMA_MIGRATION_DIR_RE as L,CLICKHOUSE_MIGRATION_SKIP_RE as i}from"./constants.js";import{pickLatestPrismaMigration as G}from"./pickLatestPrismaMigration.js";import{pickLatestClickHouseMigration as D}from"./pickLatestClickHouseMigration.js";import{isOrderableSchemaVersion as p,isSchemaVersionSatisfied as f}from"./compareSchemaVersion.js";import{verifyExpectedSchemaVersion as n}from"./verifyExpectedSchemaVersion.js";import{CLICKHOUSE_MANAGED_USERS_ENV as F,MANAGED_USER_NAME_PATTERN as d,userPasswordEnvName as h,ManagedUserNameSchema as l,ManagedUserNamesSchema as B}from"./clickhouseSqlUsers.js";import{FJALL_SCHEMA_GATE_USER as k,FJALL_MAINTENANCE_USER as u,FJALL_CLICKHOUSE_USERS as b,FJALL_CLICKHOUSE_USER_PREFIX as g,isReservedClickHouseUserName as v,CLICKHOUSE_FRAMEWORK_USERS_ENV as J,CLICKHOUSE_MAINTENANCE_PROFILE_ENV as W,CLICKHOUSE_BACKUP_SCRATCH_DATABASE as w,FrameworkUserNameSchema as y,FrameworkUserNamesSchema as Y}from"./clickhouseFrameworkUsers.js";import{SCHEMA_GATE_EXIT as j,SCHEMA_GATE_EXIT_HINTS as q,schemaGateExitKind as z}from"./schemaGateExit.js";import{CONTROL_PLANE_ACTIVITY_RECENCY_SECONDS as $,buildControlPlaneActivityQuery as EE}from"./controlPlaneActivity.js";import{sleepAbortable as AE}from"./sleepAbortable.js";export{w as CLICKHOUSE_BACKUP_SCRATCH_DATABASE,J as CLICKHOUSE_FRAMEWORK_USERS_ENV,W as CLICKHOUSE_MAINTENANCE_PROFILE_ENV,F as CLICKHOUSE_MANAGED_USERS_ENV,i as CLICKHOUSE_MIGRATION_SKIP_RE,$ as CONTROL_PLANE_ACTIVITY_RECENCY_SECONDS,e as EXPECTED_CH_SCHEMA_VERSION_ENV,S as EXPECTED_SCHEMA_VERSION_ENV,C as EXPECTED_SCHEMA_VERSION_TOOL_ENV,b as FJALL_CLICKHOUSE_USERS,g as FJALL_CLICKHOUSE_USER_PREFIX,u as FJALL_MAINTENANCE_USER,k as FJALL_SCHEMA_GATE_USER,y as FrameworkUserNameSchema,Y as FrameworkUserNamesSchema,d as MANAGED_USER_NAME_PATTERN,A as MIGRATION_SNAPSHOT_NAME_PREFIX,l as ManagedUserNameSchema,B as ManagedUserNamesSchema,L as PRISMA_MIGRATION_DIR_RE,r as SCHEMA_ADMIN_PASSWORD_ENV,N as SCHEMA_ADMIN_USER_ENV,o as SCHEMA_GATE_CH_CA_CERT_ENV,I as SCHEMA_GATE_CH_DATABASE_ENV,O as SCHEMA_GATE_CH_PASSWORD_ENV,H as SCHEMA_GATE_CH_URL_ENV,a as SCHEMA_GATE_CH_USER_ENV,U as SCHEMA_GATE_CONTAINER_NAME,R as SCHEMA_GATE_DB_PASSWORD_ENV,T as SCHEMA_GATE_DB_URL_BASE_ENV,M as SCHEMA_GATE_DB_USER_ENV,s as SCHEMA_GATE_ECR_REPO_NAME,j as SCHEMA_GATE_EXIT,q as SCHEMA_GATE_EXIT_HINTS,t as SCHEMA_GATE_PUBLIC_IMAGE_REPO,m as SCHEMA_GATE_START_TIMEOUT_SECONDS,EE as buildControlPlaneActivityQuery,p as isOrderableSchemaVersion,v as isReservedClickHouseUserName,f as isSchemaVersionSatisfied,D as pickLatestClickHouseMigration,G as pickLatestPrismaMigration,z as schemaGateExitKind,AE as sleepAbortable,h as userPasswordEnvName,n as verifyExpectedSchemaVersion};
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Exit taxonomy for the materialised schema-gate container
3
+ * (`@fjall/schema-gate`). The dependent app containers declare
4
+ * `dependsOn: [{ container: "fjall-schema-gate", condition: "SUCCESS" }]`, so
5
+ * ANY non-zero exit stops the task pre-RUNNING and counts toward the
6
+ * deployment circuit breaker — the codes exist for the forensic log and the
7
+ * deploy-time ECS tail, not for ECS itself.
8
+ *
9
+ * Lives in `@fjall/util` because three packages must agree on it: the gate
10
+ * runner emits the codes, `@fjall/deploy-core`'s deployment tail translates
11
+ * them into one human line, and the constructs' docs cite them. Coupled
12
+ * values across package boundaries — a drift would have the tail describe
13
+ * an exit the runner never emits.
14
+ *
15
+ * - `pass` (0) — every configured target verified; app containers start.
16
+ * - `refused` (1) — a target's live schema does not satisfy the expected
17
+ * version (forward skew, or a never-migrated database). The gate did its
18
+ * job; the deploy must roll back or the migration must land first.
19
+ * - `configError` (2) — the gate could not even attempt verification:
20
+ * missing/empty env, unsupported tool or engine family, or an internal
21
+ * error. Points at a construct bug or an author override.
22
+ * - `connectionError` (3) — the server could not be reached after the
23
+ * bounded retry budget (network, TLS, timeout). Indeterminate: the schema
24
+ * may be fine.
25
+ * - `busy` (4) — the server was reachable and answered every attempt, but
26
+ * refused to run the probe because a concurrency cap or quota was
27
+ * exhausted (ClickHouse `TOO_MANY_SIMULTANEOUS_QUERIES` 202 /
28
+ * `QUOTA_EXCEEDED` 201, Postgres `too_many_connections` 53300 /
29
+ * `cannot_connect_now` 57P03) for the whole budget. Indeterminate, and
30
+ * distinct from `connectionError` because the cure is different: the
31
+ * server is healthy and something else holds the slot.
32
+ * - `denied` (5) — the server was reachable and rejected the gate's
33
+ * identity or its rights (auth failure, unknown user, missing grant,
34
+ * read-only violation). Definitive: no retry will change it, and the fix
35
+ * is provisioning, not capacity.
36
+ *
37
+ * Precedence when targets disagree: `configError` is detected before any
38
+ * connection is attempted; then `refused` (definitive evidence), then
39
+ * `denied` (definitive), then `busy`, then `connectionError` (both
40
+ * indeterminate; `busy` carries the more specific cure).
41
+ */
42
+ export declare const SCHEMA_GATE_EXIT: {
43
+ readonly pass: 0;
44
+ readonly refused: 1;
45
+ readonly configError: 2;
46
+ readonly connectionError: 3;
47
+ readonly busy: 4;
48
+ readonly denied: 5;
49
+ };
50
+ export type SchemaGateExitKind = keyof typeof SCHEMA_GATE_EXIT;
51
+ export type SchemaGateExitCode = (typeof SCHEMA_GATE_EXIT)[SchemaGateExitKind];
52
+ /**
53
+ * One human line per exit kind, for the deploy-time ECS tail and forensic
54
+ * logs. Keyed on the taxonomy so adding an exit kind without a hint is a
55
+ * compile error (`Record<SchemaGateExitKind, string>` rejects a missing key).
56
+ */
57
+ export declare const SCHEMA_GATE_EXIT_HINTS: Record<SchemaGateExitKind, string>;
58
+ /**
59
+ * Inverse lookup for the deployment tail: an ECS container exit code → the
60
+ * taxonomy kind, or `undefined` for a code the gate never emits (a signal
61
+ * exit such as 143, or an OOM 137).
62
+ */
63
+ export declare function schemaGateExitKind(code: number): SchemaGateExitKind | undefined;
@@ -0,0 +1 @@
1
+ var o=Object.defineProperty;var t=(e,r)=>o(e,"name",{value:r,configurable:!0});const a={pass:0,refused:1,configError:2,connectionError:3,busy:4,denied:5},i={pass:"schema gate passed",refused:"schema gate refused: live schema does not satisfy the expected version \u2014 land the migration or roll back",configError:"schema gate could not run: missing or invalid gate configuration \u2014 check the task definition's FJALL_SCHEMA_GATE_* env",connectionError:"schema gate could not reach the database within its budget \u2014 check network reachability and database health",busy:"schema gate was refused a query slot for its whole budget \u2014 the database is healthy; another workload holds the concurrency cap or quota",denied:"schema gate identity was rejected \u2014 the gate user's credentials or grants are wrong; re-run the migration task to re-provision it"},s=new Map(Object.keys(a).map(e=>[a[e],e]));function c(e){return s.get(e)}t(c,"schemaGateExitKind");export{a as SCHEMA_GATE_EXIT,i as SCHEMA_GATE_EXIT_HINTS,c as schemaGateExitKind};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Promise-based sleep that short-circuits when the supplied abort signal
3
+ * fires. Poll and retry loops that can be running when a shutdown signal
4
+ * arrives (SIGTERM in a worker, an ECS stop of the gate container) MUST use
5
+ * this for inter-attempt delays so the signal does not stall for the full
6
+ * sleep. Resolves (never rejects) on abort; callers check `signal.aborted`
7
+ * after waking. Shared by `@fjall/clickhouse`, `@fjall/schema-gate` and
8
+ * `@fjall/deploy-core`.
9
+ */
10
+ export declare function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void>;
@@ -0,0 +1 @@
1
+ var u=Object.defineProperty;var r=(t,e)=>u(t,"name",{value:e,configurable:!0});function m(t,e){return e?.aborted===!0?Promise.resolve():new Promise(o=>{const i=setTimeout(()=>{e?.removeEventListener("abort",n),o()},t),n=r(()=>{clearTimeout(i),o()},"onAbort");e?.addEventListener("abort",n,{once:!0})})}r(m,"sleepAbortable");export{m as sleepAbortable};
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The framework vocabulary and its bridge to the pattern vocabulary.
3
+ *
4
+ * Detection answers "what did the author build this with?" (`Framework`).
5
+ * Creation asks "which shape does Fjall deploy?" (`PatternType`). Those are
6
+ * different questions with different membership, and until this module nothing
7
+ * joined them: `apps detect` reported `astro`, the agent skill told the agent to
8
+ * feed what detect reported straight into `--pattern`, and `--pattern astro`
9
+ * reached `PATTERN_REGISTRY[...]` as an unchecked index and threw a `TypeError`
10
+ * that surfaced to the agent as `code: UNKNOWN`. Five of the seven framework
11
+ * values had no pattern at all; the join existed only in the skill's prose.
12
+ *
13
+ * `FRAMEWORK_PATTERN_RECOMMENDATIONS` is the compiler's checklist for that
14
+ * join. It is a `Record<Framework, PatternRecommendation>`, so an eighth
15
+ * framework fails to compile until someone states what it means for
16
+ * `--pattern` — the same discipline `PATTERN_REGISTRY` applies to patterns.
17
+ *
18
+ * The three verdicts are deliberately distinct, because they are three
19
+ * different instructions to hand an agent mid-onboarding:
20
+ *
21
+ * - `recommended` — a deployable pattern fits; proceed with it.
22
+ * - `blocked` — the pattern exists but cannot deploy yet; stop, and say why.
23
+ * - `unsupported` — no pattern shape fits this framework; stop, and say so.
24
+ *
25
+ * `recommended` and `blocked` are separated at the *type* level rather than by
26
+ * author discipline: their `pattern` fields are mapped types filtered on the
27
+ * registry's own `deployable` flag, in the same idiom as `OpenNextPatternType`.
28
+ * Flipping `nextjs` to `deployable: true` makes `UndeployablePatternType`
29
+ * uninhabited and breaks the `blocked` entry, forcing it to be moved rather
30
+ * than left behind as a lie. The reverse holds too: a pattern that loses its
31
+ * construct cannot stay in `recommended`.
32
+ */
33
+ import { PATTERN_REGISTRY, type PatternType, type PatternCreateInput } from "./patternTypes.js";
34
+ /**
35
+ * Every framework the detector can name.
36
+ *
37
+ * This vocabulary lives here rather than in `@fjall/generator` because the
38
+ * bridge below needs both halves in one place, and `generator` already depends
39
+ * on `@fjall/util` (never the reverse). `generator/src/detection` re-exports
40
+ * these names and builds the Zod schema over them, exactly as it already does
41
+ * for `STATIC_SITE_ROUTING_VALUES`.
42
+ *
43
+ * - `nextjs+payload` is a strict superset of both its parts and must be
44
+ * matched before either: Payload v3 ships as a Next.js plugin, so both
45
+ * dependencies appear in a Payload app.
46
+ * - `unknown` is a real member, not an absence — a repository with a parseable
47
+ * `package.json` and no recognised signal is still a detection result.
48
+ */
49
+ export declare const FRAMEWORK_VALUES: readonly ["nextjs", "payload", "nextjs+payload", "express", "remix", "astro", "unknown"];
50
+ export type Framework = (typeof FRAMEWORK_VALUES)[number];
51
+ export declare const FRAMEWORKS: ReadonlySet<string>;
52
+ export declare function isFramework(value: unknown): value is Framework;
53
+ /**
54
+ * The patterns that can deploy end-to-end, derived from the registry's own
55
+ * flag at the type level. `DEPLOYABLE_PATTERN_TYPES` is the value-level twin.
56
+ */
57
+ export type DeployablePatternType = {
58
+ [K in PatternType]: (typeof PATTERN_REGISTRY)[K]["deployable"] extends true ? K : never;
59
+ }[PatternType];
60
+ /**
61
+ * The patterns the generator knows but cannot yet synthesise. Uninhabited once
62
+ * every pattern is deployable — at which point the `blocked` verdict below
63
+ * stops compiling, which is the intended signal, not a defect.
64
+ */
65
+ export type UndeployablePatternType = {
66
+ [K in PatternType]: (typeof PATTERN_REGISTRY)[K]["deployable"] extends false ? K : never;
67
+ }[PatternType];
68
+ /**
69
+ * What a detected framework means for `--pattern`.
70
+ *
71
+ * Every arm carries prose because every arm is read aloud to a user by an
72
+ * agent. A verdict an agent cannot explain is a verdict it will second-guess,
73
+ * and second-guessing is what the "do not infer the pattern" instruction in the
74
+ * skill exists to prevent.
75
+ */
76
+ export type PatternRecommendation = {
77
+ readonly kind: "recommended";
78
+ /** Constrained to deployable patterns by the registry's own flag. */
79
+ readonly pattern: DeployablePatternType;
80
+ /** Why this pattern fits, in the agent's own words to the user. */
81
+ readonly rationale: string;
82
+ } | {
83
+ readonly kind: "blocked";
84
+ /** Constrained to patterns the registry marks undeployable. */
85
+ readonly pattern: UndeployablePatternType;
86
+ /** What is missing, so the agent can say more than "no". */
87
+ readonly reason: string;
88
+ } | {
89
+ readonly kind: "unsupported";
90
+ /** Why no pattern fits this framework's shape. */
91
+ readonly reason: string;
92
+ };
93
+ /**
94
+ * The framework → pattern join.
95
+ *
96
+ * This is the *evidence-free* verdict: it knows only the framework name. A
97
+ * detector holding more evidence (an Astro config declaring `output: "server"`,
98
+ * say) returns the same union with a different arm — that is a refinement of
99
+ * one contract, not a second framing path.
100
+ */
101
+ export declare const FRAMEWORK_PATTERN_RECOMMENDATIONS: {
102
+ readonly astro: {
103
+ readonly kind: "recommended";
104
+ readonly pattern: "staticsite";
105
+ readonly rationale: "Astro builds to a directory of static assets, which is what the static-site pattern serves from S3 behind CloudFront.";
106
+ };
107
+ readonly payload: {
108
+ readonly kind: "recommended";
109
+ readonly pattern: "payload";
110
+ readonly rationale: "Payload CMS runs on OpenNext with a database and migrations, which the payload pattern provisions.";
111
+ };
112
+ readonly "nextjs+payload": {
113
+ readonly kind: "recommended";
114
+ readonly pattern: "payload";
115
+ readonly rationale: "Payload v3 ships as a Next.js plugin, so a repository carrying both dependencies is a Payload application and deploys as one.";
116
+ };
117
+ readonly nextjs: {
118
+ readonly kind: "blocked";
119
+ readonly pattern: "nextjs";
120
+ readonly reason: "The Next.js pattern has no CDK construct yet — IPatternProps omits it, so a generated app fails at synthesis. Deploy a Payload application, or supply a static export with --pattern staticsite.";
121
+ };
122
+ readonly remix: {
123
+ readonly kind: "unsupported";
124
+ readonly reason: "Remix serves from a Node server at request time; Fjall has no server-rendering pattern beyond OpenNext, and no static-export path from Remix.";
125
+ };
126
+ readonly express: {
127
+ readonly kind: "unsupported";
128
+ readonly reason: "Express is a long-running HTTP server. Fjall's patterns cover OpenNext applications and pre-built static sites; neither shape fits.";
129
+ };
130
+ readonly unknown: {
131
+ readonly kind: "unsupported";
132
+ readonly reason: "No framework signal was found in package.json. If this is a pre-built static site, pass --pattern staticsite explicitly with --source, --build-command and --output-dir.";
133
+ };
134
+ };
135
+ /**
136
+ * The evidence-free verdict for a framework. Total by construction — there is
137
+ * no undefined branch to guard, which is the whole point of the record above.
138
+ */
139
+ export declare function recommendPatternForFramework(framework: Framework): PatternRecommendation;
140
+ /**
141
+ * The frameworks an agent can carry all the way to a deploy today, derived
142
+ * from the table rather than restated. Used by the skill's vocabulary block so
143
+ * its prose cannot drift from the join.
144
+ */
145
+ export declare const CREATABLE_FRAMEWORKS: readonly Framework[];
146
+ /**
147
+ * The inputs a framework's recommended pattern still needs from the caller.
148
+ * Empty for anything not `recommended` — there is nothing to collect for a
149
+ * pattern that cannot be created.
150
+ */
151
+ export declare function requiredInputsForFramework(framework: Framework): readonly PatternCreateInput[];
@@ -0,0 +1 @@
1
+ var o=Object.defineProperty;var a=(e,t)=>o(e,"name",{value:t,configurable:!0});import{PATTERN_REGISTRY as s}from"./patternTypes.js";const r=["nextjs","payload","nextjs+payload","express","remix","astro","unknown"],i=new Set(r);function c(e){return typeof e=="string"&&i.has(e)}a(c,"isFramework");const n={astro:{kind:"recommended",pattern:"staticsite",rationale:"Astro builds to a directory of static assets, which is what the static-site pattern serves from S3 behind CloudFront."},payload:{kind:"recommended",pattern:"payload",rationale:"Payload CMS runs on OpenNext with a database and migrations, which the payload pattern provisions."},"nextjs+payload":{kind:"recommended",pattern:"payload",rationale:"Payload v3 ships as a Next.js plugin, so a repository carrying both dependencies is a Payload application and deploys as one."},nextjs:{kind:"blocked",pattern:"nextjs",reason:"The Next.js pattern has no CDK construct yet \u2014 IPatternProps omits it, so a generated app fails at synthesis. Deploy a Payload application, or supply a static export with --pattern staticsite."},remix:{kind:"unsupported",reason:"Remix serves from a Node server at request time; Fjall has no server-rendering pattern beyond OpenNext, and no static-export path from Remix."},express:{kind:"unsupported",reason:"Express is a long-running HTTP server. Fjall's patterns cover OpenNext applications and pre-built static sites; neither shape fits."},unknown:{kind:"unsupported",reason:"No framework signal was found in package.json. If this is a pre-built static site, pass --pattern staticsite explicitly with --source, --build-command and --output-dir."}};function l(e){return n[e]}a(l,"recommendPatternForFramework");const m=r.filter(e=>n[e].kind==="recommended");function u(e){const t=n[e];return t.kind==="recommended"?s[t.pattern].requiredCreateInputs:[]}a(u,"requiredInputsForFramework");export{m as CREATABLE_FRAMEWORKS,i as FRAMEWORKS,n as FRAMEWORK_PATTERN_RECOMMENDATIONS,r as FRAMEWORK_VALUES,c as isFramework,l as recommendPatternForFramework,u as requiredInputsForFramework};
@@ -7,5 +7,6 @@
7
7
  * the webapp bundles) can take the vocabulary without depending on the root
8
8
  * barrel's contents staying pure.
9
9
  */
10
- export { PATTERN_TYPE_VALUES, type PatternType, PATTERN_TYPES, isPatternType, type PatternArtefact, type PatternStackPlacement, type PatternDescriptor, PATTERN_REGISTRY, patternConstructId, DEPLOYABLE_PATTERN_TYPES, type OpenNextPatternType, OPENNEXT_PATTERN_TYPES, isOpenNextPatternType, STATIC_SITE_ROUTING_VALUES, type StaticSiteRouting } from "./patternTypes.js";
10
+ export { PATTERN_TYPE_VALUES, type PatternType, PATTERN_TYPES, isPatternType, type PatternArtefact, type PatternStackPlacement, type PatternDescriptor, PATTERN_REGISTRY, patternConstructId, DEPLOYABLE_PATTERN_TYPES, type OpenNextPatternType, OPENNEXT_PATTERN_TYPES, isOpenNextPatternType, STATIC_SITE_ROUTING_VALUES, type StaticSiteRouting, PATTERN_CREATE_INPUT_VALUES, type PatternCreateInput, PATTERN_CREATE_INPUT_FLAGS } from "./patternTypes.js";
11
+ export { FRAMEWORK_VALUES, type Framework, FRAMEWORKS, isFramework, type DeployablePatternType, type UndeployablePatternType, type PatternRecommendation, FRAMEWORK_PATTERN_RECOMMENDATIONS, recommendPatternForFramework, CREATABLE_FRAMEWORKS, requiredInputsForFramework } from "./frameworkPatterns.js";
11
12
  export { DEFAULT_FORMS_FROM_LOCAL_PART, defaultFormsFromAddress, defaultFormsCorsOrigin, isAddressAtDomain } from "./staticSiteForms.js";
@@ -1 +1 @@
1
- import{PATTERN_TYPE_VALUES as r,PATTERN_TYPES as A,isPatternType as _,PATTERN_REGISTRY as t,patternConstructId as P,DEPLOYABLE_PATTERN_TYPES as e,OPENNEXT_PATTERN_TYPES as s,isOpenNextPatternType as R,STATIC_SITE_ROUTING_VALUES as o}from"./patternTypes.js";import{DEFAULT_FORMS_FROM_LOCAL_PART as S,defaultFormsFromAddress as O,defaultFormsCorsOrigin as d,isAddressAtDomain as n}from"./staticSiteForms.js";export{S as DEFAULT_FORMS_FROM_LOCAL_PART,e as DEPLOYABLE_PATTERN_TYPES,s as OPENNEXT_PATTERN_TYPES,t as PATTERN_REGISTRY,A as PATTERN_TYPES,r as PATTERN_TYPE_VALUES,o as STATIC_SITE_ROUTING_VALUES,d as defaultFormsCorsOrigin,O as defaultFormsFromAddress,n as isAddressAtDomain,R as isOpenNextPatternType,_ as isPatternType,P as patternConstructId};
1
+ import{PATTERN_TYPE_VALUES as A,PATTERN_TYPES as r,isPatternType as R,PATTERN_REGISTRY as _,patternConstructId as e,DEPLOYABLE_PATTERN_TYPES as P,OPENNEXT_PATTERN_TYPES as o,isOpenNextPatternType as t,STATIC_SITE_ROUTING_VALUES as F,PATTERN_CREATE_INPUT_VALUES as N,PATTERN_CREATE_INPUT_FLAGS as S}from"./patternTypes.js";import{FRAMEWORK_VALUES as s,FRAMEWORKS as m,isFramework as L,FRAMEWORK_PATTERN_RECOMMENDATIONS as a,recommendPatternForFramework as n,CREATABLE_FRAMEWORKS as d,requiredInputsForFramework as I}from"./frameworkPatterns.js";import{DEFAULT_FORMS_FROM_LOCAL_PART as p,defaultFormsFromAddress as C,defaultFormsCorsOrigin as M,isAddressAtDomain as U}from"./staticSiteForms.js";export{d as CREATABLE_FRAMEWORKS,p as DEFAULT_FORMS_FROM_LOCAL_PART,P as DEPLOYABLE_PATTERN_TYPES,m as FRAMEWORKS,a as FRAMEWORK_PATTERN_RECOMMENDATIONS,s as FRAMEWORK_VALUES,o as OPENNEXT_PATTERN_TYPES,S as PATTERN_CREATE_INPUT_FLAGS,N as PATTERN_CREATE_INPUT_VALUES,_ as PATTERN_REGISTRY,r as PATTERN_TYPES,A as PATTERN_TYPE_VALUES,F as STATIC_SITE_ROUTING_VALUES,M as defaultFormsCorsOrigin,C as defaultFormsFromAddress,U as isAddressAtDomain,L as isFramework,t as isOpenNextPatternType,R as isPatternType,e as patternConstructId,n as recommendPatternForFramework,I as requiredInputsForFramework};
@@ -31,6 +31,26 @@ export type PatternArtefact = "opennext-lambda" | "static-assets";
31
31
  * Lambda (and usually a database) and belong in the compute stack.
32
32
  */
33
33
  export type PatternStackPlacement = "compute" | "cdn";
34
+ /**
35
+ * Create-time inputs a pattern cannot invent for itself.
36
+ *
37
+ * A static site is built from someone else's repository, so Fjall cannot guess
38
+ * where the sources live, how they build, or which directory to upload — get
39
+ * `outputDir` wrong and the deploy succeeds onto an empty bucket. Declaring the
40
+ * set here rather than in the create handler's control flow is what lets the
41
+ * CLI refuse the command with a list of missing flags instead of surfacing a
42
+ * raw Zod issue array, and lets `apps detect` prefill exactly the values the
43
+ * chosen pattern will be asked for.
44
+ */
45
+ export declare const PATTERN_CREATE_INPUT_VALUES: readonly ["source", "buildCommand", "outputDir"];
46
+ export type PatternCreateInput = (typeof PATTERN_CREATE_INPUT_VALUES)[number];
47
+ /** The CLI flag each input arrives on. Kept beside the vocabulary so error
48
+ * messages name a flag the user can actually type. */
49
+ export declare const PATTERN_CREATE_INPUT_FLAGS: {
50
+ readonly source: "--source";
51
+ readonly buildCommand: "--build-command";
52
+ readonly outputDir: "--output-dir";
53
+ };
34
54
  export interface PatternDescriptor {
35
55
  /** Human-facing name, used by the CLI picker and progress output. */
36
56
  readonly label: string;
@@ -48,6 +68,12 @@ export interface PatternDescriptor {
48
68
  * adding the construct.
49
69
  */
50
70
  readonly deployable: boolean;
71
+ /**
72
+ * Inputs the caller must supply for this pattern; the create flow refuses
73
+ * the command when any are absent. Empty for patterns whose scaffold is
74
+ * generated wholesale and therefore knows its own layout.
75
+ */
76
+ readonly requiredCreateInputs: readonly PatternCreateInput[];
51
77
  }
52
78
  export declare const PATTERN_REGISTRY: {
53
79
  readonly payload: {
@@ -56,6 +82,7 @@ export declare const PATTERN_REGISTRY: {
56
82
  readonly artefact: "opennext-lambda";
57
83
  readonly stackPlacement: "compute";
58
84
  readonly deployable: true;
85
+ readonly requiredCreateInputs: readonly [];
59
86
  };
60
87
  readonly nextjs: {
61
88
  readonly label: "Next.js";
@@ -63,6 +90,7 @@ export declare const PATTERN_REGISTRY: {
63
90
  readonly artefact: "opennext-lambda";
64
91
  readonly stackPlacement: "compute";
65
92
  readonly deployable: false;
93
+ readonly requiredCreateInputs: readonly [];
66
94
  };
67
95
  readonly staticsite: {
68
96
  readonly label: "Static site";
@@ -70,6 +98,7 @@ export declare const PATTERN_REGISTRY: {
70
98
  readonly artefact: "static-assets";
71
99
  readonly stackPlacement: "cdn";
72
100
  readonly deployable: true;
101
+ readonly requiredCreateInputs: readonly ["source", "buildCommand", "outputDir"];
73
102
  };
74
103
  };
75
104
  /**
@@ -1 +1 @@
1
- var c=Object.defineProperty;var e=(t,a)=>c(t,"name",{value:a,configurable:!0});import{toPascalCase as s}from"../naming/caseConversion.js";const n=["payload","nextjs","staticsite"],r=new Set(n);function E(t){return typeof t=="string"&&r.has(t)}e(E,"isPatternType");const o={payload:{label:"Payload CMS",constructIdSuffix:"Payload",artefact:"opennext-lambda",stackPlacement:"compute",deployable:!0},nextjs:{label:"Next.js",constructIdSuffix:"Nextjs",artefact:"opennext-lambda",stackPlacement:"compute",deployable:!1},staticsite:{label:"Static site",constructIdSuffix:"StaticSite",artefact:"static-assets",stackPlacement:"cdn",deployable:!0}};function P(t,a){return`${s(t)}${o[a].constructIdSuffix}`}e(P,"patternConstructId");const f=n.filter(t=>o[t].deployable),l=n.filter(t=>o[t].artefact==="opennext-lambda"),p=new Set(l);function x(t){return t!=null&&p.has(t)}e(x,"isOpenNextPatternType");const d=["multipage","spa"];export{f as DEPLOYABLE_PATTERN_TYPES,l as OPENNEXT_PATTERN_TYPES,o as PATTERN_REGISTRY,r as PATTERN_TYPES,n as PATTERN_TYPE_VALUES,d as STATIC_SITE_ROUTING_VALUES,x as isOpenNextPatternType,E as isPatternType,P as patternConstructId};
1
+ var n=Object.defineProperty;var e=(t,o)=>n(t,"name",{value:o,configurable:!0});import{toPascalCase as s}from"../naming/caseConversion.js";const a=["payload","nextjs","staticsite"],c=new Set(a);function l(t){return typeof t=="string"&&c.has(t)}e(l,"isPatternType");const d=["source","buildCommand","outputDir"],E={source:"--source",buildCommand:"--build-command",outputDir:"--output-dir"},r={payload:{label:"Payload CMS",constructIdSuffix:"Payload",artefact:"opennext-lambda",stackPlacement:"compute",deployable:!0,requiredCreateInputs:[]},nextjs:{label:"Next.js",constructIdSuffix:"Nextjs",artefact:"opennext-lambda",stackPlacement:"compute",deployable:!1,requiredCreateInputs:[]},staticsite:{label:"Static site",constructIdSuffix:"StaticSite",artefact:"static-assets",stackPlacement:"cdn",deployable:!0,requiredCreateInputs:["source","buildCommand","outputDir"]}};function P(t,o){return`${s(t)}${r[o].constructIdSuffix}`}e(P,"patternConstructId");const x=a.filter(t=>r[t].deployable),u=a.filter(t=>r[t].artefact==="opennext-lambda"),p=new Set(u);function f(t){return t!=null&&p.has(t)}e(f,"isOpenNextPatternType");const S=["multipage","spa"];export{x as DEPLOYABLE_PATTERN_TYPES,u as OPENNEXT_PATTERN_TYPES,E as PATTERN_CREATE_INPUT_FLAGS,d as PATTERN_CREATE_INPUT_VALUES,r as PATTERN_REGISTRY,c as PATTERN_TYPES,a as PATTERN_TYPE_VALUES,S as STATIC_SITE_ROUTING_VALUES,f as isOpenNextPatternType,l as isPatternType,P as patternConstructId};
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Idempotently guarantee that a `.gitignore` in `directory` carries `entries`.
3
+ *
4
+ * Absent file → written with `header` followed by the entries. Existing file →
5
+ * only the missing entries are appended, under `marker` so the addition is
6
+ * attributable. An entry already present anywhere in the file (at any
7
+ * indentation) is left alone, so repeat calls are no-ops.
8
+ *
9
+ * Best-effort by design: this maintains repository hygiene, never correctness.
10
+ * The caller's real work — writing config or recording deploy state — must not
11
+ * fail because a `.gitignore` could not be written (read-only checkout, no
12
+ * repository at all, permissions). Returns whether the file now provably
13
+ * carries every entry, for callers that want to assert or log.
14
+ */
15
+ export declare function ensureGitignoreEntries(directory: string, entries: readonly string[], options: {
16
+ header?: string;
17
+ marker: string;
18
+ }): boolean;
@@ -0,0 +1,9 @@
1
+ var d=Object.defineProperty;var u=(o,e)=>d(o,"name",{value:e,configurable:!0});import i from"fs";import g from"path";function l(o,e,s){const t=g.join(o,".gitignore");try{const r=i.existsSync(t)?i.readFileSync(t,"utf8"):void 0;if(r===void 0){const n=s.header??"";return i.writeFileSync(t,`${n}${e.join(`
2
+ `)}
3
+ `,{encoding:"utf8"}),!0}const a=new Set(r.split(`
4
+ `).map(n=>n.trim())),c=e.filter(n=>!a.has(n));if(c.length===0)return!0;const f=r===""||r.endsWith(`
5
+ `)?"":`
6
+ `;return i.appendFileSync(t,`${f}${s.marker}
7
+ ${c.join(`
8
+ `)}
9
+ `,"utf8"),!0}catch{return!1}}u(l,"ensureGitignoreEntries");export{l as ensureGitignoreEntries};