Skip to content

Commit 47a7854

Browse files
vivek7405t
andauthored
feat: collapse first-pass scaffold friction (check chain, agent-docs, cn split, prose) (#825)
* chore: start scaffold example-gallery work (#824) * fix(ui): split registry lib/utils.ts so importing cn() does not pin a page (#819) The registry util mixed the pure cn()/domId/ensureId with HTMLElement-era helpers (Base, defineElement, an SSR stub) that the WebComponent migration left dead, plus onBeforeCache (which references document). Any client global in the module marks it client-effecting, so importing cn() into a page pinned it to the browser and then a co-imported .server.ts tripped no-server-import-in-browser-module. Delete the dead HTMLElement helpers and move onBeforeCache to a new lib/dom.ts, leaving utils.ts pure. The CLI copies dom.ts to lib/utils/dom.ts and rewrites the component import alias. Verified: a page importing cn + a .server.ts no longer trips the check. * fix(server): print the full import chain in no-server-import message (#804) The message truncated the chain to `… ->`, so an agent could not tell whether the broken edge was in the page itself (carrier problem) or a forgotten types module. BFS the module graph to print every hop, and when the edge enters via a types-shaped module name the typedef-relocation fix. Collapses the first-pass iteration loop the agent hit building the todo app. * docs: add orm, types-and-mutations, component-shadowing agent-docs (#804) Codify the shapes that caused the first-pass iteration loops building the todo app: the Drizzle rc.3 query surface (db.query reads, the dropped select({...}) projection overload, no-arg .returning()); where server-derived types live so a shipping module does not pin db/*.server.ts (import type vs value import, the carrier rule); and the inherited WebComponent members app code must not shadow (title, remove, ...) with the exact TS error each produces. * feat(cli): pre-edit hook warns on server-only import before the file lands (#804) The scaffolded check-server-imports hook (PreToolUse Edit/Write) statically peeks the proposed content: if a browser-facing app module adds an import of a .server.{ts,js} utility with no 'use server' directive, it warns (the import would throw at load in the browser). A 'use server' action and an import type are ignored. WARN by default (webjs check is the authoritative gate); WEBJS_SERVER_IMPORT_GATE=block hard-blocks. Moves the #804 iteration loop from post-check to pre-edit. * test(ui): import onBeforeCache from lib/dom.ts after the #819 split * docs: scaffold guidance for .server distinction, optimistic UI, a11y, prune Add four topics to all five per-agent rule files in lockstep: the .server.ts vs 'use server' decision heuristic (#820); optimistic UI by default with the when-not-to guidance (#817); accessible control labeling via <label for> on both JS and no-JS paths; and keep-what-you-use pruning (#818), scoped as a no-op for the api template, with the durable knowledge layer always kept. * fix(cli): ship the check-server-imports hook in scaffolded apps (#804) * test(ui): assert Base/defineElement removed instead of present (#819) * fix(ui): mirror lib/dom.ts in the ui-website registry copy (#819) The website's copy-registry.js mirrored only lib/utils.ts, so after the #819 split the components importing onBeforeCache from ../lib/dom.ts resolved to a nonexistent path and 500'd SSR on the deployed registry host. Copy dom.ts and rewrite its import too. * test: scaffolded cn.ts is pure, onBeforeCache in dom.ts (#819) * fix(ui): teach webjs ui add/init the lib/dom.ts split (#819) The #819 split added lib/dom.ts (onBeforeCache), but the external webjs ui add/init path only handled lib/utils.ts, so adding a dom-importing component (dialog, sonner, ...) emitted a broken ../lib/dom.ts import. Add a lib-dom registry item + declare it on the 6 overlay components, generalize the add rewrite to both utils.ts and dom.ts (and drop the no-utils early return so a dom-only component like sonner is rewritten), and have init write dom.ts too. --------- Co-authored-by: t <t@t>
1 parent a579b14 commit 47a7854

34 files changed

Lines changed: 1289 additions & 170 deletions

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ reference there.
2222
| `agent-docs/testing.md` | Unit, browser, convention validation, the `handle()` test harness (`@webjsdev/server/testing`) |
2323
| `agent-docs/framework-dev.md` | Monorepo dev (only when editing webjs itself): commands, repo-health git config, changelog flow, dev error overlay |
2424
| `agent-docs/recipes.md` | Page / route / action / component recipes |
25+
| `agent-docs/orm.md` | Drizzle rc.3 query surface: `db.query` reads, the removed `db.select({...})` projection overload, no-arg `.returning()` (#804) |
26+
| `agent-docs/types-and-mutations.md` | Where server-derived types live so a shipping module does not pin `db/*.server.ts` (`import type` vs value import, the carrier rule) (#804) |
27+
| `agent-docs/components-shadowing.md` | Inherited `WebComponent` members app code must not shadow (`title`, `remove`, ...) and the `TS2415`/`TS2416` each produces (#804) |
2528
| `agent-docs/lit-muscle-memory-gotchas.md` | **READ FIRST** when writing components. Lit patterns that break webjs SSR or reactivity, with the webjs-shaped fix for each |
2629

2730
---

agent-docs/components-shadowing.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Component member shadowing: names a WebComponent must not reuse
2+
3+
A `WebComponent` inherits two layers of members. In the browser it extends
4+
`HTMLElement` (so every DOM property and method is present), and it extends the
5+
framework base that owns the reactivity lifecycle (`render`, `update`,
6+
`requestUpdate`, and the lit-aligned hooks). SSR swaps `HTMLElement` for an
7+
`ElementShim`-style stand-in with the same public surface. When app code
8+
declares a reactive property (via the `WebComponent({ ... })` factory) or a
9+
method whose NAME collides with one of those inherited members, one of two
10+
things happens.
11+
12+
- **Type-incompatible collision.** TypeScript refuses the class. A reactive
13+
property that shadows a native property of a different type raises **`TS2415`**
14+
on the `class X extends WebComponent({ ... })` line ("Class 'X' incorrectly
15+
extends base class ...; types of property 'y' are incompatible"). A method or
16+
field in the class body whose signature differs from the inherited one raises
17+
**`TS2416`** on that member ("Property 'y' in type 'X' is not assignable to the
18+
same property in base type").
19+
- **Type-compatible collision.** It compiles, then silently hijacks the native
20+
member at runtime (a `title` reactive prop overrides the element's tooltip /
21+
`title`-attribute reflection, a `hidden` prop overrides native hide behavior).
22+
No error, wrong behavior.
23+
24+
The fix for every row below is the same. **Rename the field or method** to a
25+
name that is not an inherited member (`postTitle` instead of `title`,
26+
`removeItem` instead of `remove`). Only override an inherited lifecycle method
27+
deliberately and with its exact signature (override `render` / `connectedCallback`
28+
on purpose, never repurpose the name for unrelated app logic).
29+
30+
## Members not to shadow
31+
32+
| Member | Origin | Base type or signature | Shadow with an app prop / method | Error | Fix (rename to) |
33+
|---|---|---|---|---|---|
34+
| `title` | HTMLElement | `string` | `title: prop<Post>(Object)` (a post object) | `TS2415` | `post`, `heading` |
35+
| `id` | HTMLElement | `string` | `id: prop(Number)` (a numeric row id) | `TS2415` | `postId`, `rowId` |
36+
| `slot` | HTMLElement | `string` | `slot: prop<Slot>(Object)` | `TS2415` | `bookingSlot` |
37+
| `role` | HTMLElement | `string \| null` | `role: prop<Role>(Object)` (a user role) | `TS2415` | `userRole` |
38+
| `hidden` | HTMLElement | `boolean` | `hidden: prop(String)` | `TS2415` | `isHidden` (or keep `boolean`) |
39+
| `dir` | HTMLElement | `string` | `dir: prop<Direction>(Object)` | `TS2415` | `direction` |
40+
| `lang` | HTMLElement | `string` | `lang: prop<Lang>(Object)` | `TS2415` | `language` |
41+
| `translate` | HTMLElement | `boolean` | `translate: prop(String)` | `TS2415` | `translationKey` |
42+
| `draggable` | HTMLElement | `boolean` | `draggable: prop(String)` | `TS2415` | `isDraggable` |
43+
| `tabIndex` | HTMLElement | `number` | `tabIndex: prop(String)` | `TS2415` | `tabOrder` |
44+
| `className` | HTMLElement | `string` | `className: prop<string[]>(Array)` | `TS2415` | `variantClasses` |
45+
| `dataset` | HTMLElement | `DOMStringMap` | `dataset: prop<Data>(Object)` | `TS2415` | `payload`, `meta` |
46+
| `remove` | Element | `(): void` | `remove(id: number): Promise<void>` | `TS2416` | `removeItem`, `deleteRow` |
47+
| `closest` | Element | `(sel: string) => Element \| null` | `closest(n: number)` | `TS2416` | `nearest` |
48+
| `matches` | Element | `(sel: string) => boolean` | `matches(other: T)` | `TS2416` | `isMatch` |
49+
| `focus` | HTMLElement | `(opts?) => void` | `focus(field: string)` | `TS2416` | `focusField` |
50+
| `blur` | HTMLElement | `(): void` | `blur(amount: number)` | `TS2416` | `applyBlur` |
51+
| `click` | HTMLElement | `(): void` | `click(e: Event)` | `TS2416` | `handleClick` |
52+
| `append` / `prepend` | Element | `(...nodes) => void` | `append(item: T)` | `TS2416` | `appendItem` |
53+
| `before` / `after` | Element | `(...nodes) => void` | `after(cb: () => void)` | `TS2416` | `runAfter` |
54+
| `render` | WebComponent | `() => TemplateResult \| Promise<...>` | `render(data: T)` (arg added) | `TS2416` | override with the real signature |
55+
| `update` | WebComponent | `(changed: Map) => void` | `update(input: T)` | `TS2416` | `applyUpdate`, `save` |
56+
| `requestUpdate` | WebComponent | `(name?, old?) => void` | `requestUpdate(payload: T)` | `TS2416` | `queueUpdate` |
57+
| `updated` / `firstUpdated` | WebComponent | `(changed: Map) => void` | `updated(row: T)` | `TS2416` | `onUpdated` |
58+
| `willUpdate` / `shouldUpdate` | WebComponent | `(changed: Map) => boolean \| void` | repurposed for app logic | `TS2416` | rename or override correctly |
59+
| `connectedCallback` | WebComponent | `(): void` | `connectedCallback(user: T)` | `TS2416` | override with no args |
60+
| `renderError` / `renderFallback` | WebComponent | `(err?) => TemplateResult` | `renderFallback(id: number)` | `TS2416` | override with the real signature |
61+
| `addController` / `removeController` | WebComponent | `(c: Controller) => void` | app method of the same name | `TS2416` | rename |
62+
| `updateComplete` | WebComponent | `get(): Promise<boolean>` | `updateComplete: prop(Boolean)` | `TS2415` | `isComplete` |
63+
64+
Framework-private fields are all underscore-prefixed (`_renderRoot`,
65+
`_connected`, `_changedProperties`, `_updatePromise`, `_isUpdating`). Never
66+
declare a reactive prop or field with a leading underscore that matches one, and
67+
never write to them from app code. Use a plainly-named reactive prop or a signal
68+
instead.
69+
70+
## Names that are safe (not inherited)
71+
72+
Common component prop names that do NOT collide, so they need no rename.
73+
74+
`label`, `open`, `count`, `value` (declare it, native `HTMLElement` has none),
75+
`name`, `items`, `todos`, `active`, `variant`, `size`, `checked`, `selected`,
76+
`heading`, `caption`, `message`, `status`.
77+
78+
Note. `open`, `value`, `checked`, and `selected` ARE native properties on
79+
specific built-in elements (`<details>`, `<input>`, `<option>`), but a
80+
`WebComponent` extends the generic `HTMLElement`, which does not define them, so
81+
they are free on a custom element. When in doubt, grep the base surface in
82+
`packages/core/src/component.js` (or `node_modules/@webjsdev/core/src/component.js`
83+
in an app) rather than guessing.
84+
85+
Cross-references. Reactive property declaration is in the root `AGENTS.md`
86+
(`WebComponent` essentials) and `agent-docs/components.md`. The lit patterns that
87+
break webjs reactivity are in `agent-docs/lit-muscle-memory-gotchas.md`.

agent-docs/orm.md

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# ORM: the Drizzle query surface (rc.3)
2+
3+
The scaffold pins **`drizzle-orm@^1.0.0-rc.3`** (`db/columns.server.ts` header
4+
records this, research #562). rc.3 is the 1.0 release candidate with the new
5+
Relational Query Builder v2 (RQBv2) wired up in `db/connection.server.ts` via
6+
`drizzle({ client, relations })`. Several method overloads that older Drizzle
7+
tutorials rely on do NOT exist in rc.3, so training-data patterns from
8+
Drizzle 0.29 through 0.36 trip real TypeScript errors here. This page codifies
9+
the shapes that compile against the installed version.
10+
11+
Ground rules for this version.
12+
13+
1. **Reads go through the relational query API** (`db.query.<table>.findFirst`
14+
/ `.findMany`), not `db.select().from()`. Relations are pre-registered in
15+
`db/connection.server.ts`, so `with` joins work with zero boilerplate.
16+
2. **`db.select()` takes no argument in rc.3.** The projection overload
17+
(`db.select({ ... })`) was removed. Passing a projection object is a
18+
`TS2554` compile error.
19+
3. **`.returning()` takes no argument in rc.3.** It always returns the full
20+
inserted / updated / deleted rows (columns only, no relations). Passing a
21+
field object is a `TS2554` compile error.
22+
23+
All examples assume the scaffold imports.
24+
25+
```ts
26+
import { eq, and, inArray, desc } from 'drizzle-orm';
27+
import { db } from '#db/connection.server.ts';
28+
import { posts, users } from '#db/schema.server.ts';
29+
```
30+
31+
These belong in a `*.server.ts` query or action (never a page or component,
32+
invariant 1). Reads live in `modules/<feature>/queries/*.server.ts`, mutations
33+
in `modules/<feature>/actions/*.server.ts`, one function per file.
34+
35+
---
36+
37+
## Reads: use the relational query API
38+
39+
`db.query.<table>` reads a whole row by default, filters with a **plain object**
40+
`where` (the RQBv2 shape, not a `sql` expression), orders with an `orderBy`
41+
object, and pulls relations with `with`. This is the shape every scaffold query
42+
uses.
43+
44+
```ts
45+
// RIGHT: single row by a unique column
46+
const post = await db.query.posts.findFirst({
47+
where: { slug: input.slug },
48+
with: { author: { columns: { name: true, email: true } } },
49+
});
50+
51+
// RIGHT: a list, newest first, with a projected column set
52+
const rows = await db.query.posts.findMany({
53+
orderBy: { createdAt: 'desc' },
54+
where: { authorId: me.id },
55+
columns: { id: true, slug: true, title: true },
56+
with: { author: { columns: { name: true } } },
57+
});
58+
59+
// RIGHT: existence check, pull only the id
60+
const exists = await db.query.posts.findFirst({
61+
where: { slug },
62+
columns: { id: true },
63+
});
64+
```
65+
66+
The `where` object supports equality directly (`{ slug }`), nested operators
67+
(`{ createdAt: { gt: cutoff } }`), and `AND` by listing multiple keys. Reach for
68+
the imported `eq` / `and` / `inArray` operators only on the `db.select` /
69+
`db.delete` / `db.update` builders below, where the `.where()` clause is an SQL
70+
expression rather than the RQBv2 object.
71+
72+
### Pitfall: the `db.select({ ... })` projection overload is gone
73+
74+
Older Drizzle let you project columns by passing an object to `select`. rc.3
75+
removed that overload, so the argument no longer type-checks.
76+
77+
```ts
78+
// WRONG (rc.3): TS2554 "Expected 0 arguments, but got 1"
79+
const rows = await db
80+
.select({ id: posts.id, title: posts.title })
81+
.from(posts);
82+
```
83+
84+
If you genuinely need the query-builder (a manual join, a `groupBy`, an
85+
aggregate the RQB does not express), call `select` with **no arguments** to get
86+
the full row, then narrow in JS.
87+
88+
```ts
89+
// RIGHT: full-row select, no projection argument
90+
const rows = await db.select().from(posts).where(eq(posts.authorId, me.id));
91+
```
92+
93+
For anything the relational API covers (by-id lookups, ordered lists, relation
94+
joins, column subsets), prefer `db.query.*`. It is the shorter, typed,
95+
relation-aware path and it sidesteps the removed overload entirely.
96+
97+
---
98+
99+
## Mutations: `insert` / `update` / `delete` with a no-arg `.returning()`
100+
101+
Mutations use the query-builder, filter with the imported SQL operators, and
102+
read back rows with `.returning()`. In rc.3 `.returning()` has **only the
103+
no-argument overload**, so it yields the full row set every time.
104+
105+
```ts
106+
// RIGHT: insert, read back the created row
107+
const [row] = await db
108+
.insert(posts)
109+
.values({ title, body, slug, authorId: me.id })
110+
.returning();
111+
112+
// RIGHT: update by id, read back the updated row
113+
const [row] = await db
114+
.update(posts)
115+
.set({ title, body })
116+
.where(eq(posts.id, id))
117+
.returning();
118+
119+
// RIGHT: delete by id (no read-back needed)
120+
await db.delete(posts).where(eq(posts.id, id));
121+
```
122+
123+
### Pitfall: `.returning({ ... })` with a field argument
124+
125+
The older field-selecting `.returning({ id: posts.id })` overload does not exist
126+
in rc.3.
127+
128+
```ts
129+
// WRONG (rc.3): TS2554 "Expected 0 arguments, but got 1"
130+
const [row] = await db
131+
.insert(posts)
132+
.values({ title, body, slug, authorId: me.id })
133+
.returning({ id: posts.id, slug: posts.slug });
134+
```
135+
136+
Call `.returning()` bare and destructure or map the fields you need in JS.
137+
138+
```ts
139+
// RIGHT: full row back, then pick fields
140+
const [row] = await db.insert(posts).values({ ... }).returning();
141+
const created = { id: row.id, slug: row.slug };
142+
```
143+
144+
### `.returning()` yields columns only, never relations
145+
146+
A `.returning()` row is the table's own columns. It does NOT carry `with`
147+
relations the way a `db.query.*` read does. When the caller expects a joined
148+
shape (an author name alongside the post), insert then re-read, or splice the
149+
already-known related value in by hand.
150+
151+
```ts
152+
// RIGHT: insert returns columns; add the known author locally
153+
const [row] = await db.insert(posts).values({ title, body, slug, authorId: me.id }).returning();
154+
return { success: true, data: { ...formatPost(row), authorName: me.name } };
155+
```
156+
157+
---
158+
159+
## Column helpers live in `db/columns.server.ts` (the one dialect seam)
160+
161+
The schema is written against helpers (`table`, `pk`, `uuidPk`, `uuid`, `bool`,
162+
`timestamp`, `createdAt`, `updatedAt`, `index`) rather than raw drizzle
163+
builders, so the same `db/schema.server.ts` runs on SQLite and Postgres. Only
164+
`db/columns.server.ts` differs per dialect. Two of those helpers exist because
165+
rc.3 has not yet exposed a stable no-arg overload.
166+
167+
- **`table`** wraps `sqliteTableCreator((name) => name, 'snake_case')`. In rc.3
168+
the snake_case casing lives on the table factory, so column keys map to
169+
snake_case SQL names automatically. Do not restate a `snake_case` casing
170+
option on `drizzle()`.
171+
- **`index(...cols)`** wraps rc.3's `index(name)`, which still requires a name
172+
argument the runtime auto-fills. The helper synthesizes drizzle-kit's own
173+
collision-free name, so schema authors call `index(t.createdAt)` with no name.
174+
Replace it with a bare `index()` once 1.0 stable ships the no-arg overload.
175+
176+
When you add a table, use the helpers (`pk()` for an integer autoincrement id,
177+
`uuidPk()` for an app-generated uuid string id, `createdAt()` for a
178+
default-to-now timestamp) rather than reaching for raw `integer(...).primaryKey(...)`
179+
call sites, so the Postgres swap stays a one-file change.
180+
181+
---
182+
183+
## Where each query shape belongs
184+
185+
| Task | API | File |
186+
|---|---|---|
187+
| Row by unique column | `db.query.t.findFirst({ where })` | `queries/*.server.ts` |
188+
| Ordered / filtered list | `db.query.t.findMany({ orderBy, where, with })` | `queries/*.server.ts` |
189+
| Manual join / aggregate | `db.select().from(t)` (no projection arg) | `queries/*.server.ts` |
190+
| Create | `db.insert(t).values(...).returning()` | `actions/*.server.ts` |
191+
| Update | `db.update(t).set(...).where(eq(...)).returning()` | `actions/*.server.ts` |
192+
| Delete | `db.delete(t).where(eq(...))` | `actions/*.server.ts` |
193+
194+
Cross-references. Derived row TYPES and the `ActionResult<T>` envelope belong in
195+
a browser-safe `modules/<feature>/types.ts` (see `agent-docs/types-and-mutations.md`).
196+
Full page / action / query recipes live in `agent-docs/recipes.md`. The
197+
server-only boundary that keeps `db` off the client is in the root `AGENTS.md`
198+
(invariant 1).

0 commit comments

Comments
 (0)