@pikku/core 0.12.95 → 0.12.97
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/CHANGELOG.md +66 -0
- package/dist/dev/hot-reload.js +24 -4
- package/dist/dev/module-runner.d.ts +20 -3
- package/dist/dev/module-runner.js +17 -4
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/require-origin.d.ts +23 -0
- package/dist/middleware/require-origin.js +57 -0
- package/dist/services/secret-service.d.ts +5 -0
- package/dist/services/typed-variables-service.d.ts +34 -0
- package/dist/services/typed-variables-service.js +69 -3
- package/dist/wirings/workflow/scenario-prose.d.ts +23 -1
- package/dist/wirings/workflow/scenario-prose.js +12 -3
- package/dist/wirings/workflow/scenario-run.types.d.ts +7 -0
- package/package.json +1 -1
- package/src/dev/hot-reload.test.ts +42 -0
- package/src/dev/hot-reload.ts +30 -4
- package/src/dev/module-runner.test.ts +56 -13
- package/src/dev/module-runner.ts +32 -10
- package/src/middleware/index.ts +1 -0
- package/src/middleware/require-origin.test.ts +115 -0
- package/src/middleware/require-origin.ts +79 -0
- package/src/public-surface.json +4 -1
- package/src/services/secret-service.ts +5 -0
- package/src/services/typed-variables-service.test.ts +93 -0
- package/src/services/typed-variables-service.ts +94 -3
- package/src/wirings/virtual-user/virtual-user-derive.test.ts +5 -5
- package/src/wirings/workflow/scenario-prose.test.ts +134 -9
- package/src/wirings/workflow/scenario-prose.ts +37 -2
- package/src/wirings/workflow/scenario-run.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,69 @@
|
|
|
1
|
+
## 0.12.97
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 8154b1c: Restore the `SecretService.getSecret` JSDoc noting its failure mode, and state the `optional` carve-out `defineSecret` already documents: a key declared `optional` resolves `undefined` when absent rather than throwing. The line was on `main` and was removed by mistake in a comment cleanup on #1411 — the PR that changes what that throw says — leaving `getSecret` the only one of the interface's methods without its documented failure mode.
|
|
6
|
+
- 6d9c09c: Resolve a variable's declared default instead of dropping it.
|
|
7
|
+
|
|
8
|
+
`defineVariable` takes a schema, and a schema can carry a default — `z.enum(['https://api.github.com']).default('https://api.github.com')` is the shape most addons declare their base URL with. Nothing read it. `variables.get('GITHUB_BASE_URL')` returned `undefined` on a host that had not set it, and the `as string` at the call site hid that until a request went to `undefined/repos/...`.
|
|
9
|
+
|
|
10
|
+
The default now resolves in `TypedVariablesService`, which is the layer that knows what was declared — `VariablesService` only knows what a host put in it. A stored value always wins; a schema with no default still resolves to `undefined`.
|
|
11
|
+
|
|
12
|
+
`VariableStatus` gains `hasDefault`, and `getMissing()` no longer lists a variable that defaults: it has a value, just not one anybody has to supply. `isConfigured` still means what it said — that a host set it.
|
|
13
|
+
|
|
14
|
+
For this to work the generated `TYPED_VARIABLES_META` now carries the schema as a value rather than only `z.infer`-ing its type, so the schema module is retained in the emit instead of being elided.
|
|
15
|
+
|
|
16
|
+
- 239332b: Move first-party product analytics out of application code and into the framework.
|
|
17
|
+
|
|
18
|
+
`createAnalytics<Event>({ endpoint })` in `@pikku/react` is the buffered beacon client: it is typed against the app's own event union, flushes on an interval, on size and on `pagehide`/`visibilitychange` (via `sendBeacon`, so the abandon-point events survive unload), never surfaces a failure to the user and never retries. It also carries the delegated `data-analytics-click` listener, registered in the capture phase so a component calling `stopPropagation()` cannot silence instrumentation, and merging `data-analytics-meta` from ancestors with nearest-wins. Put the client on the Pikku instance and `usePikkuAnalytics<Event>()` reaches it from the provider, alongside `usePikkuFetch` and `usePikkuRPC`.
|
|
19
|
+
|
|
20
|
+
`requireOrigin()` in `@pikku/core/middleware` is a server-side origin lock for any unauthed route, and is re-exported from the generated `#pikku/middleware` leaf alongside `cors`. Unlike `cors()` — which only sets response headers a non-browser client ignores — it rejects with a 403 before the function body. Comparison is exact on the parsed origin, so `https://evil-myapp.com` cannot suffix-match `myapp.com`, and a missing `Origin` is rejected because a real browser always sets one on a cross-origin-capable POST. Allowed origins default to the request's own host and can be extended with a list or a resolver over services. `isAllowedOrigin` and `toOrigin` are exported for direct unit testing.
|
|
21
|
+
|
|
22
|
+
Together these let an app keep only its event registry and its wiring, instead of a few hundred lines of copied transport.
|
|
23
|
+
|
|
24
|
+
## 0.12.96
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- 88629af: Say why a hot-reload import failed instead of only that it did.
|
|
29
|
+
|
|
30
|
+
The dev module runner caught every failure bare and returned `null`, and the reloader turned that into a single line: `Failed to import: … (keeping old code)`. Keeping the old code is the right call, but it leaves the running process disagreeing with the file on disk, and the only symptom is a function returning stale output while the editor shows the new source — `tsc` passes, every import resolves, and there is nothing anywhere to explain it.
|
|
31
|
+
|
|
32
|
+
`run` now returns `{ ok: true, exports }` or `{ ok: false, error }`, so the failure case cannot be read past, and the reloader prints the error's message and stack under the existing line. A failure matching pikku's own documented limitation — a file using top-level `await`, which the `cjs` emit cannot express — says so outright, because in that case nothing is wrong with the file and re-reading it will never reveal that.
|
|
33
|
+
|
|
34
|
+
- f1ccfe3: A step ladder reads as one paragraph, not a list of restatements
|
|
35
|
+
|
|
36
|
+
Every step prefixed its actor with `the `, named that actor again, and repeated
|
|
37
|
+
the phase keyword. A three-step run by one person said their name three times
|
|
38
|
+
and `Given` three times, only read as English when the persona key happened to
|
|
39
|
+
be a role noun, and never said who that person was — the fabric template's own
|
|
40
|
+
placeholder came out as `the nadia opens /app`.
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
Given yasser (the founder) signs in
|
|
44
|
+
When yasser opens the dashboard
|
|
45
|
+
And sees the audit log
|
|
46
|
+
And nadia reviews the invite
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The article is gone: the actor key is the subject verbatim, so a persona named
|
|
50
|
+
after a person reads as that person. A repeated phase reads as `And`, the way
|
|
51
|
+
Gherkin has always written it. A step that continues both the phase and the
|
|
52
|
+
actor drops the repeated subject, because English drops a repeated subject in a
|
|
53
|
+
compound predicate — it takes both, since eliding across a phase change gives
|
|
54
|
+
`When opens the dashboard`, and a pronoun rather than a name would give `they
|
|
55
|
+
sees`, step templates being authored in the third person singular.
|
|
56
|
+
|
|
57
|
+
An actor is introduced once, by the persona's `jobTitle` — prose someone wrote
|
|
58
|
+
for a reader. `roles` is authorisation, so a persona whose only description is a
|
|
59
|
+
`reviewer` grant gets no introduction rather than one assembled out of grants.
|
|
60
|
+
A row carries `sentenceWithRole` alongside `sentence`, set only where an actor
|
|
61
|
+
is first named, so a renderer can offer the introduction as a toggle without
|
|
62
|
+
parsing a composed sentence back apart.
|
|
63
|
+
|
|
64
|
+
`{placeholder}` filling, the `#ordinal` lookup for repeated step names and an
|
|
65
|
+
actorless step reading as its description alone are all unchanged.
|
|
66
|
+
|
|
1
67
|
## 0.12.95
|
|
2
68
|
|
|
3
69
|
### Patch Changes
|
package/dist/dev/hot-reload.js
CHANGED
|
@@ -6,7 +6,7 @@ import { clearMiddlewareCache } from '../middleware-runner.js';
|
|
|
6
6
|
import { clearPermissionsCache } from '../permissions.js';
|
|
7
7
|
import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js';
|
|
8
8
|
import { httpRouter } from '../wirings/http/routers/http-router.js';
|
|
9
|
-
import { createModuleRunner } from './module-runner.js';
|
|
9
|
+
import { createModuleRunner, isTopLevelAwaitLimitation, } from './module-runner.js';
|
|
10
10
|
export { reloadGeneratedMeta, reconcileAddonRegistry } from './reload-meta.js';
|
|
11
11
|
const isFunctionConfig = (value) => {
|
|
12
12
|
return (typeof value === 'object' &&
|
|
@@ -40,6 +40,20 @@ const isWatchedTsFile = (filename) => {
|
|
|
40
40
|
// Hidden files: editor/sed atomic-write temps must never trigger a reload.
|
|
41
41
|
!basename(filename).startsWith('.'));
|
|
42
42
|
};
|
|
43
|
+
/** Not every reload failure is a mistake in the file: pikku's reloader emits
|
|
44
|
+
* `cjs`, which has no way to express top-level `await`, so a perfectly valid
|
|
45
|
+
* module can fail here forever. Saying so outright saves the reader from
|
|
46
|
+
* hunting a bug that is not in their code. The stack is dropped in that case
|
|
47
|
+
* because it points into esbuild rather than at anything actionable. */
|
|
48
|
+
const reloadFailureReason = (error) => {
|
|
49
|
+
if (isTopLevelAwaitLimitation(error)) {
|
|
50
|
+
return (` ${error.message}\n` +
|
|
51
|
+
' This is a pikku limitation, not a mistake in your file: the hot-reloader compiles to `cjs`, ' +
|
|
52
|
+
'which cannot express top-level `await`. Move the awaited work into a function, or restart the ' +
|
|
53
|
+
'dev server to pick the file up.');
|
|
54
|
+
}
|
|
55
|
+
return ` ${error.stack ?? error.message}`;
|
|
56
|
+
};
|
|
43
57
|
export async function pikkuDevReloader(options) {
|
|
44
58
|
const { srcDirectories, logger, pikkuDir = '.pikku' } = options;
|
|
45
59
|
const absSrcDirs = srcDirectories.map((d) => resolve(d));
|
|
@@ -56,11 +70,17 @@ export async function pikkuDevReloader(options) {
|
|
|
56
70
|
return;
|
|
57
71
|
const compiledFile = await findCompiledFile(changedTsFile, srcDir, absPikkuDir);
|
|
58
72
|
const importPath = compiledFile ?? changedTsFile;
|
|
59
|
-
const
|
|
60
|
-
if (!
|
|
61
|
-
|
|
73
|
+
const result = await moduleRunner.run(importPath);
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
// Keeping the old code leaves the process disagreeing with the file on
|
|
76
|
+
// disk, and the only symptom is stale output from a function that looks
|
|
77
|
+
// correct in the editor — so the reason has to be printed here, where it
|
|
78
|
+
// is still known, rather than left for the developer to reconstruct.
|
|
79
|
+
logger.error(`Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)\n` +
|
|
80
|
+
reloadFailureReason(result.error));
|
|
62
81
|
return;
|
|
63
82
|
}
|
|
83
|
+
const mod = result.exports;
|
|
64
84
|
// knowledge: decisions/internals/hot-reload-writes-into-the-function-map-captured-at-startup.md
|
|
65
85
|
for (const [exportName, exportValue] of Object.entries(mod)) {
|
|
66
86
|
if (!isFunctionConfig(exportValue))
|
|
@@ -1,10 +1,27 @@
|
|
|
1
|
+
/** The outcome of one run. A failure carries its error rather than collapsing
|
|
2
|
+
* to `null`: the caller keeps serving the previously-loaded code, so unless the
|
|
3
|
+
* reason travels with the failure the running process silently disagrees with
|
|
4
|
+
* the file on disk and nothing anywhere says why. */
|
|
5
|
+
export type PikkuModuleRunResult = {
|
|
6
|
+
ok: true;
|
|
7
|
+
exports: Record<string, unknown>;
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
error: Error;
|
|
11
|
+
};
|
|
1
12
|
export interface PikkuModuleRunner {
|
|
2
13
|
/** Run a user module by absolute path. Repeated runs of one path overwrite a
|
|
3
|
-
* single registry slot.
|
|
4
|
-
* previously-loaded code
|
|
5
|
-
|
|
14
|
+
* single registry slot. Failure is returned, not thrown, so the caller can
|
|
15
|
+
* keep the previously-loaded code — and the discriminant makes that case
|
|
16
|
+
* impossible to read past by accident. */
|
|
17
|
+
run: (absPath: string) => Promise<PikkuModuleRunResult>;
|
|
6
18
|
evict: (absPath: string) => void;
|
|
7
19
|
clear: () => void;
|
|
8
20
|
readonly size: number;
|
|
9
21
|
}
|
|
22
|
+
/** esbuild states pikku's one documented reload limitation only in the text of
|
|
23
|
+
* its transform error. Matching it is worth the fragility: the developer's file
|
|
24
|
+
* is correct, and no amount of re-reading it will reveal that the reloader —
|
|
25
|
+
* not the file — is what cannot cope. */
|
|
26
|
+
export declare const isTopLevelAwaitLimitation: (error: Error) => boolean;
|
|
10
27
|
export declare const createModuleRunner: () => PikkuModuleRunner;
|
|
@@ -13,6 +13,11 @@ const loadTransform = async () => {
|
|
|
13
13
|
transformSync = esbuild.transformSync;
|
|
14
14
|
return transformSync;
|
|
15
15
|
};
|
|
16
|
+
/** esbuild states pikku's one documented reload limitation only in the text of
|
|
17
|
+
* its transform error. Matching it is worth the fragility: the developer's file
|
|
18
|
+
* is correct, and no amount of re-reading it will reveal that the reloader —
|
|
19
|
+
* not the file — is what cannot cope. */
|
|
20
|
+
export const isTopLevelAwaitLimitation = (error) => /top-level await/i.test(error.message);
|
|
16
21
|
export const createModuleRunner = () => {
|
|
17
22
|
const registry = new Map();
|
|
18
23
|
const run = async (filePath) => {
|
|
@@ -30,12 +35,20 @@ export const createModuleRunner = () => {
|
|
|
30
35
|
const moduleObj = { exports: {} };
|
|
31
36
|
fn(require, moduleObj.exports, moduleObj, absPath, dirname(absPath));
|
|
32
37
|
registry.set(absPath, moduleObj.exports);
|
|
33
|
-
return moduleObj.exports;
|
|
38
|
+
return { ok: true, exports: moduleObj.exports };
|
|
34
39
|
}
|
|
35
|
-
catch {
|
|
40
|
+
catch (thrown) {
|
|
36
41
|
// A bad edit, or the one known limitation: a file using top-level
|
|
37
|
-
// `await`, which cannot be emitted in `cjs` form.
|
|
38
|
-
|
|
42
|
+
// `await`, which cannot be emitted in `cjs` form. Normalised to an
|
|
43
|
+
// `Error` so the caller always has a message and a stack to print
|
|
44
|
+
// without re-deriving them; a non-`Error` throw keeps its original value
|
|
45
|
+
// as the `cause`.
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
error: thrown instanceof Error
|
|
49
|
+
? thrown
|
|
50
|
+
: new Error(String(thrown), { cause: thrown }),
|
|
51
|
+
};
|
|
39
52
|
}
|
|
40
53
|
};
|
|
41
54
|
return {
|
|
@@ -3,6 +3,7 @@ export { authCookie } from './auth-cookie.js';
|
|
|
3
3
|
export { authBearer } from './auth-bearer.js';
|
|
4
4
|
export { pikkuRemoteAuthMiddleware } from './remote-auth.js';
|
|
5
5
|
export { cors } from './cors.js';
|
|
6
|
+
export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js';
|
|
6
7
|
export { telemetryOuter, telemetryInner } from './telemetry.js';
|
|
7
8
|
export { addTagMiddleware, addTagMiddleware as addMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
|
|
8
9
|
export { addGlobalPermission } from '../permissions.js';
|
package/dist/middleware/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { authCookie } from './auth-cookie.js';
|
|
|
3
3
|
export { authBearer } from './auth-bearer.js';
|
|
4
4
|
export { pikkuRemoteAuthMiddleware } from './remote-auth.js';
|
|
5
5
|
export { cors } from './cors.js';
|
|
6
|
+
export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js';
|
|
6
7
|
export { telemetryOuter, telemetryInner } from './telemetry.js';
|
|
7
8
|
export { addTagMiddleware, addTagMiddleware as addMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
|
|
8
9
|
export { addGlobalPermission } from '../permissions.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CoreSingletonServices } from '../types/core.types.js';
|
|
2
|
+
/** Scheme + host + port, or null for anything unparseable including the literal `"null"` origin. */
|
|
3
|
+
export declare const toOrigin: (value: string | null | undefined) => string | null;
|
|
4
|
+
/**
|
|
5
|
+
* Whether a request origin may post to an origin-locked route.
|
|
6
|
+
*
|
|
7
|
+
* The comparison is exact on the parsed origin, never a suffix match:
|
|
8
|
+
* `endsWith('myapp.com')` also accepts `https://evil-myapp.com`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const isAllowedOrigin: (requestOrigin: string | null, hostOrigin: string | null, configuredOrigins: string[]) => boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Rejects a request with a 403 unless its `Origin` is this app's own or explicitly allowed.
|
|
13
|
+
*
|
|
14
|
+
* This is not what `cors()` does. CORS sets response headers and is enforced by the
|
|
15
|
+
* browser, so a non-browser client ignores them and the request still runs; this rejects
|
|
16
|
+
* before the function body. It stops another site's page from posting to an unauthed
|
|
17
|
+
* route — it is not flood control, because `Origin` is trusted from nobody but a browser.
|
|
18
|
+
* A missing `Origin` is rejected too: a real browser sets one on a cross-origin-capable POST.
|
|
19
|
+
*/
|
|
20
|
+
export declare const requireOrigin: import("./middleware.types.js").CorePikkuMiddlewareFactory<{
|
|
21
|
+
/** Extra allowed origins beyond the request's own host, or a resolver for them. */
|
|
22
|
+
origins?: string[] | ((services: CoreSingletonServices) => string[] | Promise<string[]>);
|
|
23
|
+
}>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { InvalidOriginError } from '../errors/errors.js';
|
|
2
|
+
import { pikkuMiddleware, pikkuMiddlewareFactory, } from './middleware-factories.js';
|
|
3
|
+
/** Scheme + host + port, or null for anything unparseable including the literal `"null"` origin. */
|
|
4
|
+
export const toOrigin = (value) => {
|
|
5
|
+
if (!value)
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(value);
|
|
9
|
+
return url.protocol && url.host ? url.origin : null;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Whether a request origin may post to an origin-locked route.
|
|
17
|
+
*
|
|
18
|
+
* The comparison is exact on the parsed origin, never a suffix match:
|
|
19
|
+
* `endsWith('myapp.com')` also accepts `https://evil-myapp.com`.
|
|
20
|
+
*/
|
|
21
|
+
export const isAllowedOrigin = (requestOrigin, hostOrigin, configuredOrigins) => {
|
|
22
|
+
if (!requestOrigin)
|
|
23
|
+
return false;
|
|
24
|
+
if (hostOrigin && requestOrigin === hostOrigin)
|
|
25
|
+
return true;
|
|
26
|
+
return configuredOrigins.some((allowed) => toOrigin(allowed) === requestOrigin);
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Rejects a request with a 403 unless its `Origin` is this app's own or explicitly allowed.
|
|
30
|
+
*
|
|
31
|
+
* This is not what `cors()` does. CORS sets response headers and is enforced by the
|
|
32
|
+
* browser, so a non-browser client ignores them and the request still runs; this rejects
|
|
33
|
+
* before the function body. It stops another site's page from posting to an unauthed
|
|
34
|
+
* route — it is not flood control, because `Origin` is trusted from nobody but a browser.
|
|
35
|
+
* A missing `Origin` is rejected too: a real browser sets one on a cross-origin-capable POST.
|
|
36
|
+
*/
|
|
37
|
+
export const requireOrigin = pikkuMiddlewareFactory(({ origins = [] } = {}) => pikkuMiddleware({
|
|
38
|
+
name: 'requireOrigin',
|
|
39
|
+
description: 'Rejects requests that did not come from this app.',
|
|
40
|
+
func: async (services, { http }, next) => {
|
|
41
|
+
const request = http?.request;
|
|
42
|
+
if (!request)
|
|
43
|
+
return next();
|
|
44
|
+
const requestOrigin = toOrigin(request.header('origin')) ??
|
|
45
|
+
toOrigin(request.header('referer'));
|
|
46
|
+
const host = request.header('host');
|
|
47
|
+
const proto = request.header('x-forwarded-proto') ?? 'https';
|
|
48
|
+
const hostOrigin = host ? toOrigin(`${proto}://${host}`) : null;
|
|
49
|
+
const configured = typeof origins === 'function'
|
|
50
|
+
? await origins(services)
|
|
51
|
+
: origins;
|
|
52
|
+
if (!isAllowedOrigin(requestOrigin, hostOrigin, configured)) {
|
|
53
|
+
throw new InvalidOriginError(`Rejected origin ${requestOrigin ?? '(none)'}`);
|
|
54
|
+
}
|
|
55
|
+
return next();
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
@@ -4,6 +4,11 @@ export type SecretValues<T> = {
|
|
|
4
4
|
[K in keyof T]: SecretValue<T[K]>;
|
|
5
5
|
};
|
|
6
6
|
export interface SecretService {
|
|
7
|
+
/**
|
|
8
|
+
* Throws if the secret is not found, unless `defineSecret` declared it
|
|
9
|
+
* `optional` — then absence resolves `undefined`. Unwrap the result with
|
|
10
|
+
* `.reveal()`.
|
|
11
|
+
*/
|
|
7
12
|
getSecret<T = string>(key: string): Promise<SecretValue<T>>;
|
|
8
13
|
/** Answers for any key, including a disallowed one — it must not throw. */
|
|
9
14
|
hasSecret(key: string): Promise<boolean>;
|
|
@@ -1,14 +1,34 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
1
2
|
import type { VariablesService } from './variables-service.js';
|
|
2
3
|
export interface VariableStatus {
|
|
3
4
|
variableId: string;
|
|
4
5
|
name: string;
|
|
5
6
|
displayName: string;
|
|
6
7
|
isConfigured: boolean;
|
|
8
|
+
/** Whether the declaration answers for itself when the host sets nothing. */
|
|
9
|
+
hasDefault: boolean;
|
|
7
10
|
}
|
|
8
11
|
export type VariableMeta = {
|
|
9
12
|
name: string;
|
|
10
13
|
displayName: string;
|
|
14
|
+
/**
|
|
15
|
+
* The shape the variable was declared with. It is the schema itself rather
|
|
16
|
+
* than a description of it, because a default is only knowable by running it:
|
|
17
|
+
* `undefined` goes in and, if the declaration carries one, the default comes
|
|
18
|
+
* back out.
|
|
19
|
+
*
|
|
20
|
+
* A thunk is accepted, and is what code generation emits. The generated file
|
|
21
|
+
* and the file declaring the schema import each other, so reading the schema
|
|
22
|
+
* while the modules are still initializing throws — deferring the read until
|
|
23
|
+
* a variable is actually asked for is what keeps the cycle harmless.
|
|
24
|
+
*/
|
|
25
|
+
schema?: StandardSchemaV1 | (() => StandardSchemaV1);
|
|
11
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* A declared default is the answer to a variable nobody set, so it is resolved
|
|
29
|
+
* here rather than in `VariablesService`: the store knows what a host has put
|
|
30
|
+
* in it, and only this layer knows what was declared.
|
|
31
|
+
*/
|
|
12
32
|
export declare class TypedVariablesService<TMap = Record<string, unknown>> implements VariablesService {
|
|
13
33
|
private variables;
|
|
14
34
|
private variablesMeta;
|
|
@@ -21,5 +41,19 @@ export declare class TypedVariablesService<TMap = Record<string, unknown>> imple
|
|
|
21
41
|
has(name: string): Promise<boolean> | boolean;
|
|
22
42
|
delete(name: string): Promise<void> | void;
|
|
23
43
|
getAllStatus(): Promise<VariableStatus[]>;
|
|
44
|
+
/**
|
|
45
|
+
* What a deployment still has to be told. A variable that defaults is not on
|
|
46
|
+
* this list — it has a value, just not one anybody has to supply.
|
|
47
|
+
*/
|
|
24
48
|
getMissing(): Promise<VariableStatus[]>;
|
|
49
|
+
/**
|
|
50
|
+
* The value the declaration answers with when the host set nothing, or
|
|
51
|
+
* `undefined` when it does not answer for itself.
|
|
52
|
+
*/
|
|
53
|
+
private resolveDefault;
|
|
54
|
+
/**
|
|
55
|
+
* Kept synchronous when the defaults resolve synchronously, so a caller that
|
|
56
|
+
* did not await `getVariables` before does not have to start.
|
|
57
|
+
*/
|
|
58
|
+
private withDefaults;
|
|
25
59
|
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
const isPromise = (value) => typeof value?.then === 'function';
|
|
2
|
+
/**
|
|
3
|
+
* A declared default is the answer to a variable nobody set, so it is resolved
|
|
4
|
+
* here rather than in `VariablesService`: the store knows what a host has put
|
|
5
|
+
* in it, and only this layer knows what was declared.
|
|
6
|
+
*/
|
|
1
7
|
export class TypedVariablesService {
|
|
2
8
|
variables;
|
|
3
9
|
variablesMeta;
|
|
@@ -6,10 +12,18 @@ export class TypedVariablesService {
|
|
|
6
12
|
this.variablesMeta = variablesMeta;
|
|
7
13
|
}
|
|
8
14
|
get(name) {
|
|
9
|
-
|
|
15
|
+
const stored = this.variables.get(name);
|
|
16
|
+
if (isPromise(stored)) {
|
|
17
|
+
return stored.then((value) => value === undefined ? this.resolveDefault(name) : value);
|
|
18
|
+
}
|
|
19
|
+
return stored === undefined ? this.resolveDefault(name) : stored;
|
|
10
20
|
}
|
|
11
21
|
getVariables(names) {
|
|
12
|
-
|
|
22
|
+
const stored = this.variables.getVariables(names);
|
|
23
|
+
if (isPromise(stored)) {
|
|
24
|
+
return stored.then((values) => this.withDefaults(names, values));
|
|
25
|
+
}
|
|
26
|
+
return this.withDefaults(names, stored);
|
|
13
27
|
}
|
|
14
28
|
getAll() {
|
|
15
29
|
return this.variables.getAll();
|
|
@@ -32,12 +46,64 @@ export class TypedVariablesService {
|
|
|
32
46
|
name: meta.name,
|
|
33
47
|
displayName: meta.displayName,
|
|
34
48
|
isConfigured: all[variableId] !== undefined,
|
|
49
|
+
hasDefault: (await this.resolveDefault(variableId)) !== undefined,
|
|
35
50
|
});
|
|
36
51
|
}
|
|
37
52
|
return results;
|
|
38
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* What a deployment still has to be told. A variable that defaults is not on
|
|
56
|
+
* this list — it has a value, just not one anybody has to supply.
|
|
57
|
+
*/
|
|
39
58
|
async getMissing() {
|
|
40
59
|
const all = await this.getAllStatus();
|
|
41
|
-
return all.filter((v) => !v.isConfigured);
|
|
60
|
+
return all.filter((v) => !v.isConfigured && !v.hasDefault);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The value the declaration answers with when the host set nothing, or
|
|
64
|
+
* `undefined` when it does not answer for itself.
|
|
65
|
+
*/
|
|
66
|
+
resolveDefault(name) {
|
|
67
|
+
const declared = this.variablesMeta[name]?.schema;
|
|
68
|
+
if (!declared) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const schema = typeof declared === 'function' ? declared() : declared;
|
|
72
|
+
const result = schema['~standard'].validate(undefined);
|
|
73
|
+
if (isPromise(result)) {
|
|
74
|
+
return result.then(unwrapDefault);
|
|
75
|
+
}
|
|
76
|
+
return unwrapDefault(result);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Kept synchronous when the defaults resolve synchronously, so a caller that
|
|
80
|
+
* did not await `getVariables` before does not have to start.
|
|
81
|
+
*/
|
|
82
|
+
withDefaults(names, values) {
|
|
83
|
+
const out = { ...values };
|
|
84
|
+
const pending = [];
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
if (out[name] !== undefined)
|
|
87
|
+
continue;
|
|
88
|
+
const fallback = this.resolveDefault(name);
|
|
89
|
+
if (isPromise(fallback)) {
|
|
90
|
+
pending.push(fallback.then((value) => {
|
|
91
|
+
if (value !== undefined)
|
|
92
|
+
out[name] = value;
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
else if (fallback !== undefined) {
|
|
96
|
+
out[name] = fallback;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (pending.length > 0) {
|
|
100
|
+
return Promise.all(pending).then(() => out);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
42
103
|
}
|
|
43
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* A schema with no default rejects `undefined`, which is not a failure here —
|
|
107
|
+
* it is the answer that there is nothing to fall back to.
|
|
108
|
+
*/
|
|
109
|
+
const unwrapDefault = (result) => result.issues ? undefined : result.value;
|
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
import type { ScenarioStepPhase } from './scenario-step.types.js';
|
|
2
2
|
export declare const renderStepTemplate: (template: string, input: unknown) => string;
|
|
3
|
-
export declare const composeStepProse: ({ phase, description, template, input, actor, keywordWidth, }: {
|
|
3
|
+
export declare const composeStepProse: ({ phase, description, template, input, actor, actorRole, continuesPhase, continuesActor, keywordWidth, }: {
|
|
4
4
|
phase: ScenarioStepPhase;
|
|
5
5
|
description: string;
|
|
6
6
|
template?: string;
|
|
7
7
|
input?: unknown;
|
|
8
8
|
actor?: string;
|
|
9
|
+
/**
|
|
10
|
+
* What this actor is, rendered as an apposition after their key — "yasser
|
|
11
|
+
* (the founder)". Only pass it where the actor has not been named yet: an
|
|
12
|
+
* ordinary run repeats one actor for a dozen steps, and repeating the role
|
|
13
|
+
* with them turns the one piece of context into the noise around it.
|
|
14
|
+
*/
|
|
15
|
+
actorRole?: string;
|
|
16
|
+
/**
|
|
17
|
+
* This step repeats the phase of the one before it, so it reads as `And`
|
|
18
|
+
* rather than saying `Given` three times — the same thing Gherkin does.
|
|
19
|
+
*/
|
|
20
|
+
continuesPhase?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The step before this one had the same actor. Combined with `continuesPhase`
|
|
23
|
+
* the subject is dropped, because English drops a repeated subject in a
|
|
24
|
+
* compound predicate: "yasser opens the dashboard / and sees the audit log".
|
|
25
|
+
*
|
|
26
|
+
* It takes both. Dropping the subject across a phase change gives "When opens
|
|
27
|
+
* the dashboard", and a pronoun instead of a name would give "they sees",
|
|
28
|
+
* since step templates are authored in the third person singular.
|
|
29
|
+
*/
|
|
30
|
+
continuesActor?: boolean;
|
|
9
31
|
keywordWidth?: number;
|
|
10
32
|
}) => string;
|
|
@@ -14,9 +14,13 @@ const formatValue = (value) => {
|
|
|
14
14
|
}
|
|
15
15
|
return String(value);
|
|
16
16
|
};
|
|
17
|
-
export const composeStepProse = ({ phase, description, template, input, actor, keywordWidth, }) => {
|
|
18
|
-
const keyword = capitalise(phase);
|
|
19
|
-
|
|
17
|
+
export const composeStepProse = ({ phase, description, template, input, actor, actorRole, continuesPhase, continuesActor, keywordWidth, }) => {
|
|
18
|
+
const keyword = capitalise(continuesPhase ? 'and' : phase);
|
|
19
|
+
// The actor key is the subject verbatim, with no article in front of it.
|
|
20
|
+
// "the ${actor}" only reads as English when the key happens to be a role
|
|
21
|
+
// noun — it turns a persona named after a person into "the nadia", which
|
|
22
|
+
// is the reporter quietly imposing a naming convention on the author.
|
|
23
|
+
const subject = continuesPhase && continuesActor ? '' : composeSubject(actor, actorRole);
|
|
20
24
|
const rendered = template ? renderStepTemplate(template, input) : description;
|
|
21
25
|
const sentence = [subject, rendered].filter(Boolean).join(' ');
|
|
22
26
|
if (keywordWidth === undefined) {
|
|
@@ -24,4 +28,9 @@ export const composeStepProse = ({ phase, description, template, input, actor, k
|
|
|
24
28
|
}
|
|
25
29
|
return `${keyword.padEnd(keywordWidth)} ${sentence}`;
|
|
26
30
|
};
|
|
31
|
+
const composeSubject = (actor, actorRole) => {
|
|
32
|
+
if (!actor)
|
|
33
|
+
return '';
|
|
34
|
+
return actorRole ? `${actor} (the ${actorRole})` : actor;
|
|
35
|
+
};
|
|
27
36
|
const capitalise = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
@@ -35,6 +35,13 @@ export interface ScenarioArtifact {
|
|
|
35
35
|
/** One step of a run, already joined to the prose that declared it. */
|
|
36
36
|
export interface ScenarioStepRow {
|
|
37
37
|
sentence: string;
|
|
38
|
+
/**
|
|
39
|
+
* The same sentence with the actor's role in it — "yasser (the founder)
|
|
40
|
+
* signs in". Set only on the step that first names each actor, and only
|
|
41
|
+
* when a persona declares a job title or a role, so a reader who wants the
|
|
42
|
+
* context picks this and one who wants the bare run picks `sentence`.
|
|
43
|
+
*/
|
|
44
|
+
sentenceWithRole?: string;
|
|
38
45
|
status: string;
|
|
39
46
|
durationMs?: number;
|
|
40
47
|
error?: string;
|
package/package.json
CHANGED
|
@@ -215,6 +215,48 @@ describe('pikkuDevReloader', { concurrency: false }, () => {
|
|
|
215
215
|
assert.deepEqual(await func.func({} as any, {}, {} as any), {
|
|
216
216
|
working: true,
|
|
217
217
|
})
|
|
218
|
+
|
|
219
|
+
// Serving the old code is only safe if the developer is told why; without
|
|
220
|
+
// the reason the sole symptom is a function that ignores the file on disk.
|
|
221
|
+
const failureLog = mockLogger
|
|
222
|
+
.getLogs()
|
|
223
|
+
.find((l) => l.message.includes('Failed to import'))
|
|
224
|
+
assert.ok(failureLog, 'Should log the failed import')
|
|
225
|
+
assert.ok(
|
|
226
|
+
failureLog!.message.includes('keeping old code'),
|
|
227
|
+
'Should say the old code is still being served'
|
|
228
|
+
)
|
|
229
|
+
assert.match(failureLog!.message, /badFunc\.js/)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('should name the top-level await limitation when a reload hits it', async (t) => {
|
|
233
|
+
if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
|
|
234
|
+
|
|
235
|
+
await writeFile(join(tmpDir, 'tlaFunc.ts'), '// initial')
|
|
236
|
+
|
|
237
|
+
reloader = await pikkuDevReloader({
|
|
238
|
+
srcDirectories: [tmpDir],
|
|
239
|
+
logger: mockLogger,
|
|
240
|
+
pikkuDir: tmpDir,
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
await writeFile(
|
|
244
|
+
join(tmpDir, 'tlaFunc.ts'),
|
|
245
|
+
`const config = await Promise.resolve({ ok: true })
|
|
246
|
+
export const tlaFunc = { func: async () => config }
|
|
247
|
+
// trigger ${Date.now()}`
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
await wait(300)
|
|
251
|
+
|
|
252
|
+
const failureLog = mockLogger
|
|
253
|
+
.getLogs()
|
|
254
|
+
.find((l) => l.message.includes('Failed to import'))
|
|
255
|
+
assert.ok(failureLog, 'Should log the failed import')
|
|
256
|
+
// The file is valid TypeScript; pointing at pikku's own `cjs` emit is the
|
|
257
|
+
// difference between a two-minute fix and an afternoon.
|
|
258
|
+
assert.match(failureLog!.message, /top-level `?await`?/i)
|
|
259
|
+
assert.match(failureLog!.message, /pikku limitation/i)
|
|
218
260
|
})
|
|
219
261
|
|
|
220
262
|
test('should ignore non-ts files, test files, and gen files', async (t) => {
|
package/src/dev/hot-reload.ts
CHANGED
|
@@ -9,7 +9,10 @@ import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middlewa
|
|
|
9
9
|
import { httpRouter } from '../wirings/http/routers/http-router.js'
|
|
10
10
|
import type { Logger } from '../services/logger.js'
|
|
11
11
|
import type { CorePikkuFunctionConfig } from '../function/functions.types.js'
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
createModuleRunner,
|
|
14
|
+
isTopLevelAwaitLimitation,
|
|
15
|
+
} from './module-runner.js'
|
|
13
16
|
|
|
14
17
|
export { reloadGeneratedMeta, reconcileAddonRegistry } from './reload-meta.js'
|
|
15
18
|
|
|
@@ -63,6 +66,23 @@ const isWatchedTsFile = (filename: string): boolean => {
|
|
|
63
66
|
)
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
/** Not every reload failure is a mistake in the file: pikku's reloader emits
|
|
70
|
+
* `cjs`, which has no way to express top-level `await`, so a perfectly valid
|
|
71
|
+
* module can fail here forever. Saying so outright saves the reader from
|
|
72
|
+
* hunting a bug that is not in their code. The stack is dropped in that case
|
|
73
|
+
* because it points into esbuild rather than at anything actionable. */
|
|
74
|
+
const reloadFailureReason = (error: Error): string => {
|
|
75
|
+
if (isTopLevelAwaitLimitation(error)) {
|
|
76
|
+
return (
|
|
77
|
+
` ${error.message}\n` +
|
|
78
|
+
' This is a pikku limitation, not a mistake in your file: the hot-reloader compiles to `cjs`, ' +
|
|
79
|
+
'which cannot express top-level `await`. Move the awaited work into a function, or restart the ' +
|
|
80
|
+
'dev server to pick the file up.'
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return ` ${error.stack ?? error.message}`
|
|
84
|
+
}
|
|
85
|
+
|
|
66
86
|
export interface PikkuDevReloaderHandle {
|
|
67
87
|
close: () => void
|
|
68
88
|
/** Re-import every file changed since the last drain (post-codegen, once
|
|
@@ -96,13 +116,19 @@ export async function pikkuDevReloader(
|
|
|
96
116
|
)
|
|
97
117
|
const importPath = compiledFile ?? changedTsFile
|
|
98
118
|
|
|
99
|
-
const
|
|
100
|
-
if (!
|
|
119
|
+
const result = await moduleRunner.run(importPath)
|
|
120
|
+
if (!result.ok) {
|
|
121
|
+
// Keeping the old code leaves the process disagreeing with the file on
|
|
122
|
+
// disk, and the only symptom is stale output from a function that looks
|
|
123
|
+
// correct in the editor — so the reason has to be printed here, where it
|
|
124
|
+
// is still known, rather than left for the developer to reconstruct.
|
|
101
125
|
logger.error(
|
|
102
|
-
`Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)`
|
|
126
|
+
`Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)\n` +
|
|
127
|
+
reloadFailureReason(result.error)
|
|
103
128
|
)
|
|
104
129
|
return
|
|
105
130
|
}
|
|
131
|
+
const mod = result.exports
|
|
106
132
|
|
|
107
133
|
// knowledge: decisions/internals/hot-reload-writes-into-the-function-map-captured-at-startup.md
|
|
108
134
|
for (const [exportName, exportValue] of Object.entries(mod)) {
|