@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
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.458",
9
+ "version": "0.23.459",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -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
- const prisma = getPrisma();
99
- const rows: Array<{ slug: string; config: unknown }> =
100
- await prisma.registry_entries.findMany({
101
- where: { type: "block_schema", is_active: true },
102
- select: { slug: true, config: true },
103
- orderBy: { slug: "asc" },
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>)[part];
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
- if (!source.optional && source.where) {
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) ${broken.map((ref) => `:${ref}`).join(", ")} resolved to undefined — refusing to query without them (a dropped filter silently matches an arbitrary row)`,
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
  }