@assembly-js/app-foundation 0.0.2 → 0.0.4

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/CLAUDE.md CHANGED
@@ -31,7 +31,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
31
31
  <!-- /feature:notification-settings -->
32
32
  - `clearing-client-notifications` — REQUIRED when the app sends client notifications: clear the portal sidebar badge with markNotificationRead (clear-on-view vs clear-on-action)
33
33
  <!-- feature:api-resources -->
34
- - `assembly-automations` — declare the app's API contract in `.assembly/api-resources.json` (a separate file from `app.json`): domain events that become automation triggers + workspace webhooks, and staff-invokable operations that become automation actions. Declared events must also be emitted at runtime, reporting the acting user on the documented headers — declaring alone never fires anything. Read it before adding either.
34
+ - `assembly-automations` — REQUIRED when the request asks for a platform-side effect in reaction to an in-app event (a message, a CRM field, a contract, a form, a file — not the app's own tables), even if the user never says "automation": declare the app's API contract in `.assembly/api-resources.json` (a separate file from `app.json`) — domain events that become automation triggers + workspace webhooks, and staff-invokable operations that become automation actions. Declared events must also be emitted at runtime, reporting the acting user on the documented headers — declaring alone never fires anything. Read it before adding either.
35
35
  <!-- /feature:api-resources -->
36
36
  - `assembly-app-surfaces` — REQUIRED before writing `.assembly/app.json`: declare which surfaces (dashboard, client portal, notification) the app exposes; omitting the client surface keeps the app off client portal nav entirely
37
37
  - `assembly-app-bridge-header` — breadcrumbs (don't include app name), CTAs
@@ -125,6 +125,10 @@ The `surfaces` field in `.assembly/app.json` declares **surface types** (dashboa
125
125
  ## What's already built in — DO NOT REINVENT
126
126
 
127
127
  Before adding a feature, check whether Assembly already covers it. If it does, **use the existing module via the SDK** instead of rebuilding.
128
+ <!-- feature:api-resources -->
129
+
130
+ **Then ask who should perform it — you or the platform.** Reaching a module through the SDK is right when the user is doing the thing *now*: they pressed a button and are watching it happen. When the effect should instead happen *in reaction to* something that happens in the app ("when a project is completed, message the client"), that is a workflow that integrates with the Assembly platform, and **the platform performs it, not your code.** The test is where the effect lands: on Assembly's own data rather than only in your app's tables. Decide this **before** you write or plan the call — read the `assembly-automations` skill to decide it, and don't reach for the module's SDK method until you have.
131
+ <!-- /feature:api-resources -->
128
132
 
129
133
  **Built-in modules (core, available in every portal):**
130
134
 
@@ -207,7 +211,10 @@ Two things flow into the running app from Assembly:
207
211
 
208
212
  ## Third-party APIs and secrets
209
213
 
210
- Any external service (SendGrid, Twilio, Resend, a niche SaaS, an API-key-only service) reaches its credential the same way: it **must** flow through `process.env` and be declared in the app manifest, per the rules below. Managed OAuth integrations are not available here — there is no provider whose credential the platform brokers for you.
214
+ Two paths, decided by whether an `assembly-integrations` skill is present and, if so, its supported-provider list:
215
+
216
+ - **Supported OAuth provider (e.g. Slack), when the `assembly-integrations` skill is present:** use `requiredIntegrations` + `getIntegration()` from `@assembly-js/app-foundation/server` — no credential, no env var, no `requiredSecrets` entry. The skill covers this; read it before deciding.
217
+ - **Anything else** (SendGrid, Twilio, Resend, a niche SaaS, an API-key-only service), or **any external service when the `assembly-integrations` skill is absent** (managed OAuth is not available for this workspace): the credential **must** flow through `process.env` and be declared in the app manifest, per the rules below.
211
218
 
212
219
  ### Two rules
213
220
 
@@ -697,11 +704,14 @@ Everything above resolves the **viewer's** bearer from the request cookie. Some
697
704
 
698
705
  ```ts
699
706
  // app/api/cron/digest/route.ts — Vercel cron or a webhook handler
700
- import { getOfflineAssembly } from '@assembly-js/app-foundation/server';
707
+ import { getOfflineAssembly, listAll } from '@assembly-js/app-foundation/server';
701
708
 
702
709
  export async function GET() {
703
710
  const assembly = await getOfflineAssembly(); // app-scoped, no cookie needed
704
- const { data: clients = [] } = await assembly.listClients();
711
+ // Background work must cover everyone — walk every page (see Pagination).
712
+ const clients = await listAll((nextToken) =>
713
+ assembly.listClients({ nextToken, limit: 100 }),
714
+ );
705
715
  // …do the background work (send a digest, reconcile state, etc.)…
706
716
  return Response.json({ ok: true });
707
717
  }
@@ -971,11 +981,11 @@ Keys in the `customFields` object are the field's `key` property (from `listCust
971
981
 
972
982
  The same rule applies to every native resource the app points at: a message channel, a form, a portal file or file channel. You MUST NOT hardcode one of their IDs in code. Those IDs are minted per workspace and are only valid in the workspace they came from — paste one into source and the feature works exactly once, in the workspace you built it in. Everywhere else the lookup returns nothing and the feature silently does nothing, or the call fails. No data fix can reach it, because the broken value is in the code.
973
983
 
974
- List, then match on a stable attribute the workspace owner controls — or resolve the resource from the current request's identity:
984
+ List — paging through every page, or the match false-negatives once the workspace outgrows one page — then match on a stable attribute the workspace owner controls, or resolve the resource from the current request's identity:
975
985
 
976
986
  ```ts
977
987
  // A form, by the user's wording. The display name is on `fields`, not the form.
978
- const { data: forms = [] } = await assembly.listForms({});
988
+ const forms = await listAll((nextToken) => assembly.listForms({ nextToken, limit: 100 }));
979
989
  const intakeForm = forms.find((f) => f.fields?.name?.toLowerCase().includes('intake'));
980
990
  if (!intakeForm?.id) {
981
991
  return <div>The intake form hasn&apos;t been set up in this workspace yet.</div>;
@@ -1045,21 +1055,20 @@ if (!res.ok) {
1045
1055
 
1046
1056
  ### Pagination
1047
1057
 
1048
- List methods take `limit` (default ~20, max 100) and `nextToken` (cursor). The response is `{ data: [...], nextToken: string | null }`. Loop until `nextToken` is null:
1058
+ List methods take `limit` (default ~20, max 100) and `nextToken` (cursor). The response is `{ data, nextToken }`, and one call returns only the first page — a list rendered from it looks correct until the workspace has more than ~20 rows.
1059
+
1060
+ `@assembly-js/app-foundation/client` exports `listAll` for the whole walk — import it, don't rewrite it. It works for any `list*` method, since they all share that envelope:
1049
1061
 
1050
1062
  ```ts
1051
- async function listAllClients() {
1052
- const all = [];
1053
- let cursor: string | undefined;
1054
- do {
1055
- const page = await assembly.listClients({ limit: 100, nextToken: cursor });
1056
- all.push(...page.data);
1057
- cursor = page.nextToken ?? undefined;
1058
- } while (cursor);
1059
- return all;
1060
- }
1063
+ import { listAll } from '@assembly-js/app-foundation/client';
1064
+
1065
+ const clients = await listAll((nextToken) =>
1066
+ assembly.listClients({ limit: 100, nextToken }),
1067
+ );
1061
1068
  ```
1062
1069
 
1070
+ Pass `limit: 100` (the max) so the walk makes 5x fewer round trips than the ~20 default.
1071
+
1063
1072
  ---
1064
1073
 
1065
1074
  ## Client-side SDK: `@assembly-js/app-bridge`
@@ -1118,8 +1127,12 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1118
1127
  - ❌ Don't use `Select` or `Combobox` to pick any Assembly person or organization record (clients, internal users, team members, staff, companies). Always use `UserCompanySelector` — in forms, filters, dropdowns, "assign to" controls, everywhere.
1119
1128
  - ❌ Don't use `Combobox` when the user asks for a "dropdown" or "select" — use `Select`. Only use `Combobox` when the user explicitly needs type-to-search/filter functionality for non-user/non-company data.
1120
1129
  - ❌ Don't manually render the selected value in a `Select` trigger. Use `<SelectValue placeholder="..." />` inside `SelectTrigger` **and** pass `items={options}` to the `<Select>` root — without `items`, Base UI cannot resolve labels and will display raw values (UUIDs) in the trigger. See "Select and Combobox — base-ui differences" for the full pattern.
1121
- - ❌ Don't ship a third-party API call without a `requiredSecrets` entry for its credential in `.assembly/app.json`. The deploy pipeline reads the manifest to know what to ask the user for — without it, the credential is never set and the app stays broken. See "Third-party APIs and secrets" above.
1130
+ - ❌ Don't ship a third-party API call without its manifest entry: `requiredIntegrations` for supported OAuth providers (when the `assembly-integrations` skill is present — see it), `requiredSecrets` for everything else. The deploy pipeline reads the manifest to know what to ask the user for — without it, the credential is never set and the app stays broken. See "Third-party APIs and secrets" above.
1122
1131
  - ❌ Don't read third-party credentials from anywhere other than `process.env` (no DB, no URL params, no client bundle, no hardcoded defaults, no `.env*` files committed to the repo).
1132
+ <!-- feature:api-resources -->
1133
+ - ❌ Don't create `.assembly/api-resources.json` unless **both** are true: the request is reactive (something happens *because* an in-app event happened, not because the user pressed a button), **and** the effect lands on the Assembly platform — a message, a client/company custom field, a contract, a form, a file. "When a time entry is logged, message the contact" qualifies even though the user never says "automation". A reaction that only writes the app's own tables does not, a platform write the user triggers directly does not, and a domain full of automatable mutations does not; an unrequested declaration surfaces actions to every workspace admin in the automation builder. When both *are* true, see the `assembly-automations` skill — declare and emit the trigger rather than hard-coding the platform-side action, and never put `apiResources` in `.assembly/app.json`; they are separate files with different owners.
1134
+ - ❌ Don't declare an `exposeToAutomations` operation without serving an authenticated route at that record's `baseUrl` — the platform calls it with a query-string app token that `requireAuth` cannot read, so hand-rolled auth here silently yields a public endpoint that mutates workspace data. Read the `assembly-automations` skill's "Serving actions" section and copy its wrapper.
1135
+ <!-- /feature:api-resources -->
1123
1136
 
1124
1137
  ---
1125
1138
 
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when a page route is orphaned — no link, router.push,
3
+ // redirect, or header-CTA target anywhere else in the app points at it.
4
+ // Next.js happily builds and typechecks a page nothing links to, so an
5
+ // unreachable page ships silently; when that page manages a prerequisite
6
+ // resource (e.g. stages that cases require), the whole primary flow dies
7
+ // with it. Dependency free on purpose: it runs inside the generated app's
8
+ // sandbox before any deps beyond Node are guaranteed. Known limit: matching
9
+ // is statically evident string references — fully dynamic path construction
10
+ // is out of scope (the gate guards against pages the agent forgot to link,
11
+ // not obfuscation).
12
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
13
+ import { join, relative, sep } from 'node:path';
14
+
15
+ const ROOT = process.cwd();
16
+ const APP_DIR = join(ROOT, 'app');
17
+ const MANIFEST = join(ROOT, '.assembly', 'app.json');
18
+
19
+ // Surface entry routes are loaded directly by the host portal, so they need
20
+ // no in-app inbound link. Mirrors .assembly/check-token-usage.mjs: only
21
+ // declared surfaces qualify; '/' alone when no manifest declares any.
22
+ function allowedEntryRoutes() {
23
+ const defaults = ['/'];
24
+ try {
25
+ const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
26
+ const declared = ['internal', 'client', 'notification']
27
+ .map((k) => manifest?.surfaces?.[k]?.path)
28
+ .filter((p) => typeof p === 'string' && p.startsWith('/'))
29
+ .map((p) => (p === '/' ? p : p.replace(/\/+$/, '')));
30
+ if (declared.length > 0) return new Set(declared);
31
+ } catch {
32
+ // fall through to defaults
33
+ }
34
+ return new Set(defaults);
35
+ }
36
+
37
+ function sourceFiles(dir) {
38
+ const out = [];
39
+ if (!existsSync(dir)) return out;
40
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
41
+ const full = join(dir, entry.name);
42
+ if (entry.isDirectory()) {
43
+ if (entry.name === 'node_modules' || entry.name === '.next') continue;
44
+ out.push(...sourceFiles(full));
45
+ } else if (/\.(tsx|ts|jsx|js|mjs)$/.test(entry.name)) {
46
+ out.push(full);
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // The URL route a page file serves; route groups `(group)` and parallel-route
53
+ // slots `@slot` never appear in the URL.
54
+ function routeForPageFile(file) {
55
+ const segments = relative(APP_DIR, file).split(sep).slice(0, -1);
56
+ const urlSegments = segments.filter(
57
+ (seg) => !(seg.startsWith('(') && seg.endsWith(')')) && !seg.startsWith('@'),
58
+ );
59
+ return '/' + urlSegments.join('/');
60
+ }
61
+
62
+ if (!existsSync(APP_DIR)) process.exit(0);
63
+
64
+ const allowed = allowedEntryRoutes();
65
+ const files = [
66
+ ...sourceFiles(APP_DIR),
67
+ ...sourceFiles(join(ROOT, 'components')),
68
+ ...sourceFiles(join(ROOT, 'lib')),
69
+ ];
70
+ const pageFiles = files.filter((f) =>
71
+ /^page\.(tsx|ts|jsx|js)$/.test(f.split(sep).pop()),
72
+ );
73
+ const contents = new Map(files.map((f) => [f, readFileSync(f, 'utf8')]));
74
+
75
+ function escapeRe(s) {
76
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
77
+ }
78
+
79
+ // A reference is the route appearing as a string/template path in any file
80
+ // other than the page's own: href="/setup", router.push('/setup'),
81
+ // redirect(`/setup?tab=1`), an actions-menu handler, etc. Dynamic routes
82
+ // match on the static prefix before the first dynamic segment, so a list
83
+ // page's `/cases/${id}` satisfies /cases/[id] (and its descendants — a
84
+ // detail page is the click-path to its own sub-routes).
85
+ function referencePattern(route) {
86
+ const dynIdx = route.indexOf('[');
87
+ if (dynIdx === -1) {
88
+ // Full path, terminated so /setup doesn't match /setup2 or /foo/setup.
89
+ return new RegExp(`['"\`]${escapeRe(route)}(?:['"\`?#]|\\$\\{)`);
90
+ }
91
+ const prefix = route.slice(0, dynIdx); // includes trailing '/'
92
+ if (prefix === '/') return null; // root-level dynamic route: not statically checkable
93
+ return new RegExp(`['"\`]${escapeRe(prefix)}`);
94
+ }
95
+
96
+ const violations = [];
97
+ for (const file of pageFiles) {
98
+ const route = routeForPageFile(file);
99
+ if (allowed.has(route)) continue;
100
+ const pattern = referencePattern(route);
101
+ if (!pattern) continue;
102
+ const referenced = files.some(
103
+ (other) => other !== file && pattern.test(contents.get(other)),
104
+ );
105
+ if (!referenced) {
106
+ violations.push(`${relative(ROOT, file).split(sep).join('/')} (route ${route})`);
107
+ }
108
+ }
109
+
110
+ if (violations.length > 0) {
111
+ console.error('');
112
+ console.error('Unreachable page routes — nothing in the app links to them:');
113
+ console.error('');
114
+ for (const v of violations) console.error(' - ' + v);
115
+ console.error('');
116
+ console.error(
117
+ 'Every page needs a click path from a surface entry route (' +
118
+ [...allowed].join(', ') +
119
+ '):',
120
+ );
121
+ console.error('a <Link>, router.push, redirect, header primaryCta/secondaryCta, or');
122
+ console.error('actions-menu item on an already-reachable page must point at it.');
123
+ console.error('');
124
+ console.error('If the page manages a prerequisite resource (e.g. stages that cases');
125
+ console.error('require, templates that documents require), add its entry point next to');
126
+ console.error("the dependent flow — e.g. an actions-menu item on the main view and a");
127
+ console.error('link on the empty state that blocks on it.');
128
+ process.exit(1);
129
+ }
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build if any page reads the session token from `searchParams`.
3
+ // Dependency-free on purpose: it runs inside the generated app's sandbox before
4
+ // any deps beyond Node exist.
5
+ //
6
+ // Auth is cookie-borne in the bearer model: the foundation middleware exchanges
7
+ // the first-load `?token=` for an HttpOnly bearer cookie exactly once, and
8
+ // getAssembly() / getSessionIdentity() read that cookie. No page needs
9
+ // `searchParams.token` — not even a surface entry page. A page gated on it
10
+ // renders its auth-rejection state to everyone after any client-side navigation
11
+ // (`router.push`, `<Link>`), which drops query params. Fetch per-user data via
12
+ // server actions (useServerAction) or authFetch route handlers instead.
13
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
14
+ import { join, relative, sep } from 'node:path';
15
+
16
+ const ROOT = process.cwd();
17
+ const APP_DIR = join(ROOT, 'app');
18
+
19
+ function pageFiles(dir) {
20
+ const out = [];
21
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
22
+ const full = join(dir, entry.name);
23
+ if (entry.isDirectory()) {
24
+ if (entry.name === 'node_modules') continue;
25
+ out.push(...pageFiles(full));
26
+ } else if (/^page\.(tsx|ts|jsx|js)$/.test(entry.name)) {
27
+ out.push(full);
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+
33
+ // A 'use client' page never has the dangerous dependency: it holds no token at
34
+ // all (identity comes from useAssembly()), and useSearchParams-for-filters is a
35
+ // different mechanism than the server `searchParams` prop this targets. The
36
+ // failure class is server components reading the URL token, so client files are
37
+ // skipped. The directive must be the file's first statement; allow leading
38
+ // comments/whitespace.
39
+ function isClientComponent(content) {
40
+ const head = content
41
+ .replace(/^\uFEFF/, '')
42
+ .replace(/^(?:\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/))*\s*/, '');
43
+ return /^['"]use client['"]/.test(head);
44
+ }
45
+
46
+ // True when the file reads a `token` property off the route's searchParams —
47
+ // directly, via destructuring, bracket access, optional chaining, or through
48
+ // any chain of local aliases (const sp = searchParams; const p = await sp;
49
+ // p['token']). Alias tracking is a fixpoint over simple assignments, so
50
+ // indirection depth doesn't matter. Pattern-based on purpose (no AST dependency
51
+ // inside the sandbox); these are the shapes generated server components actually
52
+ // produce. Known limit: statically evident reads only — dynamic key construction
53
+ // or cross-file data flow is out of scope (this guards code an agent naturally
54
+ // writes, not obfuscation).
55
+ function readsTokenFromSearchParams(content) {
56
+ if (!content.includes('searchParams')) return false;
57
+ // Every local name that transitively aliases searchParams, awaited or not.
58
+ // Seed with destructuring renames of the prop itself: ({ searchParams: sp }).
59
+ const names = new Set(['searchParams']);
60
+ for (const m of content.matchAll(/\bsearchParams\s*:\s*(\w+)/g)) {
61
+ names.add(m[1]);
62
+ }
63
+ // Optional TS type annotation between the name and '=' (const p: SP = ...).
64
+ const aliasRe = /(?:const|let|var)\s+(\w+)(?:\s*:\s*[^=;]+)?\s*=\s*(?:await\s+)?(?:props\s*\.\s*)?(\w+)\b/g;
65
+ let grew = true;
66
+ while (grew) {
67
+ grew = false;
68
+ for (const m of content.matchAll(aliasRe)) {
69
+ if (names.has(m[2]) && !names.has(m[1])) {
70
+ names.add(m[1]);
71
+ grew = true;
72
+ }
73
+ }
74
+ }
75
+ // Bracket keys: the 'token' literal plus any const bound to it.
76
+ const tokenKeys = ["'token'", '"token"'];
77
+ for (const m of content.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*['"]token['"]/g)) {
78
+ tokenKeys.push(m[1]);
79
+ }
80
+ const bracketKey = `(?:${tokenKeys
81
+ .map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
82
+ .join('|')})`;
83
+ for (const rawName of names) {
84
+ const name = rawName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
85
+ // name.token / name?.token / name['token'] / (await name).token variants
86
+ if (
87
+ new RegExp(
88
+ `\\b${name}\\s*\\)?\\s*(?:!\\s*)?(?:(?:\\?\\.|\\.)\\s*token\\b|(?:\\?\\.)?\\s*\\[\\s*${bracketKey}\\s*\\])`,
89
+ ).test(content)
90
+ ) {
91
+ return true;
92
+ }
93
+ // const { token } = name / = await name
94
+ if (
95
+ new RegExp(
96
+ `\\{[^}]*\\btoken\\b[^}]*\\}\\s*=\\s*(?:await\\s+)?(?:props\\s*\\.\\s*)?${name}\\b\\s*!?`,
97
+ ).test(content)
98
+ ) {
99
+ return true;
100
+ }
101
+ }
102
+ return false;
103
+ }
104
+
105
+ if (!existsSync(APP_DIR)) process.exit(0);
106
+
107
+ const violations = [];
108
+ for (const file of pageFiles(APP_DIR)) {
109
+ const content = readFileSync(file, 'utf8');
110
+ if (isClientComponent(content)) continue;
111
+ if (readsTokenFromSearchParams(content)) {
112
+ violations.push(relative(ROOT, file).split(sep).join('/'));
113
+ }
114
+ }
115
+
116
+ if (violations.length > 0) {
117
+ console.error('');
118
+ console.error('A page reads the session token from searchParams:');
119
+ console.error('');
120
+ for (const v of violations) console.error(' - ' + v);
121
+ console.error('');
122
+ console.error('Auth is in the bearer cookie, not the URL — no page, not even a surface');
123
+ console.error('entry page, should read searchParams.token. getAssembly() and');
124
+ console.error('getSessionIdentity() read the bearer cookie; fetch per-user data via');
125
+ console.error('server actions (useServerAction) or authFetch route handlers.');
126
+ console.error('See CLAUDE.md and the assembly-session-token skill.');
127
+ process.exit(1);
128
+ }
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ // Pushes the Drizzle schema to the provisioned Neon database at build time,
3
+ // then smoke-checks that the expected tables were created. Exits 0 silently
4
+ // when DATABASE_URL is absent (snapshot builds run without a real database).
5
+ import { execFileSync } from "node:child_process";
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ const DATABASE_URL = process.env.DATABASE_URL?.trim();
10
+ if (!DATABASE_URL) {
11
+ process.exit(0);
12
+ }
13
+
14
+ const schemaPath = join(process.cwd(), "lib", "schema.ts");
15
+ let schemaSource = "";
16
+ try {
17
+ schemaSource = readFileSync(schemaPath, "utf8");
18
+ } catch {
19
+ // schema.ts missing: drizzle-kit will report its own error below.
20
+ }
21
+
22
+ // drizzle-kit is a dev dependency; its binary lives in node_modules/.bin.
23
+ const drizzleKit = join(process.cwd(), "node_modules", ".bin", "drizzle-kit");
24
+ try {
25
+ execFileSync(drizzleKit, ["push", "--force"], {
26
+ stdio: "inherit",
27
+ env: process.env,
28
+ });
29
+ } catch (err) {
30
+ if (err.code === "ENOENT") {
31
+ console.error(
32
+ "\ndrizzle-kit binary not found — run `bun install` before building.",
33
+ );
34
+ } else {
35
+ console.error(
36
+ "\ndrizzle-kit push failed — database schema was not applied.",
37
+ );
38
+ }
39
+ process.exit(1);
40
+ }
41
+
42
+ // Smoke-check: verify each pgTable() declared in lib/schema.ts exists in the
43
+ // database. Checking specific names avoids a false-pass when pre-existing tables
44
+ // from a prior deployment make the total count non-zero.
45
+ const tableNames = [
46
+ ...schemaSource.matchAll(/pgTable\s*\(\s*['"]([^'"]+)['"]/g),
47
+ ].map((m) => m[1]);
48
+ if (tableNames.length === 0) {
49
+ process.exit(0);
50
+ }
51
+
52
+ const { neon } = await import("@neondatabase/serverless");
53
+ const sql = neon(DATABASE_URL);
54
+ const rows = await sql`
55
+ SELECT table_name
56
+ FROM information_schema.tables
57
+ WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
58
+ `;
59
+ const found = new Set(rows.map((r) => r.table_name));
60
+ const missing = tableNames.filter((t) => !found.has(t));
61
+ if (missing.length > 0) {
62
+ console.error(
63
+ `\nSchema push reported success but the following table(s) are missing: ${missing.join(", ")}.`,
64
+ "\nVerify that DATABASE_URL points to the correct Neon project and that lib/schema.ts exports valid pgTable() definitions.",
65
+ );
66
+ process.exit(1);
67
+ }
68
+ console.log(
69
+ `Schema verified: ${tableNames.length} table(s) confirmed (${tableNames.join(", ")}).`,
70
+ );