@riligar/contract 1.0.1 → 2.0.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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@riligar/contract",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
- "description": "O contrato de API da stack RiLiGar: envelope \u00fanico, cat\u00e1logo de c\u00f3digos de erro e os helpers que tornam imposs\u00edvel responder fora dele.",
6
+ "description": "The API contract of the RiLiGar stack: a single envelope, a catalogue of error codes, and the helpers that make it impossible to answer outside it.",
7
7
  "main": "src/index.js",
8
8
  "files": [
9
9
  "src",
package/src/codes.js CHANGED
@@ -1,74 +1,74 @@
1
1
  // ============================================================================
2
- // CÓDIGOS DE ERRO o vocabulário fechado da stack
2
+ // ERROR CODESthe stack's closed vocabulary
3
3
  // ============================================================================
4
4
  //
5
- // `code` é a parte do erro que o AGENTE lê. `message` é para o humano e pode
6
- // mudar de redação a qualquer momento; `code` não poderenomear um código é
7
- // quebra de contrato, e por isso ele vive aqui, num enum fechado, e não solto
8
- // em cada handler.
5
+ // `code` is the part of the error the AGENT reads. `message` is for the human
6
+ // and its wording may change at any time; `code` may notrenaming a code is
7
+ // a breaking change to the contract, and that is why it lives here, in a closed
8
+ // enum, instead of loose in each handler.
9
9
  //
10
- // Este vocabulário não foi inventado: é a consolidação dos `reason` que já
11
- // existiam espalhados pelos sete produtos (`plan_limit`, `has_resources`,
12
- // `taken`, `expired`, `duplicate_name`, `no_access`…). A stack já havia
13
- // convergido sozinha não tinha escrito, e por isso cada produto grafava
14
- // o mesmo conceito de um jeito.
10
+ // This vocabulary was not invented: it is the consolidation of the `reason`
11
+ // values that already existed scattered across the seven products
12
+ // (`plan_limit`, `has_resources`, `taken`, `expired`, `duplicate_name`,
13
+ // `no_access`…). The stack had already converged on its own it just had never
14
+ // written it down, and so each product spelled the same concept its own way.
15
15
  //
16
- // Adicionar um código é mudança deliberada: entra aqui, não no handler.
16
+ // Adding a code is a deliberate change: it goes here, not in the handler.
17
17
 
18
18
  export const CODES = {
19
- // ── Identidade e acesso ──────────────────────────────────────────────
20
- /** Sem credencial, ou credencial ilegível. O cliente não se identificou. */
19
+ // ── Identity and access ──────────────────────────────────────────────
20
+ /** No credential, or an unreadable one. The client did not identify itself. */
21
21
  UNAUTHORIZED: 'UNAUTHORIZED',
22
- /** Identificado, mas sem permissão para ESTE recurso. */
22
+ /** Identified, but without permission for THIS resource. */
23
23
  FORBIDDEN: 'FORBIDDEN',
24
- /** Token válido, emitido para OUTRO servidor (RFC 8707). */
24
+ /** Valid token, issued for ANOTHER server (RFC 8707). */
25
25
  WRONG_AUDIENCE: 'WRONG_AUDIENCE',
26
- /** A credencial não cobre a operaçãofalta escopo, não permissão. */
26
+ /** The credential does not cover the operation missing scope, not permission. */
27
27
  INSUFFICIENT_SCOPE: 'INSUFFICIENT_SCOPE',
28
28
 
29
- // ── O recurso ────────────────────────────────────────────────────────
30
- /** Não existe, ou não é visível para quem pergunta. */
29
+ // ── The resource ─────────────────────────────────────────────────────
30
+ /** Does not exist, or is not visible to whoever is asking. */
31
31
  NOT_FOUND: 'NOT_FOUND',
32
- /** existe outro com a mesma chave natural (nome, slug, e-mail). */
32
+ /** Another one already exists with the same natural key (name, slug, e-mail). */
33
33
  CONFLICT: 'CONFLICT',
34
- /** O nome pedido está tomado. É um CONFLICT com diagnóstico próprio. */
34
+ /** The requested name is taken. It is a CONFLICT with its own diagnosis. */
35
35
  NAME_TAKEN: 'NAME_TAKEN',
36
- /** Existiu e não vale maisversão de deploy, token, link de convite. */
36
+ /** It existed and no longer holds deploy version, token, invite link. */
37
37
  EXPIRED: 'EXPIRED',
38
- /** A remoção esbarra em dependentes que precisam sair antes. */
38
+ /** The removal runs into dependents that have to go first. */
39
39
  HAS_RESOURCES: 'HAS_RESOURCES',
40
40
 
41
- // ── O pedido ─────────────────────────────────────────────────────────
42
- /** Corpo, query ou path malformado. `details` carrega o que falhou. */
41
+ // ── The request ──────────────────────────────────────────────────────
42
+ /** Malformed body, query or path. `details` carries what failed. */
43
43
  VALIDATION_ERROR: 'VALIDATION_ERROR',
44
- /** Sintaticamente válido, mas impossível no estado atual do recurso. */
44
+ /** Syntactically valid, but impossible in the resource's current state. */
45
45
  UNPROCESSABLE: 'UNPROCESSABLE',
46
- /** Excedeu a janela de chamadas. `details.retryAfter` diz quando voltar. */
46
+ /** Exceeded the call window. `details.retryAfter` says when to come back. */
47
47
  RATE_LIMITED: 'RATE_LIMITED',
48
- /** Corpo maior que o teto aceito. */
48
+ /** Body larger than the accepted ceiling. */
49
49
  PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
50
50
 
51
- // ── Comercial ────────────────────────────────────────────────────────
52
- /** O plano atual não comporta. `details` traz current/limit/requiredPlan. */
51
+ // ── Commercial ───────────────────────────────────────────────────────
52
+ /** The current plan does not cover it. `details` brings current/limit/requiredPlan. */
53
53
  PLAN_LIMIT: 'PLAN_LIMIT',
54
- /** A assinatura do dono não está ativa. Bloqueia a operação inteira. */
54
+ /** The owner's subscription is not active. Blocks the whole operation. */
55
55
  SUBSCRIPTION_REQUIRED: 'SUBSCRIPTION_REQUIRED',
56
56
 
57
- // ── Nós ──────────────────────────────────────────────────────────────
58
- /** Falha inesperada. NUNCA carrega mensagem de exceção interna. */
57
+ // ── Us ───────────────────────────────────────────────────────────────
58
+ /** Unexpected failure. NEVER carries an internal exception message. */
59
59
  INTERNAL_ERROR: 'INTERNAL_ERROR',
60
- /** Dependência externa fora do ar. É transitório vale repetir. */
60
+ /** External dependency down. It is transientworth retrying. */
61
61
  SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
62
- /** Um provedor de terceiros recusou (gateway, SES, Cloudflare). */
62
+ /** A third-party provider refused (gateway, SES, Cloudflare). */
63
63
  UPSTREAM_ERROR: 'UPSTREAM_ERROR',
64
64
  }
65
65
 
66
66
  /**
67
- * O status HTTP de cada código.
67
+ * The HTTP status of each code.
68
68
  *
69
- * A tabela vive aqui para que status e código NUNCA discordem: um `NOT_FOUND`
70
- * com 200 é o defeito que esta migração existe para eliminar, e a única forma
71
- * de garantir isso é o chamador não escolher o número.
69
+ * The table lives here so that status and code NEVER disagree: a `NOT_FOUND`
70
+ * with a 200 is the defect this migration exists to eliminate, and the only way
71
+ * to guarantee that is for the caller not to pick the number.
72
72
  */
73
73
  export const STATUS_BY_CODE = {
74
74
  UNAUTHORIZED: 401,
@@ -95,43 +95,45 @@ export const STATUS_BY_CODE = {
95
95
  UPSTREAM_ERROR: 502,
96
96
  }
97
97
 
98
- /** Um erro transitório merece nova tentativa; os outros, não. */
98
+ /** A transient error deserves a retry; the others do not. */
99
99
  export const RETRIABLE = new Set([CODES.RATE_LIMITED, CODES.SERVICE_UNAVAILABLE, CODES.UPSTREAM_ERROR])
100
100
 
101
101
  /**
102
- * Os `reason` antigos, e o código que cada um virou.
102
+ * The old `reason` values, and the code each one became.
103
103
  *
104
- * POR QUE ISTO EXISTE, E POR QUE NÃO É DÍVIDA
104
+ * WHY THIS EXISTS, AND WHY IT IS NOT DEBT
105
105
  *
106
- * Antes deste pacote, cada produto tinha seu vocabulário de `reason` em
107
- * snake_case: `invalid_request`, `plan_limit`, `not_found`. São centenas de
108
- * chamadas espalhadas no Auth são 54. Trocá-las todas de uma vez seria
109
- * uma refatoração ampla, com risco real e zero ganho visível: o que o cliente
110
- * da API é o `code` da RESPOSTA, e ele já sai correto.
106
+ * Before this package, each product had its own vocabulary of `reason` values
107
+ * in snake_case: `invalid_request`, `plan_limit`, `not_found`. There are
108
+ * hundreds of calls scattered around in Auth alone there are 54. Swapping
109
+ * them all at once would be a sweeping refactor, with real risk and zero
110
+ * visible gain: what the API client reads is the `code` of the RESPONSE, and
111
+ * that already comes out correct.
111
112
  *
112
- * A tradução acontece na borda, uma vez, aqui. `falha('invalid_request')`
113
- * emite `VALIDATION_ERROR` — o handler continua legível na língua que
114
- * falava, e a resposta cumpre o contrato.
113
+ * The translation happens at the edge, once, here. `failure('invalid_request')`
114
+ * emits `VALIDATION_ERROR` — the handler stays readable in the language it
115
+ * already spoke, and the response honours the contract.
115
116
  *
116
- * O QUE ISTO NÃO É
117
+ * WHAT THIS IS NOT
117
118
  *
118
- * Não é um segundo vocabulário. Nada aqui é código válido de saída: o mapa só
119
- * traduz PARA `CODES`, nunca a partir dele. Código novo escreve
120
- * `CODES.VALIDATION_ERROR` direto; esta tabela cobre o que já existia.
119
+ * It is not a second vocabulary. Nothing here is a valid output code: the map
120
+ * only translates INTO `CODES`, never out of it. New code writes
121
+ * `CODES.VALIDATION_ERROR` directly; this table only covers what already
122
+ * existed.
121
123
  *
122
- * O mapa é fechado de propósito. Um `reason` que não estiver aqui cai em
123
- * `INTERNAL_ERROR` — barulhento, e é o certo: código desconhecido é bug de
124
- * quem chamou, não algo para adivinhar.
124
+ * The map is closed on purpose. A `reason` that is not here falls into
125
+ * `INTERNAL_ERROR` — loud, and that is the right call: an unknown code is a bug
126
+ * in whoever called, not something to guess at.
125
127
  */
126
128
  export const ALIASES = {
127
- // ── Validação ────────────────────────────────────────────────────────
129
+ // ── Validation ───────────────────────────────────────────────────────
128
130
  validation_error: CODES.VALIDATION_ERROR,
129
131
  invalid_request: CODES.VALIDATION_ERROR,
130
132
  invalid_email: CODES.VALIDATION_ERROR,
131
133
  bad_request: CODES.VALIDATION_ERROR,
132
134
  missing_field: CODES.VALIDATION_ERROR,
133
135
 
134
- // ── Identidade e acesso ──────────────────────────────────────────────
136
+ // ── Identity and access ──────────────────────────────────────────────
135
137
  unauthorized: CODES.UNAUTHORIZED,
136
138
  forbidden: CODES.FORBIDDEN,
137
139
  access_denied: CODES.FORBIDDEN,
@@ -140,7 +142,7 @@ export const ALIASES = {
140
142
  wrong_audience: CODES.WRONG_AUDIENCE,
141
143
  insufficient_scope: CODES.INSUFFICIENT_SCOPE,
142
144
 
143
- // ── Recurso ──────────────────────────────────────────────────────────
145
+ // ── Resource ─────────────────────────────────────────────────────────
144
146
  not_found: CODES.NOT_FOUND,
145
147
  conflict: CODES.CONFLICT,
146
148
  taken: CODES.NAME_TAKEN,
@@ -151,24 +153,24 @@ export const ALIASES = {
151
153
  has_resources: CODES.HAS_RESOURCES,
152
154
  organization_not_empty: CODES.HAS_RESOURCES,
153
155
 
154
- // ── Não processável ──────────────────────────────────────────────────
156
+ // ── Unprocessable ────────────────────────────────────────────────────
155
157
  unprocessable: CODES.UNPROCESSABLE,
156
158
  no_op: CODES.UNPROCESSABLE,
157
159
  last_owner: CODES.UNPROCESSABLE,
158
- // RFC 8628: o device flow espera o humano digitar o código. Não é erro de
159
- // cliente nem falha do servidoré "ainda não".
160
+ // RFC 8628: the device flow waits for the human to type the code. It is
161
+ // neither a client error nor a server failure it is "not yet".
160
162
  authorization_pending: CODES.UNPROCESSABLE,
161
163
 
162
- // ── Plano e cobrança ─────────────────────────────────────────────────
164
+ // ── Plan and billing ─────────────────────────────────────────────────
163
165
  plan_limit: CODES.PLAN_LIMIT,
164
166
  subscription_required: CODES.SUBSCRIPTION_REQUIRED,
165
167
 
166
- // ── Ritmo e tamanho ──────────────────────────────────────────────────
168
+ // ── Pace and size ────────────────────────────────────────────────────
167
169
  rate_limited: CODES.RATE_LIMITED,
168
170
  slow_down: CODES.RATE_LIMITED,
169
171
  payload_too_large: CODES.PAYLOAD_TOO_LARGE,
170
172
 
171
- // ── Falha nossa ou de terceiro ───────────────────────────────────────
173
+ // ── Our failure, or a third party's ──────────────────────────────────
172
174
  server_error: CODES.INTERNAL_ERROR,
173
175
  internal_error: CODES.INTERNAL_ERROR,
174
176
  service_unavailable: CODES.SERVICE_UNAVAILABLE,
@@ -176,22 +178,23 @@ export const ALIASES = {
176
178
  email_delivery_failed: CODES.UPSTREAM_ERROR,
177
179
  }
178
180
 
179
- export const isCode = valor => Object.prototype.hasOwnProperty.call(STATUS_BY_CODE, valor)
181
+ export const isCode = value => Object.prototype.hasOwnProperty.call(STATUS_BY_CODE, value)
180
182
 
181
183
  /**
182
- * O código canônico a partir do que o handler escreveu.
184
+ * The canonical code, derived from whatever the handler wrote.
183
185
  *
184
- * Aceita o código canônico (passa direto), um `reason` antigo (traduz), ou
185
- * qualquer outra coisa (`INTERNAL_ERROR`). É o único ponto da stack que decide
186
- * issoantes, cada produto tinha a sua tabela, e elas divergiam.
186
+ * Accepts the already canonical code (passes straight through), an old `reason`
187
+ * (translates it), or anything else (`INTERNAL_ERROR`). It is the only point in
188
+ * the stack that decides this before, each product had its own table, and they
189
+ * diverged.
187
190
  */
188
- export const toCode = valor => (isCode(valor) ? valor : ALIASES[valor] || CODES.INTERNAL_ERROR)
191
+ export const toCode = value => (isCode(value) ? value : ALIASES[value] || CODES.INTERNAL_ERROR)
189
192
 
190
193
  /**
191
- * O código a partir de um status HTTP.
194
+ * The code, derived from an HTTP status.
192
195
  *
193
- * Para os pontos que tinham o número na mãoum `throw { status: 401 }`
194
- * pego num catch, por exemploe não têm de onde tirar o código.
196
+ * For the places that already had the number at handa `throw { status: 401 }`
197
+ * caught in a catch, for exampleand have nowhere to get the code from.
195
198
  */
196
199
  export const STATUS_TO_CODE = {
197
200
  400: CODES.VALIDATION_ERROR,
package/src/index.js CHANGED
@@ -1,25 +1,25 @@
1
1
  // ============================================================================
2
- // @riligar/contract — o contrato de API dos sete produtos
2
+ // @riligar/contract — the API contract of the seven products
3
3
  // ============================================================================
4
4
  //
5
- // A norma escrita vive em `website/docs/2026-09-09-api-contract.md`. Este
6
- // pacote é a norma EXECUTÁVEL: o que os produtos importam para responder, e o
7
- // que os testes importam para conferir.
5
+ // The written norm lives in `website/docs/2026-09-09-api-contract.md`. This
6
+ // package is the EXECUTABLE norm: what the products import in order to respond,
7
+ // and what the tests import in order to check.
8
8
  //
9
- // A regra de ouro é a que este pacote torna mecânica: **um agente que aprendeu
10
- // um produto da RiLiGar sabe usar os outros seis.**
9
+ // The golden rule is the one this package makes mechanical: **an agent that has
10
+ // learned one RiLiGar product already knows how to use the other six.**
11
11
  //
12
- // POR QUE OS SCHEMAS NÃO SAEM DAQUI
12
+ // WHY THE SCHEMAS DO NOT COME OUT OF HERE
13
13
  //
14
- // `schemas.js` importa Zod, que é peer OPCIONAL um worker que precisa
15
- // responder não deve carregar um validador de 60 kB. Mas enquanto este arquivo
16
- // reexportava os schemas, o import era eager: `import { falha } from
17
- // '@riligar/contract'` puxava `schemas.js`, que puxava Zod, e a instalação
18
- // quebrava com "Cannot find package 'zod'" em quem seguiu a peer como
19
- // opcional. O peer opcional não era opcional.
14
+ // `schemas.js` imports Zod, which is an OPTIONAL peer — a worker that only needs
15
+ // to respond should not have to load a 60 kB validator. But as long as this file
16
+ // reexported the schemas, the import was eager: `import { failure } from
17
+ // '@riligar/contract'` pulled in `schemas.js`, which pulled in Zod, and the
18
+ // install broke with "Cannot find package 'zod'" for anyone who treated the peer
19
+ // as optional. The optional peer was not optional.
20
20
  //
21
- // Quem quer os schemas importa `@riligar/contract/schemas`, e Zod é
22
- // requisito legítimo daquele caminhodeclarado no export map, não escondido
23
- // numa cadeia de reexports.
21
+ // Whoever wants the schemas imports `@riligar/contract/schemas`, and there Zod
22
+ // is a legitimate requirement of that path declared in the export map, not
23
+ // hidden in a chain of reexports.
24
24
  export { CODES, STATUS_BY_CODE, RETRIABLE, isCode, toCode, codeFromStatus, ALIASES } from './codes.js'
25
- export { ok, criado, colecao, falha, falhaComStatus, corpoDeFalha, paginacao } from './respostas.js'
25
+ export { ok, created, collection, failure, failureWithStatus, failureBody, pagination, page } from './responses.js'
@@ -0,0 +1,189 @@
1
+ // ============================================================================
2
+ // RESPONSES — the only way out of the server
3
+ // ============================================================================
4
+ //
5
+ // Four functions. If a handler in any of the seven products builds a
6
+ // `Response` by hand, it has stepped outside the contract.
7
+ //
8
+ // The point of this file is not to save typing — it is to make the defect
9
+ // IMPOSSIBLE. `failure()` derives the status from the code: there is no
10
+ // signature that lets you return `NOT_FOUND` with a 200. That discordant pair
11
+ // was exactly what made an agent read 200, conclude success, and carry on with
12
+ // data that did not exist.
13
+ import { CODES, STATUS_BY_CODE, RETRIABLE, isCode, toCode, codeFromStatus } from './codes.js'
14
+
15
+ const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' }
16
+
17
+ const respond = (body, status, headers) => new Response(JSON.stringify(body), { status, headers: { ...JSON_HEADERS, ...headers } })
18
+
19
+ /**
20
+ * One resource, at the root.
21
+ *
22
+ * ok({ id: 'mon_a1', name: 'checkout' })
23
+ * → 200 {"id":"mon_a1","name":"checkout"}
24
+ *
25
+ * No `data`, no `success`, no `message`. The HTTP status already said it went
26
+ * well; repeating that in the body is noise the agent has to learn to ignore —
27
+ * once per product.
28
+ */
29
+ export const ok = (resource, { status = 200, headers } = {}) => respond(resource, status, headers)
30
+
31
+ /**
32
+ * A resource that has just been BORN.
33
+ *
34
+ * 201 is the creation signal, and nothing else. An upsert that updated returns
35
+ * `ok()`; the one that created returns `created()`. That is what lets the agent
36
+ * know whether its own call was the one that created — without having to
37
+ * compare timestamps.
38
+ */
39
+ export const created = (resource, { headers } = {}) => respond(resource, 201, headers)
40
+
41
+ /**
42
+ * A collection, always in the same shape.
43
+ *
44
+ * collection([a, b], { limit: 50, offset: 0, total: 128 })
45
+ * → {"items":[…],"page":{"limit":50,"offset":0,"total":128,"hasMore":true}}
46
+ *
47
+ * `items` is always an array — empty is a legitimate response and has the SAME
48
+ * shape as a full one. Pruning the empty list (which Payments used to do) broke
49
+ * on the very first client, because every test environment has seeded data.
50
+ *
51
+ * `hasMore` is explicit on purpose: the consumer should not have to compare
52
+ * `offset + limit` against `total` to know whether to keep paginating.
53
+ *
54
+ * ────────────────────────────────────────────────────────────────────────────
55
+ * `total: null` WHEN NOBODY COUNTED — and that is the whole point of this helper.
56
+ *
57
+ * Until 09/10 the defaults filled `page` from the array itself: a route that
58
+ * NEVER paginated returned `{limit: N, offset: 0, total: N, hasMore: false}`,
59
+ * byte for byte identical to a route that paginated and reached the end. Of the
60
+ * stack's 39 collection routes, 29 did not paginate and all 29 asserted
61
+ * `hasMore: false` — the very field that exists so the agent need not deduce
62
+ * was deducing wrong in 87% of cases.
63
+ *
64
+ * Now: whoever does not pass `total` gets `total: null` and `hasMore: null`.
65
+ * Null means "I don't know", and that differs from zero and from false. An
66
+ * agent reading `total: null` knows it must ask another way; one reading
67
+ * `hasMore: false` stops paginating — and those two conclusions must not come
68
+ * out of the same response.
69
+ *
70
+ * Whoever truly paginates passes `total` and gets `hasMore` computed. Whoever
71
+ * returns the whole list on purpose passes `total: items.length`, and then
72
+ * `hasMore: false` is an assertion, not a default.
73
+ */
74
+ export const collection = (list, page = {}, { status = 200, headers } = {}) => {
75
+ const items = Array.isArray(list) ? list : []
76
+ const limit = page.limit ?? items.length
77
+ const offset = page.offset ?? 0
78
+
79
+ // `null` only when NOBODY counted. `total: 0` is a legitimate count.
80
+ const total = page.total ?? null
81
+ const hasMore = page.hasMore ?? (total === null ? null : offset + items.length < total)
82
+
83
+ /*
84
+ * `extra` goes at the ROOT, not inside `page`. Hoster's `activeDeployId`
85
+ * and Monitors' `counts` are context about the SET, not about pagination —
86
+ * inside `page` they would claim to be pagination, and a generic client
87
+ * reading `page` to paginate would trip over them.
88
+ */
89
+ return respond({ items, page: { limit, offset, total, hasMore }, ...(page.extra ?? {}) }, status, headers)
90
+ }
91
+
92
+ /**
93
+ * A failure. The status comes from the CODE — the caller does not pick the number.
94
+ *
95
+ * failure(CODES.NOT_FOUND, 'Project not found.')
96
+ * → 404 {"error":{"code":"NOT_FOUND","message":"Project not found."}}
97
+ *
98
+ * `details` is for what the agent can ACT on: the limit that was exceeded, the
99
+ * field that was missing, how many seconds to wait. Never for a stack trace.
100
+ *
101
+ * An unknown code becomes INTERNAL_ERROR rather than a silent 500 with a
102
+ * malformed body: if someone wrote a code outside the enum, the bug is ours,
103
+ * and the response to the client stays well formed.
104
+ */
105
+ export const failure = (code, message, { details, headers, status } = {}) => {
106
+ const resolved = toCode(code)
107
+ const body = { error: { code: resolved, message: String(message ?? '') } }
108
+
109
+ if (details && Object.keys(details).length) body.error.details = details
110
+ if (RETRIABLE.has(resolved)) body.error.retriable = true
111
+
112
+ return respond(body, status ?? STATUS_BY_CODE[resolved], headers)
113
+ }
114
+
115
+ /**
116
+ * The error body, without the `Response` around it.
117
+ *
118
+ * For whoever answers through a framework that assembles the response itself —
119
+ * the Elysia of Storage and Functions, where the handler returns an object and
120
+ * the status travels via `set.status`. Returns `{ body, status }` so the two go
121
+ * together and there is no way to forget one.
122
+ */
123
+ export const failureBody = (code, message, details) => {
124
+ const resolved = toCode(code)
125
+ const error = { code: resolved, message: String(message ?? '') }
126
+
127
+ if (details && Object.keys(details).length) error.details = details
128
+ if (RETRIABLE.has(resolved)) error.retriable = true
129
+
130
+ return { body: { error }, status: STATUS_BY_CODE[resolved] }
131
+ }
132
+
133
+ /**
134
+ * Reads `limit`/`offset` from a URL, with a ceiling.
135
+ *
136
+ * A `limit` with no ceiling is a way to take the service down with one query; a
137
+ * ceiling with no `offset` is what made record 101 of Messages unreachable. The
138
+ * two travel together.
139
+ */
140
+ export const pagination = (url, { defaultLimit = 50, maxLimit = 200 } = {}) => {
141
+ const p = url instanceof URL ? url.searchParams : new URL(url, 'http://x').searchParams
142
+ const rawLimit = Number(p.get('limit'))
143
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), maxLimit) : defaultLimit
144
+ const rawOffset = Number(p.get('offset'))
145
+ const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0
146
+ return { limit, offset }
147
+ }
148
+
149
+ /**
150
+ * The page of a paginated query, ready for `collection`.
151
+ *
152
+ * It exists to make MECHANICAL what the stack got wrong by hand on two fronts:
153
+ *
154
+ * 1. **The reported `limit` must be the APPLIED one.** Two routes clamped in
155
+ * the data layer and reported the raw query value: with `?limit=5000` they
156
+ * returned 200 rows and said `page:{limit:5000, hasMore:false}`. The client
157
+ * concludes "I asked for 5000, got 200, therefore it's over" — and stops
158
+ * paginating halfway.
159
+ *
160
+ * 2. **`total` comes from whoever counted.** Passing `total` is mandatory here,
161
+ * because `collection` on its own has no way to tell whether nobody counted
162
+ * or the count happened to equal the array length.
163
+ *
164
+ * const { limit, offset } = pagination(url)
165
+ * const rows = await fetchRows(limit, offset)
166
+ * const [{ total }] = await count()
167
+ * return collection(rows, page({ limit, offset, total }))
168
+ */
169
+ export const page = ({ limit, offset = 0, total, extra }) => {
170
+ if (!Number.isFinite(total)) {
171
+ throw new TypeError('page() requires a numeric `total` — use collection(items) directly when nobody counted.')
172
+ }
173
+ return { limit, offset, total, extra }
174
+ }
175
+
176
+ /**
177
+ * A failure for which only the HTTP status is known.
178
+ *
179
+ * For the global error handler, where a `throw { status, message }` arrives
180
+ * from any layer and there is no code at hand. It derives the `code` from the
181
+ * number instead of always falling into `INTERNAL_ERROR` — a 401 that turns
182
+ * into a 500 at the edge hides the real problem from whoever is debugging.
183
+ *
184
+ * `status` is preserved: whoever already had the number keeps it, even when the
185
+ * canonical code maps to another (401 for `UNAUTHORIZED`, 402 for `PLAN_LIMIT`).
186
+ */
187
+ export const failureWithStatus = (status, message, details, headers) => failure(codeFromStatus(status), message, { details, headers, status })
188
+
189
+ export { CODES, STATUS_BY_CODE }
package/src/schemas.js CHANGED
@@ -1,18 +1,18 @@
1
1
  // ============================================================================
2
- // SCHEMAS — o contrato como coisa executável
2
+ // SCHEMAS — the contract as an executable thing
3
3
  // ============================================================================
4
4
  //
5
- // As mesmas regras de `respostas.js`, do lado de quem LÊ. Servem para o teste
6
- // de contrato afirmar a forma sem reimplementar a asserção em cada suíte, e
7
- // para um consumidor validar o que recebeu.
5
+ // The same rules as `responses.js`, from the READER's side. They let the
6
+ // contract test assert the shape without reimplementing the assertion in every
7
+ // suite, and let a consumer validate what it received.
8
8
  //
9
- // Zod é peer: os sete produtos o têm (v4), e embutir uma segunda cópia num
10
- // worker significa pagar o bundle duas vezes.
9
+ // Zod is a peer: the seven products already have it (v4), and embedding a
10
+ // second copy in a worker means paying the bundle cost twice.
11
11
  import { z } from 'zod'
12
12
 
13
13
  import { STATUS_BY_CODE } from './codes.js'
14
14
 
15
- /** O código é o enum fechado nada de string livre. */
15
+ /** The code is the closed enum — no free-form strings. */
16
16
  export const codeSchema = z.enum(Object.keys(STATUS_BY_CODE))
17
17
 
18
18
  export const errorSchema = z.object({
@@ -39,42 +39,43 @@ export const collectionSchema = item =>
39
39
  })
40
40
 
41
41
  /**
42
- * O recurso vai na raiz, então não envelope a validaro que se pode
43
- * afirmar é o que ele NÃO pode ser.
42
+ * The resource goes at the root, so there is no envelope to validatewhat can
43
+ * be asserted is what it must NOT be.
44
44
  *
45
- * As três chaves proibidas são exatamente os envelopes que a stack usava. Um
46
- * recurso que ainda tenha `data`, `success` ou `message` no topo é um produto
47
- * que não migrou e o teste de contrato precisa dizer isso com todas as
48
- * letras, em vez de passar porque "veio um objeto".
45
+ * The three forbidden keys are exactly the envelopes the stack used to use. A
46
+ * resource that still carries `data`, `success` or `message` at the top is a
47
+ * product that has not migrated and the contract test must say so in plain
48
+ * words, instead of passing because "an object came back".
49
49
  */
50
- export const resourceSchema = corpo =>
51
- (corpo ?? z.object({}).loose()).refine(v => !v || typeof v !== 'object' || (!('data' in v) && !('success' in v) && !('message' in v)), {
52
- message: 'recurso não pode vir envelopado em `data`, `success` ou `message`',
50
+ export const resourceSchema = body =>
51
+ (body ?? z.object({}).loose()).refine(v => !v || typeof v !== 'object' || (!('data' in v) && !('success' in v) && !('message' in v)), {
52
+ message: 'a resource must not be wrapped in `data`, `success` or `message`',
53
53
  })
54
54
 
55
55
  const CAMEL = /^[a-z][a-zA-Z0-9]*$/
56
56
 
57
57
  /**
58
- * Toda chave do corpo é camelCase, em qualquer profundidade.
58
+ * Every key in the body is camelCase, at any depth.
59
59
  *
60
- * O Storage devolvia `tenant_id` no corpo e `tenantId` no path da MESMA rota.
61
- * Um consumidor precisava saber, campo a campo, qual vocabulário usar.
60
+ * Storage used to return `tenant_id` in the body and `tenantId` in the path of
61
+ * the SAME route. A consumer had to know, field by field, which vocabulary to
62
+ * use.
62
63
  *
63
- * Chaves que são DADO do usuário (o nome de uma coleção, de uma variável de
64
- * ambiente, de um cabeçalho) não seguem a regranão são contrato nosso.
65
- * Por isso `ignorar`.
64
+ * Keys that are user DATA (the name of a collection, of an environment
65
+ * variable, of a header) do not follow the rule they are not our contract.
66
+ * Hence `ignore`.
66
67
  */
67
- export const chavesCamelCase = (valor, { ignorar = [] } = {}, caminho = '') => {
68
- const fora = []
69
- const anda = (v, path) => {
70
- if (Array.isArray(v)) return v.forEach((x, i) => anda(x, `${path}[${i}]`))
68
+ export const camelCaseKeys = (value, { ignore = [] } = {}, path = '') => {
69
+ const offenders = []
70
+ const walk = (v, path) => {
71
+ if (Array.isArray(v)) return v.forEach((x, i) => walk(x, `${path}[${i}]`))
71
72
  if (!v || typeof v !== 'object') return
72
73
  for (const [k, sub] of Object.entries(v)) {
73
- const aqui = path ? `${path}.${k}` : k
74
- if (!ignorar.includes(k) && !CAMEL.test(k)) fora.push(aqui)
75
- anda(sub, aqui)
74
+ const here = path ? `${path}.${k}` : k
75
+ if (!ignore.includes(k) && !CAMEL.test(k)) offenders.push(here)
76
+ walk(sub, here)
76
77
  }
77
78
  }
78
- anda(valor, caminho)
79
- return fora
79
+ walk(value, path)
80
+ return offenders
80
81
  }
package/src/respostas.js DELETED
@@ -1,135 +0,0 @@
1
- // ============================================================================
2
- // RESPOSTAS — a única forma de sair do servidor
3
- // ============================================================================
4
- //
5
- // Quatro funções. Se um handler dos sete produtos constrói `Response` na mão,
6
- // é porque saiu do contrato.
7
- //
8
- // O ponto do arquivo não é economizar digitação — é tornar o defeito
9
- // IMPOSSÍVEL. `falha()` deriva o status do código: não existe assinatura que
10
- // permita devolver `NOT_FOUND` com 200. Era exatamente esse par discordante
11
- // que fazia o agente ler 200, concluir sucesso e seguir com dado inexistente.
12
- import { CODES, STATUS_BY_CODE, RETRIABLE, isCode, toCode, codeFromStatus } from './codes.js'
13
-
14
- const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' }
15
-
16
- const responder = (corpo, status, headers) => new Response(JSON.stringify(corpo), { status, headers: { ...JSON_HEADERS, ...headers } })
17
-
18
- /**
19
- * Um recurso, na raiz.
20
- *
21
- * ok({ id: 'mon_a1', name: 'checkout' })
22
- * → 200 {"id":"mon_a1","name":"checkout"}
23
- *
24
- * Sem `data`, sem `success`, sem `message`. O status HTTP já disse que deu
25
- * certo; repetir isso no corpo é ruído que o agente precisa aprender a
26
- * ignorar — por produto.
27
- */
28
- export const ok = (recurso, { status = 200, headers } = {}) => responder(recurso, status, headers)
29
-
30
- /**
31
- * Um recurso que acabou de NASCER.
32
- *
33
- * 201 é o sinal de criação, e só. Um upsert que atualizou devolve `ok()`;
34
- * quem criou devolve `criado()`. É o que permite ao agente saber se a
35
- * chamada dele foi a que criou — sem precisar comparar timestamps.
36
- */
37
- export const criado = (recurso, { headers } = {}) => responder(recurso, 201, headers)
38
-
39
- /**
40
- * Uma coleção, sempre com a mesma forma.
41
- *
42
- * colecao([a, b], { limit: 50, offset: 0, total: 128 })
43
- * → {"items":[…],"page":{"limit":50,"offset":0,"total":128,"hasMore":true}}
44
- *
45
- * `items` é sempre um array — vazio é uma resposta legítima e tem a MESMA
46
- * forma da cheia. A poda de lista vazia (que o Payments fazia) quebrava só no
47
- * primeiro cliente, porque todo ambiente de teste tem dado semeado.
48
- *
49
- * `hasMore` é explícito de propósito: o consumidor não deveria precisar
50
- * comparar `offset + limit` com `total` para saber se continua paginando.
51
- * Quando `total` não é conhecido (contar custaria uma query a mais em coisa
52
- * que ninguém pagina), `hasMore` sai do tamanho da página cheia.
53
- */
54
- export const colecao = (itens, pagina = {}, { status = 200, headers } = {}) => {
55
- const items = Array.isArray(itens) ? itens : []
56
- const limit = pagina.limit ?? items.length
57
- const offset = pagina.offset ?? 0
58
- const total = pagina.total ?? offset + items.length
59
- const hasMore = pagina.hasMore ?? offset + items.length < total
60
-
61
- return responder({ items, page: { limit, offset, total, hasMore } }, status, headers)
62
- }
63
-
64
- /**
65
- * Uma falha. O status vem do CÓDIGO — quem chama não escolhe o número.
66
- *
67
- * falha(CODES.NOT_FOUND, 'Projeto não encontrado.')
68
- * → 404 {"error":{"code":"NOT_FOUND","message":"Projeto não encontrado."}}
69
- *
70
- * `details` é para o que o agente pode AGIR: o limite que estourou, o campo
71
- * que faltou, quantos segundos esperar. Nunca para stack trace.
72
- *
73
- * Um código desconhecido vira INTERNAL_ERROR em vez de 500 silencioso com
74
- * corpo torto: se alguém escreveu um código fora do enum, o bug é nosso, e a
75
- * resposta ao cliente continua bem formada.
76
- */
77
- export const falha = (code, message, { details, headers, status } = {}) => {
78
- const codigo = toCode(code)
79
- const corpo = { error: { code: codigo, message: String(message ?? '') } }
80
-
81
- if (details && Object.keys(details).length) corpo.error.details = details
82
- if (RETRIABLE.has(codigo)) corpo.error.retriable = true
83
-
84
- return responder(corpo, status ?? STATUS_BY_CODE[codigo], headers)
85
- }
86
-
87
- /**
88
- * O corpo do erro, sem a `Response` em volta.
89
- *
90
- * Para quem responde por um framework que monta a resposta sozinho — o Elysia
91
- * do Storage e do Functions, onde o handler devolve objeto e o status vai por
92
- * `set.status`. Devolve `{ corpo, status }` para os dois irem juntos e não
93
- * haver como esquecer um.
94
- */
95
- export const corpoDeFalha = (code, message, details) => {
96
- const codigo = toCode(code)
97
- const error = { code: codigo, message: String(message ?? '') }
98
-
99
- if (details && Object.keys(details).length) error.details = details
100
- if (RETRIABLE.has(codigo)) error.retriable = true
101
-
102
- return { corpo: { error }, status: STATUS_BY_CODE[codigo] }
103
- }
104
-
105
- /**
106
- * Lê `limit`/`offset` de uma URL, com teto.
107
- *
108
- * Um `limit` sem teto é um jeito de derrubar o serviço com uma query; um teto
109
- * sem `offset` é o que tornava o registro 101 do Messages inalcançável. Os
110
- * dois andam juntos.
111
- */
112
- export const paginacao = (url, { limitPadrao = 50, limitMaximo = 200 } = {}) => {
113
- const p = url instanceof URL ? url.searchParams : new URL(url, 'http://x').searchParams
114
- const bruto = Number(p.get('limit'))
115
- const limit = Number.isFinite(bruto) && bruto > 0 ? Math.min(Math.floor(bruto), limitMaximo) : limitPadrao
116
- const off = Number(p.get('offset'))
117
- const offset = Number.isFinite(off) && off > 0 ? Math.floor(off) : 0
118
- return { limit, offset }
119
- }
120
-
121
- /**
122
- * Uma falha de que só se sabe o status HTTP.
123
- *
124
- * Para o handler global de erro, onde chega um `throw { status, message }` de
125
- * qualquer camada e não há código na mão. Deriva o `code` do número em vez de
126
- * cair sempre em `INTERNAL_ERROR` — um 401 que vira 500 na borda esconde o
127
- * problema real de quem depura.
128
- *
129
- * `status` é preservado: quem já tinha o número continua com ele, mesmo quando
130
- * o código canônico mapeia para outro (401 de `UNAUTHORIZED`, 402 de
131
- * `PLAN_LIMIT`).
132
- */
133
- export const falhaComStatus = (status, message, details, headers) => falha(codeFromStatus(status), message, { details, headers, status })
134
-
135
- export { CODES, STATUS_BY_CODE }