cabloy 5.1.219 → 5.1.221

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 (89) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/agent-governance.yml +2 -2
  3. package/.github/workflows/create-cabloy.yml +3 -3
  4. package/.github/workflows/docs-pages.yml +2 -2
  5. package/.github/workflows/playwright-e2e.yml +4 -4
  6. package/.github/workflows/vona-cov-pg.yml +2 -2
  7. package/.github/workflows/vona-test-crud.yml +2 -2
  8. package/.github/workflows/vona-test-mysql.yml +2 -2
  9. package/.github/workflows/vona-test-pg.yml +2 -2
  10. package/.github/workflows/vona-test-sqlite3.yml +2 -2
  11. package/.github/workflows/vona-tsc.yml +2 -2
  12. package/.github/workflows/zova-ui.yml +2 -2
  13. package/CHANGELOG.md +23 -0
  14. package/README.md +1 -1
  15. package/package.json +4 -3
  16. package/repo-docs/frontend/openapi-sdk-guide.md +2 -0
  17. package/repo-docs/fullstack/quickstart.md +3 -3
  18. package/repo-docs/reference/repo-scripts.md +16 -0
  19. package/scripts/generateOpenapiIsolated.test.ts +25 -0
  20. package/scripts/generateOpenapiIsolated.ts +46 -12
  21. package/scripts/runIsolatedTest.test.ts +165 -45
  22. package/scripts/runIsolatedTest.ts +111 -24
  23. package/scripts/strictGenerationDiagnostics.test.ts +1009 -0
  24. package/scripts/strictGenerationDiagnostics.ts +61 -0
  25. package/scripts/strictGenerationStartup.test.ts +212 -0
  26. package/vona/package.original.json +3 -2
  27. package/vona/packages-cli/cli/package.json +1 -1
  28. package/vona/packages-cli/cli-set-api/package.json +1 -1
  29. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.dev.ts +14 -4
  30. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/generateEntryFiles.ts +6 -1
  31. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/generateVonaMeta.ts +2 -10
  32. package/vona/packages-cli/cli-set-api/src/lib/bean/toolsBin/validateGenerationPaths.ts +27 -0
  33. package/vona/packages-cli/cli-set-api/test/binDevLifecycle.test.ts +1 -1
  34. package/vona/packages-cli/cli-set-api/test/strictGenerationPrivacy.test.ts +439 -0
  35. package/vona/packages-vona/vona/package.json +1 -1
  36. package/vona/packages-vona/vona-core/package.json +1 -1
  37. package/vona/packages-vona/vona-core/src/lib/bean/beanBase.ts +4 -0
  38. package/vona/packages-vona/vona-core/src/lib/core/asyncLocalStorage.ts +84 -12
  39. package/vona/packages-vona/vona-core/src/lib/core/index.ts +14 -0
  40. package/vona/packages-vona/vona-core/src/lib/core/logger/appLogger.ts +19 -3
  41. package/vona/packages-vona/vona-core/src/lib/core/logger/diagnosticLogger.test.ts +346 -0
  42. package/vona/packages-vona/vona-core/src/lib/core/logger/diagnosticLogger.ts +138 -0
  43. package/vona/packages-vona/vona-core/src/lib/core/logger/index.ts +1 -0
  44. package/vona/packages-vona/vona-core/src/lib/core/logger/utils.ts +25 -5
  45. package/vona/packages-vona/vona-core/src/lib/core/requestBoundary.ts +89 -0
  46. package/vona/packages-vona/vona-core/src/lib/core/testFreshness.test.ts +379 -0
  47. package/vona/packages-vona/vona-core/src/lib/core/testFreshness.ts +232 -0
  48. package/vona/packages-vona/vona-core/src/lib/core/testStrictMode.ts +9 -0
  49. package/vona/packages-vona/vona-core/src/lib/framework/start.test.ts +4 -3
  50. package/vona/packages-vona/vona-core/src/lib/framework/start.ts +2 -0
  51. package/vona/packages-vona/vona-mock/package.json +1 -1
  52. package/vona/pnpm-lock.yaml +691 -356
  53. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  54. package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
  55. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +111 -60
  56. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/service/executor.ts +6 -6
  57. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/types/executor.ts +13 -1
  58. package/vona/src/suite-vendor/a-vona/modules/a-jwt/package.json +1 -1
  59. package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/lib/jwtClientLogging.test.ts +182 -0
  60. package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/service/jwtClient.ts +2 -8
  61. package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
  62. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/aopMethod.log.ts +4 -1
  63. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/bean.logger.ts +9 -0
  64. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +27 -14
  65. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactHttpLog.test.ts +207 -0
  66. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactHttpLog.ts +30 -0
  67. package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +33 -1
  68. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/package.json +1 -1
  69. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/index.ts +11 -0
  70. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/src/lib/protectedSqliteRegistry.ts +356 -0
  71. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  72. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +6 -0
  73. package/vona/src/suite-vendor/a-vona/modules/a-startup/test/devSeed.test.ts +2 -2
  74. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/package.json +1 -1
  75. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/bean/bean.telemetry.ts +8 -1
  76. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/bean/middlewareSystem.trace.ts +9 -14
  77. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/lib/spanDiagnostics.ts +70 -0
  78. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/service/telemetry.ts +37 -23
  79. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  80. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +31 -4
  81. package/vona/src/suite-vendor/a-vona/package.json +2 -2
  82. package/zova/package.original.json +3 -2
  83. package/zova/packages-cli/cli/package.json +2 -2
  84. package/zova/packages-cli/cli-set-front/package.json +1 -1
  85. package/zova/packages-cli/cli-set-front/src/lib/bean/cli.bin.buildRest.ts +11 -101
  86. package/zova/packages-cli/cli-set-front/src/lib/common/restDependencies.ts +78 -0
  87. package/zova/packages-cli/cli-set-front/src/lib/common/restDependencyVersions.ts +252 -0
  88. package/zova/packages-cli/cli-set-front/test/restDependencies.test.ts +568 -0
  89. package/zova/pnpm-lock.yaml +442 -564
@@ -0,0 +1,356 @@
1
+ import { createRequire } from 'node:module';
2
+ import path from 'node:path';
3
+ import { getRequestBoundary } from 'vona';
4
+
5
+ const MAX_NAME_BYTES = 256;
6
+ const MAX_CONFIG_BYTES = 65_536;
7
+ const LOOKUP_BUDGET_MS = 1_000;
8
+ const PRIVATE_TIMEOUT_MS = 100;
9
+
10
+ export const PROTECTED_SQLITE_REGISTRY_SQL = `SELECT
11
+ CASE WHEN typeof(id) = 'integer' AND id > 0 AND id <= 9007199254740991 THEN id ELSE NULL END AS id,
12
+ CASE WHEN typeof(name) = 'text' AND length(CAST(name AS BLOB)) <= 256 THEN name ELSE NULL END AS name,
13
+ CASE WHEN typeof(disabled) = 'integer' AND disabled IN (0, 1) THEN disabled ELSE NULL END AS disabled,
14
+ CASE WHEN typeof(isolate) = 'integer' AND isolate IN (0, 1) THEN isolate ELSE NULL END AS isolate,
15
+ CASE
16
+ WHEN config IS NULL THEN NULL
17
+ WHEN typeof(config) = 'text' AND length(CAST(config AS BLOB)) <= 65536 THEN config
18
+ ELSE NULL
19
+ END AS config,
20
+ CASE
21
+ WHEN typeof(id) = 'integer' AND id > 0 AND id <= 9007199254740991
22
+ AND typeof(name) = 'text' AND length(CAST(name AS BLOB)) <= 256
23
+ AND typeof(disabled) = 'integer' AND disabled IN (0, 1)
24
+ AND typeof(isolate) = 'integer' AND isolate IN (0, 1)
25
+ AND (config IS NULL OR (typeof(config) = 'text' AND length(CAST(config AS BLOB)) <= 65536))
26
+ THEN 1
27
+ ELSE 0
28
+ END AS __protected_valid
29
+ FROM aInstance
30
+ WHERE name COLLATE BINARY = ?
31
+ AND deleted = 0
32
+ LIMIT 2`;
33
+
34
+ type ProtectedSqliteState = 'new' | 'ready' | 'faulted' | 'closed';
35
+
36
+ type ProtectedSqlitePrepareOutcome =
37
+ | 'ready'
38
+ | 'already_prepared'
39
+ | 'boundary_required'
40
+ | 'unsupported'
41
+ | 'unavailable';
42
+
43
+ export interface ProtectedSqliteRegistryOptions {
44
+ readonly filename: string;
45
+ }
46
+
47
+ export interface ProtectedSqliteRegistryRow {
48
+ readonly id: number;
49
+ readonly name: string;
50
+ readonly disabled: boolean;
51
+ readonly isolate: boolean;
52
+ readonly config: string | null;
53
+ }
54
+
55
+ export interface ProtectedSqliteRegistryPrepareResult {
56
+ readonly outcome: ProtectedSqlitePrepareOutcome;
57
+ }
58
+
59
+ export type ProtectedSqliteRegistryLookupOutcome =
60
+ | 'found'
61
+ | 'missing'
62
+ | 'duplicate'
63
+ | 'disabled'
64
+ | 'invalid_input'
65
+ | 'expired'
66
+ | 'boundary_required'
67
+ | 'reentrant'
68
+ | 'unavailable';
69
+
70
+ export type ProtectedSqliteRegistryLookupResult =
71
+ | Readonly<{
72
+ readonly outcome: 'found';
73
+ readonly row: ProtectedSqliteRegistryRow;
74
+ }>
75
+ | Readonly<{ readonly outcome: Exclude<ProtectedSqliteRegistryLookupOutcome, 'found'> }>;
76
+
77
+ export interface ProtectedSqliteRegistryCloseResult {
78
+ readonly outcome: 'closed' | 'close_failed';
79
+ }
80
+
81
+ export interface ProtectedSqliteRegistrySnapshot {
82
+ readonly state: ProtectedSqliteState;
83
+ readonly active: boolean;
84
+ readonly closing: boolean;
85
+ }
86
+
87
+ export interface ProtectedSqliteRegistry {
88
+ readonly prepare: () => ProtectedSqliteRegistryPrepareResult;
89
+ readonly lookup: (name: string, deadlineAt?: number) => ProtectedSqliteRegistryLookupResult;
90
+ readonly close: () => Promise<ProtectedSqliteRegistryCloseResult>;
91
+ readonly snapshot: () => ProtectedSqliteRegistrySnapshot;
92
+ }
93
+
94
+ interface ProtectedSqliteStatement {
95
+ all(name: string): readonly unknown[];
96
+ }
97
+
98
+ interface ProtectedSqliteDatabase {
99
+ pragma(sql: string, options?: Readonly<{ readonly simple: true }>): unknown;
100
+ prepare(sql: string): ProtectedSqliteStatement;
101
+ close(): void;
102
+ }
103
+
104
+ interface ProtectedSqliteOpenOptions {
105
+ readonly readonly: true;
106
+ readonly fileMustExist: true;
107
+ readonly timeout: 100;
108
+ }
109
+
110
+ type ProtectedSqliteOpen = (
111
+ filename: string,
112
+ options: ProtectedSqliteOpenOptions,
113
+ ) => ProtectedSqliteDatabase;
114
+
115
+ interface ProtectedSqliteDependencies {
116
+ readonly open: ProtectedSqliteOpen;
117
+ readonly now: () => number;
118
+ readonly hasRequestBoundary: () => boolean;
119
+ }
120
+
121
+ const productionDependencies: ProtectedSqliteDependencies = Object.freeze({
122
+ open: openNativeSqlite,
123
+ now: () => performance.now(),
124
+ hasRequestBoundary: () => getRequestBoundary() !== undefined,
125
+ });
126
+
127
+ export function createProtectedSqliteRegistry(
128
+ options: ProtectedSqliteRegistryOptions,
129
+ ): ProtectedSqliteRegistry {
130
+ return createProtectedSqliteRegistryInternal(options, productionDependencies);
131
+ }
132
+
133
+ // This seam is intentionally not exported by the package barrel. It makes lifecycle and
134
+ // diagnostic-containment tests deterministic without accepting an alternate production driver.
135
+ export function createProtectedSqliteRegistryForTest(
136
+ options: unknown,
137
+ dependencies: Readonly<
138
+ Partial<ProtectedSqliteDependencies> & { readonly open: ProtectedSqliteOpen }
139
+ >,
140
+ ): ProtectedSqliteRegistry {
141
+ return createProtectedSqliteRegistryInternal(options, {
142
+ open: dependencies.open,
143
+ now: dependencies.now ?? (() => performance.now()),
144
+ hasRequestBoundary:
145
+ dependencies.hasRequestBoundary ?? (() => getRequestBoundary() !== undefined),
146
+ });
147
+ }
148
+
149
+ export function createProtectedSqliteRegistryInternal(
150
+ options: unknown,
151
+ dependencies: ProtectedSqliteDependencies,
152
+ ): ProtectedSqliteRegistry {
153
+ const filename = readSupportedFilename(options);
154
+ let state: ProtectedSqliteState = 'new';
155
+ let attempted = false;
156
+ let active = false;
157
+ let closing = false;
158
+ let database: ProtectedSqliteDatabase | undefined;
159
+ let statement: ProtectedSqliteStatement | undefined;
160
+ let closePromise: Promise<ProtectedSqliteRegistryCloseResult> | undefined;
161
+ let closeResolve: ((result: ProtectedSqliteRegistryCloseResult) => void) | undefined;
162
+
163
+ const unavailable = (outcome: Exclude<ProtectedSqliteRegistryLookupOutcome, 'found'>) =>
164
+ Object.freeze({ outcome }) as ProtectedSqliteRegistryLookupResult;
165
+
166
+ const finalizeClose = (): ProtectedSqliteRegistryCloseResult => {
167
+ if (!database) {
168
+ state = 'closed';
169
+ return Object.freeze({ outcome: 'closed' });
170
+ }
171
+ try {
172
+ database.close();
173
+ } catch {
174
+ state = 'faulted';
175
+ return Object.freeze({ outcome: 'close_failed' });
176
+ }
177
+ database = undefined;
178
+ statement = undefined;
179
+ state = 'closed';
180
+ return Object.freeze({ outcome: 'closed' });
181
+ };
182
+
183
+ const settleDeferredClose = () => {
184
+ if (!closeResolve) return;
185
+ const resolve = closeResolve;
186
+ closeResolve = undefined;
187
+ resolve(finalizeClose());
188
+ };
189
+
190
+ const prepare = (): ProtectedSqliteRegistryPrepareResult => {
191
+ if (dependencies.hasRequestBoundary()) return Object.freeze({ outcome: 'boundary_required' });
192
+ if (closing || state === 'closed') return Object.freeze({ outcome: 'unavailable' });
193
+ if (attempted) return Object.freeze({ outcome: 'already_prepared' });
194
+ attempted = true;
195
+ if (!filename) {
196
+ state = 'faulted';
197
+ return Object.freeze({ outcome: 'unsupported' });
198
+ }
199
+ try {
200
+ database = dependencies.open(filename, {
201
+ readonly: true,
202
+ fileMustExist: true,
203
+ timeout: PRIVATE_TIMEOUT_MS,
204
+ });
205
+ const journalMode = database.pragma('journal_mode', { simple: true });
206
+ const busyTimeout = database.pragma('busy_timeout', { simple: true });
207
+ if (journalMode !== 'wal' || busyTimeout !== PRIVATE_TIMEOUT_MS) throw new Error();
208
+ statement = database.prepare(PROTECTED_SQLITE_REGISTRY_SQL);
209
+ const probe = statement.all('');
210
+ if (
211
+ !Array.isArray(probe) ||
212
+ probe.length > 2 ||
213
+ probe.some(row => !isValidProjectionRow(row))
214
+ ) {
215
+ throw new Error();
216
+ }
217
+ state = 'ready';
218
+ return Object.freeze({ outcome: 'ready' });
219
+ } catch {
220
+ state = 'faulted';
221
+ return Object.freeze({ outcome: 'unavailable' });
222
+ }
223
+ };
224
+
225
+ const lookup = (name: string, deadlineAt = dependencies.now() + LOOKUP_BUDGET_MS) => {
226
+ if (!dependencies.hasRequestBoundary()) return unavailable('boundary_required');
227
+ if (state !== 'ready' || closing || !database || !statement) return unavailable('unavailable');
228
+ if (!isBoundedText(name, MAX_NAME_BYTES)) {
229
+ return unavailable('invalid_input');
230
+ }
231
+ if (!Number.isFinite(deadlineAt) || dependencies.now() >= deadlineAt) {
232
+ return unavailable('expired');
233
+ }
234
+ if (active) return unavailable('reentrant');
235
+ active = true;
236
+ try {
237
+ const rows = statement.all(name);
238
+ if (closing || dependencies.now() >= deadlineAt) return unavailable('expired');
239
+ if (!Array.isArray(rows)) {
240
+ state = 'faulted';
241
+ return unavailable('unavailable');
242
+ }
243
+ if (rows.length > 2) {
244
+ state = 'faulted';
245
+ return unavailable('unavailable');
246
+ }
247
+ if (rows.length === 0) return unavailable('missing');
248
+ if (rows.length === 2) return unavailable('duplicate');
249
+ const row = rows[0];
250
+ if (!isValidProjectionRow(row)) {
251
+ state = 'faulted';
252
+ return unavailable('unavailable');
253
+ }
254
+ const projected = row as Record<string, unknown>;
255
+ if (projected.disabled === 1) return unavailable('disabled');
256
+ const result = Object.freeze({
257
+ outcome: 'found' as const,
258
+ row: Object.freeze({
259
+ id: projected.id as number,
260
+ name: projected.name as string,
261
+ disabled: projected.disabled === 1,
262
+ isolate: projected.isolate === 1,
263
+ config: projected.config as string | null,
264
+ }),
265
+ });
266
+ return result;
267
+ } catch {
268
+ state = 'faulted';
269
+ return unavailable('unavailable');
270
+ } finally {
271
+ active = false;
272
+ if (closing) settleDeferredClose();
273
+ }
274
+ };
275
+
276
+ const close = () => {
277
+ if (closePromise) return closePromise;
278
+ closing = true;
279
+ if (active) {
280
+ closePromise = new Promise<ProtectedSqliteRegistryCloseResult>(resolve => {
281
+ closeResolve = resolve;
282
+ });
283
+ } else {
284
+ closePromise = Promise.resolve(finalizeClose());
285
+ }
286
+ return closePromise;
287
+ };
288
+
289
+ return Object.freeze({
290
+ prepare,
291
+ lookup,
292
+ close,
293
+ snapshot: () => Object.freeze({ state, active, closing }),
294
+ });
295
+ }
296
+
297
+ function readSupportedFilename(options: unknown): string | undefined {
298
+ try {
299
+ if (!options || typeof options !== 'object' || Array.isArray(options)) return undefined;
300
+ const prototype = Object.getPrototypeOf(options);
301
+ if (prototype !== Object.prototype && prototype !== null) return undefined;
302
+ const keys = Reflect.ownKeys(options);
303
+ if (keys.length !== 1 || keys[0] !== 'filename') return undefined;
304
+ const descriptor = Object.getOwnPropertyDescriptor(options, 'filename');
305
+ if (!descriptor || !Object.hasOwn(descriptor, 'value')) return undefined;
306
+ const filename: unknown = descriptor.value;
307
+ if (
308
+ typeof filename !== 'string' ||
309
+ !filename.isWellFormed() ||
310
+ !path.isAbsolute(filename) ||
311
+ filename.includes('\0')
312
+ ) {
313
+ return undefined;
314
+ }
315
+ return filename;
316
+ } catch {
317
+ return undefined;
318
+ }
319
+ }
320
+
321
+ function isBoundedText(value: unknown, maxBytes: number): value is string {
322
+ return (
323
+ typeof value === 'string' &&
324
+ value.isWellFormed() &&
325
+ Buffer.byteLength(value, 'utf8') <= maxBytes
326
+ );
327
+ }
328
+
329
+ function isValidProjectionRow(row: unknown): boolean {
330
+ if (!row || typeof row !== 'object') return false;
331
+ const projected = row as Record<string, unknown>;
332
+ if (projected.__protected_valid !== 1) return false;
333
+ if (
334
+ typeof projected.id !== 'number' ||
335
+ !Number.isSafeInteger(projected.id) ||
336
+ projected.id <= 0
337
+ ) {
338
+ return false;
339
+ }
340
+ if (!isBoundedText(projected.name, MAX_NAME_BYTES)) return false;
341
+ if (projected.disabled !== 0 && projected.disabled !== 1) return false;
342
+ if (projected.isolate !== 0 && projected.isolate !== 1) return false;
343
+ return projected.config === null || isBoundedText(projected.config, MAX_CONFIG_BYTES);
344
+ }
345
+
346
+ function openNativeSqlite(
347
+ filename: string,
348
+ options: ProtectedSqliteOpenOptions,
349
+ ): ProtectedSqliteDatabase {
350
+ const require = createRequire(import.meta.url);
351
+ const BetterSqlite3 = require('better-sqlite3') as new (
352
+ filename: string,
353
+ options: ProtectedSqliteOpenOptions,
354
+ ) => ProtectedSqliteDatabase;
355
+ return new BetterSqlite3(filename, options);
356
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-startup",
3
- "version": "5.1.20",
3
+ "version": "5.1.21",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -5,6 +5,8 @@ import { isNil } from '@cabloy/utils';
5
5
  import fse from 'fs-extra';
6
6
  import {
7
7
  assertIsolatedTestConfig,
8
+ assertFreshTestResourcesReady,
9
+ requiresFreshTestResources,
8
10
  BeanBase,
9
11
  cast,
10
12
  getRuntimePathPhysicalRoot,
@@ -242,6 +244,10 @@ export class ServiceStartup extends BeanBase {
242
244
  private async _clearResources() {
243
245
  if (!this.app.meta.isTest) return;
244
246
  assertIsolatedTestConfig(this.app);
247
+ if (requiresFreshTestResources()) {
248
+ assertFreshTestResourcesReady(this.app);
249
+ return;
250
+ }
245
251
  // redis
246
252
  await this.$scope.redis.service.redis.clearAllData();
247
253
  // .app/public
@@ -217,7 +217,7 @@ describe('creation-driven dev seed', { concurrency: false }, () => {
217
217
  const { service, app, calls, pending } = fixture(['', 'isolateTest']);
218
218
  const runStartup = service.runStartup.bind(service);
219
219
  service.runStartup = async (name, instance, options) => {
220
- if (name === 'a-version:instanceInit' && instance === 'isolateTest') {
220
+ if (name === 'a-version:instanceInit' && String(instance) === 'isolateTest') {
221
221
  throw new Error('isolate init failed');
222
222
  }
223
223
  await runStartup(name, instance, options);
@@ -239,7 +239,7 @@ describe('creation-driven dev seed', { concurrency: false }, () => {
239
239
  } else {
240
240
  const runStartup = service.runStartup.bind(service);
241
241
  service.runStartup = async (name, instance, options) => {
242
- if (name === 'a-version:instanceInit' && instance === 'isolateTest') {
242
+ if (name === 'a-version:instanceInit' && String(instance) === 'isolateTest') {
243
243
  throw new Error('isolate init failed');
244
244
  }
245
245
  await runStartup(name, instance, options);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-telemetry",
3
- "version": "5.0.2",
3
+ "version": "5.0.3",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,7 +1,7 @@
1
1
  import type { Span, SpanOptions } from '@opentelemetry/api';
2
2
 
3
3
  import { INVALID_SPAN_CONTEXT, trace } from '@opentelemetry/api';
4
- import { BeanBase } from 'vona';
4
+ import { BeanBase, getRequestBoundary } from 'vona';
5
5
  import { Bean } from 'vona-module-a-bean';
6
6
 
7
7
  const nonRecordingSpan = trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
@@ -9,25 +9,30 @@ const nonRecordingSpan = trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
9
9
  @Bean()
10
10
  export class BeanTelemetry extends BeanBase {
11
11
  get enabled() {
12
+ if (getRequestBoundary()) return false;
12
13
  return this.scope.service.telemetry.enabled;
13
14
  }
14
15
 
15
16
  startSpan(name: string, options?: SpanOptions): Span {
17
+ if (getRequestBoundary()) return nonRecordingSpan;
16
18
  const telemetry = this.scope.service.telemetry;
17
19
  return telemetry.enabled ? telemetry.startSpan(name, options) : nonRecordingSpan;
18
20
  }
19
21
 
20
22
  withSpan<RESULT>(span: Span, fn: () => RESULT): RESULT {
23
+ if (getRequestBoundary()) return fn();
21
24
  const telemetry = this.scope.service.telemetry;
22
25
  return telemetry.enabled ? telemetry.withSpan(span, fn) : fn();
23
26
  }
24
27
 
25
28
  recordException(span: Span, error: unknown) {
29
+ if (getRequestBoundary()) return;
26
30
  const telemetry = this.scope.service.telemetry;
27
31
  if (telemetry.enabled) telemetry.recordException(span, error);
28
32
  }
29
33
 
30
34
  withNamedSpan<RESULT>(name: string, fn: () => RESULT, options?: SpanOptions): RESULT {
35
+ if (getRequestBoundary()) return fn();
31
36
  const span = this.startSpan(name, options);
32
37
  try {
33
38
  const result = this.withSpan(span, fn);
@@ -52,6 +57,7 @@ export class BeanTelemetry extends BeanBase {
52
57
  }
53
58
 
54
59
  private _recordAndEndSpan(span: Span, error: unknown) {
60
+ if (getRequestBoundary()) return;
55
61
  try {
56
62
  this.recordException(span, error);
57
63
  } catch {}
@@ -59,6 +65,7 @@ export class BeanTelemetry extends BeanBase {
59
65
  }
60
66
 
61
67
  private _endSpan(span: Span) {
68
+ if (getRequestBoundary()) return;
62
69
  try {
63
70
  span.end();
64
71
  } catch {}
@@ -1,4 +1,3 @@
1
- import type { Span } from '@opentelemetry/api';
2
1
  import type { Next } from 'vona';
3
2
  import type {
4
3
  IDecoratorMiddlewareSystemOptions,
@@ -6,9 +5,11 @@ import type {
6
5
  } from 'vona-module-a-aspect';
7
6
 
8
7
  import { context } from '@opentelemetry/api';
9
- import { BeanBase, uuidv4 } from 'vona';
8
+ import { BeanBase, captureRequestBoundary, getRequestBoundary, uuidv4 } from 'vona';
10
9
  import { MiddlewareSystem } from 'vona-module-a-aspect';
11
10
 
11
+ import { createHttpSpanEnd, recordSpanException } from '../lib/spanDiagnostics.ts';
12
+
12
13
  export interface IMiddlewareSystemOptionsTrace extends IDecoratorMiddlewareSystemOptions {}
13
14
 
14
15
  @MiddlewareSystem<IMiddlewareSystemOptionsTrace>({
@@ -16,6 +17,7 @@ export interface IMiddlewareSystemOptionsTrace extends IDecoratorMiddlewareSyste
16
17
  })
17
18
  export class MiddlewareSystemTrace extends BeanBase implements IMiddlewareSystemExecute {
18
19
  async execute(_options: IMiddlewareSystemOptionsTrace, next: Next) {
20
+ if (getRequestBoundary()) return next();
19
21
  const telemetry = this.$scope.telemetry.service.telemetry;
20
22
  if (!telemetry.enabled || this.ctx.state.telemetry?.internalAction) return next();
21
23
 
@@ -37,11 +39,10 @@ export class MiddlewareSystemTrace extends BeanBase implements IMiddlewareSystem
37
39
  ctx.state.telemetry = { requestId, context: active, serverSpan: span };
38
40
  ctx.set(requestIdHeader, requestId);
39
41
 
40
- const finish = () => this._end(span, ctx.res.statusCode || ctx.status || 500, false);
41
- const close = () =>
42
- this._end(span, ctx.res.statusCode || ctx.status || 499, !ctx.res.writableEnded);
43
- ctx.res.once('finish', finish);
44
- ctx.res.once('close', close);
42
+ const capture = captureRequestBoundary();
43
+ const terminal = createHttpSpanEnd(span, ctx.res, capture);
44
+ ctx.res.once('finish', terminal.finish);
45
+ ctx.res.once('close', terminal.close);
45
46
 
46
47
  try {
47
48
  const result = await context.with(active, next);
@@ -55,16 +56,10 @@ export class MiddlewareSystemTrace extends BeanBase implements IMiddlewareSystem
55
56
  }
56
57
  return result;
57
58
  } catch (err) {
58
- telemetry.recordException(span, err);
59
+ recordSpanException(span, err, capture);
59
60
  throw err;
60
61
  }
61
62
  }
62
-
63
- private _end(span: Span, statusCode: number, aborted: boolean) {
64
- if (!span || this.ctx.state.telemetry?.serverSpanEnded) return;
65
- this.ctx.state.telemetry!.serverSpanEnded = true;
66
- this.$scope.telemetry.service.telemetry.endHttpSpan(span, statusCode, aborted);
67
- }
68
63
  }
69
64
 
70
65
  function normalizeRequestId(value?: string) {
@@ -0,0 +1,70 @@
1
+ import type { Span } from '@opentelemetry/api';
2
+ import type { RequestBoundaryCapture } from 'vona';
3
+
4
+ import { SpanStatusCode } from '@opentelemetry/api';
5
+ import { captureRequestBoundary, getRequestBoundary } from 'vona';
6
+
7
+ export interface HttpSpanResponse {
8
+ readonly statusCode: number;
9
+ readonly writableEnded: boolean;
10
+ removeListener: (event: 'finish' | 'close', listener: () => void) => unknown;
11
+ }
12
+
13
+ export function recordSpanException(
14
+ span: Span,
15
+ error: unknown,
16
+ capture?: RequestBoundaryCapture,
17
+ ): void {
18
+ if (capture || getRequestBoundary()) return;
19
+ if (error instanceof Error) {
20
+ span.recordException(error);
21
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
22
+ return;
23
+ }
24
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
25
+ }
26
+
27
+ export function endHttpSpan(span: Span, statusCode: number, aborted = false): void {
28
+ if (getRequestBoundary()) return;
29
+ if (aborted || statusCode >= 500) {
30
+ span.setStatus({
31
+ code: SpanStatusCode.ERROR,
32
+ message: aborted ? 'client aborted' : undefined,
33
+ });
34
+ }
35
+ span.setAttribute('http.response.status_code', statusCode);
36
+ if (aborted) span.setAttribute('vona.http.aborted', true);
37
+ span.end();
38
+ }
39
+
40
+ export function createHttpSpanEnd(
41
+ span: Span,
42
+ response: HttpSpanResponse,
43
+ capture?: RequestBoundaryCapture,
44
+ ): Readonly<{ finish(): void; close(): void }> {
45
+ capture ??= captureRequestBoundary();
46
+ const reportFailure = () => capture?.reportFailure('late_callback_failed');
47
+ let ended = false;
48
+ const finish = () => end(false);
49
+ const close = () => end(true);
50
+ function end(closed: boolean) {
51
+ if (ended) return;
52
+ ended = true;
53
+ try {
54
+ response.removeListener(closed ? 'finish' : 'close', closed ? finish : close);
55
+ } catch {
56
+ reportFailure();
57
+ }
58
+ if (capture || getRequestBoundary()) return;
59
+ try {
60
+ endHttpSpan(
61
+ span,
62
+ response.statusCode || (closed ? 499 : 500),
63
+ closed && !response.writableEnded,
64
+ );
65
+ } catch {
66
+ reportFailure();
67
+ }
68
+ }
69
+ return Object.freeze({ finish, close });
70
+ }