@isparling/engram-cli 0.1.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/src/submit.ts ADDED
@@ -0,0 +1,228 @@
1
+ // The one operation:
2
+ // submit a candidate against an existing Markdown record
3
+ // -> classify the change as additive or non-additive
4
+ // -> render the complete diff
5
+ // -> gate on approval when non-additive
6
+ // -> write atomically
7
+ // -> refresh qmd once
8
+ // -> report fresh or index-stale
9
+ //
10
+ // Markdown is authoritative, qmd is derived. A refresh failure after a valid
11
+ // write never rolls back that write; it is reported as index-stale and the
12
+ // committed Markdown stands.
13
+ //
14
+ // Approval integrity: `approval_required` carries a `plan_hash` covering
15
+ // BOTH endpoints of the diff — the record bytes read and the bytes that
16
+ // would replace them. A non-additive candidate can only be committed with
17
+ // `approve: true` if `expectHash` matches the plan recomputed at commit
18
+ // time. Anything that changes the mutation invalidates the approval: the
19
+ // record changing on disk, a different candidate being submitted, or a
20
+ // submission date that alters the result.
21
+ //
22
+ // Hashing the record alone was insufficient and shipped as a defect: it
23
+ // caught a changed record but let a caller preview candidate A and then
24
+ // approve candidate B using A's hash, committing a diff never shown.
25
+
26
+ import { lstat, realpath, readFile } from "node:fs/promises";
27
+ import type { Stats } from "node:fs";
28
+ import { sep } from "node:path";
29
+ import { AtomicWriteDirectorySyncError, atomicWriteFile } from "./atomicWrite.ts";
30
+ import { validateCandidate, type Candidate } from "./candidate.ts";
31
+ import type { Classification } from "./classify.ts";
32
+ import { planMutation } from "./classify.ts";
33
+ import { hashPlan } from "./contentHash.ts";
34
+ import { parseRecord } from "./markdownRecord.ts";
35
+ import { REFRESH_NOT_ATTEMPTED, refreshQmdCollection, type FreshnessRefreshReport, type RefreshReport, type SpawnFn } from "./qmdRunner.ts";
36
+ import { resolveRecordPath, type SpaceBinding } from "./spaceBinding.ts";
37
+
38
+ /** Same shape as atomicWriteFile. Overridable so tests can simulate a
39
+ * post-rename directory-fsync failure (AtomicWriteDirectorySyncError)
40
+ * deterministically, the same way qmdRunner's spawnFn lets tests simulate
41
+ * a spawn failure — both are OS-boundary failures that are impractical to
42
+ * trigger reliably through real fault injection in a portable test. */
43
+ export type WriteRecordFn = (targetPath: string, content: string) => Promise<void>;
44
+
45
+ export type SubmitOutcome =
46
+ | {
47
+ schema_version: 0;
48
+ status: "invalid";
49
+ errors: string[];
50
+ refresh: RefreshReport;
51
+ }
52
+ | {
53
+ schema_version: 0;
54
+ status: "approval_required";
55
+ record_id: string;
56
+ classification: "non-additive";
57
+ diff: string;
58
+ plan_hash: string;
59
+ refresh: RefreshReport;
60
+ }
61
+ | {
62
+ schema_version: 0;
63
+ status: "stale_approval";
64
+ record_id: string;
65
+ classification: "non-additive";
66
+ diff: string;
67
+ expected_plan_hash: string;
68
+ actual_plan_hash: string;
69
+ reason: string;
70
+ refresh: RefreshReport;
71
+ }
72
+ | {
73
+ schema_version: 0;
74
+ status: "committed";
75
+ record_id: string;
76
+ classification: Classification;
77
+ diff: string;
78
+ written_path: string;
79
+ refresh: FreshnessRefreshReport;
80
+ };
81
+
82
+ export type SubmitInput = {
83
+ binding: SpaceBinding;
84
+ candidateInput: unknown;
85
+ approve: boolean;
86
+ /** Required when approve is true and the candidate turns out
87
+ * non-additive: must equal the plan_hash from a prior approval_required
88
+ * result, or the commit is refused as stale. */
89
+ expectHash?: string;
90
+ /** YYYY-MM-DD. Passed explicitly (never sampled internally) so results are deterministic under test. */
91
+ submittedAt: string;
92
+ /** Testing seam: overrides the real atomic write. Defaults to atomicWriteFile. */
93
+ writeRecord?: WriteRecordFn;
94
+ /** Testing seam: overrides the real qmd spawn function used for refresh. Defaults to the real child_process.spawn. */
95
+ spawnFn?: SpawnFn;
96
+ };
97
+
98
+ function invalid(errors: string[]): SubmitOutcome {
99
+ return { schema_version: 0, status: "invalid", errors, refresh: REFRESH_NOT_ATTEMPTED };
100
+ }
101
+
102
+ export async function submitCandidate(input: SubmitInput): Promise<SubmitOutcome> {
103
+ const candidateResult = validateCandidate(input.candidateInput);
104
+ if (!candidateResult.ok) {
105
+ return invalid(candidateResult.errors);
106
+ }
107
+ const candidate: Candidate = candidateResult.value;
108
+
109
+ const pathResult = resolveRecordPath(input.binding, candidate.target_id);
110
+ if (!pathResult.ok) {
111
+ return invalid(pathResult.errors);
112
+ }
113
+ const recordPath = pathResult.value;
114
+
115
+ let recordsRoot: string;
116
+ let recordStat: Stats;
117
+ try {
118
+ [recordsRoot, recordStat] = await Promise.all([realpath(input.binding.recordsRoot), lstat(recordPath)]);
119
+ } catch {
120
+ return invalid([`record not found for target_id: ${candidate.target_id}`]);
121
+ }
122
+ if (recordStat.isSymbolicLink()) {
123
+ return invalid([`record target must not be a symbolic link: ${candidate.target_id}`]);
124
+ }
125
+ let canonicalRecordPath: string;
126
+ try {
127
+ canonicalRecordPath = await realpath(recordPath);
128
+ } catch {
129
+ return invalid([`record target could not be resolved: ${candidate.target_id}`]);
130
+ }
131
+ const rootPrefix = recordsRoot.endsWith(sep) ? recordsRoot : recordsRoot + sep;
132
+ if (!canonicalRecordPath.startsWith(rootPrefix)) {
133
+ return invalid([`record target resolves outside the bound records root: ${candidate.target_id}`]);
134
+ }
135
+
136
+ // Read as bytes: the plan hash covers the record exactly as it is on disk,
137
+ // and decoding first would collapse distinct invalid UTF-8 sequences.
138
+ const recordBytes = await readFile(recordPath);
139
+ const recordText = recordBytes.toString("utf8");
140
+
141
+ const recordResult = parseRecord(recordText);
142
+ if (!recordResult.ok) {
143
+ return invalid(recordResult.errors);
144
+ }
145
+
146
+ const planResult = planMutation(recordText, recordResult.value, candidate, input.submittedAt);
147
+ if (!planResult.ok) {
148
+ return invalid(planResult.errors);
149
+ }
150
+ const plan = planResult.value;
151
+
152
+ // Covers destination and both endpoints of the diff, so approval cannot
153
+ // carry across to a different space, a different candidate, a changed
154
+ // record, or a different submission date.
155
+ const planHash = hashPlan(recordPath, recordBytes, plan.afterText);
156
+
157
+ if (plan.classification === "non-additive") {
158
+ if (!input.approve) {
159
+ return {
160
+ schema_version: 0,
161
+ status: "approval_required",
162
+ record_id: candidate.target_id,
163
+ classification: "non-additive",
164
+ diff: plan.diff,
165
+ plan_hash: planHash,
166
+ refresh: REFRESH_NOT_ATTEMPTED,
167
+ };
168
+ }
169
+
170
+ if (input.expectHash === undefined) {
171
+ return invalid([
172
+ "--approve on a non-additive candidate requires --expect <hash>, using the plan_hash from a prior approval_required result",
173
+ ]);
174
+ }
175
+
176
+ if (input.expectHash !== planHash) {
177
+ return {
178
+ schema_version: 0,
179
+ status: "stale_approval",
180
+ record_id: candidate.target_id,
181
+ classification: "non-additive",
182
+ diff: plan.diff,
183
+ expected_plan_hash: input.expectHash,
184
+ actual_plan_hash: planHash,
185
+ reason:
186
+ "the mutation is not the one that was approved; the record changed on disk, " +
187
+ "the candidate differs from the one previewed, or the submission date alters the result",
188
+ refresh: REFRESH_NOT_ATTEMPTED,
189
+ };
190
+ }
191
+ }
192
+
193
+ const writeRecord = input.writeRecord ?? atomicWriteFile;
194
+
195
+ try {
196
+ await writeRecord(recordPath, plan.afterText);
197
+ } catch (error) {
198
+ if (error instanceof AtomicWriteDirectorySyncError) {
199
+ return {
200
+ schema_version: 0,
201
+ status: "committed",
202
+ record_id: candidate.target_id,
203
+ classification: plan.classification,
204
+ diff: plan.diff,
205
+ written_path: recordPath,
206
+ refresh: {
207
+ attempted: false,
208
+ count: 0,
209
+ state: "index-stale",
210
+ detail: `refresh skipped: ${error.message}`,
211
+ },
212
+ };
213
+ }
214
+ throw error;
215
+ }
216
+
217
+ const refresh = await refreshQmdCollection(input.binding, input.spawnFn);
218
+
219
+ return {
220
+ schema_version: 0,
221
+ status: "committed",
222
+ record_id: candidate.target_id,
223
+ classification: plan.classification,
224
+ diff: plan.diff,
225
+ written_path: recordPath,
226
+ refresh,
227
+ };
228
+ }
@@ -0,0 +1,71 @@
1
+ // Symlink guard: a symlink inside the records root that resolves outside it is
2
+ // refused before any scanning qmd command runs.
3
+ //
4
+ // qmd scans collections with `followSymlinks: true` (qmd.ts's indexFiles,
5
+ // via Glob.scan). A symlink inside the bound records root that resolves
6
+ // outside it would therefore get indexed into the bound space's qmd
7
+ // collection — silently widening what "the bound space" actually covers.
8
+ // This walk runs before any qmd command that scans the filesystem
9
+ // (`collection add`, `update`) and refuses if it finds one. Records roots
10
+ // are small synthetic fixtures/spaces, so a full walk on every such call is
11
+ // cheap; this is not meant to scale to large corpora.
12
+
13
+ import { readdir, realpath } from "node:fs/promises";
14
+ import { join, sep } from "node:path";
15
+ import { realOrResolvedPath } from "./realPath.ts";
16
+ import { err, ok, type Result } from "./types.ts";
17
+
18
+ export async function verifyNoSymlinkEscape(recordsRoot: string): Promise<Result<void>> {
19
+ // A symlink target is always fully resolved by realpath(), so the root
20
+ // it's compared against must be too, or a symlinked path component
21
+ // (macOS's /var -> /private/var, which every OS temp directory lives
22
+ // under) makes an entirely-internal symlink look like an escape.
23
+ const resolvedRoot = await realOrResolvedPath(recordsRoot);
24
+ const rootWithSep = resolvedRoot.endsWith(sep) ? resolvedRoot : resolvedRoot + sep;
25
+ const errors: string[] = [];
26
+
27
+ async function walk(dir: string): Promise<void> {
28
+ let entries;
29
+ try {
30
+ entries = await readdir(dir, { withFileTypes: true });
31
+ } catch (error) {
32
+ errors.push(`failed to read directory while checking for escaping symlinks: ${dir} (${describeError(error)})`);
33
+ return;
34
+ }
35
+
36
+ for (const entry of entries) {
37
+ const entryPath = join(dir, entry.name);
38
+
39
+ if (entry.isSymbolicLink()) {
40
+ let target: string;
41
+ try {
42
+ target = await realpath(entryPath);
43
+ } catch (error) {
44
+ errors.push(`symlink could not be resolved: ${entryPath} (${describeError(error)})`);
45
+ continue;
46
+ }
47
+ if (target !== resolvedRoot && !target.startsWith(rootWithSep)) {
48
+ errors.push(`symlink escapes the bound records root: ${entryPath} -> ${target}`);
49
+ }
50
+ // Do not follow into (or through) a symlink even if it happens to
51
+ // resolve inside the root — only plain directories are walked
52
+ // further, so a symlink cannot be used to reintroduce a deeper
53
+ // escaping symlink under a name this walk already accepted.
54
+ continue;
55
+ }
56
+
57
+ if (entry.isDirectory()) {
58
+ await walk(entryPath);
59
+ }
60
+ }
61
+ }
62
+
63
+ await walk(resolvedRoot);
64
+
65
+ if (errors.length > 0) return err(errors);
66
+ return ok(undefined);
67
+ }
68
+
69
+ function describeError(error: unknown): string {
70
+ return error instanceof Error ? error.message : String(error);
71
+ }
@@ -0,0 +1,188 @@
1
+ import { link, mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { hostname } from "node:os";
3
+ import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import type { ActiveSpace } from "./spaceRegistry.ts";
6
+ import type { KnowledgeResult } from "./knowledgeTypes.ts";
7
+
8
+ type LockOwner = {
9
+ schema_version: 0;
10
+ pid: number;
11
+ hostname: string;
12
+ token: string;
13
+ };
14
+
15
+ export type TransactionLock = {
16
+ state: "acquired" | "recovered";
17
+ release: () => Promise<void>;
18
+ };
19
+
20
+ export type TransactionLockHooks = {
21
+ afterExistingOwnerRead?: () => Promise<void>;
22
+ };
23
+
24
+ export function transactionLockDirectory(binding: ActiveSpace): string {
25
+ return join(binding.spaceRoot, ".engram-knowledge-transaction.lock");
26
+ }
27
+
28
+ function lockError<T>(code: string, message: string): KnowledgeResult<T> {
29
+ return { ok: false, errors: [{ kind: "lock", code, message }] };
30
+ }
31
+
32
+ function isObject(value: unknown): value is Record<string, unknown> {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+
36
+ function errorCode(error: unknown): string | undefined {
37
+ return isObject(error) && typeof error.code === "string" ? error.code : undefined;
38
+ }
39
+
40
+ async function readOwner(path: string): Promise<KnowledgeResult<LockOwner>> {
41
+ try {
42
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
43
+ if (!isObject(parsed) || parsed.schema_version !== 0 || typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.hostname !== "string" || parsed.hostname.length === 0 || typeof parsed.token !== "string" || parsed.token.length === 0) {
44
+ return { ok: false, errors: [{ kind: "lock", code: "lock_owner_unverifiable", message: "transaction lock owner metadata is malformed" }] };
45
+ }
46
+ return { ok: true, value: { schema_version: 0, pid: parsed.pid, hostname: parsed.hostname, token: parsed.token } };
47
+ } catch (error) {
48
+ return { ok: false, errors: [{ kind: "lock", code: "lock_owner_unverifiable", message: `transaction lock owner metadata could not be read: ${error instanceof Error ? error.message : String(error)}` }] };
49
+ }
50
+ }
51
+
52
+ function processState(pid: number): "live" | "absent" | "unknown" {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return "live";
56
+ } catch (error) {
57
+ return errorCode(error) === "ESRCH" ? "absent" : "unknown";
58
+ }
59
+ }
60
+
61
+ async function installExclusiveOwnerMetadata(ownerPath: string, owner: LockOwner): Promise<KnowledgeResult<boolean>> {
62
+ const candidatePath = `${ownerPath}.candidate-${owner.pid}-${owner.token}`;
63
+ try {
64
+ await writeFile(candidatePath, JSON.stringify(owner), { encoding: "utf8", flag: "wx", mode: 0o600 });
65
+ try {
66
+ await link(candidatePath, ownerPath);
67
+ return { ok: true, value: true };
68
+ } catch (error) {
69
+ if (errorCode(error) === "EEXIST") return { ok: true, value: false };
70
+ return { ok: false, errors: [{ kind: "lock", code: "lock_install_failed", message: `transaction lock owner metadata could not be installed: ${error instanceof Error ? error.message : String(error)}` }] };
71
+ }
72
+ } catch (error) {
73
+ return { ok: false, errors: [{ kind: "lock", code: "lock_install_failed", message: `transaction lock owner metadata could not be prepared: ${error instanceof Error ? error.message : String(error)}` }] };
74
+ } finally {
75
+ await unlink(candidatePath).catch(() => {});
76
+ }
77
+ }
78
+
79
+ async function installLock(path: string, state: "acquired" | "recovered"): Promise<KnowledgeResult<TransactionLock>> {
80
+ const owner: LockOwner = { schema_version: 0, pid: process.pid, hostname: hostname(), token: randomUUID() };
81
+ const installed = await installExclusiveOwnerMetadata(join(path, "owner.json"), owner);
82
+ if (!installed.ok) return installed;
83
+ if (!installed.value) return lockError("lock_install_failed", "transaction lock owner metadata already exists");
84
+ return {
85
+ ok: true,
86
+ value: {
87
+ state,
88
+ release: async () => {
89
+ const current = await readOwner(join(path, "owner.json"));
90
+ if (!current.ok || current.value.token !== owner.token) return;
91
+ await rm(path, { recursive: true, force: false }).catch(() => {});
92
+ },
93
+ },
94
+ };
95
+ }
96
+
97
+ async function clearStaleRecoveryMarker(path: string): Promise<KnowledgeResult<void>> {
98
+ try {
99
+ await stat(path);
100
+ } catch (error) {
101
+ if (errorCode(error) === "ENOENT") return { ok: true, value: undefined };
102
+ return { ok: false, errors: [{ kind: "lock", code: "lock_recovery_failed", message: "transaction lock recovery marker could not be inspected" }] };
103
+ }
104
+
105
+ const owner = await readOwner(path);
106
+ if (!owner.ok) return { ok: false, errors: owner.errors };
107
+ if (owner.value.hostname !== hostname()) {
108
+ return lockError("lock_conflict", `transaction lock recovery is held by another host (${owner.value.hostname})`);
109
+ }
110
+ const state = processState(owner.value.pid);
111
+ if (state === "live") return lockError("lock_conflict", `transaction lock recovery is held by process ${owner.value.pid}`);
112
+ if (state === "unknown") return lockError("lock_owner_unverifiable", `transaction lock recovery owner process ${owner.value.pid} cannot be verified as absent`);
113
+ try {
114
+ await unlink(path);
115
+ return { ok: true, value: undefined };
116
+ } catch (error) {
117
+ if (errorCode(error) === "ENOENT") return { ok: true, value: undefined };
118
+ return { ok: false, errors: [{ kind: "lock", code: "lock_recovery_failed", message: "transaction lock recovery marker changed while it was being inspected" }] };
119
+ }
120
+ }
121
+
122
+ async function recoverProvenStaleLock(
123
+ path: string,
124
+ recoveryPath: string,
125
+ expectedOwner: LockOwner,
126
+ ): Promise<KnowledgeResult<void>> {
127
+ const recoveryOwner: LockOwner = { schema_version: 0, pid: process.pid, hostname: hostname(), token: randomUUID() };
128
+ const installed = await installExclusiveOwnerMetadata(recoveryPath, recoveryOwner);
129
+ if (!installed.ok) return { ok: false, errors: installed.errors };
130
+ if (!installed.value) return { ok: false, errors: [{ kind: "lock", code: "lock_conflict", message: "transaction lock stale recovery is already in progress" }] };
131
+
132
+ try {
133
+ const current = await readOwner(join(path, "owner.json"));
134
+ if (!current.ok) return { ok: false, errors: current.errors };
135
+ if (current.value.pid !== expectedOwner.pid || current.value.hostname !== expectedOwner.hostname || current.value.token !== expectedOwner.token) {
136
+ return { ok: false, errors: [{ kind: "lock", code: "lock_conflict", message: "transaction lock ownership changed during stale recovery" }] };
137
+ }
138
+ if (current.value.hostname !== hostname()) {
139
+ return { ok: false, errors: [{ kind: "lock", code: "lock_conflict", message: `transaction lock changed to another host (${current.value.hostname}) during stale recovery` }] };
140
+ }
141
+ const state = processState(current.value.pid);
142
+ if (state === "live") return { ok: false, errors: [{ kind: "lock", code: "lock_conflict", message: `transaction lock owner process ${current.value.pid} became live during stale recovery` }] };
143
+ if (state === "unknown") return { ok: false, errors: [{ kind: "lock", code: "lock_owner_unverifiable", message: `transaction lock owner process ${current.value.pid} cannot be verified as absent` }] };
144
+ try {
145
+ await rm(path, { recursive: true, force: false });
146
+ return { ok: true, value: undefined };
147
+ } catch (error) {
148
+ return { ok: false, errors: [{ kind: "lock", code: "lock_recovery_failed", message: `transaction lock stale recovery failed: ${error instanceof Error ? error.message : String(error)}` }] };
149
+ }
150
+ } finally {
151
+ const currentRecoveryOwner = await readOwner(recoveryPath);
152
+ if (currentRecoveryOwner.ok && currentRecoveryOwner.value.token === recoveryOwner.token) await unlink(recoveryPath).catch(() => {});
153
+ }
154
+ }
155
+
156
+ export async function acquireTransactionLock(binding: ActiveSpace, hooks?: TransactionLockHooks): Promise<KnowledgeResult<TransactionLock>> {
157
+ const path = transactionLockDirectory(binding);
158
+ const recoveryPath = `${path}.recovery`;
159
+ try {
160
+ await mkdir(path);
161
+ return installLock(path, "acquired");
162
+ } catch (error) {
163
+ if (errorCode(error) !== "EEXIST") {
164
+ return lockError("lock_acquire_failed", `transaction lock could not be acquired: ${error instanceof Error ? error.message : String(error)}`);
165
+ }
166
+ }
167
+
168
+ const cleared = await clearStaleRecoveryMarker(recoveryPath);
169
+ if (!cleared.ok) return cleared;
170
+ const owner = await readOwner(join(path, "owner.json"));
171
+ if (!owner.ok) return owner;
172
+ if (hooks?.afterExistingOwnerRead !== undefined) await hooks.afterExistingOwnerRead();
173
+ if (owner.value.hostname !== hostname()) {
174
+ return lockError("lock_conflict", `transaction lock is held by another host (${owner.value.hostname})`);
175
+ }
176
+ const state = processState(owner.value.pid);
177
+ if (state === "live") return lockError("lock_conflict", `transaction lock is held by process ${owner.value.pid}`);
178
+ if (state === "unknown") return lockError("lock_owner_unverifiable", `transaction lock owner process ${owner.value.pid} cannot be verified as absent`);
179
+
180
+ const recovered = await recoverProvenStaleLock(path, recoveryPath, owner.value);
181
+ if (!recovered.ok) return recovered;
182
+ try {
183
+ await mkdir(path);
184
+ } catch (error) {
185
+ return lockError("lock_conflict", `transaction lock changed during stale recovery: ${error instanceof Error ? error.message : String(error)}`);
186
+ }
187
+ return installLock(path, "recovered");
188
+ }
package/src/types.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Shared result, error, and helper types for the harness runtime.
2
+ //
3
+ // Shared hand-written validation result type. No schema library is used
4
+ // (zero runtime dependencies); every validating function in this package
5
+ // returns one of these instead of throwing on bad input.
6
+
7
+ export type Result<T> = { ok: true; value: T } | { ok: false; errors: string[] };
8
+
9
+ export function ok<T>(value: T): Result<T> {
10
+ return { ok: true, value };
11
+ }
12
+
13
+ export function err<T>(errors: string[]): Result<T> {
14
+ return { ok: false, errors };
15
+ }
16
+
17
+ /**
18
+ * Structural stand-in for NodeJS.ProcessEnv that doesn't require importing
19
+ * Node's ambient global type or casting object literals to it in tests.
20
+ * `process.env` is structurally assignable to this without a cast.
21
+ */
22
+ export type EnvLike = Record<string, string | undefined>;
23
+
24
+ /**
25
+ * Use in place of the non-null assertion operator (`!`) wherever a value is
26
+ * known by loop/validation invariant to be defined but the type checker
27
+ * cannot prove it (array indexing without noUncheckedIndexedAccess, regex
28
+ * capture groups, Map.get after a prior .has check, etc.). Unlike `!`,
29
+ * this fails loudly with a descriptive error if the invariant is ever
30
+ * actually violated, instead of silently producing `undefined` typed as
31
+ * the non-optional type.
32
+ */
33
+ export function requireDefined<T>(value: T | undefined, message: string): T {
34
+ if (value === undefined) {
35
+ throw new Error(`internal invariant violated: ${message}`);
36
+ }
37
+ return value;
38
+ }