@teamscala/service-boot 0.23.352 → 0.23.355

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.352",
9
+ "version": "0.23.355",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -37,10 +37,21 @@ import {
37
37
  type CertificateData,
38
38
  } from "./fn-routers/public-agreement-router.ts";
39
39
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
40
+ import { getServerClient } from "./page-server-orpc/server-client.ts";
40
41
  import * as v from "valibot";
41
42
 
42
43
  const AG_COOKIE = "ag_token";
43
44
 
45
+ async function findFirstAgreement<T>(input: {
46
+ where: Record<string, unknown>;
47
+ select: Record<string, boolean>;
48
+ }): Promise<T | null> {
49
+ const client = getServerClient() as unknown as {
50
+ agreements: { findFirst: (args: typeof input) => Promise<T | null> };
51
+ };
52
+ return client.agreements.findFirst(input);
53
+ }
54
+
44
55
  /**
45
56
  * Agreement gate display + behaviour, DB-driven (config/content-gate-agreement),
46
57
  * never hardcoded — mirrors proposal-gate's loadProposalGateSettings. requireOtp
@@ -112,9 +123,10 @@ const orgMemberCache = createCache<Map<string, string>>(
112
123
  * member of `organisationId`. Empty when the org is unknown or has no members.
113
124
  *
114
125
  * Resolved via a parameterized raw join over better_auth_member + "user" rather
115
- * than `getPrisma().member`: `member` is a shared @@map'd Prisma model, and each
126
+ * rather than a service-local Prisma `member` model: `member` is a shared
127
+ * @@map'd Prisma model, and each
116
128
  * service's per-service client is generated by live DB introspection from only
117
- * the tables its service_schema declares — so `getPrisma().member` is UNDEFINED
129
+ * the tables its service_schema declares — so that model is undefined
118
130
  * on a service (scala-sign, scala-proposals) that doesn't list better_auth_member.
119
131
  * A read-only raw query needs no model declaration and works on every client,
120
132
  * which is what makes org-member VIEW access actually resolve (the typed access
@@ -128,7 +140,6 @@ async function resolveOrgMemberDirectory(
128
140
  if (cached !== undefined) return cached;
129
141
  const directory = new Map<string, string>();
130
142
  try {
131
- const { getPrisma } = await import("@teamscala/db/prisma-registry");
132
143
  const rows = await queryRows`
133
144
  SELECT u.email AS email, u.name AS name
134
145
  FROM better_auth_member m
@@ -245,8 +256,13 @@ async function buildSigningSession(
245
256
  const log = getAppLogger();
246
257
  const lower = verifiedEmail.trim().toLowerCase();
247
258
  try {
248
- const { getPrisma } = await import("@teamscala/db/prisma-registry");
249
- const agreement = await getPrisma().agreements.findFirst({
259
+ const agreement = await findFirstAgreement<{
260
+ name: string | null;
261
+ signer_name: string | null;
262
+ signed_at: Date | string | null;
263
+ signature_data: unknown;
264
+ organisation_id: string | null;
265
+ }>({
250
266
  where: { id: agreementUuid },
251
267
  select: {
252
268
  name: true,
@@ -638,8 +654,7 @@ export const agreementGateConfig: ContentGateConfig = {
638
654
  }
639
655
 
640
656
  try {
641
- const { getPrisma } = await import("@teamscala/db/prisma-registry");
642
- const agreement = await getPrisma().agreements.findFirst({
657
+ const agreement = await findFirstAgreement<{ id: string; name: string }>({
643
658
  where: { page_id: pageId },
644
659
  select: { id: true, name: true },
645
660
  });
@@ -814,8 +829,10 @@ async function resolveAgreementSignerEmails(
814
829
  ): Promise<Set<string>> {
815
830
  const emails = new Set<string>();
816
831
  try {
817
- const { getPrisma } = await import("@teamscala/db/prisma-registry");
818
- const agreement = await getPrisma().agreements.findFirst({
832
+ const agreement = await findFirstAgreement<{
833
+ signer_email: string | null;
834
+ signature_data: unknown;
835
+ }>({
819
836
  where: { id: agreementId },
820
837
  select: { signer_email: true, signature_data: true },
821
838
  });
@@ -849,8 +866,10 @@ async function resolveAgreementAuthorizedEmails(
849
866
  ): Promise<Set<string>> {
850
867
  const emails = await resolveAgreementSignerEmails(agreementId);
851
868
  try {
852
- const { getPrisma } = await import("@teamscala/db/prisma-registry");
853
- const agreement = await getPrisma().agreements.findFirst({
869
+ const agreement = await findFirstAgreement<{
870
+ organisation_id: string | null;
871
+ signature_data: unknown;
872
+ }>({
854
873
  where: { id: agreementId },
855
874
  select: { organisation_id: true, signature_data: true },
856
875
  });
@@ -0,0 +1,20 @@
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
+ });
@@ -22,8 +22,8 @@
22
22
 
23
23
  import { existsSync } from "node:fs";
24
24
  import { join } from "node:path";
25
+ import { getPrisma } from "@teamscala/db/prisma-registry";
25
26
  import { BlockSchemaConfigSchema } from "@teamscala/db-validation/registry-schemas/block-schema";
26
- import { getServerClient } from "./page-server-orpc/server-client.ts";
27
27
  import * as v from "valibot";
28
28
  import type { RendererComponentEntry } from "./types.ts";
29
29
 
@@ -40,18 +40,6 @@ interface BlockSchemaConfig {
40
40
  defaults?: Record<string, unknown>;
41
41
  }
42
42
 
43
- interface RegistryEntriesModel {
44
- findMany: (args: {
45
- where: Record<string, unknown>;
46
- select: Record<string, unknown>;
47
- orderBy: Record<string, unknown>;
48
- }) => Promise<Array<{ slug: string; config: unknown }>>;
49
- }
50
-
51
- interface PrismaWithRegistry {
52
- registry_entries: RegistryEntriesModel;
53
- }
54
-
55
43
  export async function discoverBlocks(
56
44
  blockPackages: string[],
57
45
  ): Promise<DiscoveredBlocks> {
@@ -92,7 +80,11 @@ export async function discoverBlocks(
92
80
  const loaded: string[] = [];
93
81
  const skipped: string[] = [];
94
82
 
95
- const prisma = (await getServerClient()) as unknown as PrismaWithRegistry;
83
+ // Block schemas are global registry data, not a model owned by this UI
84
+ // service. The page-server ORPC client is intentionally service-scoped and
85
+ // therefore cannot expose registry_entries. Use the registered database
86
+ // client for this shared model, as every other registry reader does.
87
+ const prisma = getPrisma();
96
88
  const rows: Array<{ slug: string; config: unknown }> =
97
89
  await prisma.registry_entries.findMany({
98
90
  where: { type: "block_schema", is_active: true },
@@ -3,7 +3,8 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Regression guard for the manifest-keyed HTML cache. The lifetime-pinned
6
+ * Regression guard for the runtime-owned Puck document and manifest-keyed
7
+ * HTML cache. The lifetime-pinned
7
8
  * cache served a deleted bundle hash after a post-boot rebuild (scala-docs
8
9
  * 2026-06-13): build-client-assets rewrites asset-manifest.json + deletes the
9
10
  * old hashed bundle, but the cached HTML kept the old hash → 404 → no
@@ -29,12 +30,7 @@ function writeManifest(hydrate: string, styles: string): void {
29
30
  beforeEach(() => {
30
31
  origCwd = process.cwd();
31
32
  dir = mkdtempSync(join(tmpdir(), "read-html-template-"));
32
- mkdirSync(join(dir, "src"), { recursive: true });
33
33
  mkdirSync(join(dir, "dist", "client"), { recursive: true });
34
- writeFileSync(
35
- join(dir, "src", "index.html"),
36
- '<link href="{{stylesPath}}"><script src="{{hydratePath}}"></script>',
37
- );
38
34
  process.chdir(dir);
39
35
  });
40
36
 
@@ -44,6 +40,15 @@ afterEach(() => {
44
40
  });
45
41
 
46
42
  describe("readHtmlTemplate manifest-keyed cache", () => {
43
+ test("renders the shared Puck document without a per-service HTML file", async () => {
44
+ writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
45
+ const html = await readHtmlTemplate();
46
+ expect(html).toContain('<div id="__page"><!--ssr-outlet--></div>');
47
+ expect(html).toContain("<!--head-outlet-->");
48
+ expect(html).toContain("<!--boot-data-->");
49
+ expect(await Bun.file(join(dir, "src", "index.html")).exists()).toBe(false);
50
+ });
51
+
47
52
  test("substitutes the current manifest hashes", async () => {
48
53
  writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
49
54
  const html = await readHtmlTemplate();
@@ -3,11 +3,14 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Reads the per-service HTML template (codegen'd from
7
- * `@teamscala/scala-install/src/runtime/index.html`) and substitutes the
8
- * `{{stylesPath}}` / `{{hydratePath}}` placeholders with the current
6
+ * Builds the shared React/Puck document envelope and substitutes the current
9
7
  * content-hashed asset URLs from `dist/client/asset-manifest.json`.
10
8
  *
9
+ * The envelope is runtime-owned. UI services are Puck compositions and must
10
+ * not carry or generate a per-service `src/index.html`; requiring that file
11
+ * creates a second rendering surface that can disappear independently of the
12
+ * Hono + React/Puck runtime.
13
+ *
11
14
  * Build-time produces hashed filenames (e.g. `styles-abc123.css`); the
12
15
  * static-asset handler serves them with `cache-control: immutable`. New
13
16
  * deployments emit new hashes → new URLs → browsers refetch without ever
@@ -49,6 +52,24 @@ const FALLBACK: AssetManifest = {
49
52
  hydratePath: "/assets/hydrate.js",
50
53
  };
51
54
 
55
+ const PAGE_DOCUMENT = `<!doctype html>
56
+ <html lang="en">
57
+ <head>
58
+ <meta charset="utf-8" />
59
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
60
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
61
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
62
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=optional" />
63
+ <link rel="stylesheet" href="{{stylesPath}}" />
64
+ <!--head-outlet-->
65
+ </head>
66
+ <body>
67
+ <!--boot-data-->
68
+ <div id="__page"><!--ssr-outlet--></div>
69
+ <script type="module" src="{{hydratePath}}"></script>
70
+ </body>
71
+ </html>`;
72
+
52
73
  let cached: { html: string; manifestMtime: number } | null = null;
53
74
 
54
75
  function manifestPath(): string {
@@ -79,10 +100,8 @@ async function loadManifest(): Promise<AssetManifest> {
79
100
  export async function readHtmlTemplate(): Promise<string> {
80
101
  const mtime = await manifestMtimeMs();
81
102
  if (cached && cached.manifestMtime === mtime) return cached.html;
82
- const templatePath = join(process.cwd(), "src", "index.html");
83
- const raw = await Bun.file(templatePath).text();
84
103
  const manifest = await loadManifest();
85
- let html = raw
104
+ let html = PAGE_DOCUMENT
86
105
  .replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
87
106
  .replace(/\{\{hydratePath\}\}/g, manifest.hydratePath);
88
107
  // Tombstone stamp — surfaces a failed client build at the served layer.