@zmdb/app 1.0.0-beta.1
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/LICENSE +674 -0
- package/README.md +27 -0
- package/dist/application.d.ts +45 -0
- package/dist/application.d.ts.map +1 -0
- package/dist/application.js +186 -0
- package/dist/application.js.map +1 -0
- package/dist/commands/index.d.ts +31 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +338 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/cqrs/index.d.ts +47 -0
- package/dist/cqrs/index.d.ts.map +1 -0
- package/dist/cqrs/index.js +76 -0
- package/dist/cqrs/index.js.map +1 -0
- package/dist/data/index.d.ts +12 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +25 -0
- package/dist/data/index.js.map +1 -0
- package/dist/di/index.d.ts +44 -0
- package/dist/di/index.d.ts.map +1 -0
- package/dist/di/index.js +136 -0
- package/dist/di/index.js.map +1 -0
- package/dist/events/index.d.ts +46 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/index.js +181 -0
- package/dist/events/index.js.map +1 -0
- package/dist/health/index.d.ts +35 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +14 -0
- package/dist/health/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle.d.ts +21 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +52 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/messaging/index.d.ts +176 -0
- package/dist/messaging/index.d.ts.map +1 -0
- package/dist/messaging/index.js +442 -0
- package/dist/messaging/index.js.map +1 -0
- package/dist/messaging/transport-kit.d.ts +22 -0
- package/dist/messaging/transport-kit.d.ts.map +1 -0
- package/dist/messaging/transport-kit.js +174 -0
- package/dist/messaging/transport-kit.js.map +1 -0
- package/dist/modules/index.d.ts +52 -0
- package/dist/modules/index.d.ts.map +1 -0
- package/dist/modules/index.js +351 -0
- package/dist/modules/index.js.map +1 -0
- package/dist/modules/lifecycle-instances.d.ts +4 -0
- package/dist/modules/lifecycle-instances.d.ts.map +1 -0
- package/dist/modules/lifecycle-instances.js +19 -0
- package/dist/modules/lifecycle-instances.js.map +1 -0
- package/dist/modules/runtime.d.ts +18 -0
- package/dist/modules/runtime.d.ts.map +1 -0
- package/dist/modules/runtime.js +8 -0
- package/dist/modules/runtime.js.map +1 -0
- package/dist/observability/index.d.ts +27 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +145 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/propagation.d.ts +10 -0
- package/dist/observability/propagation.d.ts.map +1 -0
- package/dist/observability/propagation.js +96 -0
- package/dist/observability/propagation.js.map +1 -0
- package/dist/observability/types.d.ts +66 -0
- package/dist/observability/types.d.ts.map +1 -0
- package/dist/observability/types.js +10 -0
- package/dist/observability/types.js.map +1 -0
- package/dist/polyfill.d.ts +2 -0
- package/dist/polyfill.d.ts.map +1 -0
- package/dist/polyfill.js +21 -0
- package/dist/polyfill.js.map +1 -0
- package/dist/state/fixtures.d.ts +10 -0
- package/dist/state/fixtures.d.ts.map +1 -0
- package/dist/state/fixtures.js +14 -0
- package/dist/state/fixtures.js.map +1 -0
- package/dist/state/index.d.ts +26 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +37 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state-transitions.d.ts +57 -0
- package/dist/state-transitions.d.ts.map +1 -0
- package/dist/state-transitions.js +31 -0
- package/dist/state-transitions.js.map +1 -0
- package/package.json +99 -0
- package/src/application.ts +243 -0
- package/src/commands/index.ts +447 -0
- package/src/cqrs/index.ts +124 -0
- package/src/data/index.ts +33 -0
- package/src/di/index.ts +179 -0
- package/src/events/index.ts +248 -0
- package/src/health/index.ts +54 -0
- package/src/index.ts +68 -0
- package/src/lifecycle.ts +62 -0
- package/src/messaging/index.ts +668 -0
- package/src/messaging/transport-kit.ts +234 -0
- package/src/modules/index.ts +476 -0
- package/src/modules/lifecycle-instances.ts +23 -0
- package/src/modules/runtime.ts +26 -0
- package/src/observability/index.ts +204 -0
- package/src/observability/propagation.ts +107 -0
- package/src/observability/types.ts +68 -0
- package/src/polyfill.ts +24 -0
- package/src/state/fixtures.ts +20 -0
- package/src/state/index.ts +57 -0
- package/src/state-transitions.ts +150 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Container } from '../di/index.js';
|
|
2
|
+
|
|
3
|
+
const ledgers = new WeakMap<Container, object[]>();
|
|
4
|
+
|
|
5
|
+
export function createLifecycleRecorder(container: Container): (value: unknown) => void {
|
|
6
|
+
const instances: object[] = [];
|
|
7
|
+
const seen = new Set<object>();
|
|
8
|
+
ledgers.set(container, instances);
|
|
9
|
+
|
|
10
|
+
return value => {
|
|
11
|
+
if (!isObject(value) || seen.has(value)) return;
|
|
12
|
+
seen.add(value);
|
|
13
|
+
instances.push(value);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function lifecycleInstances(container: Container): readonly object[] {
|
|
18
|
+
return ledgers.get(container) ?? [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isObject(value: unknown): value is object {
|
|
22
|
+
return (typeof value === 'object' && value !== null) || typeof value === 'function';
|
|
23
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Constructor } from '../di/index.js';
|
|
2
|
+
import type { CompiledModule } from './index.js';
|
|
3
|
+
|
|
4
|
+
export type CompiledController =
|
|
5
|
+
| { readonly kind: 'eager'; readonly controller: object }
|
|
6
|
+
| {
|
|
7
|
+
readonly kind: 'deferred';
|
|
8
|
+
readonly controller: Constructor<object>;
|
|
9
|
+
readonly instance: () => Promise<object>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface CompiledModuleRuntime {
|
|
13
|
+
readonly controllers: readonly CompiledController[];
|
|
14
|
+
beginShutdown(): void;
|
|
15
|
+
waitForLoads(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const runtimes = new WeakMap<CompiledModule, CompiledModuleRuntime>();
|
|
19
|
+
|
|
20
|
+
export function rememberRuntime(compiled: CompiledModule, runtime: CompiledModuleRuntime): void {
|
|
21
|
+
runtimes.set(compiled, runtime);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function runtimeOf(compiled: CompiledModule): CompiledModuleRuntime | undefined {
|
|
25
|
+
return runtimes.get(compiled);
|
|
26
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { type ExecuteOptions } from '@zmdb/orm';
|
|
2
|
+
import { appendComment, type CommentKey, type CommentPairs, type CompiledQuery, type SqlDialect } from '@zmdb/sql';
|
|
3
|
+
|
|
4
|
+
import { fromTraceContext, toTraceparent } from './propagation.js';
|
|
5
|
+
import { SpanKind, type Attributes, type Observability, type Span, type TraceCarrier, type Tracer } from './types.js';
|
|
6
|
+
|
|
7
|
+
export { fromTraceContext, fromTraceparent, toTraceHeaders, toTraceparent } from './propagation.js';
|
|
8
|
+
export { SpanKind } from './types.js';
|
|
9
|
+
export type {
|
|
10
|
+
Attributes,
|
|
11
|
+
CommentKey,
|
|
12
|
+
CommentKeys,
|
|
13
|
+
CommentPairs,
|
|
14
|
+
Meter,
|
|
15
|
+
Observability,
|
|
16
|
+
QueryTelemetry,
|
|
17
|
+
Span,
|
|
18
|
+
SpanContext,
|
|
19
|
+
SpanOptions,
|
|
20
|
+
TraceCarrier,
|
|
21
|
+
Tracer,
|
|
22
|
+
} from './types.js';
|
|
23
|
+
|
|
24
|
+
/** Structural driver surface instrumented without importing a concrete driver. */
|
|
25
|
+
export interface ExecutingDriver {
|
|
26
|
+
readonly dialect?: SqlDialect;
|
|
27
|
+
readonly queryTelemetry?: true;
|
|
28
|
+
execute(query: CompiledQuery, options?: ExecuteOptions): Promise<readonly Record<string, unknown>[]>;
|
|
29
|
+
stream?(query: CompiledQuery, options?: ExecuteOptions): AsyncIterable<Record<string, unknown>>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Add database spans, metrics and optional sqlcommenter tags at execution.
|
|
34
|
+
*
|
|
35
|
+
* `commentValues` is request-scoped and supplies the low-cardinality values.
|
|
36
|
+
* When selected, `traceparent` comes from the query span (or the supplied
|
|
37
|
+
* parent when no tracer is configured), not from the callback.
|
|
38
|
+
*
|
|
39
|
+
* With no port configured, return the original object: no wrapper, marker,
|
|
40
|
+
* closure or per-query branch is added to the off path.
|
|
41
|
+
*/
|
|
42
|
+
export function tracedDriver(
|
|
43
|
+
driver: ExecutingDriver,
|
|
44
|
+
observability: Observability,
|
|
45
|
+
parent?: Span,
|
|
46
|
+
commentValues?: () => CommentPairs,
|
|
47
|
+
): ExecutingDriver {
|
|
48
|
+
const { tracer, meter, comments } = observability;
|
|
49
|
+
if (tracer === undefined && meter === undefined && comments === undefined) {
|
|
50
|
+
return driver;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const duration = meter?.histogram('db.client.operation.duration', 's');
|
|
54
|
+
const execute = async (
|
|
55
|
+
query: CompiledQuery,
|
|
56
|
+
options?: ExecuteOptions,
|
|
57
|
+
): Promise<readonly Record<string, unknown>[]> => {
|
|
58
|
+
const telemetry = query.telemetry;
|
|
59
|
+
const span = startQuerySpan(tracer, telemetry, parent);
|
|
60
|
+
if (span !== undefined) {
|
|
61
|
+
if (telemetry !== undefined) {
|
|
62
|
+
span.setAttribute('db.system.name', telemetry.system);
|
|
63
|
+
span.setAttribute('db.operation.name', telemetry.operation);
|
|
64
|
+
span.setAttribute('db.collection.name', telemetry.collection);
|
|
65
|
+
}
|
|
66
|
+
// Compiled SQL contains placeholders; parameter values are deliberately
|
|
67
|
+
// absent from every telemetry surface.
|
|
68
|
+
span.setAttribute('db.query.text', query.text);
|
|
69
|
+
span.setAttribute('zmdb.db.parameter_count', query.parameters.length);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const started = Date.now();
|
|
73
|
+
try {
|
|
74
|
+
const executed =
|
|
75
|
+
comments === undefined ? query : queryWithComments(query, comments.keys, commentValues?.(), span ?? parent);
|
|
76
|
+
return await driver.execute(executed, options);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (span !== undefined) {
|
|
79
|
+
const recorded = errorValue(error);
|
|
80
|
+
span.recordException(recorded);
|
|
81
|
+
span.setStatus({ error: true });
|
|
82
|
+
const code = errorCode(error);
|
|
83
|
+
if (code !== undefined) {
|
|
84
|
+
span.setAttribute('db.response.status_code', code);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
} finally {
|
|
89
|
+
if (duration !== undefined && telemetry !== undefined) {
|
|
90
|
+
const attributes: Attributes = {
|
|
91
|
+
'db.system.name': telemetry.system,
|
|
92
|
+
'db.operation.name': telemetry.operation,
|
|
93
|
+
'db.collection.name': telemetry.collection,
|
|
94
|
+
};
|
|
95
|
+
duration.record((Date.now() - started) / 1000, attributes);
|
|
96
|
+
}
|
|
97
|
+
span?.end();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const wrapped = { ...driver, execute };
|
|
102
|
+
return tracer === undefined && meter === undefined ? wrapped : { ...wrapped, queryTelemetry: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Start the consumer side of one propagated message delivery. */
|
|
106
|
+
export function consumerSpan(
|
|
107
|
+
observability: Observability,
|
|
108
|
+
envelope: TraceCarrier,
|
|
109
|
+
delivery: 'queued' | 'request-reply',
|
|
110
|
+
): Span {
|
|
111
|
+
const tracer = observability.tracer;
|
|
112
|
+
if (tracer === undefined) {
|
|
113
|
+
throw new Error('@zmdb/app: consumerSpan requires a configured tracer');
|
|
114
|
+
}
|
|
115
|
+
const remote = fromTraceContext(envelope.traceparent, envelope.tracestate);
|
|
116
|
+
if (remote === undefined) {
|
|
117
|
+
return tracer.startSpan('zmdb.message', { kind: SpanKind.CONSUMER });
|
|
118
|
+
}
|
|
119
|
+
return delivery === 'queued'
|
|
120
|
+
? tracer.startSpan('zmdb.message', { kind: SpanKind.CONSUMER, link: remote })
|
|
121
|
+
: tracer.startSpan('zmdb.message', { kind: SpanKind.CONSUMER, parent: remote });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function startQuerySpan(
|
|
125
|
+
tracer: Tracer | undefined,
|
|
126
|
+
telemetry: CompiledQuery['telemetry'],
|
|
127
|
+
parent: Span | undefined,
|
|
128
|
+
): Span | undefined {
|
|
129
|
+
if (tracer === undefined) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
const name = telemetry === undefined ? 'db.query' : `${telemetry.operation} ${telemetry.collection}`;
|
|
133
|
+
return parent === undefined
|
|
134
|
+
? tracer.startSpan(name, { kind: SpanKind.CLIENT })
|
|
135
|
+
: tracer.startSpan(name, { kind: SpanKind.CLIENT, parent: parent.spanContext() });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function errorValue(error: unknown): Error {
|
|
139
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function errorCode(error: unknown): string | number | undefined {
|
|
143
|
+
if (error === null || typeof error !== 'object' || !('code' in error)) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
const code: unknown = error.code;
|
|
147
|
+
return typeof code === 'string' || typeof code === 'number' ? code : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function queryWithComments(
|
|
151
|
+
query: CompiledQuery,
|
|
152
|
+
keys: readonly CommentKey[],
|
|
153
|
+
source: CommentPairs | undefined,
|
|
154
|
+
span: Span | undefined,
|
|
155
|
+
): CompiledQuery {
|
|
156
|
+
const pairs = selectedCommentPairs(keys, source, span);
|
|
157
|
+
const text = appendComment(query.text, pairs);
|
|
158
|
+
return text === query.text ? query : { ...query, text };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function selectedCommentPairs(
|
|
162
|
+
keys: readonly CommentKey[],
|
|
163
|
+
source: CommentPairs | undefined,
|
|
164
|
+
span: Span | undefined,
|
|
165
|
+
): CommentPairs {
|
|
166
|
+
const selected = new Map<CommentKey, string>();
|
|
167
|
+
for (const key of keys) {
|
|
168
|
+
const value = key === 'traceparent' && span !== undefined ? toTraceparent(span) : ownCommentValue(source, key);
|
|
169
|
+
if (value !== undefined) {
|
|
170
|
+
selected.set(key, value);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const action = selected.get('action');
|
|
175
|
+
const controller = selected.get('controller');
|
|
176
|
+
const framework = selected.get('framework');
|
|
177
|
+
const route = selected.get('route');
|
|
178
|
+
const traceparent = selected.get('traceparent');
|
|
179
|
+
return {
|
|
180
|
+
...(action === undefined ? {} : { action }),
|
|
181
|
+
...(controller === undefined ? {} : { controller }),
|
|
182
|
+
...(framework === undefined ? {} : { framework }),
|
|
183
|
+
...(route === undefined ? {} : { route }),
|
|
184
|
+
...(traceparent === undefined ? {} : { traceparent }),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function ownCommentValue(source: CommentPairs | undefined, key: CommentKey): string | undefined {
|
|
189
|
+
if (source === undefined || !Object.hasOwn(source, key)) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
switch (key) {
|
|
193
|
+
case 'action':
|
|
194
|
+
return source.action;
|
|
195
|
+
case 'controller':
|
|
196
|
+
return source.controller;
|
|
197
|
+
case 'framework':
|
|
198
|
+
return source.framework;
|
|
199
|
+
case 'route':
|
|
200
|
+
return source.route;
|
|
201
|
+
case 'traceparent':
|
|
202
|
+
return source.traceparent;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Span, SpanContext, TraceCarrier } from './types.js';
|
|
2
|
+
|
|
3
|
+
const LOWER_HEX = /^[0-9a-f]+$/;
|
|
4
|
+
const ZERO_TRACE_ID = '00000000000000000000000000000000';
|
|
5
|
+
const ZERO_SPAN_ID = '0000000000000000';
|
|
6
|
+
const TRACE_STATE_KEY = /^(?:[a-z][_0-9a-z\-*/]{0,255}|[a-z0-9][_0-9a-z\-*/]{0,240}@[a-z][_0-9a-z\-*/]{0,13})$/;
|
|
7
|
+
const TRACE_STATE_VALUE = /^[ -~]{0,255}[!-~]$/;
|
|
8
|
+
|
|
9
|
+
/** Parse one W3C traceparent value, ignoring malformed input. */
|
|
10
|
+
export function fromTraceparent(header: string): SpanContext | undefined {
|
|
11
|
+
return fromTraceContext(header);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Parse a W3C trace context, keeping a valid parent when tracestate is invalid. */
|
|
15
|
+
export function fromTraceContext(traceparent: string | undefined, tracestate?: string): SpanContext | undefined {
|
|
16
|
+
if (traceparent === undefined) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const fields = traceparent.split('-');
|
|
20
|
+
if (fields.length < 4) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const version = fields[0];
|
|
24
|
+
const traceId = fields[1];
|
|
25
|
+
const spanId = fields[2];
|
|
26
|
+
const flags = fields[3];
|
|
27
|
+
if (
|
|
28
|
+
version === undefined ||
|
|
29
|
+
traceId === undefined ||
|
|
30
|
+
spanId === undefined ||
|
|
31
|
+
flags === undefined ||
|
|
32
|
+
version.length !== 2 ||
|
|
33
|
+
traceId.length !== 32 ||
|
|
34
|
+
spanId.length !== 16 ||
|
|
35
|
+
flags.length !== 2 ||
|
|
36
|
+
!LOWER_HEX.test(version) ||
|
|
37
|
+
!LOWER_HEX.test(traceId) ||
|
|
38
|
+
!LOWER_HEX.test(spanId) ||
|
|
39
|
+
!LOWER_HEX.test(flags) ||
|
|
40
|
+
version === 'ff' ||
|
|
41
|
+
traceId === ZERO_TRACE_ID ||
|
|
42
|
+
spanId === ZERO_SPAN_ID ||
|
|
43
|
+
(version === '00' && fields.length !== 4)
|
|
44
|
+
) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const traceState = validatedTraceState(tracestate);
|
|
48
|
+
return {
|
|
49
|
+
traceId,
|
|
50
|
+
spanId,
|
|
51
|
+
traceFlags: Number.parseInt(flags, 16),
|
|
52
|
+
isRemote: true,
|
|
53
|
+
...(traceState === undefined ? {} : { traceState }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Render the current span as a W3C traceparent value for an outbound carrier. */
|
|
58
|
+
export function toTraceparent(span: Span): string {
|
|
59
|
+
return formatTraceparent(span.spanContext());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatTraceparent(context: SpanContext): string {
|
|
63
|
+
const flags = (context.traceFlags & 0xff).toString(16).padStart(2, '0');
|
|
64
|
+
return `00-${context.traceId.toLowerCase()}-${context.spanId.toLowerCase()}-${flags}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Render the current span as the common outbound W3C trace carrier. */
|
|
68
|
+
export function toTraceHeaders(span: Span): TraceCarrier {
|
|
69
|
+
const context = span.spanContext();
|
|
70
|
+
const traceState = validatedTraceState(context.traceState);
|
|
71
|
+
return {
|
|
72
|
+
traceparent: formatTraceparent(context),
|
|
73
|
+
...(traceState === undefined ? {} : { tracestate: traceState }),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validatedTraceState(header: string | undefined): string | undefined {
|
|
78
|
+
if (header === undefined || header.length === 0 || header.length > 512) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const members = header.split(',');
|
|
82
|
+
if (members.length > 32) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const keys = new Set<string>();
|
|
87
|
+
const normalized: string[] = [];
|
|
88
|
+
for (const rawMember of members) {
|
|
89
|
+
const member = trimOptionalWhitespace(rawMember);
|
|
90
|
+
const separator = member.indexOf('=');
|
|
91
|
+
if (separator <= 0 || member.indexOf('=', separator + 1) !== -1) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
const key = member.slice(0, separator);
|
|
95
|
+
const value = member.slice(separator + 1);
|
|
96
|
+
if (!TRACE_STATE_KEY.test(key) || !TRACE_STATE_VALUE.test(value) || value.includes(',') || keys.has(key)) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
keys.add(key);
|
|
100
|
+
normalized.push(member);
|
|
101
|
+
}
|
|
102
|
+
return normalized.join(',');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function trimOptionalWhitespace(value: string): string {
|
|
106
|
+
return value.replace(/^[\t ]+|[\t ]+$/g, '');
|
|
107
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type CommentKey, type CommentKeys, type CommentPairs, type QueryTelemetry } from '@zmdb/sql';
|
|
2
|
+
|
|
3
|
+
/** Scalar attributes accepted by the framework's telemetry ports. */
|
|
4
|
+
export type Attributes = Readonly<Record<string, string | number | boolean>>;
|
|
5
|
+
|
|
6
|
+
/** Span roles understood by the framework's telemetry port. */
|
|
7
|
+
export const SpanKind = {
|
|
8
|
+
INTERNAL: 'internal',
|
|
9
|
+
SERVER: 'server',
|
|
10
|
+
CLIENT: 'client',
|
|
11
|
+
PRODUCER: 'producer',
|
|
12
|
+
CONSUMER: 'consumer',
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export type SpanKind = (typeof SpanKind)[keyof typeof SpanKind];
|
|
16
|
+
|
|
17
|
+
/** Trace identity carried explicitly between framework layers. */
|
|
18
|
+
export interface SpanContext {
|
|
19
|
+
readonly traceId: string;
|
|
20
|
+
readonly spanId: string;
|
|
21
|
+
readonly traceFlags: number;
|
|
22
|
+
readonly isRemote?: boolean;
|
|
23
|
+
/** W3C tracestate serialized for transport without an SDK type dependency. */
|
|
24
|
+
readonly traceState?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The common W3C trace carrier used by HTTP and message transports. */
|
|
28
|
+
export interface TraceCarrier {
|
|
29
|
+
readonly traceparent?: string;
|
|
30
|
+
readonly tracestate?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The narrow span surface used by @zmdb/app. */
|
|
34
|
+
export interface Span {
|
|
35
|
+
updateName(name: string): void;
|
|
36
|
+
setAttribute(key: string, value: string | number | boolean): void;
|
|
37
|
+
recordException(error: Error): void;
|
|
38
|
+
setStatus(status: { readonly error: boolean }): void;
|
|
39
|
+
end(): void;
|
|
40
|
+
spanContext(): SpanContext;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Explicit span creation inputs; ambient context is deliberately absent. */
|
|
44
|
+
export interface SpanOptions {
|
|
45
|
+
readonly parent?: SpanContext;
|
|
46
|
+
readonly link?: SpanContext;
|
|
47
|
+
readonly kind?: SpanKind;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The narrow tracer surface used by @zmdb/app. */
|
|
51
|
+
export interface Tracer {
|
|
52
|
+
startSpan(name: string, options?: SpanOptions): Span;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The narrow meter surface used by @zmdb/app. */
|
|
56
|
+
export interface Meter {
|
|
57
|
+
counter(name: string): { add(value: number, attributes: Attributes): void };
|
|
58
|
+
histogram(name: string, unit: 's'): { record(value: number, attributes: Attributes): void };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** App-owned telemetry configuration. Presence means enabled. */
|
|
62
|
+
export interface Observability {
|
|
63
|
+
readonly tracer?: Tracer;
|
|
64
|
+
readonly meter?: Meter;
|
|
65
|
+
readonly comments?: { readonly keys: CommentKeys };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type { CommentKey, CommentKeys, CommentPairs, QueryTelemetry };
|
package/src/polyfill.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Zero-dependency Symbol.metadata baseline.
|
|
2
|
+
//
|
|
3
|
+
// Stage-3 decorator metadata relies on the well-known `Symbol.metadata`. Node 26
|
|
4
|
+
// / V8 does not yet expose it (`Symbol.metadata === undefined` as of v26.8), so
|
|
5
|
+
// we install it if — and only if — the runtime lacks it. We assign ONLY the
|
|
6
|
+
// well-known symbol and mutate nothing else on any global. When a future runtime
|
|
7
|
+
// ships it natively this becomes a no-op.
|
|
8
|
+
//
|
|
9
|
+
// This module has a side effect and must be imported before any decorated class
|
|
10
|
+
// is evaluated. `@zmdb/app`'s entry imports it first.
|
|
11
|
+
|
|
12
|
+
interface SymbolWithMetadata {
|
|
13
|
+
metadata?: symbol;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const carrier: SymbolWithMetadata = Symbol;
|
|
17
|
+
if (carrier.metadata === undefined) {
|
|
18
|
+
Object.defineProperty(Symbol, 'metadata', {
|
|
19
|
+
value: Symbol.for('Symbol.metadata'),
|
|
20
|
+
writable: false,
|
|
21
|
+
enumerable: false,
|
|
22
|
+
configurable: true,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The order workflow both state tests are about.
|
|
2
|
+
//
|
|
3
|
+
// `state.spec.ts` checks what branding does at runtime (nothing) and
|
|
4
|
+
// `state.type-test.ts` checks which transitions compile. Those are two halves of
|
|
5
|
+
// one claim, so they read the states and edges from here rather than each
|
|
6
|
+
// declaring their own — the type-test's extra `Shipped` state exists precisely so
|
|
7
|
+
// there is a `Draft -> Shipped` edge that was never defined.
|
|
8
|
+
import { defineState, transition } from './index.js';
|
|
9
|
+
|
|
10
|
+
export interface Order {
|
|
11
|
+
id: number;
|
|
12
|
+
total: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const Draft = defineState<'Draft', Order>();
|
|
16
|
+
export const Paid = defineState<'Paid', Order>();
|
|
17
|
+
export const Shipped = defineState<'Shipped', Order>();
|
|
18
|
+
|
|
19
|
+
export const pay = transition(Draft, Paid, o => ({ ...o }));
|
|
20
|
+
export const ship = transition(Paid, Shipped, o => ({ ...o }));
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @zmdb/app — compile-time domain state machines (epic #267, spec ./SPEC.md).
|
|
2
|
+
// Branded/phantom types make illegal transitions fail to compile; branding
|
|
3
|
+
// erases at runtime (0 cost). No `as` on the consumer surface — construction
|
|
4
|
+
// goes through a checked factory. No reflection.
|
|
5
|
+
|
|
6
|
+
// A unique phantom brand key so branded types are nominal (structurally
|
|
7
|
+
// incompatible across brands) yet erase to the base type at runtime.
|
|
8
|
+
declare const brand: unique symbol;
|
|
9
|
+
|
|
10
|
+
/** `T` nominally tagged with brand `B`. Erases to `T` at runtime. */
|
|
11
|
+
export type Brand<T, B extends string> = T & { readonly [brand]: B };
|
|
12
|
+
|
|
13
|
+
/** A state maker for a named state over a base type. */
|
|
14
|
+
export interface State<B extends string, T> {
|
|
15
|
+
/** Construct a branded value from base fields — no consumer `as`. */
|
|
16
|
+
create(value: T): Brand<T, B>;
|
|
17
|
+
/** Type guard narrowing an unknown/base value to this branded state. */
|
|
18
|
+
is(value: unknown): value is Brand<T, B>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Define a domain state. Returns a maker whose `create` brands base values and
|
|
23
|
+
* whose `is` narrows to the branded type. The brand exists only in the type
|
|
24
|
+
* system, so `create` is an identity at runtime.
|
|
25
|
+
*/
|
|
26
|
+
export function defineState<B extends string, T>(): State<B, T> {
|
|
27
|
+
return {
|
|
28
|
+
create(value: T): Brand<T, B> {
|
|
29
|
+
// boundary: branding is a compile-time-only tag (the brand symbol has no
|
|
30
|
+
// runtime representation), so returning the value unchanged as the branded
|
|
31
|
+
// type is sound. This is the single enumerated brand-attach boundary
|
|
32
|
+
// (ARCHITECTURE.md §2.1); it never appears on the consumer surface.
|
|
33
|
+
return value as Brand<T, B>;
|
|
34
|
+
},
|
|
35
|
+
is(value: unknown): value is Brand<T, B> {
|
|
36
|
+
// The brand is erased at runtime; a value "is" this state by construction.
|
|
37
|
+
// We can only assert non-nullish here — the compile-time brand carries the
|
|
38
|
+
// real guarantee. Callers use `is` to narrow values they already trust.
|
|
39
|
+
return value !== undefined && value !== null;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Declare a legal state transition `From -> To`. The returned function only
|
|
46
|
+
* accepts a value already branded `From`, so applying it to any other state is
|
|
47
|
+
* a compile error, and undeclared edges simply have no function.
|
|
48
|
+
*/
|
|
49
|
+
export function transition<T, From extends string, To extends string>(
|
|
50
|
+
_from: State<From, T>,
|
|
51
|
+
to: State<To, T>,
|
|
52
|
+
fn: (value: Brand<T, From>) => T,
|
|
53
|
+
): (value: Brand<T, From>) => Brand<T, To> {
|
|
54
|
+
return function (value: Brand<T, From>): Brand<T, To> {
|
|
55
|
+
return to.create(fn(value));
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { DeclaredTable, TaggedSchema, UpdateDTO } from '@zmdb/schema';
|
|
2
|
+
|
|
3
|
+
export type StateTransitions<StateValues extends string | number | symbol = string> = {
|
|
4
|
+
readonly [From in StateValues]?: readonly StateValues[];
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function defineStateTransitions<
|
|
8
|
+
StateValues extends string,
|
|
9
|
+
const T extends { readonly [From in StateValues]?: readonly StateValues[] },
|
|
10
|
+
>(transitions: T): T {
|
|
11
|
+
return transitions;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type AllowedTargetStates<Transitions, From extends string> = Transitions extends {
|
|
15
|
+
readonly [k in From]?: readonly (infer To extends string)[];
|
|
16
|
+
}
|
|
17
|
+
? To
|
|
18
|
+
: never;
|
|
19
|
+
|
|
20
|
+
export type StateUpdateDTO<
|
|
21
|
+
T extends DeclaredTable,
|
|
22
|
+
StateField extends string,
|
|
23
|
+
FromState extends string,
|
|
24
|
+
Transitions,
|
|
25
|
+
AllowedFields extends keyof UpdateDTO<T> = keyof UpdateDTO<T>,
|
|
26
|
+
> = Pick<UpdateDTO<T>, Exclude<AllowedFields, StateField>> & {
|
|
27
|
+
[P in StateField]?: AllowedTargetStates<Transitions, FromState>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** What a transition out of `From` is allowed to patch: the declared restriction if there is one, otherwise every updatable field. */
|
|
31
|
+
type PatchableFields<
|
|
32
|
+
T extends DeclaredTable,
|
|
33
|
+
Transitions extends Record<string, readonly string[]>,
|
|
34
|
+
FieldRestrictions extends { readonly [From in keyof Transitions]?: readonly (keyof UpdateDTO<T>)[] },
|
|
35
|
+
From extends keyof Transitions & string,
|
|
36
|
+
> = FieldRestrictions[From] extends readonly (keyof UpdateDTO<T>)[]
|
|
37
|
+
? FieldRestrictions[From][number]
|
|
38
|
+
: keyof UpdateDTO<T>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The `patch` argument of a transition out of `From`. When the only patchable
|
|
42
|
+
* field is the state field itself there is nothing left to pass, so the argument
|
|
43
|
+
* narrows to `Record<string, never>` and any property is a type error.
|
|
44
|
+
*/
|
|
45
|
+
type TransitionPatch<
|
|
46
|
+
T extends DeclaredTable,
|
|
47
|
+
StateField extends string,
|
|
48
|
+
Transitions extends Record<string, readonly string[]>,
|
|
49
|
+
FieldRestrictions extends { readonly [From in keyof Transitions]?: readonly (keyof UpdateDTO<T>)[] },
|
|
50
|
+
From extends keyof Transitions & string,
|
|
51
|
+
> = [Exclude<PatchableFields<T, Transitions, FieldRestrictions, From>, StateField>] extends [never]
|
|
52
|
+
? Record<string, never>
|
|
53
|
+
: Omit<Pick<UpdateDTO<T>, PatchableFields<T, Transitions, FieldRestrictions, From>>, StateField>;
|
|
54
|
+
|
|
55
|
+
export interface EntityStateMachineOptions<
|
|
56
|
+
T extends DeclaredTable,
|
|
57
|
+
StateField extends string,
|
|
58
|
+
Transitions extends Record<string, readonly string[]>,
|
|
59
|
+
FieldRestrictions extends { readonly [From in keyof Transitions]?: readonly (keyof UpdateDTO<T>)[] } = {},
|
|
60
|
+
> {
|
|
61
|
+
/**
|
|
62
|
+
* The generated schema value, for inference only — nothing reads it.
|
|
63
|
+
*
|
|
64
|
+
* It is the whole reason this machine knows what a patch may contain: `T` is the
|
|
65
|
+
* declared type, recovered from the value's phantom, and `UpdateDTO<T>` is what the
|
|
66
|
+
* transitions are checked against. Passing a schema for a different table is the one
|
|
67
|
+
* mistake this cannot catch, and it is the same mistake as pointing a repository at
|
|
68
|
+
* the wrong table.
|
|
69
|
+
*/
|
|
70
|
+
schema?: TaggedSchema<T>;
|
|
71
|
+
stateField: StateField;
|
|
72
|
+
transitions: Transitions;
|
|
73
|
+
allowedFields?: FieldRestrictions | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface EntityStateMachine<
|
|
77
|
+
T extends DeclaredTable,
|
|
78
|
+
StateField extends string,
|
|
79
|
+
Transitions extends Record<string, readonly string[]>,
|
|
80
|
+
FieldRestrictions extends { readonly [From in keyof Transitions]?: readonly (keyof UpdateDTO<T>)[] } = {},
|
|
81
|
+
> {
|
|
82
|
+
readonly stateField: StateField;
|
|
83
|
+
readonly transitions: Transitions;
|
|
84
|
+
readonly allowedFields?: FieldRestrictions | undefined;
|
|
85
|
+
canTransition<From extends keyof Transitions & string>(from: From, to: string): boolean;
|
|
86
|
+
createUpdatePayload<From extends keyof Transitions & string, To extends Transitions[From][number]>(
|
|
87
|
+
from: From,
|
|
88
|
+
to: To,
|
|
89
|
+
patch?: TransitionPatch<T, StateField, Transitions, FieldRestrictions, From>,
|
|
90
|
+
): StateUpdateDTO<T, StateField, From, Transitions, PatchableFields<T, Transitions, FieldRestrictions, From>>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createStateUpdatePayload<
|
|
94
|
+
T extends DeclaredTable,
|
|
95
|
+
StateField extends string,
|
|
96
|
+
From extends keyof Transitions & string,
|
|
97
|
+
const Transitions extends Record<string, readonly string[]>,
|
|
98
|
+
AllowedFields extends keyof UpdateDTO<T> = keyof UpdateDTO<T>,
|
|
99
|
+
>(
|
|
100
|
+
stateField: StateField,
|
|
101
|
+
transitions: Transitions,
|
|
102
|
+
from: From,
|
|
103
|
+
to: Transitions[From][number],
|
|
104
|
+
patch?: Omit<Pick<UpdateDTO<T>, AllowedFields>, StateField> | Record<string, never>,
|
|
105
|
+
): StateUpdateDTO<T, StateField, From, Transitions, AllowedFields> {
|
|
106
|
+
const allowed = transitions[from];
|
|
107
|
+
if (!Array.isArray(allowed) || !allowed.includes(to)) {
|
|
108
|
+
throw new Error(`Invalid state transition from "${from}" to "${to}" for field "${stateField}"`);
|
|
109
|
+
}
|
|
110
|
+
const payload = {
|
|
111
|
+
...patch,
|
|
112
|
+
[stateField]: to,
|
|
113
|
+
};
|
|
114
|
+
// boundary: return value is certified as StateUpdateDTO after runtime transition validation.
|
|
115
|
+
return payload as StateUpdateDTO<T, StateField, From, Transitions, AllowedFields>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function defineEntityStateMachine<
|
|
119
|
+
T extends DeclaredTable,
|
|
120
|
+
StateField extends string,
|
|
121
|
+
const Transitions extends Record<string, readonly string[]>,
|
|
122
|
+
const FieldRestrictions extends { readonly [From in keyof Transitions]?: readonly (keyof UpdateDTO<T>)[] } = {},
|
|
123
|
+
>(
|
|
124
|
+
options: EntityStateMachineOptions<T, StateField, Transitions, FieldRestrictions>,
|
|
125
|
+
): EntityStateMachine<T, StateField, Transitions, FieldRestrictions> {
|
|
126
|
+
const { stateField, transitions, allowedFields } = options;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
stateField,
|
|
130
|
+
transitions,
|
|
131
|
+
allowedFields,
|
|
132
|
+
canTransition(from: keyof Transitions & string, to: string): boolean {
|
|
133
|
+
const allowed = transitions[from];
|
|
134
|
+
return Array.isArray(allowed) && allowed.includes(to);
|
|
135
|
+
},
|
|
136
|
+
createUpdatePayload<From extends keyof Transitions & string, To extends Transitions[From][number]>(
|
|
137
|
+
from: From,
|
|
138
|
+
to: To,
|
|
139
|
+
patch?: TransitionPatch<T, StateField, Transitions, FieldRestrictions, From>,
|
|
140
|
+
) {
|
|
141
|
+
return createStateUpdatePayload<
|
|
142
|
+
T,
|
|
143
|
+
StateField,
|
|
144
|
+
From,
|
|
145
|
+
Transitions,
|
|
146
|
+
PatchableFields<T, Transitions, FieldRestrictions, From>
|
|
147
|
+
>(stateField, transitions, from, to, patch);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|