@starci/hfs 4.9.9 → 4.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +20 -8
- package/db/backup-receipt.mjs +10 -47
- package/db/drift.mjs +289 -0
- package/db/dump.mjs +12 -11
- package/db/entity-list.mjs +9 -0
- package/db/migrate.mjs +84 -260
- package/db/new.mjs +46 -0
- package/db/post-cutover.mjs +13 -17
- package/db/postgres.mjs +80 -0
- package/emit/contracts.mjs +5 -9
- package/lint/run.mjs +12 -2
- package/package.json +5 -3
- package/release/gate.mjs +82 -31
- package/release/index.mjs +19 -8
- package/release/push.mjs +4 -4
- package/release/records.mjs +54 -0
- package/release/urgent.mjs +129 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +54 -24
- package/runtime/knowledge/hfs/design-truths.yaml +1 -1
- package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
- package/runtime/knowledge/hfs/rules.yaml +250 -157
- package/runtime/knowledge/hfs/slots.yaml +199 -174
- package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
- package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
- package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
- package/runtime/knowledge/patterns/be/projections.yaml +7 -5
- package/runtime/knowledge/patterns/be/queues.yaml +8 -8
- package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
- package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
- package/runtime/knowledge/patterns/be/saga.yaml +3 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
- package/runtime/modules/kernel/command-policy.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +86 -115
- package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
- package/runtime/scripts/hfs/app-wiring.mjs +23 -2
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
- package/runtime/scripts/hfs/architecture/index.mjs +2 -3
- package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
- package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
- package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
- package/runtime/scripts/hfs/check.mjs +8 -8
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
- package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
- package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
- package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
- package/runtime/scripts/hfs/manifest-shape.mjs +6 -5
- package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
- package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
- package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
- package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
- package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
- package/runtime/scripts/hfs/rules/database.mjs +36 -39
- package/runtime/scripts/hfs/rules/edition.mjs +1 -1
- package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
- package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
- package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +8 -2
- package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/runtime/scripts/lib/swarm-stack.mjs +7 -4
- package/scaffold/add-app.mjs +2 -10
- package/scaffold/add-cli-lite.mjs +6 -6
- package/scaffold/add-compose.mjs +1 -1
- package/scaffold/add-inbox-lite.mjs +4 -8
- package/scaffold/add-plan.mjs +1 -1
- package/scaffold/add-queue-lite.mjs +16 -24
- package/scaffold/add-table.mjs +34 -26
- package/scaffold/add.mjs +2 -2
- package/scaffold/app.mjs +19 -86
- package/scaffold/lite-exports.mjs +1 -1
- package/src/main.mjs +24 -22
- package/stack/cli.mjs +10 -2
- package/stack/deploy.mjs +21 -4
- package/stack/target-binding.mjs +0 -60
- package/stack/urgent-floor.mjs +147 -0
- package/sync/ci-commands.mjs +3 -1
- package/sync/index.mjs +32 -8
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
- package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
- package/templates/app/gitignore-lite +0 -3
- package/templates/app/package-scripts/db.json +9 -0
- package/templates/app/package-scripts/package.json +0 -1
- package/templates/app/package-scripts-lite/package.json +1 -1
- package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
- package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
- package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
- package/templates/app/skeleton-lite/README.md +11 -11
- package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
- package/templates/app/tool-config-lite/prettierignore +0 -1
- package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
- package/templates/be/inbox/database-client.ts.tpl +13 -4
- package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
- package/templates/be/inbox/platform/migration.sql.tpl +22 -0
- package/templates/be/inbox/service.spec.ts.tpl +2 -2
- package/templates/be/inbox/service.ts.tpl +1 -2
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
- package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
- package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
- package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
- package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
- package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
- package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +23 -13
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
- package/templates/be/table/entities.index.ts.tpl +4 -0
- package/templates/be/table/entity.ts.tpl +21 -0
- package/templates/be/table/index.ts.tpl +1 -0
- package/templates/be/table/module.ts.tpl +1 -1
- package/templates/be/table/service.api.ts.tpl +10 -11
- package/templates/be/table/service.ts.tpl +5 -6
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
- package/db/local-migrate.mjs +0 -194
- package/db/reset.mjs +0 -125
- package/emit/db-types.mjs +0 -271
- package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
- package/runtime/scripts/api/supabase/database-push.mjs +0 -5
- package/runtime/scripts/api/supabase/database-query.mjs +0 -5
- package/runtime/scripts/api/supabase/lib.mjs +0 -55
- package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
- package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
- package/scaffold/supabase-ports.mjs +0 -26
- package/templates/app/package-scripts/supabase.json +0 -7
- package/templates/app/skeleton-lite/supabase/config.toml +0 -48
- package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
- package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
- package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
- package/templates/be/inbox/sql.ts.tpl +0 -16
- package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
- package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
- package/templates/be/table/rows.ts.tpl +0 -4
- package/templates/be/table/sql.api.ts.tpl +0 -7
- package/templates/be/table/sql.ts.tpl +0 -4
- package/templates/fe/auth/supabase-client.ts.tpl +0 -38
- package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
- /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
|
@@ -2,14 +2,46 @@ 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 { BATCH_ROWS, DatabaseError, DatabaseErrorCode } from "@modules/platform/database"
|
|
5
|
+
import { BATCH_ROWS, DatabaseError, DatabaseErrorCode, callDbFunction } from "@modules/platform/database"
|
|
6
6
|
import type { ClientOperation, ClientOperationEffect, Inbox, InboxEventId } from "./inbox.port"
|
|
7
7
|
import { IdempotencyError, IdempotencyErrorCode } from "@modules/platform/idempotency"
|
|
8
8
|
import { InboxError, InboxErrorCode } from "./errors/inbox.error"
|
|
9
9
|
import { clientSource } from "./client-operation.policy"
|
|
10
10
|
import { canonicalJson, requestFingerprint } from "./request-fingerprint.policy"
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
|
|
12
|
+
/** The JSON envelope makes a completed null value distinct from a pending SQL-null outcome. */
|
|
13
|
+
interface ClientOutcomeRow<T> {
|
|
14
|
+
/** The operation's complete JSON result, including expected refusals. */
|
|
15
|
+
readonly value: T
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The row returned by the atomic client claim. */
|
|
19
|
+
interface ClaimClientRow<T> {
|
|
20
|
+
/** The digest recorded by the first operation under this scoped key. */
|
|
21
|
+
readonly request_fingerprint: string | null
|
|
22
|
+
/** The saved result, or null only for a claim inserted by this transaction. */
|
|
23
|
+
readonly outcome: ClientOutcomeRow<T> | null
|
|
24
|
+
/** An existing row had no completed outcome, so it must never run an effect again. */
|
|
25
|
+
readonly incomplete: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The persisted outcome returned to the first caller. */
|
|
29
|
+
interface SavedClientOutcomeRow<T> {
|
|
30
|
+
/** The normalized JSON result as the database stores it. */
|
|
31
|
+
readonly outcome: ClientOutcomeRow<T>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A claimed or deleted delivery key returned by inbox persistence. */
|
|
35
|
+
interface InboxEventRow {
|
|
36
|
+
/** The event or client key owned by the row. */
|
|
37
|
+
readonly event_id: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The bounded retention batch's aggregate result. */
|
|
41
|
+
interface InboxSweepRow {
|
|
42
|
+
/** Number of completed client claims removed by this transaction. */
|
|
43
|
+
readonly deleted_count: number
|
|
44
|
+
}
|
|
13
45
|
|
|
14
46
|
@Injectable()
|
|
15
47
|
/** Claims a delivery with the receipt transaction, so a failure can be retried without losing the delivery. */
|
|
@@ -22,7 +54,7 @@ export class PostgresInbox implements Inbox {
|
|
|
22
54
|
if (!normalizedEventId) {
|
|
23
55
|
throw new DatabaseError({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
|
|
24
56
|
}
|
|
25
|
-
const rows
|
|
57
|
+
const rows = await callDbFunction<InboxEventRow>(tx, "inbox_claim_event", [source, normalizedEventId])
|
|
26
58
|
return rows.length > 0
|
|
27
59
|
}
|
|
28
60
|
|
|
@@ -32,7 +64,7 @@ export class PostgresInbox implements Inbox {
|
|
|
32
64
|
throw new InboxError({ code: InboxErrorCode.TransactionRequired })
|
|
33
65
|
const source = clientSource(request)
|
|
34
66
|
const fingerprint = requestFingerprint(request.request)
|
|
35
|
-
const rows
|
|
67
|
+
const rows = await callDbFunction<ClaimClientRow<T>>(tx, "inbox_claim_client", [
|
|
36
68
|
source,
|
|
37
69
|
request.idempotencyKey,
|
|
38
70
|
fingerprint,
|
|
@@ -52,7 +84,7 @@ export class PostgresInbox implements Inbox {
|
|
|
52
84
|
} catch (cause) {
|
|
53
85
|
throw new InboxError({ code: InboxErrorCode.OutcomeInvalid, cause })
|
|
54
86
|
}
|
|
55
|
-
const saved
|
|
87
|
+
const saved = await callDbFunction<SavedClientOutcomeRow<T>>(tx, "inbox_save_client_outcome", [
|
|
56
88
|
source,
|
|
57
89
|
request.idempotencyKey,
|
|
58
90
|
outcome,
|
|
@@ -64,7 +96,7 @@ export class PostgresInbox implements Inbox {
|
|
|
64
96
|
|
|
65
97
|
/** A row lock and SKIP LOCKED keep retention from competing with a live retry or another sweep worker. */
|
|
66
98
|
async sweepClients(cutoff: Date, tx: EntityManager): Promise<number> {
|
|
67
|
-
const rows
|
|
99
|
+
const rows = await callDbFunction<InboxSweepRow>(tx, "inbox_sweep_clients", [cutoff, BATCH_ROWS])
|
|
68
100
|
return rows[0]?.deleted_count ?? 0
|
|
69
101
|
}
|
|
70
102
|
}
|
|
@@ -47,3 +47,25 @@ with check (event_name <> '');
|
|
|
47
47
|
|
|
48
48
|
revoke all on table public.event_bus from public, anon, authenticated;
|
|
49
49
|
grant select, insert, update on table public.event_bus to app_be;
|
|
50
|
+
|
|
51
|
+
-- The claim of one delivery and the typed envelope write, called through callDbFunction of platform/database (R334).
|
|
52
|
+
create function public.inbox_claim_event(p_source text, p_event_id text)
|
|
53
|
+
returns table (event_id text)
|
|
54
|
+
language sql
|
|
55
|
+
as $$
|
|
56
|
+
insert into public.inbox as i (source, event_id) values (p_source, p_event_id)
|
|
57
|
+
on conflict (source, event_id) do nothing
|
|
58
|
+
returning i.event_id
|
|
59
|
+
$$;
|
|
60
|
+
|
|
61
|
+
create function public.event_bus_write(p_event_id text, p_event_name text, p_payload jsonb, p_correlation_id varchar)
|
|
62
|
+
returns void
|
|
63
|
+
language sql
|
|
64
|
+
as $$
|
|
65
|
+
insert into public.event_bus (event_id, event_name, payload, correlation_id) values (p_event_id, p_event_name, p_payload, p_correlation_id)
|
|
66
|
+
$$;
|
|
67
|
+
|
|
68
|
+
revoke all on function public.inbox_claim_event(text, text) from public;
|
|
69
|
+
grant execute on function public.inbox_claim_event(text, text) to app_be;
|
|
70
|
+
revoke all on function public.event_bus_write(text, text, jsonb, varchar) from public;
|
|
71
|
+
grant execute on function public.event_bus_write(text, text, jsonb, varchar) to app_be;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { fakeInbox, fakeTransaction, mockEntityManager, recordingEventBus } from "@starci/jest-preset"
|
|
2
2
|
import { Test } from "@nestjs/testing"
|
|
3
|
-
import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
|
|
3
|
+
import { PRIMARY_ENTITY_MANAGER, webhookReceiptInsert } 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 { INSERT_@@providerUpper@@_INBOX } from "./persistence/@@provider@@-inbox.sql"
|
|
8
7
|
import { @@Provider@@InboxService } from "./@@provider@@-inbox.service"
|
|
9
8
|
|
|
10
9
|
const DELIVERY = { id: "delivery-1" }
|
|
10
|
+
const INSERT_@@providerUpper@@_INBOX = webhookReceiptInsert("@@table@@")
|
|
11
11
|
|
|
12
12
|
const build = async (fresh: boolean, recorded: ReadonlyArray<{ readonly id: string }> = [{ id: "inbox-1" }]) => {
|
|
13
13
|
const entityManager = mockEntityManager({ query: [INSERT_@@providerUpper@@_INBOX, recorded] })
|
|
@@ -6,7 +6,6 @@ import { InjectEventBus } from "@modules/platform/event-bus"
|
|
|
6
6
|
import type { EventBus } from "@modules/platform/event-bus"
|
|
7
7
|
import { InjectInbox } from "@modules/platform/inbox"
|
|
8
8
|
import type { Inbox } from "@modules/platform/inbox"
|
|
9
|
-
import { INSERT_@@providerUpper@@_INBOX } from "./persistence/@@provider@@-inbox.sql"
|
|
10
9
|
|
|
11
10
|
interface @@Provider@@Delivery {
|
|
12
11
|
readonly id: string
|
|
@@ -34,7 +33,7 @@ export class @@Provider@@InboxService {
|
|
|
34
33
|
if (!(await this.inbox.claim("webhook.@@provider@@", request.eventId, manager))) return
|
|
35
34
|
const receiptId = await recordWebhookDelivery({
|
|
36
35
|
manager,
|
|
37
|
-
|
|
36
|
+
table: "@@table@@",
|
|
38
37
|
provider: "@@provider@@",
|
|
39
38
|
deliveryId: request.eventId,
|
|
40
39
|
payload: request.delivery,
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Test } from "@nestjs/testing"
|
|
2
|
+
import { FakeClock, mockEntityManager } from "@starci/jest-preset"
|
|
3
|
+
import type { MockEntityManager } from "@starci/jest-preset"
|
|
4
|
+
import { CLOCK } from "@modules/platform/clock"
|
|
5
|
+
import { JobsError, JobsErrorCode } from "./errors/jobs.error"
|
|
6
|
+
import type { ClaimedJob } from "./jobs.contracts"
|
|
7
|
+
import { JOBS_MANAGERS } from "./jobs.decorators"
|
|
8
|
+
import { JobClaimService } from "./job-claim.service"
|
|
9
|
+
|
|
10
|
+
// The statements callDbFunction of platform/database sends for each job function.
|
|
11
|
+
const CLAIM_JOB = "SELECT * FROM job_claim($1, $2, $3, $4, $5, $6)"
|
|
12
|
+
const ADVANCE_JOB = "SELECT * FROM job_advance($1, $2, $3, $4)"
|
|
13
|
+
const COMPLETE_JOB = "SELECT * FROM job_complete($1, $2, $3)"
|
|
14
|
+
const FAIL_JOB = "SELECT * FROM job_fail($1, $2, $3, $4)"
|
|
15
|
+
|
|
16
|
+
const AT = "2026-02-03T04:05:06.000Z"
|
|
17
|
+
|
|
18
|
+
const build = async (manager: MockEntityManager) => {
|
|
19
|
+
const moduleRef = await Test.createTestingModule({
|
|
20
|
+
providers: [
|
|
21
|
+
JobClaimService,
|
|
22
|
+
{ provide: JOBS_MANAGERS, useValue: [manager] },
|
|
23
|
+
{ provide: CLOCK, useValue: new FakeClock(AT) },
|
|
24
|
+
],
|
|
25
|
+
}).compile()
|
|
26
|
+
return { claims: moduleRef.get(JobClaimService) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const buildWithoutConnection = async () => {
|
|
30
|
+
const moduleRef = await Test.createTestingModule({
|
|
31
|
+
providers: [
|
|
32
|
+
JobClaimService,
|
|
33
|
+
{ provide: JOBS_MANAGERS, useValue: [] },
|
|
34
|
+
{ provide: CLOCK, useValue: new FakeClock(AT) },
|
|
35
|
+
],
|
|
36
|
+
}).compile()
|
|
37
|
+
return { claims: moduleRef.get(JobClaimService) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const claimed: ClaimedJob = { correlationId: "request-1", jobId: "j-1", kind: "mail", fencingToken: 3, currentStep: null, payload: { id: "m-1" } }
|
|
41
|
+
|
|
42
|
+
describe("JobClaimService", () => {
|
|
43
|
+
describe("claim", () => {
|
|
44
|
+
it("claims the delivery in one statement and returns the job with the bumped token", async () => {
|
|
45
|
+
const manager = mockEntityManager({
|
|
46
|
+
query: [
|
|
47
|
+
CLAIM_JOB,
|
|
48
|
+
[{ id: "j-1", kind: "mail", fencing_token: "3", current_step: null, payload: { id: "m-1" } }],
|
|
49
|
+
],
|
|
50
|
+
})
|
|
51
|
+
const { claims } = await build(manager)
|
|
52
|
+
|
|
53
|
+
const job = await claims.claim({
|
|
54
|
+
kind: "mail",
|
|
55
|
+
jobKey: "k-1",
|
|
56
|
+
correlationId: "request-1",
|
|
57
|
+
payload: { id: "m-1" },
|
|
58
|
+
workerId: "w-1",
|
|
59
|
+
leaseMs: 60000,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
expect(job).toEqual(claimed)
|
|
63
|
+
expect(manager.query).toHaveBeenCalledWith(CLAIM_JOB, [
|
|
64
|
+
"mail",
|
|
65
|
+
"k-1",
|
|
66
|
+
"w-1",
|
|
67
|
+
new Date("2026-02-03T04:06:06.000Z"),
|
|
68
|
+
JSON.stringify({ id: "m-1" }),
|
|
69
|
+
new Date(AT),
|
|
70
|
+
])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("answers null when the job is done or another worker holds a live claim", async () => {
|
|
74
|
+
const manager = mockEntityManager({ query: [CLAIM_JOB, []] })
|
|
75
|
+
const { claims } = await build(manager)
|
|
76
|
+
|
|
77
|
+
await expect(
|
|
78
|
+
claims.claim({ correlationId: "request-1", kind: "mail", jobKey: "k-1", payload: {}, workerId: "w-1", leaseMs: 1000 }),
|
|
79
|
+
).resolves.toBeNull()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe("guarded writes", () => {
|
|
84
|
+
it("records a step, completes and fails under the token the caller holds", async () => {
|
|
85
|
+
const manager = mockEntityManager({
|
|
86
|
+
query: [
|
|
87
|
+
[ADVANCE_JOB, [{ id: "j-1" }]],
|
|
88
|
+
[COMPLETE_JOB, [{ id: "j-1" }]],
|
|
89
|
+
[FAIL_JOB, [{ id: "j-1" }]],
|
|
90
|
+
],
|
|
91
|
+
})
|
|
92
|
+
const { claims } = await build(manager)
|
|
93
|
+
|
|
94
|
+
await claims.advance({ jobId: "j-1", expectedFencingToken: 3, step: "send" })
|
|
95
|
+
await claims.complete({ jobId: "j-1", expectedFencingToken: 3 })
|
|
96
|
+
await claims.fail({ jobId: "j-1", expectedFencingToken: 3, reason: "boom" })
|
|
97
|
+
|
|
98
|
+
expect(manager.query).toHaveBeenNthCalledWith(1, ADVANCE_JOB, ["j-1", 3, "send", new Date(AT)])
|
|
99
|
+
expect(manager.query).toHaveBeenNthCalledWith(2, COMPLETE_JOB, ["j-1", 3, new Date(AT)])
|
|
100
|
+
expect(manager.query).toHaveBeenNthCalledWith(3, FAIL_JOB, ["j-1", 3, "boom", new Date(AT)])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("throws JobFencedOut when a write finds no row at its token: a newer worker owns the job", async () => {
|
|
104
|
+
const manager = mockEntityManager({
|
|
105
|
+
query: [
|
|
106
|
+
[ADVANCE_JOB, []],
|
|
107
|
+
[COMPLETE_JOB, []],
|
|
108
|
+
[FAIL_JOB, []],
|
|
109
|
+
],
|
|
110
|
+
})
|
|
111
|
+
const { claims } = await build(manager)
|
|
112
|
+
|
|
113
|
+
const attempts = [
|
|
114
|
+
claims.advance({ jobId: "j-1", expectedFencingToken: 2, step: "send" }),
|
|
115
|
+
claims.complete({ jobId: "j-1", expectedFencingToken: 2 }),
|
|
116
|
+
claims.fail({ jobId: "j-1", expectedFencingToken: 2, reason: "boom" }),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
for (const attempt of attempts) {
|
|
120
|
+
await expect(attempt).rejects.toMatchObject({
|
|
121
|
+
code: JobsErrorCode.FencedOut,
|
|
122
|
+
params: { jobId: "j-1", token: 2 },
|
|
123
|
+
})
|
|
124
|
+
await expect(attempt).rejects.toBeInstanceOf(JobsError)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe("runKey", () => {
|
|
130
|
+
it("includes the job, the step and the fencing token, so a zombie's key differs from the live worker's", async () => {
|
|
131
|
+
const { claims } = await build(mockEntityManager())
|
|
132
|
+
|
|
133
|
+
expect(claims.runKey(claimed, "send")).toBe("j-1:send:3")
|
|
134
|
+
expect(claims.runKey({ ...claimed, fencingToken: 4 }, "send")).toBe("j-1:send:4")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("refuses a step name that carries the separator, because the key would be ambiguous", async () => {
|
|
138
|
+
const { claims } = await build(mockEntityManager())
|
|
139
|
+
|
|
140
|
+
expect(() => claims.runKey(claimed, "send:twice")).toThrow(JobsError)
|
|
141
|
+
expect(() => claims.runKey(claimed, "send:twice")).toThrow(JobsErrorCode.RunKeyInvalid)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe("registration", () => {
|
|
146
|
+
it("refuses to claim with no connection to hold the job table", async () => {
|
|
147
|
+
const { claims } = await buildWithoutConnection()
|
|
148
|
+
|
|
149
|
+
await expect(
|
|
150
|
+
claims.claim({ correlationId: "request-1", kind: "mail", jobKey: "k-1", payload: {}, workerId: "w-1", leaseMs: 1000 }),
|
|
151
|
+
).rejects.toMatchObject({
|
|
152
|
+
code: JobsErrorCode.ConnectionMissing,
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common"
|
|
2
|
+
import type { EntityManager } from "typeorm"
|
|
3
|
+
import { InjectClock } from "@modules/platform/clock"
|
|
4
|
+
import type { Clock } from "@modules/platform/clock"
|
|
5
|
+
import { JobsError, JobsErrorCode } from "./errors/jobs.error"
|
|
6
|
+
import { callDbFunction } from "@modules/platform/database"
|
|
7
|
+
import { isRunKey } from "./jobs.contracts"
|
|
8
|
+
import type {
|
|
9
|
+
AdvanceWrite,
|
|
10
|
+
ClaimedJob,
|
|
11
|
+
ClaimParams,
|
|
12
|
+
ClaimResult,
|
|
13
|
+
FailWrite,
|
|
14
|
+
GuardedWrite,
|
|
15
|
+
RunKey,
|
|
16
|
+
} from "./jobs.contracts"
|
|
17
|
+
import { InjectJobsManagers } from "./jobs.decorators"
|
|
18
|
+
import type { JobClaims } from "./jobs.port"
|
|
19
|
+
|
|
20
|
+
/** The record job_claim returns (a bigint arrives as text). */
|
|
21
|
+
interface ClaimedRecord {
|
|
22
|
+
/** The id of the job row. */
|
|
23
|
+
readonly id: string
|
|
24
|
+
/** The kind of the job. */
|
|
25
|
+
readonly kind: string
|
|
26
|
+
/** The fencing token after the claim. */
|
|
27
|
+
readonly fencing_token: string
|
|
28
|
+
/** The last recorded step, or null. */
|
|
29
|
+
readonly current_step: string | null
|
|
30
|
+
/** The payload, as the database returns the jsonb value. */
|
|
31
|
+
readonly payload: object
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Injectable()
|
|
35
|
+
/**
|
|
36
|
+
* The one owner of the job row, through the job_* functions of its migration (R334). A claim is one atomic upsert that bumps the fencing token in the store, so two workers can never
|
|
37
|
+
* hold the same token; every other write is `WHERE fencing_token = <expected>`, so a worker that lost its claim (a zombie) changes
|
|
38
|
+
* nothing and learns it from `JobFencedOut`.
|
|
39
|
+
*/
|
|
40
|
+
export class JobClaimService implements JobClaims {
|
|
41
|
+
constructor(
|
|
42
|
+
@InjectJobsManagers() private readonly managers: ReadonlyArray<EntityManager>,
|
|
43
|
+
@InjectClock() private readonly clock: Clock,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
/** Claims the delivery; null when the job is done or another worker holds a claim that has not expired. */
|
|
47
|
+
async claim(params: ClaimParams): Promise<ClaimResult> {
|
|
48
|
+
const now = this.clock.now()
|
|
49
|
+
const rows = await callDbFunction<ClaimedRecord>(this.writer(), "job_claim", [
|
|
50
|
+
params.kind,
|
|
51
|
+
params.jobKey,
|
|
52
|
+
params.workerId,
|
|
53
|
+
new Date(now.getTime() + params.leaseMs),
|
|
54
|
+
JSON.stringify(params.payload),
|
|
55
|
+
now,
|
|
56
|
+
])
|
|
57
|
+
const row = rows[0]
|
|
58
|
+
if (row === undefined) return null
|
|
59
|
+
// The durable delivery supplies the same request id on every retry; the job fence identifies a claim separately.
|
|
60
|
+
return {
|
|
61
|
+
jobId: row.id,
|
|
62
|
+
correlationId: params.correlationId,
|
|
63
|
+
kind: row.kind,
|
|
64
|
+
fencingToken: Number(row.fencing_token),
|
|
65
|
+
currentStep: row.current_step,
|
|
66
|
+
payload: row.payload,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Records a finished step under the token the caller holds. */
|
|
71
|
+
advance(write: AdvanceWrite): Promise<void> {
|
|
72
|
+
return this.guarded("job_advance", [write.jobId, write.expectedFencingToken, write.step, this.clock.now()], write)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Completes the job under the token the caller holds. */
|
|
76
|
+
complete(write: GuardedWrite): Promise<void> {
|
|
77
|
+
return this.guarded("job_complete", [write.jobId, write.expectedFencingToken, this.clock.now()], write)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Marks the job failed under the token the caller holds, so BullMQ can retry it. */
|
|
81
|
+
fail(write: FailWrite): Promise<void> {
|
|
82
|
+
return this.guarded("job_fail", [write.jobId, write.expectedFencingToken, write.reason, this.clock.now()], write)
|
|
83
|
+
}
|
|
84
|
+
|
|
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. */
|
|
86
|
+
runKey(job: ClaimedJob, step: string): RunKey {
|
|
87
|
+
const key = `${job.jobId}:${step}:${job.fencingToken}`
|
|
88
|
+
if (!isRunKey(key))
|
|
89
|
+
throw new JobsError({ code: JobsErrorCode.RunKeyInvalid, params: { jobId: job.jobId, step } })
|
|
90
|
+
return key
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The entity manager of the connection that holds the job table. */
|
|
94
|
+
private writer(): EntityManager {
|
|
95
|
+
const [manager] = this.managers
|
|
96
|
+
if (manager === undefined) throw new JobsError({ code: JobsErrorCode.ConnectionMissing })
|
|
97
|
+
return manager
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Runs a guarded function that updates only when the token still matches; no row back means a newer worker owns the job. */
|
|
101
|
+
private async guarded(
|
|
102
|
+
fn: "job_advance" | "job_complete" | "job_fail",
|
|
103
|
+
parameters: Array<unknown>,
|
|
104
|
+
write: GuardedWrite,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const rows = await callDbFunction<object>(this.writer(), fn, parameters)
|
|
107
|
+
if (rows.length === 0) {
|
|
108
|
+
throw new JobsError({
|
|
109
|
+
code: JobsErrorCode.FencedOut,
|
|
110
|
+
params: { jobId: write.jobId, token: write.expectedFencingToken },
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -42,3 +42,54 @@ with check (kind is not null);
|
|
|
42
42
|
|
|
43
43
|
revoke all on table public.jobs from public, anon, authenticated;
|
|
44
44
|
grant select, insert, update on table public.jobs to app_be;
|
|
45
|
+
|
|
46
|
+
-- The claim and the guarded writes, called through callDbFunction of platform/database (R334). A claim inserts a new key with
|
|
47
|
+
-- token 1 or claims a failed or expired one again, bumping the token; a done job or a live claim returns no row. Every other
|
|
48
|
+
-- write matches only the token the caller holds, so a worker that lost its claim changes nothing and gets no row back.
|
|
49
|
+
create function public.job_claim(p_kind varchar, p_job_key varchar, p_worker varchar, p_lease_end timestamptz, p_payload jsonb, p_now timestamptz)
|
|
50
|
+
returns table (id uuid, kind varchar, fencing_token bigint, current_step varchar, payload jsonb)
|
|
51
|
+
language sql
|
|
52
|
+
as $$
|
|
53
|
+
insert into public.jobs as j (kind, job_key, status, fencing_token, claimed_by, lease_expires_at, payload, created_at, updated_at)
|
|
54
|
+
values (p_kind, p_job_key, 'running', 1, p_worker, p_lease_end, p_payload, p_now, p_now)
|
|
55
|
+
on conflict (job_key) do update
|
|
56
|
+
set fencing_token = j.fencing_token + 1, claimed_by = p_worker, lease_expires_at = p_lease_end, status = 'running', error = null, updated_at = p_now
|
|
57
|
+
where j.status = 'failed' or (j.status = 'running' and j.lease_expires_at < p_now)
|
|
58
|
+
returning j.id, j.kind, j.fencing_token, j.current_step, j.payload
|
|
59
|
+
$$;
|
|
60
|
+
|
|
61
|
+
create function public.job_advance(p_id uuid, p_token bigint, p_step varchar, p_now timestamptz)
|
|
62
|
+
returns table (id uuid)
|
|
63
|
+
language sql
|
|
64
|
+
as $$
|
|
65
|
+
update public.jobs as j set current_step = p_step, updated_at = p_now
|
|
66
|
+
where j.id = p_id and j.fencing_token = p_token and j.status = 'running'
|
|
67
|
+
returning j.id
|
|
68
|
+
$$;
|
|
69
|
+
|
|
70
|
+
create function public.job_complete(p_id uuid, p_token bigint, p_now timestamptz)
|
|
71
|
+
returns table (id uuid)
|
|
72
|
+
language sql
|
|
73
|
+
as $$
|
|
74
|
+
update public.jobs as j set status = 'done', lease_expires_at = null, updated_at = p_now
|
|
75
|
+
where j.id = p_id and j.fencing_token = p_token and j.status = 'running'
|
|
76
|
+
returning j.id
|
|
77
|
+
$$;
|
|
78
|
+
|
|
79
|
+
create function public.job_fail(p_id uuid, p_token bigint, p_reason text, p_now timestamptz)
|
|
80
|
+
returns table (id uuid)
|
|
81
|
+
language sql
|
|
82
|
+
as $$
|
|
83
|
+
update public.jobs as j set status = 'failed', error = p_reason, lease_expires_at = null, updated_at = p_now
|
|
84
|
+
where j.id = p_id and j.fencing_token = p_token and j.status = 'running'
|
|
85
|
+
returning j.id
|
|
86
|
+
$$;
|
|
87
|
+
|
|
88
|
+
revoke all on function public.job_claim(varchar, varchar, varchar, timestamptz, jsonb, timestamptz) from public;
|
|
89
|
+
grant execute on function public.job_claim(varchar, varchar, varchar, timestamptz, jsonb, timestamptz) to app_be;
|
|
90
|
+
revoke all on function public.job_advance(uuid, bigint, varchar, timestamptz) from public;
|
|
91
|
+
grant execute on function public.job_advance(uuid, bigint, varchar, timestamptz) to app_be;
|
|
92
|
+
revoke all on function public.job_complete(uuid, bigint, timestamptz) from public;
|
|
93
|
+
grant execute on function public.job_complete(uuid, bigint, timestamptz) to app_be;
|
|
94
|
+
revoke all on function public.job_fail(uuid, bigint, text, timestamptz) from public;
|
|
95
|
+
grant execute on function public.job_fail(uuid, bigint, text, timestamptz) to app_be;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Test } from "@nestjs/testing"
|
|
2
|
+
import { FakeClock, fakeTransaction, mockEntityManager } from "@starci/jest-preset"
|
|
3
|
+
import { CLOCK } from "@modules/platform/clock"
|
|
4
|
+
import { QueueOutboxService } from "./queue-outbox.service"
|
|
5
|
+
|
|
6
|
+
// The statement callDbFunction of platform/database sends for queue_write.
|
|
7
|
+
const INSERT_QUEUE_ROW = "SELECT * FROM queue_write($1, $2, $3, $4)"
|
|
8
|
+
import { QueueErrorCode } from "./errors/queue.error"
|
|
9
|
+
|
|
10
|
+
const AT = "2026-02-03T04:05:06.000Z"
|
|
11
|
+
|
|
12
|
+
const build = async () => {
|
|
13
|
+
const moduleRef = await Test.createTestingModule({
|
|
14
|
+
providers: [QueueOutboxService, { provide: CLOCK, useValue: new FakeClock(AT) }],
|
|
15
|
+
}).compile()
|
|
16
|
+
return { outbox: moduleRef.get(QueueOutboxService) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("QueueOutboxService", () => {
|
|
20
|
+
describe("write", () => {
|
|
21
|
+
it("writes the job as a row of the outbox of the transaction it is given", async () => {
|
|
22
|
+
const { outbox } = await build()
|
|
23
|
+
const tx = fakeTransaction(mockEntityManager({ query: [INSERT_QUEUE_ROW, []] }))
|
|
24
|
+
|
|
25
|
+
await tx.em.transaction((manager) => outbox.write(manager, "mail", { id: "m-1" }, "request-1"))
|
|
26
|
+
|
|
27
|
+
expect(tx.em.query).toHaveBeenCalledWith(INSERT_QUEUE_ROW, [
|
|
28
|
+
"mail",
|
|
29
|
+
JSON.stringify({ id: "m-1" }),
|
|
30
|
+
new Date(AT),
|
|
31
|
+
"request-1",
|
|
32
|
+
])
|
|
33
|
+
expect(tx.commits).toBe(1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("leaves no row behind when the transaction rolls back", async () => {
|
|
37
|
+
const { outbox } = await build()
|
|
38
|
+
const tx = fakeTransaction(mockEntityManager({ query: [INSERT_QUEUE_ROW, []] }))
|
|
39
|
+
const failure = new Error("the change failed")
|
|
40
|
+
|
|
41
|
+
await expect(
|
|
42
|
+
tx.em.transaction(async (manager) => {
|
|
43
|
+
await outbox.write(manager, "mail", { id: "m-1" }, "request-1")
|
|
44
|
+
return Promise.reject(failure)
|
|
45
|
+
}),
|
|
46
|
+
).rejects.toBe(failure)
|
|
47
|
+
|
|
48
|
+
expect(tx.rollbacks).toBe(1)
|
|
49
|
+
expect(tx.committedWrites).toEqual([])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("rejects an unsafe correlation id before writing any SQL", async () => {
|
|
53
|
+
const { outbox } = await build()
|
|
54
|
+
const tx = fakeTransaction(mockEntityManager())
|
|
55
|
+
await expect(
|
|
56
|
+
tx.em.transaction((manager) => outbox.write(manager, "mail", {}, "bad\nid")),
|
|
57
|
+
).rejects.toMatchObject({ code: QueueErrorCode.CorrelationInvalid })
|
|
58
|
+
expect(tx.em.query).not.toHaveBeenCalled()
|
|
59
|
+
expect(tx.rollbacks).toBe(1)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common"
|
|
2
|
+
import type { EntityManager } from "typeorm"
|
|
3
|
+
import { InjectClock } from "@modules/platform/clock"
|
|
4
|
+
import type { Clock } from "@modules/platform/clock"
|
|
5
|
+
import { isCorrelationId } from "@modules/platform/logging"
|
|
6
|
+
import type { QueueOutbox } from "./queue.port"
|
|
7
|
+
import { QueueError, QueueErrorCode } from "./errors/queue.error"
|
|
8
|
+
import { callDbFunction } from "@modules/platform/database"
|
|
9
|
+
|
|
10
|
+
@Injectable()
|
|
11
|
+
/** The writing side of the queues: a job is a row of the outbox of the caller's transaction, so it exists exactly when the change that needs it commits. */
|
|
12
|
+
export class QueueOutboxService implements QueueOutbox {
|
|
13
|
+
constructor(@InjectClock() private readonly clock: Clock) {}
|
|
14
|
+
|
|
15
|
+
/** Writes the job in the outbox of the transaction `tx`; the relay adds it to BullMQ after the commit. */
|
|
16
|
+
async write(tx: EntityManager, queue: string, payload: object, correlationId: string): Promise<void> {
|
|
17
|
+
if (!isCorrelationId(correlationId)) throw new QueueError({ code: QueueErrorCode.CorrelationInvalid })
|
|
18
|
+
await callDbFunction(tx, "queue_write", [queue, JSON.stringify(payload), this.clock.now(), correlationId])
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -30,3 +30,38 @@ with check (queue is not null);
|
|
|
30
30
|
|
|
31
31
|
revoke all on table public.queue from public, anon, authenticated;
|
|
32
32
|
grant select, insert, update on table public.queue to app_be;
|
|
33
|
+
|
|
34
|
+
-- The outbox write, the relay's locked read of the waiting rows and its mark, called through callDbFunction of platform/database
|
|
35
|
+
-- (R334). The read locks the rows it returns for the caller's transaction and skips rows another relay holds.
|
|
36
|
+
create function public.queue_write(p_queue varchar, p_payload jsonb, p_created_at timestamptz, p_correlation_id varchar)
|
|
37
|
+
returns void
|
|
38
|
+
language sql
|
|
39
|
+
as $$
|
|
40
|
+
insert into public.queue (queue, payload, created_at, correlation_id) values (p_queue, p_payload, p_created_at, p_correlation_id)
|
|
41
|
+
$$;
|
|
42
|
+
|
|
43
|
+
create function public.queue_waiting(p_batch integer)
|
|
44
|
+
returns table (id uuid, queue varchar, payload jsonb, "correlationId" varchar)
|
|
45
|
+
language sql
|
|
46
|
+
as $$
|
|
47
|
+
select q.id, q.queue, q.payload, coalesce(q.correlation_id, q.id::text)
|
|
48
|
+
from public.queue as q
|
|
49
|
+
where q.sent_at is null
|
|
50
|
+
order by q.created_at, q.id
|
|
51
|
+
limit p_batch
|
|
52
|
+
for update skip locked
|
|
53
|
+
$$;
|
|
54
|
+
|
|
55
|
+
create function public.queue_mark_sent(p_ids uuid[], p_sent_at timestamptz)
|
|
56
|
+
returns void
|
|
57
|
+
language sql
|
|
58
|
+
as $$
|
|
59
|
+
update public.queue set sent_at = p_sent_at where id = any(p_ids)
|
|
60
|
+
$$;
|
|
61
|
+
|
|
62
|
+
revoke all on function public.queue_write(varchar, jsonb, timestamptz, varchar) from public;
|
|
63
|
+
grant execute on function public.queue_write(varchar, jsonb, timestamptz, varchar) to app_be;
|
|
64
|
+
revoke all on function public.queue_waiting(integer) from public;
|
|
65
|
+
grant execute on function public.queue_waiting(integer) to app_be;
|
|
66
|
+
revoke all on function public.queue_mark_sent(uuid[], timestamptz) from public;
|
|
67
|
+
grant execute on function public.queue_mark_sent(uuid[], timestamptz) to app_be;
|
|
@@ -9,7 +9,10 @@ import { OutboxLogEvent } from "@modules/platform/outbox"
|
|
|
9
9
|
import type { QueueOptions } from "./queue.options"
|
|
10
10
|
import { QueueRelayService } from "./queue-relay.service"
|
|
11
11
|
import type { QueueTransport } from "./queue-transport.port"
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
// The statements callDbFunction of platform/database sends for the relay's functions.
|
|
14
|
+
const SELECT_WAITING_ROWS = "SELECT * FROM queue_waiting($1)"
|
|
15
|
+
const MARK_ROWS_SENT = "SELECT * FROM queue_mark_sent($1, $2)"
|
|
13
16
|
|
|
14
17
|
const AT = "2026-02-03T04:05:06.000Z"
|
|
15
18
|
|
|
@@ -9,8 +9,19 @@ import { OutboxRelayPolicy } from "@modules/platform/outbox"
|
|
|
9
9
|
import { InjectQueueOptions, InjectQueueRelayManagers, InjectQueueTransport } from "./queue.decorators"
|
|
10
10
|
import type { QueueOptions } from "./queue.options"
|
|
11
11
|
import type { QueueTransport } from "./queue-transport.port"
|
|
12
|
-
import {
|
|
13
|
-
|
|
12
|
+
import { callDbFunction } from "@modules/platform/database"
|
|
13
|
+
|
|
14
|
+
/** One waiting row queue_waiting returns. */
|
|
15
|
+
interface QueueRow {
|
|
16
|
+
/** The correlation id recorded by the producer's transaction. */
|
|
17
|
+
readonly correlationId: string
|
|
18
|
+
/** The row id: it is the BullMQ job id, so a repeated relay pass adds nothing. */
|
|
19
|
+
readonly id: string
|
|
20
|
+
/** The queue the job goes to. */
|
|
21
|
+
readonly queue: string
|
|
22
|
+
/** The payload, as the database returns the jsonb value. */
|
|
23
|
+
readonly payload: object
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
@Injectable()
|
|
16
27
|
/**
|
|
@@ -32,8 +43,8 @@ export class QueueRelayService extends OutboxRelayPolicy<QueueRow> {
|
|
|
32
43
|
super()
|
|
33
44
|
}
|
|
34
45
|
|
|
35
|
-
protected select(tx: EntityManager): Promise<Array<QueueRow>> {
|
|
36
|
-
return tx
|
|
46
|
+
protected async select(tx: EntityManager): Promise<Array<QueueRow>> {
|
|
47
|
+
return [...(await callDbFunction<QueueRow>(tx, "queue_waiting", [this.options.relayBatch]))]
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
protected async deliver(rows: ReadonlyArray<QueueRow>): Promise<void> {
|
|
@@ -42,7 +53,7 @@ export class QueueRelayService extends OutboxRelayPolicy<QueueRow> {
|
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
protected async mark(tx: EntityManager, rows: ReadonlyArray<QueueRow>): Promise<void> {
|
|
45
|
-
await tx
|
|
56
|
+
await callDbFunction(tx, "queue_mark_sent", [rows.map((row) => row.id), this.clock.now()])
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
protected idle(): Promise<void> {
|