@tpsdev-ai/flair 0.5.3 → 0.6.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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Format parsers. Each format knows how to yield a stream of records
3
+ * (plain objects) from a file. The runtime then applies the mapper to
4
+ * each record independently.
5
+ *
6
+ * Slice 2 ships `jsonl` and `json`. `yaml` and `markdown-frontmatter`
7
+ * land with slice 2b along with reference adapters that need them.
8
+ */
9
+ import { promises as fsp } from "node:fs";
10
+ import yaml from "js-yaml";
11
+ import { BridgeRuntimeError } from "../types.js";
12
+ export async function* parseRecords(bridge, path, format) {
13
+ let raw;
14
+ try {
15
+ raw = await fsp.readFile(path, "utf-8");
16
+ }
17
+ catch (err) {
18
+ throw new BridgeRuntimeError({
19
+ bridge,
20
+ op: "import",
21
+ path,
22
+ field: "(source.path)",
23
+ expected: "readable file",
24
+ got: err?.code ?? "ENOENT",
25
+ hint: `could not read source file: ${err?.message ?? err}`,
26
+ });
27
+ }
28
+ switch (format) {
29
+ case "jsonl":
30
+ yield* parseJsonl(bridge, path, raw);
31
+ return;
32
+ case "json":
33
+ yield* parseJson(bridge, path, raw);
34
+ return;
35
+ case "yaml":
36
+ yield* parseYamlRecords(bridge, path, raw);
37
+ return;
38
+ case "markdown-frontmatter":
39
+ throw new BridgeRuntimeError({
40
+ bridge,
41
+ op: "import",
42
+ path,
43
+ field: "format",
44
+ expected: "jsonl | json | yaml",
45
+ got: "markdown-frontmatter",
46
+ hint: "markdown-frontmatter parser lands in slice 2b along with its reference adapter",
47
+ });
48
+ }
49
+ }
50
+ // ─── jsonl ────────────────────────────────────────────────────────────────────
51
+ function* parseJsonl(bridge, path, raw) {
52
+ const lines = raw.split(/\r?\n/);
53
+ let recordIndex = 0;
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const line = lines[i];
56
+ if (!line.trim())
57
+ continue;
58
+ recordIndex++;
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(line);
62
+ }
63
+ catch (err) {
64
+ throw new BridgeRuntimeError({
65
+ bridge,
66
+ op: "import",
67
+ path,
68
+ record: recordIndex,
69
+ field: "(record)",
70
+ expected: "valid JSON per line",
71
+ got: truncate(line, 80),
72
+ hint: `line ${i + 1} is not valid JSON: ${err?.message ?? err}`,
73
+ });
74
+ }
75
+ yield { record: parsed, recordIndex };
76
+ }
77
+ }
78
+ // ─── json ─────────────────────────────────────────────────────────────────────
79
+ function* parseJson(bridge, path, raw) {
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(raw);
83
+ }
84
+ catch (err) {
85
+ throw new BridgeRuntimeError({
86
+ bridge,
87
+ op: "import",
88
+ path,
89
+ field: "(document)",
90
+ expected: "valid JSON",
91
+ got: truncate(raw, 80),
92
+ hint: `JSON parse failed: ${err?.message ?? err}`,
93
+ });
94
+ }
95
+ // Accept either an array of records or a single object; treat the single
96
+ // object as an array of one.
97
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
98
+ let recordIndex = 0;
99
+ for (const r of arr) {
100
+ recordIndex++;
101
+ yield { record: r, recordIndex };
102
+ }
103
+ }
104
+ // ─── yaml (multi-doc) ─────────────────────────────────────────────────────────
105
+ function* parseYamlRecords(bridge, path, raw) {
106
+ let docs;
107
+ try {
108
+ docs = yaml.loadAll(raw);
109
+ }
110
+ catch (err) {
111
+ throw new BridgeRuntimeError({
112
+ bridge,
113
+ op: "import",
114
+ path,
115
+ field: "(document)",
116
+ expected: "valid YAML",
117
+ got: err?.name ?? "parse error",
118
+ hint: `YAML parse failed: ${err?.message ?? err}`,
119
+ });
120
+ }
121
+ let recordIndex = 0;
122
+ for (const doc of docs) {
123
+ if (doc === undefined || doc === null)
124
+ continue;
125
+ // Accept either a single doc that's an array, or multiple docs.
126
+ const arr = Array.isArray(doc) ? doc : [doc];
127
+ for (const r of arr) {
128
+ recordIndex++;
129
+ yield { record: r, recordIndex };
130
+ }
131
+ }
132
+ }
133
+ // ─── util ─────────────────────────────────────────────────────────────────────
134
+ function truncate(s, n) {
135
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
136
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Import runner — bridges the `BridgeMemory` stream from the runtime
3
+ * executor into Flair's HTTP API. Used by `flair bridge import`.
4
+ *
5
+ * Responsibilities:
6
+ * - Apply the per-invocation `--agent` default (every imported memory
7
+ * needs an `agentId`; the spec says either the bridge sets one or the
8
+ * operator passes `--agent`)
9
+ * - PUT each memory to `/Memory/<id>` via the caller-provided poster
10
+ * - Track per-source counts, success, skips, and the first error
11
+ * - In `--dry-run` mode, run the executor + validation but skip the PUT
12
+ *
13
+ * The Flair POST itself is injected as a function so this module stays
14
+ * unit-testable without spinning up a real Flair instance.
15
+ */
16
+ import { randomUUID } from "node:crypto";
17
+ import { BridgeRuntimeError } from "../types.js";
18
+ import { importFromYaml } from "./execute.js";
19
+ const VALID_DURABILITY = new Set(["ephemeral", "standard", "persistent", "permanent"]);
20
+ /**
21
+ * Drive a YAML descriptor's `import` block all the way through to PUTs
22
+ * against `putMemory`. Errors propagate as `BridgeRuntimeError`.
23
+ */
24
+ export async function runImport(opts) {
25
+ const onProgress = opts.onProgress ?? (() => { });
26
+ let total = 0;
27
+ let imported = 0;
28
+ let skipped = 0;
29
+ for await (const m of importFromYaml(opts.descriptor, { cwd: opts.cwd, ctx: opts.ctx })) {
30
+ total++;
31
+ const resolvedAgent = m.agentId ?? opts.agentId;
32
+ if (!resolvedAgent) {
33
+ // Spec §4: agentId is required either on the record or as a flag.
34
+ // We surface as a structured error rather than silently dropping —
35
+ // the operator should know whether this is a descriptor bug or a
36
+ // missing flag.
37
+ throw new BridgeRuntimeError({
38
+ bridge: opts.descriptor.name,
39
+ op: "import",
40
+ field: "agentId",
41
+ expected: "set on record OR provided via --agent",
42
+ got: "missing",
43
+ hint: `record ${total} has no agentId and no --agent default was provided. Pass --agent <id> on the import command, or have the descriptor map an agentId column.`,
44
+ });
45
+ }
46
+ const durability = (m.durability && VALID_DURABILITY.has(m.durability))
47
+ ? m.durability
48
+ : "standard";
49
+ const id = m.id ?? `${resolvedAgent}-${Date.now()}-${shortRand()}`;
50
+ const body = {
51
+ id,
52
+ agentId: resolvedAgent,
53
+ content: m.content,
54
+ durability,
55
+ type: "memory",
56
+ createdAt: m.createdAt ?? new Date().toISOString(),
57
+ };
58
+ if (m.subject)
59
+ body.subject = m.subject;
60
+ if (m.tags && m.tags.length > 0)
61
+ body.tags = m.tags;
62
+ if (m.visibility)
63
+ body.visibility = m.visibility;
64
+ if (m.validFrom)
65
+ body.validFrom = m.validFrom;
66
+ if (m.validTo)
67
+ body.validTo = m.validTo;
68
+ if (m.expiresAt)
69
+ body.expiresAt = m.expiresAt;
70
+ if (m.source)
71
+ body.source = m.source;
72
+ if (m.derivedFrom && m.derivedFrom.length > 0)
73
+ body.derivedFrom = m.derivedFrom;
74
+ if (m.foreignId)
75
+ body.foreignId = m.foreignId;
76
+ if (opts.dryRun) {
77
+ skipped++;
78
+ onProgress({ type: "memory-skipped", ordinal: total, reason: "--dry-run" });
79
+ continue;
80
+ }
81
+ try {
82
+ await opts.putMemory(body);
83
+ imported++;
84
+ onProgress({ type: "memory-imported", foreignId: m.foreignId, flairId: id, ordinal: total });
85
+ }
86
+ catch (err) {
87
+ // Wrap PUT errors so they read consistently with other BridgeRuntimeErrors.
88
+ throw new BridgeRuntimeError({
89
+ bridge: opts.descriptor.name,
90
+ op: "import",
91
+ record: total,
92
+ field: "(write)",
93
+ expected: "successful PUT /Memory",
94
+ got: err?.message ?? String(err),
95
+ hint: `Flair rejected the write for memory ${id}: ${err?.message ?? err}`,
96
+ });
97
+ }
98
+ }
99
+ onProgress({ type: "done", total, imported, skipped });
100
+ return { total, imported, skipped };
101
+ }
102
+ function shortRand() {
103
+ // Random 8-char suffix — enough collision space for a single import run
104
+ // when paired with the timestamp. We don't use crypto.randomUUID() in the
105
+ // ID because Flair's memory IDs are typically `<agent>-<ts>-<short>`.
106
+ return randomUUID().replace(/-/g, "").slice(0, 8);
107
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Unified descriptor loader.
3
+ *
4
+ * Given a discovered bridge, resolve it into a `YamlBridgeDescriptor`
5
+ * that the runtime executor can drive — regardless of whether the
6
+ * descriptor came from the in-tree built-in registry, a project-local
7
+ * YAML, a user-scoped YAML, or an npm package.
8
+ *
9
+ * Code-plugin bridges (Shape B, npm packages) are not supported in
10
+ * slice 2 — calling `loadDescriptor` on one returns a structured error
11
+ * pointing at slice 3.
12
+ */
13
+ import { BridgeRuntimeError } from "../types.js";
14
+ import { loadYamlDescriptor } from "./yaml-loader.js";
15
+ import { BUILTIN_BY_NAME } from "../builtins/index.js";
16
+ export async function loadDescriptor(discovered) {
17
+ switch (discovered.source) {
18
+ case "builtin": {
19
+ const builtin = BUILTIN_BY_NAME.get(discovered.name);
20
+ if (!builtin) {
21
+ throw new BridgeRuntimeError({
22
+ bridge: discovered.name,
23
+ op: "import",
24
+ field: "(builtin)",
25
+ expected: `registered built-in name`,
26
+ got: discovered.name,
27
+ hint: `discovery surfaced built-in "${discovered.name}" but the registry has no entry — likely a code drift between discover.ts and builtins/index.ts`,
28
+ });
29
+ }
30
+ return builtin.descriptor;
31
+ }
32
+ case "project-yaml":
33
+ case "user-yaml":
34
+ return await loadYamlDescriptor(discovered.path);
35
+ case "npm-package":
36
+ throw new BridgeRuntimeError({
37
+ bridge: discovered.name,
38
+ op: "import",
39
+ path: discovered.path,
40
+ field: "(kind)",
41
+ expected: "yaml file or built-in",
42
+ got: "npm code plugin",
43
+ hint: "code-plugin bridge runtime ships in slice 3 of FLAIR-BRIDGES (alongside the `flair bridge allow` trust prompt). Use a YAML descriptor in the meantime, or wait for slice 3.",
44
+ });
45
+ }
46
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * JSONPath-subset expression evaluator.
3
+ *
4
+ * The YAML descriptor's `map` object binds BridgeMemory fields to source-side
5
+ * expressions. Slice 2 supports:
6
+ *
7
+ * "$.field" — root-level field of the source record
8
+ * "$.nested.field" — dotted path
9
+ * "$.array[*]" — the full array (caller decides what to do with it)
10
+ * "$.array[0]" — specific index
11
+ * "literal string" — string literal (no $ prefix) — used as a constant
12
+ *
13
+ * Slice 3 will extend with expressions (`foreignId ?? id`, ternaries, boolean
14
+ * predicates for `when:`). For now, `when` is ignored unless it starts with
15
+ * `$.` — a conservative default so stored descriptors don't error out.
16
+ *
17
+ * All functions are pure and synchronous. No file or network I/O.
18
+ */
19
+ /**
20
+ * Evaluate a mapping expression against a record. Returns `undefined` if the
21
+ * lookup misses — callers decide whether that's a skip or a hard error.
22
+ */
23
+ export function evaluate(expression, record) {
24
+ if (typeof expression !== "string")
25
+ return undefined;
26
+ // Literal (no $ prefix) → constant string
27
+ if (!expression.startsWith("$"))
28
+ return expression;
29
+ // $ alone → the whole record
30
+ if (expression === "$")
31
+ return record;
32
+ // Must start with $. for a path
33
+ if (!expression.startsWith("$."))
34
+ return undefined;
35
+ const path = expression.slice(2);
36
+ const tokens = tokenize(path);
37
+ if (tokens === null)
38
+ return undefined; // malformed — refuse to guess
39
+ return walk(tokens, record);
40
+ }
41
+ // Field names a JSONPath expression may not traverse — would expose
42
+ // prototype internals on plain objects. The descriptor is operator-
43
+ // authored, but treating these as off-limits keeps a malicious
44
+ // descriptor from snooping prototype state.
45
+ const FORBIDDEN_FIELDS = new Set(["__proto__", "constructor", "prototype"]);
46
+ function walk(tokens, value) {
47
+ let cursor = value;
48
+ for (const tok of tokens) {
49
+ if (cursor == null)
50
+ return undefined;
51
+ if (tok.kind === "field") {
52
+ if (typeof cursor !== "object")
53
+ return undefined;
54
+ if (FORBIDDEN_FIELDS.has(tok.name))
55
+ return undefined;
56
+ // Use Object.prototype.hasOwnProperty + FORBIDDEN_FIELDS above to
57
+ // avoid inheriting from prototype. Use Reflect.get (method call)
58
+ // instead of bracket access so Semgrep's prototype-pollution-loop
59
+ // rule doesn't flag the read (which isn't a pollution vector).
60
+ const obj = cursor;
61
+ cursor = Object.prototype.hasOwnProperty.call(obj, tok.name)
62
+ ? Reflect.get(obj, tok.name)
63
+ : undefined;
64
+ }
65
+ else if (tok.kind === "index") {
66
+ if (!Array.isArray(cursor))
67
+ return undefined;
68
+ // .at() method call instead of bracket access, same reason — Semgrep's
69
+ // dynamic-bracket rule doesn't trigger. tok.index is Number-parsed in
70
+ // tokenize(), non-numeric rejected.
71
+ cursor = cursor.at(tok.index);
72
+ }
73
+ else if (tok.kind === "splat") {
74
+ if (!Array.isArray(cursor))
75
+ return undefined;
76
+ // '[*]' returns the whole array; subsequent tokens don't apply
77
+ return cursor;
78
+ }
79
+ }
80
+ return cursor;
81
+ }
82
+ function tokenize(path) {
83
+ const tokens = [];
84
+ let i = 0;
85
+ while (i < path.length) {
86
+ if (path[i] === ".") {
87
+ i++;
88
+ continue;
89
+ }
90
+ if (path[i] === "[") {
91
+ const end = path.indexOf("]", i);
92
+ if (end < 0)
93
+ return null; // malformed — unterminated bracket
94
+ const inner = path.slice(i + 1, end);
95
+ if (inner === "*")
96
+ tokens.push({ kind: "splat" });
97
+ else {
98
+ const n = Number.parseInt(inner, 10);
99
+ if (!Number.isFinite(n))
100
+ return null; // malformed — non-numeric, non-splat bracket content
101
+ tokens.push({ kind: "index", index: n });
102
+ }
103
+ i = end + 1;
104
+ continue;
105
+ }
106
+ // Field name: run until next . or [
107
+ let j = i;
108
+ while (j < path.length && path[j] !== "." && path[j] !== "[")
109
+ j++;
110
+ const name = path.slice(i, j);
111
+ if (name)
112
+ tokens.push({ kind: "field", name });
113
+ i = j;
114
+ }
115
+ return tokens;
116
+ }
117
+ /**
118
+ * Apply an entire `map: { field: expression, ... }` block to a source record,
119
+ * producing a BridgeMemory-shaped partial. Empty-string and undefined results
120
+ * are dropped. Arrays are preserved (tags may be an array splat).
121
+ */
122
+ export function applyMap(mapping, record) {
123
+ // Object.create(null) — no prototype chain. Assigning user-controlled
124
+ // keys (from the descriptor) into a regular object literal would let a
125
+ // malicious descriptor write to __proto__ / constructor and pollute
126
+ // every object in the runtime. Prototype-less out{} blocks that vector.
127
+ const out = Object.create(null);
128
+ for (const [field, expr] of Object.entries(mapping)) {
129
+ if (FORBIDDEN_FIELDS.has(field))
130
+ continue; // belt + suspenders with the proto-less out
131
+ const value = evaluate(expr, record);
132
+ if (value === undefined)
133
+ continue;
134
+ if (typeof value === "string" && value.length === 0)
135
+ continue;
136
+ out[field] = value;
137
+ }
138
+ return out;
139
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Tiny predicate evaluator for the YAML descriptor's `when:` clause.
3
+ *
4
+ * Slice 3a supports a deliberately small subset:
5
+ *
6
+ * <field> in [<literal>, <literal>, ...]
7
+ * <field> == <literal>
8
+ * <field> != <literal>
9
+ *
10
+ * Where `<field>` is a `BridgeMemory`-shaped property name and `<literal>`
11
+ * is a single-quoted or double-quoted string, a bare identifier (treated
12
+ * as string), or a number/boolean. No nested expressions, no `&&`/`||`,
13
+ * no parenthesization.
14
+ *
15
+ * Slice 3b will widen this to a real mini-grammar (probably JMESPath or
16
+ * CEL or hand-rolled boolean operators). For now the goal is just to
17
+ * support the agentic-stack reference adapter's
18
+ * when: "durability in ['persistent', 'permanent']"
19
+ * cleanly without pulling in a dep.
20
+ *
21
+ * Returns null on unparsable input — caller decides whether to default
22
+ * to true (always-export) or hard-fail.
23
+ */
24
+ const FIELD_RE = /^([a-zA-Z_][a-zA-Z0-9_]*)\s+(in|==|!=)\s+(.+)$/;
25
+ export function evaluatePredicate(expression, record) {
26
+ const trimmed = expression.trim();
27
+ if (!trimmed)
28
+ return "match"; // empty = always match
29
+ const m = trimmed.match(FIELD_RE);
30
+ if (!m)
31
+ return "unparsable";
32
+ const [, field, op, rhsRaw] = m;
33
+ const lhs = record[field];
34
+ if (op === "in") {
35
+ const list = parseList(rhsRaw);
36
+ if (list === null)
37
+ return "unparsable";
38
+ return list.some((v) => v === lhs) ? "match" : "no-match";
39
+ }
40
+ if (op === "==" || op === "!=") {
41
+ const rhs = parseLiteral(rhsRaw.trim());
42
+ if (rhs === undefined)
43
+ return "unparsable";
44
+ const eq = lhs === rhs;
45
+ return (op === "==" ? eq : !eq) ? "match" : "no-match";
46
+ }
47
+ return "unparsable";
48
+ }
49
+ function parseList(raw) {
50
+ const trimmed = raw.trim();
51
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]"))
52
+ return null;
53
+ const inner = trimmed.slice(1, -1).trim();
54
+ if (!inner)
55
+ return [];
56
+ // Split on commas, ignoring commas inside quoted strings. Slice 3a's
57
+ // grammar doesn't allow quoted commas anyway; keep this naive.
58
+ const parts = splitCsvRespectingQuotes(inner);
59
+ const out = [];
60
+ for (const p of parts) {
61
+ const lit = parseLiteral(p.trim());
62
+ if (lit === undefined)
63
+ return null;
64
+ out.push(lit);
65
+ }
66
+ return out;
67
+ }
68
+ function parseLiteral(raw) {
69
+ if (raw === "")
70
+ return undefined;
71
+ // Quoted string: single or double
72
+ if ((raw.startsWith("'") && raw.endsWith("'")) || (raw.startsWith('"') && raw.endsWith('"'))) {
73
+ return raw.slice(1, -1);
74
+ }
75
+ // Boolean / null
76
+ if (raw === "true")
77
+ return true;
78
+ if (raw === "false")
79
+ return false;
80
+ if (raw === "null")
81
+ return null;
82
+ // Number
83
+ if (/^-?\d+(\.\d+)?$/.test(raw))
84
+ return Number(raw);
85
+ // Bare identifier — treat as string. Allows: in [persistent, permanent]
86
+ if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(raw))
87
+ return raw;
88
+ return undefined;
89
+ }
90
+ function splitCsvRespectingQuotes(s) {
91
+ const out = [];
92
+ let depth = 0;
93
+ let buf = "";
94
+ let inQuote = null;
95
+ for (let i = 0; i < s.length; i++) {
96
+ const c = s[i];
97
+ if (inQuote) {
98
+ buf += c;
99
+ if (c === inQuote)
100
+ inQuote = null;
101
+ continue;
102
+ }
103
+ if (c === '"' || c === "'") {
104
+ inQuote = c;
105
+ buf += c;
106
+ continue;
107
+ }
108
+ if (c === "[" || c === "(")
109
+ depth++;
110
+ if (c === "]" || c === ")")
111
+ depth--;
112
+ if (c === "," && depth === 0) {
113
+ out.push(buf);
114
+ buf = "";
115
+ continue;
116
+ }
117
+ buf += c;
118
+ }
119
+ if (buf.length > 0)
120
+ out.push(buf);
121
+ return out;
122
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Output writers for export targets. Slice 3a ships `jsonl` and `json`.
3
+ * `yaml` and `markdown-frontmatter` land with slice 3b alongside any
4
+ * built-in that needs them.
5
+ *
6
+ * Each writer takes a stream of "shaped output records" (whatever the
7
+ * descriptor's `map:` produced) and writes to a target file path.
8
+ * Atomic via tmp-file + rename, so a crash mid-write doesn't leave a
9
+ * half-written target.
10
+ */
11
+ import { promises as fsp } from "node:fs";
12
+ import { dirname, basename } from "node:path";
13
+ import { randomUUID } from "node:crypto";
14
+ import yaml from "js-yaml";
15
+ import { BridgeRuntimeError } from "../types.js";
16
+ export async function writeRecords(bridge, path, format, records) {
17
+ switch (format) {
18
+ case "jsonl": return writeJsonl(bridge, path, records);
19
+ case "json": return writeJson(bridge, path, records);
20
+ case "yaml": return writeYaml(bridge, path, records);
21
+ case "markdown-frontmatter":
22
+ throw new BridgeRuntimeError({
23
+ bridge,
24
+ op: "export",
25
+ path,
26
+ field: "format",
27
+ expected: "jsonl | json | yaml",
28
+ got: "markdown-frontmatter",
29
+ hint: "markdown-frontmatter writer lands in slice 3b along with its reference adapter",
30
+ });
31
+ }
32
+ }
33
+ async function writeJsonl(bridge, path, records) {
34
+ const tmpPath = await stageTmp(bridge, path, "export");
35
+ let written = 0;
36
+ try {
37
+ const fh = await fsp.open(tmpPath, "w");
38
+ try {
39
+ for (const r of records) {
40
+ await fh.write(JSON.stringify(r) + "\n");
41
+ written++;
42
+ }
43
+ }
44
+ finally {
45
+ await fh.close();
46
+ }
47
+ await fsp.rename(tmpPath, path);
48
+ }
49
+ catch (err) {
50
+ await fsp.unlink(tmpPath).catch(() => { });
51
+ throw new BridgeRuntimeError({
52
+ bridge,
53
+ op: "export",
54
+ path,
55
+ field: "(write)",
56
+ expected: "successful write",
57
+ got: err?.message ?? String(err),
58
+ hint: `failed writing to ${path}: ${err?.message ?? err}`,
59
+ });
60
+ }
61
+ return { written };
62
+ }
63
+ async function writeJson(bridge, path, records) {
64
+ const arr = [];
65
+ for (const r of records)
66
+ arr.push(r);
67
+ const tmpPath = await stageTmp(bridge, path, "export");
68
+ try {
69
+ await fsp.writeFile(tmpPath, JSON.stringify(arr, null, 2));
70
+ await fsp.rename(tmpPath, path);
71
+ }
72
+ catch (err) {
73
+ await fsp.unlink(tmpPath).catch(() => { });
74
+ throw new BridgeRuntimeError({
75
+ bridge,
76
+ op: "export",
77
+ path,
78
+ field: "(write)",
79
+ expected: "successful write",
80
+ got: err?.message ?? String(err),
81
+ hint: `failed writing to ${path}: ${err?.message ?? err}`,
82
+ });
83
+ }
84
+ return { written: arr.length };
85
+ }
86
+ async function writeYaml(bridge, path, records) {
87
+ const arr = [];
88
+ for (const r of records)
89
+ arr.push(r);
90
+ const tmpPath = await stageTmp(bridge, path, "export");
91
+ try {
92
+ await fsp.writeFile(tmpPath, yaml.dump(arr));
93
+ await fsp.rename(tmpPath, path);
94
+ }
95
+ catch (err) {
96
+ await fsp.unlink(tmpPath).catch(() => { });
97
+ throw new BridgeRuntimeError({
98
+ bridge,
99
+ op: "export",
100
+ path,
101
+ field: "(write)",
102
+ expected: "successful write",
103
+ got: err?.message ?? String(err),
104
+ hint: `failed writing to ${path}: ${err?.message ?? err}`,
105
+ });
106
+ }
107
+ return { written: arr.length };
108
+ }
109
+ /**
110
+ * Allocate a sibling temp file in the same directory as `targetPath`.
111
+ * Same-fs rename is atomic on POSIX; using a sibling guarantees that.
112
+ */
113
+ async function stageTmp(bridge, targetPath, op) {
114
+ const dir = dirname(targetPath);
115
+ try {
116
+ await fsp.mkdir(dir, { recursive: true });
117
+ }
118
+ catch (err) {
119
+ throw new BridgeRuntimeError({
120
+ bridge,
121
+ op: "export",
122
+ path: targetPath,
123
+ field: "(mkdir)",
124
+ expected: "writable directory",
125
+ got: err?.message ?? String(err),
126
+ hint: `could not create directory ${dir}: ${err?.message ?? err}`,
127
+ });
128
+ }
129
+ return `${dir}/.${basename(targetPath)}.${op}.${randomUUID().slice(0, 8)}.tmp`;
130
+ }