@starci/hfs 4.11.12 → 4.11.15

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 (56) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/db/migrate.mjs +1 -1
  4. package/db/postgres.mjs +38 -3
  5. package/package.json +1 -1
  6. package/release/index.mjs +6 -5
  7. package/release/lint-baseline.mjs +123 -0
  8. package/release/urgent.mjs +19 -4
  9. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  10. package/runtime/knowledge/hfs/rules.yaml +3 -3
  11. package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
  12. package/runtime/scripts/api/sops/lib.mjs +25 -3
  13. package/runtime/scripts/api/sops/seal.mjs +4 -3
  14. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
  15. package/runtime/scripts/hfs/secret-init.mjs +15 -0
  16. package/runtime/scripts/hfs/secret-sops.mjs +65 -33
  17. package/runtime/scripts/lib/sops-envelope.mjs +36 -11
  18. package/scaffold/add-compose.mjs +3 -0
  19. package/scaffold/add-inbox-lite.mjs +45 -23
  20. package/scaffold/lite-exports.mjs +13 -0
  21. package/src/main.mjs +5 -5
  22. package/stack/age-install.mjs +10 -2
  23. package/stack/cli.mjs +5 -3
  24. package/stack/context.mjs +2 -2
  25. package/stack/deploy.mjs +6 -2
  26. package/stack/remote.mjs +29 -8
  27. package/stack/sealed.mjs +15 -11
  28. package/stack/server-key.mjs +31 -7
  29. package/stack/urgent-floor.mjs +101 -7
  30. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +1 -1
  31. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +1 -1
  32. package/templates/be/inbox/entities.index.ts.tpl +4 -0
  33. package/templates/be/inbox/entity.ts.tpl +29 -0
  34. package/templates/be/inbox/index.ts.tpl +1 -0
  35. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
  36. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
  37. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
  38. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
  39. package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
  40. package/templates/be/inbox/service.spec.ts.tpl +39 -15
  41. package/templates/be/inbox/service.ts.tpl +13 -13
  42. package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
  43. package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
  44. package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
  45. package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
  46. package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
  47. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
  48. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -0
  49. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
  50. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +3 -3
  51. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
  52. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  53. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
  54. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
  55. package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
  56. 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
  }
@@ -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
+ })
@@ -0,0 +1,61 @@
1
+ import { isRecord } from "@modules/platform/primitives"
2
+ import { DatabaseError, DatabaseErrorCode } from "./errors/database.error"
3
+
4
+ /** What node-postgres hands back for one returned column; a kind ending in `?` also admits SQL null. */
5
+ interface ColumnTypes {
6
+ readonly string: string
7
+ readonly number: number
8
+ readonly boolean: boolean
9
+ readonly date: Date
10
+ readonly json: unknown
11
+ readonly "string?": string | null
12
+ readonly "number?": number | null
13
+ readonly "boolean?": boolean | null
14
+ readonly "date?": Date | null
15
+ }
16
+
17
+ /** The kind one returned column is checked against. */
18
+ type ColumnKind = keyof ColumnTypes
19
+
20
+ /** The returned columns of one statement, each named as the database returns it (snake_case) with its kind. */
21
+ type ColumnShape = Readonly<Record<string, ColumnKind>>
22
+
23
+ /** The row a shape describes: every named column, typed by its kind. */
24
+ type RowOf<Shape extends ColumnShape> = { readonly [Column in keyof Shape]: ColumnTypes[Shape[Column]] }
25
+
26
+ /** A guard that proves one returned row has the shape its returned columns give it. */
27
+ type RowGuard<Row> = (value: unknown) => value is Row
28
+
29
+ /** Whether `value` is of the non-null part of a kind. */
30
+ const KIND_CHECKS: Readonly<Record<ColumnKind, (value: unknown) => boolean>> = {
31
+ string: (value) => typeof value === "string",
32
+ number: (value) => typeof value === "number",
33
+ boolean: (value) => typeof value === "boolean",
34
+ date: (value) => value instanceof Date,
35
+ json: (value) => value !== undefined,
36
+ "string?": (value) => value === null || typeof value === "string",
37
+ "number?": (value) => value === null || typeof value === "number",
38
+ "boolean?": (value) => value === null || typeof value === "boolean",
39
+ "date?": (value) => value === null || value instanceof Date,
40
+ }
41
+
42
+ /**
43
+ * The guard of the rows a RETURNING clause answers for `shape`: an object holding every named column with a value of its kind.
44
+ * A column the statement does not return is refused, so the shape and the RETURNING list cannot drift apart unseen.
45
+ */
46
+ export const rowShape =
47
+ <Shape extends ColumnShape>(shape: Shape): RowGuard<RowOf<Shape>> =>
48
+ (value: unknown): value is RowOf<Shape> =>
49
+ isRecord(value) && Object.entries(shape).every(([column, kind]) => KIND_CHECKS[kind](value[column]))
50
+
51
+ /**
52
+ * The rows a RETURNING statement answered, each proven by `isRow`: the result of `manager.query` for an INSERT or a CTE-wrapped
53
+ * UPDATE or DELETE, or the `raw` of a query builder's `execute()`. A result that is not a list of rows, or a row the guard
54
+ * refuses, throws `DATABASE_RETURNING_ROW_REJECTED`: the statement and the shape its caller expects disagree.
55
+ */
56
+ export const returnedRows = <Row>(raw: unknown, isRow: RowGuard<Row>): Array<Row> => {
57
+ if (!Array.isArray(raw)) throw new DatabaseError({ code: DatabaseErrorCode.ReturningRowRejected })
58
+ const rows: ReadonlyArray<unknown> = raw
59
+ if (!rows.every(isRow)) throw new DatabaseError({ code: DatabaseErrorCode.ReturningRowRejected })
60
+ return [...rows]
61
+ }
@@ -1,5 +1,10 @@
1
+ import { rowShape } from "@modules/platform/database"
2
+
1
3
  /** The row HIT_RATE_WINDOW answers. */
2
4
  export interface RateWindowRow {
3
5
  /** The hits of the caller's current window, this one included. */
4
6
  readonly hits: number
5
7
  }
8
+
9
+ /** Proves one row HIT_RATE_WINDOW answered is a RateWindowRow. */
10
+ export const isRateWindowRow: (value: unknown) => value is RateWindowRow = rowShape({ hits: "number" })
@@ -7,12 +7,12 @@ import { InjectClock } from "@modules/platform/clock"
7
7
  import type { Clock } from "@modules/platform/clock"
8
8
  import { InjectReflector } from "@modules/platform/composition"
9
9
  import type { DoorDecorator } from "@modules/platform/composition"
10
- import { InjectPrimaryEntityManager } from "@modules/platform/database"
10
+ import { InjectPrimaryEntityManager, returnedRows } from "@modules/platform/database"
11
11
  import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
12
12
  import { requestOf } from "./execution-request.mapper"
13
13
  import { InjectHttpSecurityOptions } from "./http-security.decorators"
14
14
  import type { HttpSecurityOptions } from "./http-security.options"
15
- import type { RateWindowRow } from "./persistence/rate-limit.rows"
15
+ import { isRateWindowRow } from "./persistence/rate-limit.rows"
16
16
  import { HIT_RATE_WINDOW, PRUNE_RATE_WINDOWS } from "./persistence/rate-limit.sql"
17
17
 
18
18
  /** The rate limit tiers of a door. */
@@ -79,7 +79,7 @@ export class RateLimitGuard implements CanActivate {
79
79
  const now = this.clock.now()
80
80
  const key = `${tier}:${requestOf(context).ip ?? "unknown"}`.slice(0, KEY_MAX)
81
81
  const resetAt = new Date(now.getTime() + this.options.rateLimit.windowMs)
82
- const rows: Array<RateWindowRow> = await this.entityManager.query(HIT_RATE_WINDOW, [key, now, resetAt])
82
+ const rows = returnedRows(await this.entityManager.query(HIT_RATE_WINDOW, [key, now, resetAt]), isRateWindowRow)
83
83
  const hits = rows[0]?.hits ?? Number.POSITIVE_INFINITY
84
84
  if (hits === 1) await this.entityManager.query(PRUNE_RATE_WINDOWS, [now])
85
85
  if (hits <= limit) return true
@@ -7,12 +7,15 @@ export enum DatabaseErrorCode {
7
7
  ConnectionUndeclared = "DATABASE_CONNECTION_UNDECLARED",
8
8
  /** A dynamic SQL identifier was not on the names its caller allows. */
9
9
  IdentifierRejected = "DATABASE_IDENTIFIER_REJECTED",
10
+ /** A RETURNING statement answered something other than rows of the shape its caller expects. */
11
+ ReturningRowRejected = "DATABASE_RETURNING_ROW_REJECTED",
10
12
  }
11
13
 
12
14
  /** How each database code travels. */
13
15
  export const DATABASE_ERROR_KINDS: Record<DatabaseErrorCode, ErrorKind> = {
14
16
  [DatabaseErrorCode.ConnectionUndeclared]: "internal",
15
17
  [DatabaseErrorCode.IdentifierRejected]: "internal",
18
+ [DatabaseErrorCode.ReturningRowRejected]: "internal",
16
19
  }
17
20
 
18
21
  /** The one error class of the database capability. */
@@ -4,4 +4,5 @@ export { DatabaseProbeService } from "./database-probe.service"
4
4
  export type { DatabaseConnectionOptions } from "./database.options"
5
5
  export { PRIMARY_DATA_SOURCE } from "./database.port"
6
6
  export { DATABASE_ERROR_KINDS } from "./errors/database.error"
7
+ export { returnedRows, rowShape } from "./returned-rows.policy"
7
8
  export { parsePrimaryDatabaseConfig } from "./primary.config"