@teamscala/service-boot 0.23.431 → 0.23.432

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/package.json +12 -19
  2. package/src/agreement-gate/signing-shell.ts +19 -15
  3. package/src/attach-proposal-accept.ts +5 -2
  4. package/src/auth-gate/public-paths.ts +7 -16
  5. package/src/auth-gate.ts +54 -42
  6. package/src/bind-page-handler.ts +20 -32
  7. package/src/build-client-assets/build-lock.ts +15 -6
  8. package/src/build-client-assets/hashed-output.ts +3 -1
  9. package/src/build-client-assets/hydrate-bundle.ts +59 -57
  10. package/src/build-client-assets/input-fingerprint.ts +50 -57
  11. package/src/build-client-assets/input-hashing.ts +2 -4
  12. package/src/build-client-assets/manifest.ts +8 -11
  13. package/src/build-client-assets/source-candidates.ts +8 -16
  14. package/src/build-client-assets/sourcemaps-upload.ts +17 -10
  15. package/src/build-client-assets/styles-build.ts +17 -19
  16. package/src/build-client-assets.test.ts +2 -2
  17. package/src/build-client-assets.ts +16 -22
  18. package/src/content-gate/access.ts +5 -2
  19. package/src/content-gate/catalog.ts +26 -18
  20. package/src/content-gate/gate.ts +7 -3
  21. package/src/discover-blocks.ts +5 -7
  22. package/src/fn-routers/agreement-certificate.ts +1 -1
  23. package/src/fn-routers/agreement-send-router.ts +5 -4
  24. package/src/fn-routers/agreement-sign-side-effects.ts +27 -22
  25. package/src/fn-routers/assessment-report-email.ts +3 -2
  26. package/src/fn-routers/auth-router.ts +20 -21
  27. package/src/fn-routers/chat-session-router-shared.ts +12 -3
  28. package/src/fn-routers/chat-session-router.ts +8 -6
  29. package/src/fn-routers/conversation-ownership.ts +3 -2
  30. package/src/fn-routers/messaging-whatsapp-router.test.ts +3 -1
  31. package/src/fn-routers/messaging-whatsapp-router.ts +17 -3
  32. package/src/fn-routers/persist-chat-messages.ts +6 -4
  33. package/src/fn-routers/proposal-send-router.ts +4 -3
  34. package/src/fn-routers/public-agreement-router.ts +5 -4
  35. package/src/fn-routers/public-assessment-router.ts +5 -4
  36. package/src/fn-routers/public-gpt-session-router.ts +17 -12
  37. package/src/fn-routers/user-preferences-router.ts +6 -15
  38. package/src/fn-routers/work-items-router.ts +21 -17
  39. package/src/handlers/platform-fault-escalation/orpc-client.ts +38 -18
  40. package/src/handlers/projects-digest.ts +1 -1
  41. package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
  42. package/src/handlers/scala-tools-currency-watch.ts +23 -32
  43. package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
  44. package/src/handlers/singleton-tools-currency-watch.ts +27 -19
  45. package/src/handlers/upstream-issue-sync.ts +11 -4
  46. package/src/handlers/whatsapp-liveness.ts +14 -4
  47. package/src/install-harness.ts +1 -1
  48. package/src/install-logging.ts +1 -1
  49. package/src/mount-orpc-router.test.ts +56 -10
  50. package/src/mount-orpc-router.ts +23 -9
  51. package/src/mount-server-packages.ts +17 -15
  52. package/src/pg-boss-mount.ts +5 -12
  53. package/src/proposal-gate.ts +7 -2
  54. package/src/read-html-template.test.ts +12 -15
  55. package/src/read-html-template.ts +2 -1
  56. package/src/readiness-gate.ts +2 -1
  57. package/src/register-crash-handlers.ts +4 -8
  58. package/src/register-page-server-client.ts +15 -19
  59. package/src/register-registry-page-route/ai-chat-prefetch.ts +16 -12
  60. package/src/register-registry-page-route/apply-page-branding.ts +68 -56
  61. package/src/register-registry-page-route/graph-cache.ts +22 -18
  62. package/src/register-registry-page-route/meta-title.ts +24 -20
  63. package/src/register-registry-page-route/prefetch-org-id.ts +6 -2
  64. package/src/register-registry-page-route/proposal-approval-email-token.ts +5 -3
  65. package/src/register-registry-page-route/run-content-gates.ts +2 -4
  66. package/src/register-registry-page-route.ts +1 -2
  67. package/src/register-static-assets.ts +6 -5
  68. package/src/resolve-app-base-url.ts +12 -5
  69. package/src/resolvers/portal-route-paths.ts +15 -11
  70. package/src/resource-chat-inject.ts +84 -72
  71. package/src/resource-not-found.ts +23 -19
  72. package/src/scala-tools-currency-watchdog.ts +41 -12
  73. package/src/two-phase-full-app.ts +61 -29
  74. package/src/two-phase-minimal-app.ts +39 -31
  75. package/src/two-phase-readiness.ts +50 -38
  76. package/src/ui-service.ts +50 -38
  77. package/src/handlers/pane-outbox-drain.ts +0 -39
  78. package/src/handlers/prune-stale-data.ts +0 -110
@@ -9,30 +9,6 @@
9
9
  * handler once phase 2 completes. Every reader goes through these exports.
10
10
  */
11
11
 
12
- import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
13
- import { WAKING_RETRY_SECONDS } from "@teamscala/os/brand/waking-page-budget.generated";
14
- import type { Context } from "hono";
15
- import { getServerClientCapabilityFault } from "./page-server-orpc/server-client.ts";
16
- import { resolveErrorPageLogo } from "./resource-not-found.ts";
17
- import type { CreatePageHandler } from "./types.ts";
18
-
19
- // Module-level storage for the real page handler, updated when Phase 2 completes.
20
- // This allows the temporary Phase 1 handler to delegate to the real handler once ready.
21
- let realPageHandler: CreatePageHandler | null = null;
22
- let fullAppReady = false;
23
-
24
- /**
25
- * Set the real page handler (called when Phase 2 completes on an SSR service).
26
- *
27
- * Registering the SSR handler implies the full app is built, so this also flips
28
- * readiness — but the reverse does NOT hold, which is why `markFullAppReady`
29
- * exists separately. See its note.
30
- */
31
- export function setPageHandler(handler: CreatePageHandler): void {
32
- realPageHandler = handler;
33
- markFullAppReady();
34
- }
35
-
36
12
  /**
37
13
  * Flip readiness once phase 2 has built the full app.
38
14
  *
@@ -53,10 +29,10 @@ export function setPageHandler(handler: CreatePageHandler): void {
53
29
  * built: 50 block(s)" logged, `GET /` correctly 302'd to sign-in, and `/health`
54
30
  * still reported starting — while three untouched UI services reported ok.
55
31
  */
56
- export function markFullAppReady(): void {
57
- fullAppReady = true;
58
- }
59
32
 
33
+ // Why: Flip readiness once phase 2 has built the full app.
34
+ // (full note hoisted to this file's leading header — kept out of the body per
35
+ // no-inline-prose-blocks / prose-stays-in-docs)
60
36
  /**
61
37
  * Reset both phase-2 flags. TEST-ONLY.
62
38
  *
@@ -71,18 +47,10 @@ export function markFullAppReady(): void {
71
47
  * Never called from production code — `setPageHandler`/`markFullAppReady` are
72
48
  * the only writers on the boot path.
73
49
  */
74
- export function __resetPhase2StateForTest(): void {
75
- realPageHandler = null;
76
- fullAppReady = false;
77
- }
78
-
79
- /**
80
- * Get the current page handler state.
81
- */
82
- export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
83
- return { ready: fullAppReady, hasHandler: realPageHandler !== null };
84
- }
85
50
 
51
+ // Why: Reset both phase-2 flags. TEST-ONLY.
52
+ // (full note hoisted to this file's leading header — kept out of the body per
53
+ // no-inline-prose-blocks / prose-stays-in-docs)
86
54
  /**
87
55
  * `/health` for a UI service — 200 ONLY once the full app can actually render
88
56
  * a page, 503 before that.
@@ -125,6 +93,50 @@ export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
125
93
  * tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
126
94
  * one consumer contract is "can you serve me a page", is moved to phase 2.
127
95
  */
96
+
97
+ // Why: `/health` for a UI service — 200 ONLY once the full app can …
98
+ // (full note hoisted to this file's leading header — kept out of the body per
99
+ // no-inline-prose-blocks / prose-stays-in-docs)
100
+ import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
101
+ import { WAKING_RETRY_SECONDS } from "@teamscala/os/brand/waking-page-budget.generated";
102
+ import type { Context } from "hono";
103
+ import { getServerClientCapabilityFault } from "./page-server-orpc/server-client.ts";
104
+ import { resolveErrorPageLogo } from "./resource-not-found.ts";
105
+ import type { CreatePageHandler } from "./types.ts";
106
+
107
+ // Module-level storage for the real page handler, updated when Phase 2 completes.
108
+ // This allows the temporary Phase 1 handler to delegate to the real handler once ready.
109
+ let realPageHandler: CreatePageHandler | null = null;
110
+ let fullAppReady = false;
111
+
112
+ /**
113
+ * Set the real page handler (called when Phase 2 completes on an SSR service).
114
+ *
115
+ * Registering the SSR handler implies the full app is built, so this also flips
116
+ * readiness — but the reverse does NOT hold, which is why `markFullAppReady`
117
+ * exists separately. See its note.
118
+ */
119
+ export function setPageHandler(handler: CreatePageHandler): void {
120
+ realPageHandler = handler;
121
+ markFullAppReady();
122
+ }
123
+
124
+ export function markFullAppReady(): void {
125
+ fullAppReady = true;
126
+ }
127
+
128
+ export function __resetPhase2StateForTest(): void {
129
+ realPageHandler = null;
130
+ fullAppReady = false;
131
+ }
132
+
133
+ /**
134
+ * Get the current page handler state.
135
+ */
136
+ export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
137
+ return { ready: fullAppReady, hasHandler: realPageHandler !== null };
138
+ }
139
+
128
140
  export function healthHandler(c: Context): Response {
129
141
  if (!fullAppReady) return c.json({ status: "starting" }, 503, { "Retry-After": "1" });
130
142
  // Assert the CAPABILITY, not just the phase flag. On 2026-08-20 this returned
package/src/ui-service.ts CHANGED
@@ -8,6 +8,56 @@
8
8
  * served by @teamscala/service-runtime/serve (one Bun.serve in dev and prod).
9
9
  */
10
10
 
11
+ /**
12
+ * Boots a UI service using two-phase boot for fast availability.
13
+ *
14
+ * Phase 1 (fast): Build and serve minimal tool-only app (ORPC, data routes,
15
+ * auth gates). This becomes available within ~10s, enabling the MCP tool
16
+ * surface to accept requests immediately after restart.
17
+ *
18
+ * Phase 2 (background): Build full app (pages, hydration) and atomically swap
19
+ * it in. The readiness gate flips to ready after Phase 1, not Phase 2.
20
+ *
21
+ * The interim mitigation (503 + gateway retry + circuit breaker) remains in
22
+ * place and is NOT undone.
23
+ */
24
+
25
+ // Why: Boots a UI service using two-phase boot for fast availabilit…
26
+ // (full note hoisted to this file's leading header — kept out of the body per
27
+ // no-inline-prose-blocks / prose-stays-in-docs)
28
+ // Resolve the listen port through the SHARED resolver (`resolveServicePort`),
29
+ // the same one the backend branch uses. The registry wins wherever it has an
30
+ // opinion and a contradicting PORT env is reported, never silently obeyed —
31
+ // full reasoning in that file.
32
+ //
33
+ // This branch previously took `process.env.PORT` unconditionally, which is
34
+ // how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
35
+ // leaked env and silently split that daemon's traffic (2026-07-31). On the
36
+ // dev host the registry wins a conflict (below). On Railway the injected PORT
37
+ // wins instead — Railway healthchecks exactly that port, and the row's port
38
+ // is the service's LOCAL dev port (e.g. portal 5200), so binding it on Railway
39
+ // makes the healthcheck probe a dead port and the deploy fails (`isRailway`
40
+ // path in the resolver).
41
+
42
+ // Why: Resolve the listen port through the SHARED resolver (`resolv…
43
+ // (full note hoisted to this file's leading header — kept out of the body per
44
+ // no-inline-prose-blocks / prose-stays-in-docs)
45
+ // NO-SILENT-REVERTS — surface the failed client build to OBSERVABILITY
46
+ // (event-log → GlitchTip, per event-log-is-the-only-durable-event-log).
47
+ // The minimal-app fallback keeps serving SSR from the LAST-successful
48
+ // manifest (now a tombstone, see build-client-assets.ts), so the page
49
+ // looks "up" while the client bundle is stale. Without this signal the
50
+ // failure is buried in boot stderr and a published fix that reached
51
+ // node_modules but failed to bundle ships invisibly stale (the
52
+ // scala-agents 2026-07-18 incident: ui-foundation@3.4.5 installed but
53
+ // served pre-fix bytes across restarts while node_modules was
54
+ // mid-install). The boot.client-build-failed event routes into
55
+ // event-log's 7-day retention → surfaces in GlitchTip as an incident
56
+ // trail the operator actually watches.
57
+
58
+ // Why: NO-SILENT-REVERTS — surface the failed client build to OBSER…
59
+ // (full note hoisted to this file's leading header — kept out of the body per
60
+ // no-inline-prose-blocks / prose-stays-in-docs)
11
61
  import { recordEvent } from "@teamscala/event-log/record-event";
12
62
  import { getAppLogger } from "@teamscala/logger/app-loggers";
13
63
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
@@ -46,19 +96,6 @@ export interface PageServerDeps {
46
96
  getSharedDb?: () => unknown;
47
97
  }
48
98
 
49
- /**
50
- * Boots a UI service using two-phase boot for fast availability.
51
- *
52
- * Phase 1 (fast): Build and serve minimal tool-only app (ORPC, data routes,
53
- * auth gates). This becomes available within ~10s, enabling the MCP tool
54
- * surface to accept requests immediately after restart.
55
- *
56
- * Phase 2 (background): Build full app (pages, hydration) and atomically swap
57
- * it in. The readiness gate flips to ready after Phase 1, not Phase 2.
58
- *
59
- * The interim mitigation (503 + gateway retry + circuit breaker) remains in
60
- * place and is NOT undone.
61
- */
62
99
  export async function bootUiService(
63
100
  config: ServiceInstallConfig,
64
101
  serviceSlug: string,
@@ -77,19 +114,6 @@ export async function bootUiService(
77
114
  deps.getSharedDb,
78
115
  );
79
116
 
80
- // Resolve the listen port through the SHARED resolver (`resolveServicePort`),
81
- // the same one the backend branch uses. The registry wins wherever it has an
82
- // opinion and a contradicting PORT env is reported, never silently obeyed —
83
- // full reasoning in that file.
84
- //
85
- // This branch previously took `process.env.PORT` unconditionally, which is
86
- // how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
87
- // leaked env and silently split that daemon's traffic (2026-07-31). On the
88
- // dev host the registry wins a conflict (below). On Railway the injected PORT
89
- // wins instead — Railway healthchecks exactly that port, and the row's port
90
- // is the service's LOCAL dev port (e.g. portal 5200), so binding it on Railway
91
- // makes the healthcheck probe a dead port and the deploy fails (`isRailway`
92
- // path in the resolver).
93
117
  const isRailway = Boolean(process.env.RAILWAY_ENVIRONMENT);
94
118
  const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
95
119
  // The APP binds its PRIVATE port (app_port) when present — for scale-to-zero
@@ -160,18 +184,6 @@ export async function bootUiService(
160
184
  log.error(
161
185
  `[ui-service-boot:${serviceSlug}] Failed to build full app, continuing with minimal app: ${reason}`,
162
186
  );
163
- // NO-SILENT-REVERTS — surface the failed client build to OBSERVABILITY
164
- // (event-log → GlitchTip, per event-log-is-the-only-durable-event-log).
165
- // The minimal-app fallback keeps serving SSR from the LAST-successful
166
- // manifest (now a tombstone, see build-client-assets.ts), so the page
167
- // looks "up" while the client bundle is stale. Without this signal the
168
- // failure is buried in boot stderr and a published fix that reached
169
- // node_modules but failed to bundle ships invisibly stale (the
170
- // scala-agents 2026-07-18 incident: ui-foundation@3.4.5 installed but
171
- // served pre-fix bytes across restarts while node_modules was
172
- // mid-install). The boot.client-build-failed event routes into
173
- // event-log's 7-day retention → surfaces in GlitchTip as an incident
174
- // trail the operator actually watches.
175
187
  recordEvent({
176
188
  kind: "boot.client-build-failed",
177
189
  payload: {
@@ -1,39 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The `pane-outbox-drain` pg-boss handler — delivers messages the operator's
7
- * WhatsApp PA queues into `pane_messages` to the addressed agent pane.
8
- *
9
- * WHY THE REGISTRATION LIVES HERE AND THE WORK LIVES IN @teamscala/channel.
10
- * The delivery itself belongs to the channel capability, so `paneOutboxDrainTick`
11
- * is owned by `@teamscala/channel` beside the dispatch handler it calls. But
12
- * `pgBossHandlerRegistry` is SERVER tier and `@teamscala/channel` is ADAPTER
13
- * tier, so the channel package cannot register a pg-boss handler without an
14
- * upward dependency — forbidden by `vertical-dependency-only`, which has no
15
- * carve-out. server → adapter is DOWNWARD, so the registration sits here and
16
- * imports the work, exactly as the four sibling handlers in this directory do.
17
- *
18
- * The MountContext route (`ctx.registerScheduledJob`) is tier-legal but NOT
19
- * usable: `start-scheduled-jobs.ts` offers only `long_running` (invoked once,
20
- * runs forever) and `recurring` (whose handlers "manage their own setInterval").
21
- * Both are the in-process timer that `pg-boss-for-background-work` forbids and
22
- * that the timer half of `in-memory-state-is-not-durable` describes — a timer
23
- * only fires if its host outlives it, so on a restart-prone service it silently
24
- * never runs while logging that it started. pg-boss is the only durable option,
25
- * because the job row outlives any worker.
26
- *
27
- * Scoped to scala-channel by its `scheduled_job` row's `service_slug`, so it
28
- * runs on the service that owns the channel rather than on scala-agents, whose
29
- * UI-asset build failures used to take PA delivery down with them.
30
- *
31
- * Registered under slug `@teamscala/service-boot:pane-outbox-drain`.
32
- */
33
- import { paneOutboxDrainTick } from "@teamscala/channel/pane-outbox/drain-tick";
34
- import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
35
-
36
- pgBossHandlerRegistry.register(
37
- "@teamscala/service-boot:pane-outbox-drain",
38
- paneOutboxDrainTick,
39
- );
@@ -1,110 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The `db-prune-stale-data` pg-boss handler. Reads the `retention` map from the
7
- * `scheduled_job` row config and prunes stale rows per entry.
8
- *
9
- * Writes use Prisma ORM methods (deleteMany/updateMany) via the prisma-registry
10
- * client — NEVER raw SQL. The `@teamscala/db` client carries the `blockRawSqlWrites`
11
- * audit extension: `$executeRaw`/`$executeRawUnsafe` are blocked so every write
12
- * is audited; ORM methods are the canonical audited path.
13
- *
14
- * SAFETY — identifier allowlist: each retention key selects a FIXED, hardcoded
15
- * Prisma model + field (table/field names are literal in code, never derived
16
- * from the row); the row's value (a duration string) is parsed to a cutoff Date.
17
- * A row can therefore never inject arbitrary SQL or address an unvetted model.
18
- * Keys not in the allowlist are skipped + logged.
19
- *
20
- * `audit_trail.*` is INTENTIONALLY EXCLUDED: `audit-trail-permanent` (critical)
21
- * forbids any DELETE FROM audit_trail. The legacy row's
22
- * `audit_trail.sync_entities: "delete all"` entry is stale drift from before
23
- * the 2026-06-19 hardening — surfaced (skipped + warned), never executed.
24
- *
25
- * Registered under slug `@teamscala/service-boot:prune-stale-data`.
26
- */
27
- import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
28
- import { Prisma } from "@teamscala/db/client";
29
- import { getPrisma } from "@teamscala/db/prisma-registry";
30
- import { getAppLogger } from "@teamscala/logger/app-loggers";
31
- import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
32
-
33
- const DURATION_RE = /^(\d+)\s+(second|minute|hour|day|week|month|year)s?$/;
34
- const UNIT_TO_MS: Record<string, number> = {
35
- second: 1_000,
36
- minute: 60_000,
37
- hour: 3_600_000,
38
- day: 86_400_000,
39
- week: 604_800_000,
40
- month: 2_592_000_000,
41
- year: 31_536_000_000,
42
- };
43
-
44
- type PrismaClient = ReturnType<typeof getPrisma>;
45
- type PruneOp = (prisma: PrismaClient, cutoff: Date) => Promise<number>;
46
-
47
- // Fixed ops — model + field names literal in code, cutoff Date is the only
48
- // variable. Adding a prunable target = adding an entry here (and confirming the
49
- // model/field + audit-trail-permanent scope).
50
- const PRUNE_OPS: Record<string, PruneOp> = {
51
- "fleet_run_events": async (prisma, cutoff) =>
52
- (await prisma.fleet_run_events.deleteMany({ where: { created_at: { lt: cutoff } } })).count,
53
- "fleet_run_transitions": async (prisma, cutoff) =>
54
- (await prisma.fleet_run_transitions.deleteMany({ where: { created_at: { lt: cutoff } } })).count,
55
- // Null out the bulky transcript JSON of old runs — keep the run row itself.
56
- // A nullable-Json column requires Prisma's DbNull sentinel (bare `null` is
57
- // ambiguous: JSON null vs SQL NULL); DbNull sets the column to SQL NULL.
58
- // Prisma namespace is re-exported from @teamscala/db/client.
59
- "fleet_agent_runs.transcript": async (prisma, cutoff) =>
60
- (
61
- await prisma.fleet_agent_runs.updateMany({
62
- where: { created_at: { lt: cutoff } },
63
- data: { transcript: Prisma.DbNull },
64
- })
65
- ).count,
66
- };
67
-
68
- export async function pruneStaleData({
69
- config,
70
- }: {
71
- config: ScheduledJobConfig;
72
- }): Promise<void> {
73
- const log = getAppLogger();
74
- const retention = config.retention;
75
- if (!retention || Object.keys(retention).length === 0) {
76
- log.info("[db-prune-stale-data] no retention entries — nothing to prune");
77
- return;
78
- }
79
- const prisma = getPrisma();
80
- let ran = 0;
81
- let skipped = 0;
82
- for (const [key, rawValue] of Object.entries(retention)) {
83
- const op = PRUNE_OPS[key];
84
- if (!op) {
85
- log.warn(
86
- `[db-prune-stale-data] skipping "${key}" — not in the prune allowlist ` +
87
- `(audit_trail is excluded by audit-trail-permanent; add the op explicitly to enable)`,
88
- );
89
- skipped++;
90
- continue;
91
- }
92
- const match = DURATION_RE.exec(rawValue.trim());
93
- if (!match) {
94
- log.warn(
95
- `[db-prune-stale-data] skipping "${key}" — value "${rawValue}" is not a duration (expected e.g. "1 day")`,
96
- );
97
- skipped++;
98
- continue;
99
- }
100
- const cutoff = new Date(Date.now() - Number(match[1]) * UNIT_TO_MS[match[2]!]);
101
- const affected = await op(prisma, cutoff);
102
- log.info(
103
- `[db-prune-stale-data] pruned "${key}" older than ${rawValue} — ${affected} row(s) affected`,
104
- );
105
- ran++;
106
- }
107
- log.info(`[db-prune-stale-data] complete — ${ran} op(s) ran, ${skipped} skipped`);
108
- }
109
-
110
- pgBossHandlerRegistry.register("@teamscala/service-boot:prune-stale-data", pruneStaleData);