@teamscala/service-boot 0.23.458 → 0.23.460
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
package/src/discover-blocks.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
import { getPrisma } from "@teamscala/db/prisma-registry";
|
|
26
|
+
import { queryActiveRegistryRows } from "@teamscala/db/registry/loader/query-rows";
|
|
26
27
|
import { BlockSchemaConfigSchema } from "@teamscala/db-validation/registry-schemas/block-schema";
|
|
27
28
|
import * as v from "valibot";
|
|
28
29
|
import { isReactElementType } from "./is-react-element-type.ts";
|
|
@@ -95,13 +96,27 @@ export async function discoverBlocks(
|
|
|
95
96
|
// service. The page-server ORPC client is intentionally service-scoped and
|
|
96
97
|
// therefore cannot expose registry_entries. Use the registered database
|
|
97
98
|
// client for this shared model, as every other registry reader does.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
99
|
+
// READ THROUGH THE SHARED HELPER, NOT A SECOND HAND-WRITTEN QUERY. This
|
|
100
|
+
// used to call prisma.registry_entries.findMany directly with its own
|
|
101
|
+
// where clause — a duplicate of a read that already exists once in
|
|
102
|
+
// queryActiveRegistryRows and is exercised by every other registry reader.
|
|
103
|
+
//
|
|
104
|
+
// The duplicate returned an EMPTY ARRAY. Measured 2026-09-03: 91 active
|
|
105
|
+
// block_schema rows in the database, ZERO loaded, and not a single skip
|
|
106
|
+
// line logged — so the loop below never ran once. The helper resolves the
|
|
107
|
+
// same rows correctly because it falls back to raw SQL when the v8
|
|
108
|
+
// accessor cannot answer; the hand-written copy had no such path and its
|
|
109
|
+
// silence was indistinguishable from "there are no blocks".
|
|
110
|
+
//
|
|
111
|
+
// Every UI service then hit the fail-closed at the call site and refused to
|
|
112
|
+
// boot, so the operator saw nothing at all rather than "0 of 91 blocks".
|
|
113
|
+
// Two copies of one read, and the copy nobody exercised is the one that
|
|
114
|
+
// broke — delete it rather than repair it.
|
|
115
|
+
const rows = (await queryActiveRegistryRows(
|
|
116
|
+
getPrisma() as never,
|
|
117
|
+
{ type: "block_schema" },
|
|
118
|
+
)) as Array<{ slug: string; config: unknown }>;
|
|
119
|
+
rows.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
105
120
|
|
|
106
121
|
// Parallel block discovery (no-serial-per-unit-work-on-hot-paths: boot is a
|
|
107
122
|
// recurring cycle via the package-poller's restarts). Previously each block's
|
|
@@ -66,11 +66,39 @@ function resolveParamRef(
|
|
|
66
66
|
let current: unknown = resolved;
|
|
67
67
|
for (const part of parts) {
|
|
68
68
|
if (current == null || typeof current !== "object") return undefined;
|
|
69
|
-
current = (current as Record<string, unknown
|
|
69
|
+
current = readField(current as Record<string, unknown>, part);
|
|
70
70
|
}
|
|
71
71
|
return current;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A resource_loader row spells DATABASE COLUMN NAMES — `page_id`,
|
|
76
|
+
* `organisation_id`, `prepared_for` — because that is what the schema calls
|
|
77
|
+
* them. The ORM hands back CAMELCASE keys — `pageId`, `organisationId`,
|
|
78
|
+
* `preparedFor`. Nothing reconciled the two, so a `:proposal.page_id` ref read
|
|
79
|
+
* a key the object does not have and returned undefined.
|
|
80
|
+
*
|
|
81
|
+
* MEASURED 2026-09-03, and the consequence is far worse than a missing value.
|
|
82
|
+
* `interpolateTemplate` DROPS a key whose value is undefined, so a dependent
|
|
83
|
+
* source declared `where: { id: ":proposal.page_id" }` was issued as
|
|
84
|
+
* `where: {}` — and findFirst with an empty filter returns AN ARBITRARY FIRST
|
|
85
|
+
* ROW. A client proposal page therefore loaded an unrelated builda_page,
|
|
86
|
+
* rendered a stranger's content under the correct brand chrome, and answered
|
|
87
|
+
* 200 throughout. Instrumenting the live resolver showed every dependent
|
|
88
|
+
* source doing it: page, website, contact, partnerOrg and clientOrg all with
|
|
89
|
+
* `where={}`; only `proposal` was correct, because its ref is the URL param
|
|
90
|
+
* `:id` rather than a sibling source.
|
|
91
|
+
*
|
|
92
|
+
* Read the spelled key first, then its camelCase equivalent. Column names stay
|
|
93
|
+
* the authoring vocabulary and a row never has to know what the ORM did to its
|
|
94
|
+
* keys.
|
|
95
|
+
*/
|
|
96
|
+
function readField(source: Record<string, unknown>, key: string): unknown {
|
|
97
|
+
if (key in source) return source[key];
|
|
98
|
+
const camel = key.replace(/_([a-z0-9])/g, (_m, c: string) => c.toUpperCase());
|
|
99
|
+
return camel === key ? undefined : source[camel];
|
|
100
|
+
}
|
|
101
|
+
|
|
74
102
|
function interpolateTemplate(
|
|
75
103
|
template: unknown,
|
|
76
104
|
params: Record<string, unknown>,
|
|
@@ -191,11 +219,33 @@ async function resolveSource(
|
|
|
191
219
|
case "orpc":
|
|
192
220
|
case "prisma": {
|
|
193
221
|
if (!source.model) return null;
|
|
194
|
-
|
|
222
|
+
// OPTIONAL MEANS THE SOURCE MAY BE ABSENT. It has never meant the
|
|
223
|
+
// source may be A STRANGER'S ROW, and that is what an unfiltered
|
|
224
|
+
// query returns. This check previously guarded only REQUIRED
|
|
225
|
+
// sources, so every optional one whose ref failed to resolve issued
|
|
226
|
+
// `where: {}` and findFirst handed back an arbitrary first row.
|
|
227
|
+
//
|
|
228
|
+
// Measured 2026-09-03 on a live client proposal: page, website,
|
|
229
|
+
// contact, partnerOrg and clientOrg ALL queried with an empty where
|
|
230
|
+
// and loaded unrelated records, which then rendered as the page. The
|
|
231
|
+
// only correct source was `proposal`, whose ref is the URL param
|
|
232
|
+
// rather than a sibling. Nothing errored and the page answered 200.
|
|
233
|
+
//
|
|
234
|
+
// A dropped filter is now refused for every source that declared
|
|
235
|
+
// one. Required sources still throw; an optional source resolves to
|
|
236
|
+
// null — absent, which is exactly what optional promises.
|
|
237
|
+
if (source.where) {
|
|
195
238
|
const broken = unresolvedTemplateRefs(source.where, params, resolved);
|
|
196
239
|
if (broken.length > 0) {
|
|
240
|
+
const refs = broken.map((ref) => `:${ref}`).join(", ");
|
|
241
|
+
if (source.optional) {
|
|
242
|
+
getAppLogger().warn(
|
|
243
|
+
`[resource_loader] optional source "${source.key}": where ref(s) ${refs} resolved to undefined — treating the source as ABSENT rather than querying without them (an unfiltered read returns an arbitrary row)`,
|
|
244
|
+
);
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
197
247
|
throw new Error(
|
|
198
|
-
`[resource_loader] required source "${source.key}": where ref(s) ${
|
|
248
|
+
`[resource_loader] required source "${source.key}": where ref(s) ${refs} resolved to undefined — refusing to query without them (a dropped filter silently matches an arbitrary row)`,
|
|
199
249
|
);
|
|
200
250
|
}
|
|
201
251
|
}
|
|
@@ -281,13 +281,16 @@ export async function buildMinimalApp(
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
const { components, loaded, skipped } = discovered;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
284
|
+
// REPORT THE DISCOVERY BEFORE JUDGING IT. This used to throw BootFatalError
|
|
285
|
+
// on `loaded.length === 0` ABOVE these two log lines, so the one boot that
|
|
286
|
+
// most needed the diagnosis was the only boot that printed none of it: the
|
|
287
|
+
// operator saw "Zero Puck components loaded" and nothing about WHICH rows
|
|
288
|
+
// were considered or WHY each was rejected. Measured 2026-09-03: 91 active
|
|
289
|
+
// block_schema rows, zero loaded, zero skip lines — and the absence of skip
|
|
290
|
+
// lines was itself unreadable, because "no candidate survived the install
|
|
291
|
+
// pre-filter" and "every candidate failed to import" are indistinguishable
|
|
292
|
+
// when neither prints. Hours went into re-deriving from outside the process
|
|
293
|
+
// what these lines already knew.
|
|
291
294
|
if (loaded.length > 0) {
|
|
292
295
|
log.info(
|
|
293
296
|
`[two-phase-boot:${serviceSlug}] Discovered ${loaded.length} block(s): ${loaded.join(", ")}`,
|
|
@@ -299,6 +302,23 @@ export async function buildMinimalApp(
|
|
|
299
302
|
`(missing componentPath/componentExport or import failed): ${skipped.join(", ")}`,
|
|
300
303
|
);
|
|
301
304
|
}
|
|
305
|
+
// SOFT-CRASH, NEVER HARD-CRASH (operator ruling 2026-09-03: "it's meant to
|
|
306
|
+
// soft crash with errors, so why the fuck is it hard crashing"). A service
|
|
307
|
+
// that refuses to start serves NOTHING — no page, no health endpoint, no
|
|
308
|
+
// error page — so a block-discovery problem presents to a client as a 502
|
|
309
|
+
// from a dead port, which is strictly less information than a served page
|
|
310
|
+
// with its blocks missing. The degraded state is loud (ERROR, both counts,
|
|
311
|
+
// every skip reason) and inspectable over HTTP, which is what makes it
|
|
312
|
+
// fixable; refusing the boot only hid the cause. `no-uncontrolled-repetition-
|
|
313
|
+
// or-cascade` is unaffected — nothing restarts, the process stays up.
|
|
314
|
+
if (loaded.length === 0) {
|
|
315
|
+
log.error(
|
|
316
|
+
`[two-phase-boot:${serviceSlug}] DEGRADED: zero Puck components loaded ` +
|
|
317
|
+
`from ${skipped.length} considered block_schema row(s). Pages will render ` +
|
|
318
|
+
`without blocks until this is fixed. Check service_install.block_packages, ` +
|
|
319
|
+
`the block_schema rows, and the skip reasons logged above.`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
302
322
|
|
|
303
323
|
// Run boot hooks BEFORE serving - some hooks may register routes and must
|
|
304
324
|
// run before Hono's matcher is compiled by the first request
|