@zerotal/core 1.7.4 → 1.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/api-surface.md CHANGED
@@ -686,8 +686,6 @@ function data_get = (target: unknown, key: string, defaultValue?: unknown) => un
686
686
 
687
687
  function deepMerge = <T extends object>(base: T, override: DeepPartial<T>) => T
688
688
 
689
- function deployEnv = () => string
690
-
691
689
  function devSurfacesEnabled = () => boolean
692
690
 
693
691
  function env = { (key: string): string | undefined; (key: string, fallback: string): string; (key: string, fallback: boolean): boolean; (key: string, fallback: number): number;}
@@ -704,8 +702,6 @@ function inject = (...tokens: BindingToken[]) => (target: new (...args: any[]) =
704
702
 
705
703
  function isDevSurfaceAllowed = (env: string) => boolean
706
704
 
707
- function isProdLike = (env: string) => boolean
708
-
709
705
  function json = (data: unknown, status?: number) => void
710
706
 
711
707
  function make = <T>(token: BindingToken<T>) => Promise<T>
@@ -728,8 +724,6 @@ function redirect = { (url: string, status?: 301 | 302 | 303 | 307 | 308): Re
728
724
 
729
725
  function redirectTo = <N extends RouteTarget>(name: N, params?: RouteParamsArg<N>, status?: 301 | 302 | 303 | 307 | 308) => ResponseBuilder
730
726
 
731
- function registerAppScope = (installer: AppScopeInstaller) => void
732
-
733
727
  function registerErrorDiagnoser = (diagnoser: ErrorDiagnoser) => void
734
728
 
735
729
  function registerFileRouteResolver = (resolver: FileRouteResolver) => void
@@ -754,8 +748,6 @@ function setAppEnv = (command?: string) => void
754
748
 
755
749
  function setCurrentPageResolver = (resolver: CurrentPageResolver) => void
756
750
 
757
- function setImplicitModelResolver = (fn: ((paramName: string) => ModelBindingResolver | undefined) | null) => void
758
-
759
751
  function sha256Hex = (input: string | Uint8Array) => string
760
752
 
761
753
  function singularize = (value: string) => string
@@ -1114,8 +1106,6 @@ type ArgDef = { name: string; required?: boolean; default?: string;}
1114
1106
 
1115
1107
  type BindingToken = ClassToken<T> | AbstractToken<T> | keyof ContainerBindings
1116
1108
 
1117
- type ClassRef = abstract new (...args: never[]) => unknown
1118
-
1119
1109
  type ContextKey = never
1120
1110
 
1121
1111
  type ContextValue = ContextRegistry[K]
@@ -1526,35 +1516,6 @@ class CssBuildCommand = {
1526
1516
  write: (msg: string) => void
1527
1517
  }
1528
1518
 
1529
- class DeployCommand = {
1530
- new (): DeployCommand
1531
- static args: ArgDef[]
1532
- static commandName: string
1533
- static description: string
1534
- static flags: ({ name: string; type: 'boolean'; description: string; default: boolean;} | { name: string; type: 'string'; description: string; default?: never;})[]
1535
- static needsApp: boolean
1536
- static target: string
1537
- _readLine: () => Promise<string>
1538
- _writer: OutputWriter
1539
- app: unknown
1540
- args: Record<string, string>
1541
- ask: (question: string, defaultValue?: string) => Promise<string>
1542
- choice: (question: string, options: string[]) => Promise<string>
1543
- confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
1544
- dim: (msg: string) => void
1545
- error: (msg: string) => void
1546
- flags: Record<string, string | number | boolean>
1547
- info: (msg: string) => void
1548
- line: (msg: string) => void
1549
- newLine: () => void
1550
- run: () => Promise<void>
1551
- secret: (question: string) => Promise<string>
1552
- section: (title: string) => void
1553
- table: (rows: [string, string][], indent?: number) => void
1554
- warn: (msg: string) => void
1555
- write: (msg: string) => void
1556
- }
1557
-
1558
1519
  class DevCommand = {
1559
1520
  new (): DevCommand
1560
1521
  static aliases: string[]
@@ -2316,8 +2277,6 @@ class WorkerCommand = {
2316
2277
  write: (msg: string) => void
2317
2278
  }
2318
2279
 
2319
- function makeDeployCommand = (target: string) => DeployCommandClass
2320
-
2321
2280
  ## ./config `(./src/config/index.ts)`
2322
2281
 
2323
2282
  class ConfigLoader = {
@@ -2511,8 +2470,6 @@ const SERVER_PROCESS_NAME = 'server'
2511
2470
 
2512
2471
  function bootBuildDecision = (outDirs: string[], env: string | undefined) => Promise<BootBuildDecision>
2513
2472
 
2514
- function browserEnvDefines = (isProduction: boolean) => Record<string, string>
2515
-
2516
2473
  function buildCssBundle = (input: string, outdir: string, minify?: boolean, loader?: Record<string, string>) => Promise<BundleResult>
2517
2474
 
2518
2475
  function buildJsBundle = (input: string, outdir: string, minify?: boolean) => Promise<BundleResult>
@@ -2523,14 +2480,8 @@ function createDeck = (options: DeckOptions) => Deck
2523
2480
 
2524
2481
  function detectCssPlugins = (cwd: string) => Promise<BunPlugin[]>
2525
2482
 
2526
- function isDevOrchestrated = (env?: Record<string, string | undefined>, argv?: readonly string[]) => boolean
2527
-
2528
2483
  function isWritableDir = (dir: string) => Promise<boolean>
2529
2484
 
2530
- function pruneBuildOutput = (outdir: string, outputs: readonly { path: string;}[]) => Promise<string[]>
2531
-
2532
- function registerDevBuildHook = (name: string, fn: BuildHookFn) => void
2533
-
2534
2485
  function registerDevHtmlSnippet = (name: string, fn: DevHtmlSnippet) => void
2535
2486
 
2536
2487
  function startDevMode = (options: StartDevModeOptions) => Promise<void>
@@ -3409,8 +3360,6 @@ class StackChannel = {
3409
3360
 
3410
3361
  const Log = LogManager
3411
3362
 
3412
- function frameworkLog = (scope: string) => BoundLogger
3413
-
3414
3363
  function LoggingConfig = (options?: Partial<LoggingConfigShape>) => LoggingConfigShape
3415
3364
 
3416
3365
  function renderTable = (data: TableData, indent?: number) => string[]
@@ -3506,17 +3455,11 @@ function hasRoute = (name: string) => boolean
3506
3455
 
3507
3456
  function resetRoutes = () => void
3508
3457
 
3509
- function routeMethod = (name: string) => string | undefined
3510
-
3511
3458
  interface RouteAction = {
3512
3459
  method: string
3513
3460
  url: string
3514
3461
  }
3515
3462
 
3516
- interface RouteMethodRegistry = {}
3517
-
3518
- type MethodedRouteName = never
3519
-
3520
3463
  type RouteArgs = [params?: RouteParamValues, query?: RouteQuery]
3521
3464
 
3522
3465
  type RouteParamValues = { [x: string]: RouteParamValue | readonly RouteParamValue[];}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.7.4",
3
+ "version": "1.7.5",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -34,7 +34,12 @@ import {
34
34
  } from "../middleware/SecureHeadersMiddleware.ts";
35
35
  import { isAllowedOrigin, allowedOriginsFrom } from "../http/originGuard.ts";
36
36
  import { rescueSync } from "../helpers/index.ts";
37
- import { configureAssets, setAssetVersion, assetVersion } from "../assets/assets.ts";
37
+ import {
38
+ configureAssets,
39
+ setAssetVersion,
40
+ assetVersion,
41
+ deriveAssetVersion,
42
+ } from "../assets/assets.ts";
38
43
  import { ConfigLoader, type ConfigMap } from "../config/ConfigLoader.ts";
39
44
  import { Emitter } from "../events/Emitter.ts";
40
45
  import { FrameworkEvents, AppBooted } from "../events/FrameworkEvents.ts";
@@ -1358,7 +1363,18 @@ export class Application {
1358
1363
  // each rebuild ships a fresh token over the reload channel (see ServeCommand).
1359
1364
  const isDevWorker = process.argv.includes("--dev-worker");
1360
1365
  configureAssets({ prefix: assets?.prefix ?? "/", dev: isDevWorker });
1361
- if (isDevWorker) setAssetVersion(Bun.env["ZT_ASSET_VERSION"] ?? "");
1366
+
1367
+ if (isDevWorker) {
1368
+ setAssetVersion(Bun.env["ZT_ASSET_VERSION"] ?? "");
1369
+ } else {
1370
+ // Production gets a token too, derived from the built files. `assets:build`
1371
+ // writes `app.js` under that name every deploy and the static handler sends
1372
+ // no `Cache-Control`, so without this a returning visitor keeps the bundle
1373
+ // their browser cached — through any number of deploys, while the server
1374
+ // reports success. Derived rather than random so a restart that changed no
1375
+ // assets does not invalidate every client's cache for nothing.
1376
+ setAssetVersion(deriveAssetVersion(`${dir}/${assets?.outDir ?? "public"}`));
1377
+ }
1362
1378
  }
1363
1379
 
1364
1380
  // Convention phase — discovers app/schedules (class-based), models, observers, jobs, etc.
@@ -14,8 +14,25 @@
14
14
  * {@link bumpAssetVersion}) and propagated from the dev orchestrator to the
15
15
  * server worker over its reload channel. This mirrors Inertia's asset-version
16
16
  * model — one token per build, busting every asset at once.
17
+ *
18
+ * ## Production needs this more than dev does
19
+ *
20
+ * It used to be dev-only, on the reasoning that production assets would be
21
+ * content-hashed by something downstream. Nothing does: `assets:build` writes
22
+ * `public/js/app.js` under that exact name every time, the static handler sends
23
+ * no `Cache-Control`, and so a returning visitor keeps whatever bundle their
24
+ * browser heuristically cached — for the life of that heuristic, across any
25
+ * number of deploys. A shipped fix that reaches nobody who has visited before is
26
+ * the worst kind, because everything on the server says it worked.
27
+ *
28
+ * So the version applies whenever it is set, in any environment. In production
29
+ * it is derived from the built files themselves ({@link deriveAssetVersion}), so
30
+ * it changes when the assets change and a plain restart does not bust a thing.
17
31
  */
18
32
 
33
+ import { readdirSync, statSync } from "node:fs";
34
+ import { join } from "node:path";
35
+
19
36
  let _prefix = "/";
20
37
  let _dev = false;
21
38
  let _version = "";
@@ -36,6 +53,41 @@ export function setAssetVersion(version: string): void {
36
53
  _version = version;
37
54
  }
38
55
 
56
+ /**
57
+ * A token derived from the built assets in `dir`, or `""` when there are none.
58
+ *
59
+ * Name, size and mtime of every file, hashed. Content would be stricter and costs
60
+ * a full read of every bundle at boot for a token that only has to *change* when
61
+ * the files do — a rebuild that produces identical bytes keeping its old token is
62
+ * the correct outcome either way.
63
+ *
64
+ * `""` when the directory is missing or empty, which leaves `asset()` emitting
65
+ * clean URLs rather than a token that means nothing.
66
+ */
67
+ export function deriveAssetVersion(dir: string): string {
68
+ let entries: string[];
69
+ try {
70
+ entries = readdirSync(dir, { recursive: true }) as string[];
71
+ } catch {
72
+ return "";
73
+ }
74
+
75
+ const parts: string[] = [];
76
+ for (const entry of entries.sort()) {
77
+ try {
78
+ const info = statSync(join(dir, entry));
79
+ if (!info.isFile()) continue;
80
+ parts.push(`${entry}:${info.size}:${Math.floor(info.mtimeMs)}`);
81
+ } catch {
82
+ // Raced with a deploy swapping the directory. Skipping the file is fine:
83
+ // whatever lands next boot produces a different token anyway.
84
+ }
85
+ }
86
+
87
+ if (parts.length === 0) return "";
88
+ return Bun.hash(parts.join("|")).toString(36).slice(0, 10);
89
+ }
90
+
39
91
  /** The current asset version, or `""` when unset. */
40
92
  export function assetVersion(): string {
41
93
  return _version;
@@ -56,15 +108,15 @@ export function bumpAssetVersion(): string {
56
108
  * Build the public URL for a front-end asset.
57
109
  *
58
110
  * @param path Asset path relative to the asset prefix (leading slash optional).
59
- * @returns `<prefix>/<path>`, with `?v=<version>` appended in dev.
111
+ * @returns `<prefix>/<path>`, with `?v=<version>` appended when a version is set.
60
112
  *
61
113
  * @example
62
- * asset("/app.css") // prod → "/app.css"
63
- * asset("app.css") // dev → "/app.css?v=lq3k7m"
114
+ * asset("/app.css") // → "/app.css?v=lq3k7m"
115
+ * asset("app.css") // → "/app.css?v=lq3k7m"
64
116
  */
65
117
  export function asset(path: string): string {
66
118
  const url = _joinUrl(_prefix, path);
67
- if (_dev && _version) {
119
+ if (_version) {
68
120
  const separator = url.includes("?") ? "&" : "?";
69
121
  return `${url}${separator}v=${_version}`;
70
122
  }
@@ -1,14 +1,19 @@
1
1
  /**
2
2
  * Front-end asset URL helper and asset versioning (the `@zerotal/core/assets`
3
- * subpath). {@link asset} builds the public URL for a built asset, appending a
4
- * `?v=<version>` cache-buster in dev; the versioning helpers let the dev
5
- * orchestrator bust every asset at once after a rebuild.
3
+ * subpath). {@link asset} builds the public URL for a built asset and appends a
4
+ * `?v=<version>` cache-buster in every environment, not only in dev.
5
+ *
6
+ * Production needs it more: `assets:build` writes `app.js` under that name on
7
+ * every deploy and the static handler sends no `Cache-Control`, so an unbusted
8
+ * URL leaves a returning visitor on whatever bundle their browser cached. The
9
+ * production token is derived from the built files, so it moves when they do and
10
+ * a plain restart busts nothing.
6
11
  *
7
12
  * @example
8
13
  * ```ts
9
14
  * import { asset } from "@zerotal/core/assets";
10
15
  *
11
- * asset("/app.css"); // prod → "/app.css"; dev → "/app.css?v=lq3k7m"
16
+ * asset("/app.css"); // → "/app.css?v=lq3k7m"
12
17
  * ```
13
18
  *
14
19
  * @packageDocumentation
@@ -113,6 +113,61 @@ function _checkErrorDiscipline(files: PackageFiles, packageName: string): Violat
113
113
  }
114
114
 
115
115
  /** Lint every package under `packagesDir` and return a report per package. */
116
+ /**
117
+ * A package below `stable` names the release by which its label is reviewed, and
118
+ * the review actually falls due.
119
+ *
120
+ * The support policy's own argument is that "an honest 'experimental' is useful
121
+ * once and corrosive indefinitely" — a package that has worn the label for a year
122
+ * is not being cautious, it is unowned. It then set a review release for `ai` and
123
+ * `arch` in a prose table, where nothing could enforce it: the version could sail
124
+ * past and the only consequence would be a sentence quietly becoming untrue.
125
+ *
126
+ * So the date lives in `package.json` beside the label it governs, and when the
127
+ * package's version reaches it, this fails until somebody performs the review —
128
+ * promote, keep with a new date and the reason, or withdraw.
129
+ */
130
+ function _checkMaturityReview(files: PackageFiles): Violation[] {
131
+ const violations: Violation[] = [];
132
+ const pkg = files.packageJson;
133
+ if (!pkg) return violations;
134
+
135
+ const maturity = pkg["maturity"] as string | undefined;
136
+ if (!maturity || maturity === "stable") return violations;
137
+
138
+ const review = pkg["maturityReview"] as string | undefined;
139
+ if (!review) {
140
+ violations.push({
141
+ rule: "maturity-review",
142
+ severity: "medium",
143
+ message: `maturity '${maturity}' with no 'maturityReview' — name the release by which the label is reviewed`,
144
+ });
145
+ return violations;
146
+ }
147
+
148
+ const version = pkg["version"] as string | undefined;
149
+ if (version && _atOrPast(version, review)) {
150
+ violations.push({
151
+ rule: "maturity-review-due",
152
+ severity: "high",
153
+ message: `maturity '${maturity}' was to be reviewed by ${review} and this package is ${version} — promote it, withdraw it, or set a new 'maturityReview' with the reason in docs/support-policy.md`,
154
+ });
155
+ }
156
+ return violations;
157
+ }
158
+
159
+ /** Whether `version` has reached `target`, comparing numerically per segment. */
160
+ function _atOrPast(version: string, target: string): boolean {
161
+ const a = version.split(".").map(Number);
162
+ const b = target.split(".").map(Number);
163
+ for (let i = 0; i < 3; i++) {
164
+ const left = a[i] ?? 0;
165
+ const right = b[i] ?? 0;
166
+ if (left !== right) return left > right;
167
+ }
168
+ return true;
169
+ }
170
+
116
171
  export async function lintPackages(packagesDir: string): Promise<PackageReport[]> {
117
172
  const reports: PackageReport[] = [];
118
173
  let entries: string[];
@@ -134,6 +189,7 @@ export async function lintPackages(packagesDir: string): Promise<PackageReport[]
134
189
  ..._checkTests(files),
135
190
  ..._checkPackaging(files),
136
191
  ..._checkErrorDiscipline(files, packageName),
192
+ ..._checkMaturityReview(files),
137
193
  ];
138
194
  reports.push({ package: packageName, violations });
139
195
  }
@@ -35,7 +35,8 @@ function _kindOf(ctor: EventCtor): string {
35
35
  *
36
36
  * @example
37
37
  * ```ts
38
- * import { FrameworkEvents, QueryExecuted } from "@zerotal/core";
38
+ * import { FrameworkEvents } from "@zerotal/core";
39
+ * import { QueryExecuted } from "@zerotal/orm";
39
40
  *
40
41
  * // In a provider's onBooting(): watch every SQL query.
41
42
  * const off = FrameworkEvents.on(QueryExecuted, (e) => {