@zerotal/core 1.6.3 → 1.7.2

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.
@@ -44,6 +44,46 @@
44
44
  /** Keys that must never be copied across — they can pollute `Object.prototype`. */
45
45
  const _UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
46
46
 
47
+ /**
48
+ * Values {@link deepMerge} treats as atomic: replaced wholesale, never recursed
49
+ * into. Kept in step with `_isPlainObject` below — a type that recursed into an
50
+ * array would ask callers for `{ 0?: string }` where the function wants
51
+ * `string[]`.
52
+ */
53
+ type _Atomic =
54
+ | readonly unknown[]
55
+ | Date
56
+ | RegExp
57
+ | Map<unknown, unknown>
58
+ | Set<unknown>
59
+ | ((...args: never[]) => unknown);
60
+
61
+ /**
62
+ * Every key optional, all the way down — the shape {@link deepMerge} actually
63
+ * accepts.
64
+ *
65
+ * `Partial<T>` only makes the *top* level optional, so
66
+ * `{ drivers: { anthropic: { apiKey } } }` — the single most common thing anyone
67
+ * writes in a config file — was a type error against a shape whose `anthropic`
68
+ * has other keys, even though the merge handles it perfectly. `@zerotal/ai` hit
69
+ * this first and defined its own copy; this is that type, promoted so nothing
70
+ * has to define it again.
71
+ *
72
+ * The explicit `| undefined` is deliberate under `exactOptionalPropertyTypes`:
73
+ * `deepMerge` documents that an `undefined` override is skipped rather than
74
+ * blanking a default, so passing one explicitly has a defined meaning and
75
+ * should type-check.
76
+ */
77
+ export type DeepPartial<T> = {
78
+ [K in keyof T]?:
79
+ | (NonNullable<T[K]> extends _Atomic
80
+ ? T[K]
81
+ : NonNullable<T[K]> extends object
82
+ ? DeepPartial<NonNullable<T[K]>>
83
+ : T[K])
84
+ | undefined;
85
+ };
86
+
47
87
  /**
48
88
  * A *plain* object: a `{}`-style record whose prototype is `Object.prototype` or
49
89
  * `null`. Class instances, arrays, Dates, Maps, etc. are intentionally excluded so
@@ -96,7 +136,7 @@ function _clone<T>(value: T): T {
96
136
  * deepMerge({ tags: ["a", "b"] }, { tags: ["c"] });
97
137
  * // → { tags: ["c"] }
98
138
  */
99
- export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
139
+ export function deepMerge<T extends object>(base: T, override: DeepPartial<T>): T {
100
140
  const result = _clone(base) as T;
101
141
  for (const key in override) {
102
142
  if (!Object.prototype.hasOwnProperty.call(override, key)) continue;
@@ -107,7 +147,7 @@ export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
107
147
  if (_isPlainObject(overrideValue) && _isPlainObject(baseValue)) {
108
148
  (result as Record<string, unknown>)[key] = deepMerge(
109
149
  baseValue,
110
- overrideValue as Partial<typeof baseValue>,
150
+ overrideValue as DeepPartial<typeof baseValue>,
111
151
  );
112
152
  } else {
113
153
  (result as Record<string, unknown>)[key] = _clone(overrideValue);
@@ -53,6 +53,21 @@ export const DEV_WORKER_ENV_VAR = "ZT_DEV";
53
53
  */
54
54
  export const DEPLOY_ENV_VAR = "ZT_APP_ENV";
55
55
 
56
+ /**
57
+ * Environment variable holding the *runtime mode* — `web`, `worker`, `console`.
58
+ *
59
+ * Separate from `APP_ENV`, which holds the deployment name, because they answer
60
+ * different questions and one variable cannot hold both. It used to try: every
61
+ * boot overwrote `APP_ENV` with the mode, so `APP_ENV=production` read back as
62
+ * `"console"` inside a CLI command and a guard written `if (env("APP_ENV") ===
63
+ * "production") refuse()` was inert exactly where destructive commands live.
64
+ *
65
+ * Written by `setAppEnv()`; read through {@link runtimeMode}. Settable by hand to
66
+ * force a mode — `APP_TYPE=web bun zt.ts something` — which is what the dev
67
+ * orchestrator does for the server it supervises.
68
+ */
69
+ export const RUNTIME_MODE_VAR = "APP_TYPE";
70
+
56
71
  /**
57
72
  * The values of `APP_ENV` that name a runtime *mode* rather than a deployment.
58
73
  * `setAppEnv()` writes these; {@link deployEnv} recognises them to know whether
@@ -73,17 +88,18 @@ export const RUNTIME_MODES: ReadonlySet<string> = new Set([
73
88
  * The deployment name this process was started with — `production`, `staging`,
74
89
  * `local`, whatever the operator set — as opposed to the runtime *mode*.
75
90
  *
76
- * `APP_ENV` carries both meanings, and the second one destroys the first:
77
- * `setAppEnv()` overwrites it with `web` / `console` / `worker` before the app
78
- * boots, so a gate that asks `isProdLike(Bun.env["APP_ENV"])` after startup is
91
+ * `APP_ENV` used to carry both meanings, and the second destroyed the first:
92
+ * `setAppEnv()` overwrote it with `web` / `console` / `worker` before the app
93
+ * booted, so a gate asking `isProdLike(Bun.env["APP_ENV"])` after startup was
79
94
  * asking whether `"web"` is production and always getting no. That was not
80
95
  * theoretical — it silently disabled the weak-`APP_KEY` refusal and left the
81
- * ORM's N+1 detector wrapping every query in production.
96
+ * ORM's N+1 detector wrapping every query in production, and it later made
97
+ * `env("APP_ENV")` return `"console"` inside a seeder.
82
98
  *
83
- * `setAppEnv()` now preserves the original value, and this reads it back. Prefer
84
- * it to `Bun.env["APP_ENV"]` for **any** production decision. Config is an
85
- * equally correct source where it is available (`config("app.env")`), but this
86
- * works before config is loaded and in processes that have none.
99
+ * The mode now lives in its own variable ({@link RUNTIME_MODE_VAR}) and `APP_ENV`
100
+ * is left alone, so this is usually just a read of it. The runtime-mode branch
101
+ * below stays for a process started by an older launcher, or one where somebody
102
+ * still exports `APP_ENV=web` by hand.
87
103
  *
88
104
  * @internal
89
105
  */
@@ -101,6 +117,30 @@ export function deployEnv(): string {
101
117
  return Bun.env[DEPLOY_ENV_VAR] ?? current;
102
118
  }
103
119
 
120
+ /**
121
+ * How this process is running — `web`, `worker`, or `console`.
122
+ *
123
+ * The other half of what `APP_ENV` used to mean. Providers are filtered on it
124
+ * (`static environments = ["console"]`), which is why getting it wrong is not a
125
+ * cosmetic problem: a provider is simply never asked to register, with no error
126
+ * and nothing missing from the logs.
127
+ *
128
+ * `fallback` is what an unset environment means, and it differs by caller:
129
+ * `setAppEnv()` treats a process that never declared itself as a script
130
+ * (`console`), while `Application.create()` has always treated one as a server
131
+ * (`web`) — an app constructed directly, in a test or a script, expects its
132
+ * web providers to register.
133
+ */
134
+ export function runtimeMode(fallback = "console"): string {
135
+ const mode = (Bun.env[RUNTIME_MODE_VAR] ?? "").toLowerCase();
136
+ if (RUNTIME_MODES.has(mode)) return mode;
137
+
138
+ // A process started by an older launcher, which put the mode in `APP_ENV`.
139
+ // eslint-disable-next-line no-restricted-syntax -- reading the legacy location is the fallback's entire job
140
+ const legacy = (Bun.env["APP_ENV"] ?? "").toLowerCase();
141
+ return RUNTIME_MODES.has(legacy) ? legacy : fallback;
142
+ }
143
+
104
144
  /**
105
145
  * Whether *this process* may expose dev-only surfaces — the stack-trace error
106
146
  * page, the trace inspector, an open monitor panel.