@sanoma-ai/workflows 0.9.0 → 0.10.0
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 +169 -52
- 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 +35 -21
- 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 +7 -24
- package/dist/apps.js +51 -67
- 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 +24 -0
- package/dist/authentication.js +68 -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 +34 -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 +10 -6
- package/dist/client.js +96 -43
- 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 +14 -3
- package/dist/config.js +55 -14
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +168 -0
- package/dist/connection.js +327 -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/cucumber.d.ts +4 -2
- package/dist/cucumber.js +8 -3
- package/dist/cucumber.js.map +1 -1
- package/dist/db.js +21 -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 +9 -177
- package/dist/describe.js +71 -34
- 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 +3 -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/index.d.ts +10 -2
- 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 +1 -16
- package/dist/lint.js.map +1 -1
- package/dist/load.d.ts +74 -0
- package/dist/load.js +357 -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 +10 -1
- package/dist/op.js +1 -1
- package/dist/op.js.map +1 -1
- package/dist/outline.js +13 -4
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +21 -3
- 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 +27 -4
- package/dist/resource.js +49 -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 +2 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +1 -1
- package/dist/sandbox.js +7 -3
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +7 -0
- package/dist/scenario.js +33 -3
- 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/shared.d.ts +17 -11
- package/dist/shared.js +34 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +4 -93
- package/dist/sites.js +61 -84
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +6 -0
- package/dist/source.js +13 -0
- package/dist/source.js.map +1 -1
- package/dist/steps.d.ts +3 -1
- 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 +0 -5
- package/package.json +6 -2
- package/src/apply-steps.ts +5 -2
- package/src/apply.ts +70 -29
- package/src/approvals.ts +7 -1
- package/src/apps.ts +54 -85
- package/src/ast.ts +6 -6
- package/src/authentication.ts +74 -0
- package/src/call-workflow.ts +5 -4
- package/src/call.ts +53 -9
- package/src/cedar.ts +14 -1
- package/src/change-steps.ts +7 -4
- package/src/change.ts +10 -0
- package/src/client.ts +108 -41
- package/src/collection.ts +10 -2
- package/src/config.ts +72 -13
- package/src/connection.ts +446 -0
- package/src/credentials.ts +94 -281
- package/src/cucumber.ts +16 -3
- package/src/db.ts +21 -2
- package/src/define.ts +7 -2
- package/src/definitions.ts +31 -0
- package/src/deployment-key.ts +23 -0
- package/src/describe.ts +102 -230
- package/src/description.ts +426 -0
- package/src/direct.ts +3 -0
- package/src/drift.ts +10 -2
- package/src/env.ts +4 -2
- package/src/index.ts +42 -1
- package/src/ledger.ts +3 -1
- package/src/lint.ts +1 -16
- package/src/load.ts +437 -0
- package/src/lockfile.ts +10 -3
- package/src/op.ts +10 -1
- package/src/outline.ts +18 -5
- package/src/paths.ts +24 -2
- package/src/plugin.ts +3 -24
- package/src/policy.ts +2 -0
- package/src/resource.ts +67 -6
- package/src/resources.ts +246 -301
- package/src/run.ts +2 -0
- package/src/sandbox.ts +11 -3
- package/src/scenario.ts +45 -3
- 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/shared.ts +35 -10
- package/src/sites.ts +70 -180
- package/src/source.ts +14 -0
- package/src/steps.ts +3 -1
- 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/run.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConnectionRegistry } from "./connection.ts";
|
|
1
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
3
|
import type { Collection } from "./collection.ts";
|
|
3
4
|
import type { DeadlineRegistry } from "./deadlines.ts";
|
|
@@ -38,6 +39,7 @@ export interface StartHold {
|
|
|
38
39
|
* from a stopped worker never reads a newer worker's drivers or policy.
|
|
39
40
|
*/
|
|
40
41
|
export interface WorkerState {
|
|
42
|
+
connections?: ConnectionRegistry;
|
|
41
43
|
/** The config's `appName`, recorded on every ledger record. */
|
|
42
44
|
app: string;
|
|
43
45
|
/** The operations as the config's connectors declare them, by id. */
|
package/src/sandbox.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
2
2
|
import { indexDrivers, sandboxDrivers } from "./config.ts";
|
|
3
|
+
import { CALL_WORKFLOW, CallInput } from "./call-workflow.ts";
|
|
3
4
|
import { opIdsOf } from "./define.ts";
|
|
4
5
|
import { SanomaError } from "./errors.ts";
|
|
5
6
|
import type { Fake, ResourceFake } from "./fake.ts";
|
|
@@ -88,7 +89,7 @@ export function seedResource(
|
|
|
88
89
|
* run whose seeding is replayed from it has none, and fails, since a sandbox run does not
|
|
89
90
|
* survive a worker restart.
|
|
90
91
|
*/
|
|
91
|
-
export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
92
|
+
export async function seedSandbox(run: Run, name: string, input: unknown): Promise<void> {
|
|
92
93
|
const { state } = run;
|
|
93
94
|
let copies: Map<string, DriverFn> | undefined;
|
|
94
95
|
// The run's secrets, in memory as its fakes are: seeding and its calls share them.
|
|
@@ -109,8 +110,15 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
|
109
110
|
});
|
|
110
111
|
}
|
|
111
112
|
const fakes = new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
|
|
112
|
-
|
|
113
|
-
const
|
|
113
|
+
// The built-ins first, so a connector's fake that implements one of their operations wins.
|
|
114
|
+
const drivers = indexDrivers(
|
|
115
|
+
[...sandboxDrivers(state.ops), ...Array.from(fakes.values(), (f) => f.driver)],
|
|
116
|
+
state.ops,
|
|
117
|
+
);
|
|
118
|
+
// The built-in call workflow selects exactly one operation. Other direct operations
|
|
119
|
+
// (including live connection mutations) need no fake unless this scenario calls them.
|
|
120
|
+
const uses =
|
|
121
|
+
run.workflow === CALL_WORKFLOW ? [CallInput.parse(input).op] : opIdsOf(state.workflows.get(run.workflow)!);
|
|
114
122
|
const seeding = scenario.given.flatMap((g) => ("op" in g ? [g.op] : []));
|
|
115
123
|
const missing = [...new Set([...uses, ...seeding])].filter((id) => !drivers.has(id));
|
|
116
124
|
if (missing.length) {
|
package/src/scenario.ts
CHANGED
|
@@ -34,7 +34,7 @@ import type { LedgerRecord } from "./ledger.ts";
|
|
|
34
34
|
import type { Driver, Op } from "./op.ts";
|
|
35
35
|
import { type OutlineNode, outlineWorkflow } from "./outline.ts";
|
|
36
36
|
import { isDirectory, kindDirs, SCENARIOS_DIR } from "./paths.ts";
|
|
37
|
-
import { isObject } from "./resource.ts";
|
|
37
|
+
import { AFTER_CREATE_MS, callAfterCreate, isObject } from "./resource.ts";
|
|
38
38
|
import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
|
|
39
39
|
import { memorySecrets } from "./secret.ts";
|
|
40
40
|
import {
|
|
@@ -903,11 +903,19 @@ function viewOf(records: readonly LedgerRecord[]): RunView {
|
|
|
903
903
|
return { records, calls, seeded, made: (op) => [...seeded(op), ...calls(op)] };
|
|
904
904
|
}
|
|
905
905
|
|
|
906
|
+
/** How long a step of an operation's own test may take live: a `Given` may seed an object as apply makes it, its `afterCreate` polled. */
|
|
907
|
+
export const FEATURE_STEP_MS = AFTER_CREATE_MS + 60_000;
|
|
908
|
+
|
|
906
909
|
/** Where an operation's own test makes its calls: an operation's driver, live, replayed or a fake's, and the fake. */
|
|
907
910
|
export interface FeatureTarget {
|
|
908
911
|
driver: Driver;
|
|
909
912
|
/** The fake whose driver it is: a scenario's faults need one. */
|
|
910
913
|
fake?: Fake<any, any>;
|
|
914
|
+
/**
|
|
915
|
+
* The driver calls the vendor: a seed's `afterCreate` waits between its calls as apply's do,
|
|
916
|
+
* and a step may take `FEATURE_STEP_MS`. Replayed or on a fake, the calls answer at once.
|
|
917
|
+
*/
|
|
918
|
+
live?: boolean;
|
|
911
919
|
}
|
|
912
920
|
|
|
913
921
|
/** Why no step, or two, match a step's text. */
|
|
@@ -1030,12 +1038,13 @@ function checkWorld(built: Built, workflow: string, view: RunView, verdicts: Map
|
|
|
1030
1038
|
* A World for an operation's own test: `Given` steps seed through the target, `When` steps call
|
|
1031
1039
|
* its operations directly (no worker, no policy), and `Then` steps fail when they do not hold.
|
|
1032
1040
|
*/
|
|
1033
|
-
function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
1041
|
+
function testWorld(built: Built, { driver, fake, live }: FeatureTarget) {
|
|
1034
1042
|
return (report: Report): SanomaWorld => {
|
|
1035
1043
|
// What the steps named (`as`), and the calls made, as a run's ledger would hold them.
|
|
1036
1044
|
const values = new Map<string, unknown>();
|
|
1037
1045
|
const records: LedgerRecord[] = [];
|
|
1038
1046
|
let runId = "";
|
|
1047
|
+
let calls = 0;
|
|
1039
1048
|
// The test's own secrets, in memory: a driver resolves `secret()` markers against them.
|
|
1040
1049
|
const secrets = memorySecrets();
|
|
1041
1050
|
/** A record of the test, as a run's ledger would hold it. */
|
|
@@ -1051,7 +1060,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
1051
1060
|
const input = op.input.parse(resolve(given, values));
|
|
1052
1061
|
const fn = driver.ops[`${op.resource}.${op.name}`];
|
|
1053
1062
|
if (!fn) throw new Error(`the driver under test does not implement ${id}`);
|
|
1054
|
-
|
|
1063
|
+
// A key of its own per call: a fake answers a repeated key with its remembered reply.
|
|
1064
|
+
const context = { idempotencyKey: `${runId}:${++calls}`, runId, opId: id, attempt: 1, secrets };
|
|
1055
1065
|
return { op, input, output: () => fn(input, context) };
|
|
1056
1066
|
};
|
|
1057
1067
|
return {
|
|
@@ -1076,6 +1086,37 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
1076
1086
|
const output = await made.output();
|
|
1077
1087
|
if (item.as !== undefined) values.set(item.as, output);
|
|
1078
1088
|
seeds.push({ op: item.op, input: made.input, output });
|
|
1089
|
+
// A resource type's create seeds the object as apply makes it: the type's `afterCreate`
|
|
1090
|
+
// calls follow, each polled as apply polls it, the seed's name (else the type's) its export.
|
|
1091
|
+
const type =
|
|
1092
|
+
made.op.name === "create" ? built.scope.types.get(`${made.op.vendor}.${made.op.resource}`) : undefined;
|
|
1093
|
+
const id = (output as { id?: unknown } | undefined)?.id;
|
|
1094
|
+
if (type?.afterCreate?.length && typeof id !== "string") {
|
|
1095
|
+
throw new Error(`"${text}": ${item.op} returned no id for its afterCreate`);
|
|
1096
|
+
}
|
|
1097
|
+
for (const hook of type?.afterCreate ?? []) {
|
|
1098
|
+
// One the target cannot call fails at once: polled, its error would read as not passing yet.
|
|
1099
|
+
const polled = built.scope.ops.get(hook.op);
|
|
1100
|
+
if (!polled || !driver.ops[`${polled.resource}.${polled.name}`]) {
|
|
1101
|
+
throw new Error(
|
|
1102
|
+
`"${text}": the driver under test does not implement ${hook.op}, an afterCreate of ${item.op}`,
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
const stuck = await callAfterCreate(
|
|
1106
|
+
hook,
|
|
1107
|
+
{ id: id as string, export: item.as ?? made.op.resource },
|
|
1108
|
+
async (op, input) => {
|
|
1109
|
+
const after = call(op, input);
|
|
1110
|
+
const out = await after.output();
|
|
1111
|
+
seeds.push({ op, input: after.input, output: out });
|
|
1112
|
+
return out;
|
|
1113
|
+
},
|
|
1114
|
+
async (seconds) => {
|
|
1115
|
+
if (live) await delay(seconds * 1000);
|
|
1116
|
+
},
|
|
1117
|
+
);
|
|
1118
|
+
if (stuck !== undefined) throw new Error(`"${text}": ${stuck}`);
|
|
1119
|
+
}
|
|
1079
1120
|
}
|
|
1080
1121
|
if (seeds.length) record({ type: "scenario.seeded", scenario: "", seeds });
|
|
1081
1122
|
return;
|
|
@@ -1442,6 +1483,7 @@ export async function testFeature(scope: Scope, file: string, line: number, targ
|
|
|
1442
1483
|
files: [name],
|
|
1443
1484
|
line,
|
|
1444
1485
|
isBug,
|
|
1486
|
+
timeoutMs: target.live ? FEATURE_STEP_MS : undefined,
|
|
1445
1487
|
});
|
|
1446
1488
|
if (report.parseErrors.length) throw new Error(report.parseErrors.map(parseErrorOf).join("\n"));
|
|
1447
1489
|
const results = stepResults(report);
|
package/src/scenarios.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { type LedgerRecord, memoryLedger } from "./ledger.ts";
|
|
|
5
5
|
import { type Check, check, drive, loadScenarios, type Scenario, type Scope } from "./scenario.ts";
|
|
6
6
|
import { errorMessage } from "./shared.ts";
|
|
7
7
|
import { startWorker } from "./worker.ts";
|
|
8
|
+
import { memorySecretStore } from "./secret-store.ts";
|
|
8
9
|
|
|
9
10
|
/*
|
|
10
11
|
* `@sanoma-ai/workflows/scenarios`: runs a config's scenarios as sandbox runs, with no test
|
|
@@ -77,7 +78,12 @@ export async function runScenarios(
|
|
|
77
78
|
config: SanomaConfig,
|
|
78
79
|
{ databaseUrl, startedBy, timeoutMs, log }: RunScenariosOptions,
|
|
79
80
|
): Promise<{ passed: string[]; failed: ScenarioFailure[] }> {
|
|
80
|
-
const shared: SanomaConfig = {
|
|
81
|
+
const shared: SanomaConfig = {
|
|
82
|
+
...config,
|
|
83
|
+
databaseUrl,
|
|
84
|
+
ledger: memoryLedger(),
|
|
85
|
+
secrets: { store: memorySecretStore(), namespace: "scenarios" },
|
|
86
|
+
};
|
|
81
87
|
const resolved = resolveConfig(shared);
|
|
82
88
|
const { scenarios, errors } = await loadScenarios(resolved);
|
|
83
89
|
const failed: ScenarioFailure[] = errors.map(({ file, message }) => ({
|
package/src/schema.ts
CHANGED
|
@@ -104,6 +104,16 @@ function buildSchema(
|
|
|
104
104
|
memberOf: [{ id: `effect:${op.effect}` }],
|
|
105
105
|
appliesTo: applies(op.id === CHANGE_MERGE ? CHANGE : "Target", {
|
|
106
106
|
...input(op.input),
|
|
107
|
+
connection: optional(
|
|
108
|
+
record({
|
|
109
|
+
id: STRING,
|
|
110
|
+
vendor: STRING,
|
|
111
|
+
method: STRING,
|
|
112
|
+
account: STRING,
|
|
113
|
+
revision: STRING,
|
|
114
|
+
scopes: { type: "Set", element: STRING },
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
107
117
|
...(before && { before: optional(before) }),
|
|
108
118
|
}),
|
|
109
119
|
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { SecretAccess, SecretStoreError, type SecretStore } from "./secret-store.ts";
|
|
2
|
+
|
|
3
|
+
const assert = (condition: unknown) => {
|
|
4
|
+
if (!condition) throw new Error("Conformance failure");
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const rejects = async (run: () => Promise<unknown>, code: string) => {
|
|
8
|
+
try {
|
|
9
|
+
await run();
|
|
10
|
+
} catch (error) {
|
|
11
|
+
assert(error instanceof SecretStoreError && error.code === code);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
throw new Error("Expected refusal");
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export interface SecretStoreCheck {
|
|
18
|
+
name: string;
|
|
19
|
+
ok: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Destructive conformance checks on a fresh, disposable namespace. Never use a production
|
|
24
|
+
* namespace. The caller supplies a store scoped to test credentials and cleans up retained
|
|
25
|
+
* versions/namespaces according to the provider's retention rules. No result contains values.
|
|
26
|
+
*/
|
|
27
|
+
export async function checkSecretStore(store: SecretStore, namespace: string): Promise<SecretStoreCheck[]> {
|
|
28
|
+
const access = new SecretAccess({ store, namespace }, store);
|
|
29
|
+
const other = new SecretAccess({ store, namespace: `${namespace}-other` }, store);
|
|
30
|
+
const results: SecretStoreCheck[] = [];
|
|
31
|
+
const check = async (name: string, run: () => Promise<void>) => {
|
|
32
|
+
try {
|
|
33
|
+
await run();
|
|
34
|
+
results.push({ name, ok: true });
|
|
35
|
+
} catch {
|
|
36
|
+
results.push({ name, ok: false });
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
await check("validate and missing read", async () => {
|
|
40
|
+
await access.validate();
|
|
41
|
+
assert((await access.read("ABSENT")) === undefined);
|
|
42
|
+
});
|
|
43
|
+
await check("external ownership", async () => {
|
|
44
|
+
const external = new SecretAccess(
|
|
45
|
+
{ store, namespace, bindings: { EXISTING: { path: "existing", management: "external" } } },
|
|
46
|
+
store,
|
|
47
|
+
);
|
|
48
|
+
await rejects(() => external.write("EXISTING", "value", { expectedVersion: null, by: "check" }), "read_only");
|
|
49
|
+
await rejects(() => external.delete("EXISTING", { expectedVersion: "v1", by: "check" }), "read_only");
|
|
50
|
+
});
|
|
51
|
+
if (!store.capabilities.writable) {
|
|
52
|
+
await check("read-only provider", () =>
|
|
53
|
+
rejects(() => access.write("VALUE", "value", { expectedVersion: null, by: "check" }), "read_only"),
|
|
54
|
+
);
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
if (store.capabilities.conditionalWrites === "none") {
|
|
58
|
+
await check("unsupported conditional writes", () =>
|
|
59
|
+
rejects(() => access.ensure("VALUE", () => "value"), "unsupported"),
|
|
60
|
+
);
|
|
61
|
+
return results;
|
|
62
|
+
}
|
|
63
|
+
await check("concurrent create and namespace separation", async () => {
|
|
64
|
+
const values = await Promise.all(Array.from({ length: 8 }, (_, i) => access.ensure("RACE", () => String(i))));
|
|
65
|
+
assert(new Set(values).size === 1);
|
|
66
|
+
assert((await other.get("RACE")) === undefined);
|
|
67
|
+
});
|
|
68
|
+
await check("replace rejects stale versions", async () => {
|
|
69
|
+
const one = await access.write("REPLACE", "one", { expectedVersion: null, by: "check" });
|
|
70
|
+
const two = await access.write("REPLACE", "two", { expectedVersion: one.version, by: "check" });
|
|
71
|
+
assert(one.version !== two.version);
|
|
72
|
+
await rejects(() => access.write("REPLACE", "stale", { expectedVersion: one.version, by: "check" }), "conflict");
|
|
73
|
+
assert((await access.get("REPLACE")) === "two");
|
|
74
|
+
});
|
|
75
|
+
await check("UTF-8 size rejection", () =>
|
|
76
|
+
rejects(
|
|
77
|
+
() =>
|
|
78
|
+
access.write("OVERSIZE", "x".repeat(store.capabilities.maxBytes + 1), { expectedVersion: null, by: "check" }),
|
|
79
|
+
"too_large",
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
await check("deletion semantics", async () => {
|
|
83
|
+
const one = await access.write("DELETE", "one", { expectedVersion: null, by: "check" });
|
|
84
|
+
if (store.capabilities.deletion === "unsupported") {
|
|
85
|
+
await rejects(() => access.delete("DELETE", { expectedVersion: one.version, by: "check" }), "unsupported");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
await rejects(() => access.delete("DELETE", { expectedVersion: "incorrect", by: "check" }), "conflict");
|
|
89
|
+
await access.delete("DELETE", { expectedVersion: one.version, by: "check" });
|
|
90
|
+
assert((await access.read("DELETE")) === undefined);
|
|
91
|
+
});
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { deploymentKey } from "./deployment-key.ts";
|
|
2
|
+
/** Opaque values belong only in trusted runtime code, never workflow history or descriptions. */
|
|
3
|
+
export interface SecretValue {
|
|
4
|
+
value: string;
|
|
5
|
+
version: string;
|
|
6
|
+
updatedBy?: string;
|
|
7
|
+
updatedAt?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A store instance already authenticates as one deployment. A namespace is not tenant authorization. */
|
|
11
|
+
export interface SecretAddress {
|
|
12
|
+
namespace: string;
|
|
13
|
+
path: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SecretStoreCapabilities {
|
|
17
|
+
writable: boolean;
|
|
18
|
+
/** UTF-8 bytes, including any serialized credential bundle. */
|
|
19
|
+
maxBytes: number;
|
|
20
|
+
/** `coordinated` covers cooperating Sanoma writers only; `none` cannot ensure or replace safely. */
|
|
21
|
+
conditionalWrites: "atomic" | "coordinated" | "none";
|
|
22
|
+
deletion: "destroy" | "soft-delete" | "unsupported";
|
|
23
|
+
changeDetection: "read" | "watch";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SecretStoreErrorCode =
|
|
27
|
+
| "invalid_configuration"
|
|
28
|
+
| "unavailable"
|
|
29
|
+
| "denied"
|
|
30
|
+
| "conflict"
|
|
31
|
+
| "read_only"
|
|
32
|
+
| "unsupported"
|
|
33
|
+
| "too_large"
|
|
34
|
+
| "invalid_value";
|
|
35
|
+
|
|
36
|
+
/** Intentionally carries no provider message, cause, path or value. */
|
|
37
|
+
export class SecretStoreError extends Error {
|
|
38
|
+
readonly code: SecretStoreErrorCode;
|
|
39
|
+
readonly retryable: boolean;
|
|
40
|
+
constructor(code: SecretStoreErrorCode) {
|
|
41
|
+
super(`Secret store: ${code}`);
|
|
42
|
+
this.name = "SecretStoreError";
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.retryable = code === "unavailable";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SecretStore {
|
|
49
|
+
/** Non-secret adapter identifier, e.g. `postgres` or `aws-secrets-manager`. */
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly capabilities: Readonly<SecretStoreCapabilities>;
|
|
52
|
+
/** Check scoped access without listing an account or returning secret material. */
|
|
53
|
+
validate(namespace: string): Promise<void>;
|
|
54
|
+
read(address: SecretAddress): Promise<SecretValue | undefined>;
|
|
55
|
+
/** null means create only. A string means replace only that exact current version. */
|
|
56
|
+
write(
|
|
57
|
+
address: SecretAddress,
|
|
58
|
+
value: string,
|
|
59
|
+
options: { expectedVersion: string | null; by: string },
|
|
60
|
+
): Promise<SecretValue>;
|
|
61
|
+
/** Delete only the exact version; report the retention behavior advertised in capabilities. */
|
|
62
|
+
delete(address: SecretAddress, options: { expectedVersion: string; by: string }): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SecretBinding =
|
|
66
|
+
| { path: string; management: "sanoma" | "external"; env?: never; bootstrap?: never }
|
|
67
|
+
| { env: string; management?: "external"; path?: never; bootstrap?: never }
|
|
68
|
+
| { bootstrap: "SANOMA_SIGNING_KEY" | "SANOMA_SECRETS_KEY"; management?: "external"; env?: never; path?: never };
|
|
69
|
+
|
|
70
|
+
export interface SecretConfiguration {
|
|
71
|
+
/** Omitted: built-in encrypted Postgres, supplied by the runtime. */
|
|
72
|
+
store?: SecretStore;
|
|
73
|
+
/** Defaults to `default`. Choose a distinct namespace and identity for each deployment. */
|
|
74
|
+
namespace?: string;
|
|
75
|
+
/** Names without a binding resolve to the same name in this namespace. */
|
|
76
|
+
bindings?: Readonly<Record<string, SecretBinding>>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SecretLocation {
|
|
80
|
+
store: string;
|
|
81
|
+
namespace: string;
|
|
82
|
+
path: string;
|
|
83
|
+
management: "sanoma" | "external";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const errorCodes = new Set<string>([
|
|
87
|
+
"invalid_configuration",
|
|
88
|
+
"unavailable",
|
|
89
|
+
"denied",
|
|
90
|
+
"conflict",
|
|
91
|
+
"read_only",
|
|
92
|
+
"unsupported",
|
|
93
|
+
"too_large",
|
|
94
|
+
"invalid_value",
|
|
95
|
+
]);
|
|
96
|
+
const storeError = (error: unknown): error is SecretStoreError =>
|
|
97
|
+
typeof error === "object" &&
|
|
98
|
+
error !== null &&
|
|
99
|
+
(error as SecretStoreError).name === "SecretStoreError" &&
|
|
100
|
+
errorCodes.has((error as SecretStoreError).code);
|
|
101
|
+
|
|
102
|
+
/** Provider failures are sanitized even when an SDK throws a credential-bearing response. */
|
|
103
|
+
export async function secretStoreCall<T>(run: () => Promise<T>): Promise<T> {
|
|
104
|
+
try {
|
|
105
|
+
return await run();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (storeError(error)) throw new SecretStoreError(error.code);
|
|
108
|
+
throw new SecretStoreError("unavailable");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const namePattern = /^[A-Z][A-Z0-9_]*$/;
|
|
113
|
+
const environmentPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
114
|
+
const nonempty = (value: unknown): value is string =>
|
|
115
|
+
typeof value === "string" && value.trim().length > 0 && !value.includes("\0");
|
|
116
|
+
|
|
117
|
+
/** Structural validation only: no secret reads, provider calls or environment access. */
|
|
118
|
+
const invalid = () => {
|
|
119
|
+
throw new SecretStoreError("invalid_configuration");
|
|
120
|
+
};
|
|
121
|
+
export function checkSecretConfiguration(config: SecretConfiguration): void {
|
|
122
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) invalid();
|
|
123
|
+
if (config.namespace !== undefined && !nonempty(config.namespace)) invalid();
|
|
124
|
+
if (config.store !== undefined) {
|
|
125
|
+
const s = config.store;
|
|
126
|
+
const c = s?.capabilities;
|
|
127
|
+
if (
|
|
128
|
+
!nonempty(s?.id) ||
|
|
129
|
+
!/^[a-z][a-z0-9-]{0,63}$/.test(s.id) ||
|
|
130
|
+
!c ||
|
|
131
|
+
typeof c.writable !== "boolean" ||
|
|
132
|
+
!Number.isSafeInteger(c.maxBytes) ||
|
|
133
|
+
c.maxBytes < 1 ||
|
|
134
|
+
!["atomic", "coordinated", "none"].includes(c.conditionalWrites) ||
|
|
135
|
+
!["destroy", "soft-delete", "unsupported"].includes(c.deletion) ||
|
|
136
|
+
!["read", "watch"].includes(c.changeDetection) ||
|
|
137
|
+
[s.validate, s.read, s.write, s.delete].some((fn) => typeof fn !== "function")
|
|
138
|
+
)
|
|
139
|
+
invalid();
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
config.bindings !== undefined &&
|
|
143
|
+
(!config.bindings || typeof config.bindings !== "object" || Array.isArray(config.bindings))
|
|
144
|
+
)
|
|
145
|
+
invalid();
|
|
146
|
+
for (const [name, b] of Object.entries(config.bindings ?? {})) {
|
|
147
|
+
if (!namePattern.test(name) || !b || typeof b !== "object") invalid();
|
|
148
|
+
if (["SANOMA_SIGNING_KEY", "SANOMA_SECRETS_KEY"].includes(name) && !("env" in b) && !("bootstrap" in b)) invalid();
|
|
149
|
+
if ("bootstrap" in b) {
|
|
150
|
+
if (
|
|
151
|
+
!["SANOMA_SIGNING_KEY", "SANOMA_SECRETS_KEY"].includes(b.bootstrap!) ||
|
|
152
|
+
b.path !== undefined ||
|
|
153
|
+
b.env !== undefined ||
|
|
154
|
+
(b.management !== undefined && b.management !== "external")
|
|
155
|
+
)
|
|
156
|
+
invalid();
|
|
157
|
+
} else if ("env" in b) {
|
|
158
|
+
if (
|
|
159
|
+
typeof b.env !== "string" ||
|
|
160
|
+
!environmentPattern.test(b.env) ||
|
|
161
|
+
b.path !== undefined ||
|
|
162
|
+
(b.management !== undefined && b.management !== "external")
|
|
163
|
+
)
|
|
164
|
+
invalid();
|
|
165
|
+
} else if (!nonempty(b.path) || !["sanoma", "external"].includes(b.management)) invalid();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Enforces binding ownership before calling a provider; preserves the drivers' get/ensure interface. */
|
|
170
|
+
export class SecretAccess {
|
|
171
|
+
readonly store: SecretStore;
|
|
172
|
+
readonly namespace: string;
|
|
173
|
+
private readonly bindings: Readonly<Record<string, SecretBinding>>;
|
|
174
|
+
|
|
175
|
+
private readonly env: (name: string) => string | undefined;
|
|
176
|
+
|
|
177
|
+
constructor(
|
|
178
|
+
config: SecretConfiguration,
|
|
179
|
+
defaultStore: SecretStore,
|
|
180
|
+
env: (name: string) => string | undefined = (name) => process.env[name],
|
|
181
|
+
) {
|
|
182
|
+
this.env = env;
|
|
183
|
+
checkSecretConfiguration(config);
|
|
184
|
+
this.store = config.store ?? defaultStore;
|
|
185
|
+
checkSecretConfiguration({ store: this.store });
|
|
186
|
+
this.namespace = config.namespace ?? "default";
|
|
187
|
+
this.bindings = Object.fromEntries(
|
|
188
|
+
Object.entries(config.bindings ?? {}).map(([name, binding]) => [name, { ...binding }]),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
location(name: string): SecretLocation {
|
|
193
|
+
if (!namePattern.test(name)) throw new SecretStoreError("invalid_configuration");
|
|
194
|
+
const binding = Object.hasOwn(this.bindings, name) ? this.bindings[name] : undefined;
|
|
195
|
+
if (binding?.bootstrap !== undefined)
|
|
196
|
+
return { store: "bootstrap", namespace: "", path: binding.bootstrap, management: "external" };
|
|
197
|
+
if (["SANOMA_SIGNING_KEY", "SANOMA_SECRETS_KEY"].includes(name) && !binding)
|
|
198
|
+
throw new SecretStoreError("invalid_configuration");
|
|
199
|
+
if (binding?.env !== undefined)
|
|
200
|
+
return { store: "environment", namespace: "", path: binding.env, management: "external" };
|
|
201
|
+
return {
|
|
202
|
+
store: this.store.id,
|
|
203
|
+
namespace: this.namespace,
|
|
204
|
+
path: binding?.path ?? name,
|
|
205
|
+
management: binding?.management ?? "sanoma",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async validate(): Promise<void> {
|
|
210
|
+
await secretStoreCall(() => this.store.validate(this.namespace));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async read(name: string): Promise<SecretValue | undefined> {
|
|
214
|
+
const location = this.location(name);
|
|
215
|
+
if (location.store === "bootstrap" && location.namespace === "") {
|
|
216
|
+
const value = deploymentKey(location.path as "SANOMA_SIGNING_KEY" | "SANOMA_SECRETS_KEY").toString("base64");
|
|
217
|
+
return { value, version: "bootstrap" };
|
|
218
|
+
}
|
|
219
|
+
if (location.store === "environment" && location.namespace === "") {
|
|
220
|
+
const value = this.env(location.path);
|
|
221
|
+
// No content-derived version (which would expose a hash of a low-entropy password).
|
|
222
|
+
return value === undefined ? undefined : { value, version: "environment" };
|
|
223
|
+
}
|
|
224
|
+
return secretStoreCall(() => this.store.read(location));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async get(name: string): Promise<string | undefined> {
|
|
228
|
+
return (await this.read(name))?.value;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async write(
|
|
232
|
+
name: string,
|
|
233
|
+
value: string,
|
|
234
|
+
options: { expectedVersion: string | null; by: string },
|
|
235
|
+
): Promise<SecretValue> {
|
|
236
|
+
const location = this.writable(name);
|
|
237
|
+
if (Buffer.byteLength(value, "utf8") > this.store.capabilities.maxBytes) throw new SecretStoreError("too_large");
|
|
238
|
+
return secretStoreCall(() => this.store.write(location, value, options));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async delete(name: string, options: { expectedVersion: string; by: string }): Promise<void> {
|
|
242
|
+
const location = this.writable(name);
|
|
243
|
+
if (this.store.capabilities.deletion === "unsupported") throw new SecretStoreError("unsupported");
|
|
244
|
+
await secretStoreCall(() => this.store.delete(location, options));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async ensure(name: string, make: () => string): Promise<string> {
|
|
248
|
+
const stored = await this.read(name);
|
|
249
|
+
if (stored) return stored.value;
|
|
250
|
+
this.writable(name);
|
|
251
|
+
try {
|
|
252
|
+
return (await this.write(name, make(), { expectedVersion: null, by: "sanoma" })).value;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (!storeError(error) || error.code !== "conflict") throw error;
|
|
255
|
+
const winner = await this.read(name);
|
|
256
|
+
if (winner) return winner.value;
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private writable(name: string): SecretLocation {
|
|
262
|
+
const location = this.location(name);
|
|
263
|
+
if (location.management === "external" || !this.store.capabilities.writable)
|
|
264
|
+
throw new SecretStoreError("read_only");
|
|
265
|
+
if (this.store.capabilities.conditionalWrites === "none") throw new SecretStoreError("unsupported");
|
|
266
|
+
return location;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Reference provider for tests. `atomic` is scoped to this shared in-process instance only. */
|
|
271
|
+
const key = (a: SecretAddress) => JSON.stringify([a.namespace, a.path]);
|
|
272
|
+
export function memorySecretStore(
|
|
273
|
+
options: { maxBytes?: number; writable?: boolean; deletion?: "destroy" | "soft-delete" } = {},
|
|
274
|
+
): SecretStore {
|
|
275
|
+
const values = new Map<string, SecretValue>();
|
|
276
|
+
const retained = new Map<string, SecretValue>();
|
|
277
|
+
let generation = 0;
|
|
278
|
+
const capabilities: SecretStoreCapabilities = {
|
|
279
|
+
writable: options.writable ?? true,
|
|
280
|
+
maxBytes: options.maxBytes ?? 65_536,
|
|
281
|
+
conditionalWrites: "atomic",
|
|
282
|
+
deletion: options.deletion ?? "destroy",
|
|
283
|
+
changeDetection: "read",
|
|
284
|
+
};
|
|
285
|
+
const mutable = () => {
|
|
286
|
+
if (!capabilities.writable) throw new SecretStoreError("read_only");
|
|
287
|
+
};
|
|
288
|
+
return {
|
|
289
|
+
id: "memory",
|
|
290
|
+
capabilities: Object.freeze(capabilities),
|
|
291
|
+
async validate(namespace) {
|
|
292
|
+
if (!nonempty(namespace)) throw new SecretStoreError("invalid_configuration");
|
|
293
|
+
},
|
|
294
|
+
async read(address) {
|
|
295
|
+
const value = values.get(key(address));
|
|
296
|
+
return value && { ...value };
|
|
297
|
+
},
|
|
298
|
+
async write(address, value, { expectedVersion, by }) {
|
|
299
|
+
mutable();
|
|
300
|
+
if (Buffer.byteLength(value, "utf8") > capabilities.maxBytes) throw new SecretStoreError("too_large");
|
|
301
|
+
const current = values.get(key(address));
|
|
302
|
+
if ((current?.version ?? null) !== expectedVersion) throw new SecretStoreError("conflict");
|
|
303
|
+
const stored = { value, version: String(++generation), updatedBy: by, updatedAt: new Date().toISOString() };
|
|
304
|
+
values.set(key(address), stored);
|
|
305
|
+
return { ...stored };
|
|
306
|
+
},
|
|
307
|
+
async delete(address, { expectedVersion }) {
|
|
308
|
+
mutable();
|
|
309
|
+
if (values.get(key(address))?.version !== expectedVersion) throw new SecretStoreError("conflict");
|
|
310
|
+
if (capabilities.deletion === "soft-delete") retained.set(expectedVersion, values.get(key(address))!);
|
|
311
|
+
values.delete(key(address));
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
package/src/shared.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { OpEntry, WorkflowEntry } from "./describe.ts";
|
|
|
5
5
|
import type { LedgerRecord } from "./ledger.ts";
|
|
6
6
|
import type { Effect } from "./op.ts";
|
|
7
7
|
import type { PreviewRequest } from "./policy.ts";
|
|
8
|
-
import type { DeclaredResource, ResourceProblem } from "./
|
|
8
|
+
import type { DeclaredResource, ResourceProblem } from "./description.ts";
|
|
9
9
|
import type { OutlineNode } from "./outline.ts";
|
|
10
10
|
|
|
11
11
|
// Types only: resource types as a UI reads them. Erased from the bundle.
|
|
@@ -18,7 +18,7 @@ export type {
|
|
|
18
18
|
ResourceFields,
|
|
19
19
|
ResourceSpec,
|
|
20
20
|
} from "./resource.ts";
|
|
21
|
-
export type { DeclaredResource, ResourceProblem } from "./
|
|
21
|
+
export type { At, DeclaredResource, ResourceProblem } from "./description.ts";
|
|
22
22
|
export type { DriftField, DriftReport, DriftResult, DriftStatus } from "./drift.ts";
|
|
23
23
|
export type { ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
|
|
24
24
|
export type { CredentialStatus } from "./env.ts";
|
|
@@ -31,6 +31,8 @@ export type { AvailableApp, AvailableApps } from "./apps-list.ts";
|
|
|
31
31
|
|
|
32
32
|
// Sanoma's claims, data (`sanoma/claims` re-exports them).
|
|
33
33
|
export * from "./claims.ts";
|
|
34
|
+
// A site's `site.ts` declares itself with it (`sanoma/site` calls it, typing its icon).
|
|
35
|
+
export { defineSite, type SiteDefinition } from "./definitions.ts";
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
38
|
* What a `ctx.sleep` without a `{ name }` is called, in its outline node and its ledger record
|
|
@@ -490,17 +492,40 @@ export function flatten(nodes: readonly OutlineNode[]): OutlineNode[] {
|
|
|
490
492
|
return nodes.flatMap((node) => [node, ...flatten(childLists(node).flat())]);
|
|
491
493
|
}
|
|
492
494
|
|
|
493
|
-
/**
|
|
494
|
-
|
|
495
|
-
|
|
495
|
+
/**
|
|
496
|
+
* Where a problem is, `file:line:column`, `file#export` or `file`; undefined for one that is the
|
|
497
|
+
* config's, with no file.
|
|
498
|
+
*/
|
|
499
|
+
export const problemAt = (p: ResourceProblem): string | undefined => {
|
|
500
|
+
if (p.file === undefined) return undefined;
|
|
501
|
+
if (p.line !== undefined) return `${p.file}:${p.line}:${p.column ?? 1}`;
|
|
502
|
+
return p.export === undefined ? p.file : `${p.file}#${p.export}`;
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* A problem as one line: `file:line:column: message`, `file#export: message` or `file: message`
|
|
507
|
+
* without a position, or the message alone without a file.
|
|
508
|
+
*/
|
|
509
|
+
export function problemLine(p: ResourceProblem): string {
|
|
510
|
+
const at = problemAt(p);
|
|
511
|
+
return at === undefined ? p.message : `${at}: ${p.message}`;
|
|
512
|
+
}
|
|
496
513
|
|
|
497
514
|
/**
|
|
498
|
-
*
|
|
499
|
-
* the
|
|
515
|
+
* Where the export `name` (`default` for the default export) is in a file's text, as UTF-16
|
|
516
|
+
* offsets: from its `export` to the next `export`, `import`, `const` or comment at the start of a
|
|
517
|
+
* line, or the end, less trailing blank space. Read from the text alone, for highlighting it; undefined
|
|
518
|
+
* when no line starts it.
|
|
500
519
|
*/
|
|
501
|
-
export function
|
|
502
|
-
|
|
503
|
-
|
|
520
|
+
export function exportSpan(text: string, name: string): [number, number] | undefined {
|
|
521
|
+
const declared = name === "default" ? "default" : `const\\s+${name.replaceAll("$", "\\$")}`;
|
|
522
|
+
const start = new RegExp(`^export\\s+${declared}(?![\\w$])`, "m").exec(text);
|
|
523
|
+
if (!start) return undefined;
|
|
524
|
+
const after = start.index + start[0].length;
|
|
525
|
+
// A comment at the start of a line is the next declaration's.
|
|
526
|
+
const next = /^(?:(?:export|import|const)\b|\/[/*])/m.exec(text.slice(after));
|
|
527
|
+
const end = next ? after + next.index : text.length;
|
|
528
|
+
return [start.index, start.index + text.slice(start.index, end).trimEnd().length];
|
|
504
529
|
}
|
|
505
530
|
|
|
506
531
|
/** A warning as one line, as `problemLine` with `warning: ` before its message: what is worth fixing but not wrong. */
|