@starci/hfs 4.10.1 → 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 +76 -0
- package/README.md +40 -1
- package/db/drift.mjs +19 -16
- package/db/image.mjs +13 -0
- package/db/migrate.mjs +3 -3
- package/db/postgres.mjs +1 -1
- package/package.json +2 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +23 -8
- package/runtime/knowledge/hfs/rules.yaml +54 -13
- package/runtime/knowledge/hfs/slots.yaml +67 -36
- package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
- package/runtime/knowledge/patterns/be/idempotency.yaml +1 -1
- package/runtime/knowledge/patterns/be/jobs.yaml +3 -0
- package/runtime/knowledge/patterns/be/queues.yaml +14 -14
- package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +28 -18
- 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 +94 -22
- 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 +3 -4
- 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 +4 -6
- package/scaffold/add-inbox-lite.mjs +2 -2
- package/scaffold/add-queue-lite.mjs +24 -5
- package/scaffold/app.mjs +2 -2
- package/src/main.mjs +5 -4
- package/stack/build.mjs +3 -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 +47 -12
- package/templates/app/db-bootstrap/db/bootstrap.sql +9 -14
- 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 +2 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +4 -2
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +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/jobs.log-events.ts.tpl +2 -0
- 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 +65 -0
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +78 -0
- package/templates/be/patterns/jobs/platform-lite/job-ticks.contracts.ts.tpl +16 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.module.ts.tpl +40 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +44 -0
- 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/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
export { EVENT_BUS_ERROR_KINDS } from "./errors/event-bus.error"
|
|
2
2
|
export { EVENT_BUS_MESSAGES } from "./messages/event-bus.messages"
|
|
3
|
-
export { BaseEvent } from "./event-bus.contracts"
|
|
4
|
-
export type {
|
|
5
|
-
|
|
3
|
+
export { BaseEvent, DEAD_QUEUE_SUFFIX, DEFAULT_MAX_DELIVERIES } from "./event-bus.contracts"
|
|
4
|
+
export type {
|
|
5
|
+
DeadLetter,
|
|
6
|
+
EventClass,
|
|
7
|
+
EventDelivery,
|
|
8
|
+
EventEnvelope,
|
|
9
|
+
EventPayload,
|
|
10
|
+
EventSubscriptionOptions,
|
|
11
|
+
ParsedEvent,
|
|
12
|
+
} from "./event-bus.contracts"
|
|
13
|
+
export { EVENT_BUS, EVENT_CONSUMER_REGISTRY, InjectEventBus, InjectEventConsumerRegistry } from "./event-bus.decorators"
|
|
6
14
|
export { EventBusModule } from "./event-bus.module"
|
|
7
|
-
export type {
|
|
15
|
+
export type { EventBusOptions } from "./event-bus.options"
|
|
16
|
+
export type { EventBus, EventConsumer, EventConsumerRegistry } from "./event-bus.port"
|
|
@@ -9,10 +9,10 @@ import { requestFingerprint } from "./request-fingerprint.policy"
|
|
|
9
9
|
import { PostgresInbox } from "./inbox.service"
|
|
10
10
|
|
|
11
11
|
// The statements callDbFunction of platform/database sends for each inbox function.
|
|
12
|
-
const CLAIM_EVENT =
|
|
13
|
-
const CLAIM_CLIENT =
|
|
14
|
-
const SAVE_CLIENT_OUTCOME =
|
|
15
|
-
const SWEEP_CLIENTS =
|
|
12
|
+
const CLAIM_EVENT = 'SELECT * FROM "public"."inbox_claim_event"($1, $2)'
|
|
13
|
+
const CLAIM_CLIENT = 'SELECT * FROM "public"."inbox_claim_client"($1, $2, $3, $4)'
|
|
14
|
+
const SAVE_CLIENT_OUTCOME = 'SELECT * FROM "public"."inbox_save_client_outcome"($1, $2, $3)'
|
|
15
|
+
const SWEEP_CLIENTS = 'SELECT * FROM "public"."inbox_sweep_clients"($1, $2)'
|
|
16
16
|
|
|
17
17
|
const runClient = <T>(
|
|
18
18
|
inbox: PostgresInbox,
|
|
@@ -54,7 +54,7 @@ export class PostgresInbox implements Inbox {
|
|
|
54
54
|
if (!normalizedEventId) {
|
|
55
55
|
throw new DatabaseError({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
|
|
56
56
|
}
|
|
57
|
-
const rows = await callDbFunction<InboxEventRow>(tx, "inbox_claim_event", [source, normalizedEventId])
|
|
57
|
+
const rows = await callDbFunction<InboxEventRow>(tx, "public.inbox_claim_event", [source, normalizedEventId])
|
|
58
58
|
return rows.length > 0
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -64,7 +64,7 @@ export class PostgresInbox implements Inbox {
|
|
|
64
64
|
throw new InboxError({ code: InboxErrorCode.TransactionRequired })
|
|
65
65
|
const source = clientSource(request)
|
|
66
66
|
const fingerprint = requestFingerprint(request.request)
|
|
67
|
-
const rows = await callDbFunction<ClaimClientRow<T>>(tx, "inbox_claim_client", [
|
|
67
|
+
const rows = await callDbFunction<ClaimClientRow<T>>(tx, "public.inbox_claim_client", [
|
|
68
68
|
source,
|
|
69
69
|
request.idempotencyKey,
|
|
70
70
|
fingerprint,
|
|
@@ -84,7 +84,7 @@ export class PostgresInbox implements Inbox {
|
|
|
84
84
|
} catch (cause) {
|
|
85
85
|
throw new InboxError({ code: InboxErrorCode.OutcomeInvalid, cause })
|
|
86
86
|
}
|
|
87
|
-
const saved = await callDbFunction<SavedClientOutcomeRow<T>>(tx, "inbox_save_client_outcome", [
|
|
87
|
+
const saved = await callDbFunction<SavedClientOutcomeRow<T>>(tx, "public.inbox_save_client_outcome", [
|
|
88
88
|
source,
|
|
89
89
|
request.idempotencyKey,
|
|
90
90
|
outcome,
|
|
@@ -96,7 +96,7 @@ export class PostgresInbox implements Inbox {
|
|
|
96
96
|
|
|
97
97
|
/** A row lock and SKIP LOCKED keep retention from competing with a live retry or another sweep worker. */
|
|
98
98
|
async sweepClients(cutoff: Date, tx: EntityManager): Promise<number> {
|
|
99
|
-
const rows = await callDbFunction<InboxSweepRow>(tx, "inbox_sweep_clients", [cutoff, BATCH_ROWS])
|
|
99
|
+
const rows = await callDbFunction<InboxSweepRow>(tx, "public.inbox_sweep_clients", [cutoff, BATCH_ROWS])
|
|
100
100
|
return rows[0]?.deleted_count ?? 0
|
|
101
101
|
}
|
|
102
102
|
}
|
|
@@ -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(
|
|
@@ -6,4 +6,6 @@ export enum JobsLogEvent {
|
|
|
6
6
|
DeliveryFailed = "jobs.delivery.failed",
|
|
7
7
|
/** A failed delivery could not even be recorded as failed; the lease will expire and the job will be claimed again. */
|
|
8
8
|
FailureNotRecorded = "jobs.delivery.failure-not-recorded",
|
|
9
|
+
/** A scheduler tick failed to fire; the next firing claims its own minute (lite, R335). */
|
|
10
|
+
TickFailed = "jobs.tick.failed",
|
|
9
11
|
}
|
|
@@ -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> {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { SchedulerRegistry } from "@nestjs/schedule"
|
|
2
|
+
import { Test } from "@nestjs/testing"
|
|
3
|
+
import { FakeClock, fakeTransaction, mockEntityManager } from "@starci/jest-preset"
|
|
4
|
+
import { CLOCK } from "@modules/platform/clock"
|
|
5
|
+
import { LOGGER } from "@modules/platform/logging"
|
|
6
|
+
import { QUEUE_OUTBOX } from "@modules/platform/queue"
|
|
7
|
+
import type { JobTick } from "./job-ticks.contracts"
|
|
8
|
+
import { JOBS_MANAGERS } from "./jobs.decorators"
|
|
9
|
+
import { JobTickService, tickMinute } from "./job-tick.service"
|
|
10
|
+
|
|
11
|
+
// The statements advisoryXactLock and callDbFunction of platform/database send for one firing.
|
|
12
|
+
const LOCK_TICK = "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))"
|
|
13
|
+
const CLAIM_TICK = 'SELECT * FROM "public"."job_tick_claim"($1, $2)'
|
|
14
|
+
|
|
15
|
+
const AT = "2026-02-03T04:05:06.000Z"
|
|
16
|
+
const TICK: JobTick = { name: "nightly-digest", cron: "0 2 * * *", timeZone: "UTC", queue: "digests", payload: { kind: "nightly" } }
|
|
17
|
+
|
|
18
|
+
const build = async (claimed: boolean) => {
|
|
19
|
+
const transaction = fakeTransaction(mockEntityManager({ query: [[LOCK_TICK, []], [CLAIM_TICK, [{ claimed }]]] }))
|
|
20
|
+
const outbox = { write: jest.fn().mockResolvedValue(undefined) }
|
|
21
|
+
const registry = { addCronJob: jest.fn() }
|
|
22
|
+
const moduleRef = await Test.createTestingModule({
|
|
23
|
+
providers: [
|
|
24
|
+
JobTickService,
|
|
25
|
+
{ provide: SchedulerRegistry, useValue: registry },
|
|
26
|
+
{ provide: JOBS_MANAGERS, useValue: [transaction.em] },
|
|
27
|
+
{ provide: QUEUE_OUTBOX, useValue: outbox },
|
|
28
|
+
{ provide: CLOCK, useValue: new FakeClock(AT) },
|
|
29
|
+
{ provide: LOGGER, useValue: { info: jest.fn(), warn: jest.fn(), error: jest.fn() } },
|
|
30
|
+
],
|
|
31
|
+
}).compile()
|
|
32
|
+
return { ticks: moduleRef.get(JobTickService), transaction, outbox, registry }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("JobTickService", () => {
|
|
36
|
+
it("names a tick's slot by its UTC minute", () => {
|
|
37
|
+
expect(tickMinute(new Date(AT))).toBe("2026-02-03T04:05")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("locks the tick, claims its minute and enqueues one job in the same transaction", async () => {
|
|
41
|
+
const { ticks, transaction, outbox } = await build(true)
|
|
42
|
+
|
|
43
|
+
await expect(ticks.fire(TICK)).resolves.toBe(true)
|
|
44
|
+
|
|
45
|
+
expect(transaction.em.query).toHaveBeenNthCalledWith(1, LOCK_TICK, ["tick:nightly-digest"])
|
|
46
|
+
expect(transaction.em.query).toHaveBeenNthCalledWith(2, CLAIM_TICK, ["nightly-digest", "2026-02-03T04:05"])
|
|
47
|
+
expect(outbox.write).toHaveBeenCalledWith(expect.anything(), "digests", { kind: "nightly" }, "tick:nightly-digest:2026-02-03T04:05")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("enqueues nothing when another replica already fired the tick this minute", async () => {
|
|
51
|
+
const { ticks, outbox } = await build(false)
|
|
52
|
+
|
|
53
|
+
await expect(ticks.fire(TICK)).resolves.toBe(false)
|
|
54
|
+
|
|
55
|
+
expect(outbox.write).not.toHaveBeenCalled()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("registers no cron job while the tick list is empty", async () => {
|
|
59
|
+
const { ticks, registry } = await build(true)
|
|
60
|
+
|
|
61
|
+
ticks.onApplicationBootstrap()
|
|
62
|
+
|
|
63
|
+
expect(registry.addCronJob).not.toHaveBeenCalled()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common"
|
|
2
|
+
import type { OnApplicationBootstrap } from "@nestjs/common"
|
|
3
|
+
import { SchedulerRegistry } from "@nestjs/schedule"
|
|
4
|
+
import { CronJob } from "cron"
|
|
5
|
+
import type { EntityManager } from "typeorm"
|
|
6
|
+
import { InjectClock } from "@modules/platform/clock"
|
|
7
|
+
import type { Clock } from "@modules/platform/clock"
|
|
8
|
+
import { advisoryXactLock, callDbFunction } from "@modules/platform/database"
|
|
9
|
+
import { InjectLogger } from "@modules/platform/logging"
|
|
10
|
+
import type { Logger } from "@modules/platform/logging"
|
|
11
|
+
import { InjectQueueOutbox } from "@modules/platform/queue"
|
|
12
|
+
import type { QueueOutbox } from "@modules/platform/queue"
|
|
13
|
+
import { JobsError, JobsErrorCode } from "./errors/jobs.error"
|
|
14
|
+
import { JOB_TICKS } from "./job-ticks.contracts"
|
|
15
|
+
import type { JobTick } from "./job-ticks.contracts"
|
|
16
|
+
import { InjectJobsManagers } from "./jobs.decorators"
|
|
17
|
+
import { JobsLogEvent } from "./jobs.log-events"
|
|
18
|
+
|
|
19
|
+
/** The record job_tick_claim returns. */
|
|
20
|
+
interface TickClaim {
|
|
21
|
+
/** True when this firing moved the tick to its minute and must enqueue. */
|
|
22
|
+
readonly claimed: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The UTC minute of `at` (`YYYY-MM-DDTHH:MM`): the slot a tick fires once in. */
|
|
26
|
+
export const tickMinute = (at: Date): string => at.toISOString().slice(0, 16)
|
|
27
|
+
|
|
28
|
+
@Injectable()
|
|
29
|
+
/**
|
|
30
|
+
* The one scheduler of the app (R335): @nestjs/schedule fires every tick of JOB_TICKS on every replica, and each firing takes
|
|
31
|
+
* advisoryXactLock("tick:<name>"), claims the tick's minute through job_tick_claim and, only when it moved the tick, writes one
|
|
32
|
+
* job into the queue outbox in the same transaction, so the deployment enqueues the tick once per minute.
|
|
33
|
+
*/
|
|
34
|
+
export class JobTickService implements OnApplicationBootstrap {
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly registry: SchedulerRegistry,
|
|
37
|
+
@InjectJobsManagers() private readonly managers: ReadonlyArray<EntityManager>,
|
|
38
|
+
@InjectQueueOutbox() private readonly outbox: QueueOutbox,
|
|
39
|
+
@InjectClock() private readonly clock: Clock,
|
|
40
|
+
@InjectLogger() private readonly logger: Logger,
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
/** Registers one cron job per tick, read in the tick's time zone; a failed firing is logged and the next one runs. */
|
|
44
|
+
onApplicationBootstrap(): void {
|
|
45
|
+
for (const tick of JOB_TICKS) {
|
|
46
|
+
this.registry.addCronJob(tick.name, CronJob.from({
|
|
47
|
+
cronTime: tick.cron,
|
|
48
|
+
timeZone: tick.timeZone,
|
|
49
|
+
onTick: async () => {
|
|
50
|
+
await this.fire(tick)
|
|
51
|
+
},
|
|
52
|
+
errorHandler: (cause: unknown) => {
|
|
53
|
+
this.logger.error(JobsLogEvent.TickFailed, cause, { tick: tick.name })
|
|
54
|
+
},
|
|
55
|
+
start: true,
|
|
56
|
+
}))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Fires the tick for the current minute; true when this replica enqueued its job, false when another one already had. */
|
|
61
|
+
async fire(tick: JobTick): Promise<boolean> {
|
|
62
|
+
const minute = tickMinute(this.clock.now())
|
|
63
|
+
return this.writer().transaction(async (tx) => {
|
|
64
|
+
await advisoryXactLock(tx, `tick:${tick.name}`)
|
|
65
|
+
const [claim] = await callDbFunction<TickClaim>(tx, "public.job_tick_claim", [tick.name, minute])
|
|
66
|
+
if (claim?.claimed !== true) return false
|
|
67
|
+
await this.outbox.write(tx, tick.queue, tick.payload, `tick:${tick.name}:${minute}`)
|
|
68
|
+
return true
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The entity manager of the connection that holds the tick table. */
|
|
73
|
+
private writer(): EntityManager {
|
|
74
|
+
const [manager] = this.managers
|
|
75
|
+
if (manager === undefined) throw new JobsError({ code: JobsErrorCode.ConnectionMissing })
|
|
76
|
+
return manager
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** One recurring tick of platform/jobs: at each minute its cron expression names, one job of `queue` is enqueued with `payload` (R335). */
|
|
2
|
+
export interface JobTick {
|
|
3
|
+
/** The stable name: the advisory lock key, the minute claim and the correlation id carry it. */
|
|
4
|
+
readonly name: string
|
|
5
|
+
/** The five-field cron expression of the minutes the tick fires at. */
|
|
6
|
+
readonly cron: string
|
|
7
|
+
/** The time zone the expression is read in; ticks run in UTC. */
|
|
8
|
+
readonly timeZone: "UTC"
|
|
9
|
+
/** The queue the tick enqueues one job of. */
|
|
10
|
+
readonly queue: string
|
|
11
|
+
/** The payload of that job: ids and literals only. */
|
|
12
|
+
readonly payload: object
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The ticks of this app, as data: JobTickService registers one cron job per entry. */
|
|
16
|
+
export const JOB_TICKS: ReadonlyArray<JobTick> = []
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Module } from "@nestjs/common"
|
|
2
|
+
import type { DynamicModule } from "@nestjs/common"
|
|
3
|
+
import { ScheduleModule } from "@nestjs/schedule"
|
|
4
|
+
import type { EntityManager } from "typeorm"
|
|
5
|
+
import { JOB_CLAIMS, JOB_PROCESSOR_REGISTRY, JOBS_MANAGERS } from "./jobs.decorators"
|
|
6
|
+
import { ConfigurableModuleClass, OPTIONS_TYPE } from "./jobs.module-definition"
|
|
7
|
+
import { JobClaimService } from "./job-claim.service"
|
|
8
|
+
import { JobRunnerService } from "./job-runner.service"
|
|
9
|
+
import { JobTickService } from "./job-tick.service"
|
|
10
|
+
|
|
11
|
+
@Module({})
|
|
12
|
+
/**
|
|
13
|
+
* The fenced jobs of a service: the claim service that owns the job row, the runner that subscribes processors to their queues and
|
|
14
|
+
* the one tick scheduler (R335), which composes @nestjs/schedule here and nowhere else. Needs the queue capability registered in
|
|
15
|
+
* the same app.
|
|
16
|
+
*/
|
|
17
|
+
export class JobsModule extends ConfigurableModuleClass {
|
|
18
|
+
/** Registers the capability once per app that runs jobs. */
|
|
19
|
+
static register(options: typeof OPTIONS_TYPE): DynamicModule {
|
|
20
|
+
const base = super.register(options)
|
|
21
|
+
return {
|
|
22
|
+
...base,
|
|
23
|
+
imports: [...(base.imports ?? []), ScheduleModule.forRoot()],
|
|
24
|
+
providers: [
|
|
25
|
+
...(base.providers ?? []),
|
|
26
|
+
{
|
|
27
|
+
provide: JOBS_MANAGERS,
|
|
28
|
+
useFactory: (manager: EntityManager) => [manager],
|
|
29
|
+
inject: [options.connection],
|
|
30
|
+
},
|
|
31
|
+
JobClaimService,
|
|
32
|
+
JobRunnerService,
|
|
33
|
+
JobTickService,
|
|
34
|
+
{ provide: JOB_CLAIMS, useExisting: JobClaimService },
|
|
35
|
+
{ provide: JOB_PROCESSOR_REGISTRY, useExisting: JobRunnerService },
|
|
36
|
+
],
|
|
37
|
+
exports: [JOB_CLAIMS, JOB_PROCESSOR_REGISTRY],
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -93,3 +93,47 @@ revoke all on function public.job_complete(uuid, bigint, timestamptz) from publi
|
|
|
93
93
|
grant execute on function public.job_complete(uuid, bigint, timestamptz) to app_be;
|
|
94
94
|
revoke all on function public.job_fail(uuid, bigint, text, timestamptz) from public;
|
|
95
95
|
grant execute on function public.job_fail(uuid, bigint, text, timestamptz) to app_be;
|
|
96
|
+
|
|
97
|
+
-- The scheduler ticks of platform/jobs (R335): one row per tick holds the last UTC minute it fired. Every replica's
|
|
98
|
+
-- @nestjs/schedule fires the tick; under advisoryXactLock('tick:<name>') job_tick_claim moves the row to the new minute and
|
|
99
|
+
-- answers claimed, so exactly one replica enqueues the tick's job per minute and a replica that comes later answers not claimed.
|
|
100
|
+
create table public.job_ticks (
|
|
101
|
+
name varchar(200) not null,
|
|
102
|
+
minute varchar(16) not null,
|
|
103
|
+
constraint job_ticks_pkey primary key (name)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
alter table public.job_ticks enable row level security;
|
|
107
|
+
alter table public.job_ticks force row level security;
|
|
108
|
+
|
|
109
|
+
create policy job_ticks_app_be_select
|
|
110
|
+
on public.job_ticks for select to app_be
|
|
111
|
+
using (name is not null);
|
|
112
|
+
|
|
113
|
+
create policy job_ticks_app_be_insert
|
|
114
|
+
on public.job_ticks for insert to app_be
|
|
115
|
+
with check (name is not null);
|
|
116
|
+
|
|
117
|
+
create policy job_ticks_app_be_update
|
|
118
|
+
on public.job_ticks for update to app_be
|
|
119
|
+
using (name is not null)
|
|
120
|
+
with check (name is not null);
|
|
121
|
+
|
|
122
|
+
revoke all on table public.job_ticks from public, anon, authenticated;
|
|
123
|
+
grant select, insert, update on table public.job_ticks to app_be;
|
|
124
|
+
|
|
125
|
+
create function public.job_tick_claim(p_name varchar, p_minute varchar)
|
|
126
|
+
returns table (claimed boolean)
|
|
127
|
+
language sql
|
|
128
|
+
as $$
|
|
129
|
+
with moved as (
|
|
130
|
+
insert into public.job_ticks as t (name, minute) values (p_name, p_minute)
|
|
131
|
+
on conflict (name) do update set minute = excluded.minute
|
|
132
|
+
where t.minute < excluded.minute
|
|
133
|
+
returning true as claimed
|
|
134
|
+
)
|
|
135
|
+
select coalesce((select m.claimed from moved as m), false)
|
|
136
|
+
$$;
|
|
137
|
+
|
|
138
|
+
revoke all on function public.job_tick_claim(varchar, varchar) from public;
|
|
139
|
+
grant execute on function public.job_tick_claim(varchar, varchar) to app_be;
|
|
@@ -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
|
})
|