@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 +44 -3
- package/package.json +2 -2
- package/src/manifest/index.ts +7 -0
- package/src/server/index.ts +2 -0
- package/src/server/logging.ts +118 -0
- package/src/server/routes.ts +76 -28
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({
|
|
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: {
|
|
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.
|
|
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.
|
|
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",
|
package/src/manifest/index.ts
CHANGED
|
@@ -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"],
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/routes.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 }
|
|
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 }
|
|
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: (
|
|
140
|
-
|
|
141
|
-
|
|
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: (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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:
|
|
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
|
|
177
|
-
* sentence is a wiring mistake and it should fail
|
|
178
|
-
* — the report-builder doctrine: required fields,
|
|
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),
|