@starci/hfs 4.11.13 → 4.11.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/db/migrate.mjs +1 -1
  3. package/db/postgres.mjs +38 -3
  4. package/package.json +1 -1
  5. package/release/index.mjs +4 -3
  6. package/release/lint-baseline.mjs +123 -0
  7. package/release/urgent.mjs +19 -4
  8. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  9. package/runtime/knowledge/hfs/rules.yaml +4 -4
  10. package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
  11. package/runtime/scripts/hfs/architecture/identity.mjs +1 -1
  12. package/runtime/scripts/hfs/architecture/jwt-ast.mjs +14 -9
  13. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
  14. package/scaffold/add-compose.mjs +3 -0
  15. package/scaffold/add-inbox-lite.mjs +45 -23
  16. package/scaffold/lite-exports.mjs +13 -0
  17. package/src/main.mjs +2 -2
  18. package/stack/cli.mjs +5 -3
  19. package/stack/deploy.mjs +6 -2
  20. package/stack/urgent-floor.mjs +101 -7
  21. package/templates/be/inbox/entities.index.ts.tpl +4 -0
  22. package/templates/be/inbox/entity.ts.tpl +29 -0
  23. package/templates/be/inbox/index.ts.tpl +1 -0
  24. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
  25. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
  26. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
  27. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
  28. package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
  29. package/templates/be/inbox/service.spec.ts.tpl +39 -15
  30. package/templates/be/inbox/service.ts.tpl +13 -13
  31. package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
  32. package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +18 -8
  33. package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +12 -3
  34. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +39 -17
  35. package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
  36. package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
  37. package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
  38. package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
  39. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
  40. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -0
  41. package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +8 -1
  42. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +121 -9
  43. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +12 -6
  44. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
  45. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +3 -3
  46. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
  47. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  48. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
  49. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
  50. package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
  51. package/templates/be/inbox/database-client.ts.tpl +0 -45
@@ -1,9 +1,17 @@
1
1
  import { Injectable } from "@nestjs/common"
2
- import type { EntityManager } from "typeorm"
2
+ import type { EntityManager, ObjectLiteral } from "typeorm"
3
3
  import { InjectClock } from "@modules/platform/clock"
4
4
  import type { Clock } from "@modules/platform/clock"
5
- import { BATCH_ROWS, DatabaseError, DatabaseErrorCode, callDbFunction } from "@modules/platform/database"
6
- import type { ClientOperation, ClientOperationEffect, Inbox, InboxEventId } from "./inbox.port"
5
+ import { BATCH_ROWS, DatabaseError, DatabaseErrorCode, callDbFunction, returnedRows, rowShape } from "@modules/platform/database"
6
+ import type {
7
+ ClientOperation,
8
+ ClientOperationEffect,
9
+ EntityInbox,
10
+ Inbox,
11
+ InboxEntityReceipt,
12
+ InboxEventId,
13
+ InboxReceiptId,
14
+ } from "./inbox.port"
7
15
  import { IdempotencyError, IdempotencyErrorCode } from "@modules/platform/idempotency"
8
16
  import { InboxError, InboxErrorCode } from "./errors/inbox.error"
9
17
  import { clientSource } from "./client-operation.policy"
@@ -43,19 +51,43 @@ interface InboxSweepRow {
43
51
  readonly deleted_count: number
44
52
  }
45
53
 
54
+ /** The row a receipt insert returns to the transaction that won it. */
55
+ const isReceiptRow = rowShape({ id: "string" })
56
+
57
+ /** The provider's delivery id, trimmed; a missing or blank one is refused before anything is claimed or written. */
58
+ const deliveryIdOf = (eventId: InboxEventId): string => {
59
+ const deliveryId = eventId?.trim()
60
+ if (!deliveryId) throw new DatabaseError({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
61
+ return deliveryId
62
+ }
63
+
46
64
  @Injectable()
47
65
  /** Claims a delivery with the receipt transaction, so a failure can be retried without losing the delivery. */
48
- export class PostgresInbox implements Inbox {
66
+ export class PostgresInbox implements Inbox, EntityInbox {
49
67
  constructor(@InjectClock() private readonly clock: Clock) {}
50
68
 
51
69
  /** The first claim wins; the same source and delivery id becomes a no-op on redelivery. */
52
70
  async claim(source: string, eventId: InboxEventId, tx: EntityManager): Promise<boolean> {
53
- const normalizedEventId = eventId?.trim()
54
- if (!normalizedEventId) {
55
- throw new DatabaseError({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
56
- }
57
- const rows = await callDbFunction<InboxEventRow>(tx, "public.inbox_claim_event", [source, normalizedEventId])
58
- return rows.length > 0
71
+ return this.claimDelivery(source, deliveryIdOf(eventId), tx)
72
+ }
73
+
74
+ /** The claim first; only its winner inserts the receipt row, and a row that already exists is left as it is. */
75
+ async claimRow<Row extends ObjectLiteral>(
76
+ source: string,
77
+ eventId: InboxEventId,
78
+ tx: EntityManager,
79
+ receipt: InboxEntityReceipt<Row>,
80
+ ): Promise<InboxReceiptId> {
81
+ const deliveryId = deliveryIdOf(eventId)
82
+ if (!(await this.claimDelivery(source, deliveryId, tx))) return undefined
83
+ const inserted = await tx
84
+ .createQueryBuilder(receipt.entity, "receipt")
85
+ .insert()
86
+ .values(receipt.row(deliveryId))
87
+ .orIgnore()
88
+ .returning("id")
89
+ .execute()
90
+ return returnedRows(inserted.raw, isReceiptRow)[0]?.id
59
91
  }
60
92
 
61
93
  /** Runs once in the domain transaction, returning the persisted JSON outcome on both the first call and every retry. */
@@ -99,4 +131,10 @@ export class PostgresInbox implements Inbox {
99
131
  const rows = await callDbFunction<InboxSweepRow>(tx, "public.inbox_sweep_clients", [cutoff, BATCH_ROWS])
100
132
  return rows[0]?.deleted_count ?? 0
101
133
  }
134
+
135
+ /** Claims a normalized delivery id; an answered row means this transaction holds the first claim. */
136
+ private async claimDelivery(source: string, deliveryId: string, tx: EntityManager): Promise<boolean> {
137
+ const rows = await callDbFunction<InboxEventRow>(tx, "public.inbox_claim_event", [source, deliveryId])
138
+ return rows.length > 0
139
+ }
102
140
  }
@@ -1,5 +1,5 @@
1
- export { INBOX, InjectInbox } from "./inbox.decorators"
1
+ export { INBOX, InjectEntityInbox, InjectInbox } from "./inbox.decorators"
2
2
  export { InboxModule } from "./inbox.module"
3
- export type { Inbox, InboxEventId } from "./inbox.port"
3
+ export type { EntityInbox, Inbox, InboxEntityReceipt, InboxEventId } from "./inbox.port"
4
4
  export { INBOX_ERROR_KINDS } from "./errors/inbox.error"
5
5
  export { INBOX_MESSAGES } from "./messages/inbox.messages"
@@ -1,19 +1,19 @@
1
1
  import { fakeInbox, fakeTransaction, mockEntityManager, recordingEventBus } from "@starci/jest-preset"
2
2
  import { Test } from "@nestjs/testing"
3
- import { PRIMARY_ENTITY_MANAGER, webhookReceiptInsert } from "@modules/platform/database"
3
+ import { DatabaseErrorCode, PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
4
4
  import { @@Provider@@DeliveryRecordedEvent } from "@modules/events/@@provider@@"
5
5
  import { EVENT_BUS } from "@modules/platform/event-bus"
6
6
  import { INBOX } from "@modules/platform/inbox"
7
+ import { @@Provider@@InboxEntity } from "./persistence/entities/@@provider@@-inbox.entity"
7
8
  import { @@Provider@@InboxService } from "./@@provider@@-inbox.service"
8
9
 
9
10
  const DELIVERY = { id: "delivery-1" }
10
- const INSERT_@@providerUpper@@_INBOX = webhookReceiptInsert("@@table@@")
11
+ const SOURCE = "webhook.@@provider@@"
11
12
 
12
- const build = async (fresh: boolean, recorded: ReadonlyArray<{ readonly id: string }> = [{ id: "inbox-1" }]) => {
13
- const entityManager = mockEntityManager({ query: [INSERT_@@providerUpper@@_INBOX, recorded] })
13
+ const build = async () => {
14
+ const entityManager = mockEntityManager()
14
15
  const transaction = fakeTransaction(entityManager)
15
16
  const claims = fakeInbox()
16
- if (!fresh) claims.seen("webhook.@@provider@@", "delivery-1")
17
17
  const events = recordingEventBus<@@Provider@@DeliveryRecordedEvent>()
18
18
  const moduleRef = await Test.createTestingModule({
19
19
  providers: [
@@ -28,41 +28,65 @@ const build = async (fresh: boolean, recorded: ReadonlyArray<{ readonly id: stri
28
28
 
29
29
  describe("@@Provider@@InboxService", () => {
30
30
  describe("record@@Provider@@Delivery", () => {
31
- it("claims, records and publishes a typed event in the same transaction", async () => {
32
- const { inbox, claims, events, entityManager, transaction } = await build(true)
31
+ it("claims, records the receipt row and publishes a typed event in the same transaction", async () => {
32
+ const { inbox, claims, events, entityManager, transaction } = await build()
33
33
 
34
34
  await inbox.record@@Provider@@Delivery({ eventId: "delivery-1", delivery: DELIVERY, correlationId: "request-1" })
35
35
 
36
- expect(entityManager.query).toHaveBeenCalledTimes(1)
37
- expect(entityManager.query).toHaveBeenCalledWith(INSERT_@@providerUpper@@_INBOX, ["@@provider@@", "delivery-1", JSON.stringify(DELIVERY)])
38
- expect(claims.claims).toEqual([{ source: "webhook.@@provider@@", eventId: "delivery-1" }])
39
- expect(events.events).toEqual([@@Provider@@DeliveryRecordedEvent.create({ receiptId: "inbox-1" })])
36
+ expect(claims.claims).toEqual([{ source: SOURCE, eventId: "delivery-1" }])
37
+ expect(claims.receipts).toEqual([
38
+ {
39
+ id: "receipt-1",
40
+ source: SOURCE,
41
+ eventId: "delivery-1",
42
+ entity: @@Provider@@InboxEntity,
43
+ row: { provider: "@@provider@@", deliveryId: "delivery-1", payload: DELIVERY },
44
+ tx: events.entries[0]?.tx,
45
+ inTransaction: true,
46
+ },
47
+ ])
48
+ expect(events.events).toEqual([@@Provider@@DeliveryRecordedEvent.create({ receiptId: "receipt-1" })])
40
49
  expect(events.allInTransaction).toBe(true)
41
50
  expect(events.entries[0]?.tx).not.toBe(entityManager)
42
51
  expect(transaction.commits).toBe(1)
43
52
  })
44
53
 
45
54
  it("acknowledges a duplicate @@provider@@ delivery without further work", async () => {
46
- const { inbox, events, entityManager, transaction } = await build(false)
55
+ const { inbox, claims, events, transaction } = await build()
56
+ claims.seen(SOURCE, "delivery-1")
47
57
 
48
58
  await inbox.record@@Provider@@Delivery({ eventId: "delivery-1", delivery: DELIVERY, correlationId: "request-1" })
49
59
 
50
- expect(entityManager.query).not.toHaveBeenCalled()
60
+ expect(claims.receipts).toEqual([])
51
61
  expect(events.events).toEqual([])
52
62
  expect(transaction.commits).toBe(1)
53
63
  })
54
64
 
55
65
  it("does not publish for an already recorded receipt", async () => {
56
- const { inbox, events, transaction } = await build(true, [])
66
+ const { inbox, claims, events, transaction } = await build()
67
+ claims.receiptExists(SOURCE, "delivery-1")
57
68
 
58
69
  await inbox.record@@Provider@@Delivery({ eventId: "delivery-1", delivery: DELIVERY, correlationId: "request-1" })
59
70
 
71
+ expect(claims.receipts).toEqual([])
60
72
  expect(events.events).toEqual([])
61
73
  expect(transaction.commits).toBe(1)
62
74
  })
63
75
 
76
+ it("refuses a delivery without an id before recording anything", async () => {
77
+ const { inbox, claims, events, transaction } = await build()
78
+
79
+ await expect(inbox.record@@Provider@@Delivery({ eventId: undefined, delivery: DELIVERY, correlationId: "request-1" })).rejects.toMatchObject({
80
+ code: DatabaseErrorCode.WebhookDeliveryIdRequired,
81
+ })
82
+
83
+ expect(claims.receipts).toEqual([])
84
+ expect(events.events).toEqual([])
85
+ expect(transaction.rollbacks).toBe(1)
86
+ })
87
+
64
88
  it("rolls back the receipt when publishing fails", async () => {
65
- const { inbox, events, transaction } = await build(true)
89
+ const { inbox, events, transaction } = await build()
66
90
  const failure = new Error("event bus unavailable")
67
91
  events.failNext("publish", failure)
68
92
 
@@ -1,11 +1,12 @@
1
1
  import { Injectable } from "@nestjs/common"
2
2
  import type { EntityManager } from "typeorm"
3
- import { InjectPrimaryEntityManager, recordWebhookDelivery } from "@modules/platform/database"
3
+ import { InjectPrimaryEntityManager } from "@modules/platform/database"
4
4
  import { @@Provider@@DeliveryRecordedEvent } from "@modules/events/@@provider@@"
5
5
  import { InjectEventBus } from "@modules/platform/event-bus"
6
6
  import type { EventBus } from "@modules/platform/event-bus"
7
- import { InjectInbox } from "@modules/platform/inbox"
8
- import type { Inbox } from "@modules/platform/inbox"
7
+ import { InjectEntityInbox } from "@modules/platform/inbox"
8
+ import type { EntityInbox, InboxEntityReceipt } from "@modules/platform/inbox"
9
+ import { @@Provider@@InboxEntity } from "./persistence/entities/@@provider@@-inbox.entity"
9
10
 
10
11
  interface @@Provider@@Delivery {
11
12
  readonly id: string
@@ -23,21 +24,20 @@ interface @@Provider@@DeliveryRequest {
23
24
  export class @@Provider@@InboxService {
24
25
  constructor(
25
26
  @InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
26
- @InjectInbox() private readonly inbox: Inbox,
27
+ @InjectEntityInbox() private readonly inbox: EntityInbox,
27
28
  @InjectEventBus() private readonly eventBus: EventBus,
28
29
  ) {}
29
30
 
30
- /** A duplicate is acknowledged without creating another pending row; this intake never dispatches a command. */
31
+ /**
32
+ * The claim and the pending receipt row commit together: the winning claim inserts the row keyed by the delivery id the
33
+ * claim normalized; a duplicate is acknowledged without another pending row, and this intake never dispatches a command.
34
+ */
31
35
  record@@Provider@@Delivery(request: @@Provider@@DeliveryRequest): Promise<void> {
32
36
  return this.entityManager.transaction(async (manager) => {
33
- if (!(await this.inbox.claim("webhook.@@provider@@", request.eventId, manager))) return
34
- const receiptId = await recordWebhookDelivery({
35
- manager,
36
- table: "@@table@@",
37
- provider: "@@provider@@",
38
- deliveryId: request.eventId,
39
- payload: request.delivery,
40
- })
37
+ const receiptId = await this.inbox.claimRow("webhook.@@provider@@", request.eventId, manager, {
38
+ entity: @@Provider@@InboxEntity,
39
+ row: (deliveryId) => ({ provider: "@@provider@@", deliveryId, payload: request.delivery }),
40
+ } satisfies InboxEntityReceipt<@@Provider@@InboxEntity>)
41
41
  if (receiptId === undefined) return
42
42
  await this.eventBus.publish(@@Provider@@DeliveryRecordedEvent.create({ receiptId }), manager, request.correlationId)
43
43
  })
@@ -5,7 +5,7 @@ import { Test } from "@nestjs/testing"
5
5
  import type { Request } from "express"
6
6
  import { @@Provider@@InboxService } from "@modules/domain/@@provider@@-inbox"
7
7
  import { Secret } from "@modules/platform/config"
8
- import { HttpSecurityModule, WebhookSignatureService } from "@modules/platform/http-security"
8
+ import { HttpSecurityModule, ReadinessService, WebhookSignatureService } from "@modules/platform/http-security"
9
9
  import type { HttpSecurityOptions } from "@modules/platform/http-security"
10
10
  import type { CorrelationRequest } from "@modules/platform/logging"
11
11
  import { @@Event@@Request } from "./dto/@@event@@.request"
@@ -46,6 +46,9 @@ const build = async (deliveries: @@Provider@@InboxService) => {
46
46
  })
47
47
  .overrideProvider(WebhookSignatureService)
48
48
  .useValue(new WebhookSignatureService(OPTIONS, new FakeClock(AT)))
49
+ // The module also provides the readiness probe, which needs the database and retry timer this door never reaches.
50
+ .overrideProvider(ReadinessService)
51
+ .useValue(mock<ReadinessService>())
49
52
  .compile()
50
53
  return moduleRef.get(@@Provider@@Webhook)
51
54
  }
@@ -1,13 +1,23 @@
1
+ import { Test } from "@nestjs/testing"
1
2
  import { mock } from "@starci/jest-preset"
2
3
  import { GraphQLError } from "graphql"
4
+ import { ERRORS_SERVICE } from "./errors.decorators"
3
5
  import type { ErrorsService } from "./errors.service"
4
6
  import { formatGraphqlError } from "./graphql-error.mapper"
5
7
 
6
8
  const formatted = { message: "raw", path: ["item"], locations: [{ line: 1, column: 3 }] }
7
9
 
10
+ const build = async () => {
11
+ const double = mock<ErrorsService>()
12
+ const moduleRef = await Test.createTestingModule({
13
+ providers: [{ provide: ERRORS_SERVICE, useValue: double }],
14
+ }).compile()
15
+ return moduleRef.get<typeof double>(ERRORS_SERVICE)
16
+ }
17
+
8
18
  describe("formatGraphqlError", () => {
9
- it("keeps the message of a malformed operation and marks it invalid without describing it", () => {
10
- const errors = mock<ErrorsService>()
19
+ it("keeps the message of a malformed operation and marks it invalid without describing it", async () => {
20
+ const errors = await build()
11
21
 
12
22
  expect(formatGraphqlError(errors, formatted, new GraphQLError("GRAPHQL_VALIDATION_FAILED"))).toEqual({
13
23
  message: "raw",
@@ -18,8 +28,8 @@ describe("formatGraphqlError", () => {
18
28
  expect(errors.describe).not.toHaveBeenCalled()
19
29
  })
20
30
 
21
- it("describes the original failure of a resolver and travels its code, kind and params", () => {
22
- const errors = mock<ErrorsService>()
31
+ it("describes the original failure of a resolver and travels its code, kind and params", async () => {
32
+ const errors = await build()
23
33
  const cause = new Error("not found")
24
34
  errors.describe.mockReturnValue({
25
35
  code: "ITEM_NOT_FOUND",
@@ -37,8 +47,8 @@ describe("formatGraphqlError", () => {
37
47
  expect(errors.describe).toHaveBeenCalledWith(cause)
38
48
  })
39
49
 
40
- it("describes a failure that is not a GraphQL error as it is", () => {
41
- const errors = mock<ErrorsService>()
50
+ it("describes a failure that is not a GraphQL error as it is", async () => {
51
+ const errors = await build()
42
52
  const cause = new Error("boom")
43
53
  errors.describe.mockReturnValue({ code: "ERRORS_INTERNAL", kind: "internal", status: 500, params: {} })
44
54
 
@@ -50,9 +60,9 @@ describe("formatGraphqlError", () => {
50
60
  expect(errors.describe).toHaveBeenCalledWith(cause)
51
61
  })
52
62
 
53
- it("leaves out the locations and path a formatted error does not carry", () => {
63
+ it("leaves out the locations and path a formatted error does not carry", async () => {
54
64
  const subject = formatGraphqlError
55
- const errors = mock<ErrorsService>()
65
+ const errors = await build()
56
66
  errors.describe.mockReturnValue({ code: "ERRORS_INTERNAL", kind: "internal", status: 500, params: {} })
57
67
 
58
68
  expect(subject(errors, { message: "raw" }, new GraphQLError("GRAPHQL_PARSE_FAILED"))).toEqual({
@@ -1,3 +1,4 @@
1
+ import { Test } from "@nestjs/testing"
1
2
  import { buildSchema, parse, validate } from "graphql"
2
3
  import { depthLimitRule } from "./graphql-depth.policy"
3
4
 
@@ -12,10 +13,17 @@ const schema = buildSchema(`
12
13
  }
13
14
  `)
14
15
 
15
- const validateDepth = (source: string, maxDepth = 2) => validate(schema, parse(source), [depthLimitRule(maxDepth)])
16
+ const DEPTH_LIMIT = Symbol("spec.depth-limit")
17
+
18
+ const build = async () => {
19
+ const moduleRef = await Test.createTestingModule({ providers: [{ provide: DEPTH_LIMIT, useValue: 2 }] }).compile()
20
+ const maxDepth = moduleRef.get<number>(DEPTH_LIMIT)
21
+ return (source: string) => validate(schema, parse(source), [depthLimitRule(maxDepth)])
22
+ }
16
23
 
17
24
  describe("depthLimitRule", () => {
18
- it("accepts an operation nested exactly to the depth limit", () => {
25
+ it("accepts an operation nested exactly to the depth limit", async () => {
26
+ const validateDepth = await build()
19
27
  const errors = validateDepth(`
20
28
  query {
21
29
  item {
@@ -27,7 +35,8 @@ describe("depthLimitRule", () => {
27
35
  expect(errors).toEqual([])
28
36
  })
29
37
 
30
- it("rejects the first selection set deeper than the limit", () => {
38
+ it("rejects the first selection set deeper than the limit", async () => {
39
+ const validateDepth = await build()
31
40
  const errors = validateDepth(`
32
41
  query {
33
42
  item {
@@ -1,12 +1,36 @@
1
1
  import type { GraphQLRequestContext, GraphQLRequestContextWillSendResponse, GraphQLResponse } from "@apollo/server"
2
+ import { Test } from "@nestjs/testing"
2
3
  import { mock } from "@starci/jest-preset"
3
4
  import type { Request } from "express"
4
5
  import type { GraphQLFormattedError } from "graphql"
6
+ import { ERRORS_SERVICE } from "@modules/platform/errors"
5
7
  import type { ErrorsService } from "@modules/platform/errors"
8
+ import { REQUEST_LOCALE } from "@modules/platform/i18n"
6
9
  import type { RequestLocale } from "@modules/platform/i18n"
7
10
  import type { GraphqlContext } from "./graphql.contracts"
8
11
  import { localizeError, localizeErrorsPlugin } from "./localize-errors.mapper"
9
12
 
13
+ const build = async () => {
14
+ const errorsDouble = mock<ErrorsService>()
15
+ const localeDouble = mock<RequestLocale>()
16
+ const moduleRef = await Test.createTestingModule({
17
+ providers: [
18
+ { provide: ERRORS_SERVICE, useValue: errorsDouble },
19
+ { provide: REQUEST_LOCALE, useValue: localeDouble },
20
+ ],
21
+ }).compile()
22
+ return {
23
+ errors: moduleRef.get<typeof errorsDouble>(ERRORS_SERVICE),
24
+ requestLocale: moduleRef.get<typeof localeDouble>(REQUEST_LOCALE),
25
+ }
26
+ }
27
+
28
+ const errorWith = (extensions?: GraphQLFormattedError["extensions"]): GraphQLFormattedError =>
29
+ extensions === undefined ? { message: "syntax error" } : { message: "syntax error", extensions }
30
+
31
+ const acceptLanguage = (language?: string): Request["headers"] =>
32
+ language === undefined ? {} : { "accept-language": language }
33
+
10
34
  const singleResponse = (errors?: ReadonlyArray<GraphQLFormattedError>): GraphQLResponse =>
11
35
  mock<GraphQLResponse>({ body: { kind: "single", singleResult: errors === undefined ? {} : { errors } } })
12
36
 
@@ -33,17 +57,17 @@ const sendResponse = async (
33
57
  describe("localizeError", () => {
34
58
  it.each([undefined, {}, { code: 400 }, { code: null }, { code: false }])(
35
59
  "returns an error without a string code unchanged (%p)",
36
- (extensions) => {
37
- const errors = mock<ErrorsService>()
38
- const error: GraphQLFormattedError = { message: "syntax error", extensions }
60
+ async (extensions) => {
61
+ const { errors } = await build()
62
+ const error = errorWith(extensions)
39
63
 
40
64
  expect(localizeError(errors, "en", error)).toBe(error)
41
65
  expect(errors.text).not.toHaveBeenCalled()
42
66
  },
43
67
  )
44
68
 
45
- it("replaces a coded error's message with the catalog text and keeps only string and number params", () => {
46
- const errors = mock<ErrorsService>()
69
+ it("replaces a coded error's message with the catalog text and keeps only string and number params", async () => {
70
+ const { errors } = await build()
47
71
  errors.text.mockReturnValue("The item was not found")
48
72
  const error: GraphQLFormattedError = {
49
73
  message: "ITEM_NOT_FOUND",
@@ -67,8 +91,8 @@ describe("localizeError", () => {
67
91
 
68
92
  it.each([undefined, null, ["i-1"], "i-1", 42, false])(
69
93
  "interpolates no params when extensions.params is %p",
70
- (params) => {
71
- const errors = mock<ErrorsService>()
94
+ async (params) => {
95
+ const { errors } = await build()
72
96
  errors.text.mockReturnValue("The item was not found")
73
97
  const error: GraphQLFormattedError = {
74
98
  message: "ITEM_NOT_FOUND",
@@ -83,8 +107,7 @@ describe("localizeError", () => {
83
107
 
84
108
  describe("localizeErrorsPlugin", () => {
85
109
  it("forwards the Accept-Language preferences and localizes every coded error in the resolved locale", async () => {
86
- const errors = mock<ErrorsService>()
87
- const requestLocale = mock<RequestLocale>()
110
+ const { errors, requestLocale } = await build()
88
111
  requestLocale.of.mockReturnValue("vi")
89
112
  errors.text.mockReturnValueOnce("localized: not found").mockReturnValueOnce("localized: denied")
90
113
  const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
@@ -115,15 +138,14 @@ describe("localizeErrorsPlugin", () => {
115
138
  it.each([undefined, ""])(
116
139
  "delegates an absent or empty Accept-Language header to the locale default (%p)",
117
140
  async (language) => {
118
- const errors = mock<ErrorsService>()
119
- const requestLocale = mock<RequestLocale>()
141
+ const { errors, requestLocale } = await build()
120
142
  requestLocale.of.mockReturnValue("vi")
121
143
  errors.text.mockReturnValue("localized: not found")
122
144
  const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
123
145
 
124
146
  const response = singleResponse([coded])
125
147
 
126
- await sendResponse(errors, requestLocale, requestContext(response, { "accept-language": language }))
148
+ await sendResponse(errors, requestLocale, requestContext(response, acceptLanguage(language)))
127
149
 
128
150
  expect(requestLocale.of).toHaveBeenCalledWith(language)
129
151
  expect(errors.text).toHaveBeenCalledWith("ITEM_NOT_FOUND", {}, "vi")
@@ -132,10 +154,11 @@ describe("localizeErrorsPlugin", () => {
132
154
  ])
133
155
  },
134
156
  )
157
+ })
135
158
 
159
+ describe("localizeErrorsPlugin on a response with nothing to localize", () => {
136
160
  it("leaves a response without errors alone", async () => {
137
- const errors = mock<ErrorsService>()
138
- const requestLocale = mock<RequestLocale>()
161
+ const { errors, requestLocale } = await build()
139
162
 
140
163
  const response = singleResponse()
141
164
  const body = response.body
@@ -149,14 +172,13 @@ describe("localizeErrorsPlugin", () => {
149
172
  })
150
173
 
151
174
  it("leaves an incremental response alone", async () => {
152
- const errors = mock<ErrorsService>()
153
- const requestLocale = mock<RequestLocale>()
175
+ const { errors, requestLocale } = await build()
154
176
  const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
155
177
  const response = mock<GraphQLResponse>({
156
178
  body: {
157
179
  kind: "incremental",
158
180
  initialResult: { errors: [coded], hasNext: true },
159
- subsequentResults: mock<AsyncGenerator>(),
181
+ subsequentResults: mock<AsyncIterable<never>>(),
160
182
  },
161
183
  })
162
184
  const body = response.body
@@ -2,12 +2,11 @@ import { Injectable } from "@nestjs/common"
2
2
  import type { EntityManager } from "typeorm"
3
3
  import { InjectClock } from "@modules/platform/clock"
4
4
  import type { Clock } from "@modules/platform/clock"
5
- import { InjectPrimaryEntityManager, LIST_ROWS_MAX } from "@modules/platform/database"
5
+ import { InjectPrimaryEntityManager, LIST_ROWS_MAX, returnedRows } from "@modules/platform/database"
6
6
  import type { CreateNoteParams, Note } from "./note.contracts"
7
7
  import { NoteError, NoteErrorCode } from "./errors/note.error"
8
8
  import { NoteEntity } from "./persistence/entities/note.entity"
9
- import { toNote } from "./persistence/note.rows"
10
- import type { NoteRow } from "./persistence/note.rows"
9
+ import { isNoteRow, toNote } from "./persistence/note.rows"
11
10
  import { INSERT_NOTE } from "./persistence/note.sql"
12
11
 
13
12
  @Injectable()
@@ -29,7 +28,7 @@ export class NoteService {
29
28
 
30
29
  /** Writes one note in the caller transaction, stamped by the clock, and answers it as stored. */
31
30
  async create(params: CreateNoteParams): Promise<Note> {
32
- const rows: Array<NoteRow> = await params.manager.query(INSERT_NOTE, [params.body, this.clock.now()])
31
+ const rows = returnedRows(await params.manager.query(INSERT_NOTE, [params.body, this.clock.now()]), isNoteRow)
33
32
  const note = toNote(rows)
34
33
  if (note === null) throw new NoteError({ code: NoteErrorCode.NoteMissing })
35
34
  return note
@@ -1,3 +1,4 @@
1
+ import { rowShape } from "@modules/platform/database"
1
2
  import type { Note } from "../note.contracts"
2
3
 
3
4
  /** The row INSERT_NOTE answers. */
@@ -10,6 +11,9 @@ export interface NoteRow {
10
11
  created_at: Date
11
12
  }
12
13
 
14
+ /** Proves one row INSERT_NOTE answered is a NoteRow. */
15
+ export const isNoteRow: (value: unknown) => value is NoteRow = rowShape({ id: "string", body: "string", created_at: "date" })
16
+
13
17
  /** The note of the row an insert answers, or null when it answered none. */
14
18
  export const toNote = (rows: ReadonlyArray<NoteRow>): Note | null => {
15
19
  const row = rows[0]
@@ -5,11 +5,14 @@ import type { ErrorKind } from "@modules/platform/errors"
5
5
  export enum DatabaseErrorCode {
6
6
  /** A dynamic SQL identifier was not on the list of names its caller allows. */
7
7
  IdentifierRejected = "DATABASE_IDENTIFIER_REJECTED",
8
+ /** A RETURNING statement answered something other than rows of the shape its caller expects. */
9
+ ReturningRowRejected = "DATABASE_RETURNING_ROW_REJECTED",
8
10
  }
9
11
 
10
12
  /** How each database code travels. */
11
13
  export const DATABASE_ERROR_KINDS: Record<DatabaseErrorCode, ErrorKind> = {
12
14
  [DatabaseErrorCode.IdentifierRejected]: "internal",
15
+ [DatabaseErrorCode.ReturningRowRejected]: "internal",
13
16
  }
14
17
 
15
18
  /** The one error class of the database capability. */
@@ -7,5 +7,6 @@ export { SeedRunnerService } from "./seed-runner.service"
7
7
  export type { DatabaseConnectionConfig, DatabaseConnectionOptions } from "./database.options"
8
8
  export { LIST_ROWS_MAX, sql } from "./database.sql"
9
9
  export { DATABASE_ERROR_KINDS } from "./errors/database.error"
10
+ export { returnedRows, rowShape } from "./returned-rows.policy"
10
11
  export { parsePrimaryDatabaseConfig } from "./primary.config"
11
12
  export { InjectPrimaryEntityManager, PRIMARY_ENTITY_MANAGER } from "./primary.decorators"
@@ -0,0 +1,84 @@
1
+ import { Test } from "@nestjs/testing"
2
+ import { DatabaseErrorCode } from "./errors/database.error"
3
+ import { returnedRows, rowShape } from "./returned-rows.policy"
4
+
5
+ const RETURNED_ROWS_POLICY: unique symbol = Symbol("database.returned-rows")
6
+
7
+ /** The policy under test, resolved through a testing module as the unit spec standard asks. */
8
+ interface ReturnedRowsPolicy {
9
+ readonly returnedRows: typeof returnedRows
10
+ readonly rowShape: typeof rowShape
11
+ }
12
+
13
+ const AT = new Date("2026-02-03T04:05:06.000Z")
14
+
15
+ describe("returned rows", () => {
16
+ let subject: ReturnedRowsPolicy
17
+
18
+ beforeEach(async () => {
19
+ const moduleRef = await Test.createTestingModule({
20
+ providers: [{ provide: RETURNED_ROWS_POLICY, useFactory: (): ReturnedRowsPolicy => ({ returnedRows, rowShape }) }],
21
+ }).compile()
22
+ subject = moduleRef.get<ReturnedRowsPolicy>(RETURNED_ROWS_POLICY)
23
+ })
24
+
25
+ describe("rowShape", () => {
26
+ it("admits a row holding every named column with a value of its kind", () => {
27
+ const isRow = subject.rowShape({ id: "string", hits: "number", open: "boolean", created_at: "date", payload: "json" })
28
+
29
+ expect(isRow({ id: "row-1", hits: 2, open: true, created_at: AT, payload: { a: 1 }, extra: "kept" })).toBe(true)
30
+ })
31
+
32
+ it("admits null only for a kind that ends in ?", () => {
33
+ const isRow = subject.rowShape({ note: "string?", hits: "number?", open: "boolean?", seen_at: "date?" })
34
+
35
+ expect(isRow({ note: null, hits: null, open: null, seen_at: null })).toBe(true)
36
+ expect(isRow({ note: "n", hits: 1, open: false, seen_at: AT })).toBe(true)
37
+ expect(subject.rowShape({ note: "string" })({ note: null })).toBe(false)
38
+ expect(subject.rowShape({ payload: "json" })({ payload: null })).toBe(true)
39
+ })
40
+
41
+ it.each([
42
+ ["a column the statement did not return", { hits: 1 }],
43
+ ["a value of another kind", { id: 7, hits: 1 }],
44
+ ["a number returned as text", { id: "row-1", hits: "1" }],
45
+ ["a value that is not a row", "row-1"],
46
+ ["null", null],
47
+ ])("refuses %s", (_case, value) => {
48
+ expect(subject.rowShape({ id: "string", hits: "number" })(value)).toBe(false)
49
+ })
50
+
51
+ it("refuses a json column the statement did not return", () => {
52
+ expect(subject.rowShape({ payload: "json" })({})).toBe(false)
53
+ })
54
+ })
55
+
56
+ describe("returnedRows", () => {
57
+ it("answers every row, typed by its guard, in the order the statement returned them", () => {
58
+ const rows = subject.returnedRows([{ id: "a" }, { id: "b" }], subject.rowShape({ id: "string" }))
59
+
60
+ expect(rows.map((row) => row.id)).toEqual(["a", "b"])
61
+ })
62
+
63
+ it("answers no rows for a statement that returned none", () => {
64
+ expect(subject.returnedRows([], subject.rowShape({ id: "string" }))).toEqual([])
65
+ })
66
+
67
+ it("refuses a row of another shape instead of reading it", () => {
68
+ expect(() => subject.returnedRows([{ id: "a" }, { id: 2 }], subject.rowShape({ id: "string" }))).toThrow(
69
+ expect.objectContaining({ code: DatabaseErrorCode.ReturningRowRejected }),
70
+ )
71
+ })
72
+
73
+ it("refuses a result that is not a list of rows, such as the [rows, count] of a bare UPDATE", () => {
74
+ const isRow = subject.rowShape({ id: "string" })
75
+
76
+ expect(() => subject.returnedRows({ id: "a" }, isRow)).toThrow(
77
+ expect.objectContaining({ code: DatabaseErrorCode.ReturningRowRejected }),
78
+ )
79
+ expect(() => subject.returnedRows([[{ id: "a" }], 1], isRow)).toThrow(
80
+ expect.objectContaining({ code: DatabaseErrorCode.ReturningRowRejected }),
81
+ )
82
+ })
83
+ })
84
+ })