@12-apps/discounts 1.1.0 → 1.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.
package/README.md CHANGED
@@ -131,7 +131,7 @@ bundle, if a host wants one, stays the host's.
131
131
 
132
132
  ## The admin surface
133
133
 
134
- `createApiDiscounts({ store, copy })` returns five `WireRoute` descriptors —
134
+ `createApiDiscounts({ store, copy, logger })` returns five `WireRoute` descriptors —
135
135
  list, read, create, re-state, archive — each **declaring the permission it
136
136
  needs** (`discounts:read` / `discounts:write`), so a host's coverage gates can
137
137
  read the policy off the assembled table instead of scanning route files.
@@ -140,7 +140,11 @@ read the policy off the assembled table instead of scanning route files.
140
140
  import { createApiDiscounts } from "@12-apps/discounts/server";
141
141
  import { PT_BR_DISCOUNTS_SERVER_COPY } from "@12-apps/discounts/server/pt-BR";
142
142
 
143
- const { routes } = createApiDiscounts({ store, copy: PT_BR_DISCOUNTS_SERVER_COPY });
143
+ const { routes } = createApiDiscounts({
144
+ store,
145
+ copy: PT_BR_DISCOUNTS_SERVER_COPY,
146
+ logger: createFeatureLogger("discounts"),
147
+ });
144
148
  ```
145
149
 
146
150
  Or, through the contract:
@@ -152,7 +156,9 @@ import { discountsServerManifest } from "@12-apps/discounts/manifest/server";
152
156
  host.adoptServer({
153
157
  manifest: discountsManifest,
154
158
  server: discountsServerManifest,
155
- bindings: { http: { mountPath: "/api/admin/:tenantSlug", config: { store, copy } } },
159
+ bindings: {
160
+ http: { mountPath: "/api/admin/:tenantSlug", config: { store, copy, logger } },
161
+ },
156
162
  });
157
163
  ```
158
164
 
@@ -160,6 +166,41 @@ Delete is a **soft archive**, and that is a product decision: the orders that
160
166
  already redeemed a discount keep their snapshot, and its redemption counter
161
167
  stays readable for reporting.
162
168
 
169
+ ### Nothing it does is silent
170
+
171
+ `logger` is **required config, with no default**, for the reason `copy` is: the
172
+ default would be a no-op, and a no-op is the exact silence it exists to end.
173
+ Every route is wrapped, and there are three outcomes:
174
+
175
+ | outcome | level | what the line names |
176
+ |---|---|---|
177
+ | a write that succeeded | `info` | the verb, the discount id, the tenant |
178
+ | a refusal this package decided (400 / 404 / 422) | `warn` | the route, the status, and for a 422 the **field** a form paints red |
179
+ | anything thrown — and it is re-thrown unchanged | `error` | the route, the tenant, the cause's message |
180
+
181
+ Every line is a **string**. A host's logger is a Winston child in practice,
182
+ whose formatter runs `util.inspect(…, { depth: 5 })` over an extra argument —
183
+ which is how a provider error's retained response body, buyer name and CPF
184
+ included, reaches a third party. The cause is folded into the sentence instead.
185
+ A line names the tenant, the route, the discount id and the field; never the
186
+ body, never a coupon code, never a name an operator typed.
187
+
188
+ The `error` case **re-throws**. A uniqueness clash, a foreign target and a dead
189
+ connection are the store's to raise in the host's own error vocabulary; a
190
+ surface that folded one into a tidy response would turn a broken database into a
191
+ refusal nobody investigates.
192
+
193
+ A host adopting through `@12-apps/wiring/consumer` already has the logger the
194
+ binder built from this package's declared namespace — pass
195
+ `assembled.loggers["@12-apps/discounts"]`. A host on no wiring passes any
196
+ `createFeatureLogger`-shaped child of its own.
197
+
198
+ The evaluator is deliberately **not** wired to it. `evaluateCart` is a pure
199
+ function called once per cart render; a logger there would file a line per
200
+ keystroke on the storefront and would need a logger threaded through a browser
201
+ bundle. A rejected coupon is already a `DiscountRejection` in the result, which
202
+ is the host's to report if it wants to.
203
+
163
204
  ## What the host still owns
164
205
 
165
206
  **The database.** A discount's rows relate to a host's own catalog and orders —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/discounts",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "The discount/promotion domain: a host-agnostic evaluator (percentage or fixed amount, order/category/item scope, automatic or coupon-triggered, stacking and exclusivity, integer-cent allocation) plus the admin CRUD surface as wiring-contract route descriptors over a store port. No database, no framework, no baked user copy.",
6
6
  "exports": {
@@ -35,7 +35,7 @@
35
35
  "devDependencies": {
36
36
  "@12-apps/eslint-config": "^1.21.1",
37
37
  "@12-apps/typescript-config": "^1.20.1",
38
- "@12-apps/wiring": "^1.7.0",
38
+ "@12-apps/wiring": "^1.10.0",
39
39
  "@types/node": "^22.10.6",
40
40
  "eslint": "^9.39.1",
41
41
  "eslint-plugin-test-flakiness": "^1.4.0",
@@ -46,6 +46,13 @@ export const discountsManifest = {
46
46
  * The server half logs through the binder's namespaced logger, so a
47
47
  * discounts failure files under `discounts` rather than under the host app
48
48
  * or nowhere.
49
+ *
50
+ * `createApiDiscounts` REFUSES to build without one, which is what makes
51
+ * that sentence a fact rather than an aspiration: for the whole of 1.0.x it
52
+ * was neither — the binder built the logger, hung it on
53
+ * `assembled.loggers["@12-apps/discounts"]`, and the package took nothing
54
+ * and logged nothing. A declared capability nobody consumes is worse than an
55
+ * undeclared one, because it reads as finished.
49
56
  */
50
57
  observability: { namespace: "discounts" },
51
58
  server: ["http"],
@@ -15,6 +15,8 @@ export {
15
15
 
16
16
  export { missingServerCopy, type DiscountsServerCopy } from "./copy";
17
17
 
18
+ export { errorText, type DiscountsLogger } from "./logging";
19
+
18
20
  export {
19
21
  DISCOUNTS_PERMISSIONS,
20
22
  DISCOUNTS_READ,
@@ -0,0 +1,118 @@
1
+ import type { WireRequest, WireResponse } from "@12-apps/wiring";
2
+
3
+ import type { DiscountsActor } from "./routes";
4
+ import { DiscountValidationError } from "./validate";
5
+
6
+ /**
7
+ * What this surface says out loud, and to whom (FUT-244).
8
+ *
9
+ * The manifest declares `observability: { namespace: "discounts" }`, which is a
10
+ * PROMISE: a discounts failure files under `discounts` rather than under the
11
+ * host app or nowhere. Declaring it bought nothing on its own — the binder
12
+ * built the namespaced logger, hung it on `assembled.loggers`, and the package
13
+ * never took it, so for the whole of 1.0.x a refused write and a store that
14
+ * threw were equally silent. This module is the other half.
15
+ *
16
+ * ## Why every line is a STRING, and never an object
17
+ *
18
+ * A host's `LoggerPort` is a Winston child in practice, whose formatter runs
19
+ * `util.inspect(…, { depth: 5 })` over any extra argument. That is not a
20
+ * theoretical leak in this org: a provider error retains the provider's parsed
21
+ * response body, buyer name, e-mail and CPF included, so passing the cause as a
22
+ * second argument is precisely how a buyer's details reach a third party.
23
+ * {@link errorText} folds the cause into the sentence instead, which is the
24
+ * same call the origin host's own `errorText` makes and for the same reason.
25
+ *
26
+ * ## Why the sentences here are NOT copy config
27
+ *
28
+ * Everything below is written for a DEVELOPER reading a log, so it is English
29
+ * and it is the package's own — the copy-portability rule governs what a USER
30
+ * reads. Nothing logged here is shown to anyone; the operator's sentence comes
31
+ * from {@link DiscountsServerCopy} and travels in the response body.
32
+ */
33
+
34
+ /**
35
+ * The three methods a host's logger must offer — the structural twin of
36
+ * `@12-apps/wiring`'s `LoggerPort` and of `createFeatureLogger`'s return.
37
+ *
38
+ * Declared here rather than imported so that the wiring contract stays a
39
+ * type-only devDependency: a host on no wiring at all still satisfies this by
40
+ * passing any Winston/pino child, and a host on wiring passes
41
+ * `assembled.loggers["@12-apps/discounts"]` unchanged.
42
+ */
43
+ export interface DiscountsLogger {
44
+ info(message: string): void;
45
+ warn(message: string): void;
46
+ error(message: string): void;
47
+ }
48
+
49
+ /** The cause as a log line — never the object. See the module note. */
50
+ export function errorText(error: unknown): string {
51
+ return error instanceof Error ? error.message : String(error);
52
+ }
53
+
54
+ /**
55
+ * What a line may name: the tenant, the route, the discount, the field, the
56
+ * status. Never a body, never a coupon code, never a name an operator typed —
57
+ * a log line is read by whoever can read the log, and none of those are needed
58
+ * to find the failure.
59
+ */
60
+ function subject(route: string, request: WireRequest<DiscountsActor>): string {
61
+ const id = request.params.id;
62
+ return `${route}${id === undefined ? "" : ` [${id}]`} for tenant ${request.actor.clientId}`;
63
+ }
64
+
65
+ /**
66
+ * Wrap one descriptor's handler so that nothing it does is silent.
67
+ *
68
+ * Three outcomes, three levels, and the split is about who has to act:
69
+ *
70
+ * - a REFUSAL the package itself decided (a 4xx it returned, or the 422 a
71
+ * {@link DiscountValidationError} folds into) is `warn` — the operator
72
+ * typed something the rules do not allow, which is worth seeing when a
73
+ * store reports "it will not let me save" and worth nobody's pager;
74
+ * - anything else THROWN is `error` and is re-thrown unchanged: the store's
75
+ * own failures (a unique clash, a dead connection, a foreign target) belong
76
+ * to the host's error mapping, and swallowing one here to make a tidy
77
+ * response is how a broken database becomes a 404 nobody investigates;
78
+ * - a write that SUCCEEDED is `info`, because a discount is a direct lever on
79
+ * what a buyer is charged and "who repriced the menu at 03:12" is a
80
+ * question that gets asked.
81
+ *
82
+ * The catch is what makes the promise total. Before this, only the two write
83
+ * routes had any catch at all ({@link DiscountValidationError} → 422) and the
84
+ * three read routes had none, so a store that threw inside `list` produced a
85
+ * stack in the host's catch-all with nothing naming discounts.
86
+ */
87
+ export function observed(
88
+ logger: DiscountsLogger,
89
+ route: string,
90
+ handle: (request: WireRequest<DiscountsActor>) => Promise<WireResponse>,
91
+ ): (request: WireRequest<DiscountsActor>) => Promise<WireResponse> {
92
+ return async (request) => {
93
+ try {
94
+ const response = await handle(request);
95
+ if (response.status >= 400) {
96
+ logger.warn(`${subject(route, request)} refused with ${response.status}`);
97
+ }
98
+ return response;
99
+ } catch (error) {
100
+ if (error instanceof DiscountValidationError) {
101
+ logger.warn(`${subject(route, request)} refused with 422 on field "${error.field}"`);
102
+ throw error;
103
+ }
104
+ logger.error(`${subject(route, request)} failed: ${errorText(error)}`);
105
+ throw error;
106
+ }
107
+ };
108
+ }
109
+
110
+ /** The one line a successful write leaves — see {@link observed}'s third case. */
111
+ export function logWrite(
112
+ logger: DiscountsLogger,
113
+ verb: string,
114
+ request: WireRequest<DiscountsActor>,
115
+ id: string,
116
+ ): void {
117
+ logger.info(`${verb} discount ${id} for tenant ${request.actor.clientId}`);
118
+ }
@@ -3,6 +3,7 @@ import type { z } from "zod";
3
3
  import type { WireRequest, WireResponse, WireRoute } from "@12-apps/wiring";
4
4
 
5
5
  import { missingServerCopy, type DiscountsServerCopy } from "./copy";
6
+ import { logWrite, observed, type DiscountsLogger } from "./logging";
6
7
  import { listDiscountsQuery } from "./mcp";
7
8
  import { DISCOUNTS_READ, DISCOUNTS_WRITE } from "./contribution";
8
9
  import type { DiscountListInput, DiscountRecord, DiscountStore } from "./store";
@@ -45,6 +46,16 @@ export interface DiscountsApiConfig {
45
46
  store: DiscountStore;
46
47
  /** Every sentence this surface answers a human with. Required, no defaults. */
47
48
  copy: DiscountsServerCopy;
49
+ /**
50
+ * Where this surface says what it did. Required, and for the same reason
51
+ * `copy` is: a default would be a no-op, and a no-op default is exactly the
52
+ * silence this config exists to end — the manifest declares an
53
+ * `observability` namespace, so a host adopting through
54
+ * `@12-apps/wiring/consumer` already HAS the logger and passes
55
+ * `assembled.loggers["@12-apps/discounts"]`; a host on no wiring passes any
56
+ * `createFeatureLogger`-shaped child. See `./logging`.
57
+ */
58
+ logger: DiscountsLogger;
48
59
  /**
49
60
  * The schema the list query is validated against. Optional ONLY because a
50
61
  * mechanical default is portable: the package's own, built from the search
@@ -85,18 +96,33 @@ function writeFrom(body: unknown, copy: DiscountsServerCopy) {
85
96
  }
86
97
 
87
98
  /**
88
- * Run a write, turning this package's own validation failure into the 422 the
99
+ * One descriptor's handler, observed and then folded.
100
+ *
101
+ * `observed` logs every outcome and changes none of them (see `./logging`);
102
+ * this fold turns THIS package's own validation failure into the 422 the
89
103
  * operator can act on. Everything else — a uniqueness clash, a foreign target,
90
104
  * a stale id — is the STORE's to raise, in the host's own error vocabulary,
91
105
  * and travels untouched to the host's error mapping.
106
+ *
107
+ * Every route goes through it, reads included. A read cannot raise a
108
+ * `DiscountValidationError`, but it can absolutely throw — and before this the
109
+ * three read routes had no catch at all, so a store that failed inside `list`
110
+ * reached the host's catch-all with nothing in the log naming discounts.
92
111
  */
93
- async function attempt(run: () => Promise<WireResponse>): Promise<WireResponse> {
94
- try {
95
- return await run();
96
- } catch (error) {
97
- if (error instanceof DiscountValidationError) return fieldError(error);
98
- throw error;
99
- }
112
+ function endpoint(
113
+ config: DiscountsApiConfig,
114
+ route: string,
115
+ handle: (request: WireRequest<DiscountsActor>) => Promise<WireResponse>,
116
+ ): (request: WireRequest<DiscountsActor>) => Promise<WireResponse> {
117
+ const observedHandle = observed(config.logger, route, handle);
118
+ return async (request) => {
119
+ try {
120
+ return await observedHandle(request);
121
+ } catch (error) {
122
+ if (error instanceof DiscountValidationError) return fieldError(error);
123
+ throw error;
124
+ }
125
+ };
100
126
  }
101
127
 
102
128
  function listRoute(config: DiscountsApiConfig): DiscountRoute {
@@ -105,7 +131,7 @@ function listRoute(config: DiscountsApiConfig): DiscountRoute {
105
131
  method: "GET",
106
132
  path: "/discounts",
107
133
  permission: DISCOUNTS_READ,
108
- handle: async ({ actor, query }: WireRequest<DiscountsActor>) => {
134
+ handle: endpoint(config, "GET /discounts", async ({ actor, query }) => {
109
135
  const parsed = schema.safeParse(query);
110
136
  if (!parsed.success) {
111
137
  return { status: 400, body: { error: config.copy.invalidQuery } };
@@ -114,7 +140,7 @@ function listRoute(config: DiscountsApiConfig): DiscountRoute {
114
140
  // pagination }` envelope the advertised response describes, and wrapping
115
141
  // it again would nest it under a second `data`.
116
142
  return ok(await config.store.list(actor.clientId, parsed.data));
117
- },
143
+ }),
118
144
  };
119
145
  }
120
146
 
@@ -123,11 +149,11 @@ function readRoute(config: DiscountsApiConfig): DiscountRoute {
123
149
  method: "GET",
124
150
  path: "/discounts/:id",
125
151
  permission: DISCOUNTS_READ,
126
- handle: async ({ actor, params }: WireRequest<DiscountsActor>) => {
152
+ handle: endpoint(config, "GET /discounts/:id", async ({ actor, params }) => {
127
153
  const record = await config.store.get(actor.clientId, String(params.id));
128
154
  if (record === null) return { status: 404, body: { error: config.copy.notFound } };
129
155
  return ok({ data: record as DiscountRecord });
130
- },
156
+ }),
131
157
  };
132
158
  }
133
159
 
@@ -136,10 +162,12 @@ function createRoute(config: DiscountsApiConfig): DiscountRoute {
136
162
  method: "POST",
137
163
  path: "/discounts",
138
164
  permission: DISCOUNTS_WRITE,
139
- handle: ({ actor, body }: WireRequest<DiscountsActor>) =>
140
- attempt(async () =>
141
- ok({ data: await config.store.create(actor.clientId, writeFrom(body, config.copy)) }),
142
- ),
165
+ handle: endpoint(config, "POST /discounts", async (request) => {
166
+ const { actor, body } = request;
167
+ const created = await config.store.create(actor.clientId, writeFrom(body, config.copy));
168
+ logWrite(config.logger, "created", request, created.id);
169
+ return ok({ data: created });
170
+ }),
143
171
  };
144
172
  }
145
173
 
@@ -148,12 +176,13 @@ function updateRoute(config: DiscountsApiConfig): DiscountRoute {
148
176
  method: "PATCH",
149
177
  path: "/discounts/:id",
150
178
  permission: DISCOUNTS_WRITE,
151
- handle: ({ actor, params, body }: WireRequest<DiscountsActor>) =>
152
- attempt(async () => {
153
- const id = String(params.id);
154
- await config.store.update(actor.clientId, id, writeFrom(body, config.copy));
155
- return acknowledged(id);
156
- }),
179
+ handle: endpoint(config, "PATCH /discounts/:id", async (request) => {
180
+ const { actor, params, body } = request;
181
+ const id = String(params.id);
182
+ await config.store.update(actor.clientId, id, writeFrom(body, config.copy));
183
+ logWrite(config.logger, "re-stated", request, id);
184
+ return acknowledged(id);
185
+ }),
157
186
  };
158
187
  }
159
188
 
@@ -162,21 +191,39 @@ function archiveRoute(config: DiscountsApiConfig): DiscountRoute {
162
191
  method: "DELETE",
163
192
  path: "/discounts/:id",
164
193
  permission: DISCOUNTS_WRITE,
165
- handle: async ({ actor, params }: WireRequest<DiscountsActor>) => {
194
+ handle: endpoint(config, "DELETE /discounts/:id", async (request) => {
195
+ const { actor, params } = request;
166
196
  const id = String(params.id);
167
197
  await config.store.archive(actor.clientId, id);
198
+ logWrite(config.logger, "archived", request, id);
168
199
  return acknowledged(id);
169
- },
200
+ }),
170
201
  };
171
202
  }
172
203
 
204
+ /** The three methods `observed` calls — asserted before any request arrives. */
205
+ function assertLogger(logger: DiscountsLogger | undefined): void {
206
+ const complete =
207
+ typeof logger?.info === "function" &&
208
+ typeof logger.warn === "function" &&
209
+ typeof logger.error === "function";
210
+ if (complete) return;
211
+ throw new Error(
212
+ "@12-apps/discounts: createApiDiscounts needs a logger with info/warn/error — " +
213
+ "the manifest declares an observability namespace, so a wiring host passes " +
214
+ 'assembled.loggers["@12-apps/discounts"]; there is no silent default.',
215
+ );
216
+ }
217
+
173
218
  /**
174
219
  * The five descriptors, config asserted at construction.
175
220
  *
176
- * Copy is checked HERE rather than at first request, because a missing
177
- * sentence is a wiring mistake and it should fail where the wiring is written
178
- * — the report-builder doctrine: required fields, no defaults, asserted at
179
- * assembly.
221
+ * Copy and the logger are checked HERE rather than at first request, because a
222
+ * missing sentence or a missing logger is a wiring mistake and it should fail
223
+ * where the wiring is written — the report-builder doctrine: required fields,
224
+ * no defaults, asserted at assembly. The logger earned that treatment the hard
225
+ * way: for the whole of 1.0.x the manifest promised these routes logged, the
226
+ * host built the namespaced logger, and nothing connected the two.
180
227
  */
181
228
  export function createApiDiscounts(config: DiscountsApiConfig): {
182
229
  routes: readonly DiscountRoute[];
@@ -188,6 +235,7 @@ export function createApiDiscounts(config: DiscountsApiConfig): {
188
235
  "every sentence this surface answers a human with is host config, with no defaults.",
189
236
  );
190
237
  }
238
+ assertLogger(config.logger);
191
239
  return {
192
240
  routes: [
193
241
  listRoute(config),