Skip to content

Commit dcb3745

Browse files
committed
fix(stack): select('*') must alias renamed v3 columns back to their property
`select('*')` expanded the introspected column list, which holds DB column names. `addJsonbCastsV3` only emits the `prop:db_name::jsonb` PostgREST alias when it sees a *property* name; a raw DB name took the unaliased `dbNames.has(...)` branch. So on a declared table with a property→DB rename, rows came back keyed `created_at` while the declared row type promises `createdAt` — `row.createdAt` was silently `undefined` for a field TypeScript guaranteed as a Date. Synthesized columns were unaffected (property == DB name), which is why no existing test caught it: the select-star suite used only matching names, and the rename suite used only explicit selects. Adds an `expandAllColumns` hook on the base builder (identity for v2, which never supplies a column list) that the v3 dialect overrides to map DB names back through the inverse of `buildColumnKeyMap()`. Also fixes a pre-existing prototype-inheritance hazard surfaced by the new tests: `propToDb` was a plain object indexed by column names that originate in the database, so a plaintext column named `constructor` resolved to `Object.prototype.constructor` — truthy — and interpolated `function Object() { [native code] }` into the emitted select string. It was reachable from explicit selects too (`select('constructor')`), and equally from `filterColumnName` and the mutation-payload transform. `buildColumnKeyMap()` now returns a null-prototype map, and every read site is `Object.hasOwn`-guarded — the same belt-and-braces the DOMAIN_REGISTRY already uses. Regression tests assert the resulting ROW KEY, not just the select string: the Supabase double now simulates PostgREST's alias projection, since the alias is what performs the rename server-side. All five new assertions fail against the prior implementation.
1 parent 2354af4 commit dcb3745

6 files changed

Lines changed: 209 additions & 36 deletions

File tree

.changeset/eql-v3-supabase-adapter.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ now a connect-time-async factory: `await encryptedSupabaseV3(url, key)` (or
88
by their Postgres domain (`information_schema.columns.domain_name`), and derives
99
each column's encryption config from its domain — callers no longer pass a
1010
schema to `from()`. `select('*')` is supported (expanded from the introspected
11-
column list). A column using an EQL v3 domain this SDK version does not model
11+
column list, and aliased back to each declared column's JS property name so a
12+
property→DB rename round-trips). A column using an EQL v3 domain this SDK version does not model
1213
(e.g. `public.json`, `*_ord_ope`) throws at construction rather than silently
1314
passing through. Supplying `schemas` remains optional and adds compile-time
1415
types plus startup verification of the declared tables against the database.

packages/stack/__tests__/supabase-v3-select-star.test.ts

Lines changed: 125 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,32 @@ import type { EncryptionClient } from '@/encryption'
33
import { encryptedTable, types } from '@/eql/v3'
44
import { EncryptedQueryBuilderV3Impl } from '@/supabase/query-builder-v3'
55

6-
/** Minimal Supabase double that records only the select string. */
7-
function mockSupabase() {
6+
/**
7+
* Supabase double that records the select string AND simulates the part of
8+
* PostgREST this adapter depends on: a `alias:column::jsonb` token returns the
9+
* value under `alias`, a bare `column::jsonb` (or `column`) under `column`.
10+
*
11+
* Simulating the rename is the point. The alias is the ONLY thing that makes a
12+
* renamed column come back under its JS property name, and it is produced
13+
* server-side — a double that echoes rows verbatim would assert nothing about
14+
* it. `dbRows` are keyed by DB column name, exactly as Postgres stores them.
15+
*/
16+
function mockSupabase(dbRows: Record<string, unknown>[] = []) {
817
const selects: string[] = []
18+
19+
const project = (select: string) =>
20+
dbRows.map((dbRow) => {
21+
const row: Record<string, unknown> = {}
22+
for (const token of select.split(',')) {
23+
const bare = token.trim().replace(/::jsonb$/, '')
24+
const [alias, column] = bare.includes(':')
25+
? bare.split(':')
26+
: [bare, bare]
27+
row[alias] = dbRow[column]
28+
}
29+
return row
30+
})
31+
932
// biome-ignore lint/suspicious/noExplicitAny: test double
1033
const qb: any = {
1134
select: (s: string) => {
@@ -17,7 +40,7 @@ function mockSupabase() {
1740
onrejected?: ((r: unknown) => unknown) | null,
1841
) =>
1942
Promise.resolve({
20-
data: [],
43+
data: project(selects[0] ?? ''),
2144
error: null,
2245
count: null,
2346
status: 200,
@@ -27,6 +50,20 @@ function mockSupabase() {
2750
return { client: { from: () => qb }, selects }
2851
}
2952

53+
/** Identity decrypt — this suite is about column naming, not envelopes. */
54+
function mockEncryptionClient() {
55+
const operation = <T>(data: T) => ({
56+
withLockContext: () => operation(data),
57+
audit: () => operation(data),
58+
then: (f?: ((v: { data: T }) => unknown) | null) =>
59+
Promise.resolve({ data }).then(f),
60+
})
61+
return {
62+
decryptModel: (m: Record<string, unknown>) => operation(m),
63+
bulkDecryptModels: (m: Record<string, unknown>[]) => operation(m),
64+
} as unknown as EncryptionClient
65+
}
66+
3067
const users = encryptedTable('users', {
3168
email: types.TextSearch('email'),
3269
amount: types.IntegerOrd('amount'),
@@ -36,53 +73,112 @@ const users = encryptedTable('users', {
3673
// DB column names as introspection would report them (plaintext id/note included).
3774
const ALL_COLUMNS = ['id', 'email', 'amount', 'created_at', 'note']
3875

76+
function builderFor(
77+
supabase: ReturnType<typeof mockSupabase>,
78+
allColumns: string[] | null = ALL_COLUMNS,
79+
) {
80+
return new EncryptedQueryBuilderV3Impl(
81+
'users',
82+
users,
83+
mockEncryptionClient(),
84+
supabase.client,
85+
allColumns,
86+
)
87+
}
88+
3989
describe("v3 select('*') expansion", () => {
4090
it('expands * to the full column list and casts encrypted columns', async () => {
4191
const supabase = mockSupabase()
42-
const builder = new EncryptedQueryBuilderV3Impl(
43-
'users',
44-
users,
45-
{} as EncryptionClient,
46-
supabase.client,
47-
ALL_COLUMNS,
92+
await builderFor(supabase).select('*')
93+
94+
// `created_at` is aliased back to its JS property name; `id`/`note` are
95+
// plaintext passthrough and `email`/`amount` are named identically in both.
96+
expect(supabase.selects[0]).toBe(
97+
'id, email::jsonb, amount::jsonb, createdAt:created_at::jsonb, note',
4898
)
99+
})
49100

50-
await builder.select('*')
101+
it('no-arg select() behaves exactly like select("*")', async () => {
102+
const supabase = mockSupabase()
103+
await builderFor(supabase).select()
51104

52105
expect(supabase.selects[0]).toBe(
53-
'id, email::jsonb, amount::jsonb, created_at::jsonb, note',
106+
'id, email::jsonb, amount::jsonb, createdAt:created_at::jsonb, note',
54107
)
55108
})
56109

57-
it('no-arg select() behaves exactly like select("*")', async () => {
110+
it("still throws select('*') when no column list is available", async () => {
58111
const supabase = mockSupabase()
59-
const builder = new EncryptedQueryBuilderV3Impl(
60-
'users',
61-
users,
62-
{} as EncryptionClient,
63-
supabase.client,
64-
ALL_COLUMNS,
112+
const builder = builderFor(supabase, null)
113+
114+
expect(() => builder.select('*')).toThrow(/select\('\*'\)/)
115+
// v2 regression: a bare select() takes the same path and throws the same way.
116+
expect(() => builder.select()).toThrow(/select\('\*'\)/)
117+
})
118+
})
119+
120+
describe("REGRESSION: select('*') keys rows by JS property, not DB column", () => {
121+
// A declared column whose property name differs from its DB column is the
122+
// only case that can drift: synthesized columns always have property == DB
123+
// name. Before the `expandAllColumns` override, `select('*')` emitted the
124+
// unaliased `created_at::jsonb`, so rows came back keyed `created_at` while
125+
// the declared row type promises `createdAt` — `row.createdAt` was silently
126+
// `undefined` for a field TypeScript guaranteed as a Date.
127+
const dbRow = {
128+
id: 1,
129+
email: 'a@b.com',
130+
amount: 30,
131+
created_at: '2026-01-02T03:04:05.000Z',
132+
note: 'hi',
133+
}
134+
135+
it('returns the renamed column under its property name', async () => {
136+
const supabase = mockSupabase([dbRow])
137+
const { data } = await builderFor(supabase).select('*')
138+
139+
expect(data![0].createdAt).toBeInstanceOf(Date)
140+
expect((data![0].createdAt as Date).toISOString()).toBe(
141+
'2026-01-02T03:04:05.000Z',
65142
)
143+
expect(data![0]).not.toHaveProperty('created_at')
144+
})
66145

67-
await builder.select()
146+
it("select('*') and an explicit property select agree on row shape", async () => {
147+
const star = mockSupabase([dbRow])
148+
const explicit = mockSupabase([dbRow])
68149

69-
expect(supabase.selects[0]).toBe(
70-
'id, email::jsonb, amount::jsonb, created_at::jsonb, note',
150+
const { data: starData } = await builderFor(star).select('*')
151+
const { data: explicitData } = await builderFor(explicit).select(
152+
'id, email, amount, createdAt, note',
71153
)
154+
155+
expect(Object.keys(starData![0]).sort()).toEqual(
156+
Object.keys(explicitData![0]).sort(),
157+
)
158+
expect(starData![0]).toEqual(explicitData![0])
72159
})
73160

74-
it("still throws select('*') when no column list is available", async () => {
161+
it('leaves plaintext passthrough columns under their DB name', async () => {
162+
const supabase = mockSupabase([dbRow])
163+
const { data } = await builderFor(supabase).select('*')
164+
165+
expect(data![0].id).toBe(1)
166+
expect(data![0].note).toBe('hi')
167+
})
168+
169+
it('does not treat a DB column named like an Object.prototype member as a property', async () => {
170+
// `dbToProp['constructor']` would resolve to Object.prototype.constructor on
171+
// a plain object, emitting a function where a column name belongs.
75172
const supabase = mockSupabase()
76173
const builder = new EncryptedQueryBuilderV3Impl(
77-
'users',
78-
users,
79-
{} as EncryptionClient,
174+
'weird',
175+
encryptedTable('weird', { email: types.TextSearch('email') }),
176+
mockEncryptionClient(),
80177
supabase.client,
81-
null,
178+
['constructor', 'toString', 'email'],
82179
)
180+
await builder.select('*')
83181

84-
expect(() => builder.select('*')).toThrow(/select\('\*'\)/)
85-
// v2 regression: a bare select() takes the same path and throws the same way.
86-
expect(() => builder.select()).toThrow(/select\('\*'\)/)
182+
expect(supabase.selects[0]).toBe('constructor, toString, email::jsonb')
87183
})
88184
})

packages/stack/src/eql/v3/table.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,19 @@ export class EncryptedTable<T extends EncryptedV3TableColumn> {
5656
* encrypt config and FFI by DB name — `build()` keys columns by DB name, so
5757
* the two only agree when property == name. This recovers the mapping that
5858
* `build()` discards.
59+
*
60+
* NULL PROTOTYPE — load-bearing. Callers index this map by a column name that
61+
* ultimately comes from the database (`addJsonbCastsV3`, `filterColumnName`,
62+
* the mutation transform). On a plain object literal, a column named
63+
* `constructor` / `toString` / `valueOf` / `__proto__` resolves to an
64+
* inherited `Object.prototype` member, which is truthy — so a *plaintext*
65+
* column with such a name would be mistaken for a mapped encrypted column and
66+
* its `Object.prototype` value interpolated into the emitted select string.
67+
* `encryptedTable()` rejects such names as JS *properties*, but nothing
68+
* constrains the DB column names a table may contain.
5969
*/
6070
buildColumnKeyMap(): Record<string, string> {
61-
const map: Record<string, string> = {}
71+
const map = Object.create(null) as Record<string, string>
6272
for (const [property, builder] of Object.entries(this.columnBuilders)) {
6373
map[property] = builder.getName()
6474
}

packages/stack/src/supabase/helpers.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ export function addJsonbCasts(
6868
.join(',')
6969
}
7070

71+
/**
72+
* Resolve a select token to its DB column name, or `undefined`.
73+
*
74+
* `Object.hasOwn` is required, not decorative: the token comes from the caller's
75+
* select string (or, for `select('*')`, from the database's own column list).
76+
* `buildColumnKeyMap()` already returns a null-prototype map, but an inherited
77+
* `Object.prototype` member is truthy, so a plain-object map would let a column
78+
* named `constructor` interpolate `function Object() { … }` into the emitted
79+
* select string. Both guards are kept — a future refactor that drops the null
80+
* prototype must not silently reopen the hole.
81+
*/
82+
function lookupDbName(
83+
propToDb: Record<string, string>,
84+
token: string,
85+
): string | undefined {
86+
return Object.hasOwn(propToDb, token) ? propToDb[token] : undefined
87+
}
88+
7189
/**
7290
* Parse a Supabase select string and add `::jsonb` casts to encrypted EQL v3
7391
* columns, resolving JS property names to DB column names via PostgREST
@@ -106,14 +124,15 @@ export function addJsonbCastsV3(
106124
)
107125
if (aliasMatch) {
108126
const [, alias, name] = aliasMatch
109-
const db = propToDb[name] ?? (dbNames.has(name) ? name : undefined)
127+
const db =
128+
lookupDbName(propToDb, name) ?? (dbNames.has(name) ? name : undefined)
110129
if (db !== undefined) {
111130
return `${leadingWhitespace}${alias}:${db}::jsonb`
112131
}
113132
return col
114133
}
115134

116-
const db = propToDb[trimmed]
135+
const db = lookupDbName(propToDb, trimmed)
117136
if (db !== undefined) {
118137
return db === trimmed
119138
? `${leadingWhitespace}${trimmed}::jsonb`

packages/stack/src/supabase/query-builder-v3.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ export class EncryptedQueryBuilderV3Impl<
7979
private v3Table: AnyV3Table
8080
/** JS property name → DB column name, for every encrypted column. */
8181
private propToDb: Record<string, string>
82+
/** DB column name → JS property name — the inverse of {@link propToDb}, used
83+
* to expand `select('*')` back into property names. Null prototype: a DB
84+
* column literally named `constructor` / `toString` would otherwise resolve
85+
* to an inherited `Object.prototype` member and be emitted as a select token. */
86+
private dbToProp: Record<string, string>
8287
/** Built column schemas keyed by DB column name (for `cast_as`). */
8388
private columnSchemas: Record<string, ColumnSchema>
8489
/** Column builders keyed by BOTH property name and DB name. */
@@ -106,6 +111,11 @@ export class EncryptedQueryBuilderV3Impl<
106111
this.propToDb = table.buildColumnKeyMap()
107112
this.columnSchemas = table.build().columns
108113

114+
this.dbToProp = Object.create(null) as Record<string, string>
115+
for (const [property, dbName] of Object.entries(this.propToDb)) {
116+
this.dbToProp[dbName] = property
117+
}
118+
109119
this.v3Columns = {}
110120
for (const [property, builder] of Object.entries(table.columnBuilders)) {
111121
if (builder instanceof EncryptedV3Column) {
@@ -129,22 +139,48 @@ export class EncryptedQueryBuilderV3Impl<
129139
return this.v3Columns as unknown as Record<string, BuildableQueryColumn>
130140
}
131141

142+
/** Resolve a JS property name to its DB column name. `Object.hasOwn` guards
143+
* the inherited-member hazard described on {@link EncryptedTable.buildColumnKeyMap}. */
144+
private dbNameFor(name: string): string {
145+
return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name
146+
}
147+
132148
protected override filterColumnName(column: string): string {
133-
return this.propToDb[column] ?? column
149+
return this.dbNameFor(column)
134150
}
135151

136152
protected override buildSelectString(): string | null {
137153
if (this.selectColumns === null) return null
138154
return addJsonbCastsV3(this.selectColumns, this.propToDb)
139155
}
140156

157+
/**
158+
* Expand the introspected column list (DB names) into JS property names.
159+
*
160+
* Load-bearing for `select('*')` on a DECLARED table that renames a column.
161+
* `addJsonbCastsV3` only emits the `prop:db_name::jsonb` alias — the thing
162+
* that makes PostgREST return the column under its property name — when the
163+
* token it sees is a property name. Feeding it the raw DB name instead takes
164+
* the unaliased `dbNames.has(...)` branch, so the row comes back keyed
165+
* `created_at` while the declared row type promises `createdAt`, silently
166+
* yielding `undefined` for a field TypeScript guarantees.
167+
*
168+
* A DB column with no encrypted builder (plaintext passthrough, and every
169+
* synthesized column, where property == DB name) maps to itself.
170+
*/
171+
protected override expandAllColumns(columns: string[]): string[] {
172+
return columns.map((dbName) =>
173+
Object.hasOwn(this.dbToProp, dbName) ? this.dbToProp[dbName] : dbName,
174+
)
175+
}
176+
141177
/** v3 domains are plain jsonb — send the raw payload, keyed by DB name. */
142178
protected override transformEncryptedMutationModel(
143179
model: Record<string, unknown>,
144180
): Record<string, unknown> {
145181
const out: Record<string, unknown> = {}
146182
for (const [key, value] of Object.entries(model)) {
147-
out[this.propToDb[key] ?? key] = value
183+
out[this.dbNameFor(key)] = value
148184
}
149185
return out
150186
}

packages/stack/src/supabase/query-builder.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,25 @@ export class EncryptedQueryBuilderImpl<
104104
"encryptedSupabase does not support select('*'). Please list columns explicitly so that encrypted columns can be cast with ::jsonb.",
105105
)
106106
}
107-
this.selectColumns = this.allColumns.join(', ')
107+
this.selectColumns = this.expandAllColumns(this.allColumns).join(', ')
108108
} else {
109109
this.selectColumns = columns
110110
}
111111
this.selectOptions = options
112112
return this
113113
}
114114

115+
/**
116+
* Turn the introspected column list (DB names) into select tokens. The base
117+
* returns them unchanged — v2 never supplies a column list, so this is dead
118+
* for v2. The v3 dialect overrides it to emit JS property names, which is
119+
* what makes `addJsonbCastsV3` alias a renamed column back to its property
120+
* (`createdAt:created_at::jsonb`) rather than returning it under its DB name.
121+
*/
122+
protected expandAllColumns(columns: string[]): string[] {
123+
return columns
124+
}
125+
115126
insert(
116127
data: Partial<T> | Partial<T>[],
117128
options?: {

0 commit comments

Comments
 (0)