@teamscala/service-boot 0.23.457 → 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.457",
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
@@ -104,6 +104,116 @@ describe("resource-loader model capability", () => {
104
104
  });
105
105
  });
106
106
 
107
+ describe("resource_loader refs read both the snake and the camelCase spelling", () => {
108
+ // Loader rows author snake_case column names (page_id, organisation_id,
109
+ // prepared_for) while the v8 ORM hands back camelCase keys (pageId,
110
+ // organisationId, preparedFor). readField reads the spelled key first
111
+ // then its camelCase equivalent so neither side has to know the other.
112
+ // Measured through the ORPC branch: what the dependent source's findFirst
113
+ // RECEIVES as its where IS the resolution of `:proposal.page_id`.
114
+ const original = isServerClientRegistered() ? getServerClient() : null;
115
+
116
+ test("a snake_case ref resolves against a camelCase parent record", async () => {
117
+ const captured: { where: unknown } = { where: null };
118
+ const calls: string[] = [];
119
+ setServerClient({
120
+ builda_pages: {
121
+ findFirst: async (args: { where: Record<string, unknown> }) => {
122
+ calls.push(JSON.stringify(args.where));
123
+ // First call: the proposal source (id lookup) returns a
124
+ // camelCase row, exactly what the v8 chain hands back.
125
+ if (calls.length === 1) {
126
+ return {
127
+ id: "p1",
128
+ pageId: "page-uuid",
129
+ organisationId: "org-uuid",
130
+ preparedFor: "Klavdia Vainshtein",
131
+ };
132
+ }
133
+ // Second call: the dependent page source.
134
+ captured.where = args.where;
135
+ return { id: "page-uuid" };
136
+ },
137
+ },
138
+ } as never);
139
+ try {
140
+ await resolveFromLoaderConfig(
141
+ {
142
+ sources: [
143
+ {
144
+ key: "proposal",
145
+ source: "orpc",
146
+ model: "builda_pages",
147
+ where: { id: ":id" },
148
+ optional: false,
149
+ },
150
+ {
151
+ key: "page",
152
+ source: "orpc",
153
+ model: "builda_pages",
154
+ where: { id: ":proposal.page_id" },
155
+ optional: false,
156
+ depends_on: ["proposal"],
157
+ },
158
+ ],
159
+ },
160
+ { id: "p1" },
161
+ );
162
+ } finally {
163
+ setServerClient(original as never);
164
+ }
165
+ // The dependent source queried WITH the resolved page id, not an
166
+ // emptied filter matching an arbitrary row.
167
+ expect(captured.where).toEqual({ id: "page-uuid" });
168
+ });
169
+
170
+ test("identity-name fields resolve unchanged (id, path, slug)", async () => {
171
+ const captured: { where: unknown } = { where: null };
172
+ const calls: string[] = [];
173
+ setServerClient({
174
+ builda_pages: {
175
+ findFirst: async (args: { where: Record<string, unknown> }) => {
176
+ calls.push(JSON.stringify(args.where));
177
+ if (calls.length === 1) {
178
+ return { id: "p1", path: "/header", websiteId: "w-1" };
179
+ }
180
+ captured.where = args.where;
181
+ return { id: "p1" };
182
+ },
183
+ },
184
+ } as never);
185
+ try {
186
+ await resolveFromLoaderConfig(
187
+ {
188
+ sources: [
189
+ {
190
+ key: "page",
191
+ source: "orpc",
192
+ model: "builda_pages",
193
+ where: { id: ":id" },
194
+ optional: false,
195
+ },
196
+ {
197
+ key: "footer",
198
+ source: "orpc",
199
+ model: "builda_pages",
200
+ where: { path: ":page.path", website_id: ":page.website_id" },
201
+ optional: false,
202
+ depends_on: ["page"],
203
+ },
204
+ ],
205
+ },
206
+ { id: "p1" },
207
+ );
208
+ } finally {
209
+ setServerClient(original as never);
210
+ }
211
+ // path (identity) and website_id (snake → camel websiteId) both
212
+ // resolve through the same readField walk.
213
+ expect(captured.where).toEqual({ path: "/header", website_id: "w-1" });
214
+ });
215
+ });
216
+
107
217
  describe("required sources refuse unresolvable where refs", () => {
108
218
  // The 2026-09-03 proposal outage class: interpolateTemplate DROPS a key
109
219
  // whose ref resolves undefined, so a required source queries with a
@@ -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
  }
@@ -27,13 +27,34 @@ export async function resolveServiceContext(
27
27
  serviceSlug: string,
28
28
  databaseUrl: string,
29
29
  ): Promise<ServiceContext> {
30
+ // THE V8 CLIENT REGISTERS FIRST — before any registry read. getPrisma()
31
+ // serves the v8 model-indexed adapter (a81b9f0a), so an early registry
32
+ // read (getCookieDomain in buildMinimalApp, loadRegistryEntries) that ran
33
+ // against a v7-only registration found no `.orm` surface: 43695 threw
34
+ // "model not in the v8 client" (fleet-wide boot crash) and 43696+ degraded
35
+ // to EMPTY rows ("Config entry cookie-domain not found" on a row that has
36
+ // existed since July). Registering the real v8 client HERE — the earliest
37
+ // boot step — makes every later read resolve against the client the
38
+ // adapter expects. Measured 2026-09-03: this ordering gap took down every
39
+ // UI service for the second half of the Prisma 8 migration night.
40
+ const { createDbClientV8 } = await import("@teamscala/db/create-db-client-v8");
41
+ const db8 = await createDbClientV8({
42
+ connectionString: databaseUrl,
43
+ serviceName: serviceSlug,
44
+ });
45
+ const { registerDb8 } = await import("@teamscala/db/db8-registry");
46
+ registerDb8(db8 as never);
47
+ registerPrismaClient(db8 as never);
48
+
30
49
  const PrismaClientConstructor = await loadServicePrismaClient(serviceSlug);
31
50
  const dbClient = await createDbClient({
32
51
  connectionString: databaseUrl,
33
52
  serviceName: serviceSlug,
34
53
  PrismaClientConstructor: PrismaClientConstructor as never,
35
54
  });
36
- registerPrismaClient(dbClient.prisma as never as PrismaClient);
55
+ // The v7 client stays ONLY as the explicit per-call client (loadRegistry-
56
+ // Entries below, callers' disconnect) — it must NOT re-register, or the
57
+ // v7 registration overwrites the v8 one and the reads break again.
37
58
  await loadRegistryEntries(dbClient.prisma as never);
38
59
 
39
60
  const rowResult = await loadServiceInstallRow(serviceSlug);