@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.
- package/.github/copilot-instructions.md +48 -0
- package/README.md +8 -0
- package/contracts/specs/LogSDKFuntionalSpec.md +394 -0
- package/contracts/specs/fanout-semantics.v1.md +244 -0
- package/contracts/specs/sink-contract.v1.md +223 -0
- package/contracts/specs/step-record.v1.md +292 -0
- package/contracts/specs/validation-rules.v1.md +324 -0
- package/docs/LogSDK-Unified-Execution-Logging-Framework.md +93 -0
- package/docs/log_sdk_test_cases_traceability_plan.md +197 -0
- package/docs/log_sdk_test_coverage_report.md +198 -0
- package/docs/prompts/AuditorSDK.txt +214 -0
- package/package.json +29 -0
- package/src/core/clock.ts +25 -0
- package/src/core/context.ts +142 -0
- package/src/core/fanout.ts +38 -0
- package/src/core/ids.ts +35 -0
- package/src/core/message_constraints.ts +66 -0
- package/src/core/outcomes.ts +5 -0
- package/src/core/record_builder.ts +269 -0
- package/src/core/spool.ts +41 -0
- package/src/core/types.ts +56 -0
- package/src/crypto-shim.d.ts +9 -0
- package/src/fs-shim.d.ts +15 -0
- package/src/index.ts +107 -0
- package/src/node-test-shim.d.ts +1 -0
- package/src/perf_hooks-shim.d.ts +7 -0
- package/src/process-shim.d.ts +1 -0
- package/src/sinks/file_ndjson.ts +42 -0
- package/src/sinks/file_ndjson_sink.ts +45 -0
- package/src/sinks/sink_types.ts +15 -0
- package/src/sinks/stdout_sink.ts +20 -0
- package/src/validate/api_surface_guard.ts +106 -0
- package/src/validate/noncompliance.ts +33 -0
- package/src/validate/schema_guard.ts +238 -0
- package/tests/fanout.test.ts +51 -0
- package/tests/fanout_spool.test.ts +96 -0
- package/tests/message_constraints.test.ts +7 -0
- package/tests/node-shim.d.ts +1 -0
- package/tests/record_builder.test.ts +32 -0
- package/tests/sequence_monotonic.test.ts +62 -0
- package/tests/sinks_file_ndjson.test.ts +53 -0
- package/tests/step1_compliance.test.ts +192 -0
- package/tools/test_results/generate-test-traceability.js +60 -0
- package/tools/test_results/normalize-test-results.js +57 -0
- package/tools/test_results/run-tests-then-prebuild.js +103 -0
- package/tools/test_results/test-case-map.json +9 -0
- package/tsconfig.json +31 -0
- package/validators/bootstrap/validate-repo-structure.ts +590 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/// <reference path="../process-shim.d.ts" />
|
|
2
|
+
|
|
3
|
+
import type { StepRecord } from "../core/types";
|
|
4
|
+
|
|
5
|
+
export interface StdoutSink {
|
|
6
|
+
emit(record: StepRecord): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createStdoutSink(): StdoutSink {
|
|
10
|
+
return {
|
|
11
|
+
async emit(record: StepRecord): Promise<void> {
|
|
12
|
+
try {
|
|
13
|
+
const line = JSON.stringify(record);
|
|
14
|
+
process.stdout.write(`${line}\n`);
|
|
15
|
+
} catch {
|
|
16
|
+
// Best-effort only: never throw.
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
API_SURFACE_VIOLATION,
|
|
3
|
+
NonComplianceError,
|
|
4
|
+
} from "./noncompliance";
|
|
5
|
+
|
|
6
|
+
const ALLOWED_SCOPES = ["request", "job", "component"] as const;
|
|
7
|
+
|
|
8
|
+
type AllowedScope = (typeof ALLOWED_SCOPES)[number];
|
|
9
|
+
|
|
10
|
+
const FORBIDDEN_NAMES = new Set([
|
|
11
|
+
"withContext",
|
|
12
|
+
"ctx",
|
|
13
|
+
"meta",
|
|
14
|
+
"attributes",
|
|
15
|
+
"tags",
|
|
16
|
+
"labels",
|
|
17
|
+
"log",
|
|
18
|
+
"emit",
|
|
19
|
+
"metric",
|
|
20
|
+
"span",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function assertFunction(value: unknown, name: string, arity: number): void {
|
|
24
|
+
if (typeof value !== "function") {
|
|
25
|
+
throw new NonComplianceError(
|
|
26
|
+
API_SURFACE_VIOLATION,
|
|
27
|
+
`${name} must be a function.`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if ((value as Function).length !== arity) {
|
|
32
|
+
throw new NonComplianceError(
|
|
33
|
+
API_SURFACE_VIOLATION,
|
|
34
|
+
`${name} must accept exactly ${arity} argument(s).`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertChildScopePolicy(child: unknown): void {
|
|
40
|
+
const candidate = child as { allowedScopes?: unknown };
|
|
41
|
+
const allowedScopes = candidate.allowedScopes;
|
|
42
|
+
|
|
43
|
+
if (!Array.isArray(allowedScopes) || allowedScopes.length === 0) {
|
|
44
|
+
throw new NonComplianceError(
|
|
45
|
+
API_SURFACE_VIOLATION,
|
|
46
|
+
"child() must declare allowedScopes to enforce scope union."
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const scope of allowedScopes) {
|
|
51
|
+
if (!ALLOWED_SCOPES.includes(scope as AllowedScope)) {
|
|
52
|
+
throw new NonComplianceError(
|
|
53
|
+
API_SURFACE_VIOLATION,
|
|
54
|
+
`child() scope not allowed: ${String(scope)}.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function assertApiSurface(surface: unknown): void {
|
|
61
|
+
if (!surface || typeof surface !== "object") {
|
|
62
|
+
throw new NonComplianceError(
|
|
63
|
+
API_SURFACE_VIOLATION,
|
|
64
|
+
"API surface must be an object."
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const api = surface as Record<string, unknown>;
|
|
69
|
+
const keys = Object.keys(api);
|
|
70
|
+
|
|
71
|
+
for (const key of keys) {
|
|
72
|
+
if (FORBIDDEN_NAMES.has(key)) {
|
|
73
|
+
throw new NonComplianceError(
|
|
74
|
+
API_SURFACE_VIOLATION,
|
|
75
|
+
`Forbidden API method detected: ${key}.`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!("step" in api)) {
|
|
81
|
+
throw new NonComplianceError(
|
|
82
|
+
API_SURFACE_VIOLATION,
|
|
83
|
+
"API surface must expose step(message)."
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
assertFunction(api.step, "step", 1);
|
|
88
|
+
|
|
89
|
+
if ("flush" in api) {
|
|
90
|
+
assertFunction(api.flush, "flush", 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if ("child" in api) {
|
|
94
|
+
assertFunction(api.child, "child", 1);
|
|
95
|
+
assertChildScopePolicy(api.child);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const key of keys) {
|
|
99
|
+
if (key !== "step" && key !== "child" && key !== "flush") {
|
|
100
|
+
throw new NonComplianceError(
|
|
101
|
+
API_SURFACE_VIOLATION,
|
|
102
|
+
`Unexpected API export: ${key}.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const API_SURFACE_VIOLATION = "API_SURFACE_VIOLATION" as const;
|
|
2
|
+
export const CONTEXT_INJECTION_VIOLATION =
|
|
3
|
+
"CONTEXT_INJECTION_VIOLATION" as const;
|
|
4
|
+
export const PAYLOAD_EMBEDDING_VIOLATION =
|
|
5
|
+
"PAYLOAD_EMBEDDING_VIOLATION" as const;
|
|
6
|
+
export const CANONICAL_SCHEMA_VIOLATION =
|
|
7
|
+
"CANONICAL_SCHEMA_VIOLATION" as const;
|
|
8
|
+
export const SINK_MUTATION_VIOLATION = "SINK_MUTATION_VIOLATION" as const;
|
|
9
|
+
export const NO_AUTHORITATIVE_SINK = "NO_AUTHORITATIVE_SINK" as const;
|
|
10
|
+
export const FANOUT_NONDETERMINISM = "FANOUT_NONDETERMINISM" as const;
|
|
11
|
+
export const AUTHORITATIVE_FAILURE_SWALLOWED =
|
|
12
|
+
"AUTHORITATIVE_FAILURE_SWALLOWED" as const;
|
|
13
|
+
export const UNBOUNDED_BUFFER_RISK = "UNBOUNDED_BUFFER_RISK" as const;
|
|
14
|
+
|
|
15
|
+
export type NonComplianceCode =
|
|
16
|
+
| typeof API_SURFACE_VIOLATION
|
|
17
|
+
| typeof CONTEXT_INJECTION_VIOLATION
|
|
18
|
+
| typeof PAYLOAD_EMBEDDING_VIOLATION
|
|
19
|
+
| typeof CANONICAL_SCHEMA_VIOLATION
|
|
20
|
+
| typeof SINK_MUTATION_VIOLATION
|
|
21
|
+
| typeof NO_AUTHORITATIVE_SINK
|
|
22
|
+
| typeof FANOUT_NONDETERMINISM
|
|
23
|
+
| typeof AUTHORITATIVE_FAILURE_SWALLOWED
|
|
24
|
+
| typeof UNBOUNDED_BUFFER_RISK;
|
|
25
|
+
|
|
26
|
+
export class NonComplianceError extends Error {
|
|
27
|
+
readonly code: NonComplianceCode;
|
|
28
|
+
|
|
29
|
+
constructor(code: NonComplianceCode, message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { StepRecord } from "../core/types";
|
|
2
|
+
import { assertValidMessage } from "../core/message_constraints";
|
|
3
|
+
import type { SinkEntry } from "../sinks/sink_types";
|
|
4
|
+
import {
|
|
5
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
6
|
+
CONTEXT_INJECTION_VIOLATION,
|
|
7
|
+
NO_AUTHORITATIVE_SINK,
|
|
8
|
+
PAYLOAD_EMBEDDING_VIOLATION,
|
|
9
|
+
NonComplianceError,
|
|
10
|
+
} from "./noncompliance";
|
|
11
|
+
|
|
12
|
+
const REQUIRED_FIELDS: Array<keyof StepRecord> = [
|
|
13
|
+
"record_version",
|
|
14
|
+
"record_id",
|
|
15
|
+
"sequence",
|
|
16
|
+
"timestamp_utc",
|
|
17
|
+
"monotonic_time",
|
|
18
|
+
"institution",
|
|
19
|
+
"system_name",
|
|
20
|
+
"system_type",
|
|
21
|
+
"environment",
|
|
22
|
+
"system_version",
|
|
23
|
+
"message",
|
|
24
|
+
"context_hash",
|
|
25
|
+
"context_version",
|
|
26
|
+
"record_hash",
|
|
27
|
+
"hash_algorithm",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const OPTIONAL_FIELDS: Array<keyof StepRecord> = [
|
|
31
|
+
"instance_id",
|
|
32
|
+
"trace_id",
|
|
33
|
+
"span_id",
|
|
34
|
+
"parent_step_id",
|
|
35
|
+
"surface_type",
|
|
36
|
+
"surface_name",
|
|
37
|
+
"surface_instance",
|
|
38
|
+
"source_file",
|
|
39
|
+
"source_module",
|
|
40
|
+
"source_function",
|
|
41
|
+
"source_line",
|
|
42
|
+
"message_code",
|
|
43
|
+
"evidence_refs",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const ALLOWED_FIELDS = new Set<string>([...REQUIRED_FIELDS, ...OPTIONAL_FIELDS]);
|
|
47
|
+
const CONTEXT_FIELDS = ["context", "context_blob", "context_data", "context_value"];
|
|
48
|
+
|
|
49
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
50
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
51
|
+
throw new NonComplianceError(
|
|
52
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
53
|
+
"Step record must be a plain object."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return value as Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function assertStepRecord(record: unknown): asserts record is StepRecord {
|
|
60
|
+
const candidate = asRecord(record);
|
|
61
|
+
|
|
62
|
+
if (!Object.isFrozen(candidate)) {
|
|
63
|
+
throw new NonComplianceError(
|
|
64
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
65
|
+
"Step record must be immutable at emission time."
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const forbidden of CONTEXT_FIELDS) {
|
|
70
|
+
if (forbidden in candidate) {
|
|
71
|
+
throw new NonComplianceError(
|
|
72
|
+
CONTEXT_INJECTION_VIOLATION,
|
|
73
|
+
"Context must not be embedded in the step record."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const key of Object.keys(candidate)) {
|
|
79
|
+
if (!ALLOWED_FIELDS.has(key)) {
|
|
80
|
+
throw new NonComplianceError(
|
|
81
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
82
|
+
`Unexpected field in step record: ${key}.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const field of REQUIRED_FIELDS) {
|
|
88
|
+
if (!(field in candidate)) {
|
|
89
|
+
throw new NonComplianceError(
|
|
90
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
91
|
+
`Missing required field: ${field}.`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
assertStringField(candidate, "record_version");
|
|
97
|
+
assertStringField(candidate, "record_id");
|
|
98
|
+
assertIntegerField(candidate, "sequence");
|
|
99
|
+
assertStringField(candidate, "timestamp_utc");
|
|
100
|
+
assertNumberField(candidate, "monotonic_time");
|
|
101
|
+
assertStringField(candidate, "institution");
|
|
102
|
+
assertStringField(candidate, "system_name");
|
|
103
|
+
assertStringField(candidate, "system_type");
|
|
104
|
+
assertStringField(candidate, "environment");
|
|
105
|
+
assertStringField(candidate, "system_version");
|
|
106
|
+
assertStringField(candidate, "context_hash");
|
|
107
|
+
assertStringField(candidate, "context_version");
|
|
108
|
+
assertStringField(candidate, "record_hash");
|
|
109
|
+
assertStringField(candidate, "hash_algorithm");
|
|
110
|
+
|
|
111
|
+
if (candidate.record_version !== "log.step.v1") {
|
|
112
|
+
throw new NonComplianceError(
|
|
113
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
114
|
+
"record_version must be log.step.v1."
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
assertValidMessage(candidate.message);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
throw new NonComplianceError(
|
|
122
|
+
PAYLOAD_EMBEDDING_VIOLATION,
|
|
123
|
+
error instanceof Error ? error.message : "Invalid message content."
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (typeof candidate.timestamp_utc === "string") {
|
|
128
|
+
const parsed = Date.parse(candidate.timestamp_utc);
|
|
129
|
+
if (Number.isNaN(parsed)) {
|
|
130
|
+
throw new NonComplianceError(
|
|
131
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
132
|
+
"timestamp_utc must be a valid ISO-8601 string."
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
assertOptionalStringField(candidate, "instance_id");
|
|
138
|
+
assertOptionalStringField(candidate, "trace_id");
|
|
139
|
+
assertOptionalStringField(candidate, "span_id");
|
|
140
|
+
assertOptionalStringField(candidate, "parent_step_id");
|
|
141
|
+
assertOptionalStringField(candidate, "surface_type");
|
|
142
|
+
assertOptionalStringField(candidate, "surface_name");
|
|
143
|
+
assertOptionalStringField(candidate, "surface_instance");
|
|
144
|
+
assertOptionalStringField(candidate, "source_file");
|
|
145
|
+
assertOptionalStringField(candidate, "source_module");
|
|
146
|
+
assertOptionalStringField(candidate, "source_function");
|
|
147
|
+
assertOptionalStringField(candidate, "message_code");
|
|
148
|
+
|
|
149
|
+
if ("source_line" in candidate && candidate.source_line !== undefined) {
|
|
150
|
+
assertNumberField(candidate, "source_line");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if ("evidence_refs" in candidate && candidate.evidence_refs !== undefined) {
|
|
154
|
+
if (!Array.isArray(candidate.evidence_refs)) {
|
|
155
|
+
throw new NonComplianceError(
|
|
156
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
157
|
+
"evidence_refs must be an array of strings."
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
for (const ref of candidate.evidence_refs) {
|
|
161
|
+
if (typeof ref !== "string" || ref.length === 0) {
|
|
162
|
+
throw new NonComplianceError(
|
|
163
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
164
|
+
"evidence_refs must contain non-empty strings."
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function assertHasAuthoritativeSink(
|
|
172
|
+
sinks: ReadonlyArray<SinkEntry>
|
|
173
|
+
): void {
|
|
174
|
+
const hasAuthoritative = sinks.some(
|
|
175
|
+
(entry) => entry.sinkClass === "authoritative"
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (!hasAuthoritative) {
|
|
179
|
+
throw new NonComplianceError(
|
|
180
|
+
NO_AUTHORITATIVE_SINK,
|
|
181
|
+
"At least one authoritative sink is required."
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function assertStringField(
|
|
187
|
+
record: Record<string, unknown>,
|
|
188
|
+
field: string
|
|
189
|
+
): void {
|
|
190
|
+
const value = record[field];
|
|
191
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
192
|
+
throw new NonComplianceError(
|
|
193
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
194
|
+
`${field} must be a non-empty string.`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function assertOptionalStringField(
|
|
200
|
+
record: Record<string, unknown>,
|
|
201
|
+
field: string
|
|
202
|
+
): void {
|
|
203
|
+
if (field in record && record[field] !== undefined) {
|
|
204
|
+
const value = record[field];
|
|
205
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
206
|
+
throw new NonComplianceError(
|
|
207
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
208
|
+
`${field} must be a non-empty string when provided.`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function assertNumberField(
|
|
215
|
+
record: Record<string, unknown>,
|
|
216
|
+
field: string
|
|
217
|
+
): void {
|
|
218
|
+
const value = record[field];
|
|
219
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
220
|
+
throw new NonComplianceError(
|
|
221
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
222
|
+
`${field} must be a finite number.`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function assertIntegerField(
|
|
228
|
+
record: Record<string, unknown>,
|
|
229
|
+
field: string
|
|
230
|
+
): void {
|
|
231
|
+
const value = record[field];
|
|
232
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
233
|
+
throw new NonComplianceError(
|
|
234
|
+
CANONICAL_SCHEMA_VIOLATION,
|
|
235
|
+
`${field} must be a non-negative integer.`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { test, expect } from "vitest";
|
|
2
|
+
import type { StepRecord } from "../src/core/types";
|
|
3
|
+
import { fanout } from "../src/core/fanout";
|
|
4
|
+
import type { SinkEntry } from "../src/sinks/sink_types";
|
|
5
|
+
|
|
6
|
+
function buildRecord(): StepRecord {
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
record_version: "log.step.v1",
|
|
9
|
+
record_id: "rec_fanout_001",
|
|
10
|
+
sequence: 1,
|
|
11
|
+
timestamp_utc: new Date().toISOString(),
|
|
12
|
+
monotonic_time: Date.now(),
|
|
13
|
+
institution: "UnifyPlane",
|
|
14
|
+
system_name: "LogSDK",
|
|
15
|
+
system_type: "service",
|
|
16
|
+
environment: "test",
|
|
17
|
+
system_version: "1.0.0",
|
|
18
|
+
instance_id: "unit-test",
|
|
19
|
+
message: "Fanout check",
|
|
20
|
+
context_hash: "context_hash_001",
|
|
21
|
+
context_version: "log.context.v1",
|
|
22
|
+
record_hash: "hash_001",
|
|
23
|
+
hash_algorithm: "sha256",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("fanout OK path", async () => {
|
|
28
|
+
const order: string[] = [];
|
|
29
|
+
const sinks: SinkEntry[] = [
|
|
30
|
+
{
|
|
31
|
+
sinkClass: "authoritative",
|
|
32
|
+
sink: {
|
|
33
|
+
emit() {
|
|
34
|
+
order.push("authoritative");
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
sinkClass: "observability",
|
|
40
|
+
sink: {
|
|
41
|
+
emit() {
|
|
42
|
+
order.push("observability");
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const outcome = await fanout(buildRecord(), sinks);
|
|
49
|
+
expect(outcome).toBe("OK");
|
|
50
|
+
expect(order).toEqual(["authoritative", "observability"]);
|
|
51
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { test, expect, vi } from "vitest";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { SinkEntry } from "../src/sinks/sink_types";
|
|
6
|
+
import { fanout } from "../src/core/fanout";
|
|
7
|
+
import type { StepRecord } from "../src/core/types";
|
|
8
|
+
import * as spool from "../src/core/spool";
|
|
9
|
+
|
|
10
|
+
function buildRecord(recordId: string): StepRecord {
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
record_version: "log.step.v1",
|
|
13
|
+
record_id: recordId,
|
|
14
|
+
sequence: 0,
|
|
15
|
+
timestamp_utc: new Date().toISOString(),
|
|
16
|
+
monotonic_time: Date.now(),
|
|
17
|
+
institution: "UnifyPlane",
|
|
18
|
+
system_name: "LogSDK",
|
|
19
|
+
system_type: "service",
|
|
20
|
+
environment: "test",
|
|
21
|
+
system_version: "1.0.0",
|
|
22
|
+
instance_id: "unit-test",
|
|
23
|
+
message: "Spool check",
|
|
24
|
+
context_hash: "context_hash_001",
|
|
25
|
+
context_version: "log.context.v1",
|
|
26
|
+
record_hash: "hash_001",
|
|
27
|
+
hash_algorithm: "sha256",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test("fanout returns DEGRADED when authoritative sink fails but spool succeeds", async () => {
|
|
32
|
+
const spoolPath = path.join(
|
|
33
|
+
os.tmpdir(),
|
|
34
|
+
`logsdk-spool-${Date.now()}-${Math.random()}.ndjson`
|
|
35
|
+
);
|
|
36
|
+
const previousSpool = process.env.LOGSDK_EMERGENCY_SPOOL_PATH;
|
|
37
|
+
process.env.LOGSDK_EMERGENCY_SPOOL_PATH = spoolPath;
|
|
38
|
+
|
|
39
|
+
const sinks: SinkEntry[] = [
|
|
40
|
+
{
|
|
41
|
+
sinkClass: "authoritative",
|
|
42
|
+
sink: {
|
|
43
|
+
emit() {
|
|
44
|
+
throw new Error("boom");
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
sinkClass: "observability",
|
|
50
|
+
sink: {
|
|
51
|
+
emit() {
|
|
52
|
+
// best effort
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const outcome = await fanout(buildRecord("rec_spool_001"), sinks);
|
|
60
|
+
expect(outcome).toBe("DEGRADED");
|
|
61
|
+
|
|
62
|
+
const content = await fs.readFile(spoolPath, "utf8");
|
|
63
|
+
expect(content).toContain("rec_spool_001");
|
|
64
|
+
} finally {
|
|
65
|
+
await fs.rm(spoolPath, { force: true });
|
|
66
|
+
if (previousSpool === undefined) {
|
|
67
|
+
delete process.env.LOGSDK_EMERGENCY_SPOOL_PATH;
|
|
68
|
+
} else {
|
|
69
|
+
process.env.LOGSDK_EMERGENCY_SPOOL_PATH = previousSpool;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("fanout returns FAILED when emergency spool cannot persist", async () => {
|
|
75
|
+
const sinks: SinkEntry[] = [
|
|
76
|
+
{
|
|
77
|
+
sinkClass: "authoritative",
|
|
78
|
+
sink: {
|
|
79
|
+
emit() {
|
|
80
|
+
throw new Error("boom");
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const spy = vi
|
|
87
|
+
.spyOn(spool, "writeEmergencySpool")
|
|
88
|
+
.mockRejectedValue(new Error("spool failure"));
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const outcome = await fanout(buildRecord("rec_spool_002"), sinks);
|
|
92
|
+
expect(outcome).toBe("FAILED");
|
|
93
|
+
} finally {
|
|
94
|
+
spy.mockRestore();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { test, expect } from "vitest";
|
|
2
|
+
import { assertValidMessage } from "../src/core/message_constraints";
|
|
3
|
+
|
|
4
|
+
test("message constraints accept bounded message", () => {
|
|
5
|
+
const message = "Step completed successfully.";
|
|
6
|
+
expect(assertValidMessage(message)).toBe(message);
|
|
7
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { test, expect } from "vitest";
|
|
2
|
+
import type { StepRecord } from "../src/core/types";
|
|
3
|
+
import { assertStepRecord } from "../src/validate/schema_guard";
|
|
4
|
+
|
|
5
|
+
function buildRecord(overrides: Partial<StepRecord> = {}): StepRecord {
|
|
6
|
+
const record: StepRecord = {
|
|
7
|
+
record_version: "log.step.v1",
|
|
8
|
+
record_id: "rec_test_001",
|
|
9
|
+
sequence: 1,
|
|
10
|
+
timestamp_utc: new Date().toISOString(),
|
|
11
|
+
monotonic_time: Date.now(),
|
|
12
|
+
institution: "UnifyPlane",
|
|
13
|
+
system_name: "LogSDK",
|
|
14
|
+
system_type: "service",
|
|
15
|
+
environment: "test",
|
|
16
|
+
system_version: "1.0.0",
|
|
17
|
+
instance_id: "unit-test",
|
|
18
|
+
message: "Step emitted",
|
|
19
|
+
context_hash: "context_hash_001",
|
|
20
|
+
context_version: "log.context.v1",
|
|
21
|
+
record_hash: "hash_001",
|
|
22
|
+
hash_algorithm: "sha256",
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return Object.freeze(record);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("valid step record emitted", () => {
|
|
30
|
+
const record = buildRecord();
|
|
31
|
+
expect(() => assertStepRecord(record)).not.toThrow();
|
|
32
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { beforeEach, test, expect } from "vitest";
|
|
2
|
+
import { initLogSDK } from "../src/index";
|
|
3
|
+
import { resetContextForTests } from "../src/core/context";
|
|
4
|
+
import type { StepRecord } from "../src/core/types";
|
|
5
|
+
|
|
6
|
+
const baseSystem = {
|
|
7
|
+
institution: "UnifyPlane",
|
|
8
|
+
system_name: "LogSDK",
|
|
9
|
+
system_type: "service",
|
|
10
|
+
environment: "test",
|
|
11
|
+
system_version: "1.0.0",
|
|
12
|
+
instance_id: "unit-test",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
resetContextForTests();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function createCaptureSink(records: StepRecord[]) {
|
|
20
|
+
return {
|
|
21
|
+
sinkClass: "authoritative" as const,
|
|
22
|
+
sink: {
|
|
23
|
+
emit(record: StepRecord) {
|
|
24
|
+
records.push(record);
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("emitted sequences start at 0 and increment by 1", async () => {
|
|
31
|
+
const records: StepRecord[] = [];
|
|
32
|
+
const log = initLogSDK({
|
|
33
|
+
context: { build: "test" },
|
|
34
|
+
system: baseSystem,
|
|
35
|
+
sinks: [createCaptureSink(records)],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await log.step("first step");
|
|
39
|
+
await log.step("second step");
|
|
40
|
+
|
|
41
|
+
expect(records[0].sequence).toBe(0);
|
|
42
|
+
expect(records[1].sequence).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("monotonic_time strictly increases across successive steps", async () => {
|
|
46
|
+
const records: StepRecord[] = [];
|
|
47
|
+
const log = initLogSDK({
|
|
48
|
+
context: { build: "test" },
|
|
49
|
+
system: baseSystem,
|
|
50
|
+
sinks: [createCaptureSink(records)],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const steps = 5;
|
|
54
|
+
for (let i = 0; i < steps; i += 1) {
|
|
55
|
+
await log.step(`step ${i}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
expect(records.length).toBe(steps);
|
|
59
|
+
for (let i = 1; i < records.length; i += 1) {
|
|
60
|
+
expect(records[i].monotonic_time).toBeGreaterThan(records[i - 1].monotonic_time);
|
|
61
|
+
}
|
|
62
|
+
});
|