@teamscala/service-boot 0.23.458 → 0.23.459
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
|
}
|