stonyx 0.2.3-alpha.46 → 0.2.3-alpha.48

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/README.md CHANGED
@@ -22,12 +22,12 @@ stonyx serve
22
22
  The `new` command walks you through module selection and generates a ready-to-run project. From there, the CLI handles bootstrapping:
23
23
 
24
24
  ```bash
25
- stonyx serve # Bootstrap + run app.js
25
+ stonyx serve # Bootstrap + run app.ts (or app.js)
26
26
  stonyx test # Bootstrap + run tests
27
27
  stonyx help # Show all available commands
28
28
  ```
29
29
 
30
- Stonyx reads `config/environment.js`, initializes all `@stonyx/*` modules from your `devDependencies`, and runs your application.
30
+ Stonyx reads `config/environment.ts` (preferred) or `config/environment.js`, initializes all `@stonyx/*` modules from your `devDependencies`, and runs your application.
31
31
 
32
32
  ## Documentation
33
33
 
package/dist/cli/help.js CHANGED
@@ -23,8 +23,8 @@ export default async function help({ args, builtInCommands, loadModuleCommands }
23
23
  }
24
24
  console.log('\nAliases: s=serve, t=test, n=new, h=help\n');
25
25
  console.log('Project conventions:\n');
26
- console.log(' Entry point: app.js');
27
- console.log(' Config: config/environment.js');
26
+ console.log(' Entry point: app.ts (or app.js)');
27
+ console.log(' Config: config/environment.ts (or .js)');
28
28
  console.log(' DB schema: config/db-schema.js');
29
29
  console.log(' Models: models/');
30
30
  console.log(' Serializers: serializers/');
package/dist/main.js CHANGED
@@ -16,7 +16,7 @@
16
16
  import Chronicle from '@stonyx/logs';
17
17
  import loadModules from './modules.js';
18
18
  import { mergeObject } from '@stonyx/utils/object';
19
- import { importConfig } from './util/import-config.js';
19
+ import { importConfig, CONFIG_NOT_FOUND_PREFIX } from './util/import-config.js';
20
20
  import { resolveModuleName } from './util/resolve-module-name.js';
21
21
  export default class Stonyx {
22
22
  static initialized = false;
@@ -60,8 +60,12 @@ export default class Stonyx {
60
60
  Object.assign(config, merged);
61
61
  }
62
62
  catch (err) {
63
- // Missing test override is non-fatal; re-throw import errors that aren't "not found"
64
- if (!(err instanceof Error) || !err.message.startsWith('Config not found:'))
63
+ // A test override that is genuinely ABSENT is non-fatal. Everything else
64
+ // re-throws — including `CONFIG_NOT_LOADABLE_PREFIX`, which is the whole
65
+ // point of that prefix existing (abofs/stonyx#105, invariant I2). A
66
+ // config the consumer wrote and this loader declined must never reach
67
+ // the caller as "you have no test overrides".
68
+ if (!(err instanceof Error) || !err.message.startsWith(CONFIG_NOT_FOUND_PREFIX))
65
69
  throw err;
66
70
  }
67
71
  }
@@ -1 +1,43 @@
1
+ /**
2
+ * "There is no config here." Callers are allowed to treat this as benign —
3
+ * `src/main.ts` does, for the optional `test/config/environment.*` override.
4
+ *
5
+ * `main.ts` matches on this exact string, so it is exported rather than
6
+ * duplicated: the swallow and the thrower must not be able to drift apart.
7
+ */
8
+ export declare const CONFIG_NOT_FOUND_PREFIX = "Config not found:";
9
+ /**
10
+ * "There is a config here and I declined to load it." Deliberately NOT prefixed
11
+ * with `CONFIG_NOT_FOUND_PREFIX`, because `main.ts`'s `NODE_ENV=test` catch
12
+ * swallows that prefix and must not swallow this.
13
+ *
14
+ * This is invariant I2 of the Sprint 93 install-and-boot cluster — "no silent
15
+ * decline". Before #105 the two states were literally indistinguishable:
16
+ * measured against `stonyx@0.2.3-beta.95`, a consumer with `config/environment.ts`
17
+ * and the same consumer after `mv config/environment.js config/environment.xyz`
18
+ * both produced the byte-identical `Config not found: .../config/environment.js`.
19
+ */
20
+ export declare const CONFIG_NOT_LOADABLE_PREFIX = "Config present but not loadable:";
21
+ /**
22
+ * Did this runtime refuse OUR config file, or something our config imported?
23
+ *
24
+ * `FILE_TYPE_REFUSAL_CODES.has(code)` alone cannot tell. A config that loaded
25
+ * and ran perfectly well, but whose own nested `import()` hit a `.ts` under
26
+ * `node_modules`, throws the SAME code — and was reported as "this Node runtime
27
+ * refused to load your config", naming a file that is fine. That misdirection
28
+ * points the consumer at the one file that is not the problem, and the likeliest
29
+ * instance of it is the exact `.ts`-under-node_modules case this loader's own
30
+ * docs tell people to look for.
31
+ *
32
+ * Returns true when the refusal names our path, or when no path could be
33
+ * extracted at all (unknown message shape — keep re-framing rather than
34
+ * silently losing the loud error that invariant I2 exists for).
35
+ *
36
+ * Exported ONLY so its branches can be reached directly. Driving it through
37
+ * `importConfig` reaches the two path-comparison outcomes and nothing else:
38
+ * the "no path extractable" fallback and the `realPath` catch both survived
39
+ * mutation with the suite green. A predicate inside the guard that makes
40
+ * failures loud does not get to have branches no test can reach.
41
+ */
42
+ export declare function refusalIsAboutTheConfig(message: string, configPath: string): boolean;
1
43
  export declare function importConfig<T = unknown>(basePath: string): Promise<T>;
@@ -1,10 +1,175 @@
1
- import { existsSync } from 'fs';
2
- import { pathToFileURL } from 'url';
1
+ import { existsSync, realpathSync } from 'fs';
2
+ import { fileURLToPath, pathToFileURL } from 'url';
3
+ /**
4
+ * Extensions `importConfig` will load, in preference order.
5
+ *
6
+ * `.ts` first. `stonyx@0.2.3-beta.62` shipped exactly this list; some release in
7
+ * `(beta.62, beta.82]` reduced it to a hard-coded `.js`, which is the regression
8
+ * abofs/stonyx#105 reports. Three places in this repo disagreed with that
9
+ * reduction and none were changed with it:
10
+ * - `docs/configuration.md:7,24` documents `.ts` as preferred and describes
11
+ * this exact algorithm verbatim;
12
+ * - `scripts/postinstall.js` writes `config/environment.ts` whenever the
13
+ * consumer has a `tsconfig.json` — the framework wrote the file its own
14
+ * loader then refused to read, which is how every TypeScript consumer (the
15
+ * shape `docs/cli.md:29` calls the default) failed to boot at all;
16
+ * - `src/util/resolve-entry-point.ts` — the sibling resolver, for `app.{ts,js}`
17
+ * — still prefers `.ts` today.
18
+ */
19
+ const LOADABLE_EXTENSIONS = ['ts', 'js'];
20
+ /**
21
+ * Extensions a consumer plausibly writes a config as, that this loader does not
22
+ * RESOLVE. Their only purpose is to be DETECTED: see `CONFIG_NOT_LOADABLE_PREFIX`.
23
+ *
24
+ * Named for what is actually true of them. `.mjs`, `.cjs` and `.json` are all
25
+ * perfectly readable by Node — `import()` handles every one of them today.
26
+ * Nothing here is unreadable; they are simply not in `LOADABLE_EXTENSIONS`, so
27
+ * this loader never forms a path with them, and a consumer who writes one gets
28
+ * "config present but not loadable" instead of a silent "not found". The old
29
+ * name, UNREADABLE_EXTENSIONS, asserted a property of Node that is false, and
30
+ * invited a future reader to "fix" it by adding them to the resolver.
31
+ */
32
+ const UNRESOLVED_EXTENSIONS = ['mts', 'cts', 'tsx', 'jsx', 'mjs', 'cjs', 'json'];
33
+ /**
34
+ * "There is no config here." Callers are allowed to treat this as benign —
35
+ * `src/main.ts` does, for the optional `test/config/environment.*` override.
36
+ *
37
+ * `main.ts` matches on this exact string, so it is exported rather than
38
+ * duplicated: the swallow and the thrower must not be able to drift apart.
39
+ */
40
+ export const CONFIG_NOT_FOUND_PREFIX = 'Config not found:';
41
+ /**
42
+ * "There is a config here and I declined to load it." Deliberately NOT prefixed
43
+ * with `CONFIG_NOT_FOUND_PREFIX`, because `main.ts`'s `NODE_ENV=test` catch
44
+ * swallows that prefix and must not swallow this.
45
+ *
46
+ * This is invariant I2 of the Sprint 93 install-and-boot cluster — "no silent
47
+ * decline". Before #105 the two states were literally indistinguishable:
48
+ * measured against `stonyx@0.2.3-beta.95`, a consumer with `config/environment.ts`
49
+ * and the same consumer after `mv config/environment.js config/environment.xyz`
50
+ * both produced the byte-identical `Config not found: .../config/environment.js`.
51
+ */
52
+ export const CONFIG_NOT_LOADABLE_PREFIX = 'Config present but not loadable:';
53
+ /**
54
+ * Node error codes that mean "I can see this file and I refuse to load it",
55
+ * as distinct from "your config threw" or "your config imports something
56
+ * missing". Only these are re-framed; everything else propagates untouched so a
57
+ * genuine syntax error in a consumer's config still reads as one.
58
+ *
59
+ * `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` is the load-bearing member:
60
+ * Node refuses to type-strip anything under `node_modules`, so a `@stonyx/*`
61
+ * module that ships `config/environment.ts` crashes every consumer. That rule
62
+ * is unchanged by this fix and is why `docs/conventions/framework-modules.md`
63
+ * still requires modules to ship `.js` — but the failure is now named.
64
+ */
65
+ const FILE_TYPE_REFUSAL_CODES = new Set([
66
+ 'ERR_UNKNOWN_FILE_EXTENSION',
67
+ 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING',
68
+ ]);
69
+ /**
70
+ * The first absolute POSIX path named in a Node error message.
71
+ *
72
+ * Both refusal codes name the file they refused, but in different shapes —
73
+ * measured on node v24.13.0:
74
+ *
75
+ * ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING
76
+ * Stripping types is currently unsupported for files under node_modules,
77
+ * for "/private/tmp/x/node_modules/dep/thing.ts"
78
+ * ERR_UNKNOWN_FILE_EXTENSION
79
+ * Unknown file extension ".ts" for /private/tmp/x/environment.ts
80
+ *
81
+ * The second names a bare extension BEFORE it names the file, which is why
82
+ * this requires a leading `/` rather than matching the first quoted token.
83
+ * Neither error exposes the path as an own property: `Object.keys(error)` is
84
+ * `['code']` for both, so the message is the only source.
85
+ *
86
+ * Deliberately POSIX-only. A Windows path is simply not extracted, and
87
+ * `refusalIsAboutTheConfig` treats "no path extracted" as "cannot tell" and
88
+ * keeps the pre-existing behaviour, so this degrades rather than misfires.
89
+ */
90
+ const REFUSED_PATH_PATTERN = /(?:^|[\s"'(])((?:file:\/\/)?\/[^\s"'()]+\.[A-Za-z0-9]+)/;
91
+ /** Resolves symlinks so `/tmp/...` and `/private/tmp/...` compare equal. */
92
+ function realPath(path) {
93
+ try {
94
+ return realpathSync(path);
95
+ }
96
+ catch {
97
+ return path;
98
+ }
99
+ }
100
+ /**
101
+ * Did this runtime refuse OUR config file, or something our config imported?
102
+ *
103
+ * `FILE_TYPE_REFUSAL_CODES.has(code)` alone cannot tell. A config that loaded
104
+ * and ran perfectly well, but whose own nested `import()` hit a `.ts` under
105
+ * `node_modules`, throws the SAME code — and was reported as "this Node runtime
106
+ * refused to load your config", naming a file that is fine. That misdirection
107
+ * points the consumer at the one file that is not the problem, and the likeliest
108
+ * instance of it is the exact `.ts`-under-node_modules case this loader's own
109
+ * docs tell people to look for.
110
+ *
111
+ * Returns true when the refusal names our path, or when no path could be
112
+ * extracted at all (unknown message shape — keep re-framing rather than
113
+ * silently losing the loud error that invariant I2 exists for).
114
+ *
115
+ * Exported ONLY so its branches can be reached directly. Driving it through
116
+ * `importConfig` reaches the two path-comparison outcomes and nothing else:
117
+ * the "no path extractable" fallback and the `realPath` catch both survived
118
+ * mutation with the suite green. A predicate inside the guard that makes
119
+ * failures loud does not get to have branches no test can reach.
120
+ */
121
+ export function refusalIsAboutTheConfig(message, configPath) {
122
+ const refused = REFUSED_PATH_PATTERN.exec(message)?.[1];
123
+ if (!refused)
124
+ return true;
125
+ const refusedPath = refused.startsWith('file://') ? fileURLToPath(refused) : refused;
126
+ return refusedPath === configPath || realPath(refusedPath) === realPath(configPath);
127
+ }
3
128
  export async function importConfig(basePath) {
4
- const path = `${basePath}.js`;
5
- if (!existsSync(path)) {
6
- throw new Error(`Config not found: ${path}`);
129
+ const matches = LOADABLE_EXTENSIONS.filter(ext => existsSync(`${basePath}.${ext}`));
130
+ if (matches.length === 0) {
131
+ const declined = UNRESOLVED_EXTENSIONS.filter(ext => existsSync(`${basePath}.${ext}`));
132
+ if (declined.length > 0) {
133
+ throw new Error(`${CONFIG_NOT_LOADABLE_PREFIX} ${basePath}.${declined.join(` and ${basePath}.`)} ` +
134
+ `exists, but Stonyx loads ${basePath}.ts or ${basePath}.js only. ` +
135
+ `Rename or compile it — this is not a missing config.`);
136
+ }
137
+ throw new Error(`${CONFIG_NOT_FOUND_PREFIX} ${basePath}.{ts,js}`);
138
+ }
139
+ if (matches.length > 1) {
140
+ console.warn(`Warning: both ${basePath}.ts and ${basePath}.js exist. Using .ts — delete the .js to silence this warning (it is likely a stale compiled artifact or postinstall stub).`);
141
+ }
142
+ const path = `${basePath}.${matches[0]}`;
143
+ try {
144
+ const mod = await import(pathToFileURL(path).href);
145
+ return mod.default;
146
+ }
147
+ catch (error) {
148
+ const code = error?.code;
149
+ if (code && FILE_TYPE_REFUSAL_CODES.has(code) && refusalIsAboutTheConfig(error.message, path)) {
150
+ // Both files exist AND the `.ts` was refused. This is the one state where
151
+ // the both-present warning printed a few lines above is actively wrong
152
+ // advice: it says "delete the .js", and here the `.js` is the only file
153
+ // this runtime can read. Say so explicitly rather than leaving the
154
+ // consumer to reconcile two messages from the same call.
155
+ //
156
+ // It is also a deliberate behaviour change. `stonyx@0.2.3-beta.95`
157
+ // resolved `${basePath}.js` unconditionally, so this exact state LOADED
158
+ // and booted; measured against that loader, same fixture, plain node:
159
+ // it returned the `.js` default export with no warning. Preferring `.ts`
160
+ // is the point of #105, and the population most likely to hold both
161
+ // files is the one that worked around #105 by renaming to `.js` while
162
+ // the postinstall kept writing the `.ts` stub. So this message has to
163
+ // carry the "this used to work" fact, not just the remedy.
164
+ const sibling = matches.length > 1
165
+ ? ` ${basePath}.js also exists and this runtime CAN read it, but .ts wins the preference order` +
166
+ ` so the .js is never reached. Disregard the "delete the .js" warning above — in THIS state the` +
167
+ ` fix is the opposite one: remove or compile ${basePath}.ts.` +
168
+ ` (stonyx@0.2.3-beta.95 loaded the .js here; preferring .ts is a deliberate change — abofs/stonyx#105.)`
169
+ : '';
170
+ throw new Error(`${CONFIG_NOT_LOADABLE_PREFIX} ${path} exists, but this Node runtime refused to load it ` +
171
+ `(${code}): ${error.message}.${sibling}`, { cause: error });
172
+ }
173
+ throw error;
7
174
  }
8
- const mod = await import(pathToFileURL(path).href);
9
- return mod.default;
10
175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stonyx",
3
- "version": "0.2.3-alpha.46",
3
+ "version": "0.2.3-alpha.48",
4
4
  "description": "Base stonyx framework module",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",