@unifyplane/logsdk 1.0.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.
Files changed (48) hide show
  1. package/.github/copilot-instructions.md +48 -0
  2. package/README.md +8 -0
  3. package/contracts/specs/LogSDKFuntionalSpec.md +394 -0
  4. package/contracts/specs/fanout-semantics.v1.md +244 -0
  5. package/contracts/specs/sink-contract.v1.md +223 -0
  6. package/contracts/specs/step-record.v1.md +292 -0
  7. package/contracts/specs/validation-rules.v1.md +324 -0
  8. package/docs/LogSDK-Unified-Execution-Logging-Framework.md +93 -0
  9. package/docs/log_sdk_test_cases_traceability_plan.md +197 -0
  10. package/docs/log_sdk_test_coverage_report.md +198 -0
  11. package/docs/prompts/AuditorSDK.txt +214 -0
  12. package/package.json +29 -0
  13. package/src/core/clock.ts +25 -0
  14. package/src/core/context.ts +142 -0
  15. package/src/core/fanout.ts +38 -0
  16. package/src/core/ids.ts +35 -0
  17. package/src/core/message_constraints.ts +66 -0
  18. package/src/core/outcomes.ts +5 -0
  19. package/src/core/record_builder.ts +269 -0
  20. package/src/core/spool.ts +41 -0
  21. package/src/core/types.ts +56 -0
  22. package/src/crypto-shim.d.ts +9 -0
  23. package/src/fs-shim.d.ts +15 -0
  24. package/src/index.ts +107 -0
  25. package/src/node-test-shim.d.ts +1 -0
  26. package/src/perf_hooks-shim.d.ts +7 -0
  27. package/src/process-shim.d.ts +1 -0
  28. package/src/sinks/file_ndjson.ts +42 -0
  29. package/src/sinks/file_ndjson_sink.ts +45 -0
  30. package/src/sinks/sink_types.ts +15 -0
  31. package/src/sinks/stdout_sink.ts +20 -0
  32. package/src/validate/api_surface_guard.ts +106 -0
  33. package/src/validate/noncompliance.ts +33 -0
  34. package/src/validate/schema_guard.ts +238 -0
  35. package/tests/fanout.test.ts +51 -0
  36. package/tests/fanout_spool.test.ts +96 -0
  37. package/tests/message_constraints.test.ts +7 -0
  38. package/tests/node-shim.d.ts +1 -0
  39. package/tests/record_builder.test.ts +32 -0
  40. package/tests/sequence_monotonic.test.ts +62 -0
  41. package/tests/sinks_file_ndjson.test.ts +53 -0
  42. package/tests/step1_compliance.test.ts +192 -0
  43. package/tools/test_results/generate-test-traceability.js +60 -0
  44. package/tools/test_results/normalize-test-results.js +57 -0
  45. package/tools/test_results/run-tests-then-prebuild.js +103 -0
  46. package/tools/test_results/test-case-map.json +9 -0
  47. package/tsconfig.json +31 -0
  48. package/validators/bootstrap/validate-repo-structure.ts +590 -0
@@ -0,0 +1,66 @@
1
+ const MESSAGE_MAX_LENGTH = 512;
2
+ const BASE64_MIN_LENGTH = 80;
3
+
4
+ function isJsonObjectOrArray(value: string): boolean {
5
+ const trimmed = value.trim();
6
+ if (!trimmed) {
7
+ return false;
8
+ }
9
+
10
+ const startsWithBrace = trimmed.startsWith("{") && trimmed.endsWith("}");
11
+ const startsWithBracket = trimmed.startsWith("[") && trimmed.endsWith("]");
12
+
13
+ if (!startsWithBrace && !startsWithBracket) {
14
+ return false;
15
+ }
16
+
17
+ try {
18
+ const parsed = JSON.parse(trimmed);
19
+ return typeof parsed === "object" && parsed !== null;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ function looksLikeBase64Blob(value: string): boolean {
26
+ const trimmed = value.trim();
27
+ if (trimmed.length < BASE64_MIN_LENGTH) {
28
+ return false;
29
+ }
30
+
31
+ if (/\s/.test(trimmed)) {
32
+ return false;
33
+ }
34
+
35
+ if (/^[A-Za-z0-9+/=_-]+$/.test(trimmed)) {
36
+ return true;
37
+ }
38
+
39
+ return /[A-Za-z0-9+/=_-]{80,}/.test(trimmed);
40
+ }
41
+
42
+ export function assertValidMessage(message: unknown): string {
43
+ if (typeof message !== "string") {
44
+ throw new Error("Message must be a string.");
45
+ }
46
+
47
+ if (message.length === 0) {
48
+ throw new Error("Message must not be empty.");
49
+ }
50
+
51
+ if (message.length > MESSAGE_MAX_LENGTH) {
52
+ throw new Error(`Message exceeds maximum length of ${MESSAGE_MAX_LENGTH}.`);
53
+ }
54
+
55
+ if (isJsonObjectOrArray(message)) {
56
+ throw new Error("Message must not be a JSON object or array.");
57
+ }
58
+
59
+ if (looksLikeBase64Blob(message)) {
60
+ throw new Error("Message must not contain base64-like payloads.");
61
+ }
62
+
63
+ return message;
64
+ }
65
+
66
+ export { MESSAGE_MAX_LENGTH };
@@ -0,0 +1,5 @@
1
+ export const OK = "OK" as const;
2
+ export const DEGRADED = "DEGRADED" as const;
3
+ export const FAILED = "FAILED" as const;
4
+
5
+ export type Outcome = typeof OK | typeof DEGRADED | typeof FAILED;
@@ -0,0 +1,269 @@
1
+ /// <reference path="../crypto-shim.d.ts" />
2
+ import { createHash, randomUUID } from "crypto";
3
+ import { getContextHash, getContextVersion, JsonValue } from "./context";
4
+ import { monotonicNow } from "./clock";
5
+ import { assertValidMessage, MESSAGE_MAX_LENGTH } from "./message_constraints";
6
+ import type { StepRecord } from "./types";
7
+
8
+ const RECORD_VERSION = "log.step.v1";
9
+ const HASH_ALGORITHM = "sha256";
10
+ export const message_constraints = {
11
+ maxLength: MESSAGE_MAX_LENGTH,
12
+ validate: assertValidMessage,
13
+ };
14
+
15
+ export type EvidenceRef = string;
16
+
17
+ export type SystemOwnership = {
18
+ institution: string;
19
+ system_name: string;
20
+ system_type: string;
21
+ environment: string;
22
+ system_version: string;
23
+ instance_id?: string;
24
+ };
25
+
26
+ export type StepRecordOverrides = {
27
+ record_id?: string;
28
+ timestamp_utc?: string;
29
+ monotonic_time?: number;
30
+ message_code?: string;
31
+ evidence_refs?: EvidenceRef[];
32
+ trace_id?: string;
33
+ span_id?: string;
34
+ parent_step_id?: string;
35
+ surface_type?: string;
36
+ surface_name?: string;
37
+ surface_instance?: string;
38
+ source_file?: string;
39
+ source_module?: string;
40
+ source_function?: string;
41
+ source_line?: number;
42
+ };
43
+
44
+ export function buildStepRecord(
45
+ message: string,
46
+ sequence: number,
47
+ system: SystemOwnership,
48
+ overrides: StepRecordOverrides = {}
49
+ ): StepRecord {
50
+ const validatedMessage = message_constraints.validate(message);
51
+ validateSequence(sequence);
52
+ validateSystemOwnership(system);
53
+
54
+ const contextHash = getContextHash();
55
+ const contextVersion = getContextVersion();
56
+
57
+ const recordId = overrides.record_id ?? createRecordId();
58
+ const timestampUtc = overrides.timestamp_utc ?? new Date().toISOString();
59
+ const monotonicTime = overrides.monotonic_time ?? monotonicNow();
60
+
61
+ const baseRecord: Omit<StepRecord, "record_hash" | "hash_algorithm"> = {
62
+ record_version: RECORD_VERSION,
63
+ record_id: recordId,
64
+ sequence,
65
+ timestamp_utc: timestampUtc,
66
+ monotonic_time: monotonicTime,
67
+ institution: system.institution,
68
+ system_name: system.system_name,
69
+ system_type: system.system_type,
70
+ environment: system.environment,
71
+ system_version: system.system_version,
72
+ message: validatedMessage,
73
+ context_hash: contextHash,
74
+ context_version: contextVersion,
75
+ };
76
+
77
+ setOptionalField(baseRecord, "instance_id", system.instance_id);
78
+ setOptionalField(baseRecord, "trace_id", overrides.trace_id);
79
+ setOptionalField(baseRecord, "span_id", overrides.span_id);
80
+ setOptionalField(baseRecord, "parent_step_id", overrides.parent_step_id);
81
+ setOptionalField(baseRecord, "surface_type", overrides.surface_type);
82
+ setOptionalField(baseRecord, "surface_name", overrides.surface_name);
83
+ setOptionalField(baseRecord, "surface_instance", overrides.surface_instance);
84
+ setOptionalField(baseRecord, "source_file", overrides.source_file);
85
+ setOptionalField(baseRecord, "source_module", overrides.source_module);
86
+ setOptionalField(baseRecord, "source_function", overrides.source_function);
87
+ setOptionalField(baseRecord, "source_line", overrides.source_line);
88
+ setOptionalField(baseRecord, "message_code", overrides.message_code);
89
+ setOptionalField(baseRecord, "evidence_refs", overrides.evidence_refs);
90
+
91
+ const canonical = stableStringify(
92
+ normalizeJsonValueOrdered(canonicalizeForHash(baseRecord), "$")
93
+ );
94
+ const recordHash = createHash(HASH_ALGORITHM).update(canonical).digest("hex");
95
+
96
+ const record: StepRecord = {
97
+ ...baseRecord,
98
+ record_hash: recordHash,
99
+ hash_algorithm: HASH_ALGORITHM,
100
+ };
101
+
102
+ return deepFreeze(record);
103
+ }
104
+
105
+ function validateSequence(sequence: number): void {
106
+ if (!Number.isInteger(sequence) || sequence < 0) {
107
+ throw new Error("Sequence must be a non-negative integer");
108
+ }
109
+ }
110
+
111
+ function validateSystemOwnership(system: SystemOwnership): void {
112
+ const requiredFields: Array<keyof SystemOwnership> = [
113
+ "institution",
114
+ "system_name",
115
+ "system_type",
116
+ "environment",
117
+ "system_version",
118
+ ];
119
+
120
+ for (const field of requiredFields) {
121
+ const value = system[field];
122
+ if (typeof value !== "string" || value.trim().length === 0) {
123
+ throw new Error(`Missing required system field: ${field}`);
124
+ }
125
+ }
126
+
127
+ if (system.instance_id !== undefined && typeof system.instance_id !== "string") {
128
+ throw new Error("instance_id must be a string when provided");
129
+ }
130
+ }
131
+
132
+ function createRecordId(): string {
133
+ return typeof randomUUID === "function"
134
+ ? randomUUID()
135
+ : createHash("sha256")
136
+ .update(`${Date.now()}-${Math.random()}`)
137
+ .digest("hex");
138
+ }
139
+
140
+ function deepFreeze<T>(value: T): T {
141
+ if (!value || typeof value !== "object") {
142
+ return value;
143
+ }
144
+
145
+ if (Object.isFrozen(value)) {
146
+ return value;
147
+ }
148
+
149
+ Object.freeze(value);
150
+
151
+ for (const key of Object.keys(value as object)) {
152
+ const child = (value as Record<string, unknown>)[key];
153
+ deepFreeze(child);
154
+ }
155
+
156
+ if (Array.isArray(value)) {
157
+ for (const item of value) {
158
+ deepFreeze(item);
159
+ }
160
+ }
161
+
162
+ return value;
163
+ }
164
+
165
+ function canonicalizeForHash(
166
+ record: Omit<StepRecord, "record_hash" | "hash_algorithm">
167
+ ): JsonValue {
168
+ const ordered: Record<string, JsonValue> = {};
169
+ for (const field of HASH_FIELD_ORDER) {
170
+ const value = record[field];
171
+ if (value !== undefined) {
172
+ ordered[field] = normalizeJsonValueOrdered(value, `${String(field)}`);
173
+ }
174
+ }
175
+ return ordered;
176
+ }
177
+
178
+ function normalizeJsonValueOrdered(value: unknown, path: string): JsonValue {
179
+ if (
180
+ value === null ||
181
+ typeof value === "string" ||
182
+ typeof value === "number" ||
183
+ typeof value === "boolean"
184
+ ) {
185
+ return value;
186
+ }
187
+
188
+ if (typeof value === "bigint") {
189
+ return value.toString();
190
+ }
191
+
192
+ if (value instanceof Date) {
193
+ return value.toISOString();
194
+ }
195
+
196
+ if (Array.isArray(value)) {
197
+ return value.map((item, index) =>
198
+ normalizeJsonValueOrdered(item, `${path}[${index}]`)
199
+ );
200
+ }
201
+
202
+ if (typeof value === "object") {
203
+ const proto = Object.getPrototypeOf(value);
204
+ if (proto !== Object.prototype && proto !== null) {
205
+ throw new Error(`Unsupported object at ${path}`);
206
+ }
207
+
208
+ const obj = value as Record<string, unknown>;
209
+ const result: Record<string, JsonValue> = {};
210
+ const keys = Object.keys(obj);
211
+
212
+ for (const key of keys) {
213
+ const next = obj[key];
214
+ if (next === undefined) {
215
+ throw new Error(`Undefined value at ${path}.${key}`);
216
+ }
217
+ result[key] = normalizeJsonValueOrdered(next, `${path}.${key}`);
218
+ }
219
+
220
+ return result;
221
+ }
222
+
223
+ throw new Error(`Unsupported type at ${path}`);
224
+ }
225
+
226
+ function stableStringify(value: JsonValue): string {
227
+ return JSON.stringify(value);
228
+ }
229
+
230
+ function setOptionalField<K extends keyof Omit<StepRecord, "record_hash" | "hash_algorithm">>(
231
+ record: Omit<StepRecord, "record_hash" | "hash_algorithm">,
232
+ key: K,
233
+ value: Omit<StepRecord, "record_hash" | "hash_algorithm">[K]
234
+ ): void {
235
+ if (value !== undefined) {
236
+ (record as Record<string, unknown>)[key] = value as JsonValue;
237
+ }
238
+ }
239
+
240
+ const HASH_FIELD_ORDER: Array<
241
+ keyof Omit<StepRecord, "record_hash" | "hash_algorithm">
242
+ > = [
243
+ "record_version",
244
+ "record_id",
245
+ "sequence",
246
+ "timestamp_utc",
247
+ "monotonic_time",
248
+ "institution",
249
+ "system_name",
250
+ "system_type",
251
+ "environment",
252
+ "system_version",
253
+ "instance_id",
254
+ "trace_id",
255
+ "span_id",
256
+ "parent_step_id",
257
+ "surface_type",
258
+ "surface_name",
259
+ "surface_instance",
260
+ "source_file",
261
+ "source_module",
262
+ "source_function",
263
+ "source_line",
264
+ "message",
265
+ "message_code",
266
+ "context_hash",
267
+ "context_version",
268
+ "evidence_refs",
269
+ ];
@@ -0,0 +1,41 @@
1
+ /// <reference path="../fs-shim.d.ts" />
2
+
3
+ import { promises as fs } from "fs";
4
+ import os from "os";
5
+ import path from "path";
6
+ import type { StepRecord } from "./types";
7
+
8
+ const DEFAULT_SPOOL_NAME = "logsdk-emergency-spool.ndjson";
9
+
10
+ function getSpoolPath(): string {
11
+ return (
12
+ process.env.LOGSDK_EMERGENCY_SPOOL_PATH ??
13
+ path.join(os.tmpdir(), DEFAULT_SPOOL_NAME)
14
+ );
15
+ }
16
+
17
+ export function getEmergencySpoolPath(): string {
18
+ return getSpoolPath();
19
+ }
20
+
21
+ export async function writeEmergencySpool(record: StepRecord): Promise<void> {
22
+ const spoolPath = getSpoolPath();
23
+ const payload = `${JSON.stringify(record)}\n`;
24
+ const handle = await fs.open(spoolPath, "a");
25
+
26
+ try {
27
+ const result = await handle.write(payload, undefined, "utf8");
28
+
29
+ if (!result || typeof result.bytesWritten !== "number") {
30
+ throw new Error("Emergency spool write did not report bytes.");
31
+ }
32
+
33
+ if (result.bytesWritten !== payload.length) {
34
+ throw new Error("Emergency spool partial write detected.");
35
+ }
36
+
37
+ await handle.sync();
38
+ } finally {
39
+ await handle.close();
40
+ }
41
+ }
@@ -0,0 +1,56 @@
1
+ // Internal core types for LogSDK v1.0
2
+ // Types only: no runtime logic.
3
+
4
+ export type SinkClass = "authoritative" | "observability";
5
+
6
+ export type FanoutOutcome = "OK" | "DEGRADED" | "FAILED";
7
+
8
+ export interface StepRecord {
9
+ // 1. Identity & Versioning
10
+ record_version: "log.step.v1";
11
+ record_id: string;
12
+ sequence: number;
13
+
14
+ // 2. Time (Dual Clock)
15
+ timestamp_utc: string;
16
+ monotonic_time: number;
17
+
18
+ // 3. System Ownership (Evidence Boundary)
19
+ institution: string;
20
+ system_name: string;
21
+ system_type: string;
22
+ environment: string;
23
+ system_version: string;
24
+ instance_id?: string;
25
+
26
+ // 4. Execution Correlation (Derived Automatically)
27
+ trace_id?: string;
28
+ span_id?: string;
29
+ parent_step_id?: string;
30
+
31
+ // 5. Execution Surface (Where This Happened)
32
+ surface_type?: string;
33
+ surface_name?: string;
34
+ surface_instance?: string;
35
+
36
+ // 6. Source Location (Best-Effort, Factual)
37
+ source_file?: string;
38
+ source_module?: string;
39
+ source_function?: string;
40
+ source_line?: number;
41
+
42
+ // 7. Step Message (The Only Human Input)
43
+ message: string;
44
+ message_code?: string;
45
+
46
+ // 8. Context Capsule Reference (Frozen Once)
47
+ context_hash: string;
48
+ context_version: string;
49
+
50
+ // 9. Evidence References (Optional, Structured)
51
+ evidence_refs?: string[];
52
+
53
+ // 10. Integrity
54
+ record_hash: string;
55
+ hash_algorithm: string;
56
+ }
@@ -0,0 +1,9 @@
1
+ declare module "crypto" {
2
+ export type Hash = {
3
+ update(data: string): Hash;
4
+ digest(encoding: "hex" | string): string;
5
+ };
6
+
7
+ export function createHash(algorithm: string): Hash;
8
+ export function randomUUID(): string;
9
+ }
@@ -0,0 +1,15 @@
1
+ declare module "fs" {
2
+ export type FileHandle = {
3
+ write(
4
+ data: string,
5
+ position?: number,
6
+ encoding?: string
7
+ ): Promise<{ bytesWritten: number }>;
8
+ sync(): Promise<void>;
9
+ close(): Promise<void>;
10
+ };
11
+
12
+ export const promises: {
13
+ open(path: string, flags: string): Promise<FileHandle>;
14
+ };
15
+ }
package/src/index.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { initContext } from "./core/context";
2
+ import { createIdGenerator } from "./core/ids";
3
+ import { buildStepRecord, SystemOwnership } from "./core/record_builder";
4
+ import { assertValidMessage } from "./core/message_constraints";
5
+ import { fanout } from "./core/fanout";
6
+ import { assertApiSurface } from "./validate/api_surface_guard";
7
+ import { assertStepRecord } from "./validate/schema_guard";
8
+ import {
9
+ AUTHORITATIVE_FAILURE_SWALLOWED,
10
+ CONTEXT_INJECTION_VIOLATION,
11
+ PAYLOAD_EMBEDDING_VIOLATION,
12
+ NonComplianceError,
13
+ } from "./validate/noncompliance";
14
+ import type { StepRecord } from "./core/types";
15
+
16
+ type SinkClass = "authoritative" | "observability";
17
+
18
+ type Sink = {
19
+ emit(record: Readonly<StepRecord>): void | Promise<void>;
20
+ flush?(): void | Promise<void>;
21
+ };
22
+
23
+ type SinkEntry = {
24
+ sink: Sink;
25
+ sinkClass: SinkClass;
26
+ };
27
+
28
+ export type LogSDKConfig = {
29
+ context: unknown;
30
+ system: SystemOwnership;
31
+ sinks: ReadonlyArray<SinkEntry>;
32
+ };
33
+
34
+ export type LogApi = {
35
+ step(message: string): Promise<void>;
36
+ flush?: () => Promise<void>;
37
+ };
38
+
39
+ export type { SystemOwnership };
40
+
41
+ export function initLogSDK(config: LogSDKConfig): LogApi {
42
+ initContext(config.context);
43
+
44
+ const idGenerator = createIdGenerator(config.system.instance_id);
45
+ const sinks = [...config.sinks];
46
+
47
+ const api: LogApi = {
48
+ async step(message: string): Promise<void> {
49
+ if (typeof message !== "string") {
50
+ if (message && typeof message === "object") {
51
+ throw new NonComplianceError(
52
+ CONTEXT_INJECTION_VIOLATION,
53
+ "Per-step context injection is not allowed."
54
+ );
55
+ }
56
+
57
+ throw new NonComplianceError(
58
+ PAYLOAD_EMBEDDING_VIOLATION,
59
+ "Message must be a string."
60
+ );
61
+ }
62
+
63
+ try {
64
+ assertValidMessage(message);
65
+ } catch (error) {
66
+ throw new NonComplianceError(
67
+ PAYLOAD_EMBEDDING_VIOLATION,
68
+ error instanceof Error ? error.message : "Invalid message content."
69
+ );
70
+ }
71
+
72
+ const record = buildStepRecord(message, idGenerator.nextSequence(), config.system, {
73
+ record_id: idGenerator.nextRecordId(),
74
+ });
75
+
76
+ assertStepRecord(record);
77
+
78
+ const outcome = await fanout(record, sinks);
79
+ if (outcome === "FAILED") {
80
+ throw new NonComplianceError(
81
+ AUTHORITATIVE_FAILURE_SWALLOWED,
82
+ "Authoritative sink failed to emit."
83
+ );
84
+ }
85
+ },
86
+ async flush(): Promise<void> {
87
+ for (const entry of sinks) {
88
+ if (entry.sink.flush) {
89
+ await entry.sink.flush();
90
+ }
91
+ }
92
+ },
93
+ };
94
+
95
+ assertApiSurface(api);
96
+ return api;
97
+ }
98
+
99
+ export function createLogger(config: LogSDKConfig): LogApi {
100
+ return initLogSDK(config);
101
+ }
102
+
103
+ export { fileNdjsonSink } from "./sinks/file_ndjson";
104
+
105
+
106
+ export { authoritativeNdjsonSink } from "./sinks/file_ndjson";
107
+
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ declare module "perf_hooks" {
2
+ export type Performance = {
3
+ now(): number;
4
+ };
5
+
6
+ export const performance: Performance;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import type { StepRecord } from "../core/types";
4
+
5
+ type SinkClass = "authoritative" | "observability";
6
+
7
+ type Sink = {
8
+ emit(record: Readonly<StepRecord>): void | Promise<void>;
9
+ flush?(): void | Promise<void>;
10
+ };
11
+
12
+ type SinkEntry = {
13
+ sink: Sink;
14
+ sinkClass: SinkClass;
15
+ };
16
+
17
+ export function fileNdjsonSink(pathOrOptions: string | { path: string }): SinkEntry {
18
+ const p = typeof pathOrOptions === "string" ? pathOrOptions : pathOrOptions.path;
19
+ const dir = path.dirname(p);
20
+ if (!fs.existsSync(dir)) {
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ }
23
+ const sink: Sink = {
24
+ emit(record: Readonly<StepRecord>): void {
25
+ fs.appendFileSync(p, JSON.stringify(record) + "\n", { encoding: "utf8" });
26
+ },
27
+ };
28
+ return { sink, sinkClass: "observability" };
29
+ }
30
+ export function authoritativeNdjsonSink(pathOrOptions: string | { path: string }): SinkEntry {
31
+ const p = typeof pathOrOptions === "string" ? pathOrOptions : pathOrOptions.path;
32
+ const dir = path.dirname(p);
33
+ if (!fs.existsSync(dir)) {
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ }
36
+ const sink: Sink = {
37
+ emit(record: Readonly<StepRecord>): void {
38
+ fs.appendFileSync(p, JSON.stringify(record) + "\n", { encoding: "utf8" });
39
+ },
40
+ };
41
+ return { sink, sinkClass: "authoritative" };
42
+ }
@@ -0,0 +1,45 @@
1
+ /// <reference path="../fs-shim.d.ts" />
2
+
3
+ import { promises as fs } from "fs";
4
+ import type { StepRecord } from "../core/types";
5
+
6
+ export interface FileNdjsonSink {
7
+ emit(record: StepRecord): Promise<void>;
8
+ close(): Promise<void>;
9
+ }
10
+
11
+ export function createFileNdjsonSink(filePath: string): FileNdjsonSink {
12
+ let closed = false;
13
+
14
+ return {
15
+ async emit(record: StepRecord): Promise<void> {
16
+ if (closed) {
17
+ throw new Error("File NDJSON sink is closed.");
18
+ }
19
+
20
+ const line = JSON.stringify(record);
21
+ const payload = `${line}\n`;
22
+ const handle = await fs.open(filePath, "a");
23
+
24
+ try {
25
+ const result = await handle.write(payload, undefined, "utf8");
26
+ if (!result || typeof result.bytesWritten !== "number") {
27
+ throw new Error("File NDJSON sink write did not report bytes.");
28
+ }
29
+
30
+ if (result.bytesWritten !== payload.length) {
31
+ throw new Error("File NDJSON sink partial write detected.");
32
+ }
33
+ await handle.sync();
34
+ } catch (error) {
35
+ const message = error instanceof Error ? error.message : String(error);
36
+ throw new Error(`File NDJSON sink write failed: ${message}`);
37
+ } finally {
38
+ await handle.close();
39
+ }
40
+ },
41
+ async close(): Promise<void> {
42
+ closed = true;
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,15 @@
1
+ import type { StepRecord as CoreStepRecord } from "../core/types";
2
+
3
+ export type StepRecord = Readonly<CoreStepRecord>;
4
+
5
+ export type SinkClass = "authoritative" | "observability";
6
+
7
+ export interface Sink<TRecord = StepRecord> {
8
+ emit(record: Readonly<TRecord>): void | Promise<void>;
9
+ flush?(): void | Promise<void>;
10
+ }
11
+
12
+ export interface SinkEntry<TRecord = StepRecord> {
13
+ sink: Sink<TRecord>;
14
+ sinkClass: SinkClass;
15
+ }