@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
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
|
|
@@ -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
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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);
|