@entifix/business 0.5.0 → 0.6.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.
@@ -25,7 +25,7 @@ export interface HealthProbe {
25
25
  * (`catalog`, `saga`), an exchange (`entifix.events`), a service (`zitadel`).
26
26
  * Never a URI, never a database name: `catalog` is physically
27
27
  * `tenant_<organizationId>`, and a document naming those is an organization
28
- * enumerator (ADR 0031).
28
+ * enumerator.
29
29
  *
30
30
  * It is a list rather than one string because a single connection can back
31
31
  * several Stores: marketplace-admin-service opens one Mongo client for the
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/health/health-registry.ts"],"sourcesContent":["import { Context, Effect, Layer, Ref } from 'effect';\n\n/**\n * What a probe is talking to. Read by `GET /api/$service` to sort the wiring\n * document into stores, brokers and upstreams; readiness ignores it entirely.\n */\nexport type ProbeKind = 'datastore' | 'broker' | 'upstream';\n\n/**\n * One named readiness fact — \"can this service still reach Mongo?\".\n *\n * `check` is fully applied: whoever registers the probe has already closed over\n * the client handle, so the readiness route can run every probe without knowing\n * what any of them talk to. It must not fail — a probe that cannot answer\n * answers `false`; an unhandled defect in a probe would take down the very\n * endpoint that is supposed to report trouble.\n */\nexport interface HealthProbe {\n readonly name: string;\n /**\n * What sort of thing is on the other end. `datastore` is a Store's backing\n * engine, `broker` an exchange, `upstream` another service.\n */\n readonly kind: ProbeKind;\n /**\n * The **logical** names this probe covers — a Store's register name\n * (`catalog`, `saga`), an exchange (`entifix.events`), a service (`zitadel`).\n * Never a URI, never a database name: `catalog` is physically\n * `tenant_<organizationId>`, and a document naming those is an organization\n * enumerator (ADR 0031).\n *\n * It is a list rather than one string because a single connection can back\n * several Stores: marketplace-admin-service opens one Mongo client for the\n * `catalog` store and the co-deployed `transaction` slice's `saga` store.\n * Registering one probe per Store instead would put both names into the\n * readiness response's `failing` array, which must not change.\n *\n * The client package that opens the connection cannot know a Store's register\n * name, so the composition root passes it in — the one thing about a probe\n * that is not derivable from the client.\n */\n readonly targets: readonly string[];\n readonly check: Effect.Effect<boolean>;\n}\n\n/** The outcome of running every registered probe. */\nexport interface HealthReport {\n readonly ready: boolean;\n /** Names of the probes that answered `false`, in registration order. */\n readonly failing: readonly string[];\n}\n\n/**\n * How long a single probe may take before it counts as failing.\n *\n * This is not belt-and-braces. A driver that queues commands while its\n * connection is down (ioredis with `maxRetriesPerRequest`, for one) leaves the\n * probe pending instead of rejecting, so without a deadline `/api/health/ready`\n * *hangs* when a datastore disappears — the one moment it has to answer. A\n * probe that cannot answer in time is not ready, by definition.\n */\nconst PROBE_TIMEOUT = '2 seconds';\n\nexport interface HealthRegistry {\n /** Add a probe. Called by an infrastructure layer as it constructs a client. */\n readonly register: (probe: HealthProbe) => Effect.Effect<void>;\n /** Run every probe and summarise. Never fails. */\n readonly report: Effect.Effect<HealthReport>;\n /**\n * The registrations themselves, in registration order — what each probe is\n * for, without running any of them.\n *\n * This is what makes the wiring document and readiness generate from **one**\n * registration rather than from two lists that drift. Deliberately separate\n * from {@link report}: describing a service must never cost a round trip to\n * every datastore it has.\n */\n readonly probes: Effect.Effect<readonly HealthProbe[]>;\n}\n\n/**\n * DI tag for the readiness registry.\n *\n * The point of a registry rather than a hand-written list per service: a\n * `-service` that gains a datastore gains its readiness probe by merging that\n * client's probe layer, and nothing in the service's own code has to remember\n * to describe it. A list in `main.ts` drifts the first time someone adds a\n * dependency; this cannot, because the probe ships with the client.\n *\n * It lives here, in the contract layer, so `entifix-ts-*-client` packages can\n * register into it without depending on a shell.\n */\nexport class HealthRegistryTag extends Context.Service<\n HealthRegistryTag,\n HealthRegistry\n>()('HealthRegistryTag') {}\n\n/**\n * Live registry over a `Ref`. Provide it **once** per service and share the\n * instance (`Layer.provideMerge`), or the probes register into a registry the\n * readiness route never reads.\n */\nexport const HealthRegistryLayer: Layer.Layer<HealthRegistryTag> = Layer.effect(\n HealthRegistryTag,\n Effect.gen(function* () {\n const probes = yield* Ref.make<readonly HealthProbe[]>([]);\n\n return {\n register: probe => Ref.update(probes, current => [...current, probe]),\n\n probes: Ref.get(probes),\n\n report: Effect.gen(function* () {\n const current = yield* Ref.get(probes);\n const results = yield* Effect.forEach(\n current,\n probe =>\n probe.check.pipe(\n // A probe that throws is a failing probe, not a broken endpoint.\n Effect.catchDefect(() => Effect.succeed(false)),\n Effect.timeoutOrElse({\n duration: PROBE_TIMEOUT,\n orElse: () => Effect.succeed(false),\n }),\n Effect.map(ok => ({ name: probe.name, ok })),\n ),\n { concurrency: 'unbounded' },\n );\n const failing = results.filter(r => !r.ok).map(r => r.name);\n return { ready: failing.length === 0, failing };\n }),\n };\n }),\n);\n"],"names":["Context","Effect","Layer","Ref","PROBE_TIMEOUT","HealthRegistryTag","Service","HealthRegistryLayer","effect","gen","probes","make","register","probe","update","current","get","report","results","forEach","check","pipe","catchDefect","succeed","timeoutOrElse","duration","orElse","map","ok","name","concurrency","failing","filter","r","ready","length"],"mappings":"AAAA,SAASA,OAAO,EAAEC,MAAM,EAAEC,KAAK,EAAEC,GAAG,QAAQ,SAAS;AAoDrD;;;;;;;;CAQC,GACD,MAAMC,gBAAgB;AAmBtB;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,0BAA0BL,QAAQM,OAAO,GAGlD;AAAsB;AAE1B;;;;CAIC,GACD,OAAO,MAAMC,sBAAsDL,MAAMM,MAAM,CAC7EH,mBACAJ,OAAOQ,GAAG,CAAC;IACT,MAAMC,SAAS,OAAOP,IAAIQ,IAAI,CAAyB,EAAE;IAEzD,OAAO;QACLC,UAAUC,CAAAA,QAASV,IAAIW,MAAM,CAACJ,QAAQK,CAAAA,UAAW;uBAAIA;oBAASF;iBAAM;QAEpEH,QAAQP,IAAIa,GAAG,CAACN;QAEhBO,QAAQhB,OAAOQ,GAAG,CAAC;YACjB,MAAMM,UAAU,OAAOZ,IAAIa,GAAG,CAACN;YAC/B,MAAMQ,UAAU,OAAOjB,OAAOkB,OAAO,CACnCJ,SACAF,CAAAA,QACEA,MAAMO,KAAK,CAACC,IAAI,CACd,iEAAiE;gBACjEpB,OAAOqB,WAAW,CAAC,IAAMrB,OAAOsB,OAAO,CAAC,SACxCtB,OAAOuB,aAAa,CAAC;oBACnBC,UAAUrB;oBACVsB,QAAQ,IAAMzB,OAAOsB,OAAO,CAAC;gBAC/B,IACAtB,OAAO0B,GAAG,CAACC,CAAAA,KAAO,CAAA;wBAAEC,MAAMhB,MAAMgB,IAAI;wBAAED;oBAAG,CAAA,KAE7C;gBAAEE,aAAa;YAAY;YAE7B,MAAMC,UAAUb,QAAQc,MAAM,CAACC,CAAAA,IAAK,CAACA,EAAEL,EAAE,EAAED,GAAG,CAACM,CAAAA,IAAKA,EAAEJ,IAAI;YAC1D,OAAO;gBAAEK,OAAOH,QAAQI,MAAM,KAAK;gBAAGJ;YAAQ;QAChD;IACF;AACF,IACA"}
1
+ {"version":3,"sources":["../../src/health/health-registry.ts"],"sourcesContent":["import { Context, Effect, Layer, Ref } from 'effect';\n\n/**\n * What a probe is talking to. Read by `GET /api/$service` to sort the wiring\n * document into stores, brokers and upstreams; readiness ignores it entirely.\n */\nexport type ProbeKind = 'datastore' | 'broker' | 'upstream';\n\n/**\n * One named readiness fact — \"can this service still reach Mongo?\".\n *\n * `check` is fully applied: whoever registers the probe has already closed over\n * the client handle, so the readiness route can run every probe without knowing\n * what any of them talk to. It must not fail — a probe that cannot answer\n * answers `false`; an unhandled defect in a probe would take down the very\n * endpoint that is supposed to report trouble.\n */\nexport interface HealthProbe {\n readonly name: string;\n /**\n * What sort of thing is on the other end. `datastore` is a Store's backing\n * engine, `broker` an exchange, `upstream` another service.\n */\n readonly kind: ProbeKind;\n /**\n * The **logical** names this probe covers — a Store's register name\n * (`catalog`, `saga`), an exchange (`entifix.events`), a service (`zitadel`).\n * Never a URI, never a database name: `catalog` is physically\n * `tenant_<organizationId>`, and a document naming those is an organization\n * enumerator.\n *\n * It is a list rather than one string because a single connection can back\n * several Stores: marketplace-admin-service opens one Mongo client for the\n * `catalog` store and the co-deployed `transaction` slice's `saga` store.\n * Registering one probe per Store instead would put both names into the\n * readiness response's `failing` array, which must not change.\n *\n * The client package that opens the connection cannot know a Store's register\n * name, so the composition root passes it in — the one thing about a probe\n * that is not derivable from the client.\n */\n readonly targets: readonly string[];\n readonly check: Effect.Effect<boolean>;\n}\n\n/** The outcome of running every registered probe. */\nexport interface HealthReport {\n readonly ready: boolean;\n /** Names of the probes that answered `false`, in registration order. */\n readonly failing: readonly string[];\n}\n\n/**\n * How long a single probe may take before it counts as failing.\n *\n * This is not belt-and-braces. A driver that queues commands while its\n * connection is down (ioredis with `maxRetriesPerRequest`, for one) leaves the\n * probe pending instead of rejecting, so without a deadline `/api/health/ready`\n * *hangs* when a datastore disappears — the one moment it has to answer. A\n * probe that cannot answer in time is not ready, by definition.\n */\nconst PROBE_TIMEOUT = '2 seconds';\n\nexport interface HealthRegistry {\n /** Add a probe. Called by an infrastructure layer as it constructs a client. */\n readonly register: (probe: HealthProbe) => Effect.Effect<void>;\n /** Run every probe and summarise. Never fails. */\n readonly report: Effect.Effect<HealthReport>;\n /**\n * The registrations themselves, in registration order — what each probe is\n * for, without running any of them.\n *\n * This is what makes the wiring document and readiness generate from **one**\n * registration rather than from two lists that drift. Deliberately separate\n * from {@link report}: describing a service must never cost a round trip to\n * every datastore it has.\n */\n readonly probes: Effect.Effect<readonly HealthProbe[]>;\n}\n\n/**\n * DI tag for the readiness registry.\n *\n * The point of a registry rather than a hand-written list per service: a\n * `-service` that gains a datastore gains its readiness probe by merging that\n * client's probe layer, and nothing in the service's own code has to remember\n * to describe it. A list in `main.ts` drifts the first time someone adds a\n * dependency; this cannot, because the probe ships with the client.\n *\n * It lives here, in the contract layer, so `entifix-ts-*-client` packages can\n * register into it without depending on a shell.\n */\nexport class HealthRegistryTag extends Context.Service<\n HealthRegistryTag,\n HealthRegistry\n>()('HealthRegistryTag') {}\n\n/**\n * Live registry over a `Ref`. Provide it **once** per service and share the\n * instance (`Layer.provideMerge`), or the probes register into a registry the\n * readiness route never reads.\n */\nexport const HealthRegistryLayer: Layer.Layer<HealthRegistryTag> = Layer.effect(\n HealthRegistryTag,\n Effect.gen(function* () {\n const probes = yield* Ref.make<readonly HealthProbe[]>([]);\n\n return {\n register: probe => Ref.update(probes, current => [...current, probe]),\n\n probes: Ref.get(probes),\n\n report: Effect.gen(function* () {\n const current = yield* Ref.get(probes);\n const results = yield* Effect.forEach(\n current,\n probe =>\n probe.check.pipe(\n // A probe that throws is a failing probe, not a broken endpoint.\n Effect.catchDefect(() => Effect.succeed(false)),\n Effect.timeoutOrElse({\n duration: PROBE_TIMEOUT,\n orElse: () => Effect.succeed(false),\n }),\n Effect.map(ok => ({ name: probe.name, ok })),\n ),\n { concurrency: 'unbounded' },\n );\n const failing = results.filter(r => !r.ok).map(r => r.name);\n return { ready: failing.length === 0, failing };\n }),\n };\n }),\n);\n"],"names":["Context","Effect","Layer","Ref","PROBE_TIMEOUT","HealthRegistryTag","Service","HealthRegistryLayer","effect","gen","probes","make","register","probe","update","current","get","report","results","forEach","check","pipe","catchDefect","succeed","timeoutOrElse","duration","orElse","map","ok","name","concurrency","failing","filter","r","ready","length"],"mappings":"AAAA,SAASA,OAAO,EAAEC,MAAM,EAAEC,KAAK,EAAEC,GAAG,QAAQ,SAAS;AAoDrD;;;;;;;;CAQC,GACD,MAAMC,gBAAgB;AAmBtB;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,0BAA0BL,QAAQM,OAAO,GAGlD;AAAsB;AAE1B;;;;CAIC,GACD,OAAO,MAAMC,sBAAsDL,MAAMM,MAAM,CAC7EH,mBACAJ,OAAOQ,GAAG,CAAC;IACT,MAAMC,SAAS,OAAOP,IAAIQ,IAAI,CAAyB,EAAE;IAEzD,OAAO;QACLC,UAAUC,CAAAA,QAASV,IAAIW,MAAM,CAACJ,QAAQK,CAAAA,UAAW;uBAAIA;oBAASF;iBAAM;QAEpEH,QAAQP,IAAIa,GAAG,CAACN;QAEhBO,QAAQhB,OAAOQ,GAAG,CAAC;YACjB,MAAMM,UAAU,OAAOZ,IAAIa,GAAG,CAACN;YAC/B,MAAMQ,UAAU,OAAOjB,OAAOkB,OAAO,CACnCJ,SACAF,CAAAA,QACEA,MAAMO,KAAK,CAACC,IAAI,CACd,iEAAiE;gBACjEpB,OAAOqB,WAAW,CAAC,IAAMrB,OAAOsB,OAAO,CAAC,SACxCtB,OAAOuB,aAAa,CAAC;oBACnBC,UAAUrB;oBACVsB,QAAQ,IAAMzB,OAAOsB,OAAO,CAAC;gBAC/B,IACAtB,OAAO0B,GAAG,CAACC,CAAAA,KAAO,CAAA;wBAAEC,MAAMhB,MAAMgB,IAAI;wBAAED;oBAAG,CAAA,KAE7C;gBAAEE,aAAa;YAAY;YAE7B,MAAMC,UAAUb,QAAQc,MAAM,CAACC,CAAAA,IAAK,CAACA,EAAEL,EAAE,EAAED,GAAG,CAACM,CAAAA,IAAKA,EAAEJ,IAAI;YAC1D,OAAO;gBAAEK,OAAOH,QAAQI,MAAM,KAAK;gBAAGJ;YAAQ;QAChD;IACF;AACF,IACA"}
@@ -1,6 +1,6 @@
1
1
  import { Context, Effect } from 'effect';
2
2
  /**
3
- * The order a shutdown runs in (ADR 0030).
3
+ * The order a shutdown runs in.
4
4
  *
5
5
  * **`stop-intake`** stops the process taking new work — cancelling the bus's
6
6
  * consumers, interrupting a polling daemon. **`flush`** carries out what is
@@ -1,6 +1,6 @@
1
1
  import { Cause, Context, Effect } from 'effect';
2
2
  /**
3
- * The order a shutdown runs in (ADR 0030).
3
+ * The order a shutdown runs in.
4
4
  *
5
5
  * **`stop-intake`** stops the process taking new work — cancelling the bus's
6
6
  * consumers, interrupting a polling daemon. **`flush`** carries out what is
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shutdown/shutdown-registry.ts"],"sourcesContent":["import { Cause, Context, Effect } from 'effect';\n\n/**\n * The order a shutdown runs in (ADR 0030).\n *\n * **`stop-intake`** stops the process taking new work — cancelling the bus's\n * consumers, interrupting a polling daemon. **`flush`** carries out what is\n * already committed but not yet delivered: the outbox relay's final pass. The\n * two are separate phases rather than one list because they are ordered against\n * each other and not merely against the connections closing — flushing while\n * consumers are still folding events would race a drain against fresh work, and\n * the relay would go on finding entries the handlers keep writing.\n */\nexport const SHUTDOWN_PHASES = ['stop-intake', 'flush'] as const;\n\nexport type ShutdownPhase = (typeof SHUTDOWN_PHASES)[number];\n\n/**\n * One thing to do before this process's connections close.\n *\n * `run` is fully applied, exactly like `HealthProbe.check`: whoever\n * registers the hook has already closed over the connector, the fiber or the\n * store, so the drain can run every hook without knowing what any of them\n * touches.\n */\nexport interface ShutdownHook {\n /** Logged when the hook runs, and when it fails. Never rendered to a caller. */\n readonly name: string;\n readonly phase: ShutdownPhase;\n readonly run: Effect.Effect<void>;\n}\n\n/**\n * How long one hook may take before the drain moves on without it.\n *\n * Not belt-and-braces. Kubernetes sends SIGKILL at the end of\n * `terminationGracePeriodSeconds` whatever the process is doing, so an\n * unbounded hook does not buy time — it spends the budget of the hooks behind\n * it and then loses them entirely. A hook that cannot finish in time has\n * already failed; the difference is whether the *next* one gets to run.\n */\nconst HOOK_TIMEOUT = '10 seconds';\n\n/**\n * What a service does on its way out, registered by whoever opened the thing\n * that needs draining.\n *\n * A sibling of `HealthRegistry` and `WiringRegistry`, and for the same reason:\n * a `-service` that gains a bus gains its consumer drain by merging that\n * adapter's layer, and nothing in the service's own `main.ts` has to remember\n * to list it. A hand-written sequence per composition root drifts the first\n * time someone adds a dependency; this cannot, because the hook ships with the\n * client that needs it.\n */\nexport interface ShutdownRegistry {\n /** Add a hook. Called by an adapter layer as it constructs its client. */\n readonly register: (hook: ShutdownHook) => Effect.Effect<void>;\n /**\n * Whether this process has begun terminating.\n *\n * Read by `GET /api/health/ready`, which answers `503` from here **before**\n * it consults any probe: nothing is failing, the process is leaving.\n */\n readonly terminating: Effect.Effect<boolean>;\n /**\n * Run every hook, phase by phase, in registration order. Never fails.\n *\n * Called from a `Layer` finalizer, which is what orders it: the drain layer\n * is built after the composition root, so it is released before it, and the\n * connections a hook needs are still open while it runs.\n */\n readonly drain: Effect.Effect<void>;\n}\n\n/**\n * DI tag for the shutdown registry.\n *\n * In the contract layer beside `HealthRegistryTag` so an\n * `entifix-ts-*-client` package can register a hook without depending on a\n * shell.\n */\nexport class ShutdownRegistryTag extends Context.Service<\n ShutdownRegistryTag,\n ShutdownRegistry\n>()('ShutdownRegistryTag') {}\n\n/**\n * Runs one hook and swallows whatever it does.\n *\n * A failure and a timeout are logged the same way on purpose: both mean this\n * hook did not do its job, both leave the ones behind it still worth running,\n * and neither is something a terminating process can act on. `Cause.pretty`\n * keeps them distinguishable in Loki, where someone reading them actually is.\n */\nconst runHook = (hook: ShutdownHook): Effect.Effect<void> =>\n hook.run.pipe(\n Effect.timeout(HOOK_TIMEOUT),\n Effect.catchCause(cause =>\n Effect.logError('shutdown hook did not complete').pipe(\n // Annotations, not a rendered message: the tooling logger emits these\n // as structured fields, so which hook stalled a rollout is queryable.\n Effect.annotateLogs({\n hook: hook.name,\n phase: hook.phase,\n cause: Cause.pretty(cause),\n }),\n ),\n ),\n );\n\n/**\n * Builds the registry and the synchronous latch that flips it.\n *\n * Two halves for two callers, the `makeAmqpConnector` shape: the\n * `registry` goes into the layer graph, while `begin` is called from a\n * `process.on('SIGTERM')` handler — outside Effect, in the same tick the\n * runtime starts interrupting — so `/api/health/ready` stops claiming to be\n * ready the instant the signal lands rather than whenever the finalizers get\n * around to running.\n */\nexport const makeShutdownRegistry = (): {\n registry: ShutdownRegistry;\n begin: () => void;\n} => {\n const hooks: ShutdownHook[] = [];\n let terminating = false;\n\n const registry: ShutdownRegistry = {\n register: hook =>\n Effect.sync(() => {\n hooks.push(hook);\n }),\n\n terminating: Effect.sync(() => terminating),\n\n drain: Effect.gen(function* () {\n terminating = true;\n yield* Effect.logInfo('draining before shutdown').pipe(\n Effect.annotateLogs({ hooks: hooks.length }),\n );\n for (const phase of SHUTDOWN_PHASES) {\n // Sequential, and in registration order: `stop-intake` before `flush`\n // is the whole contract, and within a phase a later hook may depend on\n // an earlier one having stopped.\n yield* Effect.forEach(\n hooks.filter(hook => hook.phase === phase),\n runHook,\n { discard: true },\n );\n }\n yield* Effect.logInfo('drained');\n }),\n };\n\n return {\n registry,\n begin: () => {\n terminating = true;\n },\n };\n};\n"],"names":["Cause","Context","Effect","SHUTDOWN_PHASES","HOOK_TIMEOUT","ShutdownRegistryTag","Service","runHook","hook","run","pipe","timeout","catchCause","cause","logError","annotateLogs","name","phase","pretty","makeShutdownRegistry","hooks","terminating","registry","register","sync","push","drain","gen","logInfo","length","forEach","filter","discard","begin"],"mappings":"AAAA,SAASA,KAAK,EAAEC,OAAO,EAAEC,MAAM,QAAQ,SAAS;AAEhD;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,kBAAkB;IAAC;IAAe;CAAQ,CAAU;AAmBjE;;;;;;;;CAQC,GACD,MAAMC,eAAe;AAiCrB;;;;;;CAMC,GACD,OAAO,MAAMC,4BAA4BJ,QAAQK,OAAO,GAGpD;AAAwB;AAE5B;;;;;;;CAOC,GACD,MAAMC,UAAU,CAACC,OACfA,KAAKC,GAAG,CAACC,IAAI,CACXR,OAAOS,OAAO,CAACP,eACfF,OAAOU,UAAU,CAACC,CAAAA,QAChBX,OAAOY,QAAQ,CAAC,kCAAkCJ,IAAI,CACpD,sEAAsE;QACtE,sEAAsE;QACtER,OAAOa,YAAY,CAAC;YAClBP,MAAMA,KAAKQ,IAAI;YACfC,OAAOT,KAAKS,KAAK;YACjBJ,OAAOb,MAAMkB,MAAM,CAACL;QACtB;AAKR;;;;;;;;;CASC,GACD,OAAO,MAAMM,uBAAuB;IAIlC,MAAMC,QAAwB,EAAE;IAChC,IAAIC,cAAc;IAElB,MAAMC,WAA6B;QACjCC,UAAUf,CAAAA,OACRN,OAAOsB,IAAI,CAAC;gBACVJ,MAAMK,IAAI,CAACjB;YACb;QAEFa,aAAanB,OAAOsB,IAAI,CAAC,IAAMH;QAE/BK,OAAOxB,OAAOyB,GAAG,CAAC;YAChBN,cAAc;YACd,OAAOnB,OAAO0B,OAAO,CAAC,4BAA4BlB,IAAI,CACpDR,OAAOa,YAAY,CAAC;gBAAEK,OAAOA,MAAMS,MAAM;YAAC;YAE5C,KAAK,MAAMZ,SAASd,gBAAiB;gBACnC,sEAAsE;gBACtE,uEAAuE;gBACvE,iCAAiC;gBACjC,OAAOD,OAAO4B,OAAO,CACnBV,MAAMW,MAAM,CAACvB,CAAAA,OAAQA,KAAKS,KAAK,KAAKA,QACpCV,SACA;oBAAEyB,SAAS;gBAAK;YAEpB;YACA,OAAO9B,OAAO0B,OAAO,CAAC;QACxB;IACF;IAEA,OAAO;QACLN;QACAW,OAAO;YACLZ,cAAc;QAChB;IACF;AACF,EAAE"}
1
+ {"version":3,"sources":["../../src/shutdown/shutdown-registry.ts"],"sourcesContent":["import { Cause, Context, Effect } from 'effect';\n\n/**\n * The order a shutdown runs in.\n *\n * **`stop-intake`** stops the process taking new work — cancelling the bus's\n * consumers, interrupting a polling daemon. **`flush`** carries out what is\n * already committed but not yet delivered: the outbox relay's final pass. The\n * two are separate phases rather than one list because they are ordered against\n * each other and not merely against the connections closing — flushing while\n * consumers are still folding events would race a drain against fresh work, and\n * the relay would go on finding entries the handlers keep writing.\n */\nexport const SHUTDOWN_PHASES = ['stop-intake', 'flush'] as const;\n\nexport type ShutdownPhase = (typeof SHUTDOWN_PHASES)[number];\n\n/**\n * One thing to do before this process's connections close.\n *\n * `run` is fully applied, exactly like `HealthProbe.check`: whoever\n * registers the hook has already closed over the connector, the fiber or the\n * store, so the drain can run every hook without knowing what any of them\n * touches.\n */\nexport interface ShutdownHook {\n /** Logged when the hook runs, and when it fails. Never rendered to a caller. */\n readonly name: string;\n readonly phase: ShutdownPhase;\n readonly run: Effect.Effect<void>;\n}\n\n/**\n * How long one hook may take before the drain moves on without it.\n *\n * Not belt-and-braces. Kubernetes sends SIGKILL at the end of\n * `terminationGracePeriodSeconds` whatever the process is doing, so an\n * unbounded hook does not buy time — it spends the budget of the hooks behind\n * it and then loses them entirely. A hook that cannot finish in time has\n * already failed; the difference is whether the *next* one gets to run.\n */\nconst HOOK_TIMEOUT = '10 seconds';\n\n/**\n * What a service does on its way out, registered by whoever opened the thing\n * that needs draining.\n *\n * A sibling of `HealthRegistry` and `WiringRegistry`, and for the same reason:\n * a `-service` that gains a bus gains its consumer drain by merging that\n * adapter's layer, and nothing in the service's own `main.ts` has to remember\n * to list it. A hand-written sequence per composition root drifts the first\n * time someone adds a dependency; this cannot, because the hook ships with the\n * client that needs it.\n */\nexport interface ShutdownRegistry {\n /** Add a hook. Called by an adapter layer as it constructs its client. */\n readonly register: (hook: ShutdownHook) => Effect.Effect<void>;\n /**\n * Whether this process has begun terminating.\n *\n * Read by `GET /api/health/ready`, which answers `503` from here **before**\n * it consults any probe: nothing is failing, the process is leaving.\n */\n readonly terminating: Effect.Effect<boolean>;\n /**\n * Run every hook, phase by phase, in registration order. Never fails.\n *\n * Called from a `Layer` finalizer, which is what orders it: the drain layer\n * is built after the composition root, so it is released before it, and the\n * connections a hook needs are still open while it runs.\n */\n readonly drain: Effect.Effect<void>;\n}\n\n/**\n * DI tag for the shutdown registry.\n *\n * In the contract layer beside `HealthRegistryTag` so an\n * `entifix-ts-*-client` package can register a hook without depending on a\n * shell.\n */\nexport class ShutdownRegistryTag extends Context.Service<\n ShutdownRegistryTag,\n ShutdownRegistry\n>()('ShutdownRegistryTag') {}\n\n/**\n * Runs one hook and swallows whatever it does.\n *\n * A failure and a timeout are logged the same way on purpose: both mean this\n * hook did not do its job, both leave the ones behind it still worth running,\n * and neither is something a terminating process can act on. `Cause.pretty`\n * keeps them distinguishable in Loki, where someone reading them actually is.\n */\nconst runHook = (hook: ShutdownHook): Effect.Effect<void> =>\n hook.run.pipe(\n Effect.timeout(HOOK_TIMEOUT),\n Effect.catchCause(cause =>\n Effect.logError('shutdown hook did not complete').pipe(\n // Annotations, not a rendered message: the tooling logger emits these\n // as structured fields, so which hook stalled a rollout is queryable.\n Effect.annotateLogs({\n hook: hook.name,\n phase: hook.phase,\n cause: Cause.pretty(cause),\n }),\n ),\n ),\n );\n\n/**\n * Builds the registry and the synchronous latch that flips it.\n *\n * Two halves for two callers, the `makeAmqpConnector` shape: the\n * `registry` goes into the layer graph, while `begin` is called from a\n * `process.on('SIGTERM')` handler — outside Effect, in the same tick the\n * runtime starts interrupting — so `/api/health/ready` stops claiming to be\n * ready the instant the signal lands rather than whenever the finalizers get\n * around to running.\n */\nexport const makeShutdownRegistry = (): {\n registry: ShutdownRegistry;\n begin: () => void;\n} => {\n const hooks: ShutdownHook[] = [];\n let terminating = false;\n\n const registry: ShutdownRegistry = {\n register: hook =>\n Effect.sync(() => {\n hooks.push(hook);\n }),\n\n terminating: Effect.sync(() => terminating),\n\n drain: Effect.gen(function* () {\n terminating = true;\n yield* Effect.logInfo('draining before shutdown').pipe(\n Effect.annotateLogs({ hooks: hooks.length }),\n );\n for (const phase of SHUTDOWN_PHASES) {\n // Sequential, and in registration order: `stop-intake` before `flush`\n // is the whole contract, and within a phase a later hook may depend on\n // an earlier one having stopped.\n yield* Effect.forEach(\n hooks.filter(hook => hook.phase === phase),\n runHook,\n { discard: true },\n );\n }\n yield* Effect.logInfo('drained');\n }),\n };\n\n return {\n registry,\n begin: () => {\n terminating = true;\n },\n };\n};\n"],"names":["Cause","Context","Effect","SHUTDOWN_PHASES","HOOK_TIMEOUT","ShutdownRegistryTag","Service","runHook","hook","run","pipe","timeout","catchCause","cause","logError","annotateLogs","name","phase","pretty","makeShutdownRegistry","hooks","terminating","registry","register","sync","push","drain","gen","logInfo","length","forEach","filter","discard","begin"],"mappings":"AAAA,SAASA,KAAK,EAAEC,OAAO,EAAEC,MAAM,QAAQ,SAAS;AAEhD;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,kBAAkB;IAAC;IAAe;CAAQ,CAAU;AAmBjE;;;;;;;;CAQC,GACD,MAAMC,eAAe;AAiCrB;;;;;;CAMC,GACD,OAAO,MAAMC,4BAA4BJ,QAAQK,OAAO,GAGpD;AAAwB;AAE5B;;;;;;;CAOC,GACD,MAAMC,UAAU,CAACC,OACfA,KAAKC,GAAG,CAACC,IAAI,CACXR,OAAOS,OAAO,CAACP,eACfF,OAAOU,UAAU,CAACC,CAAAA,QAChBX,OAAOY,QAAQ,CAAC,kCAAkCJ,IAAI,CACpD,sEAAsE;QACtE,sEAAsE;QACtER,OAAOa,YAAY,CAAC;YAClBP,MAAMA,KAAKQ,IAAI;YACfC,OAAOT,KAAKS,KAAK;YACjBJ,OAAOb,MAAMkB,MAAM,CAACL;QACtB;AAKR;;;;;;;;;CASC,GACD,OAAO,MAAMM,uBAAuB;IAIlC,MAAMC,QAAwB,EAAE;IAChC,IAAIC,cAAc;IAElB,MAAMC,WAA6B;QACjCC,UAAUf,CAAAA,OACRN,OAAOsB,IAAI,CAAC;gBACVJ,MAAMK,IAAI,CAACjB;YACb;QAEFa,aAAanB,OAAOsB,IAAI,CAAC,IAAMH;QAE/BK,OAAOxB,OAAOyB,GAAG,CAAC;YAChBN,cAAc;YACd,OAAOnB,OAAO0B,OAAO,CAAC,4BAA4BlB,IAAI,CACpDR,OAAOa,YAAY,CAAC;gBAAEK,OAAOA,MAAMS,MAAM;YAAC;YAE5C,KAAK,MAAMZ,SAASd,gBAAiB;gBACnC,sEAAsE;gBACtE,uEAAuE;gBACvE,iCAAiC;gBACjC,OAAOD,OAAO4B,OAAO,CACnBV,MAAMW,MAAM,CAACvB,CAAAA,OAAQA,KAAKS,KAAK,KAAKA,QACpCV,SACA;oBAAEyB,SAAS;gBAAK;YAEpB;YACA,OAAO9B,OAAO0B,OAAO,CAAC;QACxB;IACF;IAEA,OAAO;QACLN;QACAW,OAAO;YACLZ,cAAc;QAChB;IACF;AACF,EAAE"}
@@ -62,7 +62,7 @@ export interface TokenClaims {
62
62
  readonly partyId?: string;
63
63
  /**
64
64
  * The business domains the acting organization is provisioned for — the
65
- * second assignment ceiling (ADR 0007), carried so a server-rendered menu can
65
+ * second assignment ceiling beside the role, carried so a server-rendered menu can
66
66
  * shape itself without a lookup on every render.
67
67
  *
68
68
  * Absent for a session with no organization, and that is **not** the same as
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tokens/token-service.ts"],"sourcesContent":["import type { EntifixError, EntityId } from '@entifix/core';\nimport { Context } from 'effect';\nimport { Effect } from 'effect/Effect';\n\n/**\n * The claims a stateless access token carries between the perimeter that mints\n * it and the services that verify it. Kept small on purpose: everything here is\n * copied into every request and can only refresh when a new token is minted, so\n * only authorization-critical, stable subject data belongs in it. Richer or\n * volatile state stays in the session record and is read on demand.\n */\nexport interface TokenClaims {\n /** Canonical user id — the value services key their data against. */\n readonly userId: EntityId;\n /** Upstream identity subject that authenticated the session. */\n readonly subject: string;\n /** The session id the token was minted from; the revocation linkage. */\n readonly sessionId: string;\n readonly roles: readonly string[];\n /**\n * The organization this session is acting for, and the value every\n * tenant-plane storage handle is resolved from.\n *\n * A property of the **session**, not of the user: a party may hold several\n * memberships, and switching re-mints the token. Absent for a session with no\n * organization — a buyer, or an operator, who holds no tenant scope at all.\n *\n * Note this is a *routing* key, not an authorization input. Grants still come\n * from `roles` resolved at the consumer.\n */\n readonly activeOrganizationId?: string;\n /**\n * Which population the session belongs to — a buyer, a vendor's staff, or\n * platform staff. Typed as a plain string here because the closed set lives\n * in the business layer (`PartyRoles` in `business-ts-party-management`),\n * which this layer may not import; consumers narrow it, exactly as they\n * already do for `roles`.\n *\n * It exists because the alternative was inferring it from an *absent*\n * `activeOrganizationId`, which cannot tell a buyer from an operator — two\n * populations whose reach differs enormously. A capability that sensitive\n * must not be signalled by a missing field.\n *\n * Like `activeOrganizationId` this is routing/context, not a grant: what a\n * principal may do still comes from `roles` resolved at the consumer.\n */\n readonly partyRole?: string;\n /**\n * The party behind the account — the `Individual` id that domains recording a\n * party key against, and the value a `ProductOrder.buyerId` holds.\n *\n * It is carried because the hop from an account to a party exists in exactly\n * one store, the one that mints this token. A service scoping a read to the\n * caller's own records needs the party and cannot derive it from\n * {@link TokenClaims.userId} without reading another slice's collections.\n *\n * Absent for an account with no party record. A consumer must read the\n * absence as *no party* — scoping to nothing — never as *any party*.\n *\n * Like `activeOrganizationId` and `partyRole` this is context, not a grant:\n * it says whose records these are, never what may be done with them.\n */\n readonly partyId?: string;\n /**\n * The business domains the acting organization is provisioned for — the\n * second assignment ceiling (ADR 0007), carried so a server-rendered menu can\n * shape itself without a lookup on every render.\n *\n * Absent for a session with no organization, and that is **not** the same as\n * an empty list: an operator holds no tenant scope, so the ceiling does not\n * apply at all, while a member of an organization provisioned for nothing is\n * entitled to nothing. Consumers therefore decide from\n * {@link TokenClaims.activeOrganizationId} whether to consult this at all.\n *\n * Plain strings for the same reason `roles` is: the domain names live in the\n * business layer, which this one may not import.\n *\n * Like `activeOrganizationId` and `partyRole` this is context, not a grant.\n */\n readonly entitlements?: readonly string[];\n /** Room for a few extra stable claims without a type change. */\n readonly [key: string]: unknown;\n}\n\n/**\n * Signs and verifies short-lived access tokens. The perimeter provides it to\n * mint a token from a resolved session; every downstream service provides it to\n * verify one statelessly (no store round trip on the hot path). Vendor-neutral:\n * a `jose`/HS256 adapter backs it now, an RS256/JWKS one can later without\n * touching a consumer.\n */\nexport interface TokenService {\n /** Sign the claims into a compact token that expires after `ttlSeconds`. */\n sign(claims: TokenClaims, ttlSeconds: number): Effect<string, EntifixError>;\n /** Verify signature + expiry and return the claims, or fail. */\n verify(token: string): Effect<TokenClaims, EntifixError>;\n}\n\n/** DI tag the composition root binds to a concrete {@link TokenService}. */\nexport class TokenServiceTag extends Context.Service<\n TokenServiceTag,\n TokenService\n>()('TokenServiceTag') {}\n"],"names":["Context","TokenServiceTag","Service"],"mappings":"AACA,SAASA,OAAO,QAAQ,SAAS;AAiGjC,0EAA0E,GAC1E,OAAO,MAAMC,wBAAwBD,QAAQE,OAAO,GAGhD;AAAoB"}
1
+ {"version":3,"sources":["../../src/tokens/token-service.ts"],"sourcesContent":["import type { EntifixError, EntityId } from '@entifix/core';\nimport { Context } from 'effect';\nimport { Effect } from 'effect/Effect';\n\n/**\n * The claims a stateless access token carries between the perimeter that mints\n * it and the services that verify it. Kept small on purpose: everything here is\n * copied into every request and can only refresh when a new token is minted, so\n * only authorization-critical, stable subject data belongs in it. Richer or\n * volatile state stays in the session record and is read on demand.\n */\nexport interface TokenClaims {\n /** Canonical user id — the value services key their data against. */\n readonly userId: EntityId;\n /** Upstream identity subject that authenticated the session. */\n readonly subject: string;\n /** The session id the token was minted from; the revocation linkage. */\n readonly sessionId: string;\n readonly roles: readonly string[];\n /**\n * The organization this session is acting for, and the value every\n * tenant-plane storage handle is resolved from.\n *\n * A property of the **session**, not of the user: a party may hold several\n * memberships, and switching re-mints the token. Absent for a session with no\n * organization — a buyer, or an operator, who holds no tenant scope at all.\n *\n * Note this is a *routing* key, not an authorization input. Grants still come\n * from `roles` resolved at the consumer.\n */\n readonly activeOrganizationId?: string;\n /**\n * Which population the session belongs to — a buyer, a vendor's staff, or\n * platform staff. Typed as a plain string here because the closed set lives\n * in the business layer (`PartyRoles` in `business-ts-party-management`),\n * which this layer may not import; consumers narrow it, exactly as they\n * already do for `roles`.\n *\n * It exists because the alternative was inferring it from an *absent*\n * `activeOrganizationId`, which cannot tell a buyer from an operator — two\n * populations whose reach differs enormously. A capability that sensitive\n * must not be signalled by a missing field.\n *\n * Like `activeOrganizationId` this is routing/context, not a grant: what a\n * principal may do still comes from `roles` resolved at the consumer.\n */\n readonly partyRole?: string;\n /**\n * The party behind the account — the `Individual` id that domains recording a\n * party key against, and the value a `ProductOrder.buyerId` holds.\n *\n * It is carried because the hop from an account to a party exists in exactly\n * one store, the one that mints this token. A service scoping a read to the\n * caller's own records needs the party and cannot derive it from\n * {@link TokenClaims.userId} without reading another slice's collections.\n *\n * Absent for an account with no party record. A consumer must read the\n * absence as *no party* — scoping to nothing — never as *any party*.\n *\n * Like `activeOrganizationId` and `partyRole` this is context, not a grant:\n * it says whose records these are, never what may be done with them.\n */\n readonly partyId?: string;\n /**\n * The business domains the acting organization is provisioned for — the\n * second assignment ceiling beside the role, carried so a server-rendered menu can\n * shape itself without a lookup on every render.\n *\n * Absent for a session with no organization, and that is **not** the same as\n * an empty list: an operator holds no tenant scope, so the ceiling does not\n * apply at all, while a member of an organization provisioned for nothing is\n * entitled to nothing. Consumers therefore decide from\n * {@link TokenClaims.activeOrganizationId} whether to consult this at all.\n *\n * Plain strings for the same reason `roles` is: the domain names live in the\n * business layer, which this one may not import.\n *\n * Like `activeOrganizationId` and `partyRole` this is context, not a grant.\n */\n readonly entitlements?: readonly string[];\n /** Room for a few extra stable claims without a type change. */\n readonly [key: string]: unknown;\n}\n\n/**\n * Signs and verifies short-lived access tokens. The perimeter provides it to\n * mint a token from a resolved session; every downstream service provides it to\n * verify one statelessly (no store round trip on the hot path). Vendor-neutral:\n * a `jose`/HS256 adapter backs it now, an RS256/JWKS one can later without\n * touching a consumer.\n */\nexport interface TokenService {\n /** Sign the claims into a compact token that expires after `ttlSeconds`. */\n sign(claims: TokenClaims, ttlSeconds: number): Effect<string, EntifixError>;\n /** Verify signature + expiry and return the claims, or fail. */\n verify(token: string): Effect<TokenClaims, EntifixError>;\n}\n\n/** DI tag the composition root binds to a concrete {@link TokenService}. */\nexport class TokenServiceTag extends Context.Service<\n TokenServiceTag,\n TokenService\n>()('TokenServiceTag') {}\n"],"names":["Context","TokenServiceTag","Service"],"mappings":"AACA,SAASA,OAAO,QAAQ,SAAS;AAiGjC,0EAA0E,GAC1E,OAAO,MAAMC,wBAAwBD,QAAQE,OAAO,GAGhD;AAAoB"}
@@ -1,6 +1,6 @@
1
1
  import { Context, Effect, Layer } from 'effect';
2
2
  /**
3
- * The queue shapes a subscriber may ask for (ADR 0030).
3
+ * The queue shapes a subscriber may ask for.
4
4
  *
5
5
  * Duplicated from `SubscriptionMode` in `@entifix/transactions` rather than
6
6
  * imported: that package is T2, **above** this one at T1, so the edge would
@@ -15,7 +15,7 @@ export type BoundSubscriptionMode = (typeof BOUND_SUBSCRIPTION_MODES)[number];
15
15
  *
16
16
  * Observed rather than declared, which is the whole point: a document generated
17
17
  * from `tools/slices/` would agree with `tools/slices/` by construction and
18
- * catch nothing (ADR 0031). `queue` is the broker's own name for it, so someone
18
+ * catch nothing. `queue` is the broker's own name for it, so someone
19
19
  * looking at depth in the management UI can match the two up.
20
20
  */
21
21
  export interface BoundSubscription {
@@ -35,8 +35,8 @@ export interface BoundSubscription {
35
35
  *
36
36
  * Both halves are cumulative and never cleared. `published` is a set of event
37
37
  * **names** and nothing else: no payloads, no counts, no timestamps. Counts
38
- * would be per-replica and un-alertable, which is the telemetry path ADR 0001
39
- * already chose OTLP for.
38
+ * would be per-replica and un-alertable, which is what the metrics pipeline is
39
+ * for.
40
40
  */
41
41
  export interface WiringRegistry {
42
42
  /** Called by the bus adapter once per binding, including after a reconnect. */
@@ -1,6 +1,6 @@
1
1
  import { Context, Effect, Layer, Ref } from 'effect';
2
2
  /**
3
- * The queue shapes a subscriber may ask for (ADR 0030).
3
+ * The queue shapes a subscriber may ask for.
4
4
  *
5
5
  * Duplicated from `SubscriptionMode` in `@entifix/transactions` rather than
6
6
  * imported: that package is T2, **above** this one at T1, so the edge would
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/wiring/wiring-registry.ts"],"sourcesContent":["import { Context, Effect, Layer, Ref } from 'effect';\n\n/**\n * The queue shapes a subscriber may ask for (ADR 0030).\n *\n * Duplicated from `SubscriptionMode` in `@entifix/transactions` rather than\n * imported: that package is T2, **above** this one at T1, so the edge would\n * point upward and the tier rule fails the build on it. The same duplication exists in `tools/slices/src/types.ts`\n * for the same reason, and both lists are pinned by a spec.\n */\nexport const BOUND_SUBSCRIPTION_MODES = ['work', 'broadcast'] as const;\n\nexport type BoundSubscriptionMode = (typeof BOUND_SUBSCRIPTION_MODES)[number];\n\n/**\n * One subscription a running process **actually bound**, recorded by the bus\n * adapter as `subscribe` runs.\n *\n * Observed rather than declared, which is the whole point: a document generated\n * from `tools/slices/` would agree with `tools/slices/` by construction and\n * catch nothing (ADR 0031). `queue` is the broker's own name for it, so someone\n * looking at depth in the management UI can match the two up.\n */\nexport interface BoundSubscription {\n /** The **subscribing** slice — never the emitter. See `Subscription.slice`. */\n readonly slice: string;\n readonly pattern: string;\n readonly mode: BoundSubscriptionMode;\n readonly queue: string;\n}\n\n/**\n * What a process did with its bus, as opposed to what it can still reach.\n *\n * A sibling of `HealthRegistry` rather than part of it: a subscription is not a\n * readiness fact — a bound queue says nothing about whether the broker is\n * answering right now — and folding the two together would put entries in the\n * readiness response that no probe can check.\n *\n * Both halves are cumulative and never cleared. `published` is a set of event\n * **names** and nothing else: no payloads, no counts, no timestamps. Counts\n * would be per-replica and un-alertable, which is the telemetry path ADR 0001\n * already chose OTLP for.\n */\nexport interface WiringRegistry {\n /** Called by the bus adapter once per binding, including after a reconnect. */\n readonly recordSubscription: (\n bound: BoundSubscription,\n ) => Effect.Effect<void>;\n /** Called by the bus adapter on every publish. Idempotent per name. */\n readonly recordPublish: (eventName: string) => Effect.Effect<void>;\n /** Every distinct binding, in the order it was first made. */\n readonly subscriptions: Effect.Effect<readonly BoundSubscription[]>;\n /** Every distinct event name emitted since boot, in first-seen order. */\n readonly published: Effect.Effect<readonly string[]>;\n}\n\n/**\n * DI tag for the wiring registry.\n *\n * It lives here, in the contract layer, so `@entifix/amqp` can record\n * into it without depending on a shell — the same placement, and the same\n * reason, as {@link HealthRegistryTag}.\n */\nexport class WiringRegistryTag extends Context.Service<\n WiringRegistryTag,\n WiringRegistry\n>()('WiringRegistryTag') {}\n\n/** Whether two bindings are the same binding. */\nconst same = (a: BoundSubscription, b: BoundSubscription): boolean =>\n a.slice === b.slice &&\n a.pattern === b.pattern &&\n a.mode === b.mode &&\n a.queue === b.queue;\n\n/**\n * Live registry over a `Ref`. Provide it **once** per service and share the\n * instance (`Layer.provideMerge`), or the bus records into a registry the\n * description route never reads.\n *\n * Both recorders deduplicate. A reconnect re-runs every consumer's setup\n * against the new channel (`AmqpConnector.addConsumer`), so without it a broker\n * restart would list the same binding twice; and a publisher emits the same\n * event name continuously, so without it the list would grow without bound.\n */\nexport const WiringRegistryLayer: Layer.Layer<WiringRegistryTag> = Layer.effect(\n WiringRegistryTag,\n Effect.gen(function* () {\n const subscriptions = yield* Ref.make<readonly BoundSubscription[]>([]);\n const published = yield* Ref.make<readonly string[]>([]);\n\n return {\n recordSubscription: bound =>\n Ref.update(subscriptions, current =>\n current.some(existing => same(existing, bound))\n ? current\n : [...current, bound],\n ),\n\n recordPublish: eventName =>\n Ref.update(published, current =>\n current.includes(eventName) ? current : [...current, eventName],\n ),\n\n subscriptions: Ref.get(subscriptions),\n published: Ref.get(published),\n };\n }),\n);\n"],"names":["Context","Effect","Layer","Ref","BOUND_SUBSCRIPTION_MODES","WiringRegistryTag","Service","same","a","b","slice","pattern","mode","queue","WiringRegistryLayer","effect","gen","subscriptions","make","published","recordSubscription","bound","update","current","some","existing","recordPublish","eventName","includes","get"],"mappings":"AAAA,SAASA,OAAO,EAAEC,MAAM,EAAEC,KAAK,EAAEC,GAAG,QAAQ,SAAS;AAErD;;;;;;;CAOC,GACD,OAAO,MAAMC,2BAA2B;IAAC;IAAQ;CAAY,CAAU;AA+CvE;;;;;;CAMC,GACD,OAAO,MAAMC,0BAA0BL,QAAQM,OAAO,GAGlD;AAAsB;AAE1B,+CAA+C,GAC/C,MAAMC,OAAO,CAACC,GAAsBC,IAClCD,EAAEE,KAAK,KAAKD,EAAEC,KAAK,IACnBF,EAAEG,OAAO,KAAKF,EAAEE,OAAO,IACvBH,EAAEI,IAAI,KAAKH,EAAEG,IAAI,IACjBJ,EAAEK,KAAK,KAAKJ,EAAEI,KAAK;AAErB;;;;;;;;;CASC,GACD,OAAO,MAAMC,sBAAsDZ,MAAMa,MAAM,CAC7EV,mBACAJ,OAAOe,GAAG,CAAC;IACT,MAAMC,gBAAgB,OAAOd,IAAIe,IAAI,CAA+B,EAAE;IACtE,MAAMC,YAAY,OAAOhB,IAAIe,IAAI,CAAoB,EAAE;IAEvD,OAAO;QACLE,oBAAoBC,CAAAA,QAClBlB,IAAImB,MAAM,CAACL,eAAeM,CAAAA,UACxBA,QAAQC,IAAI,CAACC,CAAAA,WAAYlB,KAAKkB,UAAUJ,UACpCE,UACA;uBAAIA;oBAASF;iBAAM;QAG3BK,eAAeC,CAAAA,YACbxB,IAAImB,MAAM,CAACH,WAAWI,CAAAA,UACpBA,QAAQK,QAAQ,CAACD,aAAaJ,UAAU;uBAAIA;oBAASI;iBAAU;QAGnEV,eAAed,IAAI0B,GAAG,CAACZ;QACvBE,WAAWhB,IAAI0B,GAAG,CAACV;IACrB;AACF,IACA"}
1
+ {"version":3,"sources":["../../src/wiring/wiring-registry.ts"],"sourcesContent":["import { Context, Effect, Layer, Ref } from 'effect';\n\n/**\n * The queue shapes a subscriber may ask for.\n *\n * Duplicated from `SubscriptionMode` in `@entifix/transactions` rather than\n * imported: that package is T2, **above** this one at T1, so the edge would\n * point upward and the tier rule fails the build on it. The same duplication exists in `tools/slices/src/types.ts`\n * for the same reason, and both lists are pinned by a spec.\n */\nexport const BOUND_SUBSCRIPTION_MODES = ['work', 'broadcast'] as const;\n\nexport type BoundSubscriptionMode = (typeof BOUND_SUBSCRIPTION_MODES)[number];\n\n/**\n * One subscription a running process **actually bound**, recorded by the bus\n * adapter as `subscribe` runs.\n *\n * Observed rather than declared, which is the whole point: a document generated\n * from `tools/slices/` would agree with `tools/slices/` by construction and\n * catch nothing. `queue` is the broker's own name for it, so someone\n * looking at depth in the management UI can match the two up.\n */\nexport interface BoundSubscription {\n /** The **subscribing** slice — never the emitter. See `Subscription.slice`. */\n readonly slice: string;\n readonly pattern: string;\n readonly mode: BoundSubscriptionMode;\n readonly queue: string;\n}\n\n/**\n * What a process did with its bus, as opposed to what it can still reach.\n *\n * A sibling of `HealthRegistry` rather than part of it: a subscription is not a\n * readiness fact — a bound queue says nothing about whether the broker is\n * answering right now — and folding the two together would put entries in the\n * readiness response that no probe can check.\n *\n * Both halves are cumulative and never cleared. `published` is a set of event\n * **names** and nothing else: no payloads, no counts, no timestamps. Counts\n * would be per-replica and un-alertable, which is what the metrics pipeline is\n * for.\n */\nexport interface WiringRegistry {\n /** Called by the bus adapter once per binding, including after a reconnect. */\n readonly recordSubscription: (\n bound: BoundSubscription,\n ) => Effect.Effect<void>;\n /** Called by the bus adapter on every publish. Idempotent per name. */\n readonly recordPublish: (eventName: string) => Effect.Effect<void>;\n /** Every distinct binding, in the order it was first made. */\n readonly subscriptions: Effect.Effect<readonly BoundSubscription[]>;\n /** Every distinct event name emitted since boot, in first-seen order. */\n readonly published: Effect.Effect<readonly string[]>;\n}\n\n/**\n * DI tag for the wiring registry.\n *\n * It lives here, in the contract layer, so `@entifix/amqp` can record\n * into it without depending on a shell — the same placement, and the same\n * reason, as {@link HealthRegistryTag}.\n */\nexport class WiringRegistryTag extends Context.Service<\n WiringRegistryTag,\n WiringRegistry\n>()('WiringRegistryTag') {}\n\n/** Whether two bindings are the same binding. */\nconst same = (a: BoundSubscription, b: BoundSubscription): boolean =>\n a.slice === b.slice &&\n a.pattern === b.pattern &&\n a.mode === b.mode &&\n a.queue === b.queue;\n\n/**\n * Live registry over a `Ref`. Provide it **once** per service and share the\n * instance (`Layer.provideMerge`), or the bus records into a registry the\n * description route never reads.\n *\n * Both recorders deduplicate. A reconnect re-runs every consumer's setup\n * against the new channel (`AmqpConnector.addConsumer`), so without it a broker\n * restart would list the same binding twice; and a publisher emits the same\n * event name continuously, so without it the list would grow without bound.\n */\nexport const WiringRegistryLayer: Layer.Layer<WiringRegistryTag> = Layer.effect(\n WiringRegistryTag,\n Effect.gen(function* () {\n const subscriptions = yield* Ref.make<readonly BoundSubscription[]>([]);\n const published = yield* Ref.make<readonly string[]>([]);\n\n return {\n recordSubscription: bound =>\n Ref.update(subscriptions, current =>\n current.some(existing => same(existing, bound))\n ? current\n : [...current, bound],\n ),\n\n recordPublish: eventName =>\n Ref.update(published, current =>\n current.includes(eventName) ? current : [...current, eventName],\n ),\n\n subscriptions: Ref.get(subscriptions),\n published: Ref.get(published),\n };\n }),\n);\n"],"names":["Context","Effect","Layer","Ref","BOUND_SUBSCRIPTION_MODES","WiringRegistryTag","Service","same","a","b","slice","pattern","mode","queue","WiringRegistryLayer","effect","gen","subscriptions","make","published","recordSubscription","bound","update","current","some","existing","recordPublish","eventName","includes","get"],"mappings":"AAAA,SAASA,OAAO,EAAEC,MAAM,EAAEC,KAAK,EAAEC,GAAG,QAAQ,SAAS;AAErD;;;;;;;CAOC,GACD,OAAO,MAAMC,2BAA2B;IAAC;IAAQ;CAAY,CAAU;AA+CvE;;;;;;CAMC,GACD,OAAO,MAAMC,0BAA0BL,QAAQM,OAAO,GAGlD;AAAsB;AAE1B,+CAA+C,GAC/C,MAAMC,OAAO,CAACC,GAAsBC,IAClCD,EAAEE,KAAK,KAAKD,EAAEC,KAAK,IACnBF,EAAEG,OAAO,KAAKF,EAAEE,OAAO,IACvBH,EAAEI,IAAI,KAAKH,EAAEG,IAAI,IACjBJ,EAAEK,KAAK,KAAKJ,EAAEI,KAAK;AAErB;;;;;;;;;CASC,GACD,OAAO,MAAMC,sBAAsDZ,MAAMa,MAAM,CAC7EV,mBACAJ,OAAOe,GAAG,CAAC;IACT,MAAMC,gBAAgB,OAAOd,IAAIe,IAAI,CAA+B,EAAE;IACtE,MAAMC,YAAY,OAAOhB,IAAIe,IAAI,CAAoB,EAAE;IAEvD,OAAO;QACLE,oBAAoBC,CAAAA,QAClBlB,IAAImB,MAAM,CAACL,eAAeM,CAAAA,UACxBA,QAAQC,IAAI,CAACC,CAAAA,WAAYlB,KAAKkB,UAAUJ,UACpCE,UACA;uBAAIA;oBAASF;iBAAM;QAG3BK,eAAeC,CAAAA,YACbxB,IAAImB,MAAM,CAACH,WAAWI,CAAAA,UACpBA,QAAQK,QAAQ,CAACD,aAAaJ,UAAU;uBAAIA;oBAASI;iBAAU;QAGnEV,eAAed,IAAI0B,GAAG,CAACZ;QACvBE,WAAWhB,IAAI0B,GAAG,CAACV;IACrB;AACF,IACA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entifix/business",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "The ports every adapter implements — repository, session, token, health — and the generic get, load, save and delete use-cases.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@swc/helpers": "0.5.23",
54
- "@entifix/core": "0.5.0"
54
+ "@entifix/core": "0.6.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "effect": "^4.0.5"