Skip to content

Commit 0f152bf

Browse files
dmealingclaude
andcommitted
docs(agent-context): the names artifact hands you a string; placing it is per-driver
Found by converting a live adopter estate off ~200 literal physical names. The artifact half was uneventful. The half that cost the afternoon was getting a name into identifier position, and the page said nothing about it — one `sql.identifier` example, on one driver, and no statement that the question even varies. Three additions, ordered so a reader on a driver this page has never heard of still gets the rule: **`excluded.<column>` has no typed handle.** In an `ON CONFLICT … DO UPDATE SET` the `set` keys are field names the ORM maps, but the value on the right is raw SQL, and `excluded` is a pseudo-table bound to the row the INSERT proposed — no column object exists for it. That is the one place a physical name is unavoidable in otherwise fully-typed code, so it is where the constant earns the most. The estate had 23 of them. The EXISTING row is reachable, and the page now says so: Postgres exposes it under the table's own name inside ON CONFLICT, and a Drizzle column object renders as exactly that qualified name. **The per-driver section leads with the rule, not with a driver.** Find your driver's identifier form once, wrap it in a one-line helper, use the helper everywhere — and two properties decide whether a form is the right one: it escapes (the physical name is free-form, so concatenation is wrong), and it means the same thing in EVERY clause. The second is the one that bites, so the page says to test a helper across SELECT / FROM / WHERE / INSERT INTO / a column list / UPDATE SET / a transaction against a real engine before committing to it. **postgres.js is the worked example of that second property failing**, kept because the failure is silent until it is a syntax error: `sql(name)` is not an identifier — it dispatches on the SQL text BEFORE the interpolation, so `INSERT INTO ${sql(t)} (…)` hits the library's insert-builder, reads the value as a row object, and dies with `syntax error at or near "("`. `sql([name])` survives that one and is the same trapdoor a clause away. The inert form is a quoted identifier through `unsafe`, verified against a live engine in all seven positions above. Plus the derive-the-helpers-from-the-artifact shape, so a module that touches one table names the ENTITY rather than restating its own field list — a hand-written list of four columns is smaller than a spelled-out name but the same kind of thing, and it goes stale the same way when the entity gains a column. Deliberately NOT written as "here is the Drizzle recipe and here is the postgres.js recipe": that is how a page teaches an adopter on Kysely or `pg` or mysql2 that the guidance does not cover them. The named drivers are examples of a class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3v6M6uqECZ2Sb5eUv6YY
1 parent eb96780 commit 0f152bf

3 files changed

Lines changed: 210 additions & 0 deletions

File tree

  • agent-context/skills/metaobjects-runtime-ui/references
  • fixtures/agent-context-conformance
    • ts-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references
    • ts-requirements/expected/.claude/skills/metaobjects-runtime-ui/references

agent-context/skills/metaobjects-runtime-ui/references/typescript.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,76 @@ sql`SELECT ${sql.identifier(ProgramNames.fields.createdAt.column)}
8787
A literal is a second spelling of a fact the metadata owns: `@column` is free-form, and a
8888
rename in metadata moves the constant, not the string.
8989

90+
**`excluded.<column>` has no handle.** In an `ON CONFLICT … DO UPDATE SET`, Drizzle maps the
91+
`set` keys (they are FIELD names) but the value on the right is raw SQL, and `excluded` is a
92+
pseudo-table Postgres binds to the row the INSERT proposed — there is no column object for it.
93+
That is the one place a physical name is unavoidable in otherwise-typed Drizzle code, so it is
94+
where the constant earns the most:
95+
96+
```ts
97+
const excluded = (column: string) => sql`excluded.${sql.identifier(column)}`;
98+
//
99+
.onConflictDoUpdate({ target: programs.slug, set: {
100+
title: excluded(ProgramNames.fields.title.column),
101+
} })
102+
```
103+
104+
The existing row, by contrast, IS reachable: Postgres exposes it under the table's own name
105+
inside `ON CONFLICT`, and a Drizzle column object renders as exactly that qualified name — so
106+
`${programs.updatedBy}` is `"program"."updated_by"` with nothing spelled by hand.
107+
108+
### Getting the name INTO a query is a per-driver question
109+
110+
The artifact hands you a string. Every driver has its own way to place a string in identifier
111+
position, and they are not interchangeable — a value placeholder there is a syntax error, and a
112+
bare interpolation is an injection hole the moment the name stops being a constant. **Find your
113+
driver's identifier form once, wrap it in a one-line local helper, and use the helper
114+
everywhere**; that is the whole integration, and it is the same shape whether you are on Drizzle,
115+
Kysely, `postgres.js`, `node-postgres`, `mysql2`, or a query builder this page has never heard of.
116+
117+
Two properties decide whether a form is the right one:
118+
119+
- **It escapes.** A physical name is free-form (`@column` takes whatever you declare), so the
120+
helper must quote and escape rather than concatenate. Most drivers ship this; `pg` exposes it as
121+
`Client.prototype.escapeIdentifier`, `mysql2` as `escapeId`.
122+
- **It means the same thing in every clause.** This is the one that bites, because a form can work
123+
in `SELECT` and fail in `INSERT`. Test your helper in `SELECT`, `FROM`, `WHERE`, `INSERT INTO`,
124+
a column list, `UPDATE … SET` and inside a transaction before you commit to it — against a real
125+
engine, not a snapshot.
126+
127+
`postgres.js` is the worked example of the second property going wrong, and it is worth reading
128+
even if you are on another driver, because the failure is silent until it is a syntax error in
129+
production. Its `sql(name)` builder is **not** an identifier: it dispatches on the SQL text BEFORE
130+
the interpolation. In `INSERT INTO ${sql("audit_entry")} (…)` it matches the library's
131+
insert-builder, which reads the value as a row object and emits a column list, and the statement
132+
dies with `syntax error at or near "("`. `sql([name])` survives that one and is the same trapdoor
133+
a clause away. The form that is inert in every clause is a quoted identifier through `unsafe`:
134+
135+
```ts
136+
const ident = (name: string) => sql.unsafe(`"${name.replace(/"/g, '""')}"`);
137+
138+
await sql`SELECT ${ident(ProgramNames.fields.createdAt.column)}
139+
FROM ${ident(ProgramNames.sources.primary.table)}`;
140+
```
141+
142+
`unsafe` names the one thing the caller must guarantee — that the string is a name you authored,
143+
never user input — and a generated constant is exactly that. Built once on the pooled client, the
144+
fragment carries no per-query state, so it can be shared across a module and composed inside a
145+
`sql.begin()` transaction.
146+
147+
A module that touches one table usually wants the whole artifact turned into helpers at once
148+
rather than a call per site — derive them from the artifact so the module names the ENTITY and
149+
never restates its own field list:
150+
151+
```ts
152+
const P = {
153+
table: ident(ProgramNames.sources.primary.table),
154+
col: Object.fromEntries(
155+
Object.entries(ProgramNames.fields).map(([f, d]) => [f, ident(d.column)]),
156+
),
157+
};
158+
```
159+
90160
## Return-type contract
91161

92162
The runtime returns **native in-process types**, never wire strings — temporal

fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-runtime-ui/references/typescript.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,76 @@ sql`SELECT ${sql.identifier(ProgramNames.fields.createdAt.column)}
8787
A literal is a second spelling of a fact the metadata owns: `@column` is free-form, and a
8888
rename in metadata moves the constant, not the string.
8989

90+
**`excluded.<column>` has no handle.** In an `ON CONFLICT … DO UPDATE SET`, Drizzle maps the
91+
`set` keys (they are FIELD names) but the value on the right is raw SQL, and `excluded` is a
92+
pseudo-table Postgres binds to the row the INSERT proposed — there is no column object for it.
93+
That is the one place a physical name is unavoidable in otherwise-typed Drizzle code, so it is
94+
where the constant earns the most:
95+
96+
```ts
97+
const excluded = (column: string) => sql`excluded.${sql.identifier(column)}`;
98+
//
99+
.onConflictDoUpdate({ target: programs.slug, set: {
100+
title: excluded(ProgramNames.fields.title.column),
101+
} })
102+
```
103+
104+
The existing row, by contrast, IS reachable: Postgres exposes it under the table's own name
105+
inside `ON CONFLICT`, and a Drizzle column object renders as exactly that qualified name — so
106+
`${programs.updatedBy}` is `"program"."updated_by"` with nothing spelled by hand.
107+
108+
### Getting the name INTO a query is a per-driver question
109+
110+
The artifact hands you a string. Every driver has its own way to place a string in identifier
111+
position, and they are not interchangeable — a value placeholder there is a syntax error, and a
112+
bare interpolation is an injection hole the moment the name stops being a constant. **Find your
113+
driver's identifier form once, wrap it in a one-line local helper, and use the helper
114+
everywhere**; that is the whole integration, and it is the same shape whether you are on Drizzle,
115+
Kysely, `postgres.js`, `node-postgres`, `mysql2`, or a query builder this page has never heard of.
116+
117+
Two properties decide whether a form is the right one:
118+
119+
- **It escapes.** A physical name is free-form (`@column` takes whatever you declare), so the
120+
helper must quote and escape rather than concatenate. Most drivers ship this; `pg` exposes it as
121+
`Client.prototype.escapeIdentifier`, `mysql2` as `escapeId`.
122+
- **It means the same thing in every clause.** This is the one that bites, because a form can work
123+
in `SELECT` and fail in `INSERT`. Test your helper in `SELECT`, `FROM`, `WHERE`, `INSERT INTO`,
124+
a column list, `UPDATE … SET` and inside a transaction before you commit to it — against a real
125+
engine, not a snapshot.
126+
127+
`postgres.js` is the worked example of the second property going wrong, and it is worth reading
128+
even if you are on another driver, because the failure is silent until it is a syntax error in
129+
production. Its `sql(name)` builder is **not** an identifier: it dispatches on the SQL text BEFORE
130+
the interpolation. In `INSERT INTO ${sql("audit_entry")} (…)` it matches the library's
131+
insert-builder, which reads the value as a row object and emits a column list, and the statement
132+
dies with `syntax error at or near "("`. `sql([name])` survives that one and is the same trapdoor
133+
a clause away. The form that is inert in every clause is a quoted identifier through `unsafe`:
134+
135+
```ts
136+
const ident = (name: string) => sql.unsafe(`"${name.replace(/"/g, '""')}"`);
137+
138+
await sql`SELECT ${ident(ProgramNames.fields.createdAt.column)}
139+
FROM ${ident(ProgramNames.sources.primary.table)}`;
140+
```
141+
142+
`unsafe` names the one thing the caller must guarantee — that the string is a name you authored,
143+
never user input — and a generated constant is exactly that. Built once on the pooled client, the
144+
fragment carries no per-query state, so it can be shared across a module and composed inside a
145+
`sql.begin()` transaction.
146+
147+
A module that touches one table usually wants the whole artifact turned into helpers at once
148+
rather than a call per site — derive them from the artifact so the module names the ENTITY and
149+
never restates its own field list:
150+
151+
```ts
152+
const P = {
153+
table: ident(ProgramNames.sources.primary.table),
154+
col: Object.fromEntries(
155+
Object.entries(ProgramNames.fields).map(([f, d]) => [f, ident(d.column)]),
156+
),
157+
};
158+
```
159+
90160
## Return-type contract
91161

92162
The runtime returns **native in-process types**, never wire strings — temporal

fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-runtime-ui/references/typescript.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,76 @@ sql`SELECT ${sql.identifier(ProgramNames.fields.createdAt.column)}
8787
A literal is a second spelling of a fact the metadata owns: `@column` is free-form, and a
8888
rename in metadata moves the constant, not the string.
8989

90+
**`excluded.<column>` has no handle.** In an `ON CONFLICT … DO UPDATE SET`, Drizzle maps the
91+
`set` keys (they are FIELD names) but the value on the right is raw SQL, and `excluded` is a
92+
pseudo-table Postgres binds to the row the INSERT proposed — there is no column object for it.
93+
That is the one place a physical name is unavoidable in otherwise-typed Drizzle code, so it is
94+
where the constant earns the most:
95+
96+
```ts
97+
const excluded = (column: string) => sql`excluded.${sql.identifier(column)}`;
98+
//
99+
.onConflictDoUpdate({ target: programs.slug, set: {
100+
title: excluded(ProgramNames.fields.title.column),
101+
} })
102+
```
103+
104+
The existing row, by contrast, IS reachable: Postgres exposes it under the table's own name
105+
inside `ON CONFLICT`, and a Drizzle column object renders as exactly that qualified name — so
106+
`${programs.updatedBy}` is `"program"."updated_by"` with nothing spelled by hand.
107+
108+
### Getting the name INTO a query is a per-driver question
109+
110+
The artifact hands you a string. Every driver has its own way to place a string in identifier
111+
position, and they are not interchangeable — a value placeholder there is a syntax error, and a
112+
bare interpolation is an injection hole the moment the name stops being a constant. **Find your
113+
driver's identifier form once, wrap it in a one-line local helper, and use the helper
114+
everywhere**; that is the whole integration, and it is the same shape whether you are on Drizzle,
115+
Kysely, `postgres.js`, `node-postgres`, `mysql2`, or a query builder this page has never heard of.
116+
117+
Two properties decide whether a form is the right one:
118+
119+
- **It escapes.** A physical name is free-form (`@column` takes whatever you declare), so the
120+
helper must quote and escape rather than concatenate. Most drivers ship this; `pg` exposes it as
121+
`Client.prototype.escapeIdentifier`, `mysql2` as `escapeId`.
122+
- **It means the same thing in every clause.** This is the one that bites, because a form can work
123+
in `SELECT` and fail in `INSERT`. Test your helper in `SELECT`, `FROM`, `WHERE`, `INSERT INTO`,
124+
a column list, `UPDATE … SET` and inside a transaction before you commit to it — against a real
125+
engine, not a snapshot.
126+
127+
`postgres.js` is the worked example of the second property going wrong, and it is worth reading
128+
even if you are on another driver, because the failure is silent until it is a syntax error in
129+
production. Its `sql(name)` builder is **not** an identifier: it dispatches on the SQL text BEFORE
130+
the interpolation. In `INSERT INTO ${sql("audit_entry")} (…)` it matches the library's
131+
insert-builder, which reads the value as a row object and emits a column list, and the statement
132+
dies with `syntax error at or near "("`. `sql([name])` survives that one and is the same trapdoor
133+
a clause away. The form that is inert in every clause is a quoted identifier through `unsafe`:
134+
135+
```ts
136+
const ident = (name: string) => sql.unsafe(`"${name.replace(/"/g, '""')}"`);
137+
138+
await sql`SELECT ${ident(ProgramNames.fields.createdAt.column)}
139+
FROM ${ident(ProgramNames.sources.primary.table)}`;
140+
```
141+
142+
`unsafe` names the one thing the caller must guarantee — that the string is a name you authored,
143+
never user input — and a generated constant is exactly that. Built once on the pooled client, the
144+
fragment carries no per-query state, so it can be shared across a module and composed inside a
145+
`sql.begin()` transaction.
146+
147+
A module that touches one table usually wants the whole artifact turned into helpers at once
148+
rather than a call per site — derive them from the artifact so the module names the ENTITY and
149+
never restates its own field list:
150+
151+
```ts
152+
const P = {
153+
table: ident(ProgramNames.sources.primary.table),
154+
col: Object.fromEntries(
155+
Object.entries(ProgramNames.fields).map(([f, d]) => [f, ident(d.column)]),
156+
),
157+
};
158+
```
159+
90160
## Return-type contract
91161

92162
The runtime returns **native in-process types**, never wire strings — temporal

0 commit comments

Comments
 (0)