@entifix/service-shell 0.1.4 → 0.2.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 (47) hide show
  1. package/dist/auth/require-organization.d.ts +2 -2
  2. package/dist/auth/require-organization.d.ts.map +1 -1
  3. package/dist/auth/require-organization.js +1 -1
  4. package/dist/auth/require-organization.js.map +1 -1
  5. package/dist/auth/require-principal.d.ts +3 -3
  6. package/dist/auth/require-principal.d.ts.map +1 -1
  7. package/dist/auth/require-principal.js +1 -1
  8. package/dist/auth/require-principal.js.map +1 -1
  9. package/dist/auth/require-service-crossing.d.ts +4 -4
  10. package/dist/auth/require-service-crossing.d.ts.map +1 -1
  11. package/dist/auth/require-service-crossing.js +2 -2
  12. package/dist/auth/require-service-crossing.js.map +1 -1
  13. package/dist/auth/service-token.d.ts +2 -2
  14. package/dist/auth/service-token.d.ts.map +1 -1
  15. package/dist/auth/service-token.js +1 -1
  16. package/dist/auth/service-token.js.map +1 -1
  17. package/dist/entity-metadata-route.d.ts +2 -2
  18. package/dist/entity-metadata-route.d.ts.map +1 -1
  19. package/dist/entity-metadata-route.js +1 -1
  20. package/dist/entity-metadata-route.js.map +1 -1
  21. package/dist/health-routes.d.ts +3 -3
  22. package/dist/health-routes.d.ts.map +1 -1
  23. package/dist/health-routes.js +37 -33
  24. package/dist/health-routes.js.map +1 -1
  25. package/dist/load-remote-configuration.d.ts +1 -1
  26. package/dist/load-remote-configuration.d.ts.map +1 -1
  27. package/dist/load-remote-configuration.js +5 -2
  28. package/dist/load-remote-configuration.js.map +1 -1
  29. package/dist/make-service.d.ts +27 -12
  30. package/dist/make-service.d.ts.map +1 -1
  31. package/dist/make-service.js +15 -15
  32. package/dist/make-service.js.map +1 -1
  33. package/dist/observability.d.ts.map +1 -1
  34. package/dist/observability.js +39 -34
  35. package/dist/observability.js.map +1 -1
  36. package/dist/serve-test-service.d.ts +1 -1
  37. package/dist/serve-test-service.d.ts.map +1 -1
  38. package/dist/serve-test-service.js.map +1 -1
  39. package/dist/service-description-route.d.ts +3 -3
  40. package/dist/service-description-route.d.ts.map +1 -1
  41. package/dist/service-description-route.js +3 -3
  42. package/dist/service-description-route.js.map +1 -1
  43. package/dist/sse.d.ts +1 -1
  44. package/dist/sse.d.ts.map +1 -1
  45. package/dist/sse.js +2 -2
  46. package/dist/sse.js.map +1 -1
  47. package/package.json +10 -11
@@ -1 +1 @@
1
- {"version":3,"file":"load-remote-configuration.d.ts","sourceRoot":"","sources":["../src/load-remote-configuration.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,2BAA2B,EAC3B,kBAAkB,EAClB,gBAAgB,EACjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;;AAInD;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,2BAEG;CAAG;AAElD;;;;;;;;;GASG;AACH,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CACJ,CAAC;AAExE;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,6BAA6B,OAAQ,CAAC;AAEnD,mDAAmD;AACnD,MAAM,WAAW,8BAA8B;IAC7C;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAC7D;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,GAClC,cAAc,MAAM,EACpB,SAAS,MAAM,EACf,UAAS,8BAAmC,KAC3C,MAAM,CAAC,MAAM,CAAC,kBAAkB,EAAE,gBAAgB,CAwCpD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GACxC,cAAc,MAAM,EACpB,SAAS,MAAM,EACf,UAAS,8BAAmC,KAC3C,MAAM,CAAC,MAAM,CAAC,2BAA2B,EAAE,gBAAgB,CAG3D,CAAC"}
1
+ {"version":3,"file":"load-remote-configuration.d.ts","sourceRoot":"","sources":["../src/load-remote-configuration.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,2BAA2B,EAC3B,kBAAkB,EAClB,gBAAgB,EACjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;;AAInD;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,2BAGf;CAAG;AAEhC;;;;;;;;;GASG;AACH,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CACR,CAAC;AAEpE;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,6BAA6B,OAAQ,CAAC;AAEnD,mDAAmD;AACnD,MAAM,WAAW,8BAA8B;IAC7C;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAC7D;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,GAClC,cAAc,MAAM,EACpB,SAAS,MAAM,EACf,UAAS,8BAAmC,KAC3C,MAAM,CAAC,MAAM,CAAC,kBAAkB,EAAE,gBAAgB,CAwCpD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,GACxC,cAAc,MAAM,EACpB,SAAS,MAAM,EACf,UAAS,8BAAmC,KAC3C,MAAM,CAAC,MAAM,CAAC,2BAA2B,EAAE,gBAAgB,CAG3D,CAAC"}
@@ -4,7 +4,7 @@ import { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';
4
4
  /**
5
5
  * DI tag carrying the raw {@link ConfigurationPlain} a service loaded at boot,
6
6
  * so a `GET /api/config` introspection route can expose it (redacted).
7
- */ export class LoadedConfigurationTag extends Context.Tag('LoadedConfigurationTag')() {
7
+ */ export class LoadedConfigurationTag extends Context.Service()('LoadedConfigurationTag') {
8
8
  }
9
9
  /**
10
10
  * Default boot-fetch retry policy: 20 attempts spaced 1s apart (~20s window).
@@ -15,7 +15,10 @@ import { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';
15
15
  * backend can fire its boot fetch before config-service answers and, without a
16
16
  * retry, would crash with `fetch failed` (connection refused). Retrying a
17
17
  * bounded window lets the fleet come up in any order.
18
- */ export const defaultConfigRetrySchedule = Schedule.intersect(Schedule.spaced('1 seconds'), Schedule.recurs(20));
18
+ */ export const defaultConfigRetrySchedule = Schedule.max([
19
+ Schedule.spaced('1 seconds'),
20
+ Schedule.recurs(20)
21
+ ]);
19
22
  /**
20
23
  * Deadline for a single boot-fetch attempt.
21
24
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/load-remote-configuration.ts"],"sourcesContent":["import {\n ConfigurationClientInMemory,\n ConfigurationPlain,\n EntifixConnError,\n} from '@entifix/core';\nimport { Context, Effect, Schedule } from 'effect';\n\nimport { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';\n\n/**\n * DI tag carrying the raw {@link ConfigurationPlain} a service loaded at boot,\n * so a `GET /api/config` introspection route can expose it (redacted).\n */\nexport class LoadedConfigurationTag extends Context.Tag(\n 'LoadedConfigurationTag',\n)<LoadedConfigurationTag, ConfigurationPlain>() {}\n\n/**\n * Default boot-fetch retry policy: 20 attempts spaced 1s apart (~20s window).\n *\n * A dependent service `dev` target `dependsOn config-service:dev`, but Nx\n * considers a *continuous* dependency \"ready\" the moment its process starts —\n * not when its HTTP server is listening and its table is seeded. So a fast\n * backend can fire its boot fetch before config-service answers and, without a\n * retry, would crash with `fetch failed` (connection refused). Retrying a\n * bounded window lets the fleet come up in any order.\n */\nexport const defaultConfigRetrySchedule: Schedule.Schedule<unknown, unknown> =\n Schedule.intersect(Schedule.spaced('1 seconds'), Schedule.recurs(20));\n\n/**\n * Deadline for a single boot-fetch attempt.\n *\n * **The retry above only helps if an attempt can fail.** `fetch` has no timeout\n * of its own, so a connection that is accepted but never answered — a previous\n * listener dying mid-flight, a half-open socket — leaves it waiting forever.\n * The schedule never sees a failure, never retries, and the service hangs in its\n * boot layer with its port bound and nothing served: measured twice on\n * 2026-08-14, on auth-service and then marketplace-service, recovered only by a\n * full `dev:reset` ([#84](https://github.com/r10c-technologies/r10c/issues/84)).\n *\n * So an attempt is deadlined, for the same reason a readiness probe is: a check\n * that hangs is a check that failed. The ordinary race this retry was written\n * for — config-service not listening yet — still fails instantly with\n * `ECONNREFUSED` and costs nothing, so this bounds the pathological case only:\n * 20 retries × (5s deadline + 1s spacing) ≈ 2 minutes before the service exits\n * loudly instead of hanging silently.\n */\nexport const defaultConfigAttemptTimeoutMs = 5_000;\n\n/** Options for {@link loadRemoteConfiguration}. */\nexport interface LoadRemoteConfigurationOptions {\n /**\n * Retry policy for the boot fetch. Defaults to\n * {@link defaultConfigRetrySchedule}. Pass `Schedule.stop` to fail fast (a\n * single attempt, no retry) — used by tests and callers that want the error\n * immediately.\n */\n readonly retrySchedule?: Schedule.Schedule<unknown, unknown>;\n /**\n * Deadline for one attempt, in milliseconds. Defaults to\n * {@link defaultConfigAttemptTimeoutMs}. There is deliberately no way to\n * disable it — an attempt that cannot time out is the bug this exists for.\n */\n readonly attemptTimeoutMs?: number;\n}\n\n/**\n * Fetches a service's configuration from the central config-service\n * (`GET {configApiUrl}/api/config/{service}`) and returns it as a\n * {@link ConfigurationPlain}. Backends call this at boot to resolve their own\n * parameters (e.g. `mongo.uri`) instead of hardcoding them.\n *\n * Uses Node's global `fetch` (same transport approach as the REST client); any\n * network/parse/non-2xx failure becomes an {@link EntifixConnError}. Each\n * attempt is deadlined ({@link defaultConfigAttemptTimeoutMs}) and the fetch is\n * retried on a bounded schedule ({@link defaultConfigRetrySchedule}), so a\n * dependent that boots before config-service is HTTP-ready waits it out — and\n * one that reaches a socket nobody is answering gives up rather than hanging.\n */\nexport const loadRemoteConfiguration = (\n configApiUrl: string,\n service: string,\n options: LoadRemoteConfigurationOptions = {},\n): Effect.Effect<ConfigurationPlain, EntifixConnError> => {\n const url = `${configApiUrl.replace(/\\/+$/, '')}/api/config/${service}`;\n const attemptTimeoutMs =\n options.attemptTimeoutMs ?? defaultConfigAttemptTimeoutMs;\n\n const fetchOnce = Effect.tryPromise({\n try: async () => {\n const response = await fetch(url, {\n cache: 'no-store',\n headers: { [SERVICE_TOKEN_HEADER]: serviceToken() },\n signal: AbortSignal.timeout(attemptTimeoutMs),\n }).catch((error: unknown) => {\n // Rewritten because the raw abort says `This operation was aborted`,\n // which reads like a bug in the caller rather than a silent peer.\n if (error instanceof Error && error.name === 'TimeoutError') {\n throw new Error(\n `config-service did not answer within ${attemptTimeoutMs}ms at ${url}`,\n );\n }\n throw error;\n });\n if (!response.ok) {\n throw new Error(\n `config-service responded ${response.status} for ${url}`,\n );\n }\n return (await response.json()) as ConfigurationPlain;\n },\n catch: error =>\n new EntifixConnError(\n `Failed to load configuration for \"${service}\" from config-service`,\n error,\n { url, service },\n ),\n });\n\n return Effect.retry(\n fetchOnce,\n options.retrySchedule ?? defaultConfigRetrySchedule,\n );\n};\n\n/**\n * Convenience: load the remote configuration and wrap it in a\n * {@link ConfigurationClientInMemory} ready to satisfy `ConfigurationRepositoryTag`\n * and to read the service's own parameters via `.in(group).getString(key)`.\n */\nexport const loadRemoteConfigurationClient = (\n configApiUrl: string,\n service: string,\n options: LoadRemoteConfigurationOptions = {},\n): Effect.Effect<ConfigurationClientInMemory, EntifixConnError> =>\n loadRemoteConfiguration(configApiUrl, service, options).pipe(\n Effect.map(plain => new ConfigurationClientInMemory(plain)),\n );\n"],"names":["ConfigurationClientInMemory","EntifixConnError","Context","Effect","Schedule","SERVICE_TOKEN_HEADER","serviceToken","LoadedConfigurationTag","Tag","defaultConfigRetrySchedule","intersect","spaced","recurs","defaultConfigAttemptTimeoutMs","loadRemoteConfiguration","configApiUrl","service","options","url","replace","attemptTimeoutMs","fetchOnce","tryPromise","try","response","fetch","cache","headers","signal","AbortSignal","timeout","catch","error","Error","name","ok","status","json","retry","retrySchedule","loadRemoteConfigurationClient","pipe","map","plain"],"mappings":"AAAA,SACEA,2BAA2B,EAE3BC,gBAAgB,QACX,gBAAgB;AACvB,SAASC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,SAAS;AAEnD,SAASC,oBAAoB,EAAEC,YAAY,QAAQ,0BAA0B;AAE7E;;;CAGC,GACD,OAAO,MAAMC,+BAA+BL,QAAQM,GAAG,CACrD;AAC+C;AAEjD;;;;;;;;;CASC,GACD,OAAO,MAAMC,6BACXL,SAASM,SAAS,CAACN,SAASO,MAAM,CAAC,cAAcP,SAASQ,MAAM,CAAC,KAAK;AAExE;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,gCAAgC,KAAM;AAmBnD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,cACAC,SACAC,UAA0C,CAAC,CAAC;QAI1CA,2BAmCAA;IArCF,MAAMC,MAAM,GAAGH,aAAaI,OAAO,CAAC,QAAQ,IAAI,YAAY,EAAEH,SAAS;IACvE,MAAMI,oBACJH,4BAAAA,QAAQG,gBAAgB,YAAxBH,4BAA4BJ;IAE9B,MAAMQ,YAAYlB,OAAOmB,UAAU,CAAC;QAClCC,KAAK;YACH,MAAMC,WAAW,MAAMC,MAAMP,KAAK;gBAChCQ,OAAO;gBACPC,SAAS;oBAAE,CAACtB,qBAAqB,EAAEC;gBAAe;gBAClDsB,QAAQC,YAAYC,OAAO,CAACV;YAC9B,GAAGW,KAAK,CAAC,CAACC;gBACR,qEAAqE;gBACrE,kEAAkE;gBAClE,IAAIA,iBAAiBC,SAASD,MAAME,IAAI,KAAK,gBAAgB;oBAC3D,MAAM,IAAID,MACR,CAAC,qCAAqC,EAAEb,iBAAiB,MAAM,EAAEF,KAAK;gBAE1E;gBACA,MAAMc;YACR;YACA,IAAI,CAACR,SAASW,EAAE,EAAE;gBAChB,MAAM,IAAIF,MACR,CAAC,yBAAyB,EAAET,SAASY,MAAM,CAAC,KAAK,EAAElB,KAAK;YAE5D;YACA,OAAQ,MAAMM,SAASa,IAAI;QAC7B;QACAN,OAAOC,CAAAA,QACL,IAAI/B,iBACF,CAAC,kCAAkC,EAAEe,QAAQ,qBAAqB,CAAC,EACnEgB,OACA;gBAAEd;gBAAKF;YAAQ;IAErB;IAEA,OAAOb,OAAOmC,KAAK,CACjBjB,YACAJ,yBAAAA,QAAQsB,aAAa,YAArBtB,yBAAyBR;AAE7B,EAAE;AAEF;;;;CAIC,GACD,OAAO,MAAM+B,gCAAgC,CAC3CzB,cACAC,SACAC,UAA0C,CAAC,CAAC,GAE5CH,wBAAwBC,cAAcC,SAASC,SAASwB,IAAI,CAC1DtC,OAAOuC,GAAG,CAACC,CAAAA,QAAS,IAAI3C,4BAA4B2C,SACpD"}
1
+ {"version":3,"sources":["../src/load-remote-configuration.ts"],"sourcesContent":["import {\n ConfigurationClientInMemory,\n ConfigurationPlain,\n EntifixConnError,\n} from '@entifix/core';\nimport { Context, Effect, Schedule } from 'effect';\n\nimport { SERVICE_TOKEN_HEADER, serviceToken } from './auth/service-token.js';\n\n/**\n * DI tag carrying the raw {@link ConfigurationPlain} a service loaded at boot,\n * so a `GET /api/config` introspection route can expose it (redacted).\n */\nexport class LoadedConfigurationTag extends Context.Service<\n LoadedConfigurationTag,\n ConfigurationPlain\n>()('LoadedConfigurationTag') {}\n\n/**\n * Default boot-fetch retry policy: 20 attempts spaced 1s apart (~20s window).\n *\n * A dependent service `dev` target `dependsOn config-service:dev`, but Nx\n * considers a *continuous* dependency \"ready\" the moment its process starts —\n * not when its HTTP server is listening and its table is seeded. So a fast\n * backend can fire its boot fetch before config-service answers and, without a\n * retry, would crash with `fetch failed` (connection refused). Retrying a\n * bounded window lets the fleet come up in any order.\n */\nexport const defaultConfigRetrySchedule: Schedule.Schedule<unknown, unknown> =\n Schedule.max([Schedule.spaced('1 seconds'), Schedule.recurs(20)]);\n\n/**\n * Deadline for a single boot-fetch attempt.\n *\n * **The retry above only helps if an attempt can fail.** `fetch` has no timeout\n * of its own, so a connection that is accepted but never answered — a previous\n * listener dying mid-flight, a half-open socket — leaves it waiting forever.\n * The schedule never sees a failure, never retries, and the service hangs in its\n * boot layer with its port bound and nothing served: measured twice on\n * 2026-08-14, on auth-service and then marketplace-service, recovered only by a\n * full `dev:reset` ([#84](https://github.com/r10c-technologies/r10c/issues/84)).\n *\n * So an attempt is deadlined, for the same reason a readiness probe is: a check\n * that hangs is a check that failed. The ordinary race this retry was written\n * for — config-service not listening yet — still fails instantly with\n * `ECONNREFUSED` and costs nothing, so this bounds the pathological case only:\n * 20 retries × (5s deadline + 1s spacing) ≈ 2 minutes before the service exits\n * loudly instead of hanging silently.\n */\nexport const defaultConfigAttemptTimeoutMs = 5_000;\n\n/** Options for {@link loadRemoteConfiguration}. */\nexport interface LoadRemoteConfigurationOptions {\n /**\n * Retry policy for the boot fetch. Defaults to\n * {@link defaultConfigRetrySchedule}. Pass `Schedule.stop` to fail fast (a\n * single attempt, no retry) — used by tests and callers that want the error\n * immediately.\n */\n readonly retrySchedule?: Schedule.Schedule<unknown, unknown>;\n /**\n * Deadline for one attempt, in milliseconds. Defaults to\n * {@link defaultConfigAttemptTimeoutMs}. There is deliberately no way to\n * disable it — an attempt that cannot time out is the bug this exists for.\n */\n readonly attemptTimeoutMs?: number;\n}\n\n/**\n * Fetches a service's configuration from the central config-service\n * (`GET {configApiUrl}/api/config/{service}`) and returns it as a\n * {@link ConfigurationPlain}. Backends call this at boot to resolve their own\n * parameters (e.g. `mongo.uri`) instead of hardcoding them.\n *\n * Uses Node's global `fetch` (same transport approach as the REST client); any\n * network/parse/non-2xx failure becomes an {@link EntifixConnError}. Each\n * attempt is deadlined ({@link defaultConfigAttemptTimeoutMs}) and the fetch is\n * retried on a bounded schedule ({@link defaultConfigRetrySchedule}), so a\n * dependent that boots before config-service is HTTP-ready waits it out — and\n * one that reaches a socket nobody is answering gives up rather than hanging.\n */\nexport const loadRemoteConfiguration = (\n configApiUrl: string,\n service: string,\n options: LoadRemoteConfigurationOptions = {},\n): Effect.Effect<ConfigurationPlain, EntifixConnError> => {\n const url = `${configApiUrl.replace(/\\/+$/, '')}/api/config/${service}`;\n const attemptTimeoutMs =\n options.attemptTimeoutMs ?? defaultConfigAttemptTimeoutMs;\n\n const fetchOnce = Effect.tryPromise({\n try: async () => {\n const response = await fetch(url, {\n cache: 'no-store',\n headers: { [SERVICE_TOKEN_HEADER]: serviceToken() },\n signal: AbortSignal.timeout(attemptTimeoutMs),\n }).catch((error: unknown) => {\n // Rewritten because the raw abort says `This operation was aborted`,\n // which reads like a bug in the caller rather than a silent peer.\n if (error instanceof Error && error.name === 'TimeoutError') {\n throw new Error(\n `config-service did not answer within ${attemptTimeoutMs}ms at ${url}`,\n );\n }\n throw error;\n });\n if (!response.ok) {\n throw new Error(\n `config-service responded ${response.status} for ${url}`,\n );\n }\n return (await response.json()) as ConfigurationPlain;\n },\n catch: error =>\n new EntifixConnError(\n `Failed to load configuration for \"${service}\" from config-service`,\n error,\n { url, service },\n ),\n });\n\n return Effect.retry(\n fetchOnce,\n options.retrySchedule ?? defaultConfigRetrySchedule,\n );\n};\n\n/**\n * Convenience: load the remote configuration and wrap it in a\n * {@link ConfigurationClientInMemory} ready to satisfy `ConfigurationRepositoryTag`\n * and to read the service's own parameters via `.in(group).getString(key)`.\n */\nexport const loadRemoteConfigurationClient = (\n configApiUrl: string,\n service: string,\n options: LoadRemoteConfigurationOptions = {},\n): Effect.Effect<ConfigurationClientInMemory, EntifixConnError> =>\n loadRemoteConfiguration(configApiUrl, service, options).pipe(\n Effect.map(plain => new ConfigurationClientInMemory(plain)),\n );\n"],"names":["ConfigurationClientInMemory","EntifixConnError","Context","Effect","Schedule","SERVICE_TOKEN_HEADER","serviceToken","LoadedConfigurationTag","Service","defaultConfigRetrySchedule","max","spaced","recurs","defaultConfigAttemptTimeoutMs","loadRemoteConfiguration","configApiUrl","service","options","url","replace","attemptTimeoutMs","fetchOnce","tryPromise","try","response","fetch","cache","headers","signal","AbortSignal","timeout","catch","error","Error","name","ok","status","json","retry","retrySchedule","loadRemoteConfigurationClient","pipe","map","plain"],"mappings":"AAAA,SACEA,2BAA2B,EAE3BC,gBAAgB,QACX,gBAAgB;AACvB,SAASC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,SAAS;AAEnD,SAASC,oBAAoB,EAAEC,YAAY,QAAQ,0BAA0B;AAE7E;;;CAGC,GACD,OAAO,MAAMC,+BAA+BL,QAAQM,OAAO,GAGvD;AAA2B;AAE/B;;;;;;;;;CASC,GACD,OAAO,MAAMC,6BACXL,SAASM,GAAG,CAAC;IAACN,SAASO,MAAM,CAAC;IAAcP,SAASQ,MAAM,CAAC;CAAI,EAAE;AAEpE;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,gCAAgC,KAAM;AAmBnD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,cACAC,SACAC,UAA0C,CAAC,CAAC;QAI1CA,2BAmCAA;IArCF,MAAMC,MAAM,GAAGH,aAAaI,OAAO,CAAC,QAAQ,IAAI,YAAY,EAAEH,SAAS;IACvE,MAAMI,oBACJH,4BAAAA,QAAQG,gBAAgB,YAAxBH,4BAA4BJ;IAE9B,MAAMQ,YAAYlB,OAAOmB,UAAU,CAAC;QAClCC,KAAK;YACH,MAAMC,WAAW,MAAMC,MAAMP,KAAK;gBAChCQ,OAAO;gBACPC,SAAS;oBAAE,CAACtB,qBAAqB,EAAEC;gBAAe;gBAClDsB,QAAQC,YAAYC,OAAO,CAACV;YAC9B,GAAGW,KAAK,CAAC,CAACC;gBACR,qEAAqE;gBACrE,kEAAkE;gBAClE,IAAIA,iBAAiBC,SAASD,MAAME,IAAI,KAAK,gBAAgB;oBAC3D,MAAM,IAAID,MACR,CAAC,qCAAqC,EAAEb,iBAAiB,MAAM,EAAEF,KAAK;gBAE1E;gBACA,MAAMc;YACR;YACA,IAAI,CAACR,SAASW,EAAE,EAAE;gBAChB,MAAM,IAAIF,MACR,CAAC,yBAAyB,EAAET,SAASY,MAAM,CAAC,KAAK,EAAElB,KAAK;YAE5D;YACA,OAAQ,MAAMM,SAASa,IAAI;QAC7B;QACAN,OAAOC,CAAAA,QACL,IAAI/B,iBACF,CAAC,kCAAkC,EAAEe,QAAQ,qBAAqB,CAAC,EACnEgB,OACA;gBAAEd;gBAAKF;YAAQ;IAErB;IAEA,OAAOb,OAAOmC,KAAK,CACjBjB,YACAJ,yBAAAA,QAAQsB,aAAa,YAArBtB,yBAAyBR;AAE7B,EAAE;AAEF;;;;CAIC,GACD,OAAO,MAAM+B,gCAAgC,CAC3CzB,cACAC,SACAC,UAA0C,CAAC,CAAC,GAE5CH,wBAAwBC,cAAcC,SAASC,SAASwB,IAAI,CAC1DtC,OAAOuC,GAAG,CAACC,CAAAA,QAAS,IAAI3C,4BAA4B2C,SACpD"}
@@ -1,17 +1,32 @@
1
- import { HttpRouter, HttpServer } from '@effect/platform';
2
1
  import { HealthRegistryTag, type ShutdownRegistry, ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';
3
2
  import { Layer } from 'effect';
3
+ import { HttpRouter } from 'effect/http';
4
+ /**
5
+ * A service's routes: `HttpRouter.add` / `HttpRouter.addAll` layers, merged
6
+ * with `Layer.mergeAll`. Its requirements carry, as `HttpRouter` request
7
+ * markers, the domain services the handlers need.
8
+ */
9
+ export type ServiceRoutes<R = any> = Layer.Layer<never, never, R>;
10
+ /**
11
+ * What a service's `appLayer` must provide for routes requiring `R`: the
12
+ * services their handlers read per request, plus anything building the route
13
+ * layers themselves needs.
14
+ */
15
+ export type RoutesRequirements<R> = HttpRouter.Request.Only<'Requires', R> | HttpRouter.Request.Without<Exclude<R, HttpRouter.HttpRouter>>;
16
+ /** The registries {@link makeServerLayer} provides to every `appLayer`. */
17
+ export type ServiceRegistries = HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag;
4
18
  /**
5
19
  * Definition of an Effect-native backend service.
6
20
  *
7
- * `router` carries the service's routes and, in its `R` type parameter, the
8
- * exact set of domain services those routes require. `appLayer` must provide
9
- * that same `R` — the composition root. Because `R` is tracked in the type, a
10
- * missing dependency is a COMPILE error here, not a runtime surprise. This is
11
- * the uniform skeleton every `*-service` composes; the HTTP server, logging,
21
+ * `router` carries the service's routes and, in its requirements, the exact set
22
+ * of domain services those routes need. `appLayer` must provide them — the
23
+ * composition root. Because the requirements are tracked in the type, a
24
+ * missing dependency is a COMPILE error at `makeService`, not a runtime
25
+ * surprise: the launched layer must require nothing. This is the uniform
26
+ * skeleton every `*-service` composes; the HTTP server, logging,
12
27
  * `/api/health`, and graceful shutdown live here so services stay thin.
13
28
  */
14
- export interface ServiceDefinition<E, R> {
29
+ export interface ServiceDefinition<RRoutes> {
15
30
  /** Service name, surfaced on `/api/health`. */
16
31
  readonly name: string;
17
32
  /** Port to bind. Convention: 310N for domain services, 319x for platform. */
@@ -26,8 +41,8 @@ export interface ServiceDefinition<E, R> {
26
41
  * quietly mislabelling the fleet.
27
42
  */
28
43
  readonly slices: readonly string[];
29
- /** The service's routes; its `R` is satisfied by `appLayer`. */
30
- readonly router: HttpRouter.HttpRouter<E, R>;
44
+ /** The service's routes; their requirements are satisfied by `appLayer`. */
45
+ readonly router: ServiceRoutes<RRoutes>;
31
46
  /**
32
47
  * Composition root providing everything `router` requires.
33
48
  *
@@ -38,7 +53,7 @@ export interface ServiceDefinition<E, R> {
38
53
  * provided here, once, so the probes, the bus, the drain and the routes that
39
54
  * read them share one apiece.
40
55
  */
41
- readonly appLayer: Layer.Layer<R, never, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag>;
56
+ readonly appLayer: Layer.Layer<RoutesRequirements<RRoutes>, never, ServiceRegistries>;
42
57
  }
43
58
  /**
44
59
  * The service as a `Layer`: `/api/health` mounted, `router` served over an
@@ -56,7 +71,7 @@ export interface ServiceDefinition<E, R> {
56
71
  * only a real process boot has a SIGTERM to latch, and an in-process e2e boot
57
72
  * that installed one would leave a process listener per service it started.
58
73
  */
59
- export declare const makeServerLayer: <E, R>(def: ServiceDefinition<E, R>, port?: number, shutdown?: ShutdownRegistry) => Layer.Layer<never, import("@effect/platform/HttpServerError").ServeError, Exclude<Exclude<HealthRegistryTag, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | R>, HttpServer.HttpServer | import("@effect/platform/HttpPlatform").HttpPlatform | import("@effect/platform/Etag").Generator | import("@effect/platform-node/NodeContext").NodeContext> | Exclude<Exclude<ShutdownRegistryTag, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | R>, HttpServer.HttpServer | import("@effect/platform/HttpPlatform").HttpPlatform | import("@effect/platform/Etag").Generator | import("@effect/platform-node/NodeContext").NodeContext> | Exclude<Exclude<WiringRegistryTag, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | R>, HttpServer.HttpServer | import("@effect/platform/HttpPlatform").HttpPlatform | import("@effect/platform/Etag").Generator | import("@effect/platform-node/NodeContext").NodeContext> | Exclude<Exclude<HttpServer.HttpServer, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | R>, HttpServer.HttpServer | import("@effect/platform/HttpPlatform").HttpPlatform | import("@effect/platform/Etag").Generator | import("@effect/platform-node/NodeContext").NodeContext> | Exclude<Exclude<Exclude<Exclude<R, HttpRouter.RouteContext>, import("@effect/platform/HttpServerRequest").HttpServerRequest | import("effect/Scope").Scope>, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | R>, HttpServer.HttpServer | import("@effect/platform/HttpPlatform").HttpPlatform | import("@effect/platform/Etag").Generator | import("@effect/platform-node/NodeContext").NodeContext>>;
74
+ export declare const makeServerLayer: <RRoutes>(def: ServiceDefinition<RRoutes>, port?: number, shutdown?: ShutdownRegistry) => Layer.Layer<Layer.Success<Layer.Layer<never, import("effect/http/HttpServerError").ServeError, Exclude<Exclude<HealthRegistryTag, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator> | Exclude<Exclude<ShutdownRegistryTag, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator> | Exclude<Exclude<WiringRegistryTag, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator> | Exclude<Exclude<import("effect/http/HttpServer").HttpServer, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator> | Exclude<Exclude<Exclude<HttpRouter.Request.Without<RRoutes>, HttpRouter.HttpRouter>, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator> | Exclude<Exclude<Exclude<Exclude<HttpRouter.Request.Only<"Requires", RRoutes>, HttpRouter.GlobalProvided>, HttpRouter.HttpRouter>, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator> | Exclude<Exclude<Exclude<Exclude<HttpRouter.Request.Only<"GlobalRequires", RRoutes>, HttpRouter.GlobalProvided>, HttpRouter.HttpRouter>, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator>>>, Layer.Error<Layer.Layer<never, import("effect/http/HttpServerError").ServeError, Exclude<Exclude<HealthRegistryTag, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator> | Exclude<Exclude<ShutdownRegistryTag, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator> | Exclude<Exclude<WiringRegistryTag, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator> | Exclude<Exclude<import("effect/http/HttpServer").HttpServer, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator> | Exclude<Exclude<Exclude<HttpRouter.Request.Without<RRoutes>, HttpRouter.HttpRouter>, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator> | Exclude<Exclude<Exclude<Exclude<HttpRouter.Request.Only<"Requires", RRoutes>, HttpRouter.GlobalProvided>, HttpRouter.HttpRouter>, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator> | Exclude<Exclude<Exclude<Exclude<HttpRouter.Request.Only<"GlobalRequires", RRoutes>, HttpRouter.GlobalProvided>, HttpRouter.HttpRouter>, HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag | RoutesRequirements<RRoutes>>, import("effect/http/HttpServer").HttpServer | import("@effect/platform-node/NodeServices").NodeServices | import("effect/http/HttpPlatform").HttpPlatform | import("effect/http/Etag").Generator>>>>;
60
75
  /**
61
76
  * Boot an Effect service: build {@link makeServerLayer} and launch it under
62
77
  * `runMain` (SIGINT/SIGTERM interrupt the fiber, so Layer finalizers — the
@@ -73,5 +88,5 @@ export declare const makeServerLayer: <E, R>(def: ServiceDefinition<E, R>, port?
73
88
  * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no
74
89
  * Deployment for a service. It lands with the first one.
75
90
  */
76
- export declare const makeService: <E, R>(def: ServiceDefinition<E, R>) => void;
91
+ export declare const makeService: <RRoutes>(def: ServiceDefinition<RRoutes>) => void;
77
92
  //# sourceMappingURL=make-service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"make-service.d.ts","sourceRoot":"","sources":["../src/make-service.ts"],"names":[],"mappings":"AAEA,OAAO,EAAkB,UAAU,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAE1E,OAAO,EAEL,iBAAiB,EAEjB,KAAK,gBAAgB,EACrB,mBAAmB,EAEnB,iBAAiB,EAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AAKvC;;;;;;;;;GASG;AACH,MAAM,WAAW,iBAAiB,CAAC,CAAC,EAAE,CAAC;IACrC,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,gEAAgE;IAChE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC7C;;;;;;;;;OASG;IACH,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,CAC5B,CAAC,EACD,KAAK,EACL,iBAAiB,GAAG,mBAAmB,GAAG,iBAAiB,CAC5D,CAAC;CACH;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,EAAE,CAAC,EAClC,KAAK,iBAAiB,CAAC,CAAC,EAAE,CAAC,CAAC,EAC5B,OAAM,MAAiB,EACvB,WAAU,gBAAkD,olDA0C7D,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW,GAAI,CAAC,EAAE,CAAC,EAAE,KAAK,iBAAiB,CAAC,CAAC,EAAE,CAAC,CAAC,KAAG,IAahE,CAAC"}
1
+ {"version":3,"file":"make-service.d.ts","sourceRoot":"","sources":["../src/make-service.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,iBAAiB,EAEjB,KAAK,gBAAgB,EACrB,mBAAmB,EAEnB,iBAAiB,EAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAKzC;;;;GAIG;AAEH,MAAM,MAAM,aAAa,CAAC,CAAC,GAAG,GAAG,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;AAElE;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,CAAC,CAAC,IAC5B,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,GACtC,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,EAAE,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC;AAElE,2EAA2E;AAC3E,MAAM,MAAM,iBAAiB,GAC3B,iBAAiB,GAAG,mBAAmB,GAAG,iBAAiB,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,MAAM,WAAW,iBAAiB,CAAC,OAAO;IACxC,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IACxC;;;;;;;;;OASG;IACH,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,CAC5B,kBAAkB,CAAC,OAAO,CAAC,EAC3B,KAAK,EACL,iBAAiB,CAClB,CAAC;CACH;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,EACrC,KAAK,iBAAiB,CAAC,OAAO,CAAC,EAC/B,OAAM,MAAiB,EACvB,WAAU,gBAAkD,KA8C3C,KAAK,CAAC,KAAK,CAC1B,KAAK,CAAC,OAAO,4iFAAe,EAC5B,KAAK,CAAC,KAAK,4iFAAe,CAE7B,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,EAAE,KAAK,iBAAiB,CAAC,OAAO,CAAC,KAAG,IAQtE,CAAC"}
@@ -1,10 +1,10 @@
1
1
  import { createServer } from 'node:http';
2
- import { HttpMiddleware, HttpServer } from '@effect/platform';
3
2
  import { NodeHttpServer, NodeRuntime } from '@effect/platform-node';
4
3
  import { HealthRegistryLayer, makeShutdownRegistry, ShutdownRegistryTag, WiringRegistryLayer } from '@entifix/business';
5
4
  import { Effect, Layer } from 'effect';
6
- import { withHealthRoutes } from './health-routes.js';
7
- import { withServiceDescriptionRoute } from './service-description-route.js';
5
+ import { HttpRouter } from 'effect/http';
6
+ import { healthRoutes } from './health-routes.js';
7
+ import { serviceDescriptionRoute } from './service-description-route.js';
8
8
  /**
9
9
  * The service as a `Layer`: `/api/health` mounted, `router` served over an
10
10
  * `@effect/platform-node` HTTP server with request logging, `appLayer`
@@ -21,9 +21,9 @@ import { withServiceDescriptionRoute } from './service-description-route.js';
21
21
  * only a real process boot has a SIGTERM to latch, and an in-process e2e boot
22
22
  * that installed one would leave a process listener per service it started.
23
23
  */ export const makeServerLayer = (def, port = def.port, shutdown = makeShutdownRegistry().registry)=>{
24
- const router = withServiceDescriptionRoute(withHealthRoutes(def.router, def.name), def.name, def.slices);
25
- // Request logging + permissive CORS (frontends call these services
26
- // cross-origin from their dev ports). Tighten CORS per-environment later.
24
+ const routes = Layer.mergeAll(def.router, healthRoutes(def.name), serviceDescriptionRoute(def.name, def.slices), // Permissive CORS (frontends call these services cross-origin from their
25
+ // dev ports). Tighten per-environment later.
26
+ HttpRouter.cors());
27
27
  // `provideMerge` (not `provide`) so the registries the client layers
28
28
  // registered into are the same instances the readiness and description routes
29
29
  // read.
@@ -34,10 +34,16 @@ import { withServiceDescriptionRoute } from './service-description-route.js';
34
34
  // *before* the connections it needs are closed. A signal handler could not
35
35
  // give that ordering — `runMain` interrupts the fiber on SIGTERM, and a
36
36
  // handler racing that interrupt would drain against a closing client.
37
- const drained = Layer.provideMerge(Layer.scopedDiscard(Effect.acquireRelease(Effect.void, ()=>shutdown.drain)), appLayer);
38
- return HttpServer.serve(router, (app)=>HttpMiddleware.logger(HttpMiddleware.cors()(app))).pipe(Layer.provide(drained), Layer.provide(NodeHttpServer.layer(()=>createServer(), {
37
+ const drained = Layer.provideMerge(Layer.effectDiscard(Effect.acquireRelease(Effect.void, ()=>shutdown.drain)), appLayer);
38
+ // `HttpRouter.serve` logs every request unless told not to, which is the
39
+ // request logging `HttpMiddleware.logger` used to add here.
40
+ const served = HttpRouter.serve(routes).pipe(Layer.provide(drained), Layer.provide(NodeHttpServer.layer(()=>createServer(), {
39
41
  port
40
42
  })));
43
+ // `ServiceDefinition` already proved `appLayer` covers what the routes need;
44
+ // TypeScript cannot carry that proof through `Exclude` on a type parameter,
45
+ // so the closed requirement set is stated here rather than re-derived.
46
+ return served;
41
47
  };
42
48
  /**
43
49
  * Boot an Effect service: build {@link makeServerLayer} and launch it under
@@ -58,13 +64,7 @@ import { withServiceDescriptionRoute } from './service-description-route.js';
58
64
  const shutdown = makeShutdownRegistry();
59
65
  process.once('SIGTERM', shutdown.begin);
60
66
  process.once('SIGINT', shutdown.begin);
61
- // `disablePrettyLogger` keeps `runMain` from swapping Effect's `defaultLogger`
62
- // for `prettyLoggerDefault`. A service that replaces `defaultLogger` (e.g. to
63
- // route logs through `@entifix/tooling`) needs it present to replace;
64
- // services that don't simply keep the structured default logger.
65
- NodeRuntime.runMain(Layer.launch(makeServerLayer(def, def.port, shutdown.registry)), {
66
- disablePrettyLogger: true
67
- });
67
+ NodeRuntime.runMain(Layer.launch(makeServerLayer(def, def.port, shutdown.registry)));
68
68
  };
69
69
 
70
70
  //# sourceMappingURL=make-service.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/make-service.ts"],"sourcesContent":["import { createServer } from 'node:http';\n\nimport { HttpMiddleware, HttpRouter, HttpServer } from '@effect/platform';\nimport { NodeHttpServer, NodeRuntime } from '@effect/platform-node';\nimport {\n HealthRegistryLayer,\n HealthRegistryTag,\n makeShutdownRegistry,\n type ShutdownRegistry,\n ShutdownRegistryTag,\n WiringRegistryLayer,\n WiringRegistryTag,\n} from '@entifix/business';\nimport { Effect, Layer } from 'effect';\n\nimport { withHealthRoutes } from './health-routes.js';\nimport { withServiceDescriptionRoute } from './service-description-route.js';\n\n/**\n * Definition of an Effect-native backend service.\n *\n * `router` carries the service's routes and, in its `R` type parameter, the\n * exact set of domain services those routes require. `appLayer` must provide\n * that same `R` — the composition root. Because `R` is tracked in the type, a\n * missing dependency is a COMPILE error here, not a runtime surprise. This is\n * the uniform skeleton every `*-service` composes; the HTTP server, logging,\n * `/api/health`, and graceful shutdown live here so services stay thin.\n */\nexport interface ServiceDefinition<E, R> {\n /** Service name, surfaced on `/api/health`. */\n readonly name: string;\n /** Port to bind. Convention: 310N for domain services, 319x for platform. */\n readonly port: number;\n /**\n * The slices this process hosts, by their `tools/slices/` name.\n *\n * It cannot be derived. `EventSourceTag` names only the *emitting* slice, and\n * one deployment may host several: ADR 0021 co-deploys `transaction` inside\n * marketplace-admin-service. `dev-infra:map` checks each name against the\n * register's own `deployments`, so a wrong one fails the diff rather than\n * quietly mislabelling the fleet.\n */\n readonly slices: readonly string[];\n /** The service's routes; its `R` is satisfied by `appLayer`. */\n readonly router: HttpRouter.HttpRouter<E, R>;\n /**\n * Composition root providing everything `router` requires.\n *\n * It may also *require* {@link HealthRegistryTag} — that is how a client\n * layer's readiness probe registers itself — {@link WiringRegistryTag}, which\n * is how the bus records what it bound, and {@link ShutdownRegistryTag},\n * which is how it registers what draining it means. All three instances are\n * provided here, once, so the probes, the bus, the drain and the routes that\n * read them share one apiece.\n */\n readonly appLayer: Layer.Layer<\n R,\n never,\n HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag\n >;\n}\n\n/**\n * The service as a `Layer`: `/api/health` mounted, `router` served over an\n * `@effect/platform-node` HTTP server with request logging, `appLayer`\n * provided.\n *\n * Split out of {@link makeService} so the same wiring can be launched more than\n * one way. `makeService` runs it under `runMain` for production; the e2e mock\n * profile launches it in-process on an ephemeral port (see `serveTestService`).\n * Sharing this function is the point — a second assembly of router + health +\n * middleware would let the thing under test drift from the thing that ships.\n *\n * `port` overrides `def.port`, which is what an in-process boot needs.\n * `shutdown` overrides the registry, which is what {@link makeService} needs:\n * only a real process boot has a SIGTERM to latch, and an in-process e2e boot\n * that installed one would leave a process listener per service it started.\n */\nexport const makeServerLayer = <E, R>(\n def: ServiceDefinition<E, R>,\n port: number = def.port,\n shutdown: ShutdownRegistry = makeShutdownRegistry().registry,\n) => {\n const router = withServiceDescriptionRoute(\n withHealthRoutes(def.router, def.name),\n def.name,\n def.slices,\n );\n\n // Request logging + permissive CORS (frontends call these services\n // cross-origin from their dev ports). Tighten CORS per-environment later.\n // `provideMerge` (not `provide`) so the registries the client layers\n // registered into are the same instances the readiness and description routes\n // read.\n const appLayer = def.appLayer.pipe(\n Layer.provideMerge(\n Layer.mergeAll(\n HealthRegistryLayer,\n WiringRegistryLayer,\n Layer.succeed(ShutdownRegistryTag, shutdown),\n ),\n ),\n );\n\n // The graceful shutdown, and its **position** is the mechanism (ADR 0030).\n // Build order is `appLayer` → this → `serve`, so release order is the\n // reverse: the drain runs after the server has stopped taking requests and\n // *before* the connections it needs are closed. A signal handler could not\n // give that ordering — `runMain` interrupts the fiber on SIGTERM, and a\n // handler racing that interrupt would drain against a closing client.\n const drained = Layer.provideMerge(\n Layer.scopedDiscard(\n Effect.acquireRelease(Effect.void, () => shutdown.drain),\n ),\n appLayer,\n );\n\n return HttpServer.serve(router, app =>\n HttpMiddleware.logger(HttpMiddleware.cors()(app)),\n ).pipe(\n Layer.provide(drained),\n Layer.provide(NodeHttpServer.layer(() => createServer(), { port })),\n );\n};\n\n/**\n * Boot an Effect service: build {@link makeServerLayer} and launch it under\n * `runMain` (SIGINT/SIGTERM interrupt the fiber, so Layer finalizers — the\n * shutdown drain, then the db pools — release deterministically).\n *\n * The signal listeners here do **one** thing: flip the readiness latch, in the\n * same tick `runMain`'s own listener starts the interrupt, so\n * `GET /api/health/ready` stops claiming to be ready immediately rather than\n * when the finalizers get around to running. The draining itself is the\n * finalizer's, because only layer ordering can guarantee the connections are\n * still open while it happens.\n *\n * The `preStop` grace period ADR 0030 also names is **not** here and could not\n * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no\n * Deployment for a service. It lands with the first one.\n */\nexport const makeService = <E, R>(def: ServiceDefinition<E, R>): void => {\n const shutdown = makeShutdownRegistry();\n process.once('SIGTERM', shutdown.begin);\n process.once('SIGINT', shutdown.begin);\n\n // `disablePrettyLogger` keeps `runMain` from swapping Effect's `defaultLogger`\n // for `prettyLoggerDefault`. A service that replaces `defaultLogger` (e.g. to\n // route logs through `@entifix/tooling`) needs it present to replace;\n // services that don't simply keep the structured default logger.\n NodeRuntime.runMain(\n Layer.launch(makeServerLayer(def, def.port, shutdown.registry)),\n { disablePrettyLogger: true },\n );\n};\n"],"names":["createServer","HttpMiddleware","HttpServer","NodeHttpServer","NodeRuntime","HealthRegistryLayer","makeShutdownRegistry","ShutdownRegistryTag","WiringRegistryLayer","Effect","Layer","withHealthRoutes","withServiceDescriptionRoute","makeServerLayer","def","port","shutdown","registry","router","name","slices","appLayer","pipe","provideMerge","mergeAll","succeed","drained","scopedDiscard","acquireRelease","void","drain","serve","app","logger","cors","provide","layer","makeService","process","once","begin","runMain","launch","disablePrettyLogger"],"mappings":"AAAA,SAASA,YAAY,QAAQ,YAAY;AAEzC,SAASC,cAAc,EAAcC,UAAU,QAAQ,mBAAmB;AAC1E,SAASC,cAAc,EAAEC,WAAW,QAAQ,wBAAwB;AACpE,SACEC,mBAAmB,EAEnBC,oBAAoB,EAEpBC,mBAAmB,EACnBC,mBAAmB,QAEd,oBAAoB;AAC3B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAEvC,SAASC,gBAAgB,QAAQ,qBAAqB;AACtD,SAASC,2BAA2B,QAAQ,iCAAiC;AA8C7E;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,kBAAkB,CAC7BC,KACAC,OAAeD,IAAIC,IAAI,EACvBC,WAA6BV,uBAAuBW,QAAQ;IAE5D,MAAMC,SAASN,4BACbD,iBAAiBG,IAAII,MAAM,EAAEJ,IAAIK,IAAI,GACrCL,IAAIK,IAAI,EACRL,IAAIM,MAAM;IAGZ,mEAAmE;IACnE,0EAA0E;IAC1E,qEAAqE;IACrE,8EAA8E;IAC9E,QAAQ;IACR,MAAMC,WAAWP,IAAIO,QAAQ,CAACC,IAAI,CAChCZ,MAAMa,YAAY,CAChBb,MAAMc,QAAQ,CACZnB,qBACAG,qBACAE,MAAMe,OAAO,CAAClB,qBAAqBS;IAKzC,2EAA2E;IAC3E,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,wEAAwE;IACxE,sEAAsE;IACtE,MAAMU,UAAUhB,MAAMa,YAAY,CAChCb,MAAMiB,aAAa,CACjBlB,OAAOmB,cAAc,CAACnB,OAAOoB,IAAI,EAAE,IAAMb,SAASc,KAAK,IAEzDT;IAGF,OAAOnB,WAAW6B,KAAK,CAACb,QAAQc,CAAAA,MAC9B/B,eAAegC,MAAM,CAAChC,eAAeiC,IAAI,GAAGF,OAC5CV,IAAI,CACJZ,MAAMyB,OAAO,CAACT,UACdhB,MAAMyB,OAAO,CAAChC,eAAeiC,KAAK,CAAC,IAAMpC,gBAAgB;QAAEe;IAAK;AAEpE,EAAE;AAEF;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMsB,cAAc,CAAOvB;IAChC,MAAME,WAAWV;IACjBgC,QAAQC,IAAI,CAAC,WAAWvB,SAASwB,KAAK;IACtCF,QAAQC,IAAI,CAAC,UAAUvB,SAASwB,KAAK;IAErC,+EAA+E;IAC/E,8EAA8E;IAC9E,sEAAsE;IACtE,iEAAiE;IACjEpC,YAAYqC,OAAO,CACjB/B,MAAMgC,MAAM,CAAC7B,gBAAgBC,KAAKA,IAAIC,IAAI,EAAEC,SAASC,QAAQ,IAC7D;QAAE0B,qBAAqB;IAAK;AAEhC,EAAE"}
1
+ {"version":3,"sources":["../src/make-service.ts"],"sourcesContent":["import { createServer } from 'node:http';\n\nimport { NodeHttpServer, NodeRuntime } from '@effect/platform-node';\nimport {\n HealthRegistryLayer,\n HealthRegistryTag,\n makeShutdownRegistry,\n type ShutdownRegistry,\n ShutdownRegistryTag,\n WiringRegistryLayer,\n WiringRegistryTag,\n} from '@entifix/business';\nimport { Effect, Layer } from 'effect';\nimport { HttpRouter } from 'effect/http';\n\nimport { healthRoutes } from './health-routes.js';\nimport { serviceDescriptionRoute } from './service-description-route.js';\n\n/**\n * A service's routes: `HttpRouter.add` / `HttpRouter.addAll` layers, merged\n * with `Layer.mergeAll`. Its requirements carry, as `HttpRouter` request\n * markers, the domain services the handlers need.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ServiceRoutes<R = any> = Layer.Layer<never, never, R>;\n\n/**\n * What a service's `appLayer` must provide for routes requiring `R`: the\n * services their handlers read per request, plus anything building the route\n * layers themselves needs.\n */\nexport type RoutesRequirements<R> =\n | HttpRouter.Request.Only<'Requires', R>\n | HttpRouter.Request.Without<Exclude<R, HttpRouter.HttpRouter>>;\n\n/** The registries {@link makeServerLayer} provides to every `appLayer`. */\nexport type ServiceRegistries =\n HealthRegistryTag | ShutdownRegistryTag | WiringRegistryTag;\n\n/**\n * Definition of an Effect-native backend service.\n *\n * `router` carries the service's routes and, in its requirements, the exact set\n * of domain services those routes need. `appLayer` must provide them — the\n * composition root. Because the requirements are tracked in the type, a\n * missing dependency is a COMPILE error at `makeService`, not a runtime\n * surprise: the launched layer must require nothing. This is the uniform\n * skeleton every `*-service` composes; the HTTP server, logging,\n * `/api/health`, and graceful shutdown live here so services stay thin.\n */\nexport interface ServiceDefinition<RRoutes> {\n /** Service name, surfaced on `/api/health`. */\n readonly name: string;\n /** Port to bind. Convention: 310N for domain services, 319x for platform. */\n readonly port: number;\n /**\n * The slices this process hosts, by their `tools/slices/` name.\n *\n * It cannot be derived. `EventSourceTag` names only the *emitting* slice, and\n * one deployment may host several: ADR 0021 co-deploys `transaction` inside\n * marketplace-admin-service. `dev-infra:map` checks each name against the\n * register's own `deployments`, so a wrong one fails the diff rather than\n * quietly mislabelling the fleet.\n */\n readonly slices: readonly string[];\n /** The service's routes; their requirements are satisfied by `appLayer`. */\n readonly router: ServiceRoutes<RRoutes>;\n /**\n * Composition root providing everything `router` requires.\n *\n * It may also *require* {@link HealthRegistryTag} — that is how a client\n * layer's readiness probe registers itself — {@link WiringRegistryTag}, which\n * is how the bus records what it bound, and {@link ShutdownRegistryTag},\n * which is how it registers what draining it means. All three instances are\n * provided here, once, so the probes, the bus, the drain and the routes that\n * read them share one apiece.\n */\n readonly appLayer: Layer.Layer<\n RoutesRequirements<RRoutes>,\n never,\n ServiceRegistries\n >;\n}\n\n/**\n * The service as a `Layer`: `/api/health` mounted, `router` served over an\n * `@effect/platform-node` HTTP server with request logging, `appLayer`\n * provided.\n *\n * Split out of {@link makeService} so the same wiring can be launched more than\n * one way. `makeService` runs it under `runMain` for production; the e2e mock\n * profile launches it in-process on an ephemeral port (see `serveTestService`).\n * Sharing this function is the point — a second assembly of router + health +\n * middleware would let the thing under test drift from the thing that ships.\n *\n * `port` overrides `def.port`, which is what an in-process boot needs.\n * `shutdown` overrides the registry, which is what {@link makeService} needs:\n * only a real process boot has a SIGTERM to latch, and an in-process e2e boot\n * that installed one would leave a process listener per service it started.\n */\nexport const makeServerLayer = <RRoutes>(\n def: ServiceDefinition<RRoutes>,\n port: number = def.port,\n shutdown: ShutdownRegistry = makeShutdownRegistry().registry,\n) => {\n const routes = Layer.mergeAll(\n def.router,\n healthRoutes(def.name),\n serviceDescriptionRoute(def.name, def.slices),\n // Permissive CORS (frontends call these services cross-origin from their\n // dev ports). Tighten per-environment later.\n HttpRouter.cors(),\n );\n\n // `provideMerge` (not `provide`) so the registries the client layers\n // registered into are the same instances the readiness and description routes\n // read.\n const appLayer = def.appLayer.pipe(\n Layer.provideMerge(\n Layer.mergeAll(\n HealthRegistryLayer,\n WiringRegistryLayer,\n Layer.succeed(ShutdownRegistryTag, shutdown),\n ),\n ),\n );\n\n // The graceful shutdown, and its **position** is the mechanism (ADR 0030).\n // Build order is `appLayer` → this → `serve`, so release order is the\n // reverse: the drain runs after the server has stopped taking requests and\n // *before* the connections it needs are closed. A signal handler could not\n // give that ordering — `runMain` interrupts the fiber on SIGTERM, and a\n // handler racing that interrupt would drain against a closing client.\n const drained = Layer.provideMerge(\n Layer.effectDiscard(\n Effect.acquireRelease(Effect.void, () => shutdown.drain),\n ),\n appLayer,\n );\n\n // `HttpRouter.serve` logs every request unless told not to, which is the\n // request logging `HttpMiddleware.logger` used to add here.\n const served = HttpRouter.serve(routes).pipe(\n Layer.provide(drained),\n Layer.provide(NodeHttpServer.layer(() => createServer(), { port })),\n );\n // `ServiceDefinition` already proved `appLayer` covers what the routes need;\n // TypeScript cannot carry that proof through `Exclude` on a type parameter,\n // so the closed requirement set is stated here rather than re-derived.\n return served as Layer.Layer<\n Layer.Success<typeof served>,\n Layer.Error<typeof served>\n >;\n};\n\n/**\n * Boot an Effect service: build {@link makeServerLayer} and launch it under\n * `runMain` (SIGINT/SIGTERM interrupt the fiber, so Layer finalizers — the\n * shutdown drain, then the db pools — release deterministically).\n *\n * The signal listeners here do **one** thing: flip the readiness latch, in the\n * same tick `runMain`'s own listener starts the interrupt, so\n * `GET /api/health/ready` stops claiming to be ready immediately rather than\n * when the finalizers get around to running. The draining itself is the\n * finalizer's, because only layer ordering can guarantee the connections are\n * still open while it happens.\n *\n * The `preStop` grace period ADR 0030 also names is **not** here and could not\n * be: Kubernetes runs it before it sends SIGTERM, and this repo declares no\n * Deployment for a service. It lands with the first one.\n */\nexport const makeService = <RRoutes>(def: ServiceDefinition<RRoutes>): void => {\n const shutdown = makeShutdownRegistry();\n process.once('SIGTERM', shutdown.begin);\n process.once('SIGINT', shutdown.begin);\n\n NodeRuntime.runMain(\n Layer.launch(makeServerLayer(def, def.port, shutdown.registry)),\n );\n};\n"],"names":["createServer","NodeHttpServer","NodeRuntime","HealthRegistryLayer","makeShutdownRegistry","ShutdownRegistryTag","WiringRegistryLayer","Effect","Layer","HttpRouter","healthRoutes","serviceDescriptionRoute","makeServerLayer","def","port","shutdown","registry","routes","mergeAll","router","name","slices","cors","appLayer","pipe","provideMerge","succeed","drained","effectDiscard","acquireRelease","void","drain","served","serve","provide","layer","makeService","process","once","begin","runMain","launch"],"mappings":"AAAA,SAASA,YAAY,QAAQ,YAAY;AAEzC,SAASC,cAAc,EAAEC,WAAW,QAAQ,wBAAwB;AACpE,SACEC,mBAAmB,EAEnBC,oBAAoB,EAEpBC,mBAAmB,EACnBC,mBAAmB,QAEd,oBAAoB;AAC3B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AACvC,SAASC,UAAU,QAAQ,cAAc;AAEzC,SAASC,YAAY,QAAQ,qBAAqB;AAClD,SAASC,uBAAuB,QAAQ,iCAAiC;AAoEzE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,kBAAkB,CAC7BC,KACAC,OAAeD,IAAIC,IAAI,EACvBC,WAA6BX,uBAAuBY,QAAQ;IAE5D,MAAMC,SAAST,MAAMU,QAAQ,CAC3BL,IAAIM,MAAM,EACVT,aAAaG,IAAIO,IAAI,GACrBT,wBAAwBE,IAAIO,IAAI,EAAEP,IAAIQ,MAAM,GAC5C,yEAAyE;IACzE,6CAA6C;IAC7CZ,WAAWa,IAAI;IAGjB,qEAAqE;IACrE,8EAA8E;IAC9E,QAAQ;IACR,MAAMC,WAAWV,IAAIU,QAAQ,CAACC,IAAI,CAChChB,MAAMiB,YAAY,CAChBjB,MAAMU,QAAQ,CACZf,qBACAG,qBACAE,MAAMkB,OAAO,CAACrB,qBAAqBU;IAKzC,2EAA2E;IAC3E,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,wEAAwE;IACxE,sEAAsE;IACtE,MAAMY,UAAUnB,MAAMiB,YAAY,CAChCjB,MAAMoB,aAAa,CACjBrB,OAAOsB,cAAc,CAACtB,OAAOuB,IAAI,EAAE,IAAMf,SAASgB,KAAK,IAEzDR;IAGF,yEAAyE;IACzE,4DAA4D;IAC5D,MAAMS,SAASvB,WAAWwB,KAAK,CAAChB,QAAQO,IAAI,CAC1ChB,MAAM0B,OAAO,CAACP,UACdnB,MAAM0B,OAAO,CAACjC,eAAekC,KAAK,CAAC,IAAMnC,gBAAgB;QAAEc;IAAK;IAElE,6EAA6E;IAC7E,4EAA4E;IAC5E,uEAAuE;IACvE,OAAOkB;AAIT,EAAE;AAEF;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMI,cAAc,CAAUvB;IACnC,MAAME,WAAWX;IACjBiC,QAAQC,IAAI,CAAC,WAAWvB,SAASwB,KAAK;IACtCF,QAAQC,IAAI,CAAC,UAAUvB,SAASwB,KAAK;IAErCrC,YAAYsC,OAAO,CACjBhC,MAAMiC,MAAM,CAAC7B,gBAAgBC,KAAKA,IAAIC,IAAI,EAAEC,SAASC,QAAQ;AAEjE,EAAE"}
@@ -1 +1 @@
1
- {"version":3,"file":"observability.d.ts","sourceRoot":"","sources":["../src/observability.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAC5E,OAAO,EAIL,KAAK,QAAQ,EACb,KAAK,SAAS,EACd,KAAK,OAAO,EAGb,MAAM,0BAA0B,CAAC;AAKlC,OAAO,EAGL,KAAK,YAAY,EAEjB,KAAK,eAAe,EACrB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAGL,KAAK,YAAY,EAEjB,KAAK,aAAa,EACnB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,MAAM,EAAW,KAAK,EAAU,MAAM,QAAQ,CAAC;AAExD,kFAAkF;AAClF,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IACzB,mFAAmF;IACnF,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IACjC;;;;;;;;;OASG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C;;;;;;;;;OASG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAChD;AAqJD,gEAAgE;AAChE,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IACzB,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IACnD;;;;;;OAMG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,YAAY,GAAG,SAAS,CAAC;CAClD;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,GACrC,SAAS,yBAAyB,iFA8BnC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,sBAAsB,GAAI,QAAQ,mBAAmB,iFAuBjE,CAAC;AAEF,sEAAsE;AACtE,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,OAAO,0BAA0B,CAAC,CAAC;IAC9D,wEAAwE;IACxE,QAAQ,CAAC,UAAU,EAAE,SAAS,SAAS,EAAE,CAAC;IAC1C,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,MAAM,aAAa,CAAC,YAAY,CAAC,CAAC;IACrD;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC,CAAC;CACxE;AAED;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,GACzC,aAAa,MAAM,KAClB,qBA6BF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,8BAA8B,GACzC,OAAO,mBAAmB,EAC1B,aAAa,MAAM,KAClB,MAAM,CAAC,MAAM,CACd,UAAU,CAAC,OAAO,sBAAsB,CAAC,EACzC,iBAAiB,CAmBf,CAAC"}
1
+ {"version":3,"file":"observability.d.ts","sourceRoot":"","sources":["../src/observability.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAC5E,OAAO,EAIL,KAAK,QAAQ,EACb,KAAK,SAAS,EACd,KAAK,OAAO,EAGb,MAAM,0BAA0B,CAAC;AAKlC,OAAO,EAGL,KAAK,YAAY,EAEjB,KAAK,eAAe,EACrB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAGL,KAAK,YAAY,EAEjB,KAAK,aAAa,EACnB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAsB,MAAM,QAAQ,CAAC;AAG3D,kFAAkF;AAClF,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IACzB,mFAAmF;IACnF,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IACjC;;;;;;;;;OASG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C;;;;;;;;;OASG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAChD;AA0JD,gEAAgE;AAChE,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IACzB,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IACnD;;;;;;OAMG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,YAAY,GAAG,SAAS,CAAC;CAClD;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,GACrC,SAAS,yBAAyB,iFA8BnC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,sBAAsB,GAAI,QAAQ,mBAAmB,iFAuBjE,CAAC;AAEF,sEAAsE;AACtE,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,OAAO,0BAA0B,CAAC,CAAC;IAC9D,wEAAwE;IACxE,QAAQ,CAAC,UAAU,EAAE,SAAS,SAAS,EAAE,CAAC;IAC1C,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,MAAM,aAAa,CAAC,YAAY,CAAC,CAAC;IACrD;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC,CAAC;CACxE;AAED;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,GACzC,aAAa,MAAM,KAClB,qBA6BF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,8BAA8B,GACzC,OAAO,mBAAmB,EAC1B,aAAa,MAAM,KAClB,MAAM,CAAC,MAAM,CACd,UAAU,CAAC,OAAO,sBAAsB,CAAC,EACzC,iBAAiB,CAmBf,CAAC"}
@@ -6,7 +6,7 @@ import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
6
6
  import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
7
7
  import { AggregationTemporality, InMemoryMetricExporter, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
8
8
  import { BatchSpanProcessor, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
9
- import { Effect, HashMap, Layer, Logger } from 'effect';
9
+ import { Effect, Layer, Logger, References } from 'effect';
10
10
  /** Export interval used when `otel.metricIntervalMs` is unset. */ const DEFAULT_METRIC_INTERVAL_MS = 60000;
11
11
  /** The in-memory reader's interval — long enough that only `forceFlush` collects. */ const ONE_HOUR_MS = 3600000;
12
12
  /**
@@ -44,28 +44,29 @@ const makeSink = (config, endpoint)=>config.sink === 'otlp' && endpoint !== unde
44
44
  onError: (error)=>process.stderr.write(`[otlp-log-sink] export failed: ${String(error)}\n`)
45
45
  }) : makeStdoutJsonSink();
46
46
  /**
47
- * Effect's `LogLevel.label` mapped onto the four levels the tooling logger has.
47
+ * Effect's `LogLevel` mapped onto the four levels the tooling logger has.
48
48
  *
49
- * ⚠️ **The labels are upper case** — `"TRACE" | "DEBUG" | "INFO" | "WARN" |
50
- * "ERROR" | "FATAL" | "ALL" | "NONE"`, as `effect/LogLevel` declares them. This
51
- * switch used to read `'Trace'`/`'Debug'`/`'Warning'`/`'Error'`/`'Fatal'`, so
49
+ * ⚠️ **Match the spelling `effect/LogLevel` declares, and let the compiler
50
+ * hold you to it.** Effect 4's level is the string union `"All" | "Fatal" |
51
+ * "Error" | "Warn" | "Info" | "Debug" | "Trace" | "None"`. Under Effect 3 this
52
+ * switch read the level's `label`, which was upper case (`"ERROR"`), against
53
+ * mixed-case spellings (`'Error'`, `'Warning'`) typed as a plain `string` — so
52
54
  * **no case could ever match** and every log in the service fell through to
53
55
  * `default` and was emitted at `info`. Measured in Loki: an `Effect.logError`
54
56
  * arrived as `severity_text: INFO`, `severity_number: 9`, which makes an error
55
57
  * invisible to every level-based alert and dashboard while still appearing in
56
- * the log — the failure mode that reads as "we have no errors".
57
- *
58
- * `'WARNING'` is deliberately absent: Effect's label is `WARN`, and adding the
59
- * other spelling would suggest one of them is live when only this one is.
60
- */ const toToolingLevel = (label)=>{
61
- switch(label){
62
- case 'TRACE':
63
- case 'DEBUG':
58
+ * the log — the failure mode that reads as "we have no errors". Typing the
59
+ * parameter as Effect's own union is what makes a misspelt case a compile
60
+ * error now: `'Warning'` would not compile.
61
+ */ const toToolingLevel = (level)=>{
62
+ switch(level){
63
+ case 'Trace':
64
+ case 'Debug':
64
65
  return 'debug';
65
- case 'WARN':
66
+ case 'Warn':
66
67
  return 'warn';
67
- case 'ERROR':
68
- case 'FATAL':
68
+ case 'Error':
69
+ case 'Fatal':
69
70
  return 'error';
70
71
  default:
71
72
  return 'info';
@@ -74,36 +75,38 @@ const makeSink = (config, endpoint)=>config.sink === 'otlp' && endpoint !== unde
74
75
  /**
75
76
  * `Effect.annotateLogs`' entries flattened into tooling `Attributes`.
76
77
  *
77
- * The annotations arrive as a `HashMap<string, unknown>` and the attribute
78
- * values are a closed scalar set, so anything else is stringified rather than
79
- * dropped — an annotation that reaches a sink as `[object Object]` is still
80
- * more use than one that silently is not there.
81
- */ const toAttributes = (annotations)=>Object.fromEntries(Array.from(HashMap.entries(annotations), ([key, value])=>[
78
+ * The annotations are a record read off the fiber, and the attribute values are
79
+ * a closed scalar set, so anything else is stringified rather than dropped — an
80
+ * annotation that reaches a sink as `[object Object]` is still more use than one
81
+ * that silently is not there.
82
+ */ const toAttributes = (annotations)=>Object.fromEntries(Object.entries(annotations).map(([key, value])=>[
82
83
  key,
83
84
  typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null ? value : String(value)
84
85
  ]));
85
86
  const messageToString = (message)=>Array.isArray(message) ? message.map((part)=>String(part)).join(' ') : String(message);
86
87
  /**
87
- * An Effect `Logger` that forwards every Effect log (including the HTTP request
88
- * logs from `HttpMiddleware.logger`) to the tooling logger — so the whole
89
- * service logs through one structured, trace-correlated pipeline.
88
+ * An Effect `Logger` that forwards every Effect log (including the request logs
89
+ * `HttpRouter.serve` writes) to the tooling logger — so the whole service logs
90
+ * through one structured, trace-correlated pipeline.
90
91
  *
91
- * **`annotations` are forwarded**, and used not to be: they were destructured
92
+ * **Annotations are forwarded**, and used not to be: they were destructured
92
93
  * away, so every `Effect.annotateLogs` in every service was discarded before it
93
94
  * reached a sink. That made the annotated fields the outbox relay and the
94
95
  * transaction engine attach — the tenant database, the event id, the
95
96
  * transaction id, the rollback's own error — unqueryable, while the comments
96
97
  * beside them said they were structured fields in Loki. Measured: the records
97
- * arrived carrying the message and nothing else.
98
+ * arrived carrying the message and nothing else. Effect 4 no longer hands them
99
+ * to a logger at all; they live on the fiber, as
100
+ * `References.CurrentLogAnnotations`, and are read from there.
98
101
  *
99
102
  * `error` is called through its own arm because the tooling `Logger` gives it a
100
103
  * different signature — `(message, error?, attributes?)` — so passing the
101
104
  * attributes positionally as the second argument would file them as the *cause*
102
105
  * and lose them again.
103
- */ const makeEffectLogger = (logger)=>Logger.make(({ logLevel, message, annotations })=>{
104
- const level = toToolingLevel(logLevel.label);
106
+ */ const makeEffectLogger = (logger)=>Logger.make(({ logLevel, message, fiber })=>{
107
+ const level = toToolingLevel(logLevel);
105
108
  const text = messageToString(message);
106
- const attributes = toAttributes(annotations);
109
+ const attributes = toAttributes(fiber.getRef(References.CurrentLogAnnotations));
107
110
  if (level === 'error') {
108
111
  logger.error(text, undefined, attributes);
109
112
  return;
@@ -124,12 +127,14 @@ const messageToString = (message)=>Array.isArray(message) ? message.map((part)=>
124
127
  level: options.level,
125
128
  sink: options.sink
126
129
  });
127
- // Replace Effect's default logger with the tooling logger. The service boot
128
- // (`makeService`) launches under `runMain` with `disablePrettyLogger: true`,
129
- // and the e2e boots under `Effect.runFork`; both leave `defaultLogger` in
130
- // place, so this single replace takes effect in either runtime.
130
+ // Replace Effect's loggers with the tooling logger. `Logger.layer` sets the
131
+ // fiber's current loggers outright rather than adding to them, and neither
132
+ // the service boot (`runMain`) nor the e2e boot (`Effect.runFork`) installs a
133
+ // logger of its own, so this single layer takes effect in either runtime.
131
134
  const effectLogger = makeEffectLogger(toolingLogger);
132
- const LoggingLayer = Logger.replace(Logger.defaultLogger, effectLogger);
135
+ const LoggingLayer = Logger.layer([
136
+ effectLogger
137
+ ]);
133
138
  // One `NodeSdk.layer` for both signals: it builds the OTel `Resource` once,
134
139
  // so the spans and the metrics carry the same `service.name` rather than two
135
140
  // resources that can disagree. `NodeSdk` degrades an absent `spanProcessor`
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/observability.ts"],"sourcesContent":["import { NodeSdk } from '@effect/opentelemetry';\nimport type { ConfigurationClient, EntifixBuildError } from '@entifix/core';\nimport {\n type Attributes,\n createLogger,\n type Logger as ToolingLogger,\n type LogLevel,\n type LogRecord,\n type LogSink,\n makeOtlpHttpLogSink,\n makeStdoutJsonSink,\n} from '@entifix/tooling/logging';\nimport { context } from '@opentelemetry/api';\nimport { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';\nimport { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';\nimport { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';\nimport {\n AggregationTemporality,\n InMemoryMetricExporter,\n type MetricReader,\n PeriodicExportingMetricReader,\n type ResourceMetrics,\n} from '@opentelemetry/sdk-metrics';\nimport {\n BatchSpanProcessor,\n InMemorySpanExporter,\n type ReadableSpan,\n SimpleSpanProcessor,\n type SpanProcessor,\n} from '@opentelemetry/sdk-trace-base';\nimport { Effect, HashMap, Layer, Logger } from 'effect';\n\n/** Resolved observability settings (from config-service `logging.*`/`otel.*`). */\nexport interface ObservabilityConfig {\n readonly serviceName: string;\n readonly level: LogLevel;\n /** `otlp` in dev (ship straight to otel-lgtm), `stdout` in a cluster (filelog). */\n readonly sink: 'stdout' | 'otlp';\n /**\n * OTLP base URL; `/v1/traces`, `/v1/logs` and `/v1/metrics` are appended.\n *\n * **Optional, and that is the boot guarantee**: with no endpoint the service\n * still starts — logs fall back to the stdout sink and neither exporter is\n * built. It used to be a required `getString`, so a fleet whose\n * `otel.endpoint` row was missing failed to boot at all rather than running\n * without telemetry, which is the wrong trade for a signal that is not on the\n * request path.\n */\n readonly otelEndpoint?: string | undefined;\n /**\n * How often the metric reader exports, in milliseconds. Config rather than a\n * constant because it is a real operational dial (a shorter interval buys\n * resolution with cardinality-independent request volume), and optional\n * because a fleet whose seed predates the key should not fail to boot over an\n * export interval. (Optional for that reason alone: config-service reconciles\n * `SEED_ROWS` per row on every boot, so a *new* key does reach an existing\n * Postgres — `ON CONFLICT DO NOTHING` declines to overwrite an operator's\n * changed *value*, which is the case that needs a `dev:reset`.)\n */\n readonly metricIntervalMs?: number | undefined;\n}\n\n/** Export interval used when `otel.metricIntervalMs` is unset. */\nconst DEFAULT_METRIC_INTERVAL_MS = 60_000;\n\n/** The in-memory reader's interval — long enough that only `forceFlush` collects. */\nconst ONE_HOUR_MS = 3_600_000;\n\n/**\n * `@effect/opentelemetry` manages its own span tree but does not register an\n * OpenTelemetry context manager, so `@opentelemetry/api`'s active-span lookup\n * (which the tooling logger uses to stamp `trace_id`) would stay empty. Register\n * the AsyncLocalStorage manager once so the active span propagates and logs\n * correlate to their traces. Idempotent — `setGlobalContextManager` no-ops if a\n * manager is already registered (e.g. across test boots in one process).\n */\nlet contextManagerReady = false;\nconst ensureContextManager = (): void => {\n if (!contextManagerReady) {\n context.setGlobalContextManager(\n new AsyncLocalStorageContextManager().enable(),\n );\n contextManagerReady = true;\n }\n};\n\n/**\n * The OTLP base URL, or `undefined` when there is nowhere to export.\n *\n * A **blank** endpoint counts as absent, not as a base URL. config-service's\n * operator CRUD writes empty strings, so `''` is reachable in practice — and\n * taken literally it builds exporters aimed at the relative `/v1/traces` and\n * `/v1/metrics`, which resolve against nothing, fail every export, and look\n * from the call site exactly like a service that is simply quiet.\n */\nconst resolveEndpoint = (raw: string | undefined): string | undefined => {\n const trimmed = raw?.trim();\n return trimmed === undefined || trimmed === '' ? undefined : trimmed;\n};\n\nconst makeSink = (\n config: ObservabilityConfig,\n endpoint: string | undefined,\n): LogSink =>\n config.sink === 'otlp' && endpoint !== undefined\n ? makeOtlpHttpLogSink({\n endpoint,\n serviceName: config.serviceName,\n // Flush on a short interval too, so a quiet service's logs reach the\n // Collector promptly instead of waiting for a full batch.\n flushIntervalMs: 2000,\n onError: error =>\n process.stderr.write(\n `[otlp-log-sink] export failed: ${String(error)}\\n`,\n ),\n })\n : makeStdoutJsonSink();\n\n/**\n * Effect's `LogLevel.label` mapped onto the four levels the tooling logger has.\n *\n * ⚠️ **The labels are upper case** — `\"TRACE\" | \"DEBUG\" | \"INFO\" | \"WARN\" |\n * \"ERROR\" | \"FATAL\" | \"ALL\" | \"NONE\"`, as `effect/LogLevel` declares them. This\n * switch used to read `'Trace'`/`'Debug'`/`'Warning'`/`'Error'`/`'Fatal'`, so\n * **no case could ever match** and every log in the service fell through to\n * `default` and was emitted at `info`. Measured in Loki: an `Effect.logError`\n * arrived as `severity_text: INFO`, `severity_number: 9`, which makes an error\n * invisible to every level-based alert and dashboard while still appearing in\n * the log — the failure mode that reads as \"we have no errors\".\n *\n * `'WARNING'` is deliberately absent: Effect's label is `WARN`, and adding the\n * other spelling would suggest one of them is live when only this one is.\n */\nconst toToolingLevel = (label: string): LogLevel => {\n switch (label) {\n case 'TRACE':\n case 'DEBUG':\n return 'debug';\n case 'WARN':\n return 'warn';\n case 'ERROR':\n case 'FATAL':\n return 'error';\n default:\n return 'info';\n }\n};\n\n/**\n * `Effect.annotateLogs`' entries flattened into tooling `Attributes`.\n *\n * The annotations arrive as a `HashMap<string, unknown>` and the attribute\n * values are a closed scalar set, so anything else is stringified rather than\n * dropped — an annotation that reaches a sink as `[object Object]` is still\n * more use than one that silently is not there.\n */\nconst toAttributes = (\n annotations: HashMap.HashMap<string, unknown>,\n): Attributes =>\n Object.fromEntries(\n Array.from(HashMap.entries(annotations), ([key, value]) => [\n key,\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean' ||\n value === null\n ? value\n : String(value),\n ]),\n );\n\nconst messageToString = (message: unknown): string =>\n Array.isArray(message)\n ? message.map(part => String(part)).join(' ')\n : String(message);\n\n/**\n * An Effect `Logger` that forwards every Effect log (including the HTTP request\n * logs from `HttpMiddleware.logger`) to the tooling logger — so the whole\n * service logs through one structured, trace-correlated pipeline.\n *\n * **`annotations` are forwarded**, and used not to be: they were destructured\n * away, so every `Effect.annotateLogs` in every service was discarded before it\n * reached a sink. That made the annotated fields the outbox relay and the\n * transaction engine attach — the tenant database, the event id, the\n * transaction id, the rollback's own error — unqueryable, while the comments\n * beside them said they were structured fields in Loki. Measured: the records\n * arrived carrying the message and nothing else.\n *\n * `error` is called through its own arm because the tooling `Logger` gives it a\n * different signature — `(message, error?, attributes?)` — so passing the\n * attributes positionally as the second argument would file them as the *cause*\n * and lose them again.\n */\nconst makeEffectLogger = (\n logger: ToolingLogger,\n): Logger.Logger<unknown, void> =>\n Logger.make(({ logLevel, message, annotations }) => {\n const level = toToolingLevel(logLevel.label);\n const text = messageToString(message);\n const attributes = toAttributes(annotations);\n\n if (level === 'error') {\n logger.error(text, undefined, attributes);\n return;\n }\n logger[level](text, attributes);\n });\n\n/** Lower-level inputs to {@link makeObservabilityLayerWith}. */\nexport interface ObservabilityLayerOptions {\n readonly serviceName: string;\n readonly level: LogLevel;\n /** Where log records go (a real sink in prod, an in-memory double in tests). */\n readonly sink: LogSink;\n /**\n * How spans are exported (batch→OTLP in prod, in-memory in tests).\n * Omitted when there is no OTLP endpoint to export to.\n */\n readonly spanProcessor?: SpanProcessor | undefined;\n /**\n * How metrics are exported (periodic→OTLP in prod, in-memory in tests).\n *\n * Injectable for the same reason `spanProcessor` is: a test drives the exact\n * wiring that ships, so — unlike a double that reimplements the composition —\n * it can see `Metrics.layer` failing to register a producer at all.\n */\n readonly metricReader?: MetricReader | undefined;\n}\n\n/**\n * The observability composition over explicit sink + span processor: replace\n * Effect's default logger with the tooling-backed one, and stand up the OTel\n * tracer so requests produce spans. Keeping sink/processor injectable lets a\n * test drive the exact same wiring with in-memory exporters (see\n * {@link makeInMemoryObservabilityLayer}), so the correlation the e2e asserts is\n * the correlation that ships.\n */\nexport const makeObservabilityLayerWith = (\n options: ObservabilityLayerOptions,\n) => {\n ensureContextManager();\n const toolingLogger = createLogger({\n service: options.serviceName,\n level: options.level,\n sink: options.sink,\n });\n // Replace Effect's default logger with the tooling logger. The service boot\n // (`makeService`) launches under `runMain` with `disablePrettyLogger: true`,\n // and the e2e boots under `Effect.runFork`; both leave `defaultLogger` in\n // place, so this single replace takes effect in either runtime.\n const effectLogger = makeEffectLogger(toolingLogger);\n const LoggingLayer = Logger.replace(Logger.defaultLogger, effectLogger);\n // One `NodeSdk.layer` for both signals: it builds the OTel `Resource` once,\n // so the spans and the metrics carry the same `service.name` rather than two\n // resources that can disagree. `NodeSdk` degrades an absent `spanProcessor`\n // or `metricReader` to `Layer.empty` on its own (its `isNonEmpty` guard), so\n // the no-endpoint case needs no branch here — it simply exports nothing.\n //\n // The metric half needs no producer of its own: `NodeSdk` wires the reader\n // through `Metrics.layer`, whose `MetricProducer` reads **Effect's own metric\n // registry**. So `Metric.counter(…)` at any call site is exported, and a\n // domain counter stays one line where it is incremented.\n const TelemetryLayer = NodeSdk.layer(() => ({\n resource: { serviceName: options.serviceName },\n spanProcessor: options.spanProcessor,\n metricReader: options.metricReader,\n }));\n return Layer.merge(LoggingLayer, TelemetryLayer);\n};\n\n/**\n * The production/dev observability layer: log level + sink and OTLP endpoint\n * come from config-service. Merged into the service `AppLayer`.\n */\nexport const makeObservabilityLayer = (config: ObservabilityConfig) => {\n const endpoint = resolveEndpoint(config.otelEndpoint);\n return makeObservabilityLayerWith({\n serviceName: config.serviceName,\n level: config.level,\n sink: makeSink(config, endpoint),\n spanProcessor:\n endpoint === undefined\n ? undefined\n : new BatchSpanProcessor(\n new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }),\n ),\n metricReader:\n endpoint === undefined\n ? undefined\n : new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({\n url: `${endpoint}/v1/metrics`,\n }),\n exportIntervalMillis:\n config.metricIntervalMs ?? DEFAULT_METRIC_INTERVAL_MS,\n }),\n });\n};\n\n/** Captured telemetry from {@link makeInMemoryObservabilityLayer}. */\nexport interface InMemoryObservability {\n readonly layer: ReturnType<typeof makeObservabilityLayerWith>;\n /** Records the (replaced) logger emitted — the e2e asserts on these. */\n readonly logRecords: readonly LogRecord[];\n /** The spans exported so far (used to confirm requests produced traces). */\n readonly getSpans: () => ReadonlyArray<ReadableSpan>;\n /**\n * Forces a metric collection and returns what the exporter then holds.\n *\n * A push reader only exports on its interval, so a test that merely read the\n * exporter would find it empty and could not tell \"the producer is not\n * registered\" from \"the interval has not elapsed\".\n */\n readonly collectMetrics: () => Promise<ReadonlyArray<ResourceMetrics>>;\n}\n\n/**\n * Test-support: the real observability wiring with in-memory exporters, so an\n * e2e can boot the service and assert that logs are structured and carry the\n * `trace_id` of the request span that produced them.\n */\nexport const makeInMemoryObservabilityLayer = (\n serviceName: string,\n): InMemoryObservability => {\n const logRecords: LogRecord[] = [];\n const spanExporter = new InMemorySpanExporter();\n const metricExporter = new InMemoryMetricExporter(\n AggregationTemporality.CUMULATIVE,\n );\n // A long interval, because the test drives collection explicitly through\n // `collectMetrics`. A short one would export on a timer as well and make the\n // assertions depend on how long the surrounding effect happened to take.\n const metricReader = new PeriodicExportingMetricReader({\n exporter: metricExporter,\n exportIntervalMillis: ONE_HOUR_MS,\n });\n const layer = makeObservabilityLayerWith({\n serviceName,\n level: 'debug',\n sink: { emit: record => logRecords.push(record) },\n spanProcessor: new SimpleSpanProcessor(spanExporter),\n metricReader,\n });\n return {\n layer,\n logRecords,\n getSpans: () => spanExporter.getFinishedSpans(),\n collectMetrics: async () => {\n await metricReader.forceFlush();\n return metricExporter.getMetrics();\n },\n };\n};\n\n/**\n * The observability layer for a service, read from its own configuration.\n *\n * This is the whole per-service wiring: a composition root yields it and merges\n * the result into its `AppLayer`. It exists because the four reads below were\n * byte-identical in every service that had them, sitting directly above a\n * `makeObservabilityLayer` call that mapped them one-for-one — the same\n * duplication the module itself carried, one level up.\n *\n * `logging.level` and `logging.sink` are **required**: the rows are seeded for\n * every service and reach an existing table on config-service's next boot (the\n * seed reconciles per row and only declines to overwrite a value that is already\n * there), so a missing one is a real misconfiguration and should be loud.\n *\n * `otel.endpoint` and `otel.metricIntervalMs` are **optional**, for two\n * different reasons. The endpoint is what makes telemetry a *degradable*\n * dependency — with none, the service boots and serves, logs to stdout and\n * exports nothing, rather than an unreachable telemetry destination taking the\n * service down. The interval is a dial with a sane default, and requiring it\n * would fail the boot on any fleet whose seed predates it.\n *\n * Takes the {@link ConfigurationClient} port rather than a concrete client, so\n * config-service can pass the store it builds from its **own** SQL rows: it is\n * the one service that cannot fetch its configuration over HTTP, because it is\n * the thing being fetched from.\n */\nexport const observabilityFromConfiguration = (\n store: ConfigurationClient,\n serviceName: string,\n): Effect.Effect<\n ReturnType<typeof makeObservabilityLayer>,\n EntifixBuildError\n> =>\n Effect.gen(function* () {\n const level = yield* store.in('logging').getString('level');\n const sink = yield* store.in('logging').getString('sink');\n const otelEndpoint = yield* store.in('otel').getOptionalString('endpoint');\n const metricIntervalMs = yield* store\n .in('otel')\n .getOptionalNumber('metricIntervalMs');\n\n return makeObservabilityLayer({\n serviceName,\n level: level as LogLevel,\n // Anything but the explicit `stdout` is the OTLP sink, which is what makes\n // a mistyped value degrade to shipping rather than to silence.\n sink: sink === 'stdout' ? 'stdout' : 'otlp',\n otelEndpoint,\n metricIntervalMs,\n });\n });\n"],"names":["NodeSdk","createLogger","makeOtlpHttpLogSink","makeStdoutJsonSink","context","AsyncLocalStorageContextManager","OTLPMetricExporter","OTLPTraceExporter","AggregationTemporality","InMemoryMetricExporter","PeriodicExportingMetricReader","BatchSpanProcessor","InMemorySpanExporter","SimpleSpanProcessor","Effect","HashMap","Layer","Logger","DEFAULT_METRIC_INTERVAL_MS","ONE_HOUR_MS","contextManagerReady","ensureContextManager","setGlobalContextManager","enable","resolveEndpoint","raw","trimmed","trim","undefined","makeSink","config","endpoint","sink","serviceName","flushIntervalMs","onError","error","process","stderr","write","String","toToolingLevel","label","toAttributes","annotations","Object","fromEntries","Array","from","entries","key","value","messageToString","message","isArray","map","part","join","makeEffectLogger","logger","make","logLevel","level","text","attributes","makeObservabilityLayerWith","options","toolingLogger","service","effectLogger","LoggingLayer","replace","defaultLogger","TelemetryLayer","layer","resource","spanProcessor","metricReader","merge","makeObservabilityLayer","otelEndpoint","url","exporter","exportIntervalMillis","metricIntervalMs","makeInMemoryObservabilityLayer","logRecords","spanExporter","metricExporter","CUMULATIVE","emit","record","push","getSpans","getFinishedSpans","collectMetrics","forceFlush","getMetrics","observabilityFromConfiguration","store","gen","in","getString","getOptionalString","getOptionalNumber"],"mappings":"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"}
1
+ {"version":3,"sources":["../src/observability.ts"],"sourcesContent":["import { NodeSdk } from '@effect/opentelemetry';\nimport type { ConfigurationClient, EntifixBuildError } from '@entifix/core';\nimport {\n type Attributes,\n createLogger,\n type Logger as ToolingLogger,\n type LogLevel,\n type LogRecord,\n type LogSink,\n makeOtlpHttpLogSink,\n makeStdoutJsonSink,\n} from '@entifix/tooling/logging';\nimport { context } from '@opentelemetry/api';\nimport { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';\nimport { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';\nimport { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';\nimport {\n AggregationTemporality,\n InMemoryMetricExporter,\n type MetricReader,\n PeriodicExportingMetricReader,\n type ResourceMetrics,\n} from '@opentelemetry/sdk-metrics';\nimport {\n BatchSpanProcessor,\n InMemorySpanExporter,\n type ReadableSpan,\n SimpleSpanProcessor,\n type SpanProcessor,\n} from '@opentelemetry/sdk-trace-base';\nimport { Effect, Layer, Logger, References } from 'effect';\nimport type * as EffectLogLevel from 'effect/LogLevel';\n\n/** Resolved observability settings (from config-service `logging.*`/`otel.*`). */\nexport interface ObservabilityConfig {\n readonly serviceName: string;\n readonly level: LogLevel;\n /** `otlp` in dev (ship straight to otel-lgtm), `stdout` in a cluster (filelog). */\n readonly sink: 'stdout' | 'otlp';\n /**\n * OTLP base URL; `/v1/traces`, `/v1/logs` and `/v1/metrics` are appended.\n *\n * **Optional, and that is the boot guarantee**: with no endpoint the service\n * still starts — logs fall back to the stdout sink and neither exporter is\n * built. It used to be a required `getString`, so a fleet whose\n * `otel.endpoint` row was missing failed to boot at all rather than running\n * without telemetry, which is the wrong trade for a signal that is not on the\n * request path.\n */\n readonly otelEndpoint?: string | undefined;\n /**\n * How often the metric reader exports, in milliseconds. Config rather than a\n * constant because it is a real operational dial (a shorter interval buys\n * resolution with cardinality-independent request volume), and optional\n * because a fleet whose seed predates the key should not fail to boot over an\n * export interval. (Optional for that reason alone: config-service reconciles\n * `SEED_ROWS` per row on every boot, so a *new* key does reach an existing\n * Postgres — `ON CONFLICT DO NOTHING` declines to overwrite an operator's\n * changed *value*, which is the case that needs a `dev:reset`.)\n */\n readonly metricIntervalMs?: number | undefined;\n}\n\n/** Export interval used when `otel.metricIntervalMs` is unset. */\nconst DEFAULT_METRIC_INTERVAL_MS = 60_000;\n\n/** The in-memory reader's interval — long enough that only `forceFlush` collects. */\nconst ONE_HOUR_MS = 3_600_000;\n\n/**\n * `@effect/opentelemetry` manages its own span tree but does not register an\n * OpenTelemetry context manager, so `@opentelemetry/api`'s active-span lookup\n * (which the tooling logger uses to stamp `trace_id`) would stay empty. Register\n * the AsyncLocalStorage manager once so the active span propagates and logs\n * correlate to their traces. Idempotent — `setGlobalContextManager` no-ops if a\n * manager is already registered (e.g. across test boots in one process).\n */\nlet contextManagerReady = false;\nconst ensureContextManager = (): void => {\n if (!contextManagerReady) {\n context.setGlobalContextManager(\n new AsyncLocalStorageContextManager().enable(),\n );\n contextManagerReady = true;\n }\n};\n\n/**\n * The OTLP base URL, or `undefined` when there is nowhere to export.\n *\n * A **blank** endpoint counts as absent, not as a base URL. config-service's\n * operator CRUD writes empty strings, so `''` is reachable in practice — and\n * taken literally it builds exporters aimed at the relative `/v1/traces` and\n * `/v1/metrics`, which resolve against nothing, fail every export, and look\n * from the call site exactly like a service that is simply quiet.\n */\nconst resolveEndpoint = (raw: string | undefined): string | undefined => {\n const trimmed = raw?.trim();\n return trimmed === undefined || trimmed === '' ? undefined : trimmed;\n};\n\nconst makeSink = (\n config: ObservabilityConfig,\n endpoint: string | undefined,\n): LogSink =>\n config.sink === 'otlp' && endpoint !== undefined\n ? makeOtlpHttpLogSink({\n endpoint,\n serviceName: config.serviceName,\n // Flush on a short interval too, so a quiet service's logs reach the\n // Collector promptly instead of waiting for a full batch.\n flushIntervalMs: 2000,\n onError: error =>\n process.stderr.write(\n `[otlp-log-sink] export failed: ${String(error)}\\n`,\n ),\n })\n : makeStdoutJsonSink();\n\n/**\n * Effect's `LogLevel` mapped onto the four levels the tooling logger has.\n *\n * ⚠️ **Match the spelling `effect/LogLevel` declares, and let the compiler\n * hold you to it.** Effect 4's level is the string union `\"All\" | \"Fatal\" |\n * \"Error\" | \"Warn\" | \"Info\" | \"Debug\" | \"Trace\" | \"None\"`. Under Effect 3 this\n * switch read the level's `label`, which was upper case (`\"ERROR\"`), against\n * mixed-case spellings (`'Error'`, `'Warning'`) typed as a plain `string` — so\n * **no case could ever match** and every log in the service fell through to\n * `default` and was emitted at `info`. Measured in Loki: an `Effect.logError`\n * arrived as `severity_text: INFO`, `severity_number: 9`, which makes an error\n * invisible to every level-based alert and dashboard while still appearing in\n * the log — the failure mode that reads as \"we have no errors\". Typing the\n * parameter as Effect's own union is what makes a misspelt case a compile\n * error now: `'Warning'` would not compile.\n */\nconst toToolingLevel = (level: EffectLogLevel.LogLevel): LogLevel => {\n switch (level) {\n case 'Trace':\n case 'Debug':\n return 'debug';\n case 'Warn':\n return 'warn';\n case 'Error':\n case 'Fatal':\n return 'error';\n default:\n return 'info';\n }\n};\n\n/**\n * `Effect.annotateLogs`' entries flattened into tooling `Attributes`.\n *\n * The annotations are a record read off the fiber, and the attribute values are\n * a closed scalar set, so anything else is stringified rather than dropped — an\n * annotation that reaches a sink as `[object Object]` is still more use than one\n * that silently is not there.\n */\nconst toAttributes = (\n annotations: Readonly<Record<string, unknown>>,\n): Attributes =>\n Object.fromEntries(\n Object.entries(annotations).map(([key, value]) => [\n key,\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean' ||\n value === null\n ? value\n : String(value),\n ]),\n );\n\nconst messageToString = (message: unknown): string =>\n Array.isArray(message)\n ? message.map(part => String(part)).join(' ')\n : String(message);\n\n/**\n * An Effect `Logger` that forwards every Effect log (including the request logs\n * `HttpRouter.serve` writes) to the tooling logger — so the whole service logs\n * through one structured, trace-correlated pipeline.\n *\n * **Annotations are forwarded**, and used not to be: they were destructured\n * away, so every `Effect.annotateLogs` in every service was discarded before it\n * reached a sink. That made the annotated fields the outbox relay and the\n * transaction engine attach — the tenant database, the event id, the\n * transaction id, the rollback's own error — unqueryable, while the comments\n * beside them said they were structured fields in Loki. Measured: the records\n * arrived carrying the message and nothing else. Effect 4 no longer hands them\n * to a logger at all; they live on the fiber, as\n * `References.CurrentLogAnnotations`, and are read from there.\n *\n * `error` is called through its own arm because the tooling `Logger` gives it a\n * different signature — `(message, error?, attributes?)` — so passing the\n * attributes positionally as the second argument would file them as the *cause*\n * and lose them again.\n */\nconst makeEffectLogger = (\n logger: ToolingLogger,\n): Logger.Logger<unknown, void> =>\n Logger.make(({ logLevel, message, fiber }) => {\n const level = toToolingLevel(logLevel);\n const text = messageToString(message);\n const attributes = toAttributes(\n fiber.getRef(References.CurrentLogAnnotations),\n );\n\n if (level === 'error') {\n logger.error(text, undefined, attributes);\n return;\n }\n logger[level](text, attributes);\n });\n\n/** Lower-level inputs to {@link makeObservabilityLayerWith}. */\nexport interface ObservabilityLayerOptions {\n readonly serviceName: string;\n readonly level: LogLevel;\n /** Where log records go (a real sink in prod, an in-memory double in tests). */\n readonly sink: LogSink;\n /**\n * How spans are exported (batch→OTLP in prod, in-memory in tests).\n * Omitted when there is no OTLP endpoint to export to.\n */\n readonly spanProcessor?: SpanProcessor | undefined;\n /**\n * How metrics are exported (periodic→OTLP in prod, in-memory in tests).\n *\n * Injectable for the same reason `spanProcessor` is: a test drives the exact\n * wiring that ships, so — unlike a double that reimplements the composition —\n * it can see `Metrics.layer` failing to register a producer at all.\n */\n readonly metricReader?: MetricReader | undefined;\n}\n\n/**\n * The observability composition over explicit sink + span processor: replace\n * Effect's default logger with the tooling-backed one, and stand up the OTel\n * tracer so requests produce spans. Keeping sink/processor injectable lets a\n * test drive the exact same wiring with in-memory exporters (see\n * {@link makeInMemoryObservabilityLayer}), so the correlation the e2e asserts is\n * the correlation that ships.\n */\nexport const makeObservabilityLayerWith = (\n options: ObservabilityLayerOptions,\n) => {\n ensureContextManager();\n const toolingLogger = createLogger({\n service: options.serviceName,\n level: options.level,\n sink: options.sink,\n });\n // Replace Effect's loggers with the tooling logger. `Logger.layer` sets the\n // fiber's current loggers outright rather than adding to them, and neither\n // the service boot (`runMain`) nor the e2e boot (`Effect.runFork`) installs a\n // logger of its own, so this single layer takes effect in either runtime.\n const effectLogger = makeEffectLogger(toolingLogger);\n const LoggingLayer = Logger.layer([effectLogger]);\n // One `NodeSdk.layer` for both signals: it builds the OTel `Resource` once,\n // so the spans and the metrics carry the same `service.name` rather than two\n // resources that can disagree. `NodeSdk` degrades an absent `spanProcessor`\n // or `metricReader` to `Layer.empty` on its own (its `isNonEmpty` guard), so\n // the no-endpoint case needs no branch here — it simply exports nothing.\n //\n // The metric half needs no producer of its own: `NodeSdk` wires the reader\n // through `Metrics.layer`, whose `MetricProducer` reads **Effect's own metric\n // registry**. So `Metric.counter(…)` at any call site is exported, and a\n // domain counter stays one line where it is incremented.\n const TelemetryLayer = NodeSdk.layer(() => ({\n resource: { serviceName: options.serviceName },\n spanProcessor: options.spanProcessor,\n metricReader: options.metricReader,\n }));\n return Layer.merge(LoggingLayer, TelemetryLayer);\n};\n\n/**\n * The production/dev observability layer: log level + sink and OTLP endpoint\n * come from config-service. Merged into the service `AppLayer`.\n */\nexport const makeObservabilityLayer = (config: ObservabilityConfig) => {\n const endpoint = resolveEndpoint(config.otelEndpoint);\n return makeObservabilityLayerWith({\n serviceName: config.serviceName,\n level: config.level,\n sink: makeSink(config, endpoint),\n spanProcessor:\n endpoint === undefined\n ? undefined\n : new BatchSpanProcessor(\n new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }),\n ),\n metricReader:\n endpoint === undefined\n ? undefined\n : new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({\n url: `${endpoint}/v1/metrics`,\n }),\n exportIntervalMillis:\n config.metricIntervalMs ?? DEFAULT_METRIC_INTERVAL_MS,\n }),\n });\n};\n\n/** Captured telemetry from {@link makeInMemoryObservabilityLayer}. */\nexport interface InMemoryObservability {\n readonly layer: ReturnType<typeof makeObservabilityLayerWith>;\n /** Records the (replaced) logger emitted — the e2e asserts on these. */\n readonly logRecords: readonly LogRecord[];\n /** The spans exported so far (used to confirm requests produced traces). */\n readonly getSpans: () => ReadonlyArray<ReadableSpan>;\n /**\n * Forces a metric collection and returns what the exporter then holds.\n *\n * A push reader only exports on its interval, so a test that merely read the\n * exporter would find it empty and could not tell \"the producer is not\n * registered\" from \"the interval has not elapsed\".\n */\n readonly collectMetrics: () => Promise<ReadonlyArray<ResourceMetrics>>;\n}\n\n/**\n * Test-support: the real observability wiring with in-memory exporters, so an\n * e2e can boot the service and assert that logs are structured and carry the\n * `trace_id` of the request span that produced them.\n */\nexport const makeInMemoryObservabilityLayer = (\n serviceName: string,\n): InMemoryObservability => {\n const logRecords: LogRecord[] = [];\n const spanExporter = new InMemorySpanExporter();\n const metricExporter = new InMemoryMetricExporter(\n AggregationTemporality.CUMULATIVE,\n );\n // A long interval, because the test drives collection explicitly through\n // `collectMetrics`. A short one would export on a timer as well and make the\n // assertions depend on how long the surrounding effect happened to take.\n const metricReader = new PeriodicExportingMetricReader({\n exporter: metricExporter,\n exportIntervalMillis: ONE_HOUR_MS,\n });\n const layer = makeObservabilityLayerWith({\n serviceName,\n level: 'debug',\n sink: { emit: record => logRecords.push(record) },\n spanProcessor: new SimpleSpanProcessor(spanExporter),\n metricReader,\n });\n return {\n layer,\n logRecords,\n getSpans: () => spanExporter.getFinishedSpans(),\n collectMetrics: async () => {\n await metricReader.forceFlush();\n return metricExporter.getMetrics();\n },\n };\n};\n\n/**\n * The observability layer for a service, read from its own configuration.\n *\n * This is the whole per-service wiring: a composition root yields it and merges\n * the result into its `AppLayer`. It exists because the four reads below were\n * byte-identical in every service that had them, sitting directly above a\n * `makeObservabilityLayer` call that mapped them one-for-one — the same\n * duplication the module itself carried, one level up.\n *\n * `logging.level` and `logging.sink` are **required**: the rows are seeded for\n * every service and reach an existing table on config-service's next boot (the\n * seed reconciles per row and only declines to overwrite a value that is already\n * there), so a missing one is a real misconfiguration and should be loud.\n *\n * `otel.endpoint` and `otel.metricIntervalMs` are **optional**, for two\n * different reasons. The endpoint is what makes telemetry a *degradable*\n * dependency — with none, the service boots and serves, logs to stdout and\n * exports nothing, rather than an unreachable telemetry destination taking the\n * service down. The interval is a dial with a sane default, and requiring it\n * would fail the boot on any fleet whose seed predates it.\n *\n * Takes the {@link ConfigurationClient} port rather than a concrete client, so\n * config-service can pass the store it builds from its **own** SQL rows: it is\n * the one service that cannot fetch its configuration over HTTP, because it is\n * the thing being fetched from.\n */\nexport const observabilityFromConfiguration = (\n store: ConfigurationClient,\n serviceName: string,\n): Effect.Effect<\n ReturnType<typeof makeObservabilityLayer>,\n EntifixBuildError\n> =>\n Effect.gen(function* () {\n const level = yield* store.in('logging').getString('level');\n const sink = yield* store.in('logging').getString('sink');\n const otelEndpoint = yield* store.in('otel').getOptionalString('endpoint');\n const metricIntervalMs = yield* store\n .in('otel')\n .getOptionalNumber('metricIntervalMs');\n\n return makeObservabilityLayer({\n serviceName,\n level: level as LogLevel,\n // Anything but the explicit `stdout` is the OTLP sink, which is what makes\n // a mistyped value degrade to shipping rather than to silence.\n sink: sink === 'stdout' ? 'stdout' : 'otlp',\n otelEndpoint,\n metricIntervalMs,\n });\n });\n"],"names":["NodeSdk","createLogger","makeOtlpHttpLogSink","makeStdoutJsonSink","context","AsyncLocalStorageContextManager","OTLPMetricExporter","OTLPTraceExporter","AggregationTemporality","InMemoryMetricExporter","PeriodicExportingMetricReader","BatchSpanProcessor","InMemorySpanExporter","SimpleSpanProcessor","Effect","Layer","Logger","References","DEFAULT_METRIC_INTERVAL_MS","ONE_HOUR_MS","contextManagerReady","ensureContextManager","setGlobalContextManager","enable","resolveEndpoint","raw","trimmed","trim","undefined","makeSink","config","endpoint","sink","serviceName","flushIntervalMs","onError","error","process","stderr","write","String","toToolingLevel","level","toAttributes","annotations","Object","fromEntries","entries","map","key","value","messageToString","message","Array","isArray","part","join","makeEffectLogger","logger","make","logLevel","fiber","text","attributes","getRef","CurrentLogAnnotations","makeObservabilityLayerWith","options","toolingLogger","service","effectLogger","LoggingLayer","layer","TelemetryLayer","resource","spanProcessor","metricReader","merge","makeObservabilityLayer","otelEndpoint","url","exporter","exportIntervalMillis","metricIntervalMs","makeInMemoryObservabilityLayer","logRecords","spanExporter","metricExporter","CUMULATIVE","emit","record","push","getSpans","getFinishedSpans","collectMetrics","forceFlush","getMetrics","observabilityFromConfiguration","store","gen","in","getString","getOptionalString","getOptionalNumber"],"mappings":"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"}
@@ -33,5 +33,5 @@ export declare const freePort: () => Promise<number>;
33
33
  * timeout: the launch fiber is raced against the health check, so a missing
34
34
  * dependency reports its own cause.
35
35
  */
36
- export declare const serveTestService: <E, R>(def: ServiceDefinition<E, R>, { port, ...waitOptions }?: ServeTestServiceOptions) => Promise<RunningTestService>;
36
+ export declare const serveTestService: <RRoutes>(def: ServiceDefinition<RRoutes>, { port, ...waitOptions }?: ServeTestServiceOptions) => Promise<RunningTestService>;
37
37
  //# sourceMappingURL=serve-test-service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"serve-test-service.d.ts","sourceRoot":"","sources":["../src/serve-test-service.ts"],"names":[],"mappings":"AAKA,OAAO,EAAmB,KAAK,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAC5E,OAAO,EAAe,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAE1E,sEAAsE;AACtE,MAAM,WAAW,kBAAkB;IACjC,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,WAAW,uBAAwB,SAAQ,kBAAkB;IACjE,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;GAMG;AACH,eAAO,MAAM,QAAQ,QAAO,OAAO,CAAC,MAAM,CAUtC,CAAC;AAEL;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAAU,CAAC,EAAE,CAAC,EACzC,KAAK,iBAAiB,CAAC,CAAC,EAAE,CAAC,CAAC,EAC5B,2BAA0B,uBAA4B,KACrD,OAAO,CAAC,kBAAkB,CA6B5B,CAAC"}
1
+ {"version":3,"file":"serve-test-service.d.ts","sourceRoot":"","sources":["../src/serve-test-service.ts"],"names":[],"mappings":"AAKA,OAAO,EAAmB,KAAK,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAC5E,OAAO,EAAe,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAE1E,sEAAsE;AACtE,MAAM,WAAW,kBAAkB;IACjC,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,WAAW,uBAAwB,SAAQ,kBAAkB;IACjE,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;GAMG;AACH,eAAO,MAAM,QAAQ,QAAO,OAAO,CAAC,MAAM,CAUtC,CAAC;AAEL;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAAU,OAAO,EAC5C,KAAK,iBAAiB,CAAC,OAAO,CAAC,EAC/B,2BAA0B,uBAA4B,KACrD,OAAO,CAAC,kBAAkB,CA6B5B,CAAC"}