@starci/hfs 4.11.0 → 4.11.8
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/CHANGELOG.md +63 -0
- package/README.md +40 -1
- package/db/drift.mjs +8 -6
- package/db/image.mjs +9 -4
- package/db/migrate.mjs +3 -3
- package/package.json +2 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
- package/runtime/knowledge/hfs/rules.yaml +29 -12
- package/runtime/knowledge/hfs/slots.yaml +48 -37
- package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
- package/runtime/knowledge/patterns/be/queues.yaml +2 -3
- package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +17 -17
- package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
- package/runtime/scripts/gates/starcistacks.mjs +6 -2
- package/runtime/scripts/guards/push-scan.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
- package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
- package/runtime/scripts/hfs/db-errors.mjs +2 -0
- package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
- package/runtime/scripts/hfs/db-squash.mjs +514 -0
- package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
- package/runtime/scripts/hfs/example-secrets.mjs +4 -3
- package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
- package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
- package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
- package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
- package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
- package/runtime/scripts/hfs/rules/edition.mjs +1 -1
- package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
- package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
- package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
- package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
- package/runtime/scripts/hfs/sealed-members.mjs +10 -6
- package/runtime/scripts/hfs/secret-files.mjs +16 -0
- package/runtime/scripts/hfs/secret-init.mjs +120 -21
- package/runtime/scripts/hfs/secret-output.mjs +2 -1
- package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
- package/runtime/scripts/hfs/secret-sops.mjs +93 -9
- package/runtime/scripts/hfs/secret-standard.mjs +315 -0
- package/runtime/scripts/hfs/secret-store.mjs +23 -9
- package/runtime/scripts/hfs/secret.mjs +110 -46
- package/runtime/scripts/hfs/slots.mjs +8 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +20 -6
- package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
- package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
- package/runtime/scripts/lib/sealed-member.mjs +36 -0
- package/runtime/scripts/lib/secret-patterns.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.mjs +2 -2
- package/scaffold/add-inbox-lite.mjs +2 -2
- package/scaffold/app.mjs +2 -2
- package/src/main.mjs +5 -4
- package/stack/build.mjs +1 -1
- package/stack/config.mjs +2 -1
- package/stack/docker-transport.mjs +2 -1
- package/stack/remote.mjs +4 -3
- package/stack/sealed.mjs +9 -8
- package/stack/target-binding.mjs +2 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +33 -10
- package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
- package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
- package/templates/app/gitignore +2 -1
- package/templates/app/gitignore-lite +3 -1
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton-lite/README.md +2 -2
- package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
- package/templates/be/inbox/events.json.tpl +1 -1
- package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
- package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
- package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
- package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
- package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
- package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
- package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
- package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
- package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
- package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
- package/templates/be/inbox/platform/migration.sql.tpl +97 -34
- package/templates/be/inbox/service.spec.ts.tpl +1 -1
- package/templates/be/inbox/webhook-door.ts.tpl +2 -2
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
- package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +37 -2
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -3
- package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
- package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
- package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
- package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
- package/templates/app/db-image/db/Dockerfile +0 -8
- package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
--
|
|
1
|
+
-- The claim and the receipt event share the intake transaction; a consumer reads the event from its pgmq queue after commit.
|
|
2
2
|
create table public.inbox (
|
|
3
3
|
source text not null check (source <> ''),
|
|
4
4
|
event_id text not null check (event_id <> ''),
|
|
@@ -20,35 +20,16 @@ with check (source <> '');
|
|
|
20
20
|
revoke all on table public.inbox from public, anon, authenticated;
|
|
21
21
|
grant select, insert on table public.inbox to app_be;
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
alter table public.event_bus enable row level security;
|
|
33
|
-
alter table public.event_bus force row level security;
|
|
34
|
-
|
|
35
|
-
create policy "event_bus_app_be_select"
|
|
36
|
-
on public.event_bus for select to app_be
|
|
37
|
-
using (event_name <> '');
|
|
38
|
-
|
|
39
|
-
create policy "event_bus_app_be_insert"
|
|
40
|
-
on public.event_bus for insert to app_be
|
|
41
|
-
with check (event_name <> '');
|
|
42
|
-
|
|
43
|
-
create policy "event_bus_app_be_update"
|
|
44
|
-
on public.event_bus for update to app_be
|
|
45
|
-
using (event_name <> '')
|
|
46
|
-
with check (event_name <> '');
|
|
47
|
-
|
|
48
|
-
revoke all on table public.event_bus from public, anon, authenticated;
|
|
49
|
-
grant select, insert, update on table public.event_bus to app_be;
|
|
23
|
+
-- The event bus is pgmq (ruling lite-pgmq-bus-bullmq-jobs; it installs into its own schema pgmq): a publish sends the typed envelope
|
|
24
|
+
-- once to its topic, the event name, in the publisher's own transaction, and pgmq copies it into every consumer queue bound to that
|
|
25
|
+
-- name, so a rolled-back change sends nothing and no relay runs. A reactor's migration declares its consumer: pgmq.create of
|
|
26
|
+
-- '<topic>__<consumer>' and of its dead-letter queue '<topic>__<consumer>__dead', then pgmq.bind_topic('<topic>', '<topic>__<consumer>')
|
|
27
|
+
-- (R258). Each consumer reads its own queue with a visibility timeout, archives a handled message, and moves one past its
|
|
28
|
+
-- maxDeliveries to its dead-letter queue.
|
|
29
|
+
create extension if not exists pgmq;
|
|
50
30
|
|
|
51
|
-
-- The claim of one delivery and the
|
|
31
|
+
-- The claim of one delivery and the bus operations, called through callDbFunction of platform/database (R334). The bus functions are
|
|
32
|
+
-- security definer over pgmq, so the application role holds no grant on the pgmq schema; a read names only a queue bound to a topic.
|
|
52
33
|
create function public.inbox_claim_event(p_source text, p_event_id text)
|
|
53
34
|
returns table (event_id text)
|
|
54
35
|
language sql
|
|
@@ -58,14 +39,96 @@ as $$
|
|
|
58
39
|
returning i.event_id
|
|
59
40
|
$$;
|
|
60
41
|
|
|
61
|
-
create function public.
|
|
62
|
-
returns
|
|
42
|
+
create function public.event_bus_send(p_event_name text, p_envelope jsonb)
|
|
43
|
+
returns integer
|
|
63
44
|
language sql
|
|
45
|
+
security definer
|
|
46
|
+
set search_path = pg_catalog, pg_temp
|
|
47
|
+
as $$
|
|
48
|
+
select pgmq.send_topic(p_event_name, p_envelope)
|
|
49
|
+
$$;
|
|
50
|
+
|
|
51
|
+
create function public.event_bus_read(p_queue text, p_visibility_seconds integer, p_batch integer)
|
|
52
|
+
returns table (msg_id bigint, read_ct integer, message jsonb)
|
|
53
|
+
language sql
|
|
54
|
+
security definer
|
|
55
|
+
set search_path = pg_catalog, pg_temp
|
|
56
|
+
as $$
|
|
57
|
+
select m.msg_id, m.read_ct, m.message
|
|
58
|
+
from (select distinct b.queue_name from pgmq.topic_bindings as b where b.queue_name = p_queue) as bound
|
|
59
|
+
cross join lateral pgmq.read(bound.queue_name, p_visibility_seconds, p_batch) as m
|
|
60
|
+
$$;
|
|
61
|
+
|
|
62
|
+
create function public.event_bus_archive(p_queue text, p_msg_id bigint)
|
|
63
|
+
returns boolean
|
|
64
|
+
language sql
|
|
65
|
+
security definer
|
|
66
|
+
set search_path = pg_catalog, pg_temp
|
|
67
|
+
as $$
|
|
68
|
+
select pgmq.archive(p_queue, p_msg_id)
|
|
69
|
+
$$;
|
|
70
|
+
|
|
71
|
+
create function public.event_bus_bury(p_queue text, p_msg_id bigint, p_read_ct integer, p_message jsonb, p_reason text)
|
|
72
|
+
returns boolean
|
|
73
|
+
language plpgsql
|
|
74
|
+
security definer
|
|
75
|
+
set search_path = pg_catalog, pg_temp
|
|
76
|
+
as $$
|
|
77
|
+
begin
|
|
78
|
+
if not exists (select 1 from pgmq.topic_bindings as b where b.queue_name = p_queue) then
|
|
79
|
+
raise exception 'event_bus_bury: % is no consumer queue bound to a topic', p_queue;
|
|
80
|
+
end if;
|
|
81
|
+
perform pgmq.send(p_queue || '__dead', jsonb_build_object('readCount', p_read_ct, 'reason', p_reason, 'message', p_message));
|
|
82
|
+
return pgmq.archive(p_queue, p_msg_id);
|
|
83
|
+
end
|
|
84
|
+
$$;
|
|
85
|
+
|
|
86
|
+
create function public.event_bus_dead_letters(p_queue text, p_batch integer)
|
|
87
|
+
returns table (msg_id bigint, message jsonb)
|
|
88
|
+
language plpgsql
|
|
89
|
+
security definer
|
|
90
|
+
set search_path = pg_catalog, pg_temp
|
|
91
|
+
as $$
|
|
92
|
+
begin
|
|
93
|
+
if not exists (select 1 from pgmq.topic_bindings as b where b.queue_name = p_queue) then
|
|
94
|
+
raise exception 'event_bus_dead_letters: % is no consumer queue bound to a topic', p_queue;
|
|
95
|
+
end if;
|
|
96
|
+
return query execute format('select d.msg_id, d.message from pgmq.%I as d order by d.msg_id limit $1', 'q_' || p_queue || '__dead') using p_batch;
|
|
97
|
+
end
|
|
98
|
+
$$;
|
|
99
|
+
|
|
100
|
+
create function public.event_bus_requeue(p_queue text, p_msg_id bigint)
|
|
101
|
+
returns boolean
|
|
102
|
+
language plpgsql
|
|
103
|
+
security definer
|
|
104
|
+
set search_path = pg_catalog, pg_temp
|
|
64
105
|
as $$
|
|
65
|
-
|
|
106
|
+
declare
|
|
107
|
+
v_dead jsonb;
|
|
108
|
+
begin
|
|
109
|
+
if not exists (select 1 from pgmq.topic_bindings as b where b.queue_name = p_queue) then
|
|
110
|
+
raise exception 'event_bus_requeue: % is no consumer queue bound to a topic', p_queue;
|
|
111
|
+
end if;
|
|
112
|
+
execute format('delete from pgmq.%I as d where d.msg_id = $1 returning d.message', 'q_' || p_queue || '__dead') into v_dead using p_msg_id;
|
|
113
|
+
if v_dead is null then
|
|
114
|
+
return false;
|
|
115
|
+
end if;
|
|
116
|
+
perform pgmq.send(p_queue, v_dead -> 'message');
|
|
117
|
+
return true;
|
|
118
|
+
end
|
|
66
119
|
$$;
|
|
67
120
|
|
|
68
121
|
revoke all on function public.inbox_claim_event(text, text) from public;
|
|
69
122
|
grant execute on function public.inbox_claim_event(text, text) to app_be;
|
|
70
|
-
revoke all on function public.
|
|
71
|
-
grant execute on function public.
|
|
123
|
+
revoke all on function public.event_bus_send(text, jsonb) from public;
|
|
124
|
+
grant execute on function public.event_bus_send(text, jsonb) to app_be;
|
|
125
|
+
revoke all on function public.event_bus_read(text, integer, integer) from public;
|
|
126
|
+
grant execute on function public.event_bus_read(text, integer, integer) to app_be;
|
|
127
|
+
revoke all on function public.event_bus_archive(text, bigint) from public;
|
|
128
|
+
grant execute on function public.event_bus_archive(text, bigint) to app_be;
|
|
129
|
+
revoke all on function public.event_bus_bury(text, bigint, integer, jsonb, text) from public;
|
|
130
|
+
grant execute on function public.event_bus_bury(text, bigint, integer, jsonb, text) to app_be;
|
|
131
|
+
revoke all on function public.event_bus_dead_letters(text, integer) from public;
|
|
132
|
+
grant execute on function public.event_bus_dead_letters(text, integer) to app_be;
|
|
133
|
+
revoke all on function public.event_bus_requeue(text, bigint) from public;
|
|
134
|
+
grant execute on function public.event_bus_requeue(text, bigint) to app_be;
|
|
@@ -63,7 +63,7 @@ describe("@@Provider@@InboxService", () => {
|
|
|
63
63
|
|
|
64
64
|
it("rolls back the receipt when publishing fails", async () => {
|
|
65
65
|
const { inbox, events, transaction } = await build(true)
|
|
66
|
-
const failure = new Error("
|
|
66
|
+
const failure = new Error("event bus unavailable")
|
|
67
67
|
events.failNext("publish", failure)
|
|
68
68
|
|
|
69
69
|
await expect(inbox.record@@Provider@@Delivery({ eventId: "delivery-1", delivery: DELIVERY, correlationId: "request-1" })).rejects.toBe(failure)
|
|
@@ -11,8 +11,8 @@ import { @@Event@@Request } from "./dto/@@event@@.request"
|
|
|
11
11
|
@Controller("webhooks/@@provider@@")
|
|
12
12
|
/**
|
|
13
13
|
* POST /webhooks/@@provider@@, the signed intake door of the @@provider@@ notifier. The signature and replay window are proven
|
|
14
|
-
* first; the verified delivery goes to one domain intake that records a receipt and typed
|
|
15
|
-
* reactor
|
|
14
|
+
* first; the verified delivery goes to one domain intake that records a receipt and sends its typed event in its transaction. A separate
|
|
15
|
+
* reactor reads the committed event from its pgmq queue and dispatches the command.
|
|
16
16
|
*/
|
|
17
17
|
export class @@Provider@@Webhook {
|
|
18
18
|
constructor(
|
|
@@ -8,10 +8,10 @@ import { JOBS_MANAGERS } from "./jobs.decorators"
|
|
|
8
8
|
import { JobClaimService } from "./job-claim.service"
|
|
9
9
|
|
|
10
10
|
// The statements callDbFunction of platform/database sends for each job function.
|
|
11
|
-
const CLAIM_JOB =
|
|
12
|
-
const ADVANCE_JOB =
|
|
13
|
-
const COMPLETE_JOB =
|
|
14
|
-
const FAIL_JOB =
|
|
11
|
+
const CLAIM_JOB = 'SELECT * FROM "public"."job_claim"($1, $2, $3, $4, $5, $6)'
|
|
12
|
+
const ADVANCE_JOB = 'SELECT * FROM "public"."job_advance"($1, $2, $3, $4)'
|
|
13
|
+
const COMPLETE_JOB = 'SELECT * FROM "public"."job_complete"($1, $2, $3)'
|
|
14
|
+
const FAIL_JOB = 'SELECT * FROM "public"."job_fail"($1, $2, $3, $4)'
|
|
15
15
|
|
|
16
16
|
const AT = "2026-02-03T04:05:06.000Z"
|
|
17
17
|
|
|
@@ -46,7 +46,7 @@ export class JobClaimService implements JobClaims {
|
|
|
46
46
|
/** Claims the delivery; null when the job is done or another worker holds a claim that has not expired. */
|
|
47
47
|
async claim(params: ClaimParams): Promise<ClaimResult> {
|
|
48
48
|
const now = this.clock.now()
|
|
49
|
-
const rows = await callDbFunction<ClaimedRecord>(this.writer(), "job_claim", [
|
|
49
|
+
const rows = await callDbFunction<ClaimedRecord>(this.writer(), "public.job_claim", [
|
|
50
50
|
params.kind,
|
|
51
51
|
params.jobKey,
|
|
52
52
|
params.workerId,
|
|
@@ -69,17 +69,17 @@ export class JobClaimService implements JobClaims {
|
|
|
69
69
|
|
|
70
70
|
/** Records a finished step under the token the caller holds. */
|
|
71
71
|
advance(write: AdvanceWrite): Promise<void> {
|
|
72
|
-
return this.guarded("job_advance", [write.jobId, write.expectedFencingToken, write.step, this.clock.now()], write)
|
|
72
|
+
return this.guarded("public.job_advance", [write.jobId, write.expectedFencingToken, write.step, this.clock.now()], write)
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/** Completes the job under the token the caller holds. */
|
|
76
76
|
complete(write: GuardedWrite): Promise<void> {
|
|
77
|
-
return this.guarded("job_complete", [write.jobId, write.expectedFencingToken, this.clock.now()], write)
|
|
77
|
+
return this.guarded("public.job_complete", [write.jobId, write.expectedFencingToken, this.clock.now()], write)
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** Marks the job failed under the token the caller holds, so BullMQ can retry it. */
|
|
81
81
|
fail(write: FailWrite): Promise<void> {
|
|
82
|
-
return this.guarded("job_fail", [write.jobId, write.expectedFencingToken, write.reason, this.clock.now()], write)
|
|
82
|
+
return this.guarded("public.job_fail", [write.jobId, write.expectedFencingToken, write.reason, this.clock.now()], write)
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/** The idempotency key of one step of this claim: it includes the token, so a zombie's repeat is a different key than the live worker's. */
|
|
@@ -99,7 +99,7 @@ export class JobClaimService implements JobClaims {
|
|
|
99
99
|
|
|
100
100
|
/** Runs a guarded function that updates only when the token still matches; no row back means a newer worker owns the job. */
|
|
101
101
|
private async guarded(
|
|
102
|
-
fn: "job_advance" | "job_complete" | "job_fail",
|
|
102
|
+
fn: "public.job_advance" | "public.job_complete" | "public.job_fail",
|
|
103
103
|
parameters: Array<unknown>,
|
|
104
104
|
write: GuardedWrite,
|
|
105
105
|
): Promise<void> {
|
|
@@ -10,7 +10,7 @@ import { JobTickService, tickMinute } from "./job-tick.service"
|
|
|
10
10
|
|
|
11
11
|
// The statements advisoryXactLock and callDbFunction of platform/database send for one firing.
|
|
12
12
|
const LOCK_TICK = "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))"
|
|
13
|
-
const CLAIM_TICK =
|
|
13
|
+
const CLAIM_TICK = 'SELECT * FROM "public"."job_tick_claim"($1, $2)'
|
|
14
14
|
|
|
15
15
|
const AT = "2026-02-03T04:05:06.000Z"
|
|
16
16
|
const TICK: JobTick = { name: "nightly-digest", cron: "0 2 * * *", timeZone: "UTC", queue: "digests", payload: { kind: "nightly" } }
|
|
@@ -62,7 +62,7 @@ export class JobTickService implements OnApplicationBootstrap {
|
|
|
62
62
|
const minute = tickMinute(this.clock.now())
|
|
63
63
|
return this.writer().transaction(async (tx) => {
|
|
64
64
|
await advisoryXactLock(tx, `tick:${tick.name}`)
|
|
65
|
-
const [claim] = await callDbFunction<TickClaim>(tx, "job_tick_claim", [tick.name, minute])
|
|
65
|
+
const [claim] = await callDbFunction<TickClaim>(tx, "public.job_tick_claim", [tick.name, minute])
|
|
66
66
|
if (claim?.claimed !== true) return false
|
|
67
67
|
await this.outbox.write(tx, tick.queue, tick.payload, `tick:${tick.name}:${minute}`)
|
|
68
68
|
return true
|
|
@@ -4,7 +4,7 @@ import { CLOCK } from "@modules/platform/clock"
|
|
|
4
4
|
import { QueueOutboxService } from "./queue-outbox.service"
|
|
5
5
|
|
|
6
6
|
// The statement callDbFunction of platform/database sends for queue_write.
|
|
7
|
-
const INSERT_QUEUE_ROW =
|
|
7
|
+
const INSERT_QUEUE_ROW = 'SELECT * FROM "public"."queue_write"($1, $2, $3, $4)'
|
|
8
8
|
import { QueueErrorCode } from "./errors/queue.error"
|
|
9
9
|
|
|
10
10
|
const AT = "2026-02-03T04:05:06.000Z"
|
|
@@ -15,6 +15,6 @@ export class QueueOutboxService implements QueueOutbox {
|
|
|
15
15
|
/** Writes the job in the outbox of the transaction `tx`; the relay adds it to BullMQ after the commit. */
|
|
16
16
|
async write(tx: EntityManager, queue: string, payload: object, correlationId: string): Promise<void> {
|
|
17
17
|
if (!isCorrelationId(correlationId)) throw new QueueError({ code: QueueErrorCode.CorrelationInvalid })
|
|
18
|
-
await callDbFunction(tx, "queue_write", [queue, JSON.stringify(payload), this.clock.now(), correlationId])
|
|
18
|
+
await callDbFunction(tx, "public.queue_write", [queue, JSON.stringify(payload), this.clock.now(), correlationId])
|
|
19
19
|
}
|
|
20
20
|
}
|
|
@@ -11,8 +11,8 @@ import { QueueRelayService } from "./queue-relay.service"
|
|
|
11
11
|
import type { QueueTransport } from "./queue-transport.port"
|
|
12
12
|
|
|
13
13
|
// The statements callDbFunction of platform/database sends for the relay's functions.
|
|
14
|
-
const SELECT_WAITING_ROWS =
|
|
15
|
-
const MARK_ROWS_SENT =
|
|
14
|
+
const SELECT_WAITING_ROWS = 'SELECT * FROM "public"."queue_waiting"($1)'
|
|
15
|
+
const MARK_ROWS_SENT = 'SELECT * FROM "public"."queue_mark_sent"($1, $2)'
|
|
16
16
|
|
|
17
17
|
const AT = "2026-02-03T04:05:06.000Z"
|
|
18
18
|
|
|
@@ -44,7 +44,7 @@ export class QueueRelayService extends OutboxRelayPolicy<QueueRow> {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
protected async select(tx: EntityManager): Promise<Array<QueueRow>> {
|
|
47
|
-
return [...(await callDbFunction<QueueRow>(tx, "queue_waiting", [this.options.relayBatch]))]
|
|
47
|
+
return [...(await callDbFunction<QueueRow>(tx, "public.queue_waiting", [this.options.relayBatch]))]
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
protected async deliver(rows: ReadonlyArray<QueueRow>): Promise<void> {
|
|
@@ -53,7 +53,7 @@ export class QueueRelayService extends OutboxRelayPolicy<QueueRow> {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
protected async mark(tx: EntityManager, rows: ReadonlyArray<QueueRow>): Promise<void> {
|
|
56
|
-
await callDbFunction(tx, "queue_mark_sent", [rows.map((row) => row.id), this.clock.now()])
|
|
56
|
+
await callDbFunction(tx, "public.queue_mark_sent", [rows.map((row) => row.id), this.clock.now()])
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
protected idle(): Promise<void> {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EnvSource } from "@modules/platform/config"
|
|
2
2
|
import { parseServerConfig } from "@modules/platform/config"
|
|
3
3
|
import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
|
|
4
|
+
import { parseHttpConfig } from "@modules/platform/http"
|
|
4
5
|
import { parseHttpSecurityConfig } from "@modules/platform/http-security"
|
|
5
6
|
|
|
6
7
|
/** The {{app}} environment values required before composition. */
|
|
@@ -9,6 +10,8 @@ export interface {{appPascal}}Env {
|
|
|
9
10
|
readonly server: ReturnType<typeof parseServerConfig>
|
|
10
11
|
/** The origin allowlist and rate limits. */
|
|
11
12
|
readonly httpSecurity: ReturnType<typeof parseHttpSecurityConfig>
|
|
13
|
+
/** What the outbound HTTP client trusts beyond Node's bundled certificate authorities. */
|
|
14
|
+
readonly http: ReturnType<typeof parseHttpConfig>
|
|
12
15
|
/** The primary database connection. */
|
|
13
16
|
readonly database: ReturnType<typeof parsePrimaryDatabaseConfig>
|
|
14
17
|
}
|
|
@@ -17,5 +20,6 @@ export interface {{appPascal}}Env {
|
|
|
17
20
|
export const read{{appPascal}}Env = (env: EnvSource): {{appPascal}}Env => ({
|
|
18
21
|
server: parseServerConfig(env),
|
|
19
22
|
httpSecurity: parseHttpSecurityConfig(env),
|
|
23
|
+
http: parseHttpConfig(env),
|
|
20
24
|
database: parsePrimaryDatabaseConfig(env),
|
|
21
25
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HttpSecurityOptions } from "@modules/platform/http-security"
|
|
2
2
|
import type { ServerOptions } from "@modules/platform/config"
|
|
3
3
|
import type { DatabaseConnectionConfig } from "@modules/platform/database"
|
|
4
|
+
import type { HttpOptions } from "@modules/platform/http"
|
|
4
5
|
|
|
5
6
|
/** Everything the {{app}} app needs from its environment, parsed by `{{app}}.env.ts` and handed to `AppModule.register`. */
|
|
6
7
|
export interface {{appPascal}}Options {
|
|
@@ -8,6 +9,8 @@ export interface {{appPascal}}Options {
|
|
|
8
9
|
readonly server: ServerOptions
|
|
9
10
|
/** The origin allowlist and rate limits. */
|
|
10
11
|
readonly httpSecurity: HttpSecurityOptions
|
|
12
|
+
/** What the outbound HTTP client trusts beyond Node's bundled certificate authorities. */
|
|
13
|
+
readonly http: HttpOptions
|
|
11
14
|
/** The primary database connection. */
|
|
12
15
|
readonly database: DatabaseConnectionConfig
|
|
13
16
|
}
|
|
@@ -30,7 +30,7 @@ export const platformModules = (options: {{appPascal}}Options): Array<DynamicMod
|
|
|
30
30
|
RetryModule.register({ isGlobal: true }),
|
|
31
31
|
IdsModule.register({ isGlobal: true }),
|
|
32
32
|
LoggingModule.register({ isGlobal: true }),
|
|
33
|
-
HttpModule.register({ isGlobal: true }),
|
|
33
|
+
HttpModule.register({ isGlobal: true, ...options.http }),
|
|
34
34
|
I18nModule.register({
|
|
35
35
|
isGlobal: true,
|
|
36
36
|
bundles: [HTTP_MESSAGES, ERRORS_MESSAGES, HTTP_SECURITY_MESSAGES, IDENTITY_MESSAGES],
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { Test } from "@nestjs/testing"
|
|
2
2
|
import { mock } from "@starci/jest-preset"
|
|
3
|
+
import { Agent } from "undici"
|
|
3
4
|
import { HttpErrorCode } from "./errors/http.error"
|
|
4
5
|
import { FetchHttpClient } from "./fetch-http-client.client"
|
|
6
|
+
import { HTTP_OPTIONS } from "./http.decorators"
|
|
7
|
+
import type { HttpOptions } from "./http.options"
|
|
5
8
|
|
|
6
9
|
/** Asymmetric matchers read as `unknown`: jest types them `any`, which may not flow into a property or a typed argument. */
|
|
7
10
|
const anyOf = (type: unknown): unknown => expect.any(type)
|
|
8
11
|
const objectLike = (shape: Record<string, unknown>): unknown => expect.objectContaining(shape)
|
|
9
12
|
|
|
10
|
-
const build = async (): Promise<FetchHttpClient> => {
|
|
11
|
-
const moduleRef = await Test.createTestingModule({
|
|
13
|
+
const build = async (options: HttpOptions = {}): Promise<FetchHttpClient> => {
|
|
14
|
+
const moduleRef = await Test.createTestingModule({
|
|
15
|
+
providers: [FetchHttpClient, { provide: HTTP_OPTIONS, useValue: options }],
|
|
16
|
+
}).compile()
|
|
12
17
|
return moduleRef.get(FetchHttpClient)
|
|
13
18
|
}
|
|
14
19
|
|
|
@@ -233,3 +238,33 @@ describe("FetchHttpClient response stream failures", () => {
|
|
|
233
238
|
).rejects.toMatchObject({ code: HttpErrorCode.Timeout, cause: failure })
|
|
234
239
|
})
|
|
235
240
|
})
|
|
241
|
+
|
|
242
|
+
describe("FetchHttpClient trusted certificate authority", () => {
|
|
243
|
+
it("sends through fetch's own pool and has nothing to close when no CA is trusted", async () => {
|
|
244
|
+
const client = await build()
|
|
245
|
+
const response = responseOf()
|
|
246
|
+
response.text.mockResolvedValue("{}")
|
|
247
|
+
const send = jest.spyOn(global, "fetch").mockResolvedValue(response)
|
|
248
|
+
|
|
249
|
+
await client.request({ correlationId: "request-1", method: "GET", url: "https://service.test/orders", timeoutMs: 2500 })
|
|
250
|
+
|
|
251
|
+
expect(send.mock.calls[0]?.[1]).not.toHaveProperty("dispatcher")
|
|
252
|
+
await expect(client.onModuleDestroy()).resolves.toBeUndefined()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("sends every request through one pool that trusts the CA, and closes it when the app stops", async () => {
|
|
256
|
+
const client = await build({ trustedCa: "the run's fake CA" })
|
|
257
|
+
const response = responseOf()
|
|
258
|
+
response.text.mockResolvedValue("{}")
|
|
259
|
+
const send = jest.spyOn(global, "fetch").mockResolvedValue(response)
|
|
260
|
+
|
|
261
|
+
await client.request({ correlationId: "request-1", method: "GET", url: "https://idp.test/jwks", timeoutMs: 2500 })
|
|
262
|
+
await client.request({ correlationId: "request-2", method: "GET", url: "https://service.test/orders", timeoutMs: 2500 })
|
|
263
|
+
|
|
264
|
+
const pool = send.mock.calls[0]?.[1]?.dispatcher
|
|
265
|
+
expect(pool).toBeInstanceOf(Agent)
|
|
266
|
+
expect(send.mock.calls[1]?.[1]?.dispatcher).toBe(pool)
|
|
267
|
+
await client.onModuleDestroy()
|
|
268
|
+
expect(pool).toHaveProperty("closed", true)
|
|
269
|
+
})
|
|
270
|
+
})
|
|
@@ -1,11 +1,34 @@
|
|
|
1
|
+
import { rootCertificates } from "node:tls"
|
|
1
2
|
import { Injectable } from "@nestjs/common"
|
|
3
|
+
import type { OnModuleDestroy } from "@nestjs/common"
|
|
4
|
+
import { Agent } from "undici"
|
|
2
5
|
import { isRecord } from "@modules/platform/primitives"
|
|
3
6
|
import { HttpError, HttpErrorCode } from "./errors/http.error"
|
|
7
|
+
import { InjectHttpOptions } from "./http.decorators"
|
|
8
|
+
import type { HttpOptions } from "./http.options"
|
|
4
9
|
import type { HttpClient, HttpRequest, HttpResponse } from "./http.port"
|
|
5
10
|
|
|
6
11
|
@Injectable()
|
|
7
12
|
/** The HttpClient adapter over the global `fetch`; the only place outside a spec that calls it. */
|
|
8
|
-
export class FetchHttpClient implements HttpClient {
|
|
13
|
+
export class FetchHttpClient implements HttpClient, OnModuleDestroy {
|
|
14
|
+
/**
|
|
15
|
+
* The connection pool that adds `trustedCa` to Node's bundled roots (the CA joins the trusted list, verification stays
|
|
16
|
+
* on); absent without one, so every request goes through fetch's own default pool as before.
|
|
17
|
+
*/
|
|
18
|
+
private readonly dispatcher: Agent | undefined
|
|
19
|
+
|
|
20
|
+
constructor(@InjectHttpOptions() options: HttpOptions) {
|
|
21
|
+
this.dispatcher =
|
|
22
|
+
options.trustedCa === undefined
|
|
23
|
+
? undefined
|
|
24
|
+
: new Agent({ connect: { ca: [...rootCertificates, options.trustedCa] } })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Closes the pool of the trusted CA, if the app configured one, when the app stops. */
|
|
28
|
+
async onModuleDestroy(): Promise<void> {
|
|
29
|
+
await this.dispatcher?.close()
|
|
30
|
+
}
|
|
31
|
+
|
|
9
32
|
/** Sends `request` with its deadline and reads the body: parsed JSON, or the raw bytes when the request asks for them. */
|
|
10
33
|
async request(request: HttpRequest): Promise<HttpResponse> {
|
|
11
34
|
const deadline = AbortSignal.timeout(request.timeoutMs)
|
|
@@ -38,17 +61,27 @@ export class FetchHttpClient implements HttpClient {
|
|
|
38
61
|
private async send(request: HttpRequest, signal: AbortSignal): Promise<Response> {
|
|
39
62
|
try {
|
|
40
63
|
const body = this.bodyOf(request)
|
|
41
|
-
|
|
64
|
+
const init: RequestInit = {
|
|
42
65
|
method: request.method,
|
|
43
66
|
headers: this.headersOf(request),
|
|
44
67
|
...(body === undefined ? {} : { body }),
|
|
45
68
|
signal,
|
|
46
|
-
}
|
|
69
|
+
}
|
|
70
|
+
return await fetch(request.url, this.routed(init))
|
|
47
71
|
} catch (cause) {
|
|
48
72
|
throw this.failure(cause)
|
|
49
73
|
}
|
|
50
74
|
}
|
|
51
75
|
|
|
76
|
+
/**
|
|
77
|
+
* `init` sent through the trusted-CA pool when there is one. @types/node declares fetch over its own copy of undici's
|
|
78
|
+
* types, which the Agent of the undici package does not match nominally; `Object.assign` keeps the declared type of
|
|
79
|
+
* `init` and hands fetch the Agent itself.
|
|
80
|
+
*/
|
|
81
|
+
private routed(init: RequestInit): RequestInit {
|
|
82
|
+
return this.dispatcher === undefined ? init : Object.assign(init, { dispatcher: this.dispatcher })
|
|
83
|
+
}
|
|
84
|
+
|
|
52
85
|
/** The explicit id owns correlation; a copied traceparent would take precedence at the receiving logger. */
|
|
53
86
|
private headersOf(request: HttpRequest): Record<string, string> {
|
|
54
87
|
const extra = Object.fromEntries(
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { EnvSource } from "@modules/platform/config"
|
|
2
|
+
import type { HttpOptions } from "./http.options"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads the http options: `HTTP_TRUSTED_CA` (or the file `HTTP_TRUSTED_CA_FILE` names) is the PEM of one more certificate
|
|
6
|
+
* authority the outbound client trusts beside Node's bundled roots; undeclared, the client trusts the bundled roots alone.
|
|
7
|
+
*/
|
|
8
|
+
export const parseHttpConfig = (env: EnvSource): HttpOptions => {
|
|
9
|
+
const trustedCa = env.optional("HTTP_TRUSTED_CA")
|
|
10
|
+
return trustedCa === undefined ? {} : { trustedCa }
|
|
11
|
+
}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { injector } from "@modules/platform/composition"
|
|
2
2
|
import type { TypedParameterDecorator } from "@modules/platform/composition"
|
|
3
|
+
import type { HttpOptions } from "./http.options"
|
|
3
4
|
import type { HttpClient } from "./http.port"
|
|
4
5
|
|
|
6
|
+
/** Token of the http options, exported so a spec can provide it. */
|
|
7
|
+
export const HTTP_OPTIONS: unique symbol = Symbol("platform.http.options")
|
|
8
|
+
|
|
5
9
|
/** Token of the HttpClient port. */
|
|
6
10
|
export const HTTP_CLIENT: unique symbol = Symbol("platform.http.client")
|
|
7
11
|
|
|
12
|
+
/** Injects the options of the http capability. Parameter type: HttpOptions. */
|
|
13
|
+
export const InjectHttpOptions = (): TypedParameterDecorator<HttpOptions> => injector<HttpOptions>(HTTP_OPTIONS)
|
|
14
|
+
|
|
8
15
|
/** Injects the HttpClient port. Parameter type: HttpClient. */
|
|
9
16
|
export const InjectHttpClient = (): TypedParameterDecorator<HttpClient> => injector<HttpClient>(HTTP_CLIENT)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { ConfigurableModuleBuilder } from "@nestjs/common"
|
|
2
|
+
import { HTTP_OPTIONS } from "./http.decorators"
|
|
2
3
|
import type { HttpOptions } from "./http.options"
|
|
3
4
|
|
|
4
5
|
/** The configurable-module base of the http capability; `isGlobal` is decided by the app root. */
|
|
5
|
-
export const { ConfigurableModuleClass, OPTIONS_TYPE } =
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
export const { ConfigurableModuleClass, OPTIONS_TYPE } =
|
|
7
|
+
new ConfigurableModuleBuilder<HttpOptions>({ optionsInjectionToken: HTTP_OPTIONS })
|
|
8
|
+
.setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
|
|
9
|
+
.build()
|
|
@@ -1,2 +1,9 @@
|
|
|
1
|
-
/** The http capability
|
|
2
|
-
export
|
|
1
|
+
/** The options of the http capability: what the outbound client trusts beyond Node's bundled certificate authorities. */
|
|
2
|
+
export interface HttpOptions {
|
|
3
|
+
/**
|
|
4
|
+
* PEM of one more certificate authority the client trusts, beside Node's bundled roots and never instead of them, so
|
|
5
|
+
* every other host is still verified as before (the test world passes its per-run fake CA, `w.fake.<name>.ca`).
|
|
6
|
+
* Absent, the client trusts the bundled roots alone. Verification is never turned off.
|
|
7
|
+
*/
|
|
8
|
+
readonly trustedCa?: string
|
|
9
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Test } from "@nestjs/testing"
|
|
2
|
+
import { mockEntityManager } from "@starci/jest-preset"
|
|
3
|
+
import { advisoryXactLock, callDbFunction, notify } from "./database.client"
|
|
4
|
+
import { DatabaseError, DatabaseErrorCode } from "./errors/database.error"
|
|
5
|
+
|
|
6
|
+
const DATABASE_CLIENT = Symbol("database-client")
|
|
7
|
+
|
|
8
|
+
const setup = async () => {
|
|
9
|
+
const moduleRef = await Test.createTestingModule({
|
|
10
|
+
providers: [{ provide: DATABASE_CLIENT, useFactory: () => ({ advisoryXactLock, callDbFunction, notify }) }],
|
|
11
|
+
}).compile()
|
|
12
|
+
return moduleRef.get<{
|
|
13
|
+
readonly advisoryXactLock: typeof advisoryXactLock
|
|
14
|
+
readonly callDbFunction: typeof callDbFunction
|
|
15
|
+
readonly notify: typeof notify
|
|
16
|
+
}>(DATABASE_CLIENT)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What `callDbFunction` throws for `name`, or null when it builds a call. */
|
|
20
|
+
const refusalOf = async (name: string): Promise<unknown> => {
|
|
21
|
+
const client = await setup()
|
|
22
|
+
try {
|
|
23
|
+
await client.callDbFunction(mockEntityManager({ query: ['SELECT * FROM "public"."never"()', []] }), name, [])
|
|
24
|
+
return null
|
|
25
|
+
} catch (error) {
|
|
26
|
+
return error
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("callDbFunction", () => {
|
|
31
|
+
it("calls a schema-qualified function with each part quoted and the arguments as numbered parameters", async () => {
|
|
32
|
+
const client = await setup()
|
|
33
|
+
const manager = mockEntityManager({ query: ['SELECT * FROM "public"."job_claim"($1, $2)', [{ id: "job-1" }]] })
|
|
34
|
+
|
|
35
|
+
const rows = await client.callDbFunction<{ id: string }>(manager, "public.job_claim", ["mail", 3])
|
|
36
|
+
|
|
37
|
+
expect(rows).toEqual([{ id: "job-1" }])
|
|
38
|
+
expect(manager.query).toHaveBeenCalledWith('SELECT * FROM "public"."job_claim"($1, $2)', ["mail", 3])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("calls a bare pg built-in of the canon's list in pg_catalog", async () => {
|
|
42
|
+
const client = await setup()
|
|
43
|
+
const manager = mockEntityManager({ query: ['SELECT * FROM "pg_catalog"."to_regproc"($1)', [{ to_regproc: null }]] })
|
|
44
|
+
|
|
45
|
+
const rows = await client.callDbFunction<{ to_regproc: string | null }>(manager, "to_regproc", ["public.job_claim"])
|
|
46
|
+
|
|
47
|
+
expect(rows).toEqual([{ to_regproc: null }])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("calls a function of another schema with no arguments", async () => {
|
|
51
|
+
const client = await setup()
|
|
52
|
+
const manager = mockEntityManager({ query: ['SELECT * FROM "be_access"."chat_target_member"()', []] })
|
|
53
|
+
|
|
54
|
+
const rows = await client.callDbFunction(manager, "be_access.chat_target_member", [])
|
|
55
|
+
|
|
56
|
+
expect(rows).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it.each([
|
|
60
|
+
["a bare name that is not a listed built-in", "job_claim"],
|
|
61
|
+
["a pg_catalog name spelt out", "pg_catalog.pg_terminate_backend"],
|
|
62
|
+
["a quote inside a part", 'public."job_claim'],
|
|
63
|
+
["a second statement smuggled into the name", "public.job_claim(); SELECT 1; --"],
|
|
64
|
+
["an upper-case part", "public.Job_Claim"],
|
|
65
|
+
["three parts", "db.public.job_claim"],
|
|
66
|
+
["an empty schema", ".job_claim"],
|
|
67
|
+
["an empty name", ""],
|
|
68
|
+
])("refuses %s before any SQL runs", async (_case, name) => {
|
|
69
|
+
const refusal = await refusalOf(name)
|
|
70
|
+
|
|
71
|
+
expect(refusal).toBeInstanceOf(DatabaseError)
|
|
72
|
+
expect(refusal).toMatchObject({ code: DatabaseErrorCode.IdentifierRejected, params: { name } })
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe("advisoryXactLock", () => {
|
|
77
|
+
it("locks the 64-bit hash of the key for the transaction", async () => {
|
|
78
|
+
const client = await setup()
|
|
79
|
+
const manager = mockEntityManager({ query: ["SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [{}]] })
|
|
80
|
+
|
|
81
|
+
await expect(client.advisoryXactLock(manager, "jobs:mail")).resolves.toBeUndefined()
|
|
82
|
+
|
|
83
|
+
expect(manager.query).toHaveBeenCalledWith("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", ["jobs:mail"])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe("notify", () => {
|
|
88
|
+
it("sends the payload on the channel", async () => {
|
|
89
|
+
const client = await setup()
|
|
90
|
+
const manager = mockEntityManager({ query: ["SELECT pg_notify($1, $2)", [{}]] })
|
|
91
|
+
|
|
92
|
+
await expect(client.notify(manager, "jobs", '{"id":"job-1"}')).resolves.toBeUndefined()
|
|
93
|
+
|
|
94
|
+
expect(manager.query).toHaveBeenCalledWith("SELECT pg_notify($1, $2)", ["jobs", '{"id":"job-1"}'])
|
|
95
|
+
})
|
|
96
|
+
})
|