@teamscala/service-boot 0.23.460 → 0.23.462

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.460",
9
+ "version": "0.23.462",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -372,14 +372,6 @@
372
372
  "types": "./src/csrf-protection.d.ts",
373
373
  "default": "./src/csrf-protection.ts"
374
374
  },
375
- "./discover-blocks": {
376
- "types": "./src/discover-blocks.d.ts",
377
- "default": "./src/discover-blocks.ts"
378
- },
379
- "./discover-blocks.ts": {
380
- "types": "./src/discover-blocks.d.ts",
381
- "default": "./src/discover-blocks.ts"
382
- },
383
375
  "./expand-roster": {
384
376
  "types": "./src/expand-roster.d.ts",
385
377
  "default": "./src/expand-roster.ts"
@@ -800,6 +792,14 @@
800
792
  "types": "./src/is-react-element-type.d.ts",
801
793
  "default": "./src/is-react-element-type.ts"
802
794
  },
795
+ "./load-page-manifest": {
796
+ "types": "./src/load-page-manifest.d.ts",
797
+ "default": "./src/load-page-manifest.ts"
798
+ },
799
+ "./load-page-manifest.ts": {
800
+ "types": "./src/load-page-manifest.d.ts",
801
+ "default": "./src/load-page-manifest.ts"
802
+ },
803
803
  "./load-service-install-row": {
804
804
  "types": "./src/load-service-install-row.d.ts",
805
805
  "default": "./src/load-service-install-row.ts"
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Loads the service's GENERATED `src/page-manifest.ts` — the codegen output of
7
+ * the `page-manifest` transform, emitted from `block_schema` rows. That file is
8
+ * the single artefact describing which Puck components this service renders.
9
+ *
10
+ * This REPLACES `discover-blocks.ts`, a hand-written runtime scan that queried
11
+ * block_schema at boot and dynamic-imported each row. Deleting it is the point.
12
+ * The scan was introduced to survive one dead row, and it bought that by moving
13
+ * a build-time, deterministic, generated fact into a runtime path with four
14
+ * independent ways to silently resolve nothing: a registry read that answers
15
+ * with an empty array instead of failing, an install probe that reports "not
16
+ * installed" for installed packages, a `node_modules` lookup relative to
17
+ * `process.cwd()`, and a per-block try/catch that swallows each failure into a
18
+ * string nobody reads when the boot dies first. Measured 2026-09-03: 91 active
19
+ * block_schema rows, a generated manifest carrying 63 blocks sitting on disk,
20
+ * and the scan loading ZERO — for hours, across every UI service.
21
+ *
22
+ * Generation is the platform's answer to exactly this (`one-generator-pattern`,
23
+ * `business-logic-is-rows-plus-executors`): a bad row fails codegen loudly, at
24
+ * build time, with the file in front of you. It cannot present as a service
25
+ * that boots and renders nothing.
26
+ */
27
+
28
+ import { join } from "node:path";
29
+ import type { RendererComponentEntry } from "./types.ts";
30
+
31
+ interface PageManifestModule {
32
+ components?: Record<string, RendererComponentEntry>;
33
+ }
34
+
35
+ /**
36
+ * Reads the generated manifest's `components` export. Throws when the file is
37
+ * absent or exports nothing — an empty manifest means codegen has not run for
38
+ * this service, which is a build problem the operator must see named, not a
39
+ * degraded runtime state to paper over.
40
+ */
41
+ export async function loadPageManifest(
42
+ serviceSlug: string,
43
+ ): Promise<Record<string, RendererComponentEntry>> {
44
+ const manifestPath = join(process.cwd(), "src", "page-manifest.ts");
45
+ let mod: PageManifestModule;
46
+ try {
47
+ mod = (await import(manifestPath)) as PageManifestModule;
48
+ } catch (err) {
49
+ throw new Error(
50
+ `[${serviceSlug}] could not import the generated manifest at ${manifestPath}: ` +
51
+ `${err instanceof Error ? err.message : String(err)}. ` +
52
+ `Run \`scala-tools codegen run --output ${serviceSlug}-page-manifest --force\`.`,
53
+ );
54
+ }
55
+
56
+ const components = mod.components;
57
+ if (!components || Object.keys(components).length === 0) {
58
+ throw new Error(
59
+ `[${serviceSlug}] the generated manifest at ${manifestPath} exports no components. ` +
60
+ `Regenerate it: \`scala-tools codegen run --output ${serviceSlug}-page-manifest --force\`.`,
61
+ );
62
+ }
63
+ return components;
64
+ }
@@ -11,6 +11,7 @@
11
11
  * Params are interpolated from URL params and prior resolved data.
12
12
  */
13
13
 
14
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
14
15
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
15
16
  import type { ResolverFnKey } from "@teamscala/db/registry/generated/resolver-fn";
16
17
  import { ResolverFnConfigSchema } from "@teamscala/db-validation/registry-schemas/resolver-fn";
@@ -77,7 +77,7 @@ import {
77
77
  import { injectSecurityHeaders } from "./security-headers.ts";
78
78
  import { registerPageRoutes } from "./register-page-routes.ts";
79
79
  import { runBootHookList } from "./run-boot-hook-list.ts";
80
- import { discoverBlocks } from "./discover-blocks.ts";
80
+ import { loadPageManifest } from "./load-page-manifest.ts";
81
81
  import { registerPageServerClient } from "./register-page-server-client.ts";
82
82
  import { createPhase1PageHandler, healthHandler } from "./two-phase-readiness.ts";
83
83
 
@@ -250,10 +250,10 @@ export async function buildMinimalApp(
250
250
  registerPageServerClient(serviceSlug),
251
251
  );
252
252
 
253
- // NOW fire discoverBlocks (getServerClient is set). Awaited below before
254
- // register-page-routes consumes the result.
255
- const discoverPromise = runStep("discover-blocks", () =>
256
- discoverBlocks(blockPackages),
253
+ // NOW load the generated manifest (getServerClient is set). Awaited below
254
+ // before register-page-routes consumes the result.
255
+ const discoverPromise = runStep("load-page-manifest", () =>
256
+ loadPageManifest(serviceSlug),
257
257
  );
258
258
 
259
259
  const wsRouteCount = await runStep("register-ws-routes", () =>
@@ -268,9 +268,9 @@ export async function buildMinimalApp(
268
268
  // CRITICAL: Block discovery and page routes MUST be registered BEFORE serving,
269
269
  // because Hono's RegExpRouter compiles on first request and becomes immutable.
270
270
  // We defer only client asset building (expensive Vite build) to Phase 2.
271
- let discovered: Awaited<ReturnType<typeof discoverBlocks>>;
271
+ let components: Awaited<ReturnType<typeof loadPageManifest>>;
272
272
  try {
273
- discovered = await discoverPromise;
273
+ components = await discoverPromise;
274
274
  } catch (err) {
275
275
  throw new BootFatalError(
276
276
  "ui-service.page-manifest",
@@ -279,46 +279,10 @@ export async function buildMinimalApp(
279
279
  err,
280
280
  );
281
281
  }
282
+ log.info(
283
+ `[two-phase-boot:${serviceSlug}] Loaded ${Object.keys(components).length} block(s) from the generated manifest`,
284
+ );
282
285
 
283
- const { components, loaded, skipped } = discovered;
284
- // REPORT THE DISCOVERY BEFORE JUDGING IT. This used to throw BootFatalError
285
- // on `loaded.length === 0` ABOVE these two log lines, so the one boot that
286
- // most needed the diagnosis was the only boot that printed none of it: the
287
- // operator saw "Zero Puck components loaded" and nothing about WHICH rows
288
- // were considered or WHY each was rejected. Measured 2026-09-03: 91 active
289
- // block_schema rows, zero loaded, zero skip lines — and the absence of skip
290
- // lines was itself unreadable, because "no candidate survived the install
291
- // pre-filter" and "every candidate failed to import" are indistinguishable
292
- // when neither prints. Hours went into re-deriving from outside the process
293
- // what these lines already knew.
294
- if (loaded.length > 0) {
295
- log.info(
296
- `[two-phase-boot:${serviceSlug}] Discovered ${loaded.length} block(s): ${loaded.join(", ")}`,
297
- );
298
- }
299
- if (skipped.length > 0) {
300
- log.warn(
301
- `[two-phase-boot:${serviceSlug}] Skipped ${skipped.length} block_schema row(s) ` +
302
- `(missing componentPath/componentExport or import failed): ${skipped.join(", ")}`,
303
- );
304
- }
305
- // SOFT-CRASH, NEVER HARD-CRASH (operator ruling 2026-09-03: "it's meant to
306
- // soft crash with errors, so why the fuck is it hard crashing"). A service
307
- // that refuses to start serves NOTHING — no page, no health endpoint, no
308
- // error page — so a block-discovery problem presents to a client as a 502
309
- // from a dead port, which is strictly less information than a served page
310
- // with its blocks missing. The degraded state is loud (ERROR, both counts,
311
- // every skip reason) and inspectable over HTTP, which is what makes it
312
- // fixable; refusing the boot only hid the cause. `no-uncontrolled-repetition-
313
- // or-cascade` is unaffected — nothing restarts, the process stays up.
314
- if (loaded.length === 0) {
315
- log.error(
316
- `[two-phase-boot:${serviceSlug}] DEGRADED: zero Puck components loaded ` +
317
- `from ${skipped.length} considered block_schema row(s). Pages will render ` +
318
- `without blocks until this is fixed. Check service_install.block_packages, ` +
319
- `the block_schema rows, and the skip reasons logged above.`,
320
- );
321
- }
322
286
 
323
287
  // Run boot hooks BEFORE serving - some hooks may register routes and must
324
288
  // run before Hono's matcher is compiled by the first request
@@ -366,7 +330,10 @@ export async function buildMinimalApp(
366
330
  databaseUrl,
367
331
  blockPackages,
368
332
  components,
369
- loaded,
370
- skipped,
333
+ // The generated manifest IS the loaded set — every entry it exports is a
334
+ // block this service renders, so there is no "skipped" half any more. A
335
+ // row that cannot produce a component fails codegen, not boot.
336
+ loaded: Object.keys(components),
337
+ skipped: [] as string[],
371
338
  };
372
339
  }
@@ -1,20 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import { registerPrismaClient } from "@teamscala/db/prisma-registry";
3
- import type { PrismaClient } from "@teamscala/db/client";
4
- import { discoverBlocks } from "./discover-blocks.ts";
5
-
6
- test("block discovery reads the global registry through the registered database client", async () => {
7
- let calls = 0;
8
- registerPrismaClient({
9
- registry_entries: {
10
- findMany: async () => {
11
- calls += 1;
12
- return [];
13
- },
14
- },
15
- } as unknown as PrismaClient);
16
-
17
- const result = await discoverBlocks([]);
18
- expect(calls).toBe(1);
19
- expect(result).toEqual({ components: {}, loaded: [], skipped: [] });
20
- });
@@ -1,250 +0,0 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Surgical block discovery: at boot, queries every block_schema row in the
7
- * registry whose componentPath starts with one of the service's declared
8
- * block_packages, then dynamic-imports each one. Per-block try/catch — one
9
- * dead componentPath logs and skips, the rest still register and the page
10
- * renders the surviving blocks. This replaces the static
11
- * `.scala-install/page-manifest.ts` codegen output, which had the failure
12
- * mode of a single dead block_schema row crashing the whole manifest at
13
- * compile time.
14
- *
15
- * Empty block_packages is NOT fatal here: it falls back to scanning all
16
- * active block_schema (uninstalled packages skip via the per-block try/catch),
17
- * so a service whose row is unpopulated but whose blocks are installed still
18
- * renders. The real "booted with zero blocks" fail-closed is at the call site
19
- * (loaded.length === 0 → BootFatalError). A DB-unreachable error still throws —
20
- * no fallback to a package-side puckBlocks scan, which masked blockless boots.
21
- */
22
-
23
- import { getAppLogger } from "@teamscala/logger/app-loggers";
24
- import { join } from "node:path";
25
- import { getPrisma } from "@teamscala/db/prisma-registry";
26
- import { queryActiveRegistryRows } from "@teamscala/db/registry/loader/query-rows";
27
- import { BlockSchemaConfigSchema } from "@teamscala/db-validation/registry-schemas/block-schema";
28
- import * as v from "valibot";
29
- import { isReactElementType } from "./is-react-element-type.ts";
30
- import type { RendererComponentEntry } from "./types.ts";
31
-
32
- export interface DiscoveredBlocks {
33
- components: Record<string, RendererComponentEntry>;
34
- loaded: string[];
35
- skipped: string[];
36
- }
37
-
38
- interface BlockSchemaConfig {
39
- componentPath?: string;
40
- componentExport?: string;
41
- fields?: Record<string, unknown>;
42
- defaults?: Record<string, unknown>;
43
- }
44
-
45
- export async function discoverBlocks(
46
- blockPackages: string[],
47
- ): Promise<DiscoveredBlocks> {
48
- // Scope discovery to what is ACTUALLY INSTALLED, never the authored `block_packages` list. That list was an authored hint that silently
49
- // DRIFTED: a page-used package missing from it caused its blocks to be skipped and rendered BLANK with only a warning (scala-sign, 2026-06-04 —
50
- // SignIn / SignatureField / ScrollProgress all blanked because sign-in-block had fallen out of the list). The filesystem is the source of truth: a page
51
- // can only compose blocks from packages installed as the service's
52
- // dependencies, so "installed @teamgbg package" is always a correct superset
53
- // of "packages the pages need". A block_schema whose package is installed is
54
- // imported; an uninstalled one is skipped (its import would fail anyway).
55
- // This makes an authored-list mistake STRUCTURALLY UNABLE to blank a
56
- // page-used block. The install dir is also narrower than "all block_schema",
57
- // so the 2026-05-29 empty-list-scans-everything incident cannot recur either.
58
- const serviceModules = join(process.cwd(), "node_modules");
59
- const installedCache = new Map<string, boolean>();
60
- const isInstalled = async (pkg: string): Promise<boolean> => {
61
- const cached = installedCache.get(pkg);
62
- if (cached !== undefined) return cached;
63
- // Probe the package's package.json, NOT its directory. Bun.file() on a
64
- // DIRECTORY reports exists() === false, so the previous directory probe
65
- // answered "not installed" for every package that was in fact installed
66
- // — and because this predicate gates every block below, that skipped
67
- // every block and booted the service with zero Puck components. Measured
68
- // 2026-09-01: scala-portal, scala-public, scala-forms and scala-projects
69
- // all FATAL at ui-service.page-manifest with their packages present on
70
- // disk at the expected versions. A file probe is the correct shape.
71
- const ok = await Bun.file(
72
- join(serviceModules, ...pkg.split("/"), "package.json"),
73
- ).exists();
74
- installedCache.set(pkg, ok);
75
- return ok;
76
- };
77
- // `blockPackages` is retained only to surface authored-vs-installed drift to
78
- // the operator; it no longer gates discovery, so it can never blank a block.
79
- if (blockPackages.length > 0) {
80
- const installed = await Promise.all(
81
- blockPackages.map(async (p) => ({ p, ok: await isInstalled(p) })),
82
- );
83
- const stale = installed.filter((x) => !x.ok).map((x) => x.p);
84
- if (stale.length > 0) {
85
- getAppLogger().warn(
86
- `[discover-blocks] block_packages lists ${stale.length} package(s) that are not installed (ignored): ${stale.join(", ")}`,
87
- );
88
- }
89
- }
90
-
91
- const components: Record<string, RendererComponentEntry> = {};
92
- const loaded: string[] = [];
93
- const skipped: string[] = [];
94
-
95
- // Block schemas are global registry data, not a model owned by this UI
96
- // service. The page-server ORPC client is intentionally service-scoped and
97
- // therefore cannot expose registry_entries. Use the registered database
98
- // client for this shared model, as every other registry reader does.
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));
120
-
121
- // Parallel block discovery (no-serial-per-unit-work-on-hot-paths: boot is a
122
- // recurring cycle via the package-poller's restarts). Previously each block's
123
- // component was imported SERIALLY (sum of ~44 import times) under a 3s
124
- // timeout — heavy blocks (e.g. AiChat with Plate) exceeded 3s, were SKIPPED
125
- // at boot, and paid their full import at first render (cold-start cost).
126
- // Bounded-parallel (chunks of 8) collapses boot to ceil(N/8) waves of the
127
- // max import time, and the raised 8s timeout lets heavy blocks import at
128
- // boot (shifted off the first render). `skipped`/`loaded`/`components`
129
- // mutations are safe concurrently (single-threaded; atomic at await points).
130
- const loadBlock = async (row: { slug: string; config: unknown }) => {
131
- const rawConfig = parseConfig(row.config);
132
- const rawComponentPath = getStringProperty(rawConfig, "componentPath");
133
- const rawPackage = rawComponentPath
134
- ? rawComponentPath.split("/").slice(0, 2).join("/")
135
- : null;
136
- if (!rawPackage || !(await isInstalled(rawPackage))) {
137
- skipped.push(
138
- `${row.slug} (${rawPackage ?? "missing componentPath"} not installed)`,
139
- );
140
- return;
141
- }
142
-
143
- let cfg: BlockSchemaConfig;
144
- try {
145
- cfg = v.parse(BlockSchemaConfigSchema, rawConfig) as BlockSchemaConfig;
146
- } catch (err) {
147
- skipped.push(
148
- `${row.slug} (invalid block_schema config: ${formatValidationError(err)})`,
149
- );
150
- return;
151
- }
152
-
153
- const componentPath = cfg.componentPath;
154
- const componentExport = cfg.componentExport;
155
- if (!componentPath || !componentExport) {
156
- skipped.push(`${row.slug} (missing componentPath/componentExport)`);
157
- return;
158
- }
159
- // Scope to installed packages: each block_schema's componentPath must
160
- // start with a package present in the service's node_modules.
161
- const pkgPrefix = componentPath.split("/").slice(0, 2).join("/");
162
- if (!(await isInstalled(pkgPrefix))) {
163
- skipped.push(`${row.slug} (${pkgPrefix} not installed)`);
164
- return;
165
- }
166
- try {
167
- const TIMEOUT_MS = 8_000;
168
- const mod = (await Promise.race([
169
- import(componentPath),
170
- new Promise<never>((_, rej) =>
171
- setTimeout(
172
- () => rej(new Error(`import timeout >${TIMEOUT_MS}ms`)),
173
- TIMEOUT_MS,
174
- ),
175
- ),
176
- ])) as Record<string, unknown>;
177
- const component = mod[componentExport] ?? mod.default;
178
- if (!isReactElementType(component)) {
179
- skipped.push(
180
- `${row.slug} (${componentExport} not a component in ${componentPath})`,
181
- );
182
- return;
183
- }
184
- components[row.slug] = {
185
- render: component as RendererComponentEntry["render"],
186
- fields: (cfg.fields ?? {}) as never,
187
- // block_schema.defaults → the SSR component entry, so buildPuckConfig
188
- // merges them UNDER authored props at render (blocks-fully-programmable:
189
- // DB-declared defaults reach render, never a component parameter
190
- // constant). Puck's RSC <Render> ignores defaultProps itself.
191
- defaultProps: (cfg.defaults ?? {}) as never,
192
- };
193
- loaded.push(row.slug);
194
- } catch (err) {
195
- skipped.push(`${row.slug} (${(err as Error).message})`);
196
- }
197
- };
198
- // Pre-filter to rows the service can actually import, so expected-absent
199
- // blocks don't pollute the boot log as "skipped". A service (e.g.
200
- // scala-projects) installs only a subset of the platform's ~45 blocks; the
201
- // rest are expected-absent (their imports would fail anyway), not warnings.
202
- // The filesystem remains the source of truth (block-discovery-scopes-by-
203
- // install): an uninstalled block is simply not considered. Rows with a
204
- // missing componentPath are kept so `loadBlock` still flags them (real data
205
- // signal); `skipped` thus catches only genuine failures (installed package
206
- // but import/config/export failed) and the call-site WARN becomes signal.
207
- const candidateRows = [];
208
- for (const row of rows) {
209
- const cp = getStringProperty(parseConfig(row.config), "componentPath");
210
- if (!cp) {
211
- candidateRows.push(row);
212
- continue;
213
- }
214
- const pkg = cp.split("/").slice(0, 2).join("/");
215
- if (await isInstalled(pkg)) candidateRows.push(row);
216
- }
217
- // All blocks concurrent (module imports are in-process I/O + V8 compile — no
218
- // fork tax, so bounded concurrency isn't needed here; the chunk-of-8 was
219
- // needlessly serializing 44 imports into 6 waves when 1 wave suffices).
220
- await Promise.all(candidateRows.map(loadBlock));
221
-
222
- return { components, loaded, skipped };
223
- }
224
-
225
- function parseConfig(config: unknown): unknown {
226
- if (typeof config !== "string") return config;
227
- try {
228
- return JSON.parse(config) as unknown;
229
- } catch {
230
- // Malformed JSON string → absent signal, never the raw string: the caller's
231
- // valibot parse then fails and the block is skipped (fail-closed), rather
232
- // than a non-object masquerading as config (protective-layers-fail-explicitly).
233
- return null;
234
- }
235
- }
236
-
237
- function getStringProperty(value: unknown, key: string): string | null {
238
- if (!value || typeof value !== "object") return null;
239
- const maybe = (value as Record<string, unknown>)[key];
240
- return typeof maybe === "string" ? maybe : null;
241
- }
242
-
243
- function formatValidationError(err: unknown): string {
244
- if (err && typeof err === "object" && "issues" in err) {
245
- const issues = (err as { issues?: Array<{ message?: string }> }).issues;
246
- const first = Array.isArray(issues) ? issues[0]?.message : undefined;
247
- if (first) return first;
248
- }
249
- return err instanceof Error ? err.message : String(err);
250
- }