@teamscala/service-boot 0.23.355 → 0.23.357
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/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.357",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
"@teamscala/object-storage": "*",
|
|
37
37
|
"@teamscala/orpc": "*",
|
|
38
38
|
"@teamscala/os": "*",
|
|
39
|
-
"@teamscala/proc-walker": "*",
|
|
40
39
|
"@teamscala/realtime-utils": "*",
|
|
41
40
|
"@teamscala/resolver-engine": "*",
|
|
42
41
|
"@teamscala/timing": "*",
|
|
@@ -898,22 +897,6 @@
|
|
|
898
897
|
"types": "./src/security-headers.d.ts",
|
|
899
898
|
"default": "./src/security-headers.ts"
|
|
900
899
|
},
|
|
901
|
-
"./serve/serve": {
|
|
902
|
-
"types": "./src/serve/serve.d.ts",
|
|
903
|
-
"default": "./src/serve/serve.ts"
|
|
904
|
-
},
|
|
905
|
-
"./serve/serve.ts": {
|
|
906
|
-
"types": "./src/serve/serve.d.ts",
|
|
907
|
-
"default": "./src/serve/serve.ts"
|
|
908
|
-
},
|
|
909
|
-
"./serve/stamp-transport-identity": {
|
|
910
|
-
"types": "./src/serve/stamp-transport-identity.d.ts",
|
|
911
|
-
"default": "./src/serve/stamp-transport-identity.ts"
|
|
912
|
-
},
|
|
913
|
-
"./serve/stamp-transport-identity.ts": {
|
|
914
|
-
"types": "./src/serve/stamp-transport-identity.d.ts",
|
|
915
|
-
"default": "./src/serve/stamp-transport-identity.ts"
|
|
916
|
-
},
|
|
917
900
|
"./server-error-page": {
|
|
918
901
|
"types": "./src/server-error-page.d.ts",
|
|
919
902
|
"default": "./src/server-error-page.ts"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The `lane-attention-sweep` pg-boss handler. Calls the database function that
|
|
7
|
+
* reminds an orchestrator about lanes waiting on it, and records the outcome.
|
|
8
|
+
*
|
|
9
|
+
* This handler holds NO POLICY. Which lanes are waiting, how long counts as
|
|
10
|
+
* waiting, who is told, what the message says, and how often it may repeat are
|
|
11
|
+
* all in `lane_needs_attention` and `sweep_lanes_needing_attention` — so
|
|
12
|
+
* changing any of them is a migration, not a rebuild and redeploy of this
|
|
13
|
+
* service. All this file supplies is the tick and the telemetry.
|
|
14
|
+
*
|
|
15
|
+
* WHY A TICK EXISTS AT ALL, given `observability-is-passive` and
|
|
16
|
+
* `no-polling-for-pushable-state`. Both forbid polling for state a source CAN
|
|
17
|
+
* push. Here the trigger is the PASSAGE OF TIME against a threshold: a lane
|
|
18
|
+
* going silent emits nothing, because emitting is exactly what it stopped
|
|
19
|
+
* doing. There is no state transition to react to, so nothing could push it.
|
|
20
|
+
* The same justification `registry-stale-inactive-sweep` carries, recorded on
|
|
21
|
+
* the row as `poll_justification`.
|
|
22
|
+
*
|
|
23
|
+
* Registered under slug `@teamscala/service-boot:lane-attention-sweep`.
|
|
24
|
+
*/
|
|
25
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
26
|
+
import { getPrisma } from "@teamscala/db/prisma-registry";
|
|
27
|
+
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
28
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
29
|
+
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
30
|
+
|
|
31
|
+
interface RemindedLane {
|
|
32
|
+
task_id: string;
|
|
33
|
+
window_name: string | null;
|
|
34
|
+
reason: string;
|
|
35
|
+
orchestrator_task_id: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function laneAttentionSweep(ctx: { config: ScheduledJobConfig }): Promise<void> {
|
|
39
|
+
const log = getAppLogger();
|
|
40
|
+
const prisma = getPrisma();
|
|
41
|
+
|
|
42
|
+
// The repeat window is the row's, so silencing a chatty reminder is a
|
|
43
|
+
// registry edit. The default lives in the SQL function's signature, which is
|
|
44
|
+
// the one place it is defined.
|
|
45
|
+
const repeatAfter = (ctx.config as { repeat_after?: string }).repeat_after;
|
|
46
|
+
|
|
47
|
+
const reminded = repeatAfter
|
|
48
|
+
? await prisma.$queryRawUnsafe<RemindedLane[]>(
|
|
49
|
+
"SELECT * FROM sweep_lanes_needing_attention($1::interval)",
|
|
50
|
+
repeatAfter,
|
|
51
|
+
)
|
|
52
|
+
: await prisma.$queryRawUnsafe<RemindedLane[]>("SELECT * FROM sweep_lanes_needing_attention()");
|
|
53
|
+
|
|
54
|
+
// Emitted even when nothing was due. A sweep that only reports on the runs
|
|
55
|
+
// where it found something is indistinguishable from a sweep that stopped
|
|
56
|
+
// running, and this exists precisely to notice silence.
|
|
57
|
+
recordEvent({
|
|
58
|
+
kind: "fleet.lane-attention.swept",
|
|
59
|
+
payload: {
|
|
60
|
+
remindedCount: reminded.length,
|
|
61
|
+
lanes: reminded.map((lane) => ({
|
|
62
|
+
task: lane.task_id,
|
|
63
|
+
window: lane.window_name,
|
|
64
|
+
reason: lane.reason,
|
|
65
|
+
toldOrchestrator: lane.orchestrator_task_id,
|
|
66
|
+
})),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (reminded.length > 0) {
|
|
71
|
+
log.info(
|
|
72
|
+
`[lane-attention-sweep] reminded ${reminded.length} orchestrator(s) about waiting lanes`,
|
|
73
|
+
{ lanes: reminded.map((lane) => `${lane.window_name}: ${lane.reason}`) },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
pgBossHandlerRegistry.register(
|
|
79
|
+
"@teamscala/service-boot:lane-attention-sweep",
|
|
80
|
+
laneAttentionSweep as (ctx: { config: ScheduledJobConfig }) => Promise<void>,
|
|
81
|
+
);
|
package/src/pg-boss-mount.ts
CHANGED
|
@@ -34,6 +34,7 @@ import "./handlers/scala-tools-currency-watch.ts";
|
|
|
34
34
|
import "./handlers/singleton-tools-currency-watch.ts";
|
|
35
35
|
import "./handlers/registry-stale-inactive-sweep.ts";
|
|
36
36
|
import "./handlers/pane-outbox-drain.ts";
|
|
37
|
+
import "./handlers/lane-attention-sweep.ts";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Module-level reference to the mounted PgBoss instance — set by mountPgBoss on
|
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { describe, expect, test } from "bun:test";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
interpolateMeta,
|
|
15
|
+
resolveFromLoaderConfig,
|
|
16
|
+
} from "./resolve-from-loader-config";
|
|
17
|
+
import {
|
|
18
|
+
getServerClient,
|
|
19
|
+
setServerClient,
|
|
20
|
+
} from "./page-server-orpc/server-client";
|
|
14
21
|
|
|
15
22
|
describe("interpolateMeta (resolver_input ref contract)", () => {
|
|
16
23
|
test("a bare :sourceKey ref passes the whole resolved source object", () => {
|
|
@@ -42,3 +49,57 @@ describe("interpolateMeta (resolver_input ref contract)", () => {
|
|
|
42
49
|
expect(out.score).toBe(48);
|
|
43
50
|
});
|
|
44
51
|
});
|
|
52
|
+
|
|
53
|
+
describe("resource-loader model capability", () => {
|
|
54
|
+
test("an optional source still refuses a model absent from the generated client", async () => {
|
|
55
|
+
const original = getServerClient();
|
|
56
|
+
setServerClient({} as never);
|
|
57
|
+
try {
|
|
58
|
+
await expect(
|
|
59
|
+
resolveFromLoaderConfig(
|
|
60
|
+
{
|
|
61
|
+
sources: [
|
|
62
|
+
{
|
|
63
|
+
key: "body",
|
|
64
|
+
source: "orpc",
|
|
65
|
+
model: "builda_pages",
|
|
66
|
+
optional: true,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{},
|
|
71
|
+
),
|
|
72
|
+
).rejects.toThrow(
|
|
73
|
+
'requires model "builda_pages", but the generated server client does not expose findFirst',
|
|
74
|
+
);
|
|
75
|
+
} finally {
|
|
76
|
+
setServerClient(original);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("an optional source may resolve an absent row when the model capability exists", async () => {
|
|
81
|
+
const original = getServerClient();
|
|
82
|
+
setServerClient({
|
|
83
|
+
builda_pages: { findFirst: async () => null },
|
|
84
|
+
} as never);
|
|
85
|
+
try {
|
|
86
|
+
await expect(
|
|
87
|
+
resolveFromLoaderConfig(
|
|
88
|
+
{
|
|
89
|
+
sources: [
|
|
90
|
+
{
|
|
91
|
+
key: "body",
|
|
92
|
+
source: "orpc",
|
|
93
|
+
model: "builda_pages",
|
|
94
|
+
optional: true,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{},
|
|
99
|
+
),
|
|
100
|
+
).resolves.toEqual({ body: null });
|
|
101
|
+
} finally {
|
|
102
|
+
setServerClient(original);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -34,6 +34,15 @@ interface LoaderConfig {
|
|
|
34
34
|
merge_strategy?: Record<string, unknown>;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
class MissingLoaderModelCapabilityError extends Error {
|
|
38
|
+
constructor(model: string, sourceKey: string) {
|
|
39
|
+
super(
|
|
40
|
+
`[resource_loader] source "${sourceKey}" requires model "${model}", but the generated server client does not expose findFirst for it — regenerate the service schema from its page loaders`,
|
|
41
|
+
);
|
|
42
|
+
this.name = "MissingLoaderModelCapabilityError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
function resolveParamRef(
|
|
38
47
|
ref: string,
|
|
39
48
|
params: Record<string, unknown>,
|
|
@@ -161,7 +170,9 @@ async function resolveSource(
|
|
|
161
170
|
}) => Promise<unknown>;
|
|
162
171
|
}
|
|
163
172
|
| undefined;
|
|
164
|
-
if (!model?.findFirst)
|
|
173
|
+
if (!model?.findFirst) {
|
|
174
|
+
throw new MissingLoaderModelCapabilityError(source.model, source.key);
|
|
175
|
+
}
|
|
165
176
|
return model.findFirst({
|
|
166
177
|
where,
|
|
167
178
|
select: toSelectMap(source.select),
|
|
@@ -261,7 +272,10 @@ export async function resolveFromLoaderConfig(
|
|
|
261
272
|
if (!source || !result) continue;
|
|
262
273
|
if (result.status === "fulfilled") {
|
|
263
274
|
resolved[source.key] = result.value;
|
|
264
|
-
} else if (
|
|
275
|
+
} else if (
|
|
276
|
+
source.optional &&
|
|
277
|
+
!(result.reason instanceof MissingLoaderModelCapabilityError)
|
|
278
|
+
) {
|
|
265
279
|
resolved[source.key] = null;
|
|
266
280
|
} else {
|
|
267
281
|
throw result.reason;
|