@teamscala/service-boot 0.23.456 → 0.23.458
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
|
@@ -43,29 +43,68 @@ async function resolveStylesheetRaw(
|
|
|
43
43
|
}
|
|
44
44
|
return { path: p, base: dirname(p), content: await Bun.file(p).text() };
|
|
45
45
|
}
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
46
|
+
// A BARE SPECIFIER IS A PACKAGE NAME PLUS AN OPTIONAL SUBPATH, and the two
|
|
47
|
+
// must be separated before anything is looked up. `@teamscala/ui-foundation`
|
|
48
|
+
// is a package; `@teamscala/ui-foundation/theme/styles.css` is that same
|
|
49
|
+
// package plus an explicit file inside it. Treating the whole string as a
|
|
50
|
+
// package name looks for node_modules/@teamscala/ui-foundation/theme/
|
|
51
|
+
// styles.css/package.json, which cannot exist — so EVERY subpath import
|
|
52
|
+
// fails and the styles bundle dies, taking the theme layer with it.
|
|
53
|
+
//
|
|
54
|
+
// A scoped name is the FIRST TWO segments (@scope/name); an unscoped name
|
|
55
|
+
// is the first. Everything after that is a path inside the package and is
|
|
56
|
+
// resolved directly — it names a file, so there is nothing to negotiate and
|
|
57
|
+
// no style entry to consult.
|
|
58
|
+
const segments = id.split("/");
|
|
59
|
+
const nameLength = id.startsWith("@") ? 2 : 1;
|
|
60
|
+
const packageName = segments.slice(0, nameLength).join("/");
|
|
61
|
+
const subpath = segments.slice(nameLength).join("/");
|
|
62
|
+
|
|
63
|
+
// Find node_modules/<packageName>/package.json by walking upward from the
|
|
64
|
+
// importing file's dir then cwd. Bun.resolve is not used: it hands back
|
|
65
|
+
// whatever the package's main is — for a normal JS package a .js entry,
|
|
66
|
+
// not the declared stylesheet — and an `exports` allow-list blocks the
|
|
67
|
+
// package.json subpath on exactly the CSS-only packages this must serve.
|
|
54
68
|
const tried: string[] = [];
|
|
55
69
|
for (const root of [base, cwd]) {
|
|
56
70
|
let dir = root;
|
|
57
71
|
for (;;) {
|
|
58
|
-
const pkgPath = join(dir, "node_modules",
|
|
72
|
+
const pkgPath = join(dir, "node_modules", packageName, "package.json");
|
|
59
73
|
if (await Bun.file(pkgPath).exists()) {
|
|
74
|
+
const pkgDir = dirname(pkgPath);
|
|
60
75
|
const pkg = await Bun.file(pkgPath).json();
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
if (subpath) {
|
|
77
|
+
// THE EXPORTS MAP IS THE AUTHORITY ON WHERE A SUBPATH LIVES,
|
|
78
|
+
// and it commonly REMAPS. `@teamscala/ui-foundation` publishes
|
|
79
|
+
// "./theme/styles.css" -> "./src/theme/styles.css", so resolving
|
|
80
|
+
// the subpath literally against the package root looks for a
|
|
81
|
+
// file that has never existed there. Consult exports first and
|
|
82
|
+
// fall back to the literal path only for a package that
|
|
83
|
+
// declares none.
|
|
84
|
+
const exported = pkg.exports?.[`./${subpath}`];
|
|
85
|
+
const mapped: string | undefined =
|
|
86
|
+
typeof exported === "string"
|
|
87
|
+
? exported
|
|
88
|
+
: (exported?.style ?? exported?.default);
|
|
89
|
+
const r = resolve(pkgDir, mapped ?? subpath);
|
|
90
|
+
if (!(await Bun.file(r).exists())) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`style import "${id}" resolves to ${r}, which does not exist` +
|
|
93
|
+
(mapped
|
|
94
|
+
? ` (exports maps "./${subpath}" to "${mapped}")`
|
|
95
|
+
: ` (${packageName} declares no exports entry for "./${subpath}")`),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return { path: r, base: dirname(r), content: await Bun.file(r).text() };
|
|
99
|
+
}
|
|
100
|
+
// No subpath: the package itself must declare its stylesheet.
|
|
101
|
+
const entry: string | undefined = pkg.exports?.["."]?.style ?? pkg.style;
|
|
63
102
|
if (!entry) {
|
|
64
103
|
throw new Error(
|
|
65
104
|
`style import "${id}" resolved to ${pkgPath} but the package declares no style entry (exports["."].style / style) — not a stylesheet package`,
|
|
66
105
|
);
|
|
67
106
|
}
|
|
68
|
-
const r = resolve(
|
|
107
|
+
const r = resolve(pkgDir, entry);
|
|
69
108
|
return { path: r, base: dirname(r), content: await Bun.file(r).text() };
|
|
70
109
|
}
|
|
71
110
|
tried.push(pkgPath);
|
|
@@ -75,7 +114,7 @@ async function resolveStylesheetRaw(
|
|
|
75
114
|
}
|
|
76
115
|
}
|
|
77
116
|
throw new Error(
|
|
78
|
-
`style import "${id}" unresolved — no node_modules/${
|
|
117
|
+
`style import "${id}" unresolved — no node_modules/${packageName}/package.json found walking up from ${base} or ${cwd} (${tried.length} location(s) tried)`,
|
|
79
118
|
);
|
|
80
119
|
}
|
|
81
120
|
|
|
@@ -52,8 +52,7 @@ describe("interpolateMeta (resolver_input ref contract)", () => {
|
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
describe("resource-loader model capability", () => {
|
|
55
|
-
test("an optional source still refuses a model absent from the generated client", async () => {
|
|
56
|
-
const original = isServerClientRegistered() ? getServerClient() : null;
|
|
55
|
+
test("an optional source still refuses a model absent from the generated client", async () => { const original = isServerClientRegistered() ? getServerClient() : null;
|
|
57
56
|
setServerClient({} as never);
|
|
58
57
|
try {
|
|
59
58
|
await expect(
|
|
@@ -104,3 +103,224 @@ describe("resource-loader model capability", () => {
|
|
|
104
103
|
}
|
|
105
104
|
});
|
|
106
105
|
});
|
|
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
|
+
|
|
217
|
+
describe("required sources refuse unresolvable where refs", () => {
|
|
218
|
+
// The 2026-09-03 proposal outage class: interpolateTemplate DROPS a key
|
|
219
|
+
// whose ref resolves undefined, so a required source queries with a
|
|
220
|
+
// partial (or empty) filter — which matches an ARBITRARY row or none,
|
|
221
|
+
// serving a silent empty page instead of failing. Null is a real value
|
|
222
|
+
// (an unset column) and must keep flowing through.
|
|
223
|
+
const original = isServerClientRegistered() ? getServerClient() : null;
|
|
224
|
+
const capturingClient = (captured: { where: unknown }) => ({
|
|
225
|
+
builda_pages: {
|
|
226
|
+
findFirst: async (args: { where: unknown }) => {
|
|
227
|
+
captured.where = args.where;
|
|
228
|
+
return { id: "row" };
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
} as never);
|
|
232
|
+
|
|
233
|
+
test("a required source with an undefined ref throws naming it", async () => {
|
|
234
|
+
setServerClient(capturingClient({ where: null }));
|
|
235
|
+
try {
|
|
236
|
+
await expect(
|
|
237
|
+
resolveFromLoaderConfig(
|
|
238
|
+
{
|
|
239
|
+
sources: [
|
|
240
|
+
{
|
|
241
|
+
key: "page",
|
|
242
|
+
source: "orpc",
|
|
243
|
+
model: "builda_pages",
|
|
244
|
+
where: { id: ":proposal.page_id" },
|
|
245
|
+
optional: false,
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
{ id: "p1" },
|
|
250
|
+
),
|
|
251
|
+
).rejects.toThrow(/required source "page".*:proposal\.page_id.*undefined/);
|
|
252
|
+
} finally {
|
|
253
|
+
setServerClient(original as never);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("an optional source with the same undefined ref still resolves null", async () => {
|
|
258
|
+
setServerClient({
|
|
259
|
+
builda_pages: { findFirst: async () => null },
|
|
260
|
+
} as never);
|
|
261
|
+
try {
|
|
262
|
+
await expect(
|
|
263
|
+
resolveFromLoaderConfig(
|
|
264
|
+
{
|
|
265
|
+
sources: [
|
|
266
|
+
{
|
|
267
|
+
key: "page",
|
|
268
|
+
source: "orpc",
|
|
269
|
+
model: "builda_pages",
|
|
270
|
+
where: { id: ":proposal.page_id" },
|
|
271
|
+
optional: true,
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
{ id: "p1" },
|
|
276
|
+
),
|
|
277
|
+
).resolves.toEqual({ page: null });
|
|
278
|
+
} finally {
|
|
279
|
+
setServerClient(original as never);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("a resolved null flows through as a null filter on a required source", async () => {
|
|
284
|
+
const captured = { where: null as unknown };
|
|
285
|
+
// The first source resolves a row whose partner_id IS null — null is a
|
|
286
|
+
// real value flowing through interpolation, not an unresolved ref.
|
|
287
|
+
setServerClient({
|
|
288
|
+
builda_pages: {
|
|
289
|
+
findFirst: async (args: { where: unknown }) => {
|
|
290
|
+
captured.where = args.where;
|
|
291
|
+
return (args.where as { id: string }).id === "p1"
|
|
292
|
+
? { id: "p1", partner_id: null }
|
|
293
|
+
: null;
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
} as never);
|
|
297
|
+
try {
|
|
298
|
+
await resolveFromLoaderConfig(
|
|
299
|
+
{
|
|
300
|
+
sources: [
|
|
301
|
+
{
|
|
302
|
+
key: "proposal",
|
|
303
|
+
source: "orpc",
|
|
304
|
+
model: "builda_pages",
|
|
305
|
+
where: { id: ":id" },
|
|
306
|
+
optional: false,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
key: "partnerOrg",
|
|
310
|
+
source: "orpc",
|
|
311
|
+
model: "builda_pages",
|
|
312
|
+
where: { partner_id: ":proposal.partner_id" },
|
|
313
|
+
optional: false,
|
|
314
|
+
depends_on: ["proposal"],
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
{ id: "p1" },
|
|
319
|
+
);
|
|
320
|
+
} finally {
|
|
321
|
+
setServerClient(original as never);
|
|
322
|
+
}
|
|
323
|
+
// The partnerOrg source queried WITH { partner_id: null }, not refused.
|
|
324
|
+
expect(captured.where).toEqual({ partner_id: null });
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
15
|
+
import type { ResolverFnKey } from "@teamscala/db/registry/generated/resolver-fn";
|
|
15
16
|
import { ResolverFnConfigSchema } from "@teamscala/db-validation/registry-schemas/resolver-fn";
|
|
16
17
|
import { getServerClient } from "./page-server-orpc/server-client";
|
|
17
18
|
|
|
@@ -120,7 +121,7 @@ function toSelectMap(
|
|
|
120
121
|
* hardcoded if(resolver_fn==='X') chain — adding a resolver is now a registry_edit row.
|
|
121
122
|
*/
|
|
122
123
|
async function resolveByResolverFn(
|
|
123
|
-
slug:
|
|
124
|
+
slug: ResolverFnKey,
|
|
124
125
|
input: Record<string, unknown>,
|
|
125
126
|
params: Record<string, unknown>,
|
|
126
127
|
): Promise<unknown> {
|
|
@@ -145,6 +146,40 @@ async function resolveByResolverFn(
|
|
|
145
146
|
return fn({ input, client: await getServerClient(), params });
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Exact `:ref` strings inside a raw where/resolver_input whose value resolves
|
|
151
|
+
* to undefined — the keys interpolateTemplate DROPS. A dropped where key on a
|
|
152
|
+
* required source would query with a partial (or empty) filter, which matches
|
|
153
|
+
* an ARBITRARY row — worse than an error, so required sources refuse here
|
|
154
|
+
* instead (`a-component-may-not-report-a-state-it-has-not-verified`). Null is
|
|
155
|
+
* a real value (an unset column filtered as null) and never counts as broken.
|
|
156
|
+
*/
|
|
157
|
+
function unresolvedTemplateRefs(
|
|
158
|
+
template: unknown,
|
|
159
|
+
params: Record<string, unknown>,
|
|
160
|
+
resolved: Record<string, unknown>,
|
|
161
|
+
): string[] {
|
|
162
|
+
const broken: string[] = [];
|
|
163
|
+
const walk = (node: unknown): void => {
|
|
164
|
+
if (typeof node === "string") {
|
|
165
|
+
const exact = node.match(/^:([a-zA-Z0-9_.]+)$/);
|
|
166
|
+
if (exact && resolveParamRef(exact[1], params, resolved) === undefined) {
|
|
167
|
+
broken.push(exact[1]);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (Array.isArray(node)) {
|
|
172
|
+
for (const v of node) walk(v);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (node != null && typeof node === "object") {
|
|
176
|
+
for (const v of Object.values(node)) walk(v);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
walk(template);
|
|
180
|
+
return broken;
|
|
181
|
+
}
|
|
182
|
+
|
|
148
183
|
async function resolveSource(
|
|
149
184
|
source: SourceConfig,
|
|
150
185
|
params: Record<string, unknown>,
|
|
@@ -156,6 +191,14 @@ async function resolveSource(
|
|
|
156
191
|
case "orpc":
|
|
157
192
|
case "prisma": {
|
|
158
193
|
if (!source.model) return null;
|
|
194
|
+
if (!source.optional && source.where) {
|
|
195
|
+
const broken = unresolvedTemplateRefs(source.where, params, resolved);
|
|
196
|
+
if (broken.length > 0) {
|
|
197
|
+
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)`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
159
202
|
const where = source.where
|
|
160
203
|
? (interpolateTemplate(source.where, params, resolved) as Record<
|
|
161
204
|
string,
|
|
@@ -187,7 +230,11 @@ async function resolveSource(
|
|
|
187
230
|
) as Record<string, unknown>)
|
|
188
231
|
: {};
|
|
189
232
|
|
|
190
|
-
return resolveByResolverFn(
|
|
233
|
+
return resolveByResolverFn(
|
|
234
|
+
source.resolver_fn as ResolverFnKey,
|
|
235
|
+
input,
|
|
236
|
+
params,
|
|
237
|
+
);
|
|
191
238
|
}
|
|
192
239
|
default:
|
|
193
240
|
throw new Error(
|
|
@@ -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);
|