@assembly-js/app-foundation 0.13.0 → 0.14.0

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/CHANGELOG.md CHANGED
@@ -19,6 +19,10 @@ A patch release never changes what an app's build requires; a minor release may
19
19
  not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
20
  `bump: minor` change file.
21
21
 
22
+ ## 0.14.0
23
+ - [guidance] The build agent now counts related rows per record with `db.$count` or a joined subquery, instead of a raw correlated subselect that drizzle renders without table names and that reads 0 on every row.
24
+ - [behavior] Later edits to the app now catch a per-record count that would always show 0 (for example "0 answers" on every question) before the change is saved, and fix it in the same edit.
25
+
22
26
  ## 0.13.0
23
27
  - [new] `<ClientName client={client} />` and `<CompanyName company={company} />` from `@assembly-js/app-foundation/lib-client` render a name that opens the record's CRM page for staff and is plain text for a client viewer.
24
28
  - [new] `bun run build` runs `check:linked-names`, which fails when a client's or company's name is rendered by hand instead of through those components. It only runs on apps that carry the platform-links skill.
package/CLAUDE.md CHANGED
@@ -648,6 +648,14 @@ export const items = pgTable('items', {
648
648
  - Use `drizzle-kit push` (not `migrate`) — no migration files; schema is pushed directly.
649
649
  - Use `@neondatabase/serverless` with `drizzle-orm/neon-http` — not `pg` or `postgres.js`. It's the standard driver for the Vercel serverless (Node) runtime this app deploys to; `lib/db.ts` already wires it up.
650
650
  - **No interactive transactions.** `db.transaction(async (tx) => …)` is unsupported on neon-http and throws (a 500). For atomic multi-writes use `db.batch([...])`; otherwise sequential `await`s. See the `assembly-db-transactions` skill.
651
+ - **Per-row counts: `db.$count` or a joined subquery, never a raw correlated subselect.** In a `.select({ … })` field of a query with no join, drizzle writes the columns inside a `sql` template without table names, so `(select count(*) from ${answers} where ${answers.questionId} = ${questions.id})` compares answers with itself and every row counts 0, with no error. (`.where()` is unaffected.) Use one of:
652
+ ```ts
653
+ db.select({ id: questions.id, answerCount: db.$count(answers, and(eq(answers.questionId, questions.id), eq(answers.kind, 'answer'))) }).from(questions);
654
+
655
+ const ac = db.select({ questionId: answers.questionId, c: count().as('c') }).from(answers).where(eq(answers.kind, 'answer')).groupBy(answers.questionId).as('ac');
656
+ db.select({ id: questions.id, answerCount: sql<number>`coalesce(${ac.c}, 0)` }).from(questions).leftJoin(ac, eq(ac.questionId, questions.id));
657
+ ```
658
+ `bun run build` fails on the raw form (`check:sql-subselects`).
651
659
  - **Create writes every field the create form submits.** If a later save writes nested rows (packages, add-ons, blocks), create calls that same writer with the submitted payload. A success toast and a redirect to the editor do not save. See `assembly-record-writes`.
652
660
  - `DATABASE_URL` is already in the shell env. Do NOT hardcode it or write it to `.env.local`.
653
661
  - Use `db` from `lib/db.ts` in **server components, server actions, and route handlers only** — never in client components. This is enforced: `lib/db.ts` imports `server-only`, so pulling it (or anything that imports it, e.g. a lib file re-exporting constants) into a `'use client'` module fails `bun run build`. Keep client-shared constants/types in their own file, not next to server queries.
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when a raw `sql` template in a drizzle `.select({ … })` field
3
+ // list runs a correlated subselect against another table and the statement has
4
+ // no join. drizzle renders columns embedded in a select field as bare names when
5
+ // the query has a single table, so `where ${answers.questionId} = ${questions.id}`
6
+ // becomes `where "question_id" = "id"`: the inner table is compared with itself,
7
+ // the SQL stays valid, and a per-row count is 0 for every row. A field that names
8
+ // the template through a const in the same file is followed too.
9
+ // Dependency free on purpose: it runs inside the generated app's sandbox before
10
+ // any deps beyond Node are guaranteed.
11
+ //
12
+ // On a platform update turn (ASSEMBLY_UPDATE_TURN=1) it only warns, because an
13
+ // update never rewrites an app's queries.
14
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
15
+ import { join, relative, sep } from 'node:path';
16
+
17
+ const ROOT = process.cwd();
18
+ const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
19
+ const SOURCE_DIRS = ['app', 'lib', 'components', 'src'];
20
+ const SOURCE_FILE = /\.(tsx|ts|jsx|js|mjs)$/;
21
+ const OPT_OUT = /sql-subselects:\s*qualified/;
22
+ const SELECT_OBJECT = /\.select\s*\(\s*\{/g;
23
+ const SQL_TEMPLATE = /\bsql\s*(?:<[^`;]*?>)?\s*`/g;
24
+ // `const answerCountSql = sql\`…\`` or `const answerCountSql = (…) => sql\`…\``.
25
+ const NAMED_TEMPLATE =
26
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=\s*(?:\([^)]*\)\s*(?::[^=;]+)?=>\s*)?sql\s*(?:<[^`;]*?>)?\s*`/g;
27
+ const DECLARATION = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g;
28
+ const FIELD_VALUE = /:\s*([A-Za-z_$][\w$]*)\b/g;
29
+ const SUBSELECT = /\(\s*select\b/i;
30
+ const FROM_TABLE = /\bfrom\s+\$\{\s*([A-Za-z_$][\w$]*)\s*\}/gi;
31
+ const COLUMN_REF = /\$\{\s*([A-Za-z_$][\w$]*)\.[A-Za-z_$]/g;
32
+ const JOIN = /\.(?:left|inner|right|full|cross)Join(?:Lateral)?\s*\(/;
33
+ // A line that starts a new statement; without semicolons the next query would
34
+ // otherwise be read as part of this one and its join would excuse this query.
35
+ const NEXT_STATEMENT = /^\n\s*(?:export|const|let|var|function|async|return|await|if|for|while|class|import)\b/;
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 (SOURCE_FILE.test(entry.name)) {
46
+ out.push(full);
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // Index just past a string, template literal or comment starting at `i`, or `i`
53
+ // when none starts there. Templates recurse through their `${…}` holes.
54
+ function skipLiteral(src, i) {
55
+ const c = src[i];
56
+ if (c === '/' && src[i + 1] === '/') {
57
+ const end = src.indexOf('\n', i);
58
+ return end === -1 ? src.length : end;
59
+ }
60
+ if (c === '/' && src[i + 1] === '*') {
61
+ const end = src.indexOf('*/', i + 2);
62
+ return end === -1 ? src.length : end + 2;
63
+ }
64
+ if (c === '"' || c === "'") {
65
+ let j = i + 1;
66
+ while (j < src.length && src[j] !== c && src[j] !== '\n') j += src[j] === '\\' ? 2 : 1;
67
+ return j + 1;
68
+ }
69
+ if (c === '`') return templateEnd(src, i);
70
+ return i;
71
+ }
72
+
73
+ // Index just past the template literal whose opening backtick is at `i`.
74
+ function templateEnd(src, i) {
75
+ let j = i + 1;
76
+ while (j < src.length) {
77
+ if (src[j] === '\\') j += 2;
78
+ else if (src[j] === '`') return j + 1;
79
+ else if (src[j] === '$' && src[j + 1] === '{') j = closingIndex(src, j + 1) + 1;
80
+ else j += 1;
81
+ }
82
+ return src.length;
83
+ }
84
+
85
+ // Index of the bracket that closes the one at `i`.
86
+ function closingIndex(src, i) {
87
+ let depth = 0;
88
+ let j = i;
89
+ while (j < src.length) {
90
+ const skipped = skipLiteral(src, j);
91
+ if (skipped !== j) {
92
+ j = skipped;
93
+ continue;
94
+ }
95
+ const c = src[j];
96
+ if (c === '(' || c === '[' || c === '{') depth += 1;
97
+ else if (c === ')' || c === ']' || c === '}') {
98
+ depth -= 1;
99
+ if (depth === 0) return j;
100
+ }
101
+ j += 1;
102
+ }
103
+ return src.length;
104
+ }
105
+
106
+ // The statement a `.select(` belongs to, from it to the next `;` at depth 0, a
107
+ // new statement on a later line, or the bracket that closes the expression around it.
108
+ function statementTail(src, i) {
109
+ let depth = 0;
110
+ let j = i;
111
+ while (j < src.length) {
112
+ const skipped = skipLiteral(src, j);
113
+ if (skipped !== j) {
114
+ j = skipped;
115
+ continue;
116
+ }
117
+ const c = src[j];
118
+ if (c === '(' || c === '[' || c === '{') depth += 1;
119
+ else if (c === ')' || c === ']' || c === '}') {
120
+ depth -= 1;
121
+ if (depth < 0) break;
122
+ } else if (depth === 0 && (c === ';' || (c === '\n' && NEXT_STATEMENT.test(src.slice(j, j + 40))))) break;
123
+ j += 1;
124
+ }
125
+ return src.slice(i, j);
126
+ }
127
+
128
+ // Bodies of every `sql` template in `text`, with their offset in it.
129
+ function templatesIn(text) {
130
+ const out = [];
131
+ for (const m of text.matchAll(SQL_TEMPLATE)) {
132
+ const start = m.index + m[0].length - 1;
133
+ out.push({ body: text.slice(start, templateEnd(text, start)), offset: m.index });
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function correlatesAnotherTable(body) {
139
+ if (!SUBSELECT.test(body)) return false;
140
+ const inner = new Set([...body.matchAll(FROM_TABLE)].map((m) => m[1]));
141
+ if (inner.size === 0) return false;
142
+ return [...body.matchAll(COLUMN_REF)].some((m) => !inner.has(m[1]));
143
+ }
144
+
145
+ // The source with comments and quoted strings blanked out (newlines and quotes
146
+ // kept, so offsets and line numbers still match), so a query kept in a comment
147
+ // or an example string is never checked. Template literals stay: `sql` is one.
148
+ function withoutComments(src) {
149
+ let out = '';
150
+ let i = 0;
151
+ while (i < src.length) {
152
+ const end = skipLiteral(src, i);
153
+ if (end === i) {
154
+ out += src[i];
155
+ i += 1;
156
+ continue;
157
+ }
158
+ const chunk = src.slice(i, end);
159
+ if (src[i] === '/') out += chunk.replace(/[^\n]/g, ' ');
160
+ else if (src[i] === '`') out += chunk;
161
+ else out += chunk[0] + chunk.slice(1, -1).replace(/[^\n]/g, ' ') + chunk.slice(-1);
162
+ i = end;
163
+ }
164
+ return out;
165
+ }
166
+
167
+ function lineAt(src, index) {
168
+ return src.slice(0, index).split('\n').length;
169
+ }
170
+
171
+ const files = SOURCE_DIRS.flatMap((dir) => sourceFiles(join(ROOT, dir)));
172
+ const violations = [];
173
+ for (const file of files) {
174
+ const raw = readFileSync(file, 'utf8');
175
+ if (!raw.includes('.select(') || OPT_OUT.test(raw)) continue;
176
+ const src = withoutComments(raw);
177
+
178
+ // A name declared more than once in the file cannot be tied to one template
179
+ // without scope analysis, so it is not followed rather than guessed.
180
+ const declared = new Map();
181
+ for (const m of src.matchAll(DECLARATION)) declared.set(m[1], (declared.get(m[1]) ?? 0) + 1);
182
+ const named = new Map();
183
+ for (const m of src.matchAll(NAMED_TEMPLATE)) {
184
+ if (declared.get(m[1]) > 1) continue;
185
+ const start = m.index + m[0].length - 1;
186
+ named.set(m[1], src.slice(start, templateEnd(src, start)));
187
+ }
188
+
189
+ for (const m of src.matchAll(SELECT_OBJECT)) {
190
+ const objStart = m.index + m[0].length - 1;
191
+ const fields = src.slice(objStart, closingIndex(src, objStart) + 1);
192
+ const hits = templatesIn(fields)
193
+ .filter((t) => correlatesAnotherTable(t.body))
194
+ .map((t) => objStart + t.offset);
195
+ for (const v of fields.matchAll(FIELD_VALUE)) {
196
+ const body = named.get(v[1]);
197
+ if (body && correlatesAnotherTable(body)) hits.push(objStart + v.index);
198
+ }
199
+ if (hits.length === 0 || JOIN.test(statementTail(src, m.index))) continue;
200
+ const rel = relative(ROOT, file).split(sep).join('/');
201
+ for (const at of hits) violations.push(`${rel}:${lineAt(src, at)}`);
202
+ }
203
+ }
204
+
205
+ if (violations.length > 0 && UPDATE_TURN) {
206
+ console.warn('');
207
+ console.warn('check-sql-subselects: correlated sql subselect in a single-table select (this update turn is not failed for it):');
208
+ for (const v of violations) console.warn(' - ' + v);
209
+ console.warn('');
210
+ } else if (violations.length > 0) {
211
+ console.error('');
212
+ console.error('A raw sql subselect in a .select({ … }) field reads another table, and the query has no join:');
213
+ console.error('');
214
+ for (const v of violations) console.error(' - ' + v);
215
+ console.error('');
216
+ console.error('drizzle writes the columns in that template without table names when the query has');
217
+ console.error('one table, so `${answers.questionId} = ${questions.id}` compares answers with itself');
218
+ console.error('and the count is 0 on every row. Use db.$count as the field instead:');
219
+ console.error(' answerCount: db.$count(answers, and(eq(answers.questionId, questions.id), eq(answers.kind, "answer")))');
220
+ console.error('or group the counts in a subquery (.groupBy(...).as("ac")) and .leftJoin it.');
221
+ console.error('A template whose names are already qualified (sql.raw) can add the comment');
222
+ console.error('`// sql-subselects: qualified` to the file.');
223
+ process.exit(1);
224
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [