@sanoma-ai/workflows 0.9.1 → 0.10.1
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 +193 -50
- package/SECRET-SUPPORT.md +33 -0
- package/dist/apply-steps.js +5 -2
- package/dist/apply-steps.js.map +1 -1
- package/dist/apply.d.ts +3 -0
- package/dist/apply.js +27 -13
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +1 -0
- package/dist/approvals.js +6 -1
- package/dist/approvals.js.map +1 -1
- package/dist/apps.d.ts +13 -24
- package/dist/apps.js +69 -68
- package/dist/apps.js.map +1 -1
- package/dist/ast.d.ts +9 -7
- package/dist/ast.js +2 -0
- package/dist/ast.js.map +1 -1
- package/dist/authentication.d.ts +54 -0
- package/dist/authentication.js +138 -0
- package/dist/authentication.js.map +1 -0
- package/dist/call-workflow.d.ts +5 -0
- package/dist/call-workflow.js +4 -3
- package/dist/call-workflow.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +36 -7
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +14 -1
- package/dist/cedar.js.map +1 -1
- package/dist/change-steps.js +7 -5
- package/dist/change-steps.js.map +1 -1
- package/dist/change.d.ts +7 -0
- package/dist/change.js +7 -0
- package/dist/change.js.map +1 -1
- package/dist/client.d.ts +62 -10
- package/dist/client.js +272 -47
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +7 -0
- package/dist/collection.js +5 -2
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +16 -4
- package/dist/config.js +89 -21
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +389 -0
- package/dist/connection.js +657 -0
- package/dist/connection.js.map +1 -0
- package/dist/credentials.d.ts +7 -53
- package/dist/credentials.js +84 -247
- package/dist/credentials.js.map +1 -1
- package/dist/db.js +34 -2
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +9 -2
- package/dist/define.js.map +1 -1
- package/dist/definitions.d.ts +20 -0
- package/dist/definitions.js +16 -0
- package/dist/definitions.js.map +1 -0
- package/dist/deployment-key.d.ts +2 -0
- package/dist/deployment-key.js +22 -0
- package/dist/deployment-key.js.map +1 -0
- package/dist/describe.d.ts +15 -175
- package/dist/describe.js +122 -35
- package/dist/describe.js.map +1 -1
- package/dist/description.d.ts +532 -0
- package/dist/description.js +381 -0
- package/dist/description.js.map +1 -0
- package/dist/direct.js +7 -0
- package/dist/direct.js.map +1 -1
- package/dist/drift.d.ts +1 -1
- package/dist/drift.js +1 -1
- package/dist/drift.js.map +1 -1
- package/dist/env.d.ts +2 -0
- package/dist/env.js +2 -2
- package/dist/env.js.map +1 -1
- package/dist/fake.d.ts +13 -1
- package/dist/fake.js +70 -0
- package/dist/fake.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +2 -0
- package/dist/ledger.js +1 -1
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +0 -9
- package/dist/lint.js +2 -17
- package/dist/lint.js.map +1 -1
- package/dist/load.d.ts +85 -0
- package/dist/load.js +391 -0
- package/dist/load.js.map +1 -0
- package/dist/lockfile.d.ts +2 -0
- package/dist/lockfile.js +8 -3
- package/dist/lockfile.js.map +1 -1
- package/dist/op.d.ts +22 -5
- package/dist/op.js +20 -2
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +5 -0
- package/dist/outline.js +46 -5
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +18 -2
- package/dist/paths.js +19 -7
- package/dist/paths.js.map +1 -1
- package/dist/plugin.d.ts +0 -11
- package/dist/plugin.js +3 -18
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +2 -0
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +10 -4
- package/dist/resource.js +11 -4
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +33 -76
- package/dist/resources.js +219 -227
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +4 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +5 -2
- package/dist/sandbox.js +56 -7
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +5 -0
- package/dist/scenario.js +3 -2
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.js +7 -1
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.js +8 -0
- package/dist/schema.js.map +1 -1
- package/dist/secret-store-check.d.ts +11 -0
- package/dist/secret-store-check.js +76 -0
- package/dist/secret-store-check.js.map +1 -0
- package/dist/secret-store.d.ts +106 -0
- package/dist/secret-store.js +228 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/secret.d.ts +2 -0
- package/dist/secret.js +32 -0
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +21 -11
- package/dist/shared.js +41 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +4 -93
- package/dist/sites.js +76 -84
- package/dist/sites.js.map +1 -1
- package/dist/steps.d.ts +3 -0
- package/dist/steps.js.map +1 -1
- package/dist/version.d.ts +2 -1
- package/dist/version.js +2 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-driver.js +15 -6
- package/dist/wakeup-driver.js.map +1 -1
- package/dist/worker.js +12 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +53 -14
- package/dist/writer.js +113 -43
- package/dist/writer.js.map +1 -1
- package/oxlint.json +1 -5
- package/package.json +6 -1
- package/src/apply-steps.ts +5 -2
- package/src/apply.ts +54 -21
- package/src/approvals.ts +7 -1
- package/src/apps.ts +71 -86
- package/src/ast.ts +6 -6
- package/src/authentication.ts +191 -0
- package/src/call-workflow.ts +5 -4
- package/src/call.ts +52 -9
- package/src/cedar.ts +14 -1
- package/src/change-steps.ts +7 -4
- package/src/change.ts +10 -0
- package/src/client.ts +346 -45
- package/src/collection.ts +10 -2
- package/src/config.ts +107 -19
- package/src/connection.ts +912 -0
- package/src/credentials.ts +94 -281
- package/src/db.ts +34 -2
- package/src/define.ts +7 -2
- package/src/definitions.ts +31 -0
- package/src/deployment-key.ts +23 -0
- package/src/describe.ts +150 -227
- package/src/description.ts +426 -0
- package/src/direct.ts +7 -0
- package/src/drift.ts +10 -2
- package/src/env.ts +4 -2
- package/src/fake.ts +71 -0
- package/src/index.ts +53 -1
- package/src/ledger.ts +3 -1
- package/src/lint.ts +2 -17
- package/src/load.ts +466 -0
- package/src/lockfile.ts +10 -3
- package/src/op.ts +41 -7
- package/src/outline.ts +53 -7
- package/src/paths.ts +21 -6
- package/src/plugin.ts +3 -24
- package/src/policy.ts +2 -0
- package/src/resource.ts +24 -6
- package/src/resources.ts +246 -301
- package/src/run.ts +4 -0
- package/src/sandbox.ts +61 -7
- package/src/scenario.ts +24 -6
- package/src/scenarios.ts +7 -1
- package/src/schema.ts +10 -0
- package/src/secret-store-check.ts +93 -0
- package/src/secret-store.ts +314 -0
- package/src/secret.ts +29 -0
- package/src/shared.ts +44 -10
- package/src/sites.ts +85 -179
- package/src/steps.ts +3 -2
- package/src/version.ts +2 -1
- package/src/wakeup-driver.ts +22 -6
- package/src/worker.ts +12 -8
- package/src/writer.ts +145 -56
- package/dist/datafile.d.ts +0 -97
- package/dist/datafile.js +0 -479
- package/dist/datafile.js.map +0 -1
- package/src/datafile.ts +0 -601
package/src/ledger.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConnectionSnapshot } from "./connection.ts";
|
|
1
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
3
|
import { appendFile, mkdir, readFile, truncate } from "node:fs/promises";
|
|
3
4
|
import { join, resolve } from "node:path";
|
|
@@ -42,6 +43,7 @@ export type LedgerRecord = {
|
|
|
42
43
|
}
|
|
43
44
|
| {
|
|
44
45
|
type: "op.called";
|
|
46
|
+
connection?: ConnectionSnapshot;
|
|
45
47
|
op: string;
|
|
46
48
|
/**
|
|
47
49
|
* The outline node of the call that made it (`OutlineNode.path`): where the step is in the
|
|
@@ -210,7 +212,7 @@ function serialize(record: LedgerRecord): string {
|
|
|
210
212
|
/** The line `jsonlLedger` writes for a record: its JSON and a newline. */
|
|
211
213
|
const lineOf = (record: LedgerRecord) => `${serialize(record)}\n`;
|
|
212
214
|
|
|
213
|
-
const COMPARED = ["type", "input", "decision", "error", "output"] as const;
|
|
215
|
+
const COMPARED = ["type", "input", "decision", "error", "output", "connection"] as const;
|
|
214
216
|
|
|
215
217
|
/**
|
|
216
218
|
* The first record with an id stands. Says so when a later one with that id differs where it
|
package/src/lint.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
3
3
|
import { byPosition, childrenOf, type LintProblem, type Node, parsed, type SourceFile } from "./ast.ts";
|
|
4
|
-
import { COLLECTIONS_FILE, readDataFile } from "./datafile.ts";
|
|
5
4
|
import { constsOf, outlineBody, workflowLiterals } from "./outline.ts";
|
|
6
|
-
import { APPS_DIR, inside, nearestDir, RELATIVE } from "./paths.ts";
|
|
5
|
+
import { APPS_DIR, COLLECTIONS_FILE, inside, nearestDir, RELATIVE } from "./paths.ts";
|
|
7
6
|
// From src/ and from dist/ alike, the package's own oxlint.json: the one list of allowed names.
|
|
8
7
|
import oxlint from "../oxlint.json" with { type: "json" };
|
|
9
8
|
|
|
@@ -29,7 +28,7 @@ const ALLOWED_PACKAGE = new RegExp(`^(${[...AUTHOR_PACKAGES, "@sanoma-ai/connect
|
|
|
29
28
|
const REFUSED_PACKAGES: [RegExp, string][] = [
|
|
30
29
|
[/^@sanoma-ai\/testing(\/|$)/, `fakes and test helpers belong in tests; ${THROUGH_CTX}`],
|
|
31
30
|
[/^@sanoma-ai\/app(\/|$)/, "the app starts runs and decides approvals, so a workflow could approve itself"],
|
|
32
|
-
[/^@sanoma-ai\/connector-[^/]+\/(fake|driver|remote)(\/|$)/, `${THROUGH_CTX}; import the connector itself`],
|
|
31
|
+
[/^@sanoma-ai\/connector-[^/]+\/(fake|driver|remote|vault)(\/|$)/, `${THROUGH_CTX}; import the connector itself`],
|
|
33
32
|
[/^sanoma\//, "`sanoma` has no entry points for a workflow: import its names from `sanoma` itself"],
|
|
34
33
|
];
|
|
35
34
|
|
|
@@ -171,20 +170,6 @@ export function lintWorkflow(input: string | SourceFile, path?: string): LintPro
|
|
|
171
170
|
return byPosition(problems);
|
|
172
171
|
}
|
|
173
172
|
|
|
174
|
-
/**
|
|
175
|
-
* Checks a data file under `resources/`: only named imports (a connector's constructors from its
|
|
176
|
-
* `…/resources` entry, and resources from other data files inside the same `resources/`
|
|
177
|
-
* directory), `export const <name> = <vendor>.<type>({ … })` with literal fields and names of
|
|
178
|
-
* declared resources, and `export default [ … ]`. Each message says what to write instead.
|
|
179
|
-
* It is the subset `readResources` reads (`readDataFile`), without the config: whether the
|
|
180
|
-
* connector, the type and the values are known is the reader's to say.
|
|
181
|
-
*/
|
|
182
|
-
export function lintResources(source: string, filename: string): LintProblem[] {
|
|
183
|
-
const { program, problems, at } = parsed(source, filename);
|
|
184
|
-
for (const p of readDataFile(program, filename).problems) problems.push({ ...at(p.start), message: p.message });
|
|
185
|
-
return byPosition(problems);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
173
|
/** The `paths` of oxlint.json's `no-restricted-imports` rule. */
|
|
189
174
|
function restrictedImports(config: typeof oxlint): RestrictedPath[] {
|
|
190
175
|
// JSON types the rule's [severity, options] as an array of either.
|
package/src/load.ts
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { createRequire, registerHooks } from "node:module";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { collectionIn } from "./collection.ts";
|
|
6
|
+
import { callerFile } from "./define.ts";
|
|
7
|
+
import { defineApp, defineSite } from "./definitions.ts";
|
|
8
|
+
import { type Connector, VENDOR } from "./op.ts";
|
|
9
|
+
import { APPS_DIR, appOfPath, DATA_ENTRIES, inside, relPath, SECRET_ENTRIES, SITE_ENTRIES } from "./paths.ts";
|
|
10
|
+
import { type Declared, isDeclared, isObject, joinPath, type Resource, resourceTypesOf } from "./resource.ts";
|
|
11
|
+
import { secret } from "./secret.ts";
|
|
12
|
+
import { errorMessage } from "./shared.ts";
|
|
13
|
+
|
|
14
|
+
// A company's files, loaded: synchronously (`require` of an ES module, Node's type stripping and
|
|
15
|
+
// `module.registerHooks`), since what reads them is (`describeConfig`, `readPeople`). Each load
|
|
16
|
+
// is a *generation*: its files are modules of their own (`file:…?sanoma-load=<id>`), so an edit
|
|
17
|
+
// is read once a new generation starts, and what they import from the runtime (constructors,
|
|
18
|
+
// `collection`, `defineSite`, `defineApp`) is served from the loader's own objects, which record
|
|
19
|
+
// where each value was made. A superseded generation's modules stay in Node's cache: each edit
|
|
20
|
+
// costs one copy of the files it read.
|
|
21
|
+
|
|
22
|
+
/** The scheme of the loader's own modules: an entry, `sanoma-load:<id>/<file URL>`, and a runtime module, `sanoma-load:<id>/<specifier>`. */
|
|
23
|
+
const SCHEME = "sanoma-load:";
|
|
24
|
+
/** The query that tags a company file with its generation: `file:///…/github.ts?sanoma-load=<id>`. */
|
|
25
|
+
const QUERY = "sanoma-load";
|
|
26
|
+
|
|
27
|
+
/** Where a value a company file made was made, by the constructor that made it. */
|
|
28
|
+
export interface Made {
|
|
29
|
+
/** The file that called the constructor, relative to the root, with `/`: `resources/github.ts`. */
|
|
30
|
+
file: string;
|
|
31
|
+
/** Its place among everything its generation made, from 1: in each file, the order written. */
|
|
32
|
+
seq: number;
|
|
33
|
+
/**
|
|
34
|
+
* Each declared resource its fields name, by the field's dotted path (a list item's with its
|
|
35
|
+
* index, `teams.0`), as the constructor's `refs` names them.
|
|
36
|
+
*/
|
|
37
|
+
refers: Record<string, Declared>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What loading a file gave: its exports, the values it made first in the order made, then the
|
|
42
|
+
* rest by name, less any value another file loaded with it made and exports (a re-export); or
|
|
43
|
+
* the error it threw, placed in the file (from 1, UTF-16 columns) when its stack says where; or,
|
|
44
|
+
* when the error is another file's (a file it imports threw), that file, where it is reported.
|
|
45
|
+
*/
|
|
46
|
+
export type Loaded =
|
|
47
|
+
| { exports: [name: string, value: unknown][] }
|
|
48
|
+
| { error: unknown; line?: number; column?: number }
|
|
49
|
+
| { failedIn: string };
|
|
50
|
+
|
|
51
|
+
/** A load of a company's files: its modules, the runtime's modules it serves them, and what each file's stamp was. */
|
|
52
|
+
interface Generation {
|
|
53
|
+
id: string;
|
|
54
|
+
/** Real (`realpathSync`): the ES module resolver gives real paths. */
|
|
55
|
+
root: string;
|
|
56
|
+
/** What it serves the runtime's modules for: its constructors' identities and packages. */
|
|
57
|
+
typesKey: string;
|
|
58
|
+
/** The runtime's modules it serves, by specifier: each module's exports. */
|
|
59
|
+
virtual: Map<string, Record<string, unknown>>;
|
|
60
|
+
/** Each company file it resolved or was asked for, and each one's directory, by its size and modification time. */
|
|
61
|
+
stamps: Map<string, string>;
|
|
62
|
+
/** Every value its files have made, in the order made. */
|
|
63
|
+
made: object[];
|
|
64
|
+
/** Each company file its files imported, directly or not, by its path relative to the root. */
|
|
65
|
+
imported: Set<string>;
|
|
66
|
+
/** What loading each file gave, by its path relative to the root: an entry's, or a file an error is placed in. */
|
|
67
|
+
loaded: Map<string, Loaded>;
|
|
68
|
+
/** Each error reported, to the file it is reported in. */
|
|
69
|
+
reported: Map<unknown, string>;
|
|
70
|
+
/** What each call returned, by its `only` (the generation itself for none) and then the paths: the same map while nothing changed. */
|
|
71
|
+
answers: WeakMap<object, Map<string, Map<string, Loaded>>>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The loader's state, one per process: two copies of this package share it (and one set of hooks). */
|
|
75
|
+
interface Registry {
|
|
76
|
+
generations: Map<string, Generation>;
|
|
77
|
+
seq: number;
|
|
78
|
+
/** An id for each constructor object, for `typesKey`. */
|
|
79
|
+
ids: WeakMap<object, number>;
|
|
80
|
+
/** Where each value a company file made was made. */
|
|
81
|
+
made: WeakMap<object, Made>;
|
|
82
|
+
hooked?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const registry = ((globalThis as Record<symbol, unknown>)[Symbol.for("sanoma.load")] ??= {
|
|
86
|
+
generations: new Map(),
|
|
87
|
+
seq: 0,
|
|
88
|
+
ids: new WeakMap(),
|
|
89
|
+
made: new WeakMap(),
|
|
90
|
+
}) as Registry;
|
|
91
|
+
|
|
92
|
+
const require = createRequire(import.meta.url);
|
|
93
|
+
|
|
94
|
+
/** Where `value` was made, when a company file made it with a constructor the loader served. */
|
|
95
|
+
export const madeOf = (value: unknown): Made | undefined =>
|
|
96
|
+
typeof value === "object" && value !== null ? registry.made.get(value) : undefined;
|
|
97
|
+
|
|
98
|
+
/** Where `defineApp` comes from, served only to an app's `app.ts`. */
|
|
99
|
+
const APP_ENTRIES: ReadonlySet<string> = new Set(["sanoma", "@sanoma-ai/workflows"]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Loads `paths` (absolute, under `root`) as the generation of `root` and the constructors of
|
|
103
|
+
* `connectors` (`types`, by default theirs) has them, and says what each gave, by its path
|
|
104
|
+
* relative to the root with `/`, sorted. The same generation, and the same answer for the same
|
|
105
|
+
* paths, while no file it read, nor any file's directory, changed (by size and modification time:
|
|
106
|
+
* one `stat` each); else a new one, so a caller may key what it makes of the answer by it (and
|
|
107
|
+
* never changes it). `only` loads only the files whose text it passes; the answer is kept by its
|
|
108
|
+
* identity, so pass one function, not a new one per call.
|
|
109
|
+
*
|
|
110
|
+
* Served to the files, by specifier: `sanoma/resources` and `@sanoma-ai/workflows/resources`
|
|
111
|
+
* (`sanoma`, the constructors of the vendor `sanoma`, and `secret`); each connector's
|
|
112
|
+
* `<info.package>/resources` (its vendor's constructors, under its id); `sanoma/data` and
|
|
113
|
+
* `@sanoma-ai/workflows/data` (`collection`); `sanoma/site` and `@sanoma-ai/app/site`
|
|
114
|
+
* (`defineSite`); and to an app's `apps/<app>/app.ts` alone, `sanoma` and `@sanoma-ai/workflows`
|
|
115
|
+
* (`defineApp`). Anything else is resolved as Node does.
|
|
116
|
+
*/
|
|
117
|
+
export function loadModules(
|
|
118
|
+
root: string,
|
|
119
|
+
paths: readonly string[],
|
|
120
|
+
connectors: readonly Connector<any, any>[],
|
|
121
|
+
{
|
|
122
|
+
only,
|
|
123
|
+
types = resourceTypesOf(connectors),
|
|
124
|
+
}: { only?: (text: string) => boolean; types?: ReadonlyMap<string, Resource> } = {},
|
|
125
|
+
): Map<string, Loaded> {
|
|
126
|
+
// Nothing to load, and a root that may not be there yet (a company not made): nothing to resolve.
|
|
127
|
+
if (!paths.length) return new Map();
|
|
128
|
+
hook();
|
|
129
|
+
const real = realpathSync(root);
|
|
130
|
+
const gen = generationOf(real, connectors, types);
|
|
131
|
+
const files = paths.map((path) => join(real, relative(root, path)));
|
|
132
|
+
const byPaths = gen.answers.get(only ?? gen) ?? new Map<string, Map<string, Loaded>>();
|
|
133
|
+
gen.answers.set(only ?? gen, byPaths);
|
|
134
|
+
const key = files.join("\0");
|
|
135
|
+
const answered = byPaths.get(key);
|
|
136
|
+
if (answered) return answered;
|
|
137
|
+
|
|
138
|
+
const answer = new Map<string, Loaded>();
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
touch(gen, file);
|
|
141
|
+
if (only && !only(readFileSync(file, "utf8"))) continue;
|
|
142
|
+
const rel = relPath(gen.root, file);
|
|
143
|
+
const loaded = loadOne(gen, file, rel);
|
|
144
|
+
answer.set(rel, loaded);
|
|
145
|
+
if ("failedIn" in loaded) answer.set(loaded.failedIn, gen.loaded.get(loaded.failedIn)!);
|
|
146
|
+
}
|
|
147
|
+
const result = new Map([...answer].toSorted(([a], [b]) => byOrder(a, b)));
|
|
148
|
+
// A value made in one file and exported by another is listed at the file that made it.
|
|
149
|
+
const owner = new Map<unknown, string>();
|
|
150
|
+
for (const [rel, l] of result) {
|
|
151
|
+
if (!("exports" in l)) continue;
|
|
152
|
+
for (const [, value] of l.exports) if (madeOf(value)?.file === rel) owner.set(value, rel);
|
|
153
|
+
}
|
|
154
|
+
for (const [rel, l] of result) {
|
|
155
|
+
if (!("exports" in l)) continue;
|
|
156
|
+
const exports = l.exports
|
|
157
|
+
.filter(([, value]) => (owner.get(value) ?? rel) === rel)
|
|
158
|
+
.toSorted(([, a], [, b]) => byOrder(madeOf(a)?.seq ?? Infinity, madeOf(b)?.seq ?? Infinity));
|
|
159
|
+
result.set(rel, { exports });
|
|
160
|
+
}
|
|
161
|
+
byPaths.set(key, result);
|
|
162
|
+
answerGenerations.set(result, gen);
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What `answer` (a `loadModules` answer) gave for `file`: its exports, or the error it threw (or a
|
|
168
|
+
* helper it imports threw) as a problem on one line, at the file that threw it; undefined when the
|
|
169
|
+
* answer does not have the file.
|
|
170
|
+
*/
|
|
171
|
+
export function exportsOf(
|
|
172
|
+
answer: Map<string, Loaded>,
|
|
173
|
+
file: string,
|
|
174
|
+
):
|
|
175
|
+
| { exports: [name: string, value: unknown][] }
|
|
176
|
+
| { problem: { file: string; line?: number; column?: number; message: string } }
|
|
177
|
+
| undefined {
|
|
178
|
+
const loaded = answer.get(file);
|
|
179
|
+
if (loaded === undefined) return undefined;
|
|
180
|
+
if ("failedIn" in loaded) return exportsOf(answer, loaded.failedIn);
|
|
181
|
+
if ("exports" in loaded) return loaded;
|
|
182
|
+
const { error, line, column } = loaded;
|
|
183
|
+
const message = errorMessage(error).replaceAll(/\s*\n\s*/g, " ");
|
|
184
|
+
return { problem: { file, ...(line !== undefined && { line, column: column ?? 1 }), message } };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The generation each answer is of. */
|
|
188
|
+
const answerGenerations = new WeakMap<Map<string, Loaded>, Generation>();
|
|
189
|
+
|
|
190
|
+
/** Every value the files of `answer` (a `loadModules` answer) made, exported or not, in the order made. */
|
|
191
|
+
export const madeWith = (answer: Map<string, Loaded>): object[] =>
|
|
192
|
+
(answerGenerations.get(answer)?.made ?? []).filter((value) => answer.has(madeOf(value)!.file));
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The company files `answer` (a `loadModules` answer) was read from, by path relative to the root:
|
|
196
|
+
* its files and every company file they import, directly or not (or that another answer of its
|
|
197
|
+
* generation imported: never fewer).
|
|
198
|
+
*/
|
|
199
|
+
export const filesRead = (answer: Map<string, Loaded>): ReadonlySet<string> =>
|
|
200
|
+
new Set([...answer.keys(), ...(answerGenerations.get(answer)?.imported ?? [])]);
|
|
201
|
+
|
|
202
|
+
/** One file of a generation, required once; an error placed at its own file, and reported there once. */
|
|
203
|
+
function loadOne(gen: Generation, file: string, rel: string): Loaded {
|
|
204
|
+
const done = gen.loaded.get(rel);
|
|
205
|
+
if (done) return done;
|
|
206
|
+
let loaded: Loaded;
|
|
207
|
+
try {
|
|
208
|
+
const { ns } = require(`${SCHEME}${gen.id}/${pathToFileURL(file).href}`) as { ns: Record<string, unknown> };
|
|
209
|
+
loaded = { exports: Object.entries(ns) };
|
|
210
|
+
} catch (error) {
|
|
211
|
+
// A file that imports one that threw throws the same object: reported once, where it was thrown.
|
|
212
|
+
const at = typeof error === "object" ? gen.reported.get(error) : undefined;
|
|
213
|
+
if (at !== undefined) loaded = { failedIn: at };
|
|
214
|
+
else {
|
|
215
|
+
const placed = placeOf(gen, error);
|
|
216
|
+
// Never over a file that loaded (a stack cut short at `Error.stackTraceLimit`): then it is this file's.
|
|
217
|
+
const there = placed && gen.loaded.get(placed.file);
|
|
218
|
+
const { file: own = rel, line, column } = there && "exports" in there ? {} : (placed ?? {});
|
|
219
|
+
if (typeof error === "object") gen.reported.set(error, own);
|
|
220
|
+
const report = { error, ...(line !== undefined && { line }), ...(column !== undefined && { column }) };
|
|
221
|
+
gen.loaded.set(own, report);
|
|
222
|
+
loaded = own === rel ? report : { failedIn: own };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
gen.loaded.set(rel, loaded);
|
|
226
|
+
return loaded;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A frame of a generation's company file in a stack: its URL, without the query, then the tag, line and column. */
|
|
230
|
+
const TAGGED_FRAME = new RegExp(`(file://[^\\s()?]*)\\?${QUERY}=([\\w-]+):(\\d+):(\\d+)`, "g");
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Where an error belongs: the last (outermost) frame of its stack in one of the generation's
|
|
234
|
+
* files, the top level of the file that was running, not a helper it called in another file; for
|
|
235
|
+
* a syntax error, the file and line its stack starts with (the caret's column); none for an error
|
|
236
|
+
* thrown before any file ran (top-level `await`, an import Node cannot find).
|
|
237
|
+
*/
|
|
238
|
+
function placeOf(gen: Generation, error: unknown): { file: string; line?: number; column?: number } | undefined {
|
|
239
|
+
const stack = isObject(error) && typeof error.stack === "string" ? error.stack : undefined;
|
|
240
|
+
if (stack === undefined) return undefined;
|
|
241
|
+
const frame = [...stack.matchAll(TAGGED_FRAME)].findLast(([, , tag]) => tag === gen.id);
|
|
242
|
+
if (frame) {
|
|
243
|
+
const [, url, , line, column] = frame;
|
|
244
|
+
return { file: relPath(gen.root, fileURLToPath(url!)), line: Number(line), column: Number(column) };
|
|
245
|
+
}
|
|
246
|
+
// A syntax error's stack starts `<path or URL>:<line>`, then the source and a caret under the column.
|
|
247
|
+
const [first = "", ...lines] = stack.split("\n");
|
|
248
|
+
const syntax = /^(.+):(\d+)$/.exec(first);
|
|
249
|
+
const path = syntax && (syntax[1]!.startsWith("file:") ? fileURLToPath(syntax[1]!) : syntax[1]!);
|
|
250
|
+
if (syntax && path && isAbsolute(path) && inside(gen.root, path)) {
|
|
251
|
+
const caret = lines.slice(0, lines.indexOf("")).findLast((line) => /^\s*\^+\s*$/.test(line));
|
|
252
|
+
return { file: relPath(gen.root, path), line: Number(syntax[2]), ...(caret && { column: caret.indexOf("^") + 1 }) };
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Each company file under `root` a load has read, and each one's folder, with its stamp
|
|
259
|
+
* (`stampOf`) as it was before the file was read: the latest generation's for a file several
|
|
260
|
+
* read. Taken after a load, it still says what the load read, so a file changed while it read is
|
|
261
|
+
* found changed after.
|
|
262
|
+
*/
|
|
263
|
+
export function stampsOf(root: string): Map<string, string> {
|
|
264
|
+
const stamps = new Map<string, string>();
|
|
265
|
+
if (!existsSync(root)) return stamps;
|
|
266
|
+
const real = realpathSync(root);
|
|
267
|
+
// In the order made: a later generation's stamp is the newer.
|
|
268
|
+
for (const gen of registry.generations.values()) {
|
|
269
|
+
if (gen.root === real) for (const [path, stamp] of gen.stamps) stamps.set(path, stamp);
|
|
270
|
+
}
|
|
271
|
+
return stamps;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const byOrder = <T extends string | number>(a: T, b: T) => (a < b ? -1 : a > b ? 1 : 0);
|
|
275
|
+
|
|
276
|
+
/** A file's size and modification time, or that it is gone: what a generation keeps of each file it read. */
|
|
277
|
+
export function stampOf(path: string): string {
|
|
278
|
+
const stat = statSync(path, { bigint: true, throwIfNoEntry: false });
|
|
279
|
+
return stat ? `${stat.size} ${stat.mtimeNs}` : "gone";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** True while each path's stamp (`stampOf`) is still the one kept. */
|
|
283
|
+
export function fresh(stamps: ReadonlyMap<string, string>): boolean {
|
|
284
|
+
for (const [path, stamp] of stamps) if (stampOf(path) !== stamp) return false;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Records a file of the generation, and its directory, as they are now: before it is read, so an edit after is seen. */
|
|
289
|
+
function touch(gen: Generation, file: string) {
|
|
290
|
+
for (const path of [file, dirname(file)]) if (!gen.stamps.has(path)) gen.stamps.set(path, stampOf(path));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The generation of `root` and these constructors: the current one while nothing it stamped
|
|
295
|
+
* changed, else a new one. A generation of `root` whose files changed goes, whatever its
|
|
296
|
+
* constructors, so `stampsOf` never keeps the stamps of one no load will make again.
|
|
297
|
+
*/
|
|
298
|
+
function generationOf(
|
|
299
|
+
root: string,
|
|
300
|
+
connectors: readonly Connector<any, any>[],
|
|
301
|
+
types: ReadonlyMap<string, Resource>,
|
|
302
|
+
): Generation {
|
|
303
|
+
const packages = connectors.flatMap((c) => {
|
|
304
|
+
const { id, info } = c[VENDOR];
|
|
305
|
+
return info?.package ? [`${info.package}/resources`, id] : [];
|
|
306
|
+
});
|
|
307
|
+
const typesKey = [...packages, ...[...types.values()].map(idOf)].join(" ");
|
|
308
|
+
let current: Generation | undefined;
|
|
309
|
+
for (const gen of registry.generations.values()) {
|
|
310
|
+
if (gen.root !== root) continue;
|
|
311
|
+
if (!fresh(gen.stamps)) registry.generations.delete(gen.id);
|
|
312
|
+
else if (gen.typesKey === typesKey) current = gen;
|
|
313
|
+
}
|
|
314
|
+
if (current !== undefined) return current;
|
|
315
|
+
const gen: Generation = {
|
|
316
|
+
id: String(++registry.seq),
|
|
317
|
+
root,
|
|
318
|
+
typesKey,
|
|
319
|
+
virtual: new Map(),
|
|
320
|
+
stamps: new Map(),
|
|
321
|
+
made: [],
|
|
322
|
+
imported: new Set(),
|
|
323
|
+
loaded: new Map(),
|
|
324
|
+
reported: new Map(),
|
|
325
|
+
answers: new WeakMap(),
|
|
326
|
+
};
|
|
327
|
+
gen.virtual = runtimeModules(gen, connectors, types);
|
|
328
|
+
registry.generations.set(gen.id, gen);
|
|
329
|
+
return gen;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function idOf(object: object): number {
|
|
333
|
+
let id = registry.ids.get(object);
|
|
334
|
+
if (id === undefined) registry.ids.set(object, (id = ++registry.seq));
|
|
335
|
+
return id;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The runtime's modules a generation serves its files, by specifier: each constructor wrapped to record where it was called. */
|
|
339
|
+
function runtimeModules(
|
|
340
|
+
gen: Generation,
|
|
341
|
+
connectors: readonly Connector<any, any>[],
|
|
342
|
+
types: ReadonlyMap<string, Resource>,
|
|
343
|
+
): Map<string, Record<string, unknown>> {
|
|
344
|
+
const byVendor = new Map<string, Record<string, unknown>>();
|
|
345
|
+
for (const resource of types.values()) {
|
|
346
|
+
const constructors = byVendor.get(resource.vendor) ?? {};
|
|
347
|
+
// Frame 2 of `callerFile` is the wrapper's caller: the company file.
|
|
348
|
+
constructors[resource.type] = (fields: unknown, options?: unknown) =>
|
|
349
|
+
record(gen, callerFile(), (resource as (f: unknown, o: unknown) => Declared)(fields, options), refersIn(fields));
|
|
350
|
+
byVendor.set(resource.vendor, constructors);
|
|
351
|
+
}
|
|
352
|
+
// A type the vendor does not declare is refused where it is called for, as `<vendor>.<type>`;
|
|
353
|
+
// an object's own names (`toString`, `toJSON`) are not types, so printing the vendor still works.
|
|
354
|
+
const constructorsOf = (vendor: string) =>
|
|
355
|
+
new Proxy(byVendor.get(vendor) ?? {}, {
|
|
356
|
+
get(target, key) {
|
|
357
|
+
if (typeof key !== "string" || key in target || key === "toJSON") return Reflect.get(target, key);
|
|
358
|
+
const known = Object.keys(target);
|
|
359
|
+
throw new TypeError(`${vendor} has no resource type ${key}: its types are ${known.join(", ") || "none"}`);
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
const modules = new Map<string, Record<string, unknown>>();
|
|
363
|
+
for (const connector of connectors) {
|
|
364
|
+
const { id, info } = connector[VENDOR];
|
|
365
|
+
if (info?.package) modules.set(`${info.package}/resources`, { [id]: constructorsOf(id) });
|
|
366
|
+
}
|
|
367
|
+
for (const entry of SECRET_ENTRIES) modules.set(entry, { sanoma: constructorsOf("sanoma"), secret });
|
|
368
|
+
const collection = (declaration: Parameters<typeof collectionIn>[1]) => {
|
|
369
|
+
const file = callerFile();
|
|
370
|
+
return record(
|
|
371
|
+
gen,
|
|
372
|
+
file,
|
|
373
|
+
collectionIn(file === undefined ? undefined : appOfPath(relPath(gen.root, file)), declaration),
|
|
374
|
+
);
|
|
375
|
+
};
|
|
376
|
+
for (const entry of DATA_ENTRIES) modules.set(entry, { collection });
|
|
377
|
+
const site = (definition: Parameters<typeof defineSite>[0]) => record(gen, callerFile(), defineSite(definition));
|
|
378
|
+
for (const entry of SITE_ENTRIES) modules.set(entry, { defineSite: site });
|
|
379
|
+
const app = (definition: Parameters<typeof defineApp>[0]) => record(gen, callerFile(), defineApp(definition));
|
|
380
|
+
for (const entry of APP_ENTRIES) modules.set(entry, { defineApp: app });
|
|
381
|
+
return modules;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Records where `value` was made, and returns it. */
|
|
385
|
+
function record<T extends object>(gen: Generation, file: string | undefined, value: T, refers = {}): T {
|
|
386
|
+
gen.made.push(value);
|
|
387
|
+
registry.made.set(value, { file: file === undefined ? "" : relPath(gen.root, file), seq: gen.made.length, refers });
|
|
388
|
+
return value;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Each declared resource in a constructor's fields, by its dotted path, as the constructor walks them. */
|
|
392
|
+
function refersIn(value: unknown, at = "", into: Record<string, Declared> = {}): Record<string, Declared> {
|
|
393
|
+
if (isDeclared(value)) into[at] = value;
|
|
394
|
+
else if (Array.isArray(value)) value.forEach((item, i) => refersIn(item, joinPath(at, String(i)), into));
|
|
395
|
+
else if (isObject(value))
|
|
396
|
+
for (const [field, item] of Object.entries(value)) refersIn(item, joinPath(at, field), into);
|
|
397
|
+
return into;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** True for an app's `apps/<app>/app.ts`, by URL. */
|
|
401
|
+
const isAppFile = (gen: Generation, url: string) => {
|
|
402
|
+
const [top, app, file, ...rest] = relPath(gen.root, fileURLToPath(url)).split("/");
|
|
403
|
+
return top === APPS_DIR && app !== undefined && file === "app.ts" && !rest.length;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/** The generation a module URL belongs to: the query on a company file's, the first segment of the loader's own. */
|
|
407
|
+
function tagOf(url: string | undefined): string | null {
|
|
408
|
+
if (url?.startsWith("file:")) return new URL(url).searchParams.get(QUERY);
|
|
409
|
+
if (url?.startsWith(SCHEME)) return url.slice(SCHEME.length, url.indexOf("/"));
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Registers the loader's hooks, once per process (the registry's flag, for two copies of the
|
|
415
|
+
* package). A file of a generation resolves the runtime's modules to the generation's, and each
|
|
416
|
+
* company file (under the root, outside `node_modules`) to itself tagged with the generation,
|
|
417
|
+
* recorded and stamped; an entry is a module exporting the tagged file's namespace as `ns`, since
|
|
418
|
+
* `require` of a `file:` URL drops its query (the CommonJS loader keys by file name).
|
|
419
|
+
*/
|
|
420
|
+
function hook() {
|
|
421
|
+
if (registry.hooked) return;
|
|
422
|
+
registry.hooked = true;
|
|
423
|
+
registerHooks({
|
|
424
|
+
resolve(specifier, context, next) {
|
|
425
|
+
if (specifier.startsWith(SCHEME)) return { url: specifier, shortCircuit: true };
|
|
426
|
+
const tag = tagOf(context.parentURL);
|
|
427
|
+
const gen = tag === null ? undefined : registry.generations.get(tag);
|
|
428
|
+
if (gen === undefined) return next(specifier, context);
|
|
429
|
+
if (
|
|
430
|
+
gen.virtual.has(specifier) &&
|
|
431
|
+
(!APP_ENTRIES.has(specifier) || (context.parentURL!.startsWith("file:") && isAppFile(gen, context.parentURL!)))
|
|
432
|
+
) {
|
|
433
|
+
return { url: `${SCHEME}${gen.id}/${specifier}`, shortCircuit: true };
|
|
434
|
+
}
|
|
435
|
+
const resolved = next(specifier, context);
|
|
436
|
+
if (!resolved.url.startsWith("file:")) return resolved;
|
|
437
|
+
const url = new URL(resolved.url);
|
|
438
|
+
const file = fileURLToPath(url);
|
|
439
|
+
if (!inside(gen.root, file) || file.split(sep).includes("node_modules")) return resolved;
|
|
440
|
+
touch(gen, file);
|
|
441
|
+
gen.imported.add(relPath(gen.root, file));
|
|
442
|
+
url.searchParams.set(QUERY, gen.id);
|
|
443
|
+
return { ...resolved, url: url.href };
|
|
444
|
+
},
|
|
445
|
+
load(url, context, next) {
|
|
446
|
+
if (!url.startsWith(SCHEME)) return next(url, context);
|
|
447
|
+
const tag = tagOf(url)!;
|
|
448
|
+
const rest = url.slice(url.indexOf("/") + 1);
|
|
449
|
+
if (rest.startsWith("file:")) {
|
|
450
|
+
const target = new URL(rest);
|
|
451
|
+
target.searchParams.set(QUERY, tag);
|
|
452
|
+
return { format: "module", source: `export * as ns from ${JSON.stringify(target.href)};`, shortCircuit: true };
|
|
453
|
+
}
|
|
454
|
+
// Read from the registry by the module itself, so a company file gets the generation's own objects.
|
|
455
|
+
const from = `globalThis[Symbol.for("sanoma.load")].generations.get(${JSON.stringify(tag)}).virtual.get(${JSON.stringify(rest)})`;
|
|
456
|
+
const names = Object.keys(registry.generations.get(tag)?.virtual.get(rest) ?? {});
|
|
457
|
+
const source = [
|
|
458
|
+
`const m = ${from};`,
|
|
459
|
+
...names.map(
|
|
460
|
+
(name, i) => `const e${i} = m[${JSON.stringify(name)}];\nexport { e${i} as ${JSON.stringify(name)} };`,
|
|
461
|
+
),
|
|
462
|
+
].join("\n");
|
|
463
|
+
return { format: "module", source, shortCircuit: true };
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
}
|
package/src/lockfile.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { connectionRefSchema, type ConnectionRef } from "./connection.ts";
|
|
1
2
|
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { z } from "zod";
|
|
@@ -13,6 +14,7 @@ export const LOCKFILE = "resources/sanoma.lock.json";
|
|
|
13
14
|
export interface Binding {
|
|
14
15
|
type: string;
|
|
15
16
|
id: string;
|
|
17
|
+
connection?: ConnectionRef;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** The lockfile's content: by declaration key, `<file>#<export>` (the file relative to the config's root, with `/`). */
|
|
@@ -20,7 +22,11 @@ export type Bindings = Record<string, Binding>;
|
|
|
20
22
|
|
|
21
23
|
const lockfile = z.record(
|
|
22
24
|
z.string().regex(/^resources\/[^#]+#[^#]+$/),
|
|
23
|
-
z.strictObject({
|
|
25
|
+
z.strictObject({
|
|
26
|
+
type: z.string().regex(/^[^.\s]+\.[^.\s]+$/),
|
|
27
|
+
id: z.string().min(1),
|
|
28
|
+
connection: connectionRefSchema.optional(),
|
|
29
|
+
}),
|
|
24
30
|
);
|
|
25
31
|
|
|
26
32
|
/**
|
|
@@ -51,7 +57,7 @@ export function readLockfile(root: string): { bindings: Bindings; problems: Reso
|
|
|
51
57
|
export function serializeLockfile(bindings: Bindings): string {
|
|
52
58
|
const sorted = Object.entries(bindings)
|
|
53
59
|
.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
54
|
-
.map(([key, { type, id }]) => [key, { type, id }]);
|
|
60
|
+
.map(([key, { type, id, connection }]) => [key, { type, id, ...(connection && { connection }) }]);
|
|
55
61
|
return `${JSON.stringify(Object.fromEntries(sorted), null, 2)}\n`;
|
|
56
62
|
}
|
|
57
63
|
|
|
@@ -89,7 +95,8 @@ export function diffLockfiles(before: Bindings, after: Bindings): Rebind[] {
|
|
|
89
95
|
const now = after[key];
|
|
90
96
|
if (!now) return was ? [{ key, type: was.type, kind: "unbind", from: was.id }] : [];
|
|
91
97
|
if (!was) return [{ key, type: now.type, kind: "bind", to: now.id }];
|
|
92
|
-
if (was.id === now.id && was.type === now.type)
|
|
98
|
+
if (was.id === now.id && was.type === now.type && JSON.stringify(was.connection) === JSON.stringify(now.connection))
|
|
99
|
+
return [];
|
|
93
100
|
return [{ key, type: now.type, kind: "rebind", from: was.id, to: now.id }];
|
|
94
101
|
});
|
|
95
102
|
}
|