@starci/hfs 4.7.44 → 4.7.45
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 +4 -0
- package/lint/run.mjs +46 -10
- package/package.json +4 -1
- package/release/supabase-bucket.mjs +8 -0
- package/runtime/knowledge/grammars/starci/playbook.yaml +11 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -7
- package/runtime/knowledge/hfs/fe-stack.yaml +12 -1
- package/runtime/knowledge/hfs/rules.yaml +6 -2
- package/runtime/knowledge/hfs/slots.yaml +5 -5
- package/runtime/knowledge/patterns/be/realtime.yaml +71 -12
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/scripts/api/git/commit-tree.mjs +5 -0
- package/runtime/scripts/api/git/update-ref.mjs +8 -0
- package/runtime/scripts/api/git/write-tree.mjs +5 -0
- package/runtime/scripts/guards/release-definition.mjs +204 -204
- package/runtime/scripts/hfs/architecture/index.mjs +2 -1
- package/runtime/scripts/hfs/architecture/type-context.mjs +38 -23
- package/runtime/scripts/hfs/example-secrets.mjs +61 -8
- package/runtime/scripts/hfs/typescript-programs.mjs +30 -3
- package/runtime/scripts/lib/release-version.mjs +134 -134
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +1 -3
- package/templates/be/patterns/idempotency/platform/inbox/inbox.port.ts.tpl +1 -3
- package/templates/be/patterns/realtime/realtime-config.mapper.ts.tpl +32 -0
|
@@ -6,6 +6,11 @@ import path from 'node:path';
|
|
|
6
6
|
import { runProgram } from '../api/process/run-program.mjs';
|
|
7
7
|
import { init as initGit } from '../api/git/init.mjs';
|
|
8
8
|
import { add as addGit } from '../api/git/add.mjs';
|
|
9
|
+
import { runGit } from '../api/git/lib.mjs';
|
|
10
|
+
import { mergeBase } from '../api/git/merge-base.mjs';
|
|
11
|
+
import { writeTree } from '../api/git/write-tree.mjs';
|
|
12
|
+
import { commitTree } from '../api/git/commit-tree.mjs';
|
|
13
|
+
import { updateRef } from '../api/git/update-ref.mjs';
|
|
9
14
|
import { stringifyYaml } from '../../engine/yaml.mjs';
|
|
10
15
|
import { isMain } from '../lib/is-main.mjs';
|
|
11
16
|
import { readEnv } from '../lib/env.mjs';
|
|
@@ -50,7 +55,42 @@ export async function materializeExample(repoRoot, { createIdentity = generateAp
|
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
|
|
58
|
+
const COMMITTER = { 'user.name': 'starci', 'user.email': 'l4@starci.invalid' };
|
|
59
|
+
const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
60
|
+
const ok = (r) => !r.error && r.status === 0 && Boolean(String(r.stdout ?? '').trim());
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The lint base the copy's size-growth ratchet measures against: the source app's subtree at the source repository's
|
|
64
|
+
* lint baseline (STARCI_BASE_REF, then origin/main, then main),
|
|
65
|
+
* re-committed inside the copy so `merge-base` and `git show` resolve here. The source objects are reached through an
|
|
66
|
+
* objects alternate, never copied. An app absent at the baseline bases on the empty tree: every file is new.
|
|
67
|
+
* Returns the synthetic base commit sha, or null when the source sits in no repository. Missing repository baselines fail explicitly.
|
|
68
|
+
*/
|
|
69
|
+
function sealedLintBase(repoRoot, source, env) {
|
|
70
|
+
const query = { cwd: source, env };
|
|
71
|
+
const top = runGit(['rev-parse', '--show-toplevel'], query);
|
|
72
|
+
if (!ok(top)) return null;
|
|
73
|
+
const common = runGit(['rev-parse', '--git-common-dir'], query);
|
|
74
|
+
if (!ok(common)) return null;
|
|
75
|
+
const candidates = [...(env.STARCI_BASE_REF ? [env.STARCI_BASE_REF] : []), 'origin/main', 'main'];
|
|
76
|
+
let baseSha = null;
|
|
77
|
+
for (const ref of candidates) {
|
|
78
|
+
baseSha = mergeBase(source, 'HEAD', ref);
|
|
79
|
+
if (baseSha) break;
|
|
80
|
+
}
|
|
81
|
+
if (!baseSha) throw new Error('sealed lint baseline unavailable: set STARCI_BASE_REF to a source commit sharing history with HEAD, or fetch origin/main or main');
|
|
82
|
+
const relative = path.relative(top.stdout.trim(), path.resolve(source)).split(path.sep).join('/');
|
|
83
|
+
const tree = runGit(['rev-parse', '--verify', '--quiet', relative && !relative.startsWith('..') ? `${baseSha}:${relative}` : `${baseSha}^{tree}`], query);
|
|
84
|
+
const baseTree = ok(tree) ? tree.stdout.trim() : EMPTY_TREE;
|
|
85
|
+
const info = path.join(repoRoot, '.git', 'objects', 'info');
|
|
86
|
+
fs.mkdirSync(info, { recursive: true });
|
|
87
|
+
fs.appendFileSync(path.join(info, 'alternates'), `${path.resolve(source, common.stdout.trim()).split(path.sep).join('/')}/objects\n`);
|
|
88
|
+
const base = commitTree([baseTree, '-m', 'sealed lint base'], { cwd: repoRoot, env, config: COMMITTER });
|
|
89
|
+
if (!ok(base)) throw new Error('could not commit the lint base of the isolated example git tree');
|
|
90
|
+
return base.stdout.trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function trackExample(repoRoot, source) {
|
|
54
94
|
const env = withoutGitLocalEnv(process.env);
|
|
55
95
|
if (!initGit(repoRoot, { env }).ok) throw new Error('could not initialize the isolated example git tree');
|
|
56
96
|
// The linked node_modules (linkExampleDependencies: a junction on Windows, a symlink elsewhere) is the install, never a tracked entry: git treats a
|
|
@@ -59,6 +99,16 @@ function trackExample(repoRoot) {
|
|
|
59
99
|
fs.appendFileSync(path.join(repoRoot, '.git', 'info', 'exclude'), ['', '# the linked install of the example (example-secrets.mjs)', 'node_modules', ''].join('\n'));
|
|
60
100
|
const staged = addGit(['-A', '--', '.'], { cwd: repoRoot, env });
|
|
61
101
|
if (staged.error || staged.status !== 0) throw new Error('could not stage the isolated example git tree');
|
|
102
|
+
// The isolated tree is committed (and based on the source's lint baseline) so the size-growth ratchet reads a real
|
|
103
|
+
// recorded size instead of failing "baseline unavailable" in every sealed lint, as it did on a historyless tree.
|
|
104
|
+
const base = sealedLintBase(repoRoot, source, env);
|
|
105
|
+
const tree = writeTree([], { cwd: repoRoot, env });
|
|
106
|
+
if (!ok(tree)) throw new Error('could not write the isolated example git tree');
|
|
107
|
+
const committed = commitTree([tree.stdout.trim(), ...(base ? ['-p', base] : []), '-m', 'sealed snapshot'], { cwd: repoRoot, env, config: COMMITTER });
|
|
108
|
+
if (!ok(committed)) throw new Error('could not commit the isolated example git tree');
|
|
109
|
+
const moved = updateRef(repoRoot, 'HEAD', committed.stdout.trim());
|
|
110
|
+
if (!moved.ok) throw new Error('could not point the isolated example git tree at its snapshot commit');
|
|
111
|
+
return base;
|
|
62
112
|
}
|
|
63
113
|
|
|
64
114
|
function safeExampleSource(source, { preserveCoverage = false } = {}) {
|
|
@@ -82,7 +132,7 @@ function pruneEmptyDirectories(directory) {
|
|
|
82
132
|
if (!fs.readdirSync(directory).length) fs.rmdirSync(directory);
|
|
83
133
|
}
|
|
84
134
|
|
|
85
|
-
/** Await callback(tempExamplePath), return its result, and remove the os.tmpdir-based copy even on failure. */
|
|
135
|
+
/** Await callback(tempExamplePath, lintBaseRef), return its result, and remove the os.tmpdir-based copy even on failure. */
|
|
86
136
|
export async function withMaterializedExample(repoRoot, exampleRelativePath, callback, { materialize = materializeExample, linkDependencies = false, preserveCoverage = false } = {}) {
|
|
87
137
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-example-'));
|
|
88
138
|
const source = path.resolve(repoRoot, exampleRelativePath);
|
|
@@ -96,8 +146,8 @@ export async function withMaterializedExample(repoRoot, exampleRelativePath, cal
|
|
|
96
146
|
pruneEmptyDirectories(app);
|
|
97
147
|
if (linkDependencies) linkExampleDependencies(source, app);
|
|
98
148
|
await materialize(app);
|
|
99
|
-
trackExample(app);
|
|
100
|
-
return await callback(app);
|
|
149
|
+
const lintBaseRef = trackExample(app, source);
|
|
150
|
+
return await callback(app, lintBaseRef);
|
|
101
151
|
} finally {
|
|
102
152
|
safeRemove(directory, { hold: () => null });
|
|
103
153
|
}
|
|
@@ -133,16 +183,19 @@ function commandLaunch(command, args) {
|
|
|
133
183
|
return { file: process.execPath, args: [cli, ...args] };
|
|
134
184
|
}
|
|
135
185
|
|
|
136
|
-
|
|
186
|
+
// The copy's own lint base replaces any STARCI_BASE_REF of the caller, which names a ref of the source repository, not of the copy.
|
|
187
|
+
function commandEnvironment(lintBaseRef) {
|
|
137
188
|
const env = withoutGitLocalEnv(process.env);
|
|
138
|
-
|
|
189
|
+
const out = Object.fromEntries(Object.entries(env).filter(([name]) => !/^SOPS_AGE_KEY(?:_FILE)?$/iu.test(name)));
|
|
190
|
+
if (lintBaseRef) out.STARCI_BASE_REF = lintBaseRef;
|
|
191
|
+
return out;
|
|
139
192
|
}
|
|
140
193
|
|
|
141
194
|
/** Run an installed app command in a sealed temporary copy, returning its exit code and preserving report artifacts. */
|
|
142
195
|
async function runMaterializedCommand(repoRoot, command, args = [], { run = runProgram } = {}) {
|
|
143
|
-
return withMaterializedExample(repoRoot, '.', (app) => {
|
|
196
|
+
return withMaterializedExample(repoRoot, '.', (app, lintBaseRef) => {
|
|
144
197
|
const launch = commandLaunch(command, args);
|
|
145
|
-
const result = run(launch.file, launch.args, { cwd: app, env: commandEnvironment(), stdio: 'inherit', windowsHide: true });
|
|
198
|
+
const result = run(launch.file, launch.args, { cwd: app, env: commandEnvironment(lintBaseRef), stdio: 'inherit', windowsHide: true });
|
|
146
199
|
const reports = path.join(app, 'reports');
|
|
147
200
|
if (fs.existsSync(reports)) fs.cpSync(reports, path.join(repoRoot, 'reports'), { recursive: true });
|
|
148
201
|
if (result.error) throw new Error('the example check command could not start');
|
|
@@ -9,6 +9,11 @@ import { byCodeUnit } from '../lib/list.mjs';
|
|
|
9
9
|
// builds afresh.
|
|
10
10
|
const runs = new AsyncLocalStorage();
|
|
11
11
|
|
|
12
|
+
/** Share compiler inputs only until this check finishes; nested callers join the same run. */
|
|
13
|
+
export function withTypeScriptProgramRun(run) {
|
|
14
|
+
return runs.getStore() ? run() : runs.run(new Map(), run);
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
|
|
13
18
|
function keyOf(value) {
|
|
14
19
|
if (value === undefined) return 'u';
|
|
@@ -48,7 +53,28 @@ export function sharedInProgramRun(kind, ts, input, build) {
|
|
|
48
53
|
/** ts.createProgram, shared inside a program run. */
|
|
49
54
|
export function createTypeScriptProgram(ts, { rootNames, options, projectReferences }) {
|
|
50
55
|
return sharedInProgramRun('program', ts, { rootNames, options, projectReferences },
|
|
51
|
-
() => ts.createProgram({ rootNames, options, projectReferences }));
|
|
56
|
+
() => ts.createProgram({ rootNames, options, projectReferences, host: sharedSourceHost(ts, options) }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sharedSourceHost(ts, options) {
|
|
60
|
+
if (!runs.getStore()) return undefined;
|
|
61
|
+
const registry = sharedInProgramRun('documents', ts, null,
|
|
62
|
+
() => ts.createDocumentRegistry(ts.sys.useCaseSensitiveFileNames, ts.sys.getCurrentDirectory()));
|
|
63
|
+
const host = ts.createCompilerHost(options);
|
|
64
|
+
const readSource = host.getSourceFile;
|
|
65
|
+
// TypeScript's registry separates parse-affecting settings and Node module formats. Sharing syntax trees leaves
|
|
66
|
+
// module resolution and symbol/type caches with each program, including projects with different path aliases.
|
|
67
|
+
host.getSourceFile = (file, sourceOptions, onError, fresh) => {
|
|
68
|
+
if (fresh) return readSource(file, sourceOptions, onError, fresh);
|
|
69
|
+
let text;
|
|
70
|
+
try { text = host.readFile(file); } catch (error) {
|
|
71
|
+
onError?.(String(error.message ?? error));
|
|
72
|
+
text = '';
|
|
73
|
+
}
|
|
74
|
+
if (text === undefined) return undefined;
|
|
75
|
+
return registry.acquireDocument(file, options, ts.ScriptSnapshot.fromString(text), '0', undefined, sourceOptions);
|
|
76
|
+
};
|
|
77
|
+
return host;
|
|
52
78
|
}
|
|
53
79
|
|
|
54
80
|
/** Read and expand a tsconfig with the caller's TypeScript compiler and optional tracked file reader. */
|
|
@@ -66,6 +92,7 @@ export function typeScriptProjectReferencePath(ts, reference) {
|
|
|
66
92
|
}
|
|
67
93
|
|
|
68
94
|
/** Resolve an import using the selected project's compiler options. */
|
|
69
|
-
export function resolveTypeScriptModule(ts, specifier, from, options, host
|
|
70
|
-
|
|
95
|
+
export function resolveTypeScriptModule(ts, specifier, from, options, host, cache) {
|
|
96
|
+
const resolverHost = host === undefined ? ts.sys : host;
|
|
97
|
+
return ts.resolveModuleName(specifier, from, options, resolverHost, cache).resolvedModule?.resolvedFileName;
|
|
71
98
|
}
|
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
// release-version.mjs - the one reading of a release version (owner ruling release-waves). A prerelease `<label>.N` opens the release
|
|
2
|
-
// line N and `<label>.N.M` is the M-th release after it, a wave or a fix alike, so alpha.31 < alpha.31.1 < alpha.31.2 < alpha.32 in
|
|
3
|
-
// SemVer order. The next release is derived from the highest one already released, never guessed. The legacy `<label>.N-fix.K` still
|
|
4
|
-
// reads as release K after `<label>.N` and is never produced again. Pure: the callers read the tags and the registry.
|
|
5
|
-
|
|
6
|
-
const NUMERIC = /^(?:0|[1-9]\d*)$/;
|
|
7
|
-
const POSITIVE = /^[1-9]\d*$/;
|
|
8
|
-
const IDENTIFIERS = /^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/;
|
|
9
|
-
const LEGACY_FIX = '-fix.';
|
|
10
|
-
|
|
11
|
-
/** {base, wave, legacy}: the release a version counts its waves from, and its wave M (0 for the release that opens the line). */
|
|
12
|
-
export function waveOf(version) {
|
|
13
|
-
const text = String(version ?? '').split('+')[0];
|
|
14
|
-
const fix = text.lastIndexOf(LEGACY_FIX);
|
|
15
|
-
const legacyWave = fix > 0 ? text.slice(fix + LEGACY_FIX.length) : '';
|
|
16
|
-
if (POSITIVE.test(legacyWave)) return { base: text.slice(0, fix), wave: Number(legacyWave), legacy: true };
|
|
17
|
-
const ids = text.split('.');
|
|
18
|
-
const counted = ids.length > 3 && POSITIVE.test(ids.at(-1)) && (text.includes('-') ? NUMERIC.test(ids.at(-2)) : ids.length === 4);
|
|
19
|
-
return counted ? { base: ids.slice(0, -1).join('.'), wave: Number(ids.at(-1)), legacy: false } : { base: text, wave: 0, legacy: false };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Parsed SemVer identifiers {core, pre} of `version`; an unreadable version throws. */
|
|
23
|
-
export function versionParts(version) {
|
|
24
|
-
const parts = typeof version === 'string' ? version.split('+') : [];
|
|
25
|
-
if (!parts.length || parts.length > 2 || (parts.length === 2 && !IDENTIFIERS.test(parts[1]))) throw new Error(`unreadable release version ${version}`);
|
|
26
|
-
const boundary = parts[0].indexOf('-');
|
|
27
|
-
const core = (boundary < 0 ? parts[0] : parts[0].slice(0, boundary)).split('.');
|
|
28
|
-
const prerelease = boundary < 0 ? null : parts[0].slice(boundary + 1);
|
|
29
|
-
if (core.length !== 3 || core.some((part) => !NUMERIC.test(part)) || (prerelease !== null && !IDENTIFIERS.test(prerelease))) throw new Error(`unreadable release version ${version}`);
|
|
30
|
-
const pre = prerelease?.split('.') ?? [];
|
|
31
|
-
if (pre.some((part) => /^0\d+$/.test(part))) throw new Error(`invalid numeric prerelease identifier in ${version}`);
|
|
32
|
-
return { core: core.map(BigInt), pre };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const readable = (version) => {
|
|
36
|
-
try {
|
|
37
|
-
versionParts(version);
|
|
38
|
-
return true;
|
|
39
|
-
} catch {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/** The SemVer spelling a version is ordered by: a legacy `-fix.K` orders as wave K of its line. */
|
|
45
|
-
const ordered = (version) => {
|
|
46
|
-
const { base, wave, legacy } = waveOf(version);
|
|
47
|
-
return legacy ? `${base}.${wave}` : version;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const compareIdentifier = (a, b) => {
|
|
51
|
-
const numericA = /^\d+$/.test(a), numericB = /^\d+$/.test(b);
|
|
52
|
-
if (numericA && numericB) return BigInt(a) < BigInt(b) ? -1 : Number(BigInt(a) > BigInt(b));
|
|
53
|
-
if (numericA !== numericB) return numericA ? -1 : 1;
|
|
54
|
-
return a < b ? -1 : Number(a > b);
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/** SemVer precedence, numeric prerelease identifiers included; build metadata has no precedence. */
|
|
58
|
-
export function compareVersions(a, b) {
|
|
59
|
-
const left = versionParts(ordered(a)), right = versionParts(ordered(b));
|
|
60
|
-
for (const i of left.core.keys()) {
|
|
61
|
-
if (left.core[i] !== right.core[i]) return left.core[i] < right.core[i] ? -1 : 1;
|
|
62
|
-
}
|
|
63
|
-
if (!left.pre.length || !right.pre.length) return Number(!left.pre.length) - Number(!right.pre.length);
|
|
64
|
-
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i += 1) {
|
|
65
|
-
if (left.pre[i] === undefined) return -1;
|
|
66
|
-
if (right.pre[i] === undefined) return 1;
|
|
67
|
-
const compared = compareIdentifier(left.pre[i], right.pre[i]);
|
|
68
|
-
if (compared) return compared;
|
|
69
|
-
}
|
|
70
|
-
return 0;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** The release line of `version`: a prerelease's core and the identifiers before its first number (every alpha.N and alpha.N.M share one), a stable release's major and minor. */
|
|
74
|
-
export function releaseLine(version) {
|
|
75
|
-
const { core, pre } = versionParts(ordered(version));
|
|
76
|
-
if (!pre.length) return `${core.slice(0, 2).join('.')}-stable`;
|
|
77
|
-
const first = pre.findIndex((id) => /^\d+$/.test(id));
|
|
78
|
-
return `${core.join('.')}-${(first < 0 ? pre : pre.slice(0, first)).join('.')}`;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** {family, line} of a release that opens a line (`<x.y.z>-<label>.N`), or null for any other shape. */
|
|
82
|
-
function lineStart(base) {
|
|
83
|
-
if (!readable(base)) return null;
|
|
84
|
-
const { core, pre } = versionParts(base);
|
|
85
|
-
return pre.length === 2 && !/^\d+$/.test(pre[0]) && NUMERIC.test(pre[1]) ? { family: `${core.join('.')}-${pre[0]}`, line: Number(pre[1]) } : null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** The next wave after `version`: its base with M one past the highest wave of that base among `version` and `known`. */
|
|
89
|
-
export function nextWave(version, known = []) {
|
|
90
|
-
const { base } = waveOf(version);
|
|
91
|
-
const waves = [version, ...known].map(waveOf).filter((entry) => entry.base === base).map((entry) => entry.wave);
|
|
92
|
-
return `${base}.${Math.max(0, ...waves) + 1}`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** The family of a version: its core and label up to `-<label>` (`1.0.0-alpha`), or null for a stable or unlabelled version. */
|
|
96
|
-
export function familyOf(version) {
|
|
97
|
-
if (!readable(version)) return null;
|
|
98
|
-
const { core, pre } = versionParts(ordered(version));
|
|
99
|
-
return pre.length && !/^\d+$/.test(pre[0]) ? `${core.join('.')}-${pre[0]}` : null;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* What may follow the highest line-shaped release of `versions`: {highest, family, wave, line}, or null when none has that shape.
|
|
104
|
-
* With a `candidate`, a family that already has released tags is judged alone (another family's tags, such as legacy 2.0.1-alpha.N,
|
|
105
|
-
* never outrank the candidate's own line); a family without a tag falls back to the highest release overall.
|
|
106
|
-
*/
|
|
107
|
-
export function nextReleases(versions, candidate = null) {
|
|
108
|
-
const shaped = versions.filter((version) => readable(version) && lineStart(waveOf(version).base));
|
|
109
|
-
const family = candidate === null ? null : familyOf(candidate);
|
|
110
|
-
const own = family === null ? [] : shaped.filter((version) => lineStart(waveOf(version).base).family === family);
|
|
111
|
-
const pool = own.length ? own : shaped;
|
|
112
|
-
if (!pool.length) return null;
|
|
113
|
-
const highest = pool.reduce((top, version) => (compareVersions(version, top) > 0 ? version : top));
|
|
114
|
-
const start = lineStart(waveOf(highest).base);
|
|
115
|
-
return { highest, family: start.family, wave: nextWave(highest, pool), line: `${start.family}.${start.line + 1}` };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Why `version` cannot be released after `released` (the versions already tagged), or null when it can: the next wave of the highest
|
|
120
|
-
* release of its own family, the first release of the next line, or (when its family has no tag) a later release of another label or
|
|
121
|
-
* core than the highest. The legacy `-fix` form is always refused.
|
|
122
|
-
*/
|
|
123
|
-
export function releaseVersionRefusal(version, released) {
|
|
124
|
-
const next = nextReleases(released.filter((entry) => entry !== version), version);
|
|
125
|
-
const named = next ? `; the next release is ${next.wave} (the next wave or fix after ${next.highest}) or ${next.line} (the next line)` : '';
|
|
126
|
-
if (waveOf(version).legacy) return `${version} is the legacy -fix form, which is read and never released again${named}`;
|
|
127
|
-
if (!next || version === next.wave || version === next.line) return null;
|
|
128
|
-
if (!readable(version)) return `${version} is not a SemVer version${named}`;
|
|
129
|
-
if (lineStart(waveOf(version).base)?.family !== next.family && compareVersions(version, next.highest) > 0) return null;
|
|
130
|
-
return `${version} does not follow ${next.highest}${named}`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** The versions of the release tags `v<version>` in the output of `git tag --list`. */
|
|
134
|
-
export const taggedVersions = (listing) => String(listing ?? '').split(/\r?\n/).map((name) => name.trim()).filter((name) => /^v\d/.test(name)).map((name) => name.slice(1));
|
|
1
|
+
// release-version.mjs - the one reading of a release version (owner ruling release-waves). A prerelease `<label>.N` opens the release
|
|
2
|
+
// line N and `<label>.N.M` is the M-th release after it, a wave or a fix alike, so alpha.31 < alpha.31.1 < alpha.31.2 < alpha.32 in
|
|
3
|
+
// SemVer order. The next release is derived from the highest one already released, never guessed. The legacy `<label>.N-fix.K` still
|
|
4
|
+
// reads as release K after `<label>.N` and is never produced again. Pure: the callers read the tags and the registry.
|
|
5
|
+
|
|
6
|
+
const NUMERIC = /^(?:0|[1-9]\d*)$/;
|
|
7
|
+
const POSITIVE = /^[1-9]\d*$/;
|
|
8
|
+
const IDENTIFIERS = /^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/;
|
|
9
|
+
const LEGACY_FIX = '-fix.';
|
|
10
|
+
|
|
11
|
+
/** {base, wave, legacy}: the release a version counts its waves from, and its wave M (0 for the release that opens the line). */
|
|
12
|
+
export function waveOf(version) {
|
|
13
|
+
const text = String(version ?? '').split('+')[0];
|
|
14
|
+
const fix = text.lastIndexOf(LEGACY_FIX);
|
|
15
|
+
const legacyWave = fix > 0 ? text.slice(fix + LEGACY_FIX.length) : '';
|
|
16
|
+
if (POSITIVE.test(legacyWave)) return { base: text.slice(0, fix), wave: Number(legacyWave), legacy: true };
|
|
17
|
+
const ids = text.split('.');
|
|
18
|
+
const counted = ids.length > 3 && POSITIVE.test(ids.at(-1)) && (text.includes('-') ? NUMERIC.test(ids.at(-2)) : ids.length === 4);
|
|
19
|
+
return counted ? { base: ids.slice(0, -1).join('.'), wave: Number(ids.at(-1)), legacy: false } : { base: text, wave: 0, legacy: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parsed SemVer identifiers {core, pre} of `version`; an unreadable version throws. */
|
|
23
|
+
export function versionParts(version) {
|
|
24
|
+
const parts = typeof version === 'string' ? version.split('+') : [];
|
|
25
|
+
if (!parts.length || parts.length > 2 || (parts.length === 2 && !IDENTIFIERS.test(parts[1]))) throw new Error(`unreadable release version ${version}`);
|
|
26
|
+
const boundary = parts[0].indexOf('-');
|
|
27
|
+
const core = (boundary < 0 ? parts[0] : parts[0].slice(0, boundary)).split('.');
|
|
28
|
+
const prerelease = boundary < 0 ? null : parts[0].slice(boundary + 1);
|
|
29
|
+
if (core.length !== 3 || core.some((part) => !NUMERIC.test(part)) || (prerelease !== null && !IDENTIFIERS.test(prerelease))) throw new Error(`unreadable release version ${version}`);
|
|
30
|
+
const pre = prerelease?.split('.') ?? [];
|
|
31
|
+
if (pre.some((part) => /^0\d+$/.test(part))) throw new Error(`invalid numeric prerelease identifier in ${version}`);
|
|
32
|
+
return { core: core.map(BigInt), pre };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const readable = (version) => {
|
|
36
|
+
try {
|
|
37
|
+
versionParts(version);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The SemVer spelling a version is ordered by: a legacy `-fix.K` orders as wave K of its line. */
|
|
45
|
+
const ordered = (version) => {
|
|
46
|
+
const { base, wave, legacy } = waveOf(version);
|
|
47
|
+
return legacy ? `${base}.${wave}` : version;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const compareIdentifier = (a, b) => {
|
|
51
|
+
const numericA = /^\d+$/.test(a), numericB = /^\d+$/.test(b);
|
|
52
|
+
if (numericA && numericB) return BigInt(a) < BigInt(b) ? -1 : Number(BigInt(a) > BigInt(b));
|
|
53
|
+
if (numericA !== numericB) return numericA ? -1 : 1;
|
|
54
|
+
return a < b ? -1 : Number(a > b);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** SemVer precedence, numeric prerelease identifiers included; build metadata has no precedence. */
|
|
58
|
+
export function compareVersions(a, b) {
|
|
59
|
+
const left = versionParts(ordered(a)), right = versionParts(ordered(b));
|
|
60
|
+
for (const i of left.core.keys()) {
|
|
61
|
+
if (left.core[i] !== right.core[i]) return left.core[i] < right.core[i] ? -1 : 1;
|
|
62
|
+
}
|
|
63
|
+
if (!left.pre.length || !right.pre.length) return Number(!left.pre.length) - Number(!right.pre.length);
|
|
64
|
+
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i += 1) {
|
|
65
|
+
if (left.pre[i] === undefined) return -1;
|
|
66
|
+
if (right.pre[i] === undefined) return 1;
|
|
67
|
+
const compared = compareIdentifier(left.pre[i], right.pre[i]);
|
|
68
|
+
if (compared) return compared;
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The release line of `version`: a prerelease's core and the identifiers before its first number (every alpha.N and alpha.N.M share one), a stable release's major and minor. */
|
|
74
|
+
export function releaseLine(version) {
|
|
75
|
+
const { core, pre } = versionParts(ordered(version));
|
|
76
|
+
if (!pre.length) return `${core.slice(0, 2).join('.')}-stable`;
|
|
77
|
+
const first = pre.findIndex((id) => /^\d+$/.test(id));
|
|
78
|
+
return `${core.join('.')}-${(first < 0 ? pre : pre.slice(0, first)).join('.')}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** {family, line} of a release that opens a line (`<x.y.z>-<label>.N`), or null for any other shape. */
|
|
82
|
+
function lineStart(base) {
|
|
83
|
+
if (!readable(base)) return null;
|
|
84
|
+
const { core, pre } = versionParts(base);
|
|
85
|
+
return pre.length === 2 && !/^\d+$/.test(pre[0]) && NUMERIC.test(pre[1]) ? { family: `${core.join('.')}-${pre[0]}`, line: Number(pre[1]) } : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The next wave after `version`: its base with M one past the highest wave of that base among `version` and `known`. */
|
|
89
|
+
export function nextWave(version, known = []) {
|
|
90
|
+
const { base } = waveOf(version);
|
|
91
|
+
const waves = [version, ...known].map(waveOf).filter((entry) => entry.base === base).map((entry) => entry.wave);
|
|
92
|
+
return `${base}.${Math.max(0, ...waves) + 1}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The family of a version: its core and label up to `-<label>` (`1.0.0-alpha`), or null for a stable or unlabelled version. */
|
|
96
|
+
export function familyOf(version) {
|
|
97
|
+
if (!readable(version)) return null;
|
|
98
|
+
const { core, pre } = versionParts(ordered(version));
|
|
99
|
+
return pre.length && !/^\d+$/.test(pre[0]) ? `${core.join('.')}-${pre[0]}` : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What may follow the highest line-shaped release of `versions`: {highest, family, wave, line}, or null when none has that shape.
|
|
104
|
+
* With a `candidate`, a family that already has released tags is judged alone (another family's tags, such as legacy 2.0.1-alpha.N,
|
|
105
|
+
* never outrank the candidate's own line); a family without a tag falls back to the highest release overall.
|
|
106
|
+
*/
|
|
107
|
+
export function nextReleases(versions, candidate = null) {
|
|
108
|
+
const shaped = versions.filter((version) => readable(version) && lineStart(waveOf(version).base));
|
|
109
|
+
const family = candidate === null ? null : familyOf(candidate);
|
|
110
|
+
const own = family === null ? [] : shaped.filter((version) => lineStart(waveOf(version).base).family === family);
|
|
111
|
+
const pool = own.length ? own : shaped;
|
|
112
|
+
if (!pool.length) return null;
|
|
113
|
+
const highest = pool.reduce((top, version) => (compareVersions(version, top) > 0 ? version : top));
|
|
114
|
+
const start = lineStart(waveOf(highest).base);
|
|
115
|
+
return { highest, family: start.family, wave: nextWave(highest, pool), line: `${start.family}.${start.line + 1}` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Why `version` cannot be released after `released` (the versions already tagged), or null when it can: the next wave of the highest
|
|
120
|
+
* release of its own family, the first release of the next line, or (when its family has no tag) a later release of another label or
|
|
121
|
+
* core than the highest. The legacy `-fix` form is always refused.
|
|
122
|
+
*/
|
|
123
|
+
export function releaseVersionRefusal(version, released) {
|
|
124
|
+
const next = nextReleases(released.filter((entry) => entry !== version), version);
|
|
125
|
+
const named = next ? `; the next release is ${next.wave} (the next wave or fix after ${next.highest}) or ${next.line} (the next line)` : '';
|
|
126
|
+
if (waveOf(version).legacy) return `${version} is the legacy -fix form, which is read and never released again${named}`;
|
|
127
|
+
if (!next || version === next.wave || version === next.line) return null;
|
|
128
|
+
if (!readable(version)) return `${version} is not a SemVer version${named}`;
|
|
129
|
+
if (lineStart(waveOf(version).base)?.family !== next.family && compareVersions(version, next.highest) > 0) return null;
|
|
130
|
+
return `${version} does not follow ${next.highest}${named}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The versions of the release tags `v<version>` in the output of `git tag --list`. */
|
|
134
|
+
export const taggedVersions = (listing) => String(listing ?? '').split(/\r?\n/).map((name) => name.trim()).filter((name) => /^v\d/.test(name)).map((name) => name.slice(1));
|
|
@@ -13,9 +13,7 @@ export interface ClientOperation {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/** The domain work whose JSON result commits with the client receipt. */
|
|
16
|
-
export
|
|
17
|
-
(): Promise<T>
|
|
18
|
-
}
|
|
16
|
+
export type ClientOperationEffect<T> = () => Promise<T>
|
|
19
17
|
|
|
20
18
|
/** The signed provider header may omit its delivery identity; the adapter refuses that delivery. */
|
|
21
19
|
export type InboxEventId = string | undefined
|
|
@@ -27,9 +27,7 @@ export interface DeliveryClaimBackfill extends DeliveryClaim {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** The domain work whose JSON result commits with the client receipt. */
|
|
30
|
-
export
|
|
31
|
-
(): Promise<T>
|
|
32
|
-
}
|
|
30
|
+
export type ClientOperationEffect<T> = () => Promise<T>
|
|
33
31
|
|
|
34
32
|
/** Deduplicates deliveries by the source and event id of their consumer. */
|
|
35
33
|
export interface Inbox {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { EnvSource } from "@modules/platform/config"
|
|
2
|
+
import { REDIS_DEFAULT_PREFIX } from "./realtime.contracts"
|
|
3
|
+
import type { PostgresRealtimeConfig, RealtimeConfig, RedisRealtimeOptions } from "./realtime.options"
|
|
4
|
+
|
|
5
|
+
/** The hubs an app can run. */
|
|
6
|
+
const HUBS = ["memory", "postgres", "redis"] as const
|
|
7
|
+
|
|
8
|
+
/** The Redis hub: its URL (a secret), database and the prefix of its channel and keys. */
|
|
9
|
+
const redisConfigOf = (env: EnvSource): RedisRealtimeOptions => ({
|
|
10
|
+
hub: "redis",
|
|
11
|
+
url: env.secret("REALTIME_REDIS_URL"),
|
|
12
|
+
db: env.int("REALTIME_REDIS_DB", 0),
|
|
13
|
+
prefix: env.optional("REALTIME_REDIS_PREFIX") ?? REDIS_DEFAULT_PREFIX,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
/** The Postgres hub: the dedicated LISTEN URL when `REALTIME_DB_URL` is declared. */
|
|
17
|
+
const postgresConfigOf = (env: EnvSource): PostgresRealtimeConfig => ({
|
|
18
|
+
hub: "postgres",
|
|
19
|
+
listenUrl: env.has("REALTIME_DB_URL") ? env.secret("REALTIME_DB_URL") : undefined,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reads the hub from `REALTIME_HUB`; when it is not declared the full edition defaults to `redis` (the in-process `memory` hub is
|
|
24
|
+
* never a default, so a scaled deploy cannot fall back to it unseen). For the Redis hub it reads the address and prefix. A Postgres
|
|
25
|
+
* hub LISTENs on `REALTIME_DB_URL` when it is declared (a direct or session-mode URL, never a transaction pooler), else on the
|
|
26
|
+
* primary database URL; either way the boot probe refuses a connection that does not deliver notifications.
|
|
27
|
+
*/
|
|
28
|
+
export const parseRealtimeConfig = (env: EnvSource): RealtimeConfig => {
|
|
29
|
+
const hub = env.has("REALTIME_HUB") ? env.enum("REALTIME_HUB", HUBS) : "redis"
|
|
30
|
+
if (hub === "redis") return redisConfigOf(env)
|
|
31
|
+
return hub === "postgres" ? postgresConfigOf(env) : { hub }
|
|
32
|
+
}
|