From 2b4e2e923fbc03d2815e444cfec42ed2f7950b17 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 23:52:05 +1000 Subject: [PATCH 1/7] refactor(stack-supabase): fold v3 query builder into base Re-parameterize EncryptedQueryBuilderImpl over AnyV3Table and inline every EncryptedQueryBuilderV3Impl dialect override, then delete query-builder-v3.ts. Pure refactor: no runtime behaviour or wire encoding change. The decrypt path stays generation-agnostic (decryptModel/bulkDecryptModels), so stored EQL v2 payloads still decrypt (Decision 6). --- .../stack-supabase/src/query-builder-v3.ts | 989 ------------------ packages/stack-supabase/src/query-builder.ts | 938 ++++++++++++++--- 2 files changed, 818 insertions(+), 1109 deletions(-) delete mode 100644 packages/stack-supabase/src/query-builder-v3.ts diff --git a/packages/stack-supabase/src/query-builder-v3.ts b/packages/stack-supabase/src/query-builder-v3.ts deleted file mode 100644 index 8df37536..00000000 --- a/packages/stack-supabase/src/query-builder-v3.ts +++ /dev/null @@ -1,989 +0,0 @@ -import { - DATE_LIKE_CASTS, - EncryptedV3Column, - logger, - matchNeedleError, - parseSelectorSegments, - reconstructSelectorDocument, - unsupportedLeafReason, -} from '@cipherstash/stack/adapter-kit' -import type { EncryptionClient } from '@cipherstash/stack/encryption' -import type { AnyV3Table } from '@cipherstash/stack/eql/v3' -import { - type EncryptionError, - EncryptionErrorTypes, -} from '@cipherstash/stack/errors' -import type { - ColumnSchema, - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' -import type { - BuildableQueryColumn, - Encrypted, - EncryptedQueryResult, - QueryTypeName, - ScalarQueryTerm, -} from '@cipherstash/stack/types' -import { addJsonbCastsV3, selectKeyToDbV3 } from './helpers' -import { - EncryptedQueryBuilderImpl, - EncryptionFailedError, -} from './query-builder' -import type { - DbName, - DbSelect, - FilterOp, - SupabaseClientLike, - SupabaseQueryBuilder, -} from './types' - -/** cast_as kinds that reconstruct to a JS `Date` — shared with the typed v3 - * client's decrypt-model path (see `encryption/v3.ts`). */ -const DATE_LIKE_CAST_SET = new Set(DATE_LIKE_CASTS) - -/** - * The subset of a v3 column builder the dialect relies on. Structural rather - * than the concrete class union so the runtime `instanceof EncryptedV3Column` - * gate and this type stay independent. - */ -type V3ColumnLike = { - getName(): string - getEqlType(): string - getQueryCapabilities(): { - equality: boolean - orderAndRange: boolean - freeTextSearch: boolean - /** Optional: only `public.eql_v3_json_search` (`types.Json`) carries it. */ - searchableJson?: boolean - } - build(): ColumnSchema -} - -/** - * Validate an encrypted-JSON containment operand: a NON-EMPTY plain object or a - * non-empty array. Everything else is rejected with an actionable steer: - * - * - Scalars/strings: the caller meant free-text (`matches` on a text column) or - * a selector — a raw JSON string is NOT parsed, by design (parsing would make - * `'{"a":1}'` and `{a:1}` silently different queries on other surfaces). - * - Non-plain objects (`Date`, `Map`, `RegExp`, class instances): these JSON- - * serialize to scalars or `{}` — not the sub-document the caller believes. - * - `{}` and `[]`: jsonb containment holds for EVERY document (`doc @> '{}'`), - * so an accidentally-empty needle would silently return (and decrypt) the - * whole table. The Drizzle adapter rejects the same needle for the same - * reason — the two first-party adapters must agree that this is an error. - */ -function assertJsonContainmentOperand(column: string, value: unknown): void { - const isPlainObject = - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - (Object.getPrototypeOf(value) === Object.prototype || - Object.getPrototypeOf(value) === null) - if (!isPlainObject && !Array.isArray(value)) { - // Array.isArray is false on this branch by construction, so the label only - // distinguishes null / non-plain object / scalar. - const got = - value === null - ? 'null' - : typeof value === 'object' - ? (value as object).constructor?.name || 'a non-plain object' - : typeof value - throw new Error( - `[supabase v3]: encrypted JSON containment on column "${column}" takes a sub-document (plain object or array) to match, got ${got}.`, - ) - } - const empty = Array.isArray(value) - ? value.length === 0 - : Object.keys(value as object).length === 0 - if (empty) { - throw new Error( - `[supabase v3]: encrypted JSON containment on column "${column}" cannot take an empty ${Array.isArray(value) ? 'array' : 'object'} needle: it matches every row. Pass a non-empty sub-document, or omit the predicate to select all rows.`, - ) - } -} - -/** - * Reject a declared property name that is also a DIFFERENT physical column. - * - * `select('*')` expands the introspected DB names into property names, so a - * column renamed `created_at → createdAt` and a distinct plaintext column - * literally named `createdAt` both emit the token `createdAt`, which - * `addJsonbCastsV3` turns into `createdAt:created_at::jsonb` — twice. PostgREST - * returns the encrypted column under that key and the plaintext one is never - * selected, silently yielding the wrong value for a field the row type - * guarantees. - * - * Nothing downstream can disambiguate the two, and `EncryptedTable.build()`'s - * duplicate check only fires when two BUILDERS share a `getName()`. Refuse to - * construct instead. - */ -function assertNoPropertyDbNameCollision( - tableName: string, - propToDb: Record, - allColumns: string[] | null, -): void { - if (!allColumns) return - const dbNames = new Set(allColumns) - - for (const [property, dbName] of Object.entries(propToDb)) { - if (property === dbName) continue - if (!dbNames.has(property)) continue - throw new Error( - `[supabase v3]: property "${property}" on table "${tableName}" renames DB column "${dbName}", but "${property}" is also a distinct column in the database — the two collide in select('*'). Rename the property, or drop the declared rename.`, - ) - } -} - -/** - * EQL v3 dialect of {@link EncryptedQueryBuilderImpl} for native concrete-domain - * columns (`public.*` type domains, `eql_v3` operators). The query mechanism is - * v2's — direct EQL operators over PostgREST — with four narrow forks: - * - * - **Column recognition / naming** — v3 columns are `EncryptedV3Column` - * builders and may map a JS property name to a different DB column name - * (`buildColumnKeyMap`). Filters, select casts, and mutations resolve - * property → DB name; select casts alias the DB column back to the property - * (`prop:db_name::jsonb`) so result rows keep property keys. - * - **Mutation encoding** — the raw encrypted payload object is sent (the - * `public.*` domains are `DOMAIN … AS jsonb`), not v2's `{ data: … }` - * composite wrap. - * - **Query-term encoding** — scalar equality/range filters use the FULL - * storage envelope from `encrypt()`, serialized as jsonb text. - * - * NOT because narrowed terms fail the domain CHECK: the bundle defines a - * `public._query` companion for each storage domain, whose CHECK - * requires `NOT (VALUE ? 'c')` — i.e. it accepts exactly the no-ciphertext - * shape `encryptQuery` produces. Those domains are simply unreachable from - * here. PostgREST has no syntax to cast a filter VALUE, and an uncast literal - * is ambiguous between the `_query` and `jsonb` `@>`/`=` overloads (42725 — - * the bundle says so itself, see `cipherstash-encrypt-v3.sql`, the - * `_query_types.sql` note). The reachable overload is the `jsonb` one, whose - * body coerces its operand to the STORAGE domain, which does require `c`. - * (protect-ffi can mint narrowed `eql_v3.query_` operands via - * `encryptQuery`, but with no way to cast a PostgREST filter value they - * stay unreachable from this adapter.) - * - * The full envelope satisfies scalar storage-domain CHECKs by construction, - * and equality/range operators extract the term they need. - * - * EQL 3.0.2 removed the storage/jsonb escape hatch for free-text and JSON - * operators: those now require typed query-domain operands. The factory reads - * the installed EQL version and this builder fails those operators before - * encryption, so a decryptable storage envelope never enters a GET URL. - * - **Legacy `matches`, not `like`/`ilike`/`contains`** — on pre-3.0.2 EQL, - * encrypted free-text search is - * FUZZY BLOOM TOKEN MATCHING, not containment: the bundle declares `@>` on each - * match domain (`CREATE OPERATOR @> … FUNCTION = eql_v3.matches`, the SQL - * function's name), whose body is `match_term(a) @> match_term(b)` — `smallint[]` - * containment of the two bloom filters. It is order- and multiplicity- - * insensitive and one-sided (a `true` may be a false positive). PostgREST - * reaches it as `cs`. The operator is named `matches` to signal that; `contains` - * is reserved for exact (native) containment on plaintext columns. - * - * Match is tokenized + downcased, so `%` is NOT a wildcard. `like`/`ilike` on an - * encrypted column are delegated to `matches` as an APPROXIMATE compatibility - * shim (surrounding `%` stripped, internal `%`/`_` rejected, one warning) and - * pass through as real SQL LIKE on a plaintext column. - * - * Substrings DO match: the needle blooms to its own trigrams, and containment - * holds whenever every one of them is present in the stored value's bloom — - * i.e. for any substring of at least `token_length` (3) characters. Shorter - * needles bloom to nothing (`bf @> '{}'` is true for every row) and are - * rejected up front by `matchNeedleError`, not answered. - * - * Decrypted rows additionally get `Date` reconstruction from the - * encrypt-config `cast_as`, mirroring the typed v3 client. - */ -export class EncryptedQueryBuilderV3Impl< - T extends Record = Record, -> extends EncryptedQueryBuilderImpl { - private v3Table: AnyV3Table - /** JS property name → DB column name, for every encrypted column. */ - private propToDb: Record - /** DB column name → JS property name — the inverse of {@link propToDb}, used - * to expand `select('*')` back into property names. Null prototype: a DB - * column literally named `constructor` / `toString` would otherwise resolve - * to an inherited `Object.prototype` member and be emitted as a select token. */ - private dbToProp: Record - /** Built column schemas keyed by DB column name (for `cast_as`). */ - private columnSchemas: Record - /** Column builders keyed by BOTH property name and DB name. */ - private v3Columns: Record - /** EQL 3.0.2+ requires query-domain casts PostgREST cannot express. */ - private queryDomainsRequired: boolean - - constructor( - tableName: string, - table: AnyV3Table, - encryptionClient: EncryptionClient, - supabaseClient: SupabaseClientLike, - allColumns: string[] | null = null, - queryDomainsRequired = false, - ) { - super( - tableName, - // The base class only ever calls BuildableTable members on the schema - // (build / encryptModel plumbing); every v2-specific behaviour is - // overridden below. - table as unknown as EncryptedTable, - encryptionClient, - supabaseClient, - allColumns, - ) - - this.v3Table = table - this.queryDomainsRequired = queryDomainsRequired - this.propToDb = table.buildColumnKeyMap() - this.columnSchemas = table.build().columns - - this.dbToProp = Object.create(null) as Record - for (const [property, dbName] of Object.entries(this.propToDb)) { - this.dbToProp[dbName] = property - } - - assertNoPropertyDbNameCollision(tableName, this.propToDb, allColumns) - - // Null-prototype: keyed by DB column names, and `validateTransforms` reads - // it without an own-key guard — an inherited `constructor`/`toString` would - // otherwise resolve truthy for a plaintext column of that name. - this.v3Columns = Object.create(null) as Record - for (const [property, builder] of Object.entries(table.columnBuilders)) { - if (builder instanceof EncryptedV3Column) { - const col = builder as unknown as V3ColumnLike - this.v3Columns[property] = col - this.v3Columns[col.getName()] = col - } - } - - // The base class derives encrypted column names from build(), which v3 - // keys by DB name. Filters and select strings address columns by JS - // property name, so recognition must cover both. - this.encryptedColumnNames = Object.keys(this.v3Columns) - } - - // --------------------------------------------------------------------------- - // Dialect overrides - // --------------------------------------------------------------------------- - - protected override getColumnMap(): Record { - return this.v3Columns as unknown as Record - } - - /** Resolve a JS property name to its DB column name. `Object.hasOwn` guards - * the inherited-member hazard described on {@link EncryptedTable.buildColumnKeyMap}. */ - private dbNameFor(name: string): string { - return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name - } - - protected override filterColumnName(column: string): DbName { - return this.dbNameFor(column) as DbName - } - - /** - * `ORDER BY` on an OPE-backed column is supported; on every other encrypted - * column it is rejected. - * - * A bare `ORDER BY col` IS wrong. The `*_ord` domains are - * `CREATE DOMAIN … AS jsonb`, and the bundle declares no btree operator class - * on any domain — it actively lints against one (`domain_opclass`), because an - * opclass on a domain bypasses operator resolution. So the sort resolves - * through jsonb's default `jsonb_cmp` and compares the envelope's keys in - * storage order, starting at the random ciphertext `c`. No error, and a - * stable, meaningless row order. (Measured: over 10 rows it returns - * `r00,r04,r08,r01,…` where the plaintext order is `r00..r09`.) - * - * But the correct sort key is reachable without a function call. `eql_v3.ord_term` - * returns the domain's `op` term, and OPE is order-preserving by construction: - * ordering by the term reproduces the plaintext order. PostgREST cannot emit - * `ORDER BY eql_v3.ord_term(col)`, but it CAN emit a jsonb path — - * `order=col->op.asc` — which selects exactly that term. Measured against a - * live PostgREST: `order=amount->op.asc` and `.desc` both reproduce the - * plaintext order for `integer_ord` and `text_search`, over 10 rows. - * - * So the guard is on the ordering FLAVOUR, not on encryption: - * - * - `ope` present → order by `col->op`. Every plain `_ord` domain, plus - * `text_ord` and `text_search`. - * - `ore` present → reject. The `ob` term is an array of ORE blocks whose - * comparison needs the superuser-only opclass; a jsonb-path sort over it is - * meaningless. (Such a column cannot hold data on managed Postgres at all: - * its domain CHECK raises `ore_domain_unavailable`.) - * - neither → reject. Storage-only, equality-only and match-only columns - * carry no ordering term to sort by. - * - * A column absent from {@link v3Columns} is a plaintext passthrough and orders - * normally. This runtime guard is the only protection the untyped - * (no-`schemas`) surface has. - */ - protected override validateTransforms(): void { - for (const t of this.transforms) { - if (t.kind !== 'order') continue - const column = this.v3Columns[t.column] - if (!column) continue - - const indexes = this.columnSchemas[column.getName()]?.indexes - if (indexes?.ope) continue - - const reason = indexes?.ore - ? 'its ORE ordering term (`ob`) needs the superuser-only ORE operator class, which PostgREST cannot reach through a jsonb path' - : 'it carries no ordering term to sort by' - - throw new Error( - `[supabase v3]: cannot order by encrypted column "${column.getName()}" (${column.getEqlType()}) — ${reason}. ` + - 'Order by a plaintext column, or use an OPE-backed ordering domain ' + - '(`*_ord`, `text_ord`, `text_search`), or use the EQL v3 Drizzle integration.', - ) - } - } - - /** - * Encrypted ordering columns sort by their `op` term, not by the envelope. - * - * `order=col->op` is the one ordering expression PostgREST can emit that - * reaches the OPE term. It must NOT leak into filters — those compare whole - * envelopes through the `eql_v3.*` operators — which is why this is its own - * seam rather than a change to `filterColumnName`. - * - * The canonical EQL form is `ORDER BY eql_v3.ord_term(col)`, which returns - * `eql_v3_internal.ope_cllw` — a domain over `bytea`, ordered by the native - * btree. PostgREST cannot call a function, so it orders the `op` term where it - * sits, inside the envelope. The two agree because the term is what - * `ord_term()` returns. - * - * `->` (jsonb) rather than `->>` (text) keeps the comparison on the typed - * value. Note this does NOT avoid the database collation: Postgres compares - * jsonb strings with `varstr_cmp` under the default collation, exactly as it - * does text. What makes the ordering collation-independent is the term itself - * — fixed-width lowercase hex (`[0-9a-f]`, 130 chars for `integer_ord`, 82 for - * `text_search`) — and every collation orders digits before letters and hex - * letters among themselves. `match-bloom`'s sibling assertion pins that shape. - * - * This runs at column-name-mapping time (`transformToDbSpace`), BEFORE - * `buildAndExecuteQuery` calls `validateTransforms`. For an encrypted column - * with no `ope` index it therefore returns a bare `dbName` here — a name that - * would sort by `jsonb_cmp` over the ciphertext if it reached PostgREST — but - * it never does: `validateTransforms` throws (with a domain-specific reason) - * before the query executes, so the bare name is only ever an intermediate - * value on a request that is about to be rejected. - */ - protected override orderColumnName(column: string): DbName { - const dbName = this.dbNameFor(column) - const encrypted = this.v3Columns[column] - if (!encrypted) return dbName as DbName - - return ( - this.columnSchemas[dbName]?.indexes?.ope ? `${dbName}->op` : dbName - ) as DbName - } - - /** - * Resolve a raw `.filter()` operator to the capability it exercises. Unlike - * v2, a supported v3 operand is a full storage envelope, so `queryType` - * never selects a narrowing — it only tells {@link encryptCollectedTerms} - * which capability to demand of the column. Getting it wrong therefore - * produces a wrong accept/reject, not a wrong ciphertext: the base class's - * `'equality'` default rejects `.filter('bio', 'cs', …)` on a - * `public.eql_v3_text_match` column, the one query that column can answer. - * - * Unknown operators throw rather than silently defaulting to equality, which - * would encrypt a term the column may not even be able to compare. - */ - protected override queryTypeForRawOp(operator: string): QueryTypeName { - switch (operator) { - case 'cs': - return 'freeTextSearch' - case 'gt': - case 'gte': - case 'lt': - case 'lte': - return 'orderAndRange' - case 'eq': - case 'neq': - case 'in': - case 'is': - return 'equality' - default: - throw new Error( - `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, - ) - } - } - - protected override buildSelectString(): DbSelect | null { - if (this.selectColumns === null) return null - return addJsonbCastsV3(this.selectColumns, this.propToDb) - } - - /** - * Expand the introspected column list (DB names) into JS property names. - * - * Load-bearing for `select('*')` on a DECLARED table that renames a column. - * `addJsonbCastsV3` only emits the `prop:db_name::jsonb` alias — the thing - * that makes PostgREST return the column under its property name — when the - * token it sees is a property name. Feeding it the raw DB name instead takes - * the unaliased `dbNames.has(...)` branch, so the row comes back keyed - * `created_at` while the declared row type promises `createdAt`, silently - * yielding `undefined` for a field TypeScript guarantees. - * - * A DB column with no encrypted builder (plaintext passthrough, and every - * synthesized column, where property == DB name) maps to itself. - */ - protected override expandAllColumns(columns: string[]): string[] { - return columns.map((dbName) => - Object.hasOwn(this.dbToProp, dbName) ? this.dbToProp[dbName] : dbName, - ) - } - - /** v3 domains are plain jsonb — send the raw payload, keyed by DB name. */ - protected override transformEncryptedMutationModel( - model: Record, - ): Record { - const out: Record = Object.create(null) - for (const [key, value] of Object.entries(model)) { - out[this.dbNameFor(key)] = value - } - return out - } - - protected override transformEncryptedMutationModels( - models: Record[], - ): Record[] { - return models.map((model) => this.transformEncryptedMutationModel(model)) - } - - /** - * Validate a term's query type against its column's declared capabilities. - * Pure validation: `encrypt`/`bulkEncrypt` never receive the query type. On - * EQL 3.0.2+, free-text/JSON terms are rejected before this storage-encryption - * path can place ciphertext in a GET URL. - */ - private assertTermQueryable(term: ScalarQueryTerm): V3ColumnLike { - const column = term.column as unknown as V3ColumnLike - let queryType = term.queryType ?? 'equality' - const capabilities = column.getQueryCapabilities() - - // The `cs` wire operator is capability-overloaded: bloom free-text on a - // match/search TEXT column, encrypted ste_vec containment on a `types.Json` - // DOCUMENT column. Both arrive here as `freeTextSearch` (contains/matches/ - // raw `cs` all map there); resolve to the capability the column actually - // carries. The two are mutually exclusive by construction, so this can - // never reinterpret a real free-text column. - if ( - queryType === 'freeTextSearch' && - !capabilities.freeTextSearch && - capabilities.searchableJson - ) { - queryType = 'searchableJson' - } - - if ( - queryType !== 'equality' && - queryType !== 'orderAndRange' && - queryType !== 'freeTextSearch' && - queryType !== 'searchableJson' - ) { - throw new Error( - `[supabase v3]: query type "${queryType}" is not supported on EQL v3 columns`, - ) - } - - if (!capabilities[queryType]) { - throw new Error( - `[supabase v3]: column "${column.getName()}" (${column.getEqlType()}) does not support ${queryType} queries — declare the column with a domain that carries that capability`, - ) - } - - if (queryType === 'freeTextSearch' || queryType === 'searchableJson') { - // This is the common boundary for every spelling that collects an - // encrypted match/containment term: matches(), contains(), not(), raw - // filter(), and both forms of or(). Method-level checks provide earlier - // errors for the direct helpers, but cannot cover the inherited raw - // filter paths on their own. - this.assertPostgrestCanQueryEncryptedOperator('filter', column.getName()) - } - - if (queryType === 'searchableJson') { - // THE single enforced operand boundary for encrypted-JSON containment. - // Terms reach this resolver from every spelling — contains(), raw - // .filter(col,'cs',…), not(col,'contains'|'matches',…), and .or() - // string/structured conditions — and only contains() has a method-level - // guard. Without this check a raw string (e.g. a free-text term ported - // from a text column, or an .or() condition value, which is always a - // string) would be storage-encrypted as a JSON SCALAR and silently match - // nothing; pre-#650 every such spelling failed loudly on capability. - assertJsonContainmentOperand(column.getName(), term.value) - } - - // Free-text (bloom) needle floor. A needle shorter than the tokenizer's - // token_length produces NO tokens, so `bf @> '{}'` holds for every row and - // the query would silently return (and the caller decrypt) the whole table - // — a fail-open over-exposure. Reject it up front, mirroring the Drizzle v3 - // adapter (matchNeedleError) so both first-party surfaces guard identically. - // JSON containment terms (searchableJson) are validated separately above. - if (queryType === 'freeTextSearch') { - const match = column.build().indexes?.match - const reason = match ? matchNeedleError(term.value, match) : undefined - if (reason) { - throw new Error( - `[supabase v3]: cannot search column "${column.getName()}": ${reason}`, - ) - } - } - - return column - } - - private encryptionFailure(message: string, cause?: EncryptionError): never { - logger.error( - `Supabase: failed to encrypt query terms for table "${this.tableName}"`, - ) - // Most callers pass the operation's own `EncryptionError`; the contract- - // violation cases (bulk length mismatch, null envelope) have none, so - // synthesize one — a broken query encryption is still an encryption failure, - // and callers branch on `error.encryptionError` regardless. - throw new EncryptionFailedError( - `Failed to encrypt query terms: ${message}`, - cause ?? { type: EncryptionErrorTypes.EncryptionError, message }, - ) - } - - /** - * Encrypt every filter operand as a full storage envelope, serialized to jsonb - * text for the PostgREST filter value. - * - * Terms are grouped by column and each group takes ONE `bulkEncrypt` crossing. - * `in(col, [a, b, c])` collects one term per element (the list must never be - * encrypted whole), so encrypting per term spent N ZeroKMS/FFI round-trips - * where one would do. `bulkEncrypt` carries a single `{table, column}` for the - * whole payload, so the grouping is mandatory, not an optimisation: one bulk - * call over a mixed-column term array would stamp one column onto every - * plaintext. Results are scattered back onto the terms' original indices, - * which is the contract `termMap` downstream relies on. - * - * Mirrors `eql/v3/drizzle/operators.ts` `encryptOperands` — same batching - * contract, same length assertion, same fallback. Kept separate because that - * one encrypts a single-column operand list and returns `SQL[]`, while this - * must group a multi-column term array and preserve positions. - */ - protected override async encryptCollectedTerms( - terms: ScalarQueryTerm[], - ): Promise { - const groups = new Map< - V3ColumnLike, - { indices: number[]; values: ScalarQueryTerm['value'][] } - >() - terms.forEach((term, index) => { - const column = this.assertTermQueryable(term) - const group = groups.get(column) ?? { indices: [], values: [] } - group.indices.push(index) - group.values.push(term.value) - groups.set(column, group) - }) - - const bulkEncrypt = this.encryptionClient.bulkEncrypt?.bind( - this.encryptionClient, - ) - // Each term becomes the `JSON.stringify`'d storage envelope — a `string`, - // which is one arm of `EncryptedQueryResult`. PostgREST cannot cast a filter - // value to the `eql_v3.query_` twins, so v3 sends full envelopes where - // v2 sends `encryptQuery` composite literals; both are `EncryptedQueryResult`. - const results = new Array(terms.length) - - await Promise.all( - Array.from(groups, async ([column, { indices, values }]) => { - const encrypted = bulkEncrypt - ? await this.bulkEncryptGroup(bulkEncrypt, column, values) - : await this.encryptGroupPerTerm(column, values) - - encrypted.forEach((envelope, i) => { - results[indices[i]] = JSON.stringify(envelope) - }) - }), - ) - - return results - } - - /** One FFI crossing for a column's whole operand list. */ - private async bulkEncryptGroup( - bulkEncrypt: NonNullable, - column: V3ColumnLike, - values: ScalarQueryTerm['value'][], - ): Promise> { - const baseOp = bulkEncrypt( - values.map((plaintext) => ({ plaintext })) as never, - { column, table: this.v3Table } as never, - ) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) - this.encryptionFailure(result.failure.message, result.failure) - - // `bulkEncrypt` is position-stable, so a length mismatch means the contract - // was violated. Truncating instead would silently widen an `in` predicate - // (or narrow a `not.in`) to whatever came back. `result.data` is now - // `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]` — not `unknown`. - const encrypted = result.data - if (encrypted.length !== values.length) { - this.encryptionFailure( - `bulk encryption returned ${encrypted.length} terms for ${values.length} values on column "${column.getName()}".`, - ) - } - return encrypted.map((term, i) => { - // `BulkEncryptedData` types the element as `Encrypted | null`. A `null` - // envelope here would be `JSON.stringify`'d to the literal string `"null"` - // and sent as the filter operand — silently matching whatever `"null"` - // encodes to rather than failing. A query term should never encrypt to a - // null envelope, so treat it as a contract violation, not a value. - if (term.data === null) { - this.encryptionFailure( - `bulk encryption returned a null envelope at position ${i} for column "${column.getName()}".`, - ) - } - return term.data - }) - } - - /** Fallback for a client that predates `bulkEncrypt`. */ - private async encryptGroupPerTerm( - column: V3ColumnLike, - values: ScalarQueryTerm['value'][], - ): Promise { - return Promise.all( - values.map(async (value) => { - const baseOp = this.encryptionClient.encrypt(value, { - column, - table: this.v3Table, - }) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) { - this.encryptionFailure(result.failure.message, result.failure) - } - return result.data - }), - ) - } - - /** Warn once per (op, column) that a `like`/`ilike` was delegated to `matches`. */ - private static readonly warnedLikeDelegation = new Set() - - /** True when `column` is one of this table's encrypted v3 columns. */ - private isEncryptedV3Column(column: string): boolean { - return Boolean(this.v3Columns[column]) - } - - /** True when `column` is an encrypted `types.Json` document column. */ - private isSearchableJsonColumn(column: string): boolean { - const builder: V3ColumnLike | undefined = this.v3Columns[column] - return Boolean(builder?.getQueryCapabilities().searchableJson) - } - - private assertPostgrestCanQueryEncryptedOperator( - method: string, - column: string, - ): void { - if (!this.queryDomainsRequired) return - throw new Error( - `[supabase v3]: ${method}() on encrypted column "${column}" is unavailable with EQL 3.0.2+: the SQL operator requires an eql_v3.query_* cast that PostgREST cannot express. Use the Drizzle or Prisma Next adapter, or a scoped SQL/RPC function.`, - ) - } - - /** - * `contains` on the v3 surface is EXACT containment: native jsonb/array `@>` - * on a plaintext column, ENCRYPTED ste_vec `@>` on a `types.Json` column (the - * sub-document operand is storage-encrypted whole; every leaf must match at - * its path — #650). On an encrypted match/search TEXT column containment is - * not the operation (that is the fuzzy `matches`), so refuse loudly rather - * than silently emit a bloom match under a name that promises exactness. - */ - override contains(column: string, value: unknown): this { - if (this.isSearchableJsonColumn(column)) { - this.assertPostgrestCanQueryEncryptedOperator('contains', column) - // Same validator the term resolver enforces — failing here just surfaces - // the error at the call site instead of at execution. - assertJsonContainmentOperand(column, value) - return super.contains(column, value) - } - if (this.isEncryptedV3Column(column)) { - throw new Error( - `[supabase v3]: contains() is native (exact) containment and does not apply to encrypted column "${column}". Use matches() for encrypted free-text search.`, - ) - } - return super.contains(column, value) - } - - /** - * `matches` is the encrypted free-text operator: fuzzy bloom-filter token - * matching, one-sided (may false-positive), NOT containment. It requires an - * encrypted match/search column; on a plaintext column, `contains` (native - * `@>`) is what the caller means — and on an encrypted JSON column, - * `contains`/`selectorEq` are (matching a document is containment, not - * free-text). Guarded here because both spellings collect the same - * `freeTextSearch` term, which the capability resolver would otherwise - * silently accept as containment of the raw string. - */ - override matches(column: string, value: unknown): this { - if (this.isSearchableJsonColumn(column)) { - throw new Error( - `[supabase v3]: matches() is encrypted free-text search and does not apply to encrypted JSON column "${column}". Use contains("${column}", subDocument) or selectorEq("${column}", path, value).`, - ) - } - if (!this.isEncryptedV3Column(column)) { - throw new Error( - `[supabase v3]: matches() is encrypted free-text search and requires an encrypted column; "${column}" is not one. Use contains() for native containment.`, - ) - } - this.assertPostgrestCanQueryEncryptedOperator('matches', column) - return super.matches(column, value) - } - - /** - * `not(col, 'contains', …)` on an encrypted TEXT column would negate a fuzzy - * bloom match under the `contains` name — the exact confusion #617 removes — - * because the base `not()` path rewrites the `contains` spelling to the `cs` - * wire operator. Reject it and steer to the `matches` spelling (or the raw - * `cs` operator, which is honest about the wire op). - * - * On an encrypted JSON column negated containment IS the honest exact - * operation (`not.cs` over ste_vec containment — {@link selectorNe} compiles - * to it), so it passes through. Plaintext columns keep native negated - * containment, and every other operator is delegated unchanged. - */ - override not(column: string, operator: string, value: unknown): this { - if ( - operator === 'contains' && - this.isEncryptedV3Column(column) && - !this.isSearchableJsonColumn(column) - ) { - throw new Error( - `[supabase v3]: not("${column}", 'contains', …) does not apply to encrypted column "${column}" — that is fuzzy free-text matching, not containment. Use not("${column}", 'matches', …) or the raw 'cs' operator.`, - ) - } - // Mirror of the matches() guard: a `matches` spelling on a JSON column - // would otherwise resolve to containment (the two share the `cs` wire op), - // silently negating an EXACT operation under a name that promises FUZZY. - if (operator === 'matches' && this.isSearchableJsonColumn(column)) { - throw new Error( - `[supabase v3]: not("${column}", 'matches', …) does not apply to encrypted JSON column "${column}" — matches() is free-text search. Use not("${column}", 'contains', subDocument) or selectorNe("${column}", path, value).`, - ) - } - return super.not(column, operator, value) - } - - /** - * Validate + reconstruct a selector needle: `('$.user.role', 'admin')` → - * `{user: {role: 'admin'}}`. Shared by {@link selectorEq}/{@link selectorNe}; - * throws with column context for a non-JSON column, an invalid path, or a - * non-scalar leaf. - */ - private selectorNeedle( - method: string, - column: string, - path: string, - value: unknown, - ): Record { - if (!this.isSearchableJsonColumn(column)) { - throw new Error( - `[supabase v3]: ${method}() requires an encrypted JSON (types.Json) column; "${column}" is not one.`, - ) - } - // Selector comparisons compare a scalar LEAF (null included in the shared - // helper's rejection; eq/ne arm — `ordering: false`; - // PostgREST cannot express selector ordering yet, see - // cipherstash/encrypt-query-language#407). - const leafReason = unsupportedLeafReason(value, false) - if (leafReason) { - throw new Error( - `[supabase v3]: ${method}("${column}", "${path}", …): ${leafReason}`, - ) - } - // Stricter than the shared helper (whose Date/bigint arms serve the Drizzle - // surface): a stored JsonDocument leaf is a JSON scalar, so a Date/bigint - // needle could never match one — reject with the serialization steer - // instead of running a query that structurally returns nothing. - if ( - typeof value !== 'string' && - typeof value !== 'number' && - typeof value !== 'boolean' - ) { - throw new Error( - `[supabase v3]: ${method}("${column}", "${path}", …): a JSON document leaf is a JSON scalar (string/number/boolean); got ${value instanceof Date ? 'a Date — pass date.toISOString() (or the stored form)' : typeof value}.`, - ) - } - let segments: string[] - try { - segments = parseSelectorSegments(path) - } catch (err) { - throw new Error( - `[supabase v3]: ${method}("${column}", …): ${err instanceof Error ? err.message : String(err)}`, - ) - } - return reconstructSelectorDocument(segments, value) - } - - /** - * Encrypted JSONPath-selector equality: matches rows whose document carries - * exactly `value` at `path`. Equality at a path IS containment of the - * path-shaped needle (`{user: {role: 'admin'}}`), so this compiles to - * {@link contains} — the ste_vec entry at the selector matches on its - * equality/ordering term. Selector ORDERING (`gt`/`lt`/…) is not expressible - * over PostgREST until the bundle grows a needle-comparison overload - * (cipherstash/encrypt-query-language#407); the Drizzle adapter's - * `ops.selector()` supports it today. - */ - selectorEq(column: string, path: string, value: unknown): this { - this.assertPostgrestCanQueryEncryptedOperator('selectorEq', column) - const needle = this.selectorNeedle('selectorEq', column, path, value) - return super.contains(column, needle) - } - - /** - * Encrypted JSONPath-selector inequality: rows whose document does NOT carry - * `value` at `path` — INCLUDING rows where the path is absent AND rows whose - * document column is SQL NULL, matching the Drizzle selector's `ne` (whose - * `OR entry IS NULL` arm covers both absence cases). A bare `not.cs` would - * drop NULL documents under three-valued logic (`NOT (NULL @> x)` is NULL), - * so this compiles to a structured OR: - * `column.is.null, column.not.cs.` — the containment condition's - * operand is encrypted through the normal or-condition term path. - */ - selectorNe(column: string, path: string, value: unknown): this { - this.assertPostgrestCanQueryEncryptedOperator('selectorNe', column) - const needle = this.selectorNeedle('selectorNe', column, path, value) - return super.or([ - { column, op: 'is', value: null }, - { column, op: 'contains', negate: true, value: needle }, - ]) - } - - /** - * `like`/`ilike` on an ENCRYPTED column are a best-effort compatibility shim, - * delegated to `matches`. EQL v3 free-text search is fuzzy bloom token - * matching, not SQL pattern matching, so the result is APPROXIMATE — matching - * is case-insensitive and one-sided (may false-positive), and anchoring is - * lost. Leading/trailing `%` are stripped; an internal `%` or any `_` cannot be - * approximated by trigram matching and throws. A plaintext column keeps real - * SQL LIKE. - */ - override like(column: string, pattern: string): this { - if (!this.isEncryptedV3Column(column)) return super.like(column, pattern) - return this.matches(column, this.likeNeedle(column, 'like', pattern)) - } - - override ilike(column: string, pattern: string): this { - if (!this.isEncryptedV3Column(column)) return super.ilike(column, pattern) - return this.matches(column, this.likeNeedle(column, 'ilike', pattern)) - } - - /** - * Reduce a SQL LIKE pattern to a fuzzy-match needle, or throw when it cannot be - * approximated. Strips surrounding `%` (prefix/suffix wildcards, which fuzzy - * matching subsumes); an internal `%` or any `_` is unapproximable. Warns once - * per (op, column) that the delegation is approximate. - */ - private likeNeedle(column: string, op: string, pattern: string): string { - const needle = pattern.replace(/^%+/, '').replace(/%+$/, '') - if (needle.includes('%') || pattern.includes('_')) { - throw new Error( - `[supabase v3]: "${op}" pattern "${pattern}" on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an internal "%" or any "_"). Use matches("${column}", term) with a literal search term.`, - ) - } - const key = `${op}:${column}` - if (!EncryptedQueryBuilderV3Impl.warnedLikeDelegation.has(key)) { - EncryptedQueryBuilderV3Impl.warnedLikeDelegation.add(key) - logger.warn( - `[supabase v3]: "${op}" on encrypted column "${column}" is delegated to matches() (fuzzy bloom token search). Results are APPROXIMATE — case-insensitive, one-sided (may false-positive), and wildcards/anchoring are not honored. Call matches() directly to make this explicit.`, - ) - } - return needle - } - - /** - * Encrypted `matches` goes through the bloom-filter `@>`, which the bundle - * declares on the domain as PostgREST's `cs`. The operand is the full storage - * envelope; `eql_v3.matches` (the SQL function) extracts the `bf` array from - * both sides. - * - * Emitted via `filter(col, 'cs', json)` rather than `q.contains(col, json)`: - * postgrest-js's `contains` re-serializes a non-string operand, and our - * operand is already `JSON.stringify`d. Plaintext `contains` (not encrypted) - * falls through to the base's native path. - */ - protected override applyContainsFilter( - q: SupabaseQueryBuilder, - column: DbName, - value: unknown, - wasEncrypted: boolean, - ): SupabaseQueryBuilder { - if (wasEncrypted) { - this.assertPostgrestCanQueryEncryptedOperator('filter', column) - return q.filter(column, 'cs', value) - } - return super.applyContainsFilter(q, column, value, wasEncrypted) - } - - /** - * `.or()` string conditions carry raw PostgREST operators, so a free-text - * condition arrives as `cs` — not a {@link FilterOp}. Resolve it through the - * same table the raw `.filter()` path uses, so `.or('amount.cs.5')` on an - * `integer_ord` column is rejected by the capability guard rather than - * silently encrypted as an equality term. A structured `{ op: 'matches' }` - * condition maps to free-text directly. - */ - protected override queryTypeForOrOp(op: FilterOp): QueryTypeName { - if (op === 'matches') return 'freeTextSearch' - // Structured conditions may carry the `contains` METHOD spelling (the wire - // token becomes `cs` in rebuildOrString). It maps to the same capability - // gate as `cs`; on a JSON column the term resolver then re-types it to - // searchableJson and validates the operand. selectorNe's IS-NULL-inclusive - // or-form relies on this arm. - if (op === 'contains') return 'freeTextSearch' - return this.queryTypeForRawOp(op) - } - - /** Rebuild `Date` values from the encrypt-config `cast_as` (date/timestamp), - * mirroring the typed v3 client's decrypt-model path. */ - protected override postprocessDecryptedRow( - row: Record, - ): Record { - // Every key an encrypted column can appear under: the keys this select - // actually produces (including caller-chosen aliases like `ts:createdAt`), - // plus the static property and DB names as a fallback for paths that record - // no select. Aliases win. Derived here from `this.selectColumns` (the row in - // hand) rather than cached from `buildSelectString`, so a reused builder can - // never postprocess a row with a previous operation's stale select map. - const keyToDb: Record = Object.assign( - Object.create(null), - this.selectColumns === null - ? undefined - : selectKeyToDbV3(this.selectColumns, this.propToDb), - ) - for (const [property, dbName] of Object.entries(this.propToDb)) { - keyToDb[property] ??= dbName - keyToDb[dbName] ??= dbName - } - - const out: Record = { ...row } - for (const [key, dbName] of Object.entries(keyToDb)) { - const castAs = this.columnSchemas[dbName]?.cast_as - if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue - const value = out[key] - if (value == null || value instanceof Date) continue - if (typeof value === 'string' || typeof value === 'number') { - out[key] = new Date(value) - } - } - return out - } -} diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index 555df627..f09314fd 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -1,34 +1,39 @@ import type { JsPlaintext } from '@cipherstash/protect-ffi' import type { AuditConfig } from '@cipherstash/stack/adapter-kit' import { - bulkModelsToEncryptedPgComposites, + DATE_LIKE_CASTS, + EncryptedV3Column, logger, - modelToEncryptedPgComposites, + matchNeedleError, + parseSelectorSegments, + reconstructSelectorDocument, + unsupportedLeafReason, } from '@cipherstash/stack/adapter-kit' import type { EncryptionClient } from '@cipherstash/stack/encryption' -import type { EncryptionError } from '@cipherstash/stack/errors' +import type { AnyV3Table } from '@cipherstash/stack/eql/v3' +import { + type EncryptionError, + EncryptionErrorTypes, +} from '@cipherstash/stack/errors' import type { LockContextInput } from '@cipherstash/stack/identity' -import type { - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' -import { EncryptedColumn } from '@cipherstash/stack/schema' +import type { ColumnSchema } from '@cipherstash/stack/schema' import type { BuildableQueryColumn, + Encrypted, EncryptedQueryResult, QueryTypeName, ScalarQueryTerm, } from '@cipherstash/stack/types' import { - addJsonbCasts, + addJsonbCastsV3, formatContainmentOperand, formatInListOperand, - getEncryptedColumnNames, isEncryptableTerm, isEncryptedColumn, mapFilterOpToQueryType, parseOrString, rebuildOrString, + selectKeyToDbV3, } from './helpers' import type { DbConflictList, @@ -57,27 +62,159 @@ import type { TransformOp, } from './types' +/** cast_as kinds that reconstruct to a JS `Date` — shared with the typed v3 + * client's decrypt-model path (see `encryption/v3.ts`). */ +const DATE_LIKE_CAST_SET = new Set(DATE_LIKE_CASTS) + +/** + * The subset of a v3 column builder the dialect relies on. Structural rather + * than the concrete class union so the runtime `instanceof EncryptedV3Column` + * gate and this type stay independent. + */ +type V3ColumnLike = { + getName(): string + getEqlType(): string + getQueryCapabilities(): { + equality: boolean + orderAndRange: boolean + freeTextSearch: boolean + /** Optional: only `public.eql_v3_json_search` (`types.Json`) carries it. */ + searchableJson?: boolean + } + build(): ColumnSchema +} + +/** + * Validate an encrypted-JSON containment operand: a NON-EMPTY plain object or a + * non-empty array. Everything else is rejected with an actionable steer: + * + * - Scalars/strings: the caller meant free-text (`matches` on a text column) or + * a selector — a raw JSON string is NOT parsed, by design (parsing would make + * `'{"a":1}'` and `{a:1}` silently different queries on other surfaces). + * - Non-plain objects (`Date`, `Map`, `RegExp`, class instances): these JSON- + * serialize to scalars or `{}` — not the sub-document the caller believes. + * - `{}` and `[]`: jsonb containment holds for EVERY document (`doc @> '{}'`), + * so an accidentally-empty needle would silently return (and decrypt) the + * whole table. The Drizzle adapter rejects the same needle for the same + * reason — the two first-party adapters must agree that this is an error. + */ +function assertJsonContainmentOperand(column: string, value: unknown): void { + const isPlainObject = + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) + if (!isPlainObject && !Array.isArray(value)) { + // Array.isArray is false on this branch by construction, so the label only + // distinguishes null / non-plain object / scalar. + const got = + value === null + ? 'null' + : typeof value === 'object' + ? (value as object).constructor?.name || 'a non-plain object' + : typeof value + throw new Error( + `[supabase v3]: encrypted JSON containment on column "${column}" takes a sub-document (plain object or array) to match, got ${got}.`, + ) + } + const empty = Array.isArray(value) + ? value.length === 0 + : Object.keys(value as object).length === 0 + if (empty) { + throw new Error( + `[supabase v3]: encrypted JSON containment on column "${column}" cannot take an empty ${Array.isArray(value) ? 'array' : 'object'} needle: it matches every row. Pass a non-empty sub-document, or omit the predicate to select all rows.`, + ) + } +} + +/** + * Reject a declared property name that is also a DIFFERENT physical column. + * + * `select('*')` expands the introspected DB names into property names, so a + * column renamed `created_at → createdAt` and a distinct plaintext column + * literally named `createdAt` both emit the token `createdAt`, which + * `addJsonbCastsV3` turns into `createdAt:created_at::jsonb` — twice. PostgREST + * returns the encrypted column under that key and the plaintext one is never + * selected, silently yielding the wrong value for a field the row type + * guarantees. + * + * Nothing downstream can disambiguate the two, and `EncryptedTable.build()`'s + * duplicate check only fires when two BUILDERS share a `getName()`. Refuse to + * construct instead. + */ +function assertNoPropertyDbNameCollision( + tableName: string, + propToDb: Record, + allColumns: string[] | null, +): void { + if (!allColumns) return + const dbNames = new Set(allColumns) + + for (const [property, dbName] of Object.entries(propToDb)) { + if (property === dbName) continue + if (!dbNames.has(property)) continue + throw new Error( + `[supabase v3]: property "${property}" on table "${tableName}" renames DB column "${dbName}", but "${property}" is also a distinct column in the database — the two collide in select('*'). Rename the property, or drop the declared rename.`, + ) + } +} + /** * A deferred query builder that wraps Supabase's query builder to automatically - * handle encryption and decryption of data. + * handle encryption and decryption of data for native EQL v3 concrete-domain + * columns (`public.*` type domains, `eql_v3` operators). * * All chained operations are recorded synchronously. When the builder is awaited, * it encrypts mutation data, adds `::jsonb` casts, batch-encrypts filter values, * executes the real Supabase query, and decrypts results. + * + * v3 columns are `EncryptedV3Column` builders and may map a JS property name to a + * different DB column name (`buildColumnKeyMap`). Filters, select casts, and + * mutations resolve property → DB name; select casts alias the DB column back to + * the property (`prop:db_name::jsonb`) so result rows keep property keys. The raw + * encrypted payload object is sent on mutations (the `public.*` domains are + * `DOMAIN … AS jsonb`), and scalar equality/range filters use the FULL storage + * envelope from `encrypt()`, serialized as jsonb text. + * + * EQL 3.0.2 removed the storage/jsonb escape hatch for free-text and JSON + * operators: those now require typed query-domain operands PostgREST cannot + * express. The factory reads the installed EQL version and this builder fails + * those operators before encryption, so a decryptable storage envelope never + * enters a GET URL. + * + * Decrypted rows additionally get `Date` reconstruction from the encrypt-config + * `cast_as`, mirroring the typed v3 client. `decryptModel`/`bulkDecryptModels` + * are generation-agnostic in `@cipherstash/stack`, so a stored EQL v2 payload + * still decrypts through this builder's read path. */ export class EncryptedQueryBuilderImpl< T extends Record = Record, > { protected tableName: string - protected schema: EncryptedTable + protected table: AnyV3Table protected encryptionClient: EncryptionClient protected supabaseClient: SupabaseClientLike protected encryptedColumnNames: string[] /** All column names for the table (encrypted + plaintext), in ordinal order, * used to expand `select('*')`. `null` when the caller supplied no column - * list (v2, or a v3 client that could not introspect). */ + * list (a v3 client that could not introspect). */ protected allColumns: string[] | null = null + /** JS property name → DB column name, for every encrypted column. */ + private propToDb: Record + /** DB column name → JS property name — the inverse of {@link propToDb}, used + * to expand `select('*')` back into property names. Null prototype: a DB + * column literally named `constructor` / `toString` would otherwise resolve + * to an inherited `Object.prototype` member and be emitted as a select token. */ + private dbToProp: Record + /** Built column schemas keyed by DB column name (for `cast_as`). */ + private columnSchemas: Record + /** Column builders keyed by BOTH property name and DB name. */ + private v3Columns: Record + /** EQL 3.0.2+ requires query-domain casts PostgREST cannot express. */ + private queryDomainsRequired: boolean + // Recorded operations protected mutation: MutationOp | null = null protected selectColumns: string | null = null @@ -99,17 +236,43 @@ export class EncryptedQueryBuilderImpl< constructor( tableName: string, - schema: EncryptedTable, + table: AnyV3Table, encryptionClient: EncryptionClient, supabaseClient: SupabaseClientLike, allColumns: string[] | null = null, + queryDomainsRequired = false, ) { this.tableName = tableName - this.schema = schema + this.table = table this.encryptionClient = encryptionClient this.supabaseClient = supabaseClient - this.encryptedColumnNames = getEncryptedColumnNames(schema) this.allColumns = allColumns + this.queryDomainsRequired = queryDomainsRequired + this.propToDb = table.buildColumnKeyMap() + this.columnSchemas = table.build().columns + + this.dbToProp = Object.create(null) as Record + for (const [property, dbName] of Object.entries(this.propToDb)) { + this.dbToProp[dbName] = property + } + + assertNoPropertyDbNameCollision(tableName, this.propToDb, allColumns) + + // Null-prototype: keyed by DB column names, and `validateTransforms` reads + // it without an own-key guard — an inherited `constructor`/`toString` would + // otherwise resolve truthy for a plaintext column of that name. + this.v3Columns = Object.create(null) as Record + for (const [property, builder] of Object.entries(table.columnBuilders)) { + if (builder instanceof EncryptedV3Column) { + const col = builder as unknown as V3ColumnLike + this.v3Columns[property] = col + this.v3Columns[col.getName()] = col + } + } + + // Filters and select strings address columns by JS property name AND by DB + // name, so recognition must cover both. + this.encryptedColumnNames = Object.keys(this.v3Columns) } // --------------------------------------------------------------------------- @@ -135,14 +298,23 @@ export class EncryptedQueryBuilderImpl< } /** - * Turn the introspected column list (DB names) into select tokens. The base - * returns them unchanged — v2 never supplies a column list, so this is dead - * for v2. The v3 dialect overrides it to emit JS property names, which is - * what makes `addJsonbCastsV3` alias a renamed column back to its property - * (`createdAt:created_at::jsonb`) rather than returning it under its DB name. + * Expand the introspected column list (DB names) into JS property names. + * + * Load-bearing for `select('*')` on a DECLARED table that renames a column. + * `addJsonbCastsV3` only emits the `prop:db_name::jsonb` alias — the thing + * that makes PostgREST return the column under its property name — when the + * token it sees is a property name. Feeding it the raw DB name instead takes + * the unaliased `dbNames.has(...)` branch, so the row comes back keyed + * `created_at` while the declared row type promises `createdAt`, silently + * yielding `undefined` for a field TypeScript guarantees. + * + * A DB column with no encrypted builder (plaintext passthrough, and every + * synthesized column, where property == DB name) maps to itself. */ protected expandAllColumns(columns: string[]): string[] { - return columns + return columns.map((dbName) => + Object.hasOwn(this.dbToProp, dbName) ? this.dbToProp[dbName] : dbName, + ) } insert( @@ -229,28 +401,79 @@ export class EncryptedQueryBuilderImpl< return this } + /** + * `like`/`ilike` on an ENCRYPTED column are a best-effort compatibility shim, + * delegated to `matches`. EQL v3 free-text search is fuzzy bloom token + * matching, not SQL pattern matching, so the result is APPROXIMATE — matching + * is case-insensitive and one-sided (may false-positive), and anchoring is + * lost. Leading/trailing `%` are stripped; an internal `%` or any `_` cannot be + * approximated by trigram matching and throws. A plaintext column keeps real + * SQL LIKE. + */ like(column: string, pattern: string): this { - this.filters.push({ op: 'like', column, value: pattern }) - return this + if (!this.isEncryptedV3Column(column)) { + this.filters.push({ op: 'like', column, value: pattern }) + return this + } + return this.matches(column, this.likeNeedle(column, 'like', pattern)) } ilike(column: string, pattern: string): this { - this.filters.push({ op: 'ilike', column, value: pattern }) - return this + if (!this.isEncryptedV3Column(column)) { + this.filters.push({ op: 'ilike', column, value: pattern }) + return this + } + return this.matches(column, this.likeNeedle(column, 'ilike', pattern)) } + /** + * `contains` on the v3 surface is EXACT containment: native jsonb/array `@>` + * on a plaintext column, ENCRYPTED ste_vec `@>` on a `types.Json` column (the + * sub-document operand is storage-encrypted whole; every leaf must match at + * its path — #650). On an encrypted match/search TEXT column containment is + * not the operation (that is the fuzzy `matches`), so refuse loudly rather + * than silently emit a bloom match under a name that promises exactness. + */ contains(column: string, value: unknown): this { + if (this.isSearchableJsonColumn(column)) { + this.assertPostgrestCanQueryEncryptedOperator('contains', column) + // Same validator the term resolver enforces — failing here just surfaces + // the error at the call site instead of at execution. + assertJsonContainmentOperand(column, value) + this.filters.push({ op: 'contains', column, value }) + return this + } + if (this.isEncryptedV3Column(column)) { + throw new Error( + `[supabase v3]: contains() is native (exact) containment and does not apply to encrypted column "${column}". Use matches() for encrypted free-text search.`, + ) + } this.filters.push({ op: 'contains', column, value }) return this } /** - * Encrypted free-text token match (v3 encrypted columns). Emits the same - * `cs`/`@>` wire operator as `contains`, but on a match-indexed encrypted - * column it is fuzzy bloom-filter token matching, not containment — see the v3 - * builder. The v3 dialect encrypts the operand as a free-text query term. + * `matches` is the encrypted free-text operator: fuzzy bloom-filter token + * matching, one-sided (may false-positive), NOT containment. It requires an + * encrypted match/search column; on a plaintext column, `contains` (native + * `@>`) is what the caller means — and on an encrypted JSON column, + * `contains`/`selectorEq` are (matching a document is containment, not + * free-text). Guarded here because both spellings collect the same + * `freeTextSearch` term, which the capability resolver would otherwise + * silently accept as containment of the raw string. */ matches(column: string, value: unknown): this { + if (this.isSearchableJsonColumn(column)) { + throw new Error( + `[supabase v3]: matches() is encrypted free-text search and does not apply to encrypted JSON column "${column}". Use contains("${column}", subDocument) or selectorEq("${column}", path, value).`, + ) + } + if (!this.isEncryptedV3Column(column)) { + throw new Error( + `[supabase v3]: matches() is encrypted free-text search and requires an encrypted column; "${column}" is not one. Use contains() for native containment.`, + ) + } + this.assertPostgrestCanQueryEncryptedOperator('matches', column) this.filters.push({ op: 'matches', column, value }) return this } @@ -270,7 +493,36 @@ export class EncryptedQueryBuilderImpl< return this } + /** + * `not(col, 'contains', …)` on an encrypted TEXT column would negate a fuzzy + * bloom match under the `contains` name — the exact confusion #617 removes — + * because the `not()` path rewrites the `contains` spelling to the `cs` wire + * operator. Reject it and steer to the `matches` spelling (or the raw `cs` + * operator, which is honest about the wire op). + * + * On an encrypted JSON column negated containment IS the honest exact + * operation (`not.cs` over ste_vec containment — {@link selectorNe} compiles + * to it), so it passes through. Plaintext columns keep native negated + * containment, and every other operator is recorded unchanged. + */ not(column: string, operator: string, value: unknown): this { + if ( + operator === 'contains' && + this.isEncryptedV3Column(column) && + !this.isSearchableJsonColumn(column) + ) { + throw new Error( + `[supabase v3]: not("${column}", 'contains', …) does not apply to encrypted column "${column}" — that is fuzzy free-text matching, not containment. Use not("${column}", 'matches', …) or the raw 'cs' operator.`, + ) + } + // Mirror of the matches() guard: a `matches` spelling on a JSON column + // would otherwise resolve to containment (the two share the `cs` wire op), + // silently negating an EXACT operation under a name that promises FUZZY. + if (operator === 'matches' && this.isSearchableJsonColumn(column)) { + throw new Error( + `[supabase v3]: not("${column}", 'matches', …) does not apply to encrypted JSON column "${column}" — matches() is free-text search. Use not("${column}", 'contains', subDocument) or selectorNe("${column}", path, value).`, + ) + } this.notFilters.push({ column, op: operator as FilterOp, value }) return this } @@ -299,6 +551,41 @@ export class EncryptedQueryBuilderImpl< return this } + /** + * Encrypted JSONPath-selector equality: matches rows whose document carries + * exactly `value` at `path`. Equality at a path IS containment of the + * path-shaped needle (`{user: {role: 'admin'}}`), so this compiles to + * {@link contains} — the ste_vec entry at the selector matches on its + * equality/ordering term. Selector ORDERING (`gt`/`lt`/…) is not expressible + * over PostgREST until the bundle grows a needle-comparison overload + * (cipherstash/encrypt-query-language#407); the Drizzle adapter's + * `ops.selector()` supports it today. + */ + selectorEq(column: string, path: string, value: unknown): this { + this.assertPostgrestCanQueryEncryptedOperator('selectorEq', column) + const needle = this.selectorNeedle('selectorEq', column, path, value) + return this.contains(column, needle) + } + + /** + * Encrypted JSONPath-selector inequality: rows whose document does NOT carry + * `value` at `path` — INCLUDING rows where the path is absent AND rows whose + * document column is SQL NULL, matching the Drizzle selector's `ne` (whose + * `OR entry IS NULL` arm covers both absence cases). A bare `not.cs` would + * drop NULL documents under three-valued logic (`NOT (NULL @> x)` is NULL), + * so this compiles to a structured OR: + * `column.is.null, column.not.cs.` — the containment condition's + * operand is encrypted through the normal or-condition term path. + */ + selectorNe(column: string, path: string, value: unknown): this { + this.assertPostgrestCanQueryEncryptedOperator('selectorNe', column) + const needle = this.selectorNeedle('selectorNe', column, path, value) + return this.or([ + { column, op: 'is', value: null }, + { column, op: 'contains', negate: true, value: needle }, + ]) + } + // --------------------------------------------------------------------------- // Transform methods (passthrough) // --------------------------------------------------------------------------- @@ -432,10 +719,10 @@ export class EncryptedQueryBuilderImpl< ) // A failure inside any of the encrypt/decrypt steps above is thrown as an - // `EncryptionFailedError` wrapping the operation's `EncryptionError` (or, in - // the v3 dialect, a synthesized one for its contract-violation cases). - // Thread it through so callers can branch on `error.encryptionError`; a plain - // PostgREST/API error is not an `EncryptionFailedError` and leaves it unset. + // `EncryptionFailedError` wrapping the operation's `EncryptionError` (or a + // synthesized one for its contract-violation cases). Thread it through so + // callers can branch on `error.encryptionError`; a plain PostgREST/API + // error is not an `EncryptionFailedError` and leaves it unset. const error: EncryptedSupabaseError = { message, encryptionError: @@ -473,7 +760,7 @@ export class EncryptedQueryBuilderImpl< if (Array.isArray(data)) { // Bulk encrypt - const baseOp = this.encryptionClient.bulkEncryptModels(data, this.schema) + const baseOp = this.encryptionClient.bulkEncryptModels(data, this.table) const op = this.lockContext ? baseOp.withLockContext(this.lockContext) : baseOp @@ -495,7 +782,7 @@ export class EncryptedQueryBuilderImpl< } // Single model - const baseOp = this.encryptionClient.encryptModel(data, this.schema) + const baseOp = this.encryptionClient.encryptModel(data, this.table) const op = this.lockContext ? baseOp.withLockContext(this.lockContext) : baseOp @@ -517,22 +804,24 @@ export class EncryptedQueryBuilderImpl< } /** - * Encode an encrypted model for the Supabase request body. v2 wraps each - * encrypted value in the `{ data: ... }` object expected by the - * `eql_v2_encrypted` composite type. The v3 dialect overrides this — native - * `eql_v3.*` domains are plain jsonb, so the raw payload is sent instead + * Encode an encrypted model for the Supabase request body. The native + * `eql_v3.*` domains are plain jsonb, so the raw encrypted payload is sent * (keyed by DB column name). */ protected transformEncryptedMutationModel( model: Record, ): Record { - return modelToEncryptedPgComposites(model) + const out: Record = Object.create(null) + for (const [key, value] of Object.entries(model)) { + out[this.dbNameFor(key)] = value + } + return out } protected transformEncryptedMutationModels( models: Record[], ): Record[] { - return bulkModelsToEncryptedPgComposites(models) + return models.map((model) => this.transformEncryptedMutationModel(model)) } // --------------------------------------------------------------------------- @@ -541,7 +830,7 @@ export class EncryptedQueryBuilderImpl< protected buildSelectString(): DbSelect | null { if (this.selectColumns === null) return null - return addJsonbCasts(this.selectColumns, this.encryptedColumnNames) + return addJsonbCastsV3(this.selectColumns, this.propToDb) } // --------------------------------------------------------------------------- @@ -566,7 +855,7 @@ export class EncryptedQueryBuilderImpl< terms.push({ value, column, - table: this.schema, + table: this.table, queryType, returnType: 'composite-literal', }) @@ -757,35 +1046,225 @@ export class EncryptedQueryBuilderImpl< } /** - * Encrypt the collected filter terms, returning one encoded value per term - * (in order). v2 batch-encrypts via `encryptQuery` with the - * `composite-literal` return type — the `("json")` string the - * `eql_v2_encrypted` composite operators compare. The v3 dialect overrides - * this to produce full-envelope jsonb operands instead. + * Encrypt every filter operand as a full storage envelope, serialized to jsonb + * text for the PostgREST filter value. + * + * Terms are grouped by column and each group takes ONE `bulkEncrypt` crossing. + * `in(col, [a, b, c])` collects one term per element (the list must never be + * encrypted whole), so encrypting per term spent N ZeroKMS/FFI round-trips + * where one would do. `bulkEncrypt` carries a single `{table, column}` for the + * whole payload, so the grouping is mandatory, not an optimisation: one bulk + * call over a mixed-column term array would stamp one column onto every + * plaintext. Results are scattered back onto the terms' original indices, + * which is the contract `termMap` downstream relies on. + * + * Mirrors `eql/v3/drizzle/operators.ts` `encryptOperands` — same batching + * contract, same length assertion, same fallback. Kept separate because that + * one encrypts a single-column operand list and returns `SQL[]`, while this + * must group a multi-column term array and preserve positions. */ protected async encryptCollectedTerms( terms: ScalarQueryTerm[], ): Promise { - // Batch encrypt all terms in one call - const baseOp = this.encryptionClient.encryptQuery(terms) + const groups = new Map< + V3ColumnLike, + { indices: number[]; values: ScalarQueryTerm['value'][] } + >() + terms.forEach((term, index) => { + const column = this.assertTermQueryable(term) + const group = groups.get(column) ?? { indices: [], values: [] } + group.indices.push(index) + group.values.push(term.value) + groups.set(column, group) + }) + + const bulkEncrypt = this.encryptionClient.bulkEncrypt?.bind( + this.encryptionClient, + ) + // Each term becomes the `JSON.stringify`'d storage envelope — a `string`, + // which is one arm of `EncryptedQueryResult`. PostgREST cannot cast a filter + // value to the `eql_v3.query_` twins, so v3 sends full envelopes, + // serialized to jsonb text. + const results = new Array(terms.length) + + await Promise.all( + Array.from(groups, async ([column, { indices, values }]) => { + const encrypted = bulkEncrypt + ? await this.bulkEncryptGroup(bulkEncrypt, column, values) + : await this.encryptGroupPerTerm(column, values) + + encrypted.forEach((envelope, i) => { + results[indices[i]] = JSON.stringify(envelope) + }) + }), + ) + + return results + } + + /** + * Validate a term's query type against its column's declared capabilities. + * Pure validation: `encrypt`/`bulkEncrypt` never receive the query type. On + * EQL 3.0.2+, free-text/JSON terms are rejected before this storage-encryption + * path can place ciphertext in a GET URL. + */ + private assertTermQueryable(term: ScalarQueryTerm): V3ColumnLike { + const column = term.column as unknown as V3ColumnLike + let queryType = term.queryType ?? 'equality' + const capabilities = column.getQueryCapabilities() + + // The `cs` wire operator is capability-overloaded: bloom free-text on a + // match/search TEXT column, encrypted ste_vec containment on a `types.Json` + // DOCUMENT column. Both arrive here as `freeTextSearch` (contains/matches/ + // raw `cs` all map there); resolve to the capability the column actually + // carries. The two are mutually exclusive by construction, so this can + // never reinterpret a real free-text column. + if ( + queryType === 'freeTextSearch' && + !capabilities.freeTextSearch && + capabilities.searchableJson + ) { + queryType = 'searchableJson' + } + + if ( + queryType !== 'equality' && + queryType !== 'orderAndRange' && + queryType !== 'freeTextSearch' && + queryType !== 'searchableJson' + ) { + throw new Error( + `[supabase v3]: query type "${queryType}" is not supported on EQL v3 columns`, + ) + } + + if (!capabilities[queryType]) { + throw new Error( + `[supabase v3]: column "${column.getName()}" (${column.getEqlType()}) does not support ${queryType} queries — declare the column with a domain that carries that capability`, + ) + } + + if (queryType === 'freeTextSearch' || queryType === 'searchableJson') { + // This is the common boundary for every spelling that collects an + // encrypted match/containment term: matches(), contains(), not(), raw + // filter(), and both forms of or(). Method-level checks provide earlier + // errors for the direct helpers, but cannot cover the raw filter paths on + // their own. + this.assertPostgrestCanQueryEncryptedOperator('filter', column.getName()) + } + + if (queryType === 'searchableJson') { + // THE single enforced operand boundary for encrypted-JSON containment. + // Terms reach this resolver from every spelling — contains(), raw + // .filter(col,'cs',…), not(col,'contains'|'matches',…), and .or() + // string/structured conditions — and only contains() has a method-level + // guard. Without this check a raw string (e.g. a free-text term ported + // from a text column, or an .or() condition value, which is always a + // string) would be storage-encrypted as a JSON SCALAR and silently match + // nothing; pre-#650 every such spelling failed loudly on capability. + assertJsonContainmentOperand(column.getName(), term.value) + } + + // Free-text (bloom) needle floor. A needle shorter than the tokenizer's + // token_length produces NO tokens, so `bf @> '{}'` holds for every row and + // the query would silently return (and the caller decrypt) the whole table + // — a fail-open over-exposure. Reject it up front, mirroring the Drizzle v3 + // adapter (matchNeedleError) so both first-party surfaces guard identically. + // JSON containment terms (searchableJson) are validated separately above. + if (queryType === 'freeTextSearch') { + const match = column.build().indexes?.match + const reason = match ? matchNeedleError(term.value, match) : undefined + if (reason) { + throw new Error( + `[supabase v3]: cannot search column "${column.getName()}": ${reason}`, + ) + } + } + + return column + } + + private encryptionFailure(message: string, cause?: EncryptionError): never { + logger.error( + `Supabase: failed to encrypt query terms for table "${this.tableName}"`, + ) + // Most callers pass the operation's own `EncryptionError`; the contract- + // violation cases (bulk length mismatch, null envelope) have none, so + // synthesize one — a broken query encryption is still an encryption failure, + // and callers branch on `error.encryptionError` regardless. + throw new EncryptionFailedError( + `Failed to encrypt query terms: ${message}`, + cause ?? { type: EncryptionErrorTypes.EncryptionError, message }, + ) + } + + /** One FFI crossing for a column's whole operand list. */ + private async bulkEncryptGroup( + bulkEncrypt: NonNullable, + column: V3ColumnLike, + values: ScalarQueryTerm['value'][], + ): Promise> { + const baseOp = bulkEncrypt( + values.map((plaintext) => ({ plaintext })) as never, + { column, table: this.table } as never, + ) const op = this.lockContext ? baseOp.withLockContext(this.lockContext) : baseOp if (this.auditConfig) op.audit(this.auditConfig) const result = await op - if (result.failure) { - logger.error( - `Supabase: failed to encrypt query terms for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to encrypt query terms: ${result.failure.message}`, - result.failure, + if (result.failure) + this.encryptionFailure(result.failure.message, result.failure) + + // `bulkEncrypt` is position-stable, so a length mismatch means the contract + // was violated. Truncating instead would silently widen an `in` predicate + // (or narrow a `not.in`) to whatever came back. `result.data` is now + // `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]` — not `unknown`. + const encrypted = result.data + if (encrypted.length !== values.length) { + this.encryptionFailure( + `bulk encryption returned ${encrypted.length} terms for ${values.length} values on column "${column.getName()}".`, ) } + return encrypted.map((term, i) => { + // `BulkEncryptedData` types the element as `Encrypted | null`. A `null` + // envelope here would be `JSON.stringify`'d to the literal string `"null"` + // and sent as the filter operand — silently matching whatever `"null"` + // encodes to rather than failing. A query term should never encrypt to a + // null envelope, so treat it as a contract violation, not a value. + if (term.data === null) { + this.encryptionFailure( + `bulk encryption returned a null envelope at position ${i} for column "${column.getName()}".`, + ) + } + return term.data + }) + } - return result.data + /** Fallback for a client that predates `bulkEncrypt`. */ + private async encryptGroupPerTerm( + column: V3ColumnLike, + values: ScalarQueryTerm['value'][], + ): Promise { + return Promise.all( + values.map(async (value) => { + const baseOp = this.encryptionClient.encrypt(value, { + column, + table: this.table, + }) + const op = this.lockContext + ? baseOp.withLockContext(this.lockContext) + : baseOp + if (this.auditConfig) op.audit(this.auditConfig) + + const result = await op + if (result.failure) { + this.encryptionFailure(result.failure.message, result.failure) + } + return result.data + }), + ) } // --------------------------------------------------------------------------- @@ -803,9 +1282,9 @@ export class EncryptedQueryBuilderImpl< * the order in which capability errors surface. * * Safe to run BEFORE encryption: `getColumnMap()`/`encryptedColumnNames` are - * keyed by both property and DB name in v3 (and property == DB name in v2), - * so column lookup resolves identically either side of the translation, and - * `tableColumns[prop]` is the very same builder object as `tableColumns[db]`. + * keyed by both property and DB name, so column lookup resolves identically + * either side of the translation, and `tableColumns[prop]` is the very same + * builder object as `tableColumns[db]`. */ protected toDbSpace(): DbQuerySpace { return { @@ -856,12 +1335,43 @@ export class EncryptedQueryBuilderImpl< } /** - * The column expression `order()` sends to PostgREST. Its own seam, separate - * from {@link filterColumnName}: v3 orders an encrypted column by a jsonb path - * into its ordering term, which must not leak into filters. + * Encrypted ordering columns sort by their `op` term, not by the envelope. + * + * `order=col->op` is the one ordering expression PostgREST can emit that + * reaches the OPE term. It must NOT leak into filters — those compare whole + * envelopes through the `eql_v3.*` operators — which is why this is its own + * seam rather than a change to `filterColumnName`. + * + * The canonical EQL form is `ORDER BY eql_v3.ord_term(col)`, which returns + * `eql_v3_internal.ope_cllw` — a domain over `bytea`, ordered by the native + * btree. PostgREST cannot call a function, so it orders the `op` term where it + * sits, inside the envelope. The two agree because the term is what + * `ord_term()` returns. + * + * `->` (jsonb) rather than `->>` (text) keeps the comparison on the typed + * value. Note this does NOT avoid the database collation: Postgres compares + * jsonb strings with `varstr_cmp` under the default collation, exactly as it + * does text. What makes the ordering collation-independent is the term itself + * — fixed-width lowercase hex (`[0-9a-f]`, 130 chars for `integer_ord`, 82 for + * `text_search`) — and every collation orders digits before letters and hex + * letters among themselves. `match-bloom`'s sibling assertion pins that shape. + * + * This runs at column-name-mapping time (`transformToDbSpace`), BEFORE + * `buildAndExecuteQuery` calls `validateTransforms`. For an encrypted column + * with no `ope` index it therefore returns a bare `dbName` here — a name that + * would sort by `jsonb_cmp` over the ciphertext if it reached PostgREST — but + * it never does: `validateTransforms` throws (with a domain-specific reason) + * before the query executes, so the bare name is only ever an intermediate + * value on a request that is about to be rejected. */ protected orderColumnName(column: string): DbName { - return this.filterColumnName(column) + const dbName = this.dbNameFor(column) + const encrypted = this.v3Columns[column] + if (!encrypted) return dbName as DbName + + return ( + this.columnSchemas[dbName]?.indexes?.ope ? `${dbName}->op` : dbName + ) as DbName } private transformToDbSpace(t: TransformOp): DbTransformOp { @@ -889,8 +1399,6 @@ export class EncryptedQueryBuilderImpl< switch (m.kind) { case 'insert': case 'upsert': - // `resolveMutationOptions` returns the SAME reference when no column - // needed renaming, which v2 relies on. return { ...m, options: this.resolveMutationOptions(m.options) } case 'update': case 'delete': @@ -1189,8 +1697,8 @@ export class EncryptedQueryBuilderImpl< } else { // Every condition names a plaintext column, whose property name IS // its DB name — nothing to map. Forward the caller's ORIGINAL string - // byte-for-byte: v2 relies on this for nested `and()` and quoted - // values that `parseOrString`/`rebuildOrString` cannot round-trip. + // byte-for-byte: relied on for nested `and()` and quoted values that + // `parseOrString`/`rebuildOrString` cannot round-trip. q = q.or(of_.original as DbFilterString, { referencedTable: of_.referencedTable, }) @@ -1233,30 +1741,31 @@ export class EncryptedQueryBuilderImpl< } // --------------------------------------------------------------------------- - // Dialect seams — every default preserves the v2 behaviour byte-for-byte. - // The v3 builder (see ./query-builder-v3) overrides these for native - // `eql_v3.*` domain columns. + // Dialect seams for native `eql_v3.*` domain columns. // --------------------------------------------------------------------------- + /** Resolve a JS property name to its DB column name. `Object.hasOwn` guards + * the inherited-member hazard described on {@link EncryptedTable.buildColumnKeyMap}. */ + private dbNameFor(name: string): string { + return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name + } + /** - * Map a filter's column name to the DB column name PostgREST must see. - * v2 schemas key columns by their DB name already, so this is the identity; - * the v3 dialect resolves a JS property name to its DB name. + * Map a filter's column name to the DB column name PostgREST must see — + * resolving a JS property name to its DB name. * * This is the ONLY place a {@link DbName} is minted. The * {@link SupabaseQueryBuilder} seam accepts nothing else, so every column * name reaching PostgREST must pass through here. */ protected filterColumnName(column: string): DbName { - return column as DbName + return this.dbNameFor(column) as DbName } /** * Resolve the column names carried by a mutation's options. `onConflict` is a * comma-separated column list, so it needs the same property→DB mapping as a - * filter. Returns the original object when nothing changed, so v2 — where - * {@link filterColumnName} is the identity — passes the caller's reference on - * untouched. + * filter. Returns the original object when nothing changed. */ protected resolveMutationOptions< O extends { onConflict?: string } | undefined, @@ -1274,26 +1783,86 @@ export class EncryptedQueryBuilderImpl< } /** - * Validate the accumulated transforms before the query is built. Called from - * inside {@link execute}'s try, so a throw surfaces as a `status: 500` error - * result (or rethrows under `throwOnError`), matching the filter-path - * capability guard. v2 imposes no constraints. + * `ORDER BY` on an OPE-backed column is supported; on every other encrypted + * column it is rejected. + * + * A bare `ORDER BY col` IS wrong. The `*_ord` domains are + * `CREATE DOMAIN … AS jsonb`, and the bundle declares no btree operator class + * on any domain — it actively lints against one (`domain_opclass`), because an + * opclass on a domain bypasses operator resolution. So the sort resolves + * through jsonb's default `jsonb_cmp` and compares the envelope's keys in + * storage order, starting at the random ciphertext `c`. No error, and a + * stable, meaningless row order. + * + * But the correct sort key is reachable without a function call. `eql_v3.ord_term` + * returns the domain's `op` term, and OPE is order-preserving by construction: + * ordering by the term reproduces the plaintext order. PostgREST cannot emit + * `ORDER BY eql_v3.ord_term(col)`, but it CAN emit a jsonb path — + * `order=col->op.asc` — which selects exactly that term. + * + * So the guard is on the ordering FLAVOUR, not on encryption: + * + * - `ope` present → order by `col->op`. Every plain `_ord` domain, plus + * `text_ord` and `text_search`. + * - `ore` present → reject. The `ob` term is an array of ORE blocks whose + * comparison needs the superuser-only opclass; a jsonb-path sort over it is + * meaningless. + * - neither → reject. Storage-only, equality-only and match-only columns + * carry no ordering term to sort by. + * + * A column absent from {@link v3Columns} is a plaintext passthrough and orders + * normally. This runtime guard is the only protection the untyped + * (no-`schemas`) surface has. */ - protected validateTransforms(): void {} + protected validateTransforms(): void { + for (const t of this.transforms) { + if (t.kind !== 'order') continue + const column = this.v3Columns[t.column] + if (!column) continue + + const indexes = this.columnSchemas[column.getName()]?.indexes + if (indexes?.ope) continue + + const reason = indexes?.ore + ? 'its ORE ordering term (`ob`) needs the superuser-only ORE operator class, which PostgREST cannot reach through a jsonb path' + : 'it carries no ordering term to sort by' + + throw new Error( + `[supabase v3]: cannot order by encrypted column "${column.getName()}" (${column.getEqlType()}) — ${reason}. ` + + 'Order by a plaintext column, or use an OPE-backed ordering domain ' + + '(`*_ord`, `text_ord`, `text_search`), or use the EQL v3 Drizzle integration.', + ) + } + } /** - * The CipherStash query type to encrypt a raw `.filter(column, operator, …)` - * term under. `operator` is an arbitrary PostgREST operator string, not a - * {@link FilterOp}, so it cannot go through `mapFilterOpToQueryType`. + * Resolve a raw `.filter()` operator to the capability it exercises. A + * supported v3 operand is a full storage envelope, so `queryType` never + * selects a narrowing — it only tells {@link assertTermQueryable} which + * capability to demand of the column. * - * v2 encrypts every raw filter as an equality term. That is wrong — a raw - * `.filter('amount', 'gte', …)` wants an ORE term — but in v2 `queryType` - * selects the `encryptQuery` narrowing, so correcting it changes the - * ciphertext on the wire. Preserved verbatim here and tracked separately; - * the v3 dialect, where `queryType` is only a capability gate, overrides it. + * Unknown operators throw rather than silently defaulting to equality, which + * would encrypt a term the column may not even be able to compare. */ - protected queryTypeForRawOp(_operator: string): QueryTypeName { - return 'equality' + protected queryTypeForRawOp(operator: string): QueryTypeName { + switch (operator) { + case 'cs': + return 'freeTextSearch' + case 'gt': + case 'gte': + case 'lt': + case 'lte': + return 'orderAndRange' + case 'eq': + case 'neq': + case 'in': + case 'is': + return 'equality' + default: + throw new Error( + `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, + ) + } } /** @@ -1317,10 +1886,9 @@ export class EncryptedQueryBuilderImpl< } /** - * Apply a `like`/`ilike` filter. v2 relies on the `~~` operator defined on - * `eql_v2_encrypted`; the v3 dialect overrides this for encrypted columns - * because the `eql_v3.*` domains expose free-text match via `@>` - * (PostgREST `cs`) rather than a LIKE operator. + * Apply a `like`/`ilike` filter. On an encrypted column `like`/`ilike` were + * rewritten to `matches` at record time, so a `like`/`ilike` pending filter + * only ever names a plaintext column, which keeps real SQL LIKE. */ protected applyPatternFilter( q: SupabaseQueryBuilder, @@ -1336,20 +1904,27 @@ export class EncryptedQueryBuilderImpl< /** * Apply a `contains` filter. On a plaintext column this is PostgREST's native - * jsonb/array containment. The v3 dialect overrides it for encrypted columns, - * where `cs` resolves to the `@>` operator the EQL bundle declares on the - * domain, backed by `eql_v3.matches` (bloom-filter containment). + * jsonb/array containment. On an encrypted column `cs` resolves to the `@>` + * operator the EQL bundle declares on the domain, backed by `eql_v3.matches` + * (bloom-filter containment) — and the operand is the full storage envelope, + * already `JSON.stringify`d, emitted via `filter(col, 'cs', json)` rather than + * `q.contains` (postgrest-js's `contains` re-serializes a non-string operand). * - * A structured operand is serialized here rather than by postgrest-js, which - * joins array elements on `,` without quoting them — so `['with,comma']` would - * reach Postgres as two elements. Scalars keep the native path. + * A structured plaintext operand is serialized here rather than by + * postgrest-js, which joins array elements on `,` without quoting them — so + * `['with,comma']` would reach Postgres as two elements. Scalars keep the + * native path. */ protected applyContainsFilter( q: SupabaseQueryBuilder, column: DbName, value: unknown, - _wasEncrypted: boolean, + wasEncrypted: boolean, ): SupabaseQueryBuilder { + if (wasEncrypted) { + this.assertPostgrestCanQueryEncryptedOperator('filter', column) + return q.filter(column, 'cs', value) + } const literal = formatContainmentOperand(value) return literal !== null ? q.filter(column, 'cs', literal) @@ -1359,10 +1934,17 @@ export class EncryptedQueryBuilderImpl< /** * The CipherStash query type for an `.or()` condition's operator on an * encrypted column. String-form conditions carry raw PostgREST operators - * (`cs`), which are not {@link FilterOp}s; the v3 dialect maps those. + * (`cs`), which are not {@link FilterOp}s. */ protected queryTypeForOrOp(op: FilterOp): QueryTypeName { - return mapFilterOpToQueryType(op) + if (op === 'matches') return 'freeTextSearch' + // Structured conditions may carry the `contains` METHOD spelling (the wire + // token becomes `cs` in rebuildOrString). It maps to the same capability + // gate as `cs`; on a JSON column the term resolver then re-types it to + // searchableJson and validates the operand. selectorNe's IS-NULL-inclusive + // or-form relies on this arm. + if (op === 'contains') return 'freeTextSearch' + return this.queryTypeForRawOp(op) } /** @@ -1375,13 +1957,41 @@ export class EncryptedQueryBuilderImpl< } /** - * Post-process a decrypted result row. The v3 dialect reconstructs `Date` - * values from the encrypt-config `cast_as`; v2 returns rows unchanged. + * Post-process a decrypted result row: rebuild `Date` values from the + * encrypt-config `cast_as` (date/timestamp), mirroring the typed v3 client's + * decrypt-model path. */ protected postprocessDecryptedRow( row: Record, ): Record { - return row + // Every key an encrypted column can appear under: the keys this select + // actually produces (including caller-chosen aliases like `ts:createdAt`), + // plus the static property and DB names as a fallback for paths that record + // no select. Aliases win. Derived here from `this.selectColumns` (the row in + // hand) rather than cached from `buildSelectString`, so a reused builder can + // never postprocess a row with a previous operation's stale select map. + const keyToDb: Record = Object.assign( + Object.create(null), + this.selectColumns === null + ? undefined + : selectKeyToDbV3(this.selectColumns, this.propToDb), + ) + for (const [property, dbName] of Object.entries(this.propToDb)) { + keyToDb[property] ??= dbName + keyToDb[dbName] ??= dbName + } + + const out: Record = { ...row } + for (const [key, dbName] of Object.entries(keyToDb)) { + const castAs = this.columnSchemas[dbName]?.cast_as + if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue + const value = out[key] + if (value == null || value instanceof Date) continue + if (typeof value === 'string' || typeof value === 'number') { + out[key] = new Date(value) + } + } + return out } // --------------------------------------------------------------------------- @@ -1523,17 +2133,105 @@ export class EncryptedQueryBuilderImpl< // --------------------------------------------------------------------------- protected getColumnMap(): Record { - const map: Record = {} - const schema = this.schema as unknown as Record + return this.v3Columns as unknown as Record + } - for (const colName of this.encryptedColumnNames) { - const col = schema[colName] - if (col instanceof EncryptedColumn) { - map[colName] = col - } + /** Warn once per (op, column) that a `like`/`ilike` was delegated to `matches`. */ + private static readonly warnedLikeDelegation = new Set() + + /** True when `column` is one of this table's encrypted v3 columns. */ + private isEncryptedV3Column(column: string): boolean { + return Boolean(this.v3Columns[column]) + } + + /** True when `column` is an encrypted `types.Json` document column. */ + private isSearchableJsonColumn(column: string): boolean { + const builder: V3ColumnLike | undefined = this.v3Columns[column] + return Boolean(builder?.getQueryCapabilities().searchableJson) + } + + private assertPostgrestCanQueryEncryptedOperator( + method: string, + column: string, + ): void { + if (!this.queryDomainsRequired) return + throw new Error( + `[supabase v3]: ${method}() on encrypted column "${column}" is unavailable with EQL 3.0.2+: the SQL operator requires an eql_v3.query_* cast that PostgREST cannot express. Use the Drizzle or Prisma Next adapter, or a scoped SQL/RPC function.`, + ) + } + + /** + * Validate + reconstruct a selector needle: `('$.user.role', 'admin')` → + * `{user: {role: 'admin'}}`. Shared by {@link selectorEq}/{@link selectorNe}; + * throws with column context for a non-JSON column, an invalid path, or a + * non-scalar leaf. + */ + private selectorNeedle( + method: string, + column: string, + path: string, + value: unknown, + ): Record { + if (!this.isSearchableJsonColumn(column)) { + throw new Error( + `[supabase v3]: ${method}() requires an encrypted JSON (types.Json) column; "${column}" is not one.`, + ) } + // Selector comparisons compare a scalar LEAF (null included in the shared + // helper's rejection; eq/ne arm — `ordering: false`; + // PostgREST cannot express selector ordering yet, see + // cipherstash/encrypt-query-language#407). + const leafReason = unsupportedLeafReason(value, false) + if (leafReason) { + throw new Error( + `[supabase v3]: ${method}("${column}", "${path}", …): ${leafReason}`, + ) + } + // Stricter than the shared helper (whose Date/bigint arms serve the Drizzle + // surface): a stored JsonDocument leaf is a JSON scalar, so a Date/bigint + // needle could never match one — reject with the serialization steer + // instead of running a query that structurally returns nothing. + if ( + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' + ) { + throw new Error( + `[supabase v3]: ${method}("${column}", "${path}", …): a JSON document leaf is a JSON scalar (string/number/boolean); got ${value instanceof Date ? 'a Date — pass date.toISOString() (or the stored form)' : typeof value}.`, + ) + } + let segments: string[] + try { + segments = parseSelectorSegments(path) + } catch (err) { + throw new Error( + `[supabase v3]: ${method}("${column}", …): ${err instanceof Error ? err.message : String(err)}`, + ) + } + return reconstructSelectorDocument(segments, value) + } - return map + /** + * Reduce a SQL LIKE pattern to a fuzzy-match needle, or throw when it cannot be + * approximated. Strips surrounding `%` (prefix/suffix wildcards, which fuzzy + * matching subsumes); an internal `%` or any `_` is unapproximable. Warns once + * per (op, column) that the delegation is approximate. + */ + private likeNeedle(column: string, op: string, pattern: string): string { + const needle = pattern.replace(/^%+/, '').replace(/%+$/, '') + if (needle.includes('%') || pattern.includes('_')) { + throw new Error( + `[supabase v3]: "${op}" pattern "${pattern}" on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an internal "%" or any "_"). Use matches("${column}", term) with a literal search term.`, + ) + } + const key = `${op}:${column}` + if (!EncryptedQueryBuilderImpl.warnedLikeDelegation.has(key)) { + EncryptedQueryBuilderImpl.warnedLikeDelegation.add(key) + logger.warn( + `[supabase v3]: "${op}" on encrypted column "${column}" is delegated to matches() (fuzzy bloom token search). Results are APPROXIMATE — case-insensitive, one-sided (may false-positive), and wildcards/anchoring are not honored. Call matches() directly to make this explicit.`, + ) + } + return needle } } From f5f07a85b90255ee6f23affac2b3786c6a0b2d5c Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 23:52:05 +1000 Subject: [PATCH 2/7] feat(stack-supabase)!: remove EQL v2 authoring surface, de-suffix v3 API encryptedSupabase is now the introspecting EQL v3 factory (was encryptedSupabaseV3); encryptedSupabaseV3 kept as a @deprecated type-identical alias. The legacy v2 encryptedSupabase({ encryptionClient, supabaseClient }).from(table, schema) wrapper and EncryptedSupabaseConfig are removed. All *V3 type exports de-suffixed to canonical names with @deprecated *V3 aliases retained. v2 decrypt is unaffected. --- packages/stack-supabase/README.md | 19 +- packages/stack-supabase/src/index.ts | 150 ++++++---------- packages/stack-supabase/src/introspect.ts | 6 +- packages/stack-supabase/src/types.ts | 207 +++++++++++++--------- 4 files changed, 197 insertions(+), 185 deletions(-) diff --git a/packages/stack-supabase/README.md b/packages/stack-supabase/README.md index 746cfc90..70aaf198 100644 --- a/packages/stack-supabase/README.md +++ b/packages/stack-supabase/README.md @@ -9,9 +9,9 @@ Depends on `@cipherstash/stack`; install both: npm install @cipherstash/stack @cipherstash/stack-supabase @supabase/supabase-js ``` -## EQL v3 (recommended) +## EQL v3 -`encryptedSupabaseV3` introspects the database at connect time (native +`encryptedSupabase` introspects the database at connect time (native `public.eql_v3_*` column domains) — no schema argument, `select('*')` support, equality/range filters, and encrypted `order()` on OPE columns. @@ -22,18 +22,23 @@ Use the Drizzle or Prisma Next adapter, or a carefully scoped direct SQL/RPC path. ```ts -import { encryptedSupabaseV3 } from '@cipherstash/stack-supabase' +import { encryptedSupabase } from '@cipherstash/stack-supabase' -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey) +const es = await encryptedSupabase(supabaseUrl, supabaseKey) await es.from('users').select('id, email').eq('email', 'a@b.com') ``` +`encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias of +`encryptedSupabase`, so existing imports keep working. + Introspection needs a direct Postgres connection (`DATABASE_URL`), so `pg` is an optional peer and the factory cannot run in a Worker or the browser. -## EQL v2 (legacy) +## EQL v2 (removed) -`encryptedSupabase` wraps a supabase-js client with a v2 schema; still shipped for -existing v2 deployments. +The legacy EQL v2 authoring wrapper — `encryptedSupabase({ encryptionClient, +supabaseClient }).from(tableName, schema)` — has been removed; this package now +authors and queries EQL v3 only. Migrate existing v2 columns to an `eql_v3_*` +domain, or pin the last release that shipped the v2 wrapper. See the `stash-supabase` agent skill and https://cipherstash.com/docs for the full guide. diff --git a/packages/stack-supabase/src/index.ts b/packages/stack-supabase/src/index.ts index ec984726..b295a587 100644 --- a/packages/stack-supabase/src/index.ts +++ b/packages/stack-supabase/src/index.ts @@ -1,78 +1,18 @@ import { Encryption } from '@cipherstash/stack' -import type { - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' import type { UnmodelledColumn } from './introspect' import { eqlRequiresQueryDomains, introspect } from './introspect' import { EncryptedQueryBuilderImpl } from './query-builder' -import { EncryptedQueryBuilderV3Impl } from './query-builder-v3' import { mergeDeclaredTables, synthesizeTables } from './schema-builder' import type { - EncryptedQueryBuilder, - EncryptedSupabaseConfig, + EncryptedQueryBuilderUntyped, EncryptedSupabaseInstance, - EncryptedSupabaseV3Instance, - EncryptedSupabaseV3Options, + EncryptedSupabaseOptions, SupabaseClientLike, - TypedEncryptedSupabaseV3Instance, + TypedEncryptedSupabaseInstance, V3Schemas, } from './types' import { verifyDeclaredSchemas } from './verify' -/** - * Create an encrypted Supabase wrapper that transparently handles encryption - * and decryption for queries on encrypted columns. - * - * @param config - Configuration containing the encryption client and Supabase client. - * @returns An object with a `from()` method that mirrors `supabase.from()` but - * auto-encrypts mutations, adds `::jsonb` casts, encrypts filter values, and - * decrypts results. - * - * @example - * ```typescript - * import { Encryption } from '@cipherstash/stack' - * import { encryptedSupabase } from '@cipherstash/stack-supabase' - * import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' - * - * const users = encryptedTable('users', { - * name: encryptedColumn('name').freeTextSearch().equality(), - * email: encryptedColumn('email').freeTextSearch().equality(), - * }) - * - * const client = await Encryption({ schemas: [users] }) - * const eSupabase = encryptedSupabase({ encryptionClient: client, supabaseClient: supabase }) - * - * // INSERT - auto-encrypts, auto-converts to PG composite - * await eSupabase.from('users', users) - * .insert({ name: 'John', email: 'john@example.com', age: 30 }) - * - * // SELECT with filter - auto-casts ::jsonb, auto-encrypts search term, auto-decrypts - * const { data } = await eSupabase.from('users', users) - * .select('id, email, name') - * .eq('email', 'john@example.com') - * ``` - */ -export function encryptedSupabase( - config: EncryptedSupabaseConfig, -): EncryptedSupabaseInstance { - const { encryptionClient, supabaseClient } = config - - return { - from = Record>( - tableName: string, - schema: EncryptedTable, - ) { - return new EncryptedQueryBuilderImpl( - tableName, - schema, - encryptionClient, - supabaseClient, - ) - }, - } -} - /** * Throw if `tableName` carries an EQL v3 column this SDK version cannot model. * @@ -101,11 +41,17 @@ function assertTableIsModelled( } /** - * Create an encrypted Supabase wrapper for **EQL v3** schemas by introspecting - * the database at connect time. Detects EQL v3 columns by their Postgres domain - * and derives each column's encryption config from it — callers no longer pass a - * schema to `from()`. Supplying `schemas` is optional: it adds compile-time - * types and verifies the declared tables against the database at construction. + * Create an encrypted Supabase wrapper over **native EQL v3 column domains** by + * introspecting the database at connect time. Detects EQL v3 columns by their + * Postgres domain and derives each column's encryption config from it — callers + * do not pass a schema to `from()`. Supplying `schemas` is optional: it adds + * compile-time types and verifies the declared tables against the database at + * construction. + * + * Encrypted data is stored as EQL v3 payloads. The generation-agnostic decrypt + * path in `@cipherstash/stack` still reads existing EQL v2 payloads, but this + * wrapper only AUTHORS EQL v3 — the legacy v2 authoring surface (a hand-written + * client-side schema and `from(tableName, schema)`) has been removed. * * Requires a Postgres connection (`options.databaseUrl` or `DATABASE_URL`) for * introspection, so it cannot run in a Worker or the browser. @@ -125,45 +71,45 @@ function assertTableIsModelled( * * @example * ```typescript - * const supabase = await encryptedSupabaseV3(supabaseUrl, supabaseKey) + * const supabase = await encryptedSupabase(supabaseUrl, supabaseKey) * await supabase.from('users').insert({ email: 'alice@example.com' }) * const { data } = await supabase.from('users').select().eq('email', 'alice@example.com') * ``` */ -export async function encryptedSupabaseV3( +export async function encryptedSupabase( supabaseUrl: string, supabaseKey: string, - options: EncryptedSupabaseV3Options & { schemas: S }, -): Promise> -export async function encryptedSupabaseV3( + options: EncryptedSupabaseOptions & { schemas: S }, +): Promise> +export async function encryptedSupabase( supabaseUrl: string, supabaseKey: string, - options?: EncryptedSupabaseV3Options, -): Promise -export async function encryptedSupabaseV3( + options?: EncryptedSupabaseOptions, +): Promise +export async function encryptedSupabase( supabaseClient: SupabaseClientLike, - options: EncryptedSupabaseV3Options & { schemas: S }, -): Promise> -export async function encryptedSupabaseV3( + options: EncryptedSupabaseOptions & { schemas: S }, +): Promise> +export async function encryptedSupabase( supabaseClient: SupabaseClientLike, - options?: EncryptedSupabaseV3Options, -): Promise -// The implementation's option params are `EncryptedSupabaseV3Options +// The implementation's option params are `EncryptedSupabaseOptions`, NOT ``. The no-schemas overloads take -// `EncryptedSupabaseV3Options` — i.e. ``, whose `schemas` is typed +// `EncryptedSupabaseOptions` — i.e. ``, whose `schemas` is typed // `undefined` — and TS2394s against an implementation param whose `schemas` is // typed `V3Schemas`. Widening the type argument to the full constraint makes // every overload relatable to the implementation signature. -export async function encryptedSupabaseV3( +export async function encryptedSupabase( clientOrUrl: SupabaseClientLike | string, - keyOrOptions?: string | EncryptedSupabaseV3Options, - maybeOptions?: EncryptedSupabaseV3Options, + keyOrOptions?: string | EncryptedSupabaseOptions, + maybeOptions?: EncryptedSupabaseOptions, ): Promise< - EncryptedSupabaseV3Instance | TypedEncryptedSupabaseV3Instance + EncryptedSupabaseInstance | TypedEncryptedSupabaseInstance > { // 1. Resolve the Supabase client + options from the overload shape. let supabaseClient: SupabaseClientLike - let options: EncryptedSupabaseV3Options + let options: EncryptedSupabaseOptions if (typeof clientOrUrl === 'string') { const url = clientOrUrl const key = keyOrOptions as string @@ -180,10 +126,10 @@ export async function encryptedSupabaseV3( if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') throw err throw new Error( - "[supabase v3]: encryptedSupabaseV3(url, key) needs '@supabase/supabase-js' " + + "[supabase v3]: encryptedSupabase(url, key) needs '@supabase/supabase-js' " + 'to build the client, but that optional peer dependency is not installed. ' + 'Install it (`npm install @supabase/supabase-js`), or pass an existing ' + - 'client: encryptedSupabaseV3(supabaseClient, options).', + 'client: encryptedSupabase(supabaseClient, options).', { cause: err }, ) } @@ -191,7 +137,7 @@ export async function encryptedSupabaseV3( } else { supabaseClient = clientOrUrl options = - (keyOrOptions as EncryptedSupabaseV3Options) ?? {} + (keyOrOptions as EncryptedSupabaseOptions) ?? {} } // 2. Resolve the database URL for introspection. @@ -273,34 +219,46 @@ export async function encryptedSupabaseV3( // ciphertext for one. Never make it optional. assertTableIsModelled(tableName, unmodelled) const allColumns = synth.allColumns.get(tableName) ?? null - return new EncryptedQueryBuilderV3Impl( + return new EncryptedQueryBuilderImpl( tableName, table, encryptionClient, supabaseClient, allColumns, queryDomainsRequired, - ) as unknown as EncryptedQueryBuilder> + ) as unknown as EncryptedQueryBuilderUntyped> }, } return instance as unknown as - | EncryptedSupabaseV3Instance - | TypedEncryptedSupabaseV3Instance + | EncryptedSupabaseInstance + | TypedEncryptedSupabaseInstance } +/** + * @deprecated Use {@link encryptedSupabase}. `encryptedSupabaseV3` is a + * type-identical alias kept for existing imports; the `V3` suffix is redundant + * now that EQL v3 is the only generation this wrapper authors. + */ +export const encryptedSupabaseV3 = encryptedSupabase + export type { EncryptedQueryBuilder, EncryptedQueryBuilderCore, + EncryptedQueryBuilderUntyped, + // Deprecated `*V3` aliases (Decision 5 — supabase keeps type-identical aliases). EncryptedQueryBuilderV3, EncryptedQueryBuilderV3Untyped, - EncryptedSupabaseConfig, EncryptedSupabaseError, EncryptedSupabaseInstance, + EncryptedSupabaseOptions, EncryptedSupabaseResponse, EncryptedSupabaseV3Instance, EncryptedSupabaseV3Options, + FilterableKeys, + FreeTextSearchableKeys, PendingOrCondition, SupabaseClientLike, + TypedEncryptedSupabaseInstance, TypedEncryptedSupabaseV3Instance, V3FilterableKeys, V3FreeTextSearchableKeys, diff --git a/packages/stack-supabase/src/introspect.ts b/packages/stack-supabase/src/introspect.ts index 18501468..d1619f35 100644 --- a/packages/stack-supabase/src/introspect.ts +++ b/packages/stack-supabase/src/introspect.ts @@ -189,10 +189,10 @@ export async function loadPg( if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') throw err throw new Error( - '[supabase v3]: encryptedSupabaseV3 introspects the database over a direct ' + + '[supabase v3]: encryptedSupabase introspects the database over a direct ' + "Postgres connection, but the optional peer dependency 'pg' is not installed. " + - 'Install it (`npm install pg`). This also means encryptedSupabaseV3 cannot run ' + - 'in a Worker or the browser — use encryptedSupabase (EQL v2) there.', + 'Install it (`npm install pg`). This also means encryptedSupabase cannot run ' + + 'in a Worker or the browser, where a direct Postgres connection is unavailable.', { cause: err }, ) } diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index 0ea50ca9..cc5378d5 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -19,32 +19,16 @@ import type { V3Schemas } from './schema-builder' // Config & instance // --------------------------------------------------------------------------- -export type EncryptedSupabaseConfig = { - encryptionClient: EncryptionClient - supabaseClient: SupabaseClientLike -} - -export interface EncryptedSupabaseInstance { - from = Record>( - tableName: string, - schema: EncryptedTable, - ): EncryptedQueryBuilder -} - -// --------------------------------------------------------------------------- -// EQL v3 config & instance -// --------------------------------------------------------------------------- - export type { V3Schemas } /** - * Options for {@link import('./index').encryptedSupabaseV3}. + * Options for {@link import('./index').encryptedSupabase}. * * @typeParam S - declared v3 tables. When present, `from()` is constrained to * the declared table names and returns typed builders, and the tables are * verified against the database at construction. */ -export type EncryptedSupabaseV3Options< +export type EncryptedSupabaseOptions< S extends V3Schemas | undefined = undefined, > = { /** Postgres connection string for introspection. Defaults to @@ -61,7 +45,7 @@ export type EncryptedSupabaseV3Options< * Declaring a `text_search` column does NOT change its match behaviour: a * declared and a synthesized `text_search` column build byte-identically, and * neither `types.TextSearch` nor `EncryptedTextSearchColumn` accepts match - * options. See the `contains` note on `EncryptedQueryBuilderV3Impl`. + * options. See the `contains` note on `EncryptedQueryBuilderImpl`. */ schemas?: S } @@ -82,7 +66,7 @@ type V3ColumnsOfTable = Table extends { * the filterable keys so a filter on one is a type error, matching the runtime * guard in the v3 term encryption path. */ -export type NonQueryableV3Keys
= { +export type NonQueryableKeys
= { [K in Extract, string>]: [ QueryTypesForColumn[K]>, ] extends [never] @@ -124,7 +108,7 @@ type NonScalarQueryableV3Keys
= { * before #650's `searchableJson` arm the two sets coincided). Plaintext * (non-schema) columns pass through untouched, exactly as in v2. */ -export type V3FilterableKeys< +export type FilterableKeys< Table extends AnyV3Table, Row extends Record, > = Exclude, NonScalarQueryableV3Keys
> @@ -150,7 +134,7 @@ type NonFreeTextSearchV3Keys
= { * table's encrypted columns that lack a match index. Plaintext columns pass * through, where `contains` is PostgREST's native jsonb/array containment. */ -export type V3FreeTextSearchableKeys< +export type FreeTextSearchableKeys< Table extends AnyV3Table, Row extends Record, > = Exclude, NonFreeTextSearchV3Keys
> @@ -159,13 +143,13 @@ export type V3FreeTextSearchableKeys< * Row keys `matches()` accepts: ONLY the table's ENCRYPTED columns that carry a * `freeTextSearch` capability (`public.eql_v3_text_match` / `text_search`). * - * Unlike {@link V3FreeTextSearchableKeys} (which additionally lets plaintext keys + * Unlike {@link FreeTextSearchableKeys} (which additionally lets plaintext keys * through, because the old `contains` also served native containment), this * excludes plaintext columns entirely — `matches()` is encrypted free-text only, * so calling it on a plaintext column is a compile error, not a runtime throw. * Derived from the encrypted-column keys minus the non-free-text ones. */ -export type V3EncryptedFreeTextKeys< +export type EncryptedFreeTextKeys< Table extends AnyV3Table, Row extends Record, > = Exclude< @@ -194,9 +178,9 @@ type NonSearchableJsonV3Keys
= { * columns whose domain is `public.eql_v3_json_search` (`types.Json`). Plaintext * columns are excluded — on those, `contains()` is PostgREST-native containment * and the selector methods do not apply. Mirror of - * {@link V3EncryptedFreeTextKeys} for the `searchableJson` capability. + * {@link EncryptedFreeTextKeys} for the `searchableJson` capability. */ -export type V3SearchableJsonKeys< +export type SearchableJsonKeys< Table extends AnyV3Table, Row extends Record, > = Exclude< @@ -265,7 +249,7 @@ type PlaintextContainsValue = V extends readonly unknown[] * excluded here to match the runtime rejection in `validateTransforms`, * rather than type-checking clean and throwing at execute time. */ -export type NonOrderableV3Keys
= { +export type NonOrderableKeys
= { [K in Extract< keyof V3ColumnsOfTable
, string @@ -285,25 +269,25 @@ export type NonOrderableV3Keys
= { * `jsonb_cmp` and compares the random ciphertext first. But the builder does not * emit a bare `ORDER BY`: for an encrypted ordering column it emits the jsonb * path `col->op`, which selects the OPE term, and OPE is order-preserving. See - * `EncryptedQueryBuilderV3Impl.orderColumnName`. + * `EncryptedQueryBuilderImpl.orderColumnName`. * * ORE-backed (`*_ord_ore`) columns are excluded at compile time by - * {@link NonOrderableV3Keys} — the builder sorts through a jsonb path that + * {@link NonOrderableKeys} — the builder sorts through a jsonb path that * cannot reach their superuser-only ORE opclass, so `.order()` on one is a type * error, matching the runtime rejection in `validateTransforms` (defense in * depth for the untyped `.order(someString)` path). */ -export type V3OrderableKeys< +export type OrderableKeys< Table extends AnyV3Table, Row extends Record, -> = Exclude, NonOrderableV3Keys
> +> = Exclude, NonOrderableKeys
> /** * Row keys that are NOT encrypted v3 columns. Used where a method's operand is a * SQL value rather than a ciphertext envelope — `is(col, true)` in particular, * since an encrypted column holds jsonb and can never be `IS TRUE`. */ -export type V3PlaintextKeys< +export type PlaintextKeys< Table extends AnyV3Table, Row extends Record, > = Exclude< @@ -313,86 +297,86 @@ export type V3PlaintextKeys< /** * The v3 builder type: the shared {@link EncryptedQueryBuilderCore} surface with - * filter methods narrowed to {@link V3FilterableKeys} and `order()` to - * {@link V3OrderableKeys}. + * filter methods narrowed to {@link FilterableKeys} and `order()` to + * {@link OrderableKeys}. * * `like`/`ilike` are absent by construction. EQL v3 free-text search is fuzzy * bloom-filter token matching (`@>`), not SQL wildcard matching — `%` is * tokenized like any other character, so a `like` pattern is a category error. * The v3 dialect of Drizzle omits them for the same reason. Use `matches`. */ -export interface EncryptedQueryBuilderV3< +export interface EncryptedQueryBuilder< Table extends AnyV3Table, Row extends Record, > extends EncryptedQueryBuilderCore< Row, - V3FilterableKeys & StringKeyOf, - EncryptedQueryBuilderV3, - V3OrderableKeys & StringKeyOf, + FilterableKeys & StringKeyOf, + EncryptedQueryBuilder, + OrderableKeys & StringKeyOf, // `is(col, true)` is legal only on a PLAINTEXT column: an encrypted column // holds a jsonb envelope, never a SQL boolean. The two axes were threaded // separately "so they can diverge", and now they have — `order()` admits // encrypted ordering columns (sorted by their `op` term), `is(col, true)` // still admits none. - V3PlaintextKeys & StringKeyOf + PlaintextKeys & StringKeyOf > { /** Encrypted free-text token match on legacy EQL versions. EQL 3.0.2+ * requires a query-domain cast PostgREST cannot express, so this fails fast. */ - matches & StringKeyOf>( + matches & StringKeyOf>( column: K, value: string, - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder /** Native (exact) jsonb/array containment (`@>`). Plaintext columns only — an * encrypted column is a compile error (use {@link matches}). A scalar plaintext * column resolves its operand to `never` (`@>` is array/jsonb only). */ - contains & StringKeyOf>( + contains & StringKeyOf>( column: K, value: PlaintextContainsValue, - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder /** Encrypted JSON containment on legacy EQL versions. EQL 3.0.2+ requires an * `eql_v3.query_json` cast PostgREST cannot express, so this fails fast before * encrypting an operand into the request URL. */ - contains & StringKeyOf>( + contains & StringKeyOf>( column: K, value: EncryptedJsonContainsValue, - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder /** Encrypted JSONPath equality on legacy EQL versions. EQL 3.0.2+ fails fast * because PostgREST cannot express the required query-domain cast. */ - selectorEq & StringKeyOf>( + selectorEq & StringKeyOf>( column: K, path: string, value: SelectorLeafValue, - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder /** Encrypted JSONPath inequality on legacy EQL versions. EQL 3.0.2+ fails * fast because PostgREST cannot express the required query-domain cast. */ - selectorNe & StringKeyOf>( + selectorNe & StringKeyOf>( column: K, path: string, value: SelectorLeafValue, - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder /** Raw legacy containment spelling. EQL 3.0.2+ rejects this before sending. */ - filter & StringKeyOf>( + filter & StringKeyOf>( column: K, operator: 'cs', value: EncryptedJsonContainsValue, - ): EncryptedQueryBuilderV3 - filter & StringKeyOf>( + ): EncryptedQueryBuilder + filter & StringKeyOf>( column: K, operator: string, value: Row[K], - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder /** Negated legacy containment spelling. EQL 3.0.2+ rejects this before * sending. */ - not & StringKeyOf>( + not & StringKeyOf>( column: K, operator: 'contains', value: EncryptedJsonContainsValue, - ): EncryptedQueryBuilderV3 - not & StringKeyOf>( + ): EncryptedQueryBuilder + not & StringKeyOf>( column: K, operator: string, value: Row[K], - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder } /** @@ -407,12 +391,12 @@ export interface EncryptedQueryBuilderV3< * union (which subsumes the encrypted column's `string`); the runtime resolves * the column and picks the encoding (and rejects the wrong-column-kind pairing). */ -export interface EncryptedQueryBuilderV3Untyped< +export interface EncryptedQueryBuilderUntyped< Row extends Record, > extends EncryptedQueryBuilderCore< Row, StringKeyOf, - EncryptedQueryBuilderV3Untyped + EncryptedQueryBuilderUntyped > { /** Fuzzy free-text token match on an encrypted match/search column. The * operand is always the string term to tokenize (never an array/object), even @@ -420,33 +404,33 @@ export interface EncryptedQueryBuilderV3Untyped< matches>( column: K, value: string, - ): EncryptedQueryBuilderV3Untyped + ): EncryptedQueryBuilderUntyped /** Native jsonb/array containment on plaintext columns. Encrypted JSON * containment fails fast on EQL 3.0.2+. */ contains>( column: K, value: NativeContainsValue, - ): EncryptedQueryBuilderV3Untyped + ): EncryptedQueryBuilderUntyped /** Legacy encrypted JSONPath equality; fails fast on EQL 3.0.2+. */ selectorEq>( column: K, path: string, value: SelectorLeafValue, - ): EncryptedQueryBuilderV3Untyped + ): EncryptedQueryBuilderUntyped /** Legacy encrypted JSONPath inequality; fails fast on EQL 3.0.2+. */ selectorNe>( column: K, path: string, value: SelectorLeafValue, - ): EncryptedQueryBuilderV3Untyped + ): EncryptedQueryBuilderUntyped } /** Untyped instance (no `schemas`): rows default to `Record` * and `from` accepts any table name. */ -export interface EncryptedSupabaseV3Instance { +export interface EncryptedSupabaseInstance { from = Record>( tableName: string, - ): EncryptedQueryBuilderV3Untyped + ): EncryptedQueryBuilderUntyped } /** Typed instance (with `schemas: S`): a declared table name resolves to the @@ -463,13 +447,13 @@ export interface EncryptedSupabaseV3Instance { * Overload order matters: the literal-constrained signature is declared first, * so TypeScript prefers it whenever the argument is a declared key and only * falls through to `string` otherwise. */ -export interface TypedEncryptedSupabaseV3Instance { +export interface TypedEncryptedSupabaseInstance { from( table: K, - ): EncryptedQueryBuilderV3> + ): EncryptedQueryBuilder> from = Record>( table: string, - ): EncryptedQueryBuilderV3Untyped + ): EncryptedQueryBuilderUntyped } // --------------------------------------------------------------------------- @@ -801,7 +785,7 @@ export interface EncryptedQueryBuilderCore< FK extends StringKeyOf, Self, /** Keys `order()` accepts. Defaults to `FK`, so the v2 surface is unchanged; - * v3 narrows it to plaintext columns (see {@link V3OrderableKeys}). */ + * v3 narrows it to plaintext columns (see {@link OrderableKeys}). */ OK extends StringKeyOf = FK, /** Keys the BOOLEAN form of `is()` accepts. Defaults to `FK`, so the v2 * surface is unchanged; v3 narrows it to plaintext columns. Distinct from @@ -905,19 +889,84 @@ export interface EncryptedQueryBuilderCore< csv(): Self abortSignal(signal: AbortSignal): Self throwOnError(): Self - /** Escape hatch: re-types the rows and drops back to the v2 builder surface. */ - returns>(): EncryptedQueryBuilder + /** Escape hatch: re-types the rows and drops back to the untyped v3 builder + * surface. */ + returns>(): EncryptedQueryBuilderUntyped /** Bind identity-aware encryption. Accepts either a plain * `{ identityClaim }` (the common form) or a `LockContext` instance. */ withLockContext(lockContext: LockContextInput): Self audit(config: AuditConfig): Self } -/** The v2 builder: free-text search via SQL wildcard matching. */ -export interface EncryptedQueryBuilder< - T extends Record = Record, - FK extends StringKeyOf = StringKeyOf, -> extends EncryptedQueryBuilderCore> { - like(column: K, pattern: string): EncryptedQueryBuilder - ilike(column: K, pattern: string): EncryptedQueryBuilder -} +// --------------------------------------------------------------------------- +// Deprecated `*V3` aliases (Decision 5 — supabase keeps type-identical aliases). +// The v3 names are now the unsuffixed canonical exports; these aliases keep +// existing `*V3` imports compiling. +// --------------------------------------------------------------------------- + +/** @deprecated Use {@link EncryptedSupabaseOptions}. */ +export type EncryptedSupabaseV3Options< + S extends V3Schemas | undefined = undefined, +> = EncryptedSupabaseOptions + +/** @deprecated Use {@link NonQueryableKeys}. */ +export type NonQueryableV3Keys
= + NonQueryableKeys
+ +/** @deprecated Use {@link FilterableKeys}. */ +export type V3FilterableKeys< + Table extends AnyV3Table, + Row extends Record, +> = FilterableKeys + +/** @deprecated Use {@link FreeTextSearchableKeys}. */ +export type V3FreeTextSearchableKeys< + Table extends AnyV3Table, + Row extends Record, +> = FreeTextSearchableKeys + +/** @deprecated Use {@link EncryptedFreeTextKeys}. */ +export type V3EncryptedFreeTextKeys< + Table extends AnyV3Table, + Row extends Record, +> = EncryptedFreeTextKeys + +/** @deprecated Use {@link SearchableJsonKeys}. */ +export type V3SearchableJsonKeys< + Table extends AnyV3Table, + Row extends Record, +> = SearchableJsonKeys + +/** @deprecated Use {@link NonOrderableKeys}. */ +export type NonOrderableV3Keys
= + NonOrderableKeys
+ +/** @deprecated Use {@link OrderableKeys}. */ +export type V3OrderableKeys< + Table extends AnyV3Table, + Row extends Record, +> = OrderableKeys + +/** @deprecated Use {@link PlaintextKeys}. */ +export type V3PlaintextKeys< + Table extends AnyV3Table, + Row extends Record, +> = PlaintextKeys + +/** @deprecated Use {@link EncryptedQueryBuilder}. */ +export type EncryptedQueryBuilderV3< + Table extends AnyV3Table, + Row extends Record, +> = EncryptedQueryBuilder + +/** @deprecated Use {@link EncryptedQueryBuilderUntyped}. */ +export type EncryptedQueryBuilderV3Untyped< + Row extends Record, +> = EncryptedQueryBuilderUntyped + +/** @deprecated Use {@link EncryptedSupabaseInstance}. */ +export type EncryptedSupabaseV3Instance = EncryptedSupabaseInstance + +/** @deprecated Use {@link TypedEncryptedSupabaseInstance}. */ +export type TypedEncryptedSupabaseV3Instance = + TypedEncryptedSupabaseInstance From 3bde0c17667acbf2e2bc907d1473ea2d0e40538c Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 23:52:05 +1000 Subject: [PATCH 3/7] test(stack-supabase): update suites for folded v3 builder + de-suffix Point tests at the folded EncryptedQueryBuilderImpl, drop the removed v2 authoring tests (v2 live suite, v2 wire-encoding block, v2 builder type test), convert the shared execute() error-threading tests to a v3 table, and add canonical-name type assertions. --- .../supabase-encryption-error.test.ts | 37 +- .../__tests__/supabase-v3-builder.test.ts | 292 +-------------- .../__tests__/supabase-v3-factory.test.ts | 2 +- .../__tests__/supabase-v3-json.test.ts | 2 +- .../__tests__/supabase-v3-matrix.test.ts | 2 +- .../__tests__/supabase-v3-select-star.test.ts | 2 +- .../__tests__/supabase-v3-wire.test.ts | 2 +- .../__tests__/supabase-v3.test-d.ts | 45 ++- .../stack-supabase/__tests__/supabase.test.ts | 335 ------------------ .../integration/wire.integration.test.ts | 2 +- 10 files changed, 51 insertions(+), 670 deletions(-) delete mode 100644 packages/stack-supabase/__tests__/supabase.test.ts diff --git a/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts index 5154b8c1..b9acdd00 100644 --- a/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts +++ b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts @@ -1,13 +1,8 @@ import type { EncryptionClient } from '@cipherstash/stack/encryption' import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { EncryptionErrorTypes } from '@cipherstash/stack/errors' -import { - encryptedColumn, - encryptedTable as encryptedTableV2, -} from '@cipherstash/stack/schema' import { describe, expect, it } from 'vitest' import { EncryptedQueryBuilderImpl } from '../src/query-builder' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' import { createMockEncryptionClient, createMockSupabase, @@ -18,18 +13,14 @@ import { /** * Regression coverage for #626: the query builder's catch block used to hardcode * `encryptionError: undefined`, so the typed `EncryptedSupabaseError.encryptionError` - * field was dead. The v2 tests pin that a genuine encryption failure now threads its - * `EncryptionError` through the shared base `execute()` catch, while a plain - * (non-encryption) throw leaves it unset. The v3 tests cover the dialect's own - * `encryptionFailure` path, which synthesizes an `EncryptionError` for its two - * query-term contract-violation cases (length mismatch, null envelope) that have - * no operation failure to wrap. + * field was dead. The `execute()` tests pin that a genuine encryption failure now + * threads its `EncryptionError` through the shared `execute()` catch, while a plain + * (non-encryption) throw leaves it unset. The `encryptionFailure` tests cover the + * dialect's own `encryptionFailure` path, which synthesizes an `EncryptionError` + * for its two query-term contract-violation cases (length mismatch, null envelope) + * that have no operation failure to wrap. */ -const usersV2 = encryptedTableV2('users', { - email: encryptedColumn('email').freeTextSearch().equality(), -}) - const usersV3 = encryptedTable('users', { email: types.TextEq('email'), }) @@ -48,7 +39,7 @@ function failingOperation(failure: { type: string; message: string }) { } describe('EncryptedSupabaseError.encryptionError (#626)', () => { - it('v2: threads the EncryptionError through on an encryption failure', async () => { + it('threads the EncryptionError through on an encryption failure', async () => { const failure = { type: EncryptionErrorTypes.EncryptionError, message: 'zerokms unreachable', @@ -62,7 +53,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { const { client: supabase } = createMockSupabase() const builder = new EncryptedQueryBuilderImpl( 'users', - usersV2, + usersV3, encryptionClient as unknown as EncryptionClient, supabase, ) @@ -77,7 +68,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { ) }) - it('v2: leaves encryptionError unset on a plain (non-encryption) error', async () => { + it('leaves encryptionError unset on a plain (non-encryption) error', async () => { const encryptionClient = createMockEncryptionClient() const { client: supabase } = createMockSupabase() // Make the underlying supabase call throw a non-encryption error: an insert @@ -88,7 +79,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { const builder = new EncryptedQueryBuilderImpl( 'users', - usersV2, + usersV3, encryptionClient, supabase, ) @@ -105,8 +96,8 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { // wrap for its contract-violation cases, so it synthesizes an EncryptionError. // Drive it directly: a two-element `in` list whose bulkEncrypt returns one // term trips the length-mismatch check. (The base `execute()` threading is - // already covered by the v2 test above; overriding `encryptModel` here would - // only re-run that shared path, not this v3-specific branch.) + // already covered by the `execute()` tests above; overriding `encryptModel` + // here would only re-run that shared path, not this v3-specific branch.) const encryptionClient = createMockEncryptionClient() as unknown as { bulkEncrypt: (...args: unknown[]) => unknown } @@ -114,7 +105,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { operation([{ data: fakeEnvelope('ada', 'email') }]) const { client: supabase } = createMockSupabase() - const { error, status } = await new EncryptedQueryBuilderV3Impl( + const { error, status } = await new EncryptedQueryBuilderImpl( 'users', usersV3, encryptionClient as unknown as EncryptionClient, @@ -144,7 +135,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { operation([{ data: null }, { data: fakeEnvelope('grace', 'email') }]) const { client: supabase } = createMockSupabase() - const { error, status } = await new EncryptedQueryBuilderV3Impl( + const { error, status } = await new EncryptedQueryBuilderImpl( 'users', usersV3, encryptionClient as unknown as EncryptionClient, diff --git a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts index b510a9ba..92e4a664 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts @@ -1,11 +1,6 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' -import { - encryptedColumn, - encryptedTable as encryptedTableV2, -} from '@cipherstash/stack/schema' import { describe, expect, it, vi } from 'vitest' -import { encryptedSupabase } from '../src/index.js' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { createMockEncryptionClient, createMockSupabase, @@ -29,11 +24,6 @@ const users = encryptedTable('users', { bio: types.TextMatch('bio'), }) -const usersV2 = encryptedTableV2('users', { - email: encryptedColumn('email').freeTextSearch().equality(), - age: encryptedColumn('age').dataType('number').equality().orderAndRange(), -}) - // DB column names as introspection would report them (id/note are plaintext). const USERS_ALL_COLUMNS = [ 'id', @@ -1176,286 +1166,6 @@ describe('encryptedSupabaseV3 wire encoding', () => { }) }) -// --------------------------------------------------------------------------- -// v2 regression — the dialect seams must leave the v2 wire encoding untouched -// --------------------------------------------------------------------------- - -describe('encryptedSupabase (v2) wire encoding is unchanged by the dialect seams', () => { - function v2Instance(resultData: unknown = []) { - const supabase = createMockSupabase(resultData) - const es = encryptedSupabase({ - encryptionClient: createMockEncryptionClient(), - supabaseClient: supabase.client, - }) - return { es, supabase } - } - - it('wraps encrypted mutation values in the { data } composite shape', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).insert({ email: 'a@b.com', note: 'x' }) - - const [insert] = supabase.callsFor('insert') - const body = insert.args[0] as Record - expect(body.email).toHaveProperty('data') - expect(isFakeEnvelope((body.email as Record).data)).toBe( - true, - ) - expect(body.note).toBe('x') - }) - - it('encodes filter terms as composite literals via encryptQuery', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id, email').eq('email', 'a@b.com') - - const [eq] = supabase.callsFor('eq') - expect(eq.args).toEqual(['email', '("a@b.com")']) - }) - - it('keeps like on encrypted columns as like', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id, email').like('email', 'a@b') - - expect(supabase.callsFor('like')).toHaveLength(1) - expect(supabase.callsFor('filter')).toHaveLength(0) - }) - - it('adds plain ::jsonb casts without aliasing', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id, email, age') - - const [select] = supabase.callsFor('select') - expect(select.args[0]).toBe('id, email::jsonb, age::jsonb') - }) - - it('passes order() column names through unchanged', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id, age').order('age') - - const [order] = supabase.callsFor('order') - expect(order.args[0]).toBe('age') - }) - - it('passes the onConflict option through by reference', async () => { - const { es, supabase } = v2Instance() - - const options = { onConflict: 'email' } - await es.from('users', usersV2).upsert({ email: 'a@b.com' }, options) - - const [upsert] = supabase.callsFor('upsert') - expect(upsert.args[1]).toBe(options) - }) - - it('passes an all-plaintext or() string through verbatim', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id').or('id.eq.1,note.eq.x') - - const [or] = supabase.callsFor('or') - expect(or.args[0]).toBe('id.eq.1,note.eq.x') - }) - - // `contains` is not a PostgREST operator in EITHER dialect — the structured - // or() path emitted `note.contains.x` on v2 too, since the base builder never - // translated the token. Fixed in `rebuildOrString`, so both dialects inherit it. - it('translates a structured or() contains to cs', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id') - .or([{ column: 'note', op: 'contains', value: 'x' }]) - - expect(supabase.callsFor('or')[0].args[0]).toBe('note.cs.x') - }) - - // ------------------------------------------------------------------------- - // Characterization tests for the paths `toDbSpace()` will rewrite. Each pins - // the correlation between the term collector (`encryptFilterValues`) and the - // applier (`applyFilters`), which agree only by array index / column name. - // ------------------------------------------------------------------------- - - it('match() encrypts encrypted keys and passes plaintext through', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id') - .match({ email: 'a@b.com', note: 'plain' }) - - const [match] = supabase.callsFor('match') - const query = match.args[0] as Record - expect(query.email).toBe('("a@b.com")') - expect(query.note).toBe('plain') - // Key order survives the Record -> entries -> Record round-trip - expect(Object.keys(query)).toEqual(['email', 'note']) - }) - - it('not() encrypts on encrypted columns and passes plaintext through', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id').not('email', 'eq', 'a@b.com') - await es.from('users', usersV2).select('id').not('note', 'eq', 'plain') - - const [encrypted, plain] = supabase.callsFor('not') - expect(encrypted.args).toEqual(['email', 'eq', '("a@b.com")']) - expect(plain.args).toEqual(['note', 'eq', 'plain']) - }) - - // The v2 composite literal `("a@b.com")` is itself quote-bearing, so it needs - // the same escaped operand as v3 — postgrest-js's `in()` would emit - // `in.("("a@b.com")")` and PostgREST would reject it. - it('in() encrypts each element and leaves plaintext arrays alone', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id').in('email', ['a@b.com', 'c@d']) - await es.from('users', usersV2).select('id').in('note', ['x', 'y']) - - const [encrypted] = supabase.callsFor('filter') - expect(encrypted.args).toEqual([ - 'email', - 'in', - '("(\\"a@b.com\\")","(\\"c@d\\")")', - ]) - - const [plain] = supabase.callsFor('in') - expect(plain.args[1]).toEqual(['x', 'y']) - }) - - it('is() leaves the value untouched on an encrypted column', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id').is('email', null) - - const [is] = supabase.callsFor('is') - expect(is.args).toEqual(['email', null]) - }) - - it('filter() encrypts the operand on an encrypted column', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id') - .filter('email', 'eq', 'a@b.com') - await es.from('users', usersV2).select('id').filter('note', 'eq', 'plain') - - const [encrypted, plain] = supabase.callsFor('filter') - expect(encrypted.args).toEqual(['email', 'eq', '("a@b.com")']) - expect(plain.args).toEqual(['note', 'eq', 'plain']) - }) - - // The single most important characterization test: a strict nonempty SUBSET - // of the or-string's conditions is encrypted, so the condition index `j` must - // agree between the two `parseOrString` calls that `toDbSpace()` collapses - // into one. - it('or() rebuilds a mixed encrypted/plaintext string, keeping each condition on its own column', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id') - .or('email.eq.a@b.com,note.eq.x') - - const [or] = supabase.callsFor('or') - const emitted = or.args[0] as string - const [emailCond, noteCond] = emitted.split(',') - expect(emailCond).toContain('email.eq.') - expect(emailCond).toContain('a@b.com') - expect(noteCond).toBe('note.eq.x') - }) - - it('keeps every filter array correlated in a combined query', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id, email, age') - .eq('email', 'a@b.com') - .not('age', 'eq', 30) - .or('email.eq.c@d,note.eq.x') - .match({ email: 'e@f.com' }) - .filter('age', 'gte', 18) - .order('age') - .limit(10) - - expect(supabase.callsFor('eq')[0].args).toEqual(['email', '("a@b.com")']) - expect(supabase.callsFor('not')[0].args).toEqual(['age', 'eq', '("30")']) - expect(supabase.callsFor('or')[0].args[0]).toContain('note.eq.x') - expect( - (supabase.callsFor('match')[0].args[0] as Record).email, - ).toBe('("e@f.com")') - expect(supabase.callsFor('filter')[0].args).toEqual([ - 'age', - 'gte', - '("18")', - ]) - expect(supabase.callsFor('order')[0].args[0]).toBe('age') - expect(supabase.callsFor('limit')[0].args[0]).toBe(10) - }) - - // Released-side regressions. `is` is a SQL predicate — PostgREST accepts only - // null/true/false — and a null operand is SQL NULL, never a value to search - // for. Only the regular `.is()` filter skipped encryption; every other - // collector encrypted whatever it was handed, emitting operands PostgREST - // rejects. - describe('is / null operands are never encrypted', () => { - it('does not encrypt not(col, is, null)', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').not('age', 'is', null) - expect(supabase.callsFor('not')[0].args).toEqual(['age', 'is', null]) - }) - - it('does not encrypt a raw filter() is operand', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').filter('age', 'is', null) - expect(supabase.callsFor('filter')[0].args).toEqual(['age', 'is', null]) - }) - - it('forwards an or() is condition unencrypted', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').or('age.is.null') - expect(supabase.callsFor('or')[0].args[0]).toBe('age.is.null') - }) - - it('does not encrypt a null eq() operand', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').eq('email', null) - expect(supabase.callsFor('eq')[0].args).toEqual(['email', null]) - }) - - it('does not encrypt a null match() value', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').match({ email: null }) - expect(supabase.callsFor('match')[0].args[0]).toEqual({ email: null }) - }) - - it('does not encrypt null elements of an in() list', async () => { - const { es, supabase } = v2Instance() - await es - .from('users', usersV2) - .select('id') - .in('email', ['a@b.com', null]) - // `null` stays a bare PostgREST `null`, never a ciphertext. - expect(supabase.callsFor('filter')[0].args).toEqual([ - 'email', - 'in', - '("(\\"a@b.com\\")",null)', - ]) - }) - - it('treats is() as a predicate even with a non-null operand', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').is('email', false) - expect(supabase.callsFor('is')[0].args).toEqual(['email', false]) - }) - }) -}) - // --------------------------------------------------------------------------- // Property → DB name collision // diff --git a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts index 622af622..b9cebc98 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SupabaseClientLike } from '../src/index.js' import { encryptedSupabaseV3 } from '../src/index.js' import type { IntrospectionData } from '../src/introspect' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' // --- Mocks ----------------------------------------------------------------- // diff --git a/packages/stack-supabase/__tests__/supabase-v3-json.test.ts b/packages/stack-supabase/__tests__/supabase-v3-json.test.ts index f829f002..bbf747f5 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-json.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-json.test.ts @@ -12,7 +12,7 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { createMockEncryptionClient, createMockSupabase, diff --git a/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts b/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts index b4306a09..6f566dbb 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts @@ -29,7 +29,7 @@ import { V3_MATRIX, } from '@cipherstash/test-kit/catalog' import { describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { createMockEncryptionClient, createMockSupabase, diff --git a/packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts b/packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts index 1fd76f22..2d7a09a3 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts @@ -1,7 +1,7 @@ import type { EncryptionClient } from '@cipherstash/stack/encryption' import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' /** * Supabase double that records the select string AND simulates the part of diff --git a/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts index 61953f6a..6b33b7c6 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts @@ -10,7 +10,7 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { createWirePostgrest } from './helpers/postgrest-wire' import { createMockEncryptionClient } from './helpers/supabase-mock' diff --git a/packages/stack-supabase/__tests__/supabase-v3.test-d.ts b/packages/stack-supabase/__tests__/supabase-v3.test-d.ts index 2bd1a572..9d0717ea 100644 --- a/packages/stack-supabase/__tests__/supabase-v3.test-d.ts +++ b/packages/stack-supabase/__tests__/supabase-v3.test-d.ts @@ -9,6 +9,7 @@ import { } from '@cipherstash/stack/schema' import { describe, expectTypeOf, it } from 'vitest' import { + type EncryptedQueryBuilder, type EncryptedQueryBuilderV3, type EncryptedSupabaseResponse, encryptedSupabase, @@ -335,21 +336,6 @@ describe('encryptedSupabaseV3 untyped surface (no schemas)', () => { builder.contains('tags', 'vip') }) - it('keeps like/ilike on the v2 builder', () => { - const v2Users = v2EncryptedTable('users', { - email: encryptedColumn('email').freeTextSearch(), - }) - const v2 = encryptedSupabase({ - encryptionClient: {} as never, - supabaseClient, - }) - const builder = v2.from<{ email: string }>('users', v2Users) - builder.like('email', '%ada%') - builder.ilike('email', '%ada%') - // @ts-expect-error — contains is the v3 dialect's method - builder.contains('email', 'ada') - }) - it('supports a no-arg select(), like supabase-js', async () => { const supabase = await encryptedSupabaseV3(supabaseClient) supabase.from('users').select() @@ -445,3 +431,32 @@ describe('withLockContext accepts the plain { identityClaim } form (not only Loc mixedBuilder.withLockContext({ claim: ['sub'] }) }) }) + +// --------------------------------------------------------------------------- +// De-suffixed canonical names (encryptedSupabase / EncryptedQueryBuilder) +// +// `encryptedSupabaseV3` and `EncryptedQueryBuilderV3` remain as type-identical +// `@deprecated` aliases (exercised throughout the tests above); these assert the +// unsuffixed names are the same surface. +// --------------------------------------------------------------------------- + +describe('canonical (unsuffixed) exports', () => { + it('encryptedSupabase is the same factory as encryptedSupabaseV3', () => { + expectTypeOf(encryptedSupabase).toEqualTypeOf(encryptedSupabaseV3) + }) + + it('EncryptedQueryBuilder is the same type as EncryptedQueryBuilderV3', () => { + expectTypeOf>().toEqualTypeOf< + EncryptedQueryBuilderV3 + >() + }) + + it('a typed builder narrows to the documented shape', async () => { + const supabase = await encryptedSupabase(supabaseClient, { + schemas: { users }, + }) + expectTypeOf(supabase.from('users')).toEqualTypeOf< + EncryptedQueryBuilder + >() + }) +}) diff --git a/packages/stack-supabase/__tests__/supabase.test.ts b/packages/stack-supabase/__tests__/supabase.test.ts deleted file mode 100644 index 1023bc38..00000000 --- a/packages/stack-supabase/__tests__/supabase.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import 'dotenv/config' - -import { Encryption } from '@cipherstash/stack' -import { encryptedColumn, encryptedTable } from '@cipherstash/stack/schema' -import { createClient } from '@supabase/supabase-js' -import postgres from 'postgres' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { encryptedSupabase } from '../src/index.js' - -// supabase.test.ts needs a live Supabase project, so the suite is skipped -// when the Supabase environment is not configured (e.g. in CI, pending a -// containerised Supabase setup). It runs locally when SUPABASE_URL, -// SUPABASE_ANON_KEY, and DATABASE_URL are all set. -const SUPABASE_ENABLED = Boolean( - process.env.SUPABASE_URL && - process.env.SUPABASE_ANON_KEY && - process.env.DATABASE_URL, -) - -const supabase = SUPABASE_ENABLED - ? createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!) - : (undefined as unknown as ReturnType) - -const table = encryptedTable('protect-ci', { - encrypted: encryptedColumn('encrypted').freeTextSearch().equality(), - age: encryptedColumn('age').dataType('number').equality(), - // orderAndRange backs the encrypted range test below — the "range filtering - // works on Supabase" claim needs a CI-covered v2 baseline, not just the - // one-off live spike (see the v3 suite's matching range test). - score: encryptedColumn('score').dataType('number').equality().orderAndRange(), -}) - -// Row type for the protect-ci table -type ProtectCiRow = { - id: number - encrypted: string - age: number - score: number - otherField: string - test_run_id: string -} - -// Unique identifier for this test run to isolate data from concurrent test runs -// This is stored in a dedicated test_run_id column to avoid polluting test data -const TEST_RUN_ID = `test-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - -// Track all inserted IDs for cleanup -const insertedIds: number[] = [] - -beforeAll(async () => { - // Idempotent fixture setup. The `protect-ci` table is shared across the - // drizzle + protect/supabase + stack/supabase integration suites; each - // suite's beforeAll runs the same CREATE TABLE so a fresh database is - // ready without manual DBA work. The schema is the union of every - // column those suites read or write. - const sql = postgres(process.env.DATABASE_URL as string, { prepare: false }) - try { - await sql` - CREATE TABLE IF NOT EXISTS "protect-ci" ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - email eql_v2_encrypted, - age eql_v2_encrypted, - score eql_v2_encrypted, - profile eql_v2_encrypted, - encrypted eql_v2_encrypted, - "otherField" TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - test_run_id TEXT - ) - ` - // Backfill any column added after the table was first created on a - // long-lived CI database. CREATE TABLE IF NOT EXISTS is a no-op on - // those, so new columns need an explicit ADD COLUMN IF NOT EXISTS. - await sql` - ALTER TABLE "protect-ci" ADD COLUMN IF NOT EXISTS "otherField" TEXT - ` - // Tell PostgREST to refresh its schema cache so the supabase-js client - // can see a freshly created table without waiting for the polling - // interval. No-op on plain Postgres (no listener bound). - await sql`NOTIFY pgrst, 'reload schema'` - } finally { - await sql.end() - } - - // Clean up any data from this specific test run (safe for concurrent runs) - const { error } = await supabase - .from('protect-ci') - .delete() - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - console.warn(`[protect]: Failed to clean up test data: ${error.message}`) - } -}, 30000) - -afterAll(async () => { - // Clean up all data from this test run - if (insertedIds.length > 0) { - const { error } = await supabase - .from('protect-ci') - .delete() - .in('id', insertedIds) - if (error) { - console.error(`[protect]: Failed to clean up test data: ${error.message}`) - } - } -}, 30000) - -describe.skipIf(!SUPABASE_ENABLED)( - 'supabase (encryptedSupabase wrapper)', - () => { - it('should insert and select encrypted data', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const plaintext = 'hello world' - - // Insert — auto-encrypts the `encrypted` column, auto-converts to PG composite - const { data: insertedData, error: insertError } = await eSupabase - .from('protect-ci', table) - .insert({ - encrypted: plaintext, - test_run_id: TEST_RUN_ID, - }) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData![0].id) - - // Select — auto-adds ::jsonb cast to `encrypted`, auto-decrypts result - const { data, error } = await eSupabase - .from('protect-ci', table) - .select('id, encrypted') - .eq('id', insertedData![0].id) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - expect(data).toHaveLength(1) - expect(data![0].encrypted).toBe(plaintext) - }, 30000) - - it('should insert and select encrypted model data', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const model = { - encrypted: 'hello world', - otherField: 'not encrypted', - } - - // Insert — auto-encrypts `encrypted`, passes `otherField` through - const { data: insertedData, error: insertError } = await eSupabase - .from('protect-ci', table) - .insert({ - ...model, - test_run_id: TEST_RUN_ID, - }) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData![0].id) - - // Select — auto-adds ::jsonb to `encrypted`, auto-decrypts - const { data, error } = await eSupabase - .from('protect-ci', table) - .select('id, encrypted, otherField') - .eq('id', insertedData![0].id) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - expect(data).toHaveLength(1) - expect({ - encrypted: data![0].encrypted, - otherField: data![0].otherField, - }).toEqual(model) - }, 30000) - - it('should insert and select bulk encrypted model data', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const models = [ - { - encrypted: 'hello world 1', - otherField: 'not encrypted 1', - }, - { - encrypted: 'hello world 2', - otherField: 'not encrypted 2', - }, - ] - - // Bulk insert — auto-encrypts all models - const { data: insertedData, error: insertError } = await eSupabase - .from('protect-ci', table) - .insert(models.map((m) => ({ ...m, test_run_id: TEST_RUN_ID }))) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(...insertedData!.map((d) => d.id)) - - // Select — auto-decrypts all results - const { data, error } = await eSupabase - .from('protect-ci', table) - .select('id, encrypted, otherField') - .in( - 'id', - insertedData!.map((d) => d.id), - ) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - expect( - data!.map((d) => ({ - encrypted: d.encrypted, - otherField: d.otherField, - })), - ).toEqual(models) - }, 30000) - - it('should insert and query encrypted number data with equality', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const testAge = 25 - const model = { - age: testAge, - otherField: 'not encrypted', - } - - // Insert — auto-encrypts `age` - const { data: insertedData, error: insertError } = await eSupabase - .from('protect-ci', table) - .insert({ - ...model, - test_run_id: TEST_RUN_ID, - }) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData![0].id) - - // Query by encrypted `age` — auto-encrypts the search term - const { data, error } = await eSupabase - .from('protect-ci', table) - .select('id, age, otherField') - .eq('age', testAge) - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - // Verify we found our specific row with encrypted age match - expect(data).toHaveLength(1) - expect(data![0].age).toBe(testAge) - }, 30000) - - // Encrypted RANGE filtering on Supabase previously had no CI coverage — - // the custom ORE operator's resolution over PostgREST rested on a one-off - // live spike. This is the v2 baseline the v3 suite's range test mirrors. - // Range needs real ORE terms (live ZeroKMS), same gating as the suite. - // Note: range FILTERING only — ORDER BY on an encrypted column is - // unsupported on Supabase (operator families need superuser). - it('should filter encrypted number data with a range (gte/lte)', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const models = [ - { score: 10, otherField: 'low' }, - { score: 25, otherField: 'mid' }, - { score: 90, otherField: 'high' }, - ] - - const { data: insertedData, error: insertError } = await eSupabase - .from('protect-ci', table) - .insert(models.map((m) => ({ ...m, test_run_id: TEST_RUN_ID }))) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(...insertedData!.map((d) => d.id)) - - const { data, error } = await eSupabase - .from('protect-ci', table) - .select('id, score, otherField') - .gte('score', 20) - .lte('score', 30) - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - expect(data).toHaveLength(1) - expect(data![0].score).toBe(25) - expect(data![0].otherField).toBe('mid') - }, 30000) - }, -) diff --git a/packages/stack-supabase/integration/wire.integration.test.ts b/packages/stack-supabase/integration/wire.integration.test.ts index 808f6941..cd10218a 100644 --- a/packages/stack-supabase/integration/wire.integration.test.ts +++ b/packages/stack-supabase/integration/wire.integration.test.ts @@ -33,7 +33,7 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { databaseUrl } from '@cipherstash/test-kit' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { makePostgrestClient, reloadSchemaCache } from './helpers/pgrest' import { narrowedQueryTerm, storageEnvelope } from './helpers/v3-envelope' From b5cdedea4eedee8e134f9652588e48f71b208780 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 23:52:05 +1000 Subject: [PATCH 4/7] docs(stack-supabase): update stash-supabase skill + changesets for v2 removal --- .../remove-eql-v2-supabase-authoring.md | 39 +++++ .changeset/remove-eql-v2-supabase-skill.md | 11 ++ skills/stash-supabase/SKILL.md | 142 ++++++++---------- 3 files changed, 110 insertions(+), 82 deletions(-) create mode 100644 .changeset/remove-eql-v2-supabase-authoring.md create mode 100644 .changeset/remove-eql-v2-supabase-skill.md diff --git a/.changeset/remove-eql-v2-supabase-authoring.md b/.changeset/remove-eql-v2-supabase-authoring.md new file mode 100644 index 00000000..50b5d633 --- /dev/null +++ b/.changeset/remove-eql-v2-supabase-authoring.md @@ -0,0 +1,39 @@ +--- +'@cipherstash/stack-supabase': major +--- + +Remove the EQL v2 authoring surface and de-suffix the v3 API to the canonical +unsuffixed names (part of the EQL v2 removal, #707). + +- **`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory** + (formerly `encryptedSupabaseV3`). `encryptedSupabaseV3` remains as a + type-identical `@deprecated` alias, so existing imports keep working. +- **The legacy v2 `encryptedSupabase({ encryptionClient, supabaseClient })` + wrapper is removed** — with it the two-argument `from(tableName, schema)` form + and the hand-written client-side v2 schema. Its `EncryptedSupabaseConfig` and + the v2 `EncryptedSupabaseInstance`/`EncryptedQueryBuilder` type shapes are gone; + the unsuffixed type names now denote the v3 surface. +- **The `*V3` type exports are de-suffixed** to their canonical names — + `EncryptedSupabaseV3Options` → `EncryptedSupabaseOptions`, + `EncryptedSupabaseV3Instance` → `EncryptedSupabaseInstance`, + `TypedEncryptedSupabaseV3Instance` → `TypedEncryptedSupabaseInstance`, + `EncryptedQueryBuilderV3` → `EncryptedQueryBuilder`, + `EncryptedQueryBuilderV3Untyped` → `EncryptedQueryBuilderUntyped`, + `V3FilterableKeys` → `FilterableKeys`, `V3OrderableKeys` → `OrderableKeys`, and + the rest of the `*V3` key-helper types. Each keeps a type-identical + `@deprecated` `*V3` alias. + +**Not affected: reading existing data.** Only the v2 *authoring/emission* surface +is removed. Decryption in `@cipherstash/stack` is generation-agnostic, so rows +written as EQL v2 payloads still decrypt through the wrapper's read path. + +Internally the v3 query builder (`query-builder-v3.ts`) was folded into the base +`EncryptedQueryBuilderImpl`, which is now natively EQL v3; no runtime behaviour or +wire encoding changed. + +**Migration:** rename `encryptedSupabaseV3` → `encryptedSupabase` (or keep using +the alias). If you still use the v2 `encryptedSupabase({ encryptionClient, +supabaseClient }).from(table, schema)` wrapper, migrate the table to an +`eql_v3_*` column domain and switch to the introspecting factory — +`await encryptedSupabase(supabaseUrl, supabaseKey)` — see the `stash-supabase` +skill and https://cipherstash.com/docs. diff --git a/.changeset/remove-eql-v2-supabase-skill.md b/.changeset/remove-eql-v2-supabase-skill.md new file mode 100644 index 00000000..c4c33ded --- /dev/null +++ b/.changeset/remove-eql-v2-supabase-skill.md @@ -0,0 +1,11 @@ +--- +'stash': patch +--- + +Update the bundled `stash-supabase` agent skill for the EQL v2 removal (#707): +`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory (with +`encryptedSupabaseV3` kept as a `@deprecated` alias), and the legacy v2 +`encryptedSupabase({ encryptionClient, supabaseClient })` authoring wrapper has +been removed. The skill's examples, exported-type list, and migration/cutover +guidance are corrected accordingly. Skills ship inside the `stash` tarball, so +the stale v2 guidance would otherwise land in a user's project. diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index a0ebe696..b93d2dae 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -1,18 +1,21 @@ --- name: stash-supabase -description: Integrate CipherStash encryption with Supabase using @cipherstash/stack-supabase. Covers the encryptedSupabaseV3 wrapper over native EQL v3 column domains, transparent encryption/decryption on insert/update/select, encrypted scalar filters (eq, gt/gte/lt/lte, in, or), ordering on encrypted columns, EQL 3.0.2 PostgREST query-domain limitations, identity-aware encryption, and the complete query builder API. Use when adding encryption to a Supabase project, querying encrypted columns, or building secure Supabase applications. +description: Integrate CipherStash encryption with Supabase using @cipherstash/stack-supabase. Covers the encryptedSupabase wrapper over native EQL v3 column domains, transparent encryption/decryption on insert/update/select, encrypted scalar filters (eq, gt/gte/lt/lte, in, or), ordering on encrypted columns, EQL 3.0.2 PostgREST query-domain limitations, identity-aware encryption, and the complete query builder API. Use when adding encryption to a Supabase project, querying encrypted columns, or building secure Supabase applications. --- # CipherStash Stack - Supabase Integration Guide for integrating CipherStash field-level encryption with Supabase using -the `encryptedSupabaseV3` wrapper over native EQL v3 column domains. The +the `encryptedSupabase` wrapper over native EQL v3 column domains. The wrapper provides transparent encryption on mutations and decryption on selects, with support for equality, range, and ordering. -A legacy EQL v2 wrapper (`encryptedSupabase`) still ships for existing -deployments — see "Legacy: EQL v2" at the end. New projects should use -`encryptedSupabaseV3`. +> **Naming note.** `encryptedSupabase` is the current EQL v3 factory. +> `encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias, so +> existing imports keep working — prefer `encryptedSupabase` in new code. The +> old EQL v2 authoring wrapper (`encryptedSupabase({ encryptionClient, +> supabaseClient }).from(table, schema)`) has been **removed** — see +> "Legacy: EQL v2" at the end. ## When to Use This Skill @@ -102,17 +105,17 @@ SQL-standard type names (`integer`, `smallint`, `real`, `double`, `boolean`, ### 3. Initialize the wrapper ```typescript -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" // Introspects the database via options.databaseUrl or DATABASE_URL -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey) -// or wrap an existing client: await encryptedSupabaseV3(supabaseClient, options) +const es = await encryptedSupabase(supabaseUrl, supabaseKey) +// or wrap an existing client: await encryptedSupabase(supabaseClient, options) await es.from("users").insert({ email: "a@b.com", amount: 30 }) await es.from("users").select("id, email, amount").eq("email", "a@b.com") ``` -`encryptedSupabaseV3` **introspects the database at connect time**: it +`encryptedSupabase` **introspects the database at connect time**: it detects EQL v3 columns by their Postgres domain, derives each column's encryption config from the domain, and builds the encryption client internally — there is no client-side schema to hand-maintain. Introspection @@ -133,7 +136,7 @@ tables against the database at construction: ```typescript import { encryptedTable, types } from "@cipherstash/stack/eql/v3" -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" const users = encryptedTable("users", { email: types.TextSearch("email"), // public.eql_v3_text_search — eq + range + free-text @@ -141,7 +144,7 @@ const users = encryptedTable("users", { joined: types.TimestampOrd("joined_at") // public.eql_v3_timestamp_ord — eq + range, decrypts to Date }) -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey, { +const es = await encryptedSupabase(supabaseUrl, supabaseKey, { schemas: { users }, }) @@ -411,7 +414,7 @@ third-party OIDC JWT (Clerk, Supabase, Auth0, ...) with ```typescript import { OidcFederationStrategy } from "@cipherstash/stack" -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" const strategy = OidcFederationStrategy.create( process.env.CS_WORKSPACE_CRN!, @@ -419,7 +422,7 @@ const strategy = OidcFederationStrategy.create( ) if (strategy.failure) throw new Error(strategy.failure.error.message) -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey, { +const es = await encryptedSupabase(supabaseUrl, supabaseKey, { config: { authStrategy: strategy.data }, }) ``` @@ -470,7 +473,7 @@ const { data, error } = await es ```typescript import { encryptedTable, types } from "@cipherstash/stack/eql/v3" -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" // Optional declared schema — compile-time types. Introspection alone // (no `schemas`) also works. @@ -479,7 +482,7 @@ const users = encryptedTable("users", { amount: types.IntegerOrd("amount"), }) -const es = await encryptedSupabaseV3( +const es = await encryptedSupabase( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, { schemas: { users } }, // databaseUrl defaults to DATABASE_URL @@ -549,9 +552,15 @@ at all — only `.is(column, null)`. `@cipherstash/stack-supabase` also exports the following types: -- `EncryptedSupabaseV3Options`, `EncryptedSupabaseV3Instance`, `TypedEncryptedSupabaseV3Instance`, `EncryptedQueryBuilderV3`, `EncryptedQueryBuilderV3Untyped`, `V3Schemas` -- `SupabaseClientLike` -- `EncryptedSupabaseConfig`, `EncryptedSupabaseInstance`, `EncryptedQueryBuilder`, `PendingOrCondition` (legacy EQL v2) +- `EncryptedSupabaseOptions`, `EncryptedSupabaseInstance`, `TypedEncryptedSupabaseInstance`, `EncryptedQueryBuilder`, `EncryptedQueryBuilderUntyped`, `EncryptedQueryBuilderCore`, `V3Schemas` +- `FilterableKeys`, `FreeTextSearchableKeys` +- `EncryptedSupabaseResponse`, `EncryptedSupabaseError`, `PendingOrCondition`, `SupabaseClientLike` + +Each `*V3`-suffixed name from earlier releases (`EncryptedSupabaseV3Options`, +`EncryptedSupabaseV3Instance`, `TypedEncryptedSupabaseV3Instance`, +`EncryptedQueryBuilderV3`, `EncryptedQueryBuilderV3Untyped`, `V3FilterableKeys`, +`V3FreeTextSearchableKeys`) is still exported as a `@deprecated`, type-identical +alias of its unsuffixed counterpart above. ## Migrating an Existing Column to Encrypted @@ -604,7 +613,7 @@ ALTER TABLE users Apply with `supabase db reset` locally or `supabase migration up` against the remote project. -No client-side schema change is required — `encryptedSupabaseV3` introspects +No client-side schema change is required — `encryptedSupabase` introspects the new column's domain at the next client startup. If you use declared `schemas`, add the column so it is typed: @@ -633,7 +642,7 @@ Find **every** code path that writes to `users.email` and update it to also writ ```typescript // src/db/users.ts -import { es } from './clients' // encryptedSupabaseV3 instance +import { es } from './clients' // encryptedSupabase instance export async function insertUser(email: string) { return es.from('users').insert({ @@ -682,60 +691,20 @@ If something goes wrong (e.g. you discover the dual-write code wasn't actually l **EQL v3 (the schema above): there is no cut-over.** The encrypted column keeps its own name — point your application at `email_encrypted` through the -`encryptedSupabaseV3` wrapper, deploy, verify reads decrypt correctly, then skip +`encryptedSupabase` wrapper, deploy, verify reads decrypt correctly, then skip ahead to the drop step. Running `stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). -The rest of this subsection is the **EQL v2** path (an `eql_v2_encrypted` twin -queried through the legacy `encryptedSupabase` wrapper), kept for existing v2 -deployments. - -First, if you use declared `schemas`, update them to the post-cutover shape — the encrypted column will live under the original column name: - -```typescript -// src/encryption/schema.ts (post-cutover) -export const users = encryptedTable('users', { - email: types.TextSearch('email'), -}) -``` - -(Without declared schemas, introspection picks up the renamed column at the next client startup.) - -> **Known gap (EQL v2, SDK-only users):** `stash encrypt cutover` requires a pending EQL configuration, which is set by `stash db push`. If you're using the SDK without Proxy, you'll hit a "No pending EQL configuration" error from cutover. **Workaround:** run `stash db push` once before `stash encrypt cutover`. EQL v3 columns never hit this — cut-over doesn't apply to them. -> -> **Using CipherStash Proxy?** Re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix): -> -> ```bash -> stash db push -> # → writes the new config as `pending`. Active config (still pointing at -> # `email_encrypted`) keeps serving while we complete the cutover. -> ``` - -Now run the cutover: - -```bash -stash encrypt cutover --table users --column email -``` - -Inside one transaction it: (1) renames `email` → `email_plaintext` and `email_encrypted` → `email`, (2) promotes the pending EQL config to `active` (and the prior active to `inactive`), (3) records a `cut_over` event in `cs_migrations`. - -App code that does `select('email')` now returns ciphertext that must be decrypted via the `encryptedSupabaseV3` wrapper. **This is the moment that breaks read paths if they aren't going through the wrapper.** - -Update read paths to use the wrapper: - -```typescript -// Before -const { data } = await supabase.from('users').select('email').eq('id', id).single() - -// After — the wrapper decrypts transparently -const { data } = await es.from('users').select('email').eq('id', id).single() -``` - -For supported scalar queries that filter on `email`, the wrapper handles the -encrypted operators internally — calls such as `.eq()` and `.gte()` keep the -same shape, but values are encrypted before reaching the database. See -`## Query Filters` above for the EQL 3.0.2 PostgREST limitations. +> **EQL v2 cutover (`stash encrypt cutover`) via this SDK is no longer +> supported.** `@cipherstash/stack-supabase` authors and queries EQL v3 only — +> the v2 `encryptedSupabase({ encryptionClient, supabaseClient })` wrapper that +> read the post-cutover `eql_v2_encrypted` column has been removed. The CLI +> lifecycle commands still auto-detect a v2 column, but the SDK read path for one +> is gone. If you have an in-flight v2 cutover, either pin the last +> `@cipherstash/stack-supabase` release that shipped the v2 wrapper to finish it, +> or (recommended) create an `eql_v3_*` twin and run the v3 rollout above. New +> encryption should always target an `eql_v3_*` domain. #### Drop: remove the plaintext column @@ -764,15 +733,24 @@ All three are read-only. ## Legacy: EQL v2 -Earlier versions of this integration stored ciphertext in `jsonb` / -composite `eql_v2_encrypted` columns (enabled via `CREATE EXTENSION eql_v2` -or the v2 EQL bundle) and queried them through the `encryptedSupabase({ -supabaseClient, encryptionClient })` factory, which takes a hand-written -client-side schema and a two-argument `from(tableName, schema)`. That surface -still ships in `@cipherstash/stack-supabase` and is unchanged — keep using it -for existing v2 deployments — but it is not the recommended path for new -projects: use `encryptedSupabaseV3`. The CLI rollout tooling (`stash encrypt -backfill` / `cutover` / `drop`) supports both generations and auto-detects which -one a column uses, so a v2 twin is no longer needed to get CLI-managed -backfill — see the EQL version note in the migration section above. For the v2 -wrapper's full API and semantics, see the docs at https://cipherstash.com/docs. +Earlier versions of this integration stored ciphertext in composite +`eql_v2_encrypted` columns (enabled via `CREATE EXTENSION eql_v2` or the v2 EQL +bundle) and both wrote and read them through the `encryptedSupabase({ +supabaseClient, encryptionClient })` factory — a hand-written client-side schema +and a two-argument `from(tableName, schema)`. + +**That v2 wrapper has been removed.** `@cipherstash/stack-supabase` now authors +and queries EQL v3 only, via the introspecting `encryptedSupabase(url, key)` / +`encryptedSupabase(client, options)` factory described above. There is no longer +a code path in this package that emits or reads `eql_v2_encrypted` columns. + +Existing v2 deployments have two options: + +- **Migrate to EQL v3** (recommended): add an `eql_v3_*` twin column and run the + rollout in "Migrating an Existing Column to Encrypted" above. +- **Stay on v2 for now:** pin the last `@cipherstash/stack-supabase` release that + shipped the v2 wrapper. The CLI rollout tooling (`stash encrypt backfill` / + `cutover` / `drop`) still auto-detects a column's EQL generation. + +For the removed v2 wrapper's historical API and semantics, see the docs at +https://cipherstash.com/docs. From 6e167d3504af70787e09e799ab1d5f5514599da8 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 11:26:16 +1000 Subject: [PATCH 5/7] docs(stack-supabase): clarify v2 decrypt path in changeset v2 read via this adapter is intentionally removed; v2 ciphertext still decrypts through the core @cipherstash/stack client. Mixed-generation handling is customer-side (install both), per #707 out-of-scope stance. --- .changeset/remove-eql-v2-supabase-authoring.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.changeset/remove-eql-v2-supabase-authoring.md b/.changeset/remove-eql-v2-supabase-authoring.md index 50b5d633..ad783dc5 100644 --- a/.changeset/remove-eql-v2-supabase-authoring.md +++ b/.changeset/remove-eql-v2-supabase-authoring.md @@ -23,9 +23,15 @@ unsuffixed names (part of the EQL v2 removal, #707). the rest of the `*V3` key-helper types. Each keeps a type-identical `@deprecated` `*V3` alias. -**Not affected: reading existing data.** Only the v2 *authoring/emission* surface -is removed. Decryption in `@cipherstash/stack` is generation-agnostic, so rows -written as EQL v2 payloads still decrypt through the wrapper's read path. +**Reading existing v2 data.** Only the v2 *authoring/emission* surface is removed +— no v2 ciphertext is stranded. Decryption in `@cipherstash/stack` is +generation-agnostic, so EQL v2 payloads still decrypt through the core client +(`decrypt` / `decryptModel`). This adapter, however, is now EQL v3 only and will +not auto-read an `eql_v2_encrypted` column: to read legacy v2 data during +migration, decrypt fetched rows with `@cipherstash/stack` directly, or run a +v2-configured setup alongside the v3 one and route per column. Mixed-generation +handling is a customer-side concern (install both and handle it explicitly), not +adapter auto-detection. Internally the v3 query builder (`query-builder-v3.ts`) was folded into the base `EncryptedQueryBuilderImpl`, which is now natively EQL v3; no runtime behaviour or From 6af89559c31724568f57673d895e689682df87bb Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 11:58:31 +1000 Subject: [PATCH 6/7] refactor(stack-supabase): split query-builder monolith, ratchet FTA cap 91 -> 71 The EQL v2 removal folded `query-builder-v3.ts` into `query-builder.ts` (2b4e2e92). FTA penalises size superlinearly, so two files that each passed the complexity gate became one that did not: 90.88 + 73.95 -> 105.77, against a cap of 91. That failed the "Analyze v3 complexity" check on #769. `fta-v3.yml` is explicit that the cap is "a ratchet, not an aspiration", so decompose rather than raise it. The 2331-line class becomes a pipeline: column-map.ts ColumnMap - name + capability resolution, the concern every stage needed (was six correlated fields) query-encrypt.ts filter-operand terms: collect, validate, batch-encrypt query-mutation.ts row-data encryption for insert/update/upsert query-dbspace.ts property-space -> DB-space, once query-filters.ts operand substitution onto the PostgREST query query-results.ts decryption + Date reconstruction query-builder.ts the class: recorded state, fluent surface, execute() Worst file 105.77 -> 70.12 (query-encrypt.ts); cap lowered 91 -> 71. Also removed, all no-ops from the pre-fold v2/v3 inheritance: - `notFilterOperator` - an identity function with an unused parameter - `applyPatternFilter`'s dead `_wasEncrypted` parameter - `protected` throughout, now `private`; nothing extends the class and collapsed the six-times-repeated withLockContext/audit/await dance into one `withOpContext` helper - a skipped lock context would encrypt under the wrong data key, so the three steps must stay identical. `EncryptedQueryBuilderImpl` keeps its name, export site and constructor; nothing here is part of the package's public surface (index.ts does not re-export it). 466 tests pass unchanged. One test reached the unsupported- queryType backstop by subclassing to override `queryTypeForRawOp`, which is now a module function; `assertTermQueryable` is exported instead and the test calls it directly, dropping a subclass its own comment called "breaking the internal contract". Docs: correct comments asserting adapter-side EQL v2 reads. The adapter is v3 only and does not auto-read `eql_v2_encrypted` columns - introspection matches `public.eql_v3_*` domains exclusively, so such a column never enters the encrypt config. No ciphertext is stranded: core decryption stays generation-agnostic. Also retires the two-dialect scaffolding in types.ts, whose header claimed v3 does free-text via `contains` - contradicting the typed surface forty lines above it and re-introducing the exact confusion #617 removed. `EncryptedQueryBuilderCore`'s OK/BK defaults are kept: they are live for the untyped surface, only their v2-era rationale was wrong. --- .github/workflows/fta-v3.yml | 17 +- .../__tests__/supabase-v3-builder.test.ts | 54 +- packages/stack-supabase/package.json | 2 +- packages/stack-supabase/src/column-map.ts | 218 +++ packages/stack-supabase/src/helpers.ts | 2 +- packages/stack-supabase/src/index.ts | 13 +- packages/stack-supabase/src/query-builder.ts | 1716 ++--------------- packages/stack-supabase/src/query-dbspace.ts | 141 ++ packages/stack-supabase/src/query-encrypt.ts | 659 +++++++ packages/stack-supabase/src/query-filters.ts | 388 ++++ packages/stack-supabase/src/query-mutation.ts | 79 + packages/stack-supabase/src/query-results.ts | 205 ++ packages/stack-supabase/src/types.ts | 77 +- 13 files changed, 1915 insertions(+), 1656 deletions(-) create mode 100644 packages/stack-supabase/src/column-map.ts create mode 100644 packages/stack-supabase/src/query-dbspace.ts create mode 100644 packages/stack-supabase/src/query-encrypt.ts create mode 100644 packages/stack-supabase/src/query-filters.ts create mode 100644 packages/stack-supabase/src/query-mutation.ts create mode 100644 packages/stack-supabase/src/query-results.ts diff --git a/.github/workflows/fta-v3.yml b/.github/workflows/fta-v3.yml index 86f52b38..9ce0dfe2 100644 --- a/.github/workflows/fta-v3.yml +++ b/.github/workflows/fta-v3.yml @@ -68,10 +68,19 @@ jobs: # blocking gate. No `continue-on-error`. # One step per package so a failure names the offending package. Each caps # at its current worst file (a ratchet, not an aspiration): stack src/eql/v3 - # at 69, and the split adapter packages at their monolith maxima — drizzle - # 89 (operators.ts), supabase 91 (query-builder.ts). Lower a cap whenever a - # file is refactored below the next threshold; the v2 query-builder/operators - # monoliths are the debt to whittle down toward stack's 69. + # at 69, drizzle 89 (operators.ts, still a monolith), and supabase 71 + # (query-encrypt.ts at 70.12, after the query-builder monolith was split + # across column-map / query-encrypt / query-mutation / query-dbspace / + # query-filters / query-results). Lower a cap whenever a file is refactored + # below the next threshold; drizzle's operators.ts is the remaining debt to + # whittle down toward stack's 69. + # + # NB supabase's top three now cluster tightly (query-encrypt 70.12, + # query-builder 70.05, helpers 69.13), so 71 is a ~0.9 margin. A cap set + # flush against its max is fragile — the 91 this replaced was 0.12 above + # its max and a single refactor blew straight through it. If a formatting + # reflow trips this step without a real complexity increase, re-measure + # (`npx fta src --format table`) before assuming the code got worse. - name: Analyze stack (eql/v3) complexity run: pnpm --filter @cipherstash/stack run analyze:complexity diff --git a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts index 92e4a664..60d26c8f 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts @@ -1,6 +1,8 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { describe, expect, it, vi } from 'vitest' +import { ColumnMap } from '../src/column-map' import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' +import { assertTermQueryable } from '../src/query-encrypt' import { createMockEncryptionClient, createMockSupabase, @@ -1475,37 +1477,33 @@ describe('v3 raw filter() resolves the query type from the operator', () => { ]) }) - // `encryptCollectedTerms` rejects any queryType outside the four supported EQL + // `assertTermQueryable` rejects any queryType outside the four supported EQL // v3 kinds. No public call path can produce a fifth — `mapFilterOpToQueryType`, // `queryTypeForRawOp` and `queryTypeForOrOp` are exhaustive — so this backstop - // is unreachable without breaking the internal contract, which is exactly what - // the subclass below does. Keep the guard: it is what a future producer - // gaining a new QueryTypeName would trip over. (`searchableJson` used to be - // the exemplar here until #650 made it a real, supported kind.) - it('rejects a query type outside the supported EQL v3 kinds', async () => { - const supabase = createMockSupabase() + // is only reachable by handing the resolver a term the internal contract says + // cannot exist. Keep the guard: it is what a future producer gaining a new + // QueryTypeName would trip over. (`searchableJson` used to be the exemplar + // here until #650 made it a real, supported kind.) + it('rejects a query type outside the supported EQL v3 kinds', () => { + const term = { + value: 'a@b.com', + column: users.columnBuilders.email, + table: users, + queryType: 'steVecSelector', + returnType: 'composite-literal', + } as unknown as Parameters[0] - class BogusQueryType extends EncryptedQueryBuilderV3Impl { - protected override queryTypeForRawOp(_operator: string) { - return 'steVecSelector' as never - } - } - - const builder = new BogusQueryType( - 'users', - users, - createMockEncryptionClient(), - supabase.client, - USERS_ALL_COLUMNS, - ) - - const { error, status } = await builder - .select('id') - .filter('email', 'eq', 'a@b.com') - - expect(status).toBe(500) - expect(error?.message).toContain('query type "steVecSelector"') - expect(error?.message).toContain('not supported on EQL v3 columns') + expect(() => + assertTermQueryable(term, { + tableName: 'users', + table: users, + encryptionClient: createMockEncryptionClient(), + lockContext: null, + auditConfig: null, + columns: new ColumnMap('users', users, USERS_ALL_COLUMNS), + queryDomainsRequired: false, + }), + ).toThrow(/query type "steVecSelector".*not supported on EQL v3 columns/s) }) }) diff --git a/packages/stack-supabase/package.json b/packages/stack-supabase/package.json index 4b0a5bc4..dbd36fe6 100644 --- a/packages/stack-supabase/package.json +++ b/packages/stack-supabase/package.json @@ -51,7 +51,7 @@ "test": "vitest run", "test:types": "vitest --run --typecheck.only", "test:integration": "vitest run --config integration/vitest.config.ts", - "analyze:complexity": "fta src --score-cap 91" + "analyze:complexity": "fta src --score-cap 71" }, "dependencies": { "@cipherstash/stack": "workspace:*" diff --git a/packages/stack-supabase/src/column-map.ts b/packages/stack-supabase/src/column-map.ts new file mode 100644 index 00000000..8920d87d --- /dev/null +++ b/packages/stack-supabase/src/column-map.ts @@ -0,0 +1,218 @@ +import { EncryptedV3Column } from '@cipherstash/stack/adapter-kit' +import type { AnyV3Table } from '@cipherstash/stack/eql/v3' +import type { ColumnSchema } from '@cipherstash/stack/schema' +import type { BuildableQueryColumn } from '@cipherstash/stack/types' +import type { DbName } from './types' + +/** + * The subset of a v3 column builder the dialect relies on. Structural rather + * than the concrete class union so the runtime `instanceof EncryptedV3Column` + * gate and this type stay independent. + */ +export type V3ColumnLike = { + getName(): string + getEqlType(): string + getQueryCapabilities(): { + equality: boolean + orderAndRange: boolean + freeTextSearch: boolean + /** Optional: only `public.eql_v3_json_search` (`types.Json`) carries it. */ + searchableJson?: boolean + } + build(): ColumnSchema +} + +/** + * Reject a declared property name that is also a DIFFERENT physical column. + * + * `select('*')` expands the introspected DB names into property names, so a + * column renamed `created_at → createdAt` and a distinct plaintext column + * literally named `createdAt` both emit the token `createdAt`, which + * `addJsonbCastsV3` turns into `createdAt:created_at::jsonb` — twice. PostgREST + * returns the encrypted column under that key and the plaintext one is never + * selected, silently yielding the wrong value for a field the row type + * guarantees. + * + * Nothing downstream can disambiguate the two, and `EncryptedTable.build()`'s + * duplicate check only fires when two BUILDERS share a `getName()`. Refuse to + * construct instead. + */ +function assertNoPropertyDbNameCollision( + tableName: string, + propToDb: Record, + allColumns: string[] | null, +): void { + if (!allColumns) return + const dbNames = new Set(allColumns) + + for (const [property, dbName] of Object.entries(propToDb)) { + if (property === dbName) continue + if (!dbNames.has(property)) continue + throw new Error( + `[supabase v3]: property "${property}" on table "${tableName}" renames DB column "${dbName}", but "${property}" is also a distinct column in the database — the two collide in select('*'). Rename the property, or drop the declared rename.`, + ) + } +} + +/** + * Name and capability resolution for one table's columns. + * + * Every stage of the query pipeline addresses columns by BOTH the JS property + * name a caller wrote and the DB name PostgREST must see, and each needs to ask + * whether a given name is an encrypted v3 column and what it can be queried by. + * Concentrating that here means the encrypt, DB-space, filter-apply and decrypt + * modules take one collaborator instead of six correlated fields that must be + * kept in lockstep. + */ +export class ColumnMap { + /** JS property name → DB column name, for every encrypted column. */ + readonly propToDb: Record + /** Built column schemas keyed by DB column name (for `cast_as`, `indexes`). */ + readonly columnSchemas: Record + /** Every name an encrypted column answers to — property AND DB spelling. + * Filters and select strings address columns by both, so recognition must + * cover both. */ + readonly encryptedColumnNames: string[] + + /** DB column name → JS property name — the inverse of {@link propToDb}, used + * to expand `select('*')` back into property names. Null prototype: a DB + * column literally named `constructor` / `toString` would otherwise resolve + * to an inherited `Object.prototype` member and be emitted as a select token. */ + private readonly dbToProp: Record + /** Column builders keyed by BOTH property name and DB name. */ + private readonly v3Columns: Record + + constructor( + tableName: string, + table: AnyV3Table, + allColumns: string[] | null, + ) { + this.propToDb = table.buildColumnKeyMap() + this.columnSchemas = table.build().columns + + this.dbToProp = Object.create(null) as Record + for (const [property, dbName] of Object.entries(this.propToDb)) { + this.dbToProp[dbName] = property + } + + assertNoPropertyDbNameCollision(tableName, this.propToDb, allColumns) + + // Null-prototype: keyed by DB column names, and `validateTransforms` reads + // it without an own-key guard — an inherited `constructor`/`toString` would + // otherwise resolve truthy for a plaintext column of that name. + this.v3Columns = Object.create(null) as Record + for (const [property, builder] of Object.entries(table.columnBuilders)) { + if (builder instanceof EncryptedV3Column) { + const col = builder as unknown as V3ColumnLike + this.v3Columns[property] = col + this.v3Columns[col.getName()] = col + } + } + + this.encryptedColumnNames = Object.keys(this.v3Columns) + } + + /** Resolve a JS property name to its DB column name. `Object.hasOwn` guards + * the inherited-member hazard described on {@link EncryptedTable.buildColumnKeyMap}. */ + dbNameFor(name: string): string { + return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name + } + + /** + * Map a filter's column name to the DB column name PostgREST must see — + * resolving a JS property name to its DB name. + * + * This is the ONLY place a {@link DbName} is minted. The + * {@link SupabaseQueryBuilder} seam accepts nothing else, so every column + * name reaching PostgREST must pass through here. + */ + filterColumnName(column: string): DbName { + return this.dbNameFor(column) as DbName + } + + /** + * Encrypted ordering columns sort by their `op` term, not by the envelope. + * + * `order=col->op` is the one ordering expression PostgREST can emit that + * reaches the OPE term. It must NOT leak into filters — those compare whole + * envelopes through the `eql_v3.*` operators — which is why this is its own + * seam rather than a change to {@link filterColumnName}. + * + * The canonical EQL form is `ORDER BY eql_v3.ord_term(col)`, which returns + * `eql_v3_internal.ope_cllw` — a domain over `bytea`, ordered by the native + * btree. PostgREST cannot call a function, so it orders the `op` term where it + * sits, inside the envelope. The two agree because the term is what + * `ord_term()` returns. + * + * `->` (jsonb) rather than `->>` (text) keeps the comparison on the typed + * value. Note this does NOT avoid the database collation: Postgres compares + * jsonb strings with `varstr_cmp` under the default collation, exactly as it + * does text. What makes the ordering collation-independent is the term itself + * — fixed-width lowercase hex (`[0-9a-f]`, 130 chars for `integer_ord`, 82 for + * `text_search`) — and every collation orders digits before letters and hex + * letters among themselves. `match-bloom`'s sibling assertion pins that shape. + * + * This runs at column-name-mapping time (`transformToDbSpace`), BEFORE + * `buildAndExecuteQuery` calls `validateTransforms`. For an encrypted column + * with no `ope` index it therefore returns a bare `dbName` here — a name that + * would sort by `jsonb_cmp` over the ciphertext if it reached PostgREST — but + * it never does: `validateTransforms` throws (with a domain-specific reason) + * before the query executes, so the bare name is only ever an intermediate + * value on a request that is about to be rejected. + */ + orderColumnName(column: string): DbName { + const dbName = this.dbNameFor(column) + const encrypted = this.v3Columns[column] + if (!encrypted) return dbName as DbName + + return ( + this.columnSchemas[dbName]?.indexes?.ope ? `${dbName}->op` : dbName + ) as DbName + } + + /** + * Expand the introspected column list (DB names) into JS property names. + * + * Load-bearing for `select('*')` on a DECLARED table that renames a column. + * `addJsonbCastsV3` only emits the `prop:db_name::jsonb` alias — the thing + * that makes PostgREST return the column under its property name — when the + * token it sees is a property name. Feeding it the raw DB name instead takes + * the unaliased `dbNames.has(...)` branch, so the row comes back keyed + * `created_at` while the declared row type promises `createdAt`, silently + * yielding `undefined` for a field TypeScript guarantees. + * + * A DB column with no encrypted builder (plaintext passthrough, and every + * synthesized column, where property == DB name) maps to itself. + */ + expandAllColumns(columns: string[]): string[] { + return columns.map((dbName) => + Object.hasOwn(this.dbToProp, dbName) ? this.dbToProp[dbName] : dbName, + ) + } + + /** True when `column` is one of this table's encrypted v3 columns. */ + isEncryptedV3Column(column: string): boolean { + return Boolean(this.v3Columns[column]) + } + + /** True when `column` is an encrypted `types.Json` document column. */ + isSearchableJsonColumn(column: string): boolean { + const builder: V3ColumnLike | undefined = this.v3Columns[column] + return Boolean(builder?.getQueryCapabilities().searchableJson) + } + + /** The encrypted builder for `column`, by either spelling. */ + encryptedColumn(column: string): V3ColumnLike | undefined { + return this.v3Columns[column] + } + + /** The built schema for a DB column name — `cast_as` and `indexes`. */ + schemaFor(dbName: string): ColumnSchema | undefined { + return this.columnSchemas[dbName] + } + + /** The encrypted builders as the term collector's column lookup. */ + queryColumnMap(): Record { + return this.v3Columns as unknown as Record + } +} diff --git a/packages/stack-supabase/src/helpers.ts b/packages/stack-supabase/src/helpers.ts index 7d2ae5fc..f8e929ce 100644 --- a/packages/stack-supabase/src/helpers.ts +++ b/packages/stack-supabase/src/helpers.ts @@ -109,7 +109,7 @@ function lookupDbName( * property name. * - A DB column name used directly is cast in place (`db_name::jsonb`). * - Tokens that already carry a cast, or contain parens/dots (functions, - * foreign tables), are left untouched — same rules as the v2 helper. + * foreign tables), are left untouched. */ export function addJsonbCastsV3( columns: string, diff --git a/packages/stack-supabase/src/index.ts b/packages/stack-supabase/src/index.ts index b295a587..4befe972 100644 --- a/packages/stack-supabase/src/index.ts +++ b/packages/stack-supabase/src/index.ts @@ -48,10 +48,15 @@ function assertTableIsModelled( * compile-time types and verifies the declared tables against the database at * construction. * - * Encrypted data is stored as EQL v3 payloads. The generation-agnostic decrypt - * path in `@cipherstash/stack` still reads existing EQL v2 payloads, but this - * wrapper only AUTHORS EQL v3 — the legacy v2 authoring surface (a hand-written - * client-side schema and `from(tableName, schema)`) has been removed. + * Encrypted data is stored as EQL v3 payloads. This wrapper is EQL v3 only — it + * both authors and reads v3, and the legacy v2 authoring surface (a hand-written + * client-side schema and `from(tableName, schema)`) has been removed. It does + * not auto-read an `eql_v2_encrypted` column: introspection recognises the + * `public.eql_v3_*` domains exclusively, so a v2 column never enters the + * encrypt config and is returned as an untouched passthrough. No v2 ciphertext + * is stranded — decryption in `@cipherstash/stack` is generation-agnostic, so + * legacy payloads still decrypt through the core client (`decrypt` / + * `decryptModel`). Handle mixed-generation data explicitly on the caller side. * * Requires a Postgres connection (`options.databaseUrl` or `DATABASE_URL`) for * introspection, so it cannot run in a Worker or the browser. diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index f09314fd..2b299965 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -1,51 +1,34 @@ -import type { JsPlaintext } from '@cipherstash/protect-ffi' import type { AuditConfig } from '@cipherstash/stack/adapter-kit' import { - DATE_LIKE_CASTS, - EncryptedV3Column, logger, - matchNeedleError, parseSelectorSegments, reconstructSelectorDocument, unsupportedLeafReason, } from '@cipherstash/stack/adapter-kit' import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { AnyV3Table } from '@cipherstash/stack/eql/v3' -import { - type EncryptionError, - EncryptionErrorTypes, -} from '@cipherstash/stack/errors' import type { LockContextInput } from '@cipherstash/stack/identity' -import type { ColumnSchema } from '@cipherstash/stack/schema' -import type { - BuildableQueryColumn, - Encrypted, - EncryptedQueryResult, - QueryTypeName, - ScalarQueryTerm, -} from '@cipherstash/stack/types' +import { ColumnMap } from './column-map' +import { addJsonbCastsV3 } from './helpers' +import { toDbSpace } from './query-dbspace' +import { + assertJsonContainmentOperand, + assertPostgrestCanQueryEncryptedOperator, + type EncryptedFilterState, + type EncryptionContext, + EncryptionFailedError, + encryptFilterValues, +} from './query-encrypt' +import { applyFilters } from './query-filters' +import { encryptMutationData } from './query-mutation' import { - addJsonbCastsV3, - formatContainmentOperand, - formatInListOperand, - isEncryptableTerm, - isEncryptedColumn, - mapFilterOpToQueryType, - parseOrString, - rebuildOrString, - selectKeyToDbV3, -} from './helpers' + type DecryptContext, + decryptResults, + type RawSupabaseResult, +} from './query-results' import type { - DbConflictList, - DbFilterString, - DbMutationOp, - DbMutationOptions, - DbName, - DbPendingOrCondition, - DbPendingOrFilter, DbQuerySpace, DbSelect, - DbTransformOp, EncryptedSupabaseError, EncryptedSupabaseResponse, FilterOp, @@ -56,109 +39,17 @@ import type { PendingOrCondition, PendingOrFilter, PendingRawFilter, + RecordedOps, ResultMode, SupabaseClientLike, SupabaseQueryBuilder, TransformOp, } from './types' -/** cast_as kinds that reconstruct to a JS `Date` — shared with the typed v3 - * client's decrypt-model path (see `encryption/v3.ts`). */ -const DATE_LIKE_CAST_SET = new Set(DATE_LIKE_CASTS) - -/** - * The subset of a v3 column builder the dialect relies on. Structural rather - * than the concrete class union so the runtime `instanceof EncryptedV3Column` - * gate and this type stay independent. - */ -type V3ColumnLike = { - getName(): string - getEqlType(): string - getQueryCapabilities(): { - equality: boolean - orderAndRange: boolean - freeTextSearch: boolean - /** Optional: only `public.eql_v3_json_search` (`types.Json`) carries it. */ - searchableJson?: boolean - } - build(): ColumnSchema -} - -/** - * Validate an encrypted-JSON containment operand: a NON-EMPTY plain object or a - * non-empty array. Everything else is rejected with an actionable steer: - * - * - Scalars/strings: the caller meant free-text (`matches` on a text column) or - * a selector — a raw JSON string is NOT parsed, by design (parsing would make - * `'{"a":1}'` and `{a:1}` silently different queries on other surfaces). - * - Non-plain objects (`Date`, `Map`, `RegExp`, class instances): these JSON- - * serialize to scalars or `{}` — not the sub-document the caller believes. - * - `{}` and `[]`: jsonb containment holds for EVERY document (`doc @> '{}'`), - * so an accidentally-empty needle would silently return (and decrypt) the - * whole table. The Drizzle adapter rejects the same needle for the same - * reason — the two first-party adapters must agree that this is an error. - */ -function assertJsonContainmentOperand(column: string, value: unknown): void { - const isPlainObject = - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - (Object.getPrototypeOf(value) === Object.prototype || - Object.getPrototypeOf(value) === null) - if (!isPlainObject && !Array.isArray(value)) { - // Array.isArray is false on this branch by construction, so the label only - // distinguishes null / non-plain object / scalar. - const got = - value === null - ? 'null' - : typeof value === 'object' - ? (value as object).constructor?.name || 'a non-plain object' - : typeof value - throw new Error( - `[supabase v3]: encrypted JSON containment on column "${column}" takes a sub-document (plain object or array) to match, got ${got}.`, - ) - } - const empty = Array.isArray(value) - ? value.length === 0 - : Object.keys(value as object).length === 0 - if (empty) { - throw new Error( - `[supabase v3]: encrypted JSON containment on column "${column}" cannot take an empty ${Array.isArray(value) ? 'array' : 'object'} needle: it matches every row. Pass a non-empty sub-document, or omit the predicate to select all rows.`, - ) - } -} +export { EncryptionFailedError } from './query-encrypt' -/** - * Reject a declared property name that is also a DIFFERENT physical column. - * - * `select('*')` expands the introspected DB names into property names, so a - * column renamed `created_at → createdAt` and a distinct plaintext column - * literally named `createdAt` both emit the token `createdAt`, which - * `addJsonbCastsV3` turns into `createdAt:created_at::jsonb` — twice. PostgREST - * returns the encrypted column under that key and the plaintext one is never - * selected, silently yielding the wrong value for a field the row type - * guarantees. - * - * Nothing downstream can disambiguate the two, and `EncryptedTable.build()`'s - * duplicate check only fires when two BUILDERS share a `getName()`. Refuse to - * construct instead. - */ -function assertNoPropertyDbNameCollision( - tableName: string, - propToDb: Record, - allColumns: string[] | null, -): void { - if (!allColumns) return - const dbNames = new Set(allColumns) - - for (const [property, dbName] of Object.entries(propToDb)) { - if (property === dbName) continue - if (!dbNames.has(property)) continue - throw new Error( - `[supabase v3]: property "${property}" on table "${tableName}" renames DB column "${dbName}", but "${property}" is also a distinct column in the database — the two collide in select('*'). Rename the property, or drop the declared rename.`, - ) - } -} +/** Warn once per (op, column) that a `like`/`ilike` was delegated to `matches`. */ +const warnedLikeDelegation = new Set() /** * A deferred query builder that wraps Supabase's query builder to automatically @@ -184,55 +75,51 @@ function assertNoPropertyDbNameCollision( * enters a GET URL. * * Decrypted rows additionally get `Date` reconstruction from the encrypt-config - * `cast_as`, mirroring the typed v3 client. `decryptModel`/`bulkDecryptModels` - * are generation-agnostic in `@cipherstash/stack`, so a stored EQL v2 payload - * still decrypts through this builder's read path. + * `cast_as`, mirroring the typed v3 client. This builder authors and reads EQL + * v3 only: legacy `eql_v2_encrypted` columns are not recognised by introspection, + * so they never enter the encrypt config and are returned as untouched + * passthroughs. Decrypt v2 data with the core `@cipherstash/stack` client. + * + * The pipeline is split across sibling modules — `./column-map` (name and + * capability resolution), `./query-encrypt` (mutation data and filter terms), + * `./query-dbspace` (property → DB space), `./query-filters` (operand + * substitution), `./query-results` (decryption) — and orchestrated by + * {@link execute} below. */ export class EncryptedQueryBuilderImpl< T extends Record = Record, > { - protected tableName: string - protected table: AnyV3Table - protected encryptionClient: EncryptionClient - protected supabaseClient: SupabaseClientLike - protected encryptedColumnNames: string[] + private tableName: string + private table: AnyV3Table + private encryptionClient: EncryptionClient + private supabaseClient: SupabaseClientLike + /** Name and capability resolution for this table's columns. */ + private columns: ColumnMap /** All column names for the table (encrypted + plaintext), in ordinal order, * used to expand `select('*')`. `null` when the caller supplied no column * list (a v3 client that could not introspect). */ - protected allColumns: string[] | null = null - - /** JS property name → DB column name, for every encrypted column. */ - private propToDb: Record - /** DB column name → JS property name — the inverse of {@link propToDb}, used - * to expand `select('*')` back into property names. Null prototype: a DB - * column literally named `constructor` / `toString` would otherwise resolve - * to an inherited `Object.prototype` member and be emitted as a select token. */ - private dbToProp: Record - /** Built column schemas keyed by DB column name (for `cast_as`). */ - private columnSchemas: Record - /** Column builders keyed by BOTH property name and DB name. */ - private v3Columns: Record + private allColumns: string[] | null = null /** EQL 3.0.2+ requires query-domain casts PostgREST cannot express. */ private queryDomainsRequired: boolean // Recorded operations - protected mutation: MutationOp | null = null - protected selectColumns: string | null = null - protected selectOptions: + private mutation: MutationOp | null = null + private selectColumns: string | null = null + private selectOptions: | { head?: boolean; count?: 'exact' | 'planned' | 'estimated' } | undefined = undefined - protected filters: PendingFilter[] = [] - protected orFilters: PendingOrFilter[] = [] - protected matchFilters: PendingMatchFilter[] = [] - protected notFilters: PendingNotFilter[] = [] - protected rawFilters: PendingRawFilter[] = [] - protected transforms: TransformOp[] = [] - protected resultMode: ResultMode = 'array' - protected shouldThrowOnError = false + private filters: PendingFilter[] = [] + private orFilters: PendingOrFilter[] = [] + private matchFilters: PendingMatchFilter[] = [] + private notFilters: PendingNotFilter[] = [] + private rawFilters: PendingRawFilter[] = [] + private transforms: TransformOp[] = [] + private resultMode: ResultMode = 'array' + private shouldThrowOnError = false // Encryption-specific state - protected lockContext: LockContextInput | null = null - protected auditConfig: AuditConfig | null = null + private lockContext: LockContextInput | null = null + private auditConfig: AuditConfig | null = null constructor( tableName: string, @@ -248,31 +135,7 @@ export class EncryptedQueryBuilderImpl< this.supabaseClient = supabaseClient this.allColumns = allColumns this.queryDomainsRequired = queryDomainsRequired - this.propToDb = table.buildColumnKeyMap() - this.columnSchemas = table.build().columns - - this.dbToProp = Object.create(null) as Record - for (const [property, dbName] of Object.entries(this.propToDb)) { - this.dbToProp[dbName] = property - } - - assertNoPropertyDbNameCollision(tableName, this.propToDb, allColumns) - - // Null-prototype: keyed by DB column names, and `validateTransforms` reads - // it without an own-key guard — an inherited `constructor`/`toString` would - // otherwise resolve truthy for a plaintext column of that name. - this.v3Columns = Object.create(null) as Record - for (const [property, builder] of Object.entries(table.columnBuilders)) { - if (builder instanceof EncryptedV3Column) { - const col = builder as unknown as V3ColumnLike - this.v3Columns[property] = col - this.v3Columns[col.getName()] = col - } - } - - // Filters and select strings address columns by JS property name AND by DB - // name, so recognition must cover both. - this.encryptedColumnNames = Object.keys(this.v3Columns) + this.columns = new ColumnMap(tableName, table, allColumns) } // --------------------------------------------------------------------------- @@ -289,7 +152,9 @@ export class EncryptedQueryBuilderImpl< "encryptedSupabase does not support select('*'). Please list columns explicitly so that encrypted columns can be cast with ::jsonb.", ) } - this.selectColumns = this.expandAllColumns(this.allColumns).join(', ') + this.selectColumns = this.columns + .expandAllColumns(this.allColumns) + .join(', ') } else { this.selectColumns = columns } @@ -297,26 +162,6 @@ export class EncryptedQueryBuilderImpl< return this } - /** - * Expand the introspected column list (DB names) into JS property names. - * - * Load-bearing for `select('*')` on a DECLARED table that renames a column. - * `addJsonbCastsV3` only emits the `prop:db_name::jsonb` alias — the thing - * that makes PostgREST return the column under its property name — when the - * token it sees is a property name. Feeding it the raw DB name instead takes - * the unaliased `dbNames.has(...)` branch, so the row comes back keyed - * `created_at` while the declared row type promises `createdAt`, silently - * yielding `undefined` for a field TypeScript guarantees. - * - * A DB column with no encrypted builder (plaintext passthrough, and every - * synthesized column, where property == DB name) maps to itself. - */ - protected expandAllColumns(columns: string[]): string[] { - return columns.map((dbName) => - Object.hasOwn(this.dbToProp, dbName) ? this.dbToProp[dbName] : dbName, - ) - } - insert( data: Partial | Partial[], options?: { @@ -411,7 +256,7 @@ export class EncryptedQueryBuilderImpl< * SQL LIKE. */ like(column: string, pattern: string): this { - if (!this.isEncryptedV3Column(column)) { + if (!this.columns.isEncryptedV3Column(column)) { this.filters.push({ op: 'like', column, value: pattern }) return this } @@ -419,7 +264,7 @@ export class EncryptedQueryBuilderImpl< } ilike(column: string, pattern: string): this { - if (!this.isEncryptedV3Column(column)) { + if (!this.columns.isEncryptedV3Column(column)) { this.filters.push({ op: 'ilike', column, value: pattern }) return this } @@ -435,15 +280,15 @@ export class EncryptedQueryBuilderImpl< * than silently emit a bloom match under a name that promises exactness. */ contains(column: string, value: unknown): this { - if (this.isSearchableJsonColumn(column)) { - this.assertPostgrestCanQueryEncryptedOperator('contains', column) + if (this.columns.isSearchableJsonColumn(column)) { + this.assertPostgrestCanQueryEncrypted('contains', column) // Same validator the term resolver enforces — failing here just surfaces // the error at the call site instead of at execution. assertJsonContainmentOperand(column, value) this.filters.push({ op: 'contains', column, value }) return this } - if (this.isEncryptedV3Column(column)) { + if (this.columns.isEncryptedV3Column(column)) { throw new Error( `[supabase v3]: contains() is native (exact) containment and does not apply to encrypted column "${column}". Use matches() for encrypted free-text search.`, ) @@ -463,17 +308,17 @@ export class EncryptedQueryBuilderImpl< * silently accept as containment of the raw string. */ matches(column: string, value: unknown): this { - if (this.isSearchableJsonColumn(column)) { + if (this.columns.isSearchableJsonColumn(column)) { throw new Error( `[supabase v3]: matches() is encrypted free-text search and does not apply to encrypted JSON column "${column}". Use contains("${column}", subDocument) or selectorEq("${column}", path, value).`, ) } - if (!this.isEncryptedV3Column(column)) { + if (!this.columns.isEncryptedV3Column(column)) { throw new Error( `[supabase v3]: matches() is encrypted free-text search and requires an encrypted column; "${column}" is not one. Use contains() for native containment.`, ) } - this.assertPostgrestCanQueryEncryptedOperator('matches', column) + this.assertPostgrestCanQueryEncrypted('matches', column) this.filters.push({ op: 'matches', column, value }) return this } @@ -508,8 +353,8 @@ export class EncryptedQueryBuilderImpl< not(column: string, operator: string, value: unknown): this { if ( operator === 'contains' && - this.isEncryptedV3Column(column) && - !this.isSearchableJsonColumn(column) + this.columns.isEncryptedV3Column(column) && + !this.columns.isSearchableJsonColumn(column) ) { throw new Error( `[supabase v3]: not("${column}", 'contains', …) does not apply to encrypted column "${column}" — that is fuzzy free-text matching, not containment. Use not("${column}", 'matches', …) or the raw 'cs' operator.`, @@ -518,7 +363,7 @@ export class EncryptedQueryBuilderImpl< // Mirror of the matches() guard: a `matches` spelling on a JSON column // would otherwise resolve to containment (the two share the `cs` wire op), // silently negating an EXACT operation under a name that promises FUZZY. - if (operator === 'matches' && this.isSearchableJsonColumn(column)) { + if (operator === 'matches' && this.columns.isSearchableJsonColumn(column)) { throw new Error( `[supabase v3]: not("${column}", 'matches', …) does not apply to encrypted JSON column "${column}" — matches() is free-text search. Use not("${column}", 'contains', subDocument) or selectorNe("${column}", path, value).`, ) @@ -562,7 +407,7 @@ export class EncryptedQueryBuilderImpl< * `ops.selector()` supports it today. */ selectorEq(column: string, path: string, value: unknown): this { - this.assertPostgrestCanQueryEncryptedOperator('selectorEq', column) + this.assertPostgrestCanQueryEncrypted('selectorEq', column) const needle = this.selectorNeedle('selectorEq', column, path, value) return this.contains(column, needle) } @@ -578,7 +423,7 @@ export class EncryptedQueryBuilderImpl< * operand is encrypted through the normal or-condition term path. */ selectorNe(column: string, path: string, value: unknown): this { - this.assertPostgrestCanQueryEncryptedOperator('selectorNe', column) + this.assertPostgrestCanQueryEncrypted('selectorNe', column) const needle = this.selectorNeedle('selectorNe', column, path, value) return this.or([ { column, op: 'is', value: null }, @@ -686,21 +531,23 @@ export class EncryptedQueryBuilderImpl< // Core execution // --------------------------------------------------------------------------- - protected async execute(): Promise> { + private async execute(): Promise> { try { logger.debug(`Supabase encrypted query on table "${this.tableName}".`) + const ctx = this.encryptionContext() + // 1. Encrypt mutation data - const encryptedMutation = await this.encryptMutationData() + const encryptedMutation = await encryptMutationData(this.mutation, ctx) // 2. Build select string with ::jsonb casts const selectString = this.buildSelectString() // 3. Translate every recorded column name into DB-space, once. - const dbSpace = this.toDbSpace() + const dbSpace = toDbSpace(this.recordedOps(), this.columns) // 4. Batch-encrypt filter values - const encryptedFilters = await this.encryptFilterValues(dbSpace) + const encryptedFilters = await encryptFilterValues(dbSpace, ctx) // 5. Build and execute real Supabase query const result = await this.buildAndExecuteQuery( @@ -711,7 +558,12 @@ export class EncryptedQueryBuilderImpl< ) // 6. Decrypt results - return await this.decryptResults(result) + return await decryptResults(result, { + ...ctx, + selectColumns: this.selectColumns, + resultMode: this.resultMode, + hasMutation: this.mutation !== null, + } satisfies DecryptContext) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.error( @@ -745,676 +597,47 @@ export class EncryptedQueryBuilderImpl< } } - // --------------------------------------------------------------------------- - // Step 1: Encrypt mutation data - // --------------------------------------------------------------------------- - - protected async encryptMutationData(): Promise< - Record | Record[] | null - > { - if (!this.mutation) return null - - if (this.mutation.kind === 'delete') return null - - const data = this.mutation.data - - if (Array.isArray(data)) { - // Bulk encrypt - const baseOp = this.encryptionClient.bulkEncryptModels(data, this.table) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) { - logger.error( - `Supabase: failed to encrypt models for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to encrypt models: ${result.failure.message}`, - result.failure, - ) - } - - return this.transformEncryptedMutationModels(result.data) - } - - // Single model - const baseOp = this.encryptionClient.encryptModel(data, this.table) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) { - logger.error( - `Supabase: failed to encrypt model for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to encrypt model: ${result.failure.message}`, - result.failure, - ) + /** The shared slice of builder state every encrypt/decrypt step needs. Built + * per `execute()`, so `lockContext`/`auditConfig` are read at execution time. */ + private encryptionContext(): EncryptionContext { + return { + tableName: this.tableName, + table: this.table, + encryptionClient: this.encryptionClient, + lockContext: this.lockContext, + auditConfig: this.auditConfig, + columns: this.columns, + queryDomainsRequired: this.queryDomainsRequired, } - - return this.transformEncryptedMutationModel(result.data) } - /** - * Encode an encrypted model for the Supabase request body. The native - * `eql_v3.*` domains are plain jsonb, so the raw encrypted payload is sent - * (keyed by DB column name). - */ - protected transformEncryptedMutationModel( - model: Record, - ): Record { - const out: Record = Object.create(null) - for (const [key, value] of Object.entries(model)) { - out[this.dbNameFor(key)] = value + /** The recorded query in property space, for `toDbSpace`. */ + private recordedOps(): RecordedOps { + return { + filters: this.filters, + matchFilters: this.matchFilters, + notFilters: this.notFilters, + rawFilters: this.rawFilters, + orFilters: this.orFilters, + transforms: this.transforms, + mutation: this.mutation, } - return out - } - - protected transformEncryptedMutationModels( - models: Record[], - ): Record[] { - return models.map((model) => this.transformEncryptedMutationModel(model)) } // --------------------------------------------------------------------------- // Step 2: Build select string with casts // --------------------------------------------------------------------------- - protected buildSelectString(): DbSelect | null { + private buildSelectString(): DbSelect | null { if (this.selectColumns === null) return null - return addJsonbCastsV3(this.selectColumns, this.propToDb) - } - - // --------------------------------------------------------------------------- - // Step 3: Encrypt filter values - // --------------------------------------------------------------------------- - - protected async encryptFilterValues( - dbSpace: DbQuerySpace, - ): Promise { - // Collect all terms that need encryption - const terms: ScalarQueryTerm[] = [] - const termMap: TermMapping[] = [] - - const tableColumns = this.getColumnMap() - - const pushTerm = ( - value: JsPlaintext, - column: ScalarQueryTerm['column'], - queryType: QueryTypeName, - mapping: TermMapping, - ) => { - terms.push({ - value, - column, - table: this.table, - queryType, - returnType: 'composite-literal', - }) - termMap.push(mapping) - } - - /** - * Collect one term per element of an `in`-list operand. - * - * Element-wise is the only correct encoding: encrypting the array as ONE - * value collapses `(a,b)` into a single ciphertext that matches nothing. A - * null element is SQL NULL and passes through unencrypted; the applier - * restores it by index, which is why the mapping carries `inIndex`. - * - * Shared by the regular-`in`, `not(…,'in',…)` and or-condition paths. They - * drifted apart once already — the `not` path went unfixed while the other - * two encrypted element-wise — so they are kept in lockstep here rather than - * spelled out three times. - */ - const collectInListTerms = ( - op: FilterOp, - values: readonly unknown[], - column: ScalarQueryTerm['column'], - queryType: QueryTypeName, - mappingFor: (inIndex: number) => TermMapping, - ) => { - for (let j = 0; j < values.length; j++) { - if (!isEncryptableTerm(op, values[j])) continue - pushTerm(values[j] as JsPlaintext, column, queryType, mappingFor(j)) - } - } - - // Regular filters - for (let i = 0; i < dbSpace.filters.length; i++) { - const f = dbSpace.filters[i] - if (!isEncryptedColumn(f.column, this.encryptedColumnNames)) continue - - const column = tableColumns[f.column] - if (!column) continue - - if (f.op === 'in' && Array.isArray(f.value)) { - collectInListTerms( - f.op, - f.value, - column, - mapFilterOpToQueryType(f.op), - (inIndex) => ({ source: 'filter', filterIndex: i, inIndex }), - ) - } else if (!isEncryptableTerm(f.op, f.value)) { - // `is` predicate or null operand — forwarded unencrypted. - } else { - pushTerm(f.value as JsPlaintext, column, mapFilterOpToQueryType(f.op), { - source: 'filter', - filterIndex: i, - }) - } - } - - // Match filters - for (let i = 0; i < dbSpace.matchFilters.length; i++) { - const mf = dbSpace.matchFilters[i] - for (const { column: colName, value } of mf.entries) { - if (!isEncryptedColumn(colName, this.encryptedColumnNames)) continue - // `match` carries no operator; equality is implied. - if (!isEncryptableTerm('eq', value)) continue - const column = tableColumns[colName] - if (!column) continue - - pushTerm(value as JsPlaintext, column, 'equality', { - source: 'match', - matchIndex: i, - column: colName, - }) - } - } - - // Not filters - for (let i = 0; i < dbSpace.notFilters.length; i++) { - const nf = dbSpace.notFilters[i] - if (!isEncryptedColumn(nf.column, this.encryptedColumnNames)) continue - if (!isEncryptableTerm(nf.op, nf.value)) continue - const column = tableColumns[nf.column] - if (!column) continue - - if (nf.op === 'in') { - // A PostgREST list literal (`'(a,b)'`) cannot be encrypted element-wise, - // and encrypting it whole matches nothing. Refuse it rather than emit a - // filter that silently returns no rows. - if (!Array.isArray(nf.value)) { - throw new Error( - `not("${nf.column}", "in", …) on an encrypted column requires an array of values, ` + - `not a PostgREST list literal — each element must be encrypted separately`, - ) - } - collectInListTerms( - nf.op, - nf.value, - column, - mapFilterOpToQueryType(nf.op), - (inIndex) => ({ source: 'not', notIndex: i, inIndex }), - ) - continue - } - - pushTerm(nf.value as JsPlaintext, column, mapFilterOpToQueryType(nf.op), { - source: 'not', - notIndex: i, - }) - } - - // Or filters — conditions were parsed once, in `toDbSpace`. The string and - // structured forms differ only in their `source` tag; the encryption rules, - // including the `in`-list split below, are identical. - for (let i = 0; i < dbSpace.orFilters.length; i++) { - const of_ = dbSpace.orFilters[i] - const source = of_.kind === 'string' ? 'or-string' : 'or-structured' - - for (let j = 0; j < of_.conditions.length; j++) { - const cond = of_.conditions[j] - if (!isEncryptedColumn(cond.column, this.encryptedColumnNames)) continue - const column = tableColumns[cond.column] - if (!column) continue - - // `queryTypeForOrOp`, not `mapFilterOpToQueryType`: an or-condition may - // carry a raw PostgREST operator (`cs`), which is not a `FilterOp`. - const queryType = this.queryTypeForOrOp(cond.op) - const mappingFor = (inIndex?: number): TermMapping => ({ - source, - orIndex: i, - conditionIndex: j, - inIndex, - }) - - if (cond.op === 'in' && Array.isArray(cond.value)) { - collectInListTerms(cond.op, cond.value, column, queryType, mappingFor) - continue - } - - if (!isEncryptableTerm(cond.op, cond.value)) continue - pushTerm(cond.value as JsPlaintext, column, queryType, mappingFor()) - } - } - - // Raw filters - for (let i = 0; i < dbSpace.rawFilters.length; i++) { - const rf = dbSpace.rawFilters[i] - if (!isEncryptedColumn(rf.column, this.encryptedColumnNames)) continue - const column = tableColumns[rf.column] - if (!column) continue - - if (rf.operator === 'in') { - // Same contract as the `not(…, 'in', …)` path: a PostgREST list literal - // (`'("a","b")'`) cannot be encrypted element-wise, and encrypting it - // whole matches nothing. Refuse it rather than emit a filter that - // silently returns no rows. - if (!Array.isArray(rf.value)) { - throw new Error( - `filter("${rf.column}", "in", …) on an encrypted column requires an array of values, ` + - `not a PostgREST list literal — each element must be encrypted separately`, - ) - } - collectInListTerms( - 'in', - rf.value, - column, - this.queryTypeForRawOp(rf.operator), - (inIndex) => ({ source: 'raw', rawIndex: i, inIndex }), - ) - continue - } - - if (!isEncryptableTerm(rf.operator, rf.value)) continue - - pushTerm( - rf.value as JsPlaintext, - column, - this.queryTypeForRawOp(rf.operator), - { source: 'raw', rawIndex: i }, - ) - } - - if (terms.length === 0) { - return { encryptedValues: [], termMap: [] } - } - - const encryptedValues = await this.encryptCollectedTerms(terms) - return { encryptedValues, termMap } - } - - /** - * Encrypt every filter operand as a full storage envelope, serialized to jsonb - * text for the PostgREST filter value. - * - * Terms are grouped by column and each group takes ONE `bulkEncrypt` crossing. - * `in(col, [a, b, c])` collects one term per element (the list must never be - * encrypted whole), so encrypting per term spent N ZeroKMS/FFI round-trips - * where one would do. `bulkEncrypt` carries a single `{table, column}` for the - * whole payload, so the grouping is mandatory, not an optimisation: one bulk - * call over a mixed-column term array would stamp one column onto every - * plaintext. Results are scattered back onto the terms' original indices, - * which is the contract `termMap` downstream relies on. - * - * Mirrors `eql/v3/drizzle/operators.ts` `encryptOperands` — same batching - * contract, same length assertion, same fallback. Kept separate because that - * one encrypts a single-column operand list and returns `SQL[]`, while this - * must group a multi-column term array and preserve positions. - */ - protected async encryptCollectedTerms( - terms: ScalarQueryTerm[], - ): Promise { - const groups = new Map< - V3ColumnLike, - { indices: number[]; values: ScalarQueryTerm['value'][] } - >() - terms.forEach((term, index) => { - const column = this.assertTermQueryable(term) - const group = groups.get(column) ?? { indices: [], values: [] } - group.indices.push(index) - group.values.push(term.value) - groups.set(column, group) - }) - - const bulkEncrypt = this.encryptionClient.bulkEncrypt?.bind( - this.encryptionClient, - ) - // Each term becomes the `JSON.stringify`'d storage envelope — a `string`, - // which is one arm of `EncryptedQueryResult`. PostgREST cannot cast a filter - // value to the `eql_v3.query_` twins, so v3 sends full envelopes, - // serialized to jsonb text. - const results = new Array(terms.length) - - await Promise.all( - Array.from(groups, async ([column, { indices, values }]) => { - const encrypted = bulkEncrypt - ? await this.bulkEncryptGroup(bulkEncrypt, column, values) - : await this.encryptGroupPerTerm(column, values) - - encrypted.forEach((envelope, i) => { - results[indices[i]] = JSON.stringify(envelope) - }) - }), - ) - - return results - } - - /** - * Validate a term's query type against its column's declared capabilities. - * Pure validation: `encrypt`/`bulkEncrypt` never receive the query type. On - * EQL 3.0.2+, free-text/JSON terms are rejected before this storage-encryption - * path can place ciphertext in a GET URL. - */ - private assertTermQueryable(term: ScalarQueryTerm): V3ColumnLike { - const column = term.column as unknown as V3ColumnLike - let queryType = term.queryType ?? 'equality' - const capabilities = column.getQueryCapabilities() - - // The `cs` wire operator is capability-overloaded: bloom free-text on a - // match/search TEXT column, encrypted ste_vec containment on a `types.Json` - // DOCUMENT column. Both arrive here as `freeTextSearch` (contains/matches/ - // raw `cs` all map there); resolve to the capability the column actually - // carries. The two are mutually exclusive by construction, so this can - // never reinterpret a real free-text column. - if ( - queryType === 'freeTextSearch' && - !capabilities.freeTextSearch && - capabilities.searchableJson - ) { - queryType = 'searchableJson' - } - - if ( - queryType !== 'equality' && - queryType !== 'orderAndRange' && - queryType !== 'freeTextSearch' && - queryType !== 'searchableJson' - ) { - throw new Error( - `[supabase v3]: query type "${queryType}" is not supported on EQL v3 columns`, - ) - } - - if (!capabilities[queryType]) { - throw new Error( - `[supabase v3]: column "${column.getName()}" (${column.getEqlType()}) does not support ${queryType} queries — declare the column with a domain that carries that capability`, - ) - } - - if (queryType === 'freeTextSearch' || queryType === 'searchableJson') { - // This is the common boundary for every spelling that collects an - // encrypted match/containment term: matches(), contains(), not(), raw - // filter(), and both forms of or(). Method-level checks provide earlier - // errors for the direct helpers, but cannot cover the raw filter paths on - // their own. - this.assertPostgrestCanQueryEncryptedOperator('filter', column.getName()) - } - - if (queryType === 'searchableJson') { - // THE single enforced operand boundary for encrypted-JSON containment. - // Terms reach this resolver from every spelling — contains(), raw - // .filter(col,'cs',…), not(col,'contains'|'matches',…), and .or() - // string/structured conditions — and only contains() has a method-level - // guard. Without this check a raw string (e.g. a free-text term ported - // from a text column, or an .or() condition value, which is always a - // string) would be storage-encrypted as a JSON SCALAR and silently match - // nothing; pre-#650 every such spelling failed loudly on capability. - assertJsonContainmentOperand(column.getName(), term.value) - } - - // Free-text (bloom) needle floor. A needle shorter than the tokenizer's - // token_length produces NO tokens, so `bf @> '{}'` holds for every row and - // the query would silently return (and the caller decrypt) the whole table - // — a fail-open over-exposure. Reject it up front, mirroring the Drizzle v3 - // adapter (matchNeedleError) so both first-party surfaces guard identically. - // JSON containment terms (searchableJson) are validated separately above. - if (queryType === 'freeTextSearch') { - const match = column.build().indexes?.match - const reason = match ? matchNeedleError(term.value, match) : undefined - if (reason) { - throw new Error( - `[supabase v3]: cannot search column "${column.getName()}": ${reason}`, - ) - } - } - - return column - } - - private encryptionFailure(message: string, cause?: EncryptionError): never { - logger.error( - `Supabase: failed to encrypt query terms for table "${this.tableName}"`, - ) - // Most callers pass the operation's own `EncryptionError`; the contract- - // violation cases (bulk length mismatch, null envelope) have none, so - // synthesize one — a broken query encryption is still an encryption failure, - // and callers branch on `error.encryptionError` regardless. - throw new EncryptionFailedError( - `Failed to encrypt query terms: ${message}`, - cause ?? { type: EncryptionErrorTypes.EncryptionError, message }, - ) - } - - /** One FFI crossing for a column's whole operand list. */ - private async bulkEncryptGroup( - bulkEncrypt: NonNullable, - column: V3ColumnLike, - values: ScalarQueryTerm['value'][], - ): Promise> { - const baseOp = bulkEncrypt( - values.map((plaintext) => ({ plaintext })) as never, - { column, table: this.table } as never, - ) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) - this.encryptionFailure(result.failure.message, result.failure) - - // `bulkEncrypt` is position-stable, so a length mismatch means the contract - // was violated. Truncating instead would silently widen an `in` predicate - // (or narrow a `not.in`) to whatever came back. `result.data` is now - // `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]` — not `unknown`. - const encrypted = result.data - if (encrypted.length !== values.length) { - this.encryptionFailure( - `bulk encryption returned ${encrypted.length} terms for ${values.length} values on column "${column.getName()}".`, - ) - } - return encrypted.map((term, i) => { - // `BulkEncryptedData` types the element as `Encrypted | null`. A `null` - // envelope here would be `JSON.stringify`'d to the literal string `"null"` - // and sent as the filter operand — silently matching whatever `"null"` - // encodes to rather than failing. A query term should never encrypt to a - // null envelope, so treat it as a contract violation, not a value. - if (term.data === null) { - this.encryptionFailure( - `bulk encryption returned a null envelope at position ${i} for column "${column.getName()}".`, - ) - } - return term.data - }) - } - - /** Fallback for a client that predates `bulkEncrypt`. */ - private async encryptGroupPerTerm( - column: V3ColumnLike, - values: ScalarQueryTerm['value'][], - ): Promise { - return Promise.all( - values.map(async (value) => { - const baseOp = this.encryptionClient.encrypt(value, { - column, - table: this.table, - }) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) { - this.encryptionFailure(result.failure.message, result.failure) - } - return result.data - }), - ) - } - - // --------------------------------------------------------------------------- - // Phase boundary: property-space -> DB-space - // --------------------------------------------------------------------------- - - /** - * Translate every recorded column name from JS property space into DB space, - * once. Downstream (`encryptFilterValues`, `applyFilters`, - * `buildAndExecuteQuery`) consumes only the branded result, so a column can - * no longer reach PostgREST untranslated — that is a compile error. - * - * Total: `filterColumnName`, `parseOrString`, and `resolveMutationOptions` - * never throw, so this introduces no new early-throw point and cannot perturb - * the order in which capability errors surface. - * - * Safe to run BEFORE encryption: `getColumnMap()`/`encryptedColumnNames` are - * keyed by both property and DB name, so column lookup resolves identically - * either side of the translation, and `tableColumns[prop]` is the very same - * builder object as `tableColumns[db]`. - */ - protected toDbSpace(): DbQuerySpace { - return { - filters: this.filters.map((f) => ({ - ...f, - column: this.filterColumnName(f.column), - })), - matchFilters: this.matchFilters.map((mf) => ({ - entries: Object.entries(mf.query).map(([column, value]) => ({ - column: this.filterColumnName(column), - value, - })), - })), - notFilters: this.notFilters.map((nf) => ({ - ...nf, - column: this.filterColumnName(nf.column), - })), - rawFilters: this.rawFilters.map((rf) => ({ - ...rf, - column: this.filterColumnName(rf.column), - })), - orFilters: this.orFilters.map((of_) => this.orFilterToDbSpace(of_)), - transforms: this.transforms.map((t) => this.transformToDbSpace(t)), - mutation: this.mutation ? this.mutationToDbSpace(this.mutation) : null, - } - } - - /** Column names only. Which conditions were encrypted is never decided here: - * it stays derived at apply time from the substitution maps, so this pass - * never has to agree with the encryption predicate. The operator token is - * settled later still, in `rebuildOrString`, where `contains` becomes `cs` - * for encrypted and plaintext conditions alike. */ - private orFilterToDbSpace(of_: PendingOrFilter): DbPendingOrFilter { - const toDbCondition = (c: PendingOrCondition): DbPendingOrCondition => ({ - ...c, - column: this.filterColumnName(c.column), - }) - - if (of_.kind === 'string') { - return { - kind: 'string', - original: of_.value, - conditions: parseOrString(of_.value).map(toDbCondition), - referencedTable: of_.referencedTable, - } - } - return { kind: 'structured', conditions: of_.conditions.map(toDbCondition) } - } - - /** - * Encrypted ordering columns sort by their `op` term, not by the envelope. - * - * `order=col->op` is the one ordering expression PostgREST can emit that - * reaches the OPE term. It must NOT leak into filters — those compare whole - * envelopes through the `eql_v3.*` operators — which is why this is its own - * seam rather than a change to `filterColumnName`. - * - * The canonical EQL form is `ORDER BY eql_v3.ord_term(col)`, which returns - * `eql_v3_internal.ope_cllw` — a domain over `bytea`, ordered by the native - * btree. PostgREST cannot call a function, so it orders the `op` term where it - * sits, inside the envelope. The two agree because the term is what - * `ord_term()` returns. - * - * `->` (jsonb) rather than `->>` (text) keeps the comparison on the typed - * value. Note this does NOT avoid the database collation: Postgres compares - * jsonb strings with `varstr_cmp` under the default collation, exactly as it - * does text. What makes the ordering collation-independent is the term itself - * — fixed-width lowercase hex (`[0-9a-f]`, 130 chars for `integer_ord`, 82 for - * `text_search`) — and every collation orders digits before letters and hex - * letters among themselves. `match-bloom`'s sibling assertion pins that shape. - * - * This runs at column-name-mapping time (`transformToDbSpace`), BEFORE - * `buildAndExecuteQuery` calls `validateTransforms`. For an encrypted column - * with no `ope` index it therefore returns a bare `dbName` here — a name that - * would sort by `jsonb_cmp` over the ciphertext if it reached PostgREST — but - * it never does: `validateTransforms` throws (with a domain-specific reason) - * before the query executes, so the bare name is only ever an intermediate - * value on a request that is about to be rejected. - */ - protected orderColumnName(column: string): DbName { - const dbName = this.dbNameFor(column) - const encrypted = this.v3Columns[column] - if (!encrypted) return dbName as DbName - - return ( - this.columnSchemas[dbName]?.indexes?.ope ? `${dbName}->op` : dbName - ) as DbName - } - - private transformToDbSpace(t: TransformOp): DbTransformOp { - switch (t.kind) { - case 'order': - return { ...t, column: this.orderColumnName(t.column) } - // `returns` is in the union but never pushed (`returns()` is a cast). - case 'limit': - case 'range': - case 'single': - case 'maybeSingle': - case 'csv': - case 'abortSignal': - case 'throwOnError': - case 'returns': - return t - default: { - const exhaustive: never = t - return exhaustive - } - } - } - - private mutationToDbSpace(m: MutationOp): DbMutationOp { - switch (m.kind) { - case 'insert': - case 'upsert': - return { ...m, options: this.resolveMutationOptions(m.options) } - case 'update': - case 'delete': - return m // options carry no column names - default: { - const exhaustive: never = m - return exhaustive - } - } + return addJsonbCastsV3(this.selectColumns, this.columns.propToDb) } // --------------------------------------------------------------------------- - // Step 4: Build and execute real Supabase query + // Step 5: Build and execute real Supabase query // --------------------------------------------------------------------------- - protected async buildAndExecuteQuery( + private async buildAndExecuteQuery( encryptedMutation: | Record | Record[] @@ -1454,7 +677,13 @@ export class EncryptedQueryBuilderImpl< } // Apply resolved filters - query = this.applyFilters(query, encryptedFilters, dbSpace) + query = applyFilters( + query, + encryptedFilters, + dbSpace, + this.columns, + this.queryDomainsRequired, + ) // Apply transforms — column names already in DB-space. for (const t of dbSpace.transforms) { @@ -1490,298 +719,6 @@ export class EncryptedQueryBuilderImpl< return result } - // --------------------------------------------------------------------------- - // Apply filters with encrypted values substituted - // --------------------------------------------------------------------------- - - protected applyFilters( - query: SupabaseQueryBuilder, - encryptedFilters: EncryptedFilterState, - dbSpace: DbQuerySpace, - ): SupabaseQueryBuilder { - let q = query - - // Build lookup maps for quick access to encrypted values - const filterValueMap = new Map() - const filterInMap = new Map() // "filterIndex:inIndex" -> value - const matchValueMap = new Map() // "matchIndex:column" -> value - const notValueMap = new Map() - const notInMap = new Map() // "notIndex:inIndex" -> value - const rawValueMap = new Map() - const rawInMap = new Map() // "rawIndex:inIndex" -> value - const orStringConditionMap = new Map() // "orIndex:condIndex" -> value - const orStructuredConditionMap = new Map() - - for (let i = 0; i < encryptedFilters.termMap.length; i++) { - const mapping = encryptedFilters.termMap[i] - const encValue = encryptedFilters.encryptedValues[i] - - switch (mapping.source) { - case 'filter': - if (mapping.inIndex !== undefined) { - filterInMap.set( - `${mapping.filterIndex}:${mapping.inIndex}`, - encValue, - ) - } else { - filterValueMap.set(mapping.filterIndex, encValue) - } - break - case 'match': - matchValueMap.set(`${mapping.matchIndex}:${mapping.column}`, encValue) - break - case 'not': - if (mapping.inIndex !== undefined) { - notInMap.set(`${mapping.notIndex}:${mapping.inIndex}`, encValue) - } else { - notValueMap.set(mapping.notIndex, encValue) - } - break - case 'raw': - if (mapping.inIndex !== undefined) { - rawInMap.set(`${mapping.rawIndex}:${mapping.inIndex}`, encValue) - } else { - rawValueMap.set(mapping.rawIndex, encValue) - } - break - // `inIndex` widens the key to address one element of an `in` list, so a - // whole-condition value and a per-element value never collide. - case 'or-string': - orStringConditionMap.set(orKey(mapping), encValue) - break - case 'or-structured': - orStructuredConditionMap.set(orKey(mapping), encValue) - break - } - } - - // Apply regular filters - for (let i = 0; i < dbSpace.filters.length; i++) { - const f = dbSpace.filters[i] - let value = f.value - - if (filterValueMap.has(i)) { - value = filterValueMap.get(i) - } else if (f.op === 'in' && Array.isArray(f.value)) { - // Reconstruct array with encrypted values substituted - value = f.value.map((v, j) => { - const key = `${i}:${j}` - return filterInMap.has(key) ? filterInMap.get(key) : v - }) - } - - const column = f.column - const wasEncrypted = filterValueMap.has(i) - - switch (f.op) { - case 'eq': - q = q.eq(column, value) - break - case 'neq': - q = q.neq(column, value) - break - case 'gt': - q = q.gt(column, value) - break - case 'gte': - q = q.gte(column, value) - break - case 'lt': - q = q.lt(column, value) - break - case 'lte': - q = q.lte(column, value) - break - case 'like': - case 'ilike': - q = this.applyPatternFilter(q, column, f.op, value, wasEncrypted) - break - // `matches` (encrypted free-text) and `contains` (plaintext / encrypted - // JSON) share the `cs`/`@>` wire operator; the operand encoding is the - // same, so both emit through the one containment applier. - case 'contains': - case 'matches': - q = this.applyContainsFilter(q, column, value, wasEncrypted) - break - case 'is': - q = q.is(column, value) - break - case 'in': - // `wasEncrypted` above is false for in-lists: their ciphertexts land - // in `filterInMap`, keyed per element. - q = this.applyInFilter( - q, - column, - value as unknown[], - Array.isArray(f.value) && - f.value.some((_, j) => filterInMap.has(`${i}:${j}`)), - ) - break - } - } - - // Apply match filters - for (let i = 0; i < dbSpace.matchFilters.length; i++) { - const mf = dbSpace.matchFilters[i] - const resolvedQuery: Record = {} - - for (const { column: colName, value: originalValue } of mf.entries) { - const key = `${i}:${colName}` - resolvedQuery[colName] = matchValueMap.has(key) - ? matchValueMap.get(key) - : originalValue - } - - q = q.match(resolvedQuery) - } - - // Apply not filters - for (let i = 0; i < dbSpace.notFilters.length; i++) { - const nf = dbSpace.notFilters[i] - - if (nf.op === 'in' && Array.isArray(nf.value)) { - const values = nf.value.map((v, j) => - notInMap.has(`${i}:${j}`) ? notInMap.get(`${i}:${j}`) : v, - ) - q = q.not(nf.column, 'in', formatInListOperand(values)) - continue - } - - const wasEncrypted = notValueMap.has(i) - const value = wasEncrypted ? notValueMap.get(i) : nf.value - - // `contains` is a supabase-js METHOD name, not a PostgREST operator, and - // `q.not()` interpolates its operand with `String(value)` — so an array - // arrives brace-less and an object as `[object Object]`. Build the - // containment literal ourselves and emit the `cs` token, exactly as the - // `.or()` path does. A scalar (including the encrypted envelope, already - // serialized) yields `null` and is forwarded untouched. - if (nf.op === 'contains' || nf.op === 'matches') { - const literal = formatContainmentOperand(value) - q = q.not(nf.column, 'cs', literal ?? value) - continue - } - - q = q.not(nf.column, this.notFilterOperator(nf.op, wasEncrypted), value) - } - - // Apply or filters - for (let i = 0; i < dbSpace.orFilters.length; i++) { - const of_ = dbSpace.orFilters[i] - - if (of_.kind === 'string') { - // Already parsed (once) and translated by `toDbSpace`. - const parsed = [...of_.conditions] - - for (let j = 0; j < parsed.length; j++) { - const sub = substituteOrValue(orStringConditionMap, i, j, parsed[j]) - if (sub) { - parsed[j] = { ...parsed[j], value: sub.value } - } - } - - // Rebuild whenever a condition REFERENCES an encrypted column — not - // merely when a value was encrypted. An `is`/null operand on an - // encrypted column encrypts nothing, so keying on "was a value - // substituted" would send that condition down the verbatim path below - // and forward the caller's JS property name to a DB that only knows the - // column's real name. `toDbSpace` has already translated `parsed`. - const referencesEncrypted = parsed.some((c) => - isEncryptedColumn(c.column, this.encryptedColumnNames), - ) - - if (referencesEncrypted) { - q = q.or(rebuildOrString(parsed), { - referencedTable: of_.referencedTable, - }) - } else { - // Every condition names a plaintext column, whose property name IS - // its DB name — nothing to map. Forward the caller's ORIGINAL string - // byte-for-byte: relied on for nested `and()` and quoted values that - // `parseOrString`/`rebuildOrString` cannot round-trip. - q = q.or(of_.original as DbFilterString, { - referencedTable: of_.referencedTable, - }) - } - } else { - // Structured: convert to string - const conditions = of_.conditions.map((cond, j) => { - const sub = substituteOrValue(orStructuredConditionMap, i, j, cond) - return sub ? { ...cond, value: sub.value } : cond - }) - - q = q.or(rebuildOrString(conditions)) - } - } - - // Apply raw filters - for (let i = 0; i < dbSpace.rawFilters.length; i++) { - const rf = dbSpace.rawFilters[i] - - // An encrypted `in` list was encrypted element-wise; reassemble it into - // the quoted PostgREST list literal, exactly as the `not` path does. A - // plaintext column keeps its operand untouched. - if ( - rf.operator === 'in' && - Array.isArray(rf.value) && - isEncryptedColumn(rf.column, this.encryptedColumnNames) - ) { - const values = rf.value.map((v, j) => - rawInMap.has(`${i}:${j}`) ? rawInMap.get(`${i}:${j}`) : v, - ) - q = q.filter(rf.column, rf.operator, formatInListOperand(values)) - continue - } - - const value = rawValueMap.has(i) ? rawValueMap.get(i) : rf.value - q = q.filter(rf.column, rf.operator, value) - } - - return q - } - - // --------------------------------------------------------------------------- - // Dialect seams for native `eql_v3.*` domain columns. - // --------------------------------------------------------------------------- - - /** Resolve a JS property name to its DB column name. `Object.hasOwn` guards - * the inherited-member hazard described on {@link EncryptedTable.buildColumnKeyMap}. */ - private dbNameFor(name: string): string { - return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name - } - - /** - * Map a filter's column name to the DB column name PostgREST must see — - * resolving a JS property name to its DB name. - * - * This is the ONLY place a {@link DbName} is minted. The - * {@link SupabaseQueryBuilder} seam accepts nothing else, so every column - * name reaching PostgREST must pass through here. - */ - protected filterColumnName(column: string): DbName { - return this.dbNameFor(column) as DbName - } - - /** - * Resolve the column names carried by a mutation's options. `onConflict` is a - * comma-separated column list, so it needs the same property→DB mapping as a - * filter. Returns the original object when nothing changed. - */ - protected resolveMutationOptions< - O extends { onConflict?: string } | undefined, - >(options: O): DbMutationOptions | undefined { - if (!options?.onConflict) return options as DbMutationOptions | undefined - const mapped = options.onConflict - .split(',') - .map((column) => this.filterColumnName(column.trim())) - .join(',') as DbConflictList - return ( - mapped === options.onConflict - ? options - : { ...options, onConflict: mapped } - ) as DbMutationOptions - } - /** * `ORDER BY` on an OPE-backed column is supported; on every other encrypted * column it is rejected. @@ -1810,17 +747,17 @@ export class EncryptedQueryBuilderImpl< * - neither → reject. Storage-only, equality-only and match-only columns * carry no ordering term to sort by. * - * A column absent from {@link v3Columns} is a plaintext passthrough and orders + * A column with no encrypted builder is a plaintext passthrough and orders * normally. This runtime guard is the only protection the untyped * (no-`schemas`) surface has. */ - protected validateTransforms(): void { + private validateTransforms(): void { for (const t of this.transforms) { if (t.kind !== 'order') continue - const column = this.v3Columns[t.column] + const column = this.columns.encryptedColumn(t.column) if (!column) continue - const indexes = this.columnSchemas[column.getName()]?.indexes + const indexes = this.columns.schemaFor(column.getName())?.indexes if (indexes?.ope) continue const reason = indexes?.ore @@ -1835,328 +772,18 @@ export class EncryptedQueryBuilderImpl< } } - /** - * Resolve a raw `.filter()` operator to the capability it exercises. A - * supported v3 operand is a full storage envelope, so `queryType` never - * selects a narrowing — it only tells {@link assertTermQueryable} which - * capability to demand of the column. - * - * Unknown operators throw rather than silently defaulting to equality, which - * would encrypt a term the column may not even be able to compare. - */ - protected queryTypeForRawOp(operator: string): QueryTypeName { - switch (operator) { - case 'cs': - return 'freeTextSearch' - case 'gt': - case 'gte': - case 'lt': - case 'lte': - return 'orderAndRange' - case 'eq': - case 'neq': - case 'in': - case 'is': - return 'equality' - default: - throw new Error( - `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, - ) - } - } - - /** - * Apply an `in` filter. - * - * A plaintext list goes to postgrest-js's `in()`, which quotes elements that - * contain `,()`. An ENCRYPTED list cannot: every element is a - * `JSON.stringify`d envelope, and `in()` wraps it in `"…"` without escaping - * the quotes inside it, so PostgREST terminates the value at the envelope's - * first `"`. Emit the operand ourselves and hand it to `filter()`, which - * forwards it verbatim. - */ - protected applyInFilter( - q: SupabaseQueryBuilder, - column: DbName, - values: unknown[], - wasEncrypted: boolean, - ): SupabaseQueryBuilder { - if (!wasEncrypted) return q.in(column, values) - return q.filter(column, 'in', formatInListOperand(values)) - } - - /** - * Apply a `like`/`ilike` filter. On an encrypted column `like`/`ilike` were - * rewritten to `matches` at record time, so a `like`/`ilike` pending filter - * only ever names a plaintext column, which keeps real SQL LIKE. - */ - protected applyPatternFilter( - q: SupabaseQueryBuilder, - column: DbName, - op: 'like' | 'ilike', - value: unknown, - _wasEncrypted: boolean, - ): SupabaseQueryBuilder { - return op === 'like' - ? q.like(column, value as string) - : q.ilike(column, value as string) - } - - /** - * Apply a `contains` filter. On a plaintext column this is PostgREST's native - * jsonb/array containment. On an encrypted column `cs` resolves to the `@>` - * operator the EQL bundle declares on the domain, backed by `eql_v3.matches` - * (bloom-filter containment) — and the operand is the full storage envelope, - * already `JSON.stringify`d, emitted via `filter(col, 'cs', json)` rather than - * `q.contains` (postgrest-js's `contains` re-serializes a non-string operand). - * - * A structured plaintext operand is serialized here rather than by - * postgrest-js, which joins array elements on `,` without quoting them — so - * `['with,comma']` would reach Postgres as two elements. Scalars keep the - * native path. - */ - protected applyContainsFilter( - q: SupabaseQueryBuilder, - column: DbName, - value: unknown, - wasEncrypted: boolean, - ): SupabaseQueryBuilder { - if (wasEncrypted) { - this.assertPostgrestCanQueryEncryptedOperator('filter', column) - return q.filter(column, 'cs', value) - } - const literal = formatContainmentOperand(value) - return literal !== null - ? q.filter(column, 'cs', literal) - : q.contains(column, value) - } - - /** - * The CipherStash query type for an `.or()` condition's operator on an - * encrypted column. String-form conditions carry raw PostgREST operators - * (`cs`), which are not {@link FilterOp}s. - */ - protected queryTypeForOrOp(op: FilterOp): QueryTypeName { - if (op === 'matches') return 'freeTextSearch' - // Structured conditions may carry the `contains` METHOD spelling (the wire - // token becomes `cs` in rebuildOrString). It maps to the same capability - // gate as `cs`; on a JSON column the term resolver then re-types it to - // searchableJson and validates the operand. selectorNe's IS-NULL-inclusive - // or-form relies on this arm. - if (op === 'contains') return 'freeTextSearch' - return this.queryTypeForRawOp(op) - } - - /** - * The PostgREST operator to use for a `.not()` filter. Every {@link FilterOp} - * except `contains` spells the same as its PostgREST operator; `contains` is - * handled before this is reached, because it also needs its operand rewritten. - */ - protected notFilterOperator(op: FilterOp, _wasEncrypted: boolean): string { - return op - } - - /** - * Post-process a decrypted result row: rebuild `Date` values from the - * encrypt-config `cast_as` (date/timestamp), mirroring the typed v3 client's - * decrypt-model path. - */ - protected postprocessDecryptedRow( - row: Record, - ): Record { - // Every key an encrypted column can appear under: the keys this select - // actually produces (including caller-chosen aliases like `ts:createdAt`), - // plus the static property and DB names as a fallback for paths that record - // no select. Aliases win. Derived here from `this.selectColumns` (the row in - // hand) rather than cached from `buildSelectString`, so a reused builder can - // never postprocess a row with a previous operation's stale select map. - const keyToDb: Record = Object.assign( - Object.create(null), - this.selectColumns === null - ? undefined - : selectKeyToDbV3(this.selectColumns, this.propToDb), - ) - for (const [property, dbName] of Object.entries(this.propToDb)) { - keyToDb[property] ??= dbName - keyToDb[dbName] ??= dbName - } - - const out: Record = { ...row } - for (const [key, dbName] of Object.entries(keyToDb)) { - const castAs = this.columnSchemas[dbName]?.cast_as - if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue - const value = out[key] - if (value == null || value instanceof Date) continue - if (typeof value === 'string' || typeof value === 'number') { - out[key] = new Date(value) - } - } - return out - } - - // --------------------------------------------------------------------------- - // Step 5: Decrypt results - // --------------------------------------------------------------------------- - - protected async decryptResults( - result: RawSupabaseResult, - ): Promise> { - // If there's an error from Supabase, pass it through - if (result.error) { - return { - data: null, - error: { - message: result.error.message, - details: result.error.details, - hint: result.error.hint, - code: result.error.code, - }, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // No data to decrypt - if (result.data === null || result.data === undefined) { - return { - data: null, - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // Determine if we need to decrypt - const hasSelect = this.selectColumns !== null - const hasMutationWithReturning = this.mutation !== null && hasSelect - - if (!hasSelect && !hasMutationWithReturning) { - // No select means no data to decrypt (e.g., insert without .select()) - return { - data: result.data as T[], - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // Decrypt based on result mode - if (this.resultMode === 'single' || this.resultMode === 'maybeSingle') { - if (result.data === null) { - return { - data: null, - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // Single result — decrypt one model - const baseDecryptOp = this.encryptionClient.decryptModel( - result.data as Record, - ) - const decryptOp = this.lockContext - ? baseDecryptOp.withLockContext(this.lockContext) - : baseDecryptOp - if (this.auditConfig) decryptOp.audit(this.auditConfig) - - const decrypted = await decryptOp - if (decrypted.failure) { - logger.error( - `Supabase: failed to decrypt model for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to decrypt model: ${decrypted.failure.message}`, - decrypted.failure, - ) - } - - return { - data: this.postprocessDecryptedRow( - decrypted.data as Record, - ) as unknown as T[], - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // Array result — bulk decrypt - const dataArray = result.data as Record[] - if (dataArray.length === 0) { - return { - data: [] as unknown as T[], - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - const baseBulkDecryptOp = this.encryptionClient.bulkDecryptModels(dataArray) - const bulkDecryptOp = this.lockContext - ? baseBulkDecryptOp.withLockContext(this.lockContext) - : baseBulkDecryptOp - if (this.auditConfig) bulkDecryptOp.audit(this.auditConfig) - - const decrypted = await bulkDecryptOp - if (decrypted.failure) { - logger.error( - `Supabase: failed to decrypt models for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to decrypt models: ${decrypted.failure.message}`, - decrypted.failure, - ) - } - - return { - data: decrypted.data.map((row) => - this.postprocessDecryptedRow(row as Record), - ) as unknown as T[], - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- - protected getColumnMap(): Record { - return this.v3Columns as unknown as Record - } - - /** Warn once per (op, column) that a `like`/`ilike` was delegated to `matches`. */ - private static readonly warnedLikeDelegation = new Set() - - /** True when `column` is one of this table's encrypted v3 columns. */ - private isEncryptedV3Column(column: string): boolean { - return Boolean(this.v3Columns[column]) - } - - /** True when `column` is an encrypted `types.Json` document column. */ - private isSearchableJsonColumn(column: string): boolean { - const builder: V3ColumnLike | undefined = this.v3Columns[column] - return Boolean(builder?.getQueryCapabilities().searchableJson) - } - - private assertPostgrestCanQueryEncryptedOperator( + private assertPostgrestCanQueryEncrypted( method: string, column: string, ): void { - if (!this.queryDomainsRequired) return - throw new Error( - `[supabase v3]: ${method}() on encrypted column "${column}" is unavailable with EQL 3.0.2+: the SQL operator requires an eql_v3.query_* cast that PostgREST cannot express. Use the Drizzle or Prisma Next adapter, or a scoped SQL/RPC function.`, + assertPostgrestCanQueryEncryptedOperator( + this.queryDomainsRequired, + method, + column, ) } @@ -2172,7 +799,7 @@ export class EncryptedQueryBuilderImpl< path: string, value: unknown, ): Record { - if (!this.isSearchableJsonColumn(column)) { + if (!this.columns.isSearchableJsonColumn(column)) { throw new Error( `[supabase v3]: ${method}() requires an encrypted JSON (types.Json) column; "${column}" is not one.`, ) @@ -2225,8 +852,8 @@ export class EncryptedQueryBuilderImpl< ) } const key = `${op}:${column}` - if (!EncryptedQueryBuilderImpl.warnedLikeDelegation.has(key)) { - EncryptedQueryBuilderImpl.warnedLikeDelegation.add(key) + if (!warnedLikeDelegation.has(key)) { + warnedLikeDelegation.add(key) logger.warn( `[supabase v3]: "${op}" on encrypted column "${column}" is delegated to matches() (fuzzy bloom token search). Results are APPROXIMATE — case-insensitive, one-sided (may false-positive), and wildcards/anchoring are not honored. Call matches() directly to make this explicit.`, ) @@ -2234,98 +861,3 @@ export class EncryptedQueryBuilderImpl< return needle } } - -// --------------------------------------------------------------------------- -// Internal types -// --------------------------------------------------------------------------- - -type TermMapping = - | { source: 'filter'; filterIndex: number; inIndex?: number } - | { source: 'match'; matchIndex: number; column: string } - | { source: 'not'; notIndex: number; inIndex?: number } - | { source: 'raw'; rawIndex: number; inIndex?: number } - | { - source: 'or-string' - orIndex: number - conditionIndex: number - inIndex?: number - } - | { - source: 'or-structured' - orIndex: number - conditionIndex: number - inIndex?: number - } - -type EncryptedFilterState = { - // `EncryptedQueryResult[]`, not `unknown[]` — `encryptCollectedTerms` returns - // that type, and typing the field to match is what lets the restored envelope - // type reach the use site (`encryptedValues[i]`) instead of widening back to - // `unknown` at this boundary. - encryptedValues: EncryptedQueryResult[] - termMap: TermMapping[] -} - -/** Key an `.or()` condition, or one element of its `in` list. */ -function orKey(mapping: { - orIndex: number - conditionIndex: number - inIndex?: number -}): string { - const base = `${mapping.orIndex}:${mapping.conditionIndex}` - return mapping.inIndex === undefined ? base : `${base}:${mapping.inIndex}` -} - -/** - * Substitute encrypted operands back into one `.or()` condition, returning - * `undefined` when nothing was encrypted for it. - * - * An `in` list is reconstructed element-by-element so `formatOrValue` re-emits - * the `(a,b)` list form. Substituting the array as a single value would collapse - * it to one ciphertext that matches nothing. - */ -function substituteOrValue( - map: Map, - orIndex: number, - conditionIndex: number, - cond: { op: FilterOp; value: unknown }, -): { value: unknown } | undefined { - const whole = orKey({ orIndex, conditionIndex }) - if (map.has(whole)) return { value: map.get(whole) } - - if (cond.op === 'in' && Array.isArray(cond.value)) { - let substituted = false - const value = cond.value.map((element, inIndex) => { - const key = orKey({ orIndex, conditionIndex, inIndex }) - if (!map.has(key)) return element - substituted = true - return map.get(key) - }) - if (substituted) return { value } - } - - return undefined -} - -type RawSupabaseResult = { - data: unknown - error: { - message: string - details?: string - hint?: string - code?: string - } | null - count?: number | null - status: number - statusText: string -} - -export class EncryptionFailedError extends Error { - public encryptionError: EncryptionError - - constructor(message: string, encryptionError: EncryptionError) { - super(message) - this.name = 'EncryptionFailedError' - this.encryptionError = encryptionError - } -} diff --git a/packages/stack-supabase/src/query-dbspace.ts b/packages/stack-supabase/src/query-dbspace.ts new file mode 100644 index 00000000..ae68df56 --- /dev/null +++ b/packages/stack-supabase/src/query-dbspace.ts @@ -0,0 +1,141 @@ +import type { ColumnMap } from './column-map' +import { parseOrString } from './helpers' +import type { + DbConflictList, + DbMutationOp, + DbMutationOptions, + DbPendingOrCondition, + DbPendingOrFilter, + DbQuerySpace, + DbTransformOp, + MutationOp, + PendingOrCondition, + PendingOrFilter, + RecordedOps, + TransformOp, +} from './types' + +/** + * Resolve the column names carried by a mutation's options. `onConflict` is a + * comma-separated column list, so it needs the same property→DB mapping as a + * filter. Returns the original object when nothing changed. + */ +export function resolveMutationOptions< + O extends { onConflict?: string } | undefined, +>(options: O, columns: ColumnMap): DbMutationOptions | undefined { + if (!options?.onConflict) return options as DbMutationOptions | undefined + const mapped = options.onConflict + .split(',') + .map((column) => columns.filterColumnName(column.trim())) + .join(',') as DbConflictList + return ( + mapped === options.onConflict ? options : { ...options, onConflict: mapped } + ) as DbMutationOptions +} + +/** Column names only. Which conditions were encrypted is never decided here: + * it stays derived at apply time from the substitution maps, so this pass + * never has to agree with the encryption predicate. The operator token is + * settled later still, in `rebuildOrString`, where `contains` becomes `cs` + * for encrypted and plaintext conditions alike. */ +function orFilterToDbSpace( + of_: PendingOrFilter, + columns: ColumnMap, +): DbPendingOrFilter { + const toDbCondition = (c: PendingOrCondition): DbPendingOrCondition => ({ + ...c, + column: columns.filterColumnName(c.column), + }) + + if (of_.kind === 'string') { + return { + kind: 'string', + original: of_.value, + conditions: parseOrString(of_.value).map(toDbCondition), + referencedTable: of_.referencedTable, + } + } + return { kind: 'structured', conditions: of_.conditions.map(toDbCondition) } +} + +function transformToDbSpace(t: TransformOp, columns: ColumnMap): DbTransformOp { + switch (t.kind) { + case 'order': + return { ...t, column: columns.orderColumnName(t.column) } + // `returns` is in the union but never pushed (`returns()` is a cast). + case 'limit': + case 'range': + case 'single': + case 'maybeSingle': + case 'csv': + case 'abortSignal': + case 'throwOnError': + case 'returns': + return t + default: { + const exhaustive: never = t + return exhaustive + } + } +} + +function mutationToDbSpace(m: MutationOp, columns: ColumnMap): DbMutationOp { + switch (m.kind) { + case 'insert': + case 'upsert': + return { ...m, options: resolveMutationOptions(m.options, columns) } + case 'update': + case 'delete': + return m // options carry no column names + default: { + const exhaustive: never = m + return exhaustive + } + } +} + +/** + * Translate every recorded column name from JS property space into DB space, + * once. Downstream (`encryptFilterValues`, `applyFilters`, + * `buildAndExecuteQuery`) consumes only the branded result, so a column can + * no longer reach PostgREST untranslated — that is a compile error. + * + * Total: `filterColumnName`, `parseOrString`, and `resolveMutationOptions` + * never throw, so this introduces no new early-throw point and cannot perturb + * the order in which capability errors surface. + * + * Safe to run BEFORE encryption: the column map is keyed by both property and + * DB name, so column lookup resolves identically either side of the + * translation, and `tableColumns[prop]` is the very same builder object as + * `tableColumns[db]`. + */ +export function toDbSpace( + recorded: RecordedOps, + columns: ColumnMap, +): DbQuerySpace { + return { + filters: recorded.filters.map((f) => ({ + ...f, + column: columns.filterColumnName(f.column), + })), + matchFilters: recorded.matchFilters.map((mf) => ({ + entries: Object.entries(mf.query).map(([column, value]) => ({ + column: columns.filterColumnName(column), + value, + })), + })), + notFilters: recorded.notFilters.map((nf) => ({ + ...nf, + column: columns.filterColumnName(nf.column), + })), + rawFilters: recorded.rawFilters.map((rf) => ({ + ...rf, + column: columns.filterColumnName(rf.column), + })), + orFilters: recorded.orFilters.map((of_) => orFilterToDbSpace(of_, columns)), + transforms: recorded.transforms.map((t) => transformToDbSpace(t, columns)), + mutation: recorded.mutation + ? mutationToDbSpace(recorded.mutation, columns) + : null, + } +} diff --git a/packages/stack-supabase/src/query-encrypt.ts b/packages/stack-supabase/src/query-encrypt.ts new file mode 100644 index 00000000..56ac8d65 --- /dev/null +++ b/packages/stack-supabase/src/query-encrypt.ts @@ -0,0 +1,659 @@ +import type { JsPlaintext } from '@cipherstash/protect-ffi' +import type { AuditConfig } from '@cipherstash/stack/adapter-kit' +import { logger, matchNeedleError } from '@cipherstash/stack/adapter-kit' +import type { EncryptionClient } from '@cipherstash/stack/encryption' +import type { AnyV3Table } from '@cipherstash/stack/eql/v3' +import { + type EncryptionError, + EncryptionErrorTypes, +} from '@cipherstash/stack/errors' +import type { LockContextInput } from '@cipherstash/stack/identity' +import type { + Encrypted, + EncryptedQueryResult, + QueryTypeName, + ScalarQueryTerm, +} from '@cipherstash/stack/types' +import type { ColumnMap, V3ColumnLike } from './column-map' +import { + isEncryptableTerm, + isEncryptedColumn, + mapFilterOpToQueryType, +} from './helpers' +import type { DbQuerySpace, FilterOp } from './types' + +export class EncryptionFailedError extends Error { + public encryptionError: EncryptionError + + constructor(message: string, encryptionError: EncryptionError) { + super(message) + this.name = 'EncryptionFailedError' + this.encryptionError = encryptionError + } +} + +export type TermMapping = + | { source: 'filter'; filterIndex: number; inIndex?: number } + | { source: 'match'; matchIndex: number; column: string } + | { source: 'not'; notIndex: number; inIndex?: number } + | { source: 'raw'; rawIndex: number; inIndex?: number } + | { + source: 'or-string' + orIndex: number + conditionIndex: number + inIndex?: number + } + | { + source: 'or-structured' + orIndex: number + conditionIndex: number + inIndex?: number + } + +export type EncryptedFilterState = { + // `EncryptedQueryResult[]`, not `unknown[]` — `encryptCollectedTerms` returns + // that type, and typing the field to match is what lets the restored envelope + // type reach the use site (`encryptedValues[i]`) instead of widening back to + // `unknown` at this boundary. + encryptedValues: EncryptedQueryResult[] + termMap: TermMapping[] +} + +/** + * Everything an encryption step needs from the builder. Assembled per + * `execute()`, so `lockContext`/`auditConfig` are read at execution time — not + * captured when the builder was constructed. + */ +export type EncryptionContext = { + tableName: string + table: AnyV3Table + encryptionClient: EncryptionClient + lockContext: LockContextInput | null + auditConfig: AuditConfig | null + columns: ColumnMap + /** EQL 3.0.2+ requires query-domain casts PostgREST cannot express. */ + queryDomainsRequired: boolean +} + +/** + * Apply the builder's lock context and audit config to a pending operation. + * + * Every encrypt/decrypt crossing in this adapter goes through the same three + * steps, and they must stay identical: an operation that silently skipped the + * lock context would encrypt under the wrong data key. + */ +export function withOpContext( + baseOp: PromiseLike & { + withLockContext( + lockContext: LockContextInput, + ): PromiseLike & { audit(config: AuditConfig): unknown } + audit(config: AuditConfig): unknown + }, + ctx: Pick, +): PromiseLike { + const op = ctx.lockContext ? baseOp.withLockContext(ctx.lockContext) : baseOp + if (ctx.auditConfig) op.audit(ctx.auditConfig) + return op +} + +/** + * EQL 3.0.2 removed the storage/jsonb escape hatch for free-text and JSON + * operators: those now require typed query-domain operands PostgREST cannot + * express. Fail before encryption, so a decryptable storage envelope never + * enters a GET URL. + */ +export function assertPostgrestCanQueryEncryptedOperator( + queryDomainsRequired: boolean, + method: string, + column: string, +): void { + if (!queryDomainsRequired) return + throw new Error( + `[supabase v3]: ${method}() on encrypted column "${column}" is unavailable with EQL 3.0.2+: the SQL operator requires an eql_v3.query_* cast that PostgREST cannot express. Use the Drizzle or Prisma Next adapter, or a scoped SQL/RPC function.`, + ) +} + +/** + * Validate an encrypted-JSON containment operand: a NON-EMPTY plain object or a + * non-empty array. Everything else is rejected with an actionable steer: + * + * - Scalars/strings: the caller meant free-text (`matches` on a text column) or + * a selector — a raw JSON string is NOT parsed, by design (parsing would make + * `'{"a":1}'` and `{a:1}` silently different queries on other surfaces). + * - Non-plain objects (`Date`, `Map`, `RegExp`, class instances): these JSON- + * serialize to scalars or `{}` — not the sub-document the caller believes. + * - `{}` and `[]`: jsonb containment holds for EVERY document (`doc @> '{}'`), + * so an accidentally-empty needle would silently return (and decrypt) the + * whole table. The Drizzle adapter rejects the same needle for the same + * reason — the two first-party adapters must agree that this is an error. + */ +export function assertJsonContainmentOperand( + column: string, + value: unknown, +): void { + const isPlainObject = + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) + if (!isPlainObject && !Array.isArray(value)) { + // Array.isArray is false on this branch by construction, so the label only + // distinguishes null / non-plain object / scalar. + const got = + value === null + ? 'null' + : typeof value === 'object' + ? (value as object).constructor?.name || 'a non-plain object' + : typeof value + throw new Error( + `[supabase v3]: encrypted JSON containment on column "${column}" takes a sub-document (plain object or array) to match, got ${got}.`, + ) + } + const empty = Array.isArray(value) + ? value.length === 0 + : Object.keys(value as object).length === 0 + if (empty) { + throw new Error( + `[supabase v3]: encrypted JSON containment on column "${column}" cannot take an empty ${Array.isArray(value) ? 'array' : 'object'} needle: it matches every row. Pass a non-empty sub-document, or omit the predicate to select all rows.`, + ) + } +} + +/** + * Resolve a raw `.filter()` operator to the capability it exercises. A + * supported v3 operand is a full storage envelope, so `queryType` never + * selects a narrowing — it only tells {@link assertTermQueryable} which + * capability to demand of the column. + * + * Unknown operators throw rather than silently defaulting to equality, which + * would encrypt a term the column may not even be able to compare. + */ +export function queryTypeForRawOp(operator: string): QueryTypeName { + switch (operator) { + case 'cs': + return 'freeTextSearch' + case 'gt': + case 'gte': + case 'lt': + case 'lte': + return 'orderAndRange' + case 'eq': + case 'neq': + case 'in': + case 'is': + return 'equality' + default: + throw new Error( + `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, + ) + } +} + +/** + * The CipherStash query type for an `.or()` condition's operator on an + * encrypted column. String-form conditions carry raw PostgREST operators + * (`cs`), which are not {@link FilterOp}s. + */ +export function queryTypeForOrOp(op: FilterOp): QueryTypeName { + if (op === 'matches') return 'freeTextSearch' + // Structured conditions may carry the `contains` METHOD spelling (the wire + // token becomes `cs` in rebuildOrString). It maps to the same capability + // gate as `cs`; on a JSON column the term resolver then re-types it to + // searchableJson and validates the operand. selectorNe's IS-NULL-inclusive + // or-form relies on this arm. + if (op === 'contains') return 'freeTextSearch' + return queryTypeForRawOp(op) +} + +function encryptionFailure( + tableName: string, + message: string, + cause?: EncryptionError, +): never { + logger.error( + `Supabase: failed to encrypt query terms for table "${tableName}"`, + ) + // Most callers pass the operation's own `EncryptionError`; the contract- + // violation cases (bulk length mismatch, null envelope) have none, so + // synthesize one — a broken query encryption is still an encryption failure, + // and callers branch on `error.encryptionError` regardless. + throw new EncryptionFailedError( + `Failed to encrypt query terms: ${message}`, + cause ?? { type: EncryptionErrorTypes.EncryptionError, message }, + ) +} + +// --------------------------------------------------------------------------- +// Step 3: Encrypt filter values +// --------------------------------------------------------------------------- + +export async function encryptFilterValues( + dbSpace: DbQuerySpace, + ctx: EncryptionContext, +): Promise { + // Collect all terms that need encryption + const terms: ScalarQueryTerm[] = [] + const termMap: TermMapping[] = [] + + const tableColumns = ctx.columns.queryColumnMap() + const encryptedColumnNames = ctx.columns.encryptedColumnNames + + const pushTerm = ( + value: JsPlaintext, + column: ScalarQueryTerm['column'], + queryType: QueryTypeName, + mapping: TermMapping, + ) => { + terms.push({ + value, + column, + table: ctx.table, + queryType, + returnType: 'composite-literal', + }) + termMap.push(mapping) + } + + /** + * Collect one term per element of an `in`-list operand. + * + * Element-wise is the only correct encoding: encrypting the array as ONE + * value collapses `(a,b)` into a single ciphertext that matches nothing. A + * null element is SQL NULL and passes through unencrypted; the applier + * restores it by index, which is why the mapping carries `inIndex`. + * + * Shared by the regular-`in`, `not(…,'in',…)` and or-condition paths. They + * drifted apart once already — the `not` path went unfixed while the other + * two encrypted element-wise — so they are kept in lockstep here rather than + * spelled out three times. + */ + const collectInListTerms = ( + op: FilterOp, + values: readonly unknown[], + column: ScalarQueryTerm['column'], + queryType: QueryTypeName, + mappingFor: (inIndex: number) => TermMapping, + ) => { + for (let j = 0; j < values.length; j++) { + if (!isEncryptableTerm(op, values[j])) continue + pushTerm(values[j] as JsPlaintext, column, queryType, mappingFor(j)) + } + } + + // Regular filters + for (let i = 0; i < dbSpace.filters.length; i++) { + const f = dbSpace.filters[i] + if (!isEncryptedColumn(f.column, encryptedColumnNames)) continue + + const column = tableColumns[f.column] + if (!column) continue + + if (f.op === 'in' && Array.isArray(f.value)) { + collectInListTerms( + f.op, + f.value, + column, + mapFilterOpToQueryType(f.op), + (inIndex) => ({ source: 'filter', filterIndex: i, inIndex }), + ) + } else if (!isEncryptableTerm(f.op, f.value)) { + // `is` predicate or null operand — forwarded unencrypted. + } else { + pushTerm(f.value as JsPlaintext, column, mapFilterOpToQueryType(f.op), { + source: 'filter', + filterIndex: i, + }) + } + } + + // Match filters + for (let i = 0; i < dbSpace.matchFilters.length; i++) { + const mf = dbSpace.matchFilters[i] + for (const { column: colName, value } of mf.entries) { + if (!isEncryptedColumn(colName, encryptedColumnNames)) continue + // `match` carries no operator; equality is implied. + if (!isEncryptableTerm('eq', value)) continue + const column = tableColumns[colName] + if (!column) continue + + pushTerm(value as JsPlaintext, column, 'equality', { + source: 'match', + matchIndex: i, + column: colName, + }) + } + } + + // Not filters + for (let i = 0; i < dbSpace.notFilters.length; i++) { + const nf = dbSpace.notFilters[i] + if (!isEncryptedColumn(nf.column, encryptedColumnNames)) continue + if (!isEncryptableTerm(nf.op, nf.value)) continue + const column = tableColumns[nf.column] + if (!column) continue + + if (nf.op === 'in') { + // A PostgREST list literal (`'(a,b)'`) cannot be encrypted element-wise, + // and encrypting it whole matches nothing. Refuse it rather than emit a + // filter that silently returns no rows. + if (!Array.isArray(nf.value)) { + throw new Error( + `not("${nf.column}", "in", …) on an encrypted column requires an array of values, ` + + `not a PostgREST list literal — each element must be encrypted separately`, + ) + } + collectInListTerms( + nf.op, + nf.value, + column, + mapFilterOpToQueryType(nf.op), + (inIndex) => ({ source: 'not', notIndex: i, inIndex }), + ) + continue + } + + pushTerm(nf.value as JsPlaintext, column, mapFilterOpToQueryType(nf.op), { + source: 'not', + notIndex: i, + }) + } + + // Or filters — conditions were parsed once, in `toDbSpace`. The string and + // structured forms differ only in their `source` tag; the encryption rules, + // including the `in`-list split below, are identical. + for (let i = 0; i < dbSpace.orFilters.length; i++) { + const of_ = dbSpace.orFilters[i] + const source = of_.kind === 'string' ? 'or-string' : 'or-structured' + + for (let j = 0; j < of_.conditions.length; j++) { + const cond = of_.conditions[j] + if (!isEncryptedColumn(cond.column, encryptedColumnNames)) continue + const column = tableColumns[cond.column] + if (!column) continue + + // `queryTypeForOrOp`, not `mapFilterOpToQueryType`: an or-condition may + // carry a raw PostgREST operator (`cs`), which is not a `FilterOp`. + const queryType = queryTypeForOrOp(cond.op) + const mappingFor = (inIndex?: number): TermMapping => ({ + source, + orIndex: i, + conditionIndex: j, + inIndex, + }) + + if (cond.op === 'in' && Array.isArray(cond.value)) { + collectInListTerms(cond.op, cond.value, column, queryType, mappingFor) + continue + } + + if (!isEncryptableTerm(cond.op, cond.value)) continue + pushTerm(cond.value as JsPlaintext, column, queryType, mappingFor()) + } + } + + // Raw filters + for (let i = 0; i < dbSpace.rawFilters.length; i++) { + const rf = dbSpace.rawFilters[i] + if (!isEncryptedColumn(rf.column, encryptedColumnNames)) continue + const column = tableColumns[rf.column] + if (!column) continue + + if (rf.operator === 'in') { + // Same contract as the `not(…, 'in', …)` path: a PostgREST list literal + // (`'("a","b")'`) cannot be encrypted element-wise, and encrypting it + // whole matches nothing. Refuse it rather than emit a filter that + // silently returns no rows. + if (!Array.isArray(rf.value)) { + throw new Error( + `filter("${rf.column}", "in", …) on an encrypted column requires an array of values, ` + + `not a PostgREST list literal — each element must be encrypted separately`, + ) + } + collectInListTerms( + 'in', + rf.value, + column, + queryTypeForRawOp(rf.operator), + (inIndex) => ({ source: 'raw', rawIndex: i, inIndex }), + ) + continue + } + + if (!isEncryptableTerm(rf.operator, rf.value)) continue + + pushTerm(rf.value as JsPlaintext, column, queryTypeForRawOp(rf.operator), { + source: 'raw', + rawIndex: i, + }) + } + + if (terms.length === 0) { + return { encryptedValues: [], termMap: [] } + } + + const encryptedValues = await encryptCollectedTerms(terms, ctx) + return { encryptedValues, termMap } +} + +/** + * Encrypt every filter operand as a full storage envelope, serialized to jsonb + * text for the PostgREST filter value. + * + * Terms are grouped by column and each group takes ONE `bulkEncrypt` crossing. + * `in(col, [a, b, c])` collects one term per element (the list must never be + * encrypted whole), so encrypting per term spent N ZeroKMS/FFI round-trips + * where one would do. `bulkEncrypt` carries a single `{table, column}` for the + * whole payload, so the grouping is mandatory, not an optimisation: one bulk + * call over a mixed-column term array would stamp one column onto every + * plaintext. Results are scattered back onto the terms' original indices, + * which is the contract `termMap` downstream relies on. + * + * Mirrors `eql/v3/drizzle/operators.ts` `encryptOperands` — same batching + * contract, same length assertion, same fallback. Kept separate because that + * one encrypts a single-column operand list and returns `SQL[]`, while this + * must group a multi-column term array and preserve positions. + */ +async function encryptCollectedTerms( + terms: ScalarQueryTerm[], + ctx: EncryptionContext, +): Promise { + const groups = new Map< + V3ColumnLike, + { indices: number[]; values: ScalarQueryTerm['value'][] } + >() + terms.forEach((term, index) => { + const column = assertTermQueryable(term, ctx) + const group = groups.get(column) ?? { indices: [], values: [] } + group.indices.push(index) + group.values.push(term.value) + groups.set(column, group) + }) + + const bulkEncrypt = ctx.encryptionClient.bulkEncrypt?.bind( + ctx.encryptionClient, + ) + // Each term becomes the `JSON.stringify`'d storage envelope — a `string`, + // which is one arm of `EncryptedQueryResult`. PostgREST cannot cast a filter + // value to the `eql_v3.query_` twins, so v3 sends full envelopes, + // serialized to jsonb text. + const results = new Array(terms.length) + + await Promise.all( + Array.from(groups, async ([column, { indices, values }]) => { + const encrypted = bulkEncrypt + ? await bulkEncryptGroup(bulkEncrypt, column, values, ctx) + : await encryptGroupPerTerm(column, values, ctx) + + encrypted.forEach((envelope, i) => { + results[indices[i]] = JSON.stringify(envelope) + }) + }), + ) + + return results +} + +/** + * Validate a term's query type against its column's declared capabilities. + * Pure validation: `encrypt`/`bulkEncrypt` never receive the query type. On + * EQL 3.0.2+, free-text/JSON terms are rejected before this storage-encryption + * path can place ciphertext in a GET URL. + * + * Exported for direct testing: no public call path can produce an unsupported + * `queryType` (`mapFilterOpToQueryType`, {@link queryTypeForRawOp} and + * {@link queryTypeForOrOp} are exhaustive), so that backstop is only reachable + * by calling this with a hand-built term. + */ +export function assertTermQueryable( + term: ScalarQueryTerm, + ctx: EncryptionContext, +): V3ColumnLike { + const column = term.column as unknown as V3ColumnLike + let queryType = term.queryType ?? 'equality' + const capabilities = column.getQueryCapabilities() + + // The `cs` wire operator is capability-overloaded: bloom free-text on a + // match/search TEXT column, encrypted ste_vec containment on a `types.Json` + // DOCUMENT column. Both arrive here as `freeTextSearch` (contains/matches/ + // raw `cs` all map there); resolve to the capability the column actually + // carries. The two are mutually exclusive by construction, so this can + // never reinterpret a real free-text column. + if ( + queryType === 'freeTextSearch' && + !capabilities.freeTextSearch && + capabilities.searchableJson + ) { + queryType = 'searchableJson' + } + + if ( + queryType !== 'equality' && + queryType !== 'orderAndRange' && + queryType !== 'freeTextSearch' && + queryType !== 'searchableJson' + ) { + throw new Error( + `[supabase v3]: query type "${queryType}" is not supported on EQL v3 columns`, + ) + } + + if (!capabilities[queryType]) { + throw new Error( + `[supabase v3]: column "${column.getName()}" (${column.getEqlType()}) does not support ${queryType} queries — declare the column with a domain that carries that capability`, + ) + } + + if (queryType === 'freeTextSearch' || queryType === 'searchableJson') { + // This is the common boundary for every spelling that collects an + // encrypted match/containment term: matches(), contains(), not(), raw + // filter(), and both forms of or(). Method-level checks provide earlier + // errors for the direct helpers, but cannot cover the raw filter paths on + // their own. + assertPostgrestCanQueryEncryptedOperator( + ctx.queryDomainsRequired, + 'filter', + column.getName(), + ) + } + + if (queryType === 'searchableJson') { + // THE single enforced operand boundary for encrypted-JSON containment. + // Terms reach this resolver from every spelling — contains(), raw + // .filter(col,'cs',…), not(col,'contains'|'matches',…), and .or() + // string/structured conditions — and only contains() has a method-level + // guard. Without this check a raw string (e.g. a free-text term ported + // from a text column, or an .or() condition value, which is always a + // string) would be storage-encrypted as a JSON SCALAR and silently match + // nothing; pre-#650 every such spelling failed loudly on capability. + assertJsonContainmentOperand(column.getName(), term.value) + } + + // Free-text (bloom) needle floor. A needle shorter than the tokenizer's + // token_length produces NO tokens, so `bf @> '{}'` holds for every row and + // the query would silently return (and the caller decrypt) the whole table + // — a fail-open over-exposure. Reject it up front, mirroring the Drizzle v3 + // adapter (matchNeedleError) so both first-party surfaces guard identically. + // JSON containment terms (searchableJson) are validated separately above. + if (queryType === 'freeTextSearch') { + const match = column.build().indexes?.match + const reason = match ? matchNeedleError(term.value, match) : undefined + if (reason) { + throw new Error( + `[supabase v3]: cannot search column "${column.getName()}": ${reason}`, + ) + } + } + + return column +} + +/** One FFI crossing for a column's whole operand list. */ +async function bulkEncryptGroup( + bulkEncrypt: NonNullable, + column: V3ColumnLike, + values: ScalarQueryTerm['value'][], + ctx: EncryptionContext, +): Promise> { + const result = await withOpContext( + bulkEncrypt( + values.map((plaintext) => ({ plaintext })) as never, + { + column, + table: ctx.table, + } as never, + ), + ctx, + ) + if (result.failure) + encryptionFailure(ctx.tableName, result.failure.message, result.failure) + + // `bulkEncrypt` is position-stable, so a length mismatch means the contract + // was violated. Truncating instead would silently widen an `in` predicate + // (or narrow a `not.in`) to whatever came back. `result.data` is now + // `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]` — not `unknown`. + const encrypted = result.data + if (encrypted.length !== values.length) { + encryptionFailure( + ctx.tableName, + `bulk encryption returned ${encrypted.length} terms for ${values.length} values on column "${column.getName()}".`, + ) + } + return encrypted.map((term, i) => { + // `BulkEncryptedData` types the element as `Encrypted | null`. A `null` + // envelope here would be `JSON.stringify`'d to the literal string `"null"` + // and sent as the filter operand — silently matching whatever `"null"` + // encodes to rather than failing. A query term should never encrypt to a + // null envelope, so treat it as a contract violation, not a value. + if (term.data === null) { + encryptionFailure( + ctx.tableName, + `bulk encryption returned a null envelope at position ${i} for column "${column.getName()}".`, + ) + } + return term.data + }) +} + +/** Fallback for a client that predates `bulkEncrypt`. */ +async function encryptGroupPerTerm( + column: V3ColumnLike, + values: ScalarQueryTerm['value'][], + ctx: EncryptionContext, +): Promise { + return Promise.all( + values.map(async (value) => { + const result = await withOpContext( + ctx.encryptionClient.encrypt(value, { + column, + table: ctx.table, + }), + ctx, + ) + if (result.failure) { + encryptionFailure(ctx.tableName, result.failure.message, result.failure) + } + return result.data + }), + ) +} diff --git a/packages/stack-supabase/src/query-filters.ts b/packages/stack-supabase/src/query-filters.ts new file mode 100644 index 00000000..fdad1d10 --- /dev/null +++ b/packages/stack-supabase/src/query-filters.ts @@ -0,0 +1,388 @@ +import type { ColumnMap } from './column-map' +import { + formatContainmentOperand, + formatInListOperand, + isEncryptedColumn, + rebuildOrString, +} from './helpers' +import { + assertPostgrestCanQueryEncryptedOperator, + type EncryptedFilterState, +} from './query-encrypt' +import type { + DbFilterString, + DbName, + DbQuerySpace, + FilterOp, + SupabaseQueryBuilder, +} from './types' + +/** Key an `.or()` condition, or one element of its `in` list. */ +function orKey(mapping: { + orIndex: number + conditionIndex: number + inIndex?: number +}): string { + const base = `${mapping.orIndex}:${mapping.conditionIndex}` + return mapping.inIndex === undefined ? base : `${base}:${mapping.inIndex}` +} + +/** + * Substitute encrypted operands back into one `.or()` condition, returning + * `undefined` when nothing was encrypted for it. + * + * An `in` list is reconstructed element-by-element so `formatOrValue` re-emits + * the `(a,b)` list form. Substituting the array as a single value would collapse + * it to one ciphertext that matches nothing. + */ +function substituteOrValue( + map: Map, + orIndex: number, + conditionIndex: number, + cond: { op: FilterOp; value: unknown }, +): { value: unknown } | undefined { + const whole = orKey({ orIndex, conditionIndex }) + if (map.has(whole)) return { value: map.get(whole) } + + if (cond.op === 'in' && Array.isArray(cond.value)) { + let substituted = false + const value = cond.value.map((element, inIndex) => { + const key = orKey({ orIndex, conditionIndex, inIndex }) + if (!map.has(key)) return element + substituted = true + return map.get(key) + }) + if (substituted) return { value } + } + + return undefined +} + +/** + * Apply an `in` filter. + * + * A plaintext list goes to postgrest-js's `in()`, which quotes elements that + * contain `,()`. An ENCRYPTED list cannot: every element is a + * `JSON.stringify`d envelope, and `in()` wraps it in `"…"` without escaping + * the quotes inside it, so PostgREST terminates the value at the envelope's + * first `"`. Emit the operand ourselves and hand it to `filter()`, which + * forwards it verbatim. + */ +function applyInFilter( + q: SupabaseQueryBuilder, + column: DbName, + values: unknown[], + wasEncrypted: boolean, +): SupabaseQueryBuilder { + if (!wasEncrypted) return q.in(column, values) + return q.filter(column, 'in', formatInListOperand(values)) +} + +/** + * Apply a `like`/`ilike` filter. On an encrypted column `like`/`ilike` were + * rewritten to `matches` at record time, so a `like`/`ilike` pending filter + * only ever names a plaintext column, which keeps real SQL LIKE. + */ +function applyPatternFilter( + q: SupabaseQueryBuilder, + column: DbName, + op: 'like' | 'ilike', + value: unknown, +): SupabaseQueryBuilder { + return op === 'like' + ? q.like(column, value as string) + : q.ilike(column, value as string) +} + +/** + * Apply a `contains` filter. On a plaintext column this is PostgREST's native + * jsonb/array containment. On an encrypted column `cs` resolves to the `@>` + * operator the EQL bundle declares on the domain, backed by `eql_v3.matches` + * (bloom-filter containment) — and the operand is the full storage envelope, + * already `JSON.stringify`d, emitted via `filter(col, 'cs', json)` rather than + * `q.contains` (postgrest-js's `contains` re-serializes a non-string operand). + * + * A structured plaintext operand is serialized here rather than by + * postgrest-js, which joins array elements on `,` without quoting them — so + * `['with,comma']` would reach Postgres as two elements. Scalars keep the + * native path. + */ +function applyContainsFilter( + q: SupabaseQueryBuilder, + column: DbName, + value: unknown, + wasEncrypted: boolean, + queryDomainsRequired: boolean, +): SupabaseQueryBuilder { + if (wasEncrypted) { + assertPostgrestCanQueryEncryptedOperator( + queryDomainsRequired, + 'filter', + column, + ) + return q.filter(column, 'cs', value) + } + const literal = formatContainmentOperand(value) + return literal !== null + ? q.filter(column, 'cs', literal) + : q.contains(column, value) +} + +/** + * Apply every recorded filter to the real Supabase query, substituting the + * encrypted operand wherever one was produced for that position. + */ +export function applyFilters( + query: SupabaseQueryBuilder, + encryptedFilters: EncryptedFilterState, + dbSpace: DbQuerySpace, + columns: ColumnMap, + queryDomainsRequired: boolean, +): SupabaseQueryBuilder { + let q = query + const encryptedColumnNames = columns.encryptedColumnNames + + // Build lookup maps for quick access to encrypted values + const filterValueMap = new Map() + const filterInMap = new Map() // "filterIndex:inIndex" -> value + const matchValueMap = new Map() // "matchIndex:column" -> value + const notValueMap = new Map() + const notInMap = new Map() // "notIndex:inIndex" -> value + const rawValueMap = new Map() + const rawInMap = new Map() // "rawIndex:inIndex" -> value + const orStringConditionMap = new Map() // "orIndex:condIndex" -> value + const orStructuredConditionMap = new Map() + + for (let i = 0; i < encryptedFilters.termMap.length; i++) { + const mapping = encryptedFilters.termMap[i] + const encValue = encryptedFilters.encryptedValues[i] + + switch (mapping.source) { + case 'filter': + if (mapping.inIndex !== undefined) { + filterInMap.set(`${mapping.filterIndex}:${mapping.inIndex}`, encValue) + } else { + filterValueMap.set(mapping.filterIndex, encValue) + } + break + case 'match': + matchValueMap.set(`${mapping.matchIndex}:${mapping.column}`, encValue) + break + case 'not': + if (mapping.inIndex !== undefined) { + notInMap.set(`${mapping.notIndex}:${mapping.inIndex}`, encValue) + } else { + notValueMap.set(mapping.notIndex, encValue) + } + break + case 'raw': + if (mapping.inIndex !== undefined) { + rawInMap.set(`${mapping.rawIndex}:${mapping.inIndex}`, encValue) + } else { + rawValueMap.set(mapping.rawIndex, encValue) + } + break + // `inIndex` widens the key to address one element of an `in` list, so a + // whole-condition value and a per-element value never collide. + case 'or-string': + orStringConditionMap.set(orKey(mapping), encValue) + break + case 'or-structured': + orStructuredConditionMap.set(orKey(mapping), encValue) + break + } + } + + // Apply regular filters + for (let i = 0; i < dbSpace.filters.length; i++) { + const f = dbSpace.filters[i] + let value = f.value + + if (filterValueMap.has(i)) { + value = filterValueMap.get(i) + } else if (f.op === 'in' && Array.isArray(f.value)) { + // Reconstruct array with encrypted values substituted + value = f.value.map((v, j) => { + const key = `${i}:${j}` + return filterInMap.has(key) ? filterInMap.get(key) : v + }) + } + + const column = f.column + const wasEncrypted = filterValueMap.has(i) + + switch (f.op) { + case 'eq': + q = q.eq(column, value) + break + case 'neq': + q = q.neq(column, value) + break + case 'gt': + q = q.gt(column, value) + break + case 'gte': + q = q.gte(column, value) + break + case 'lt': + q = q.lt(column, value) + break + case 'lte': + q = q.lte(column, value) + break + case 'like': + case 'ilike': + q = applyPatternFilter(q, column, f.op, value) + break + // `matches` (encrypted free-text) and `contains` (plaintext / encrypted + // JSON) share the `cs`/`@>` wire operator; the operand encoding is the + // same, so both emit through the one containment applier. + case 'contains': + case 'matches': + q = applyContainsFilter( + q, + column, + value, + wasEncrypted, + queryDomainsRequired, + ) + break + case 'is': + q = q.is(column, value) + break + case 'in': + // `wasEncrypted` above is false for in-lists: their ciphertexts land + // in `filterInMap`, keyed per element. + q = applyInFilter( + q, + column, + value as unknown[], + Array.isArray(f.value) && + f.value.some((_, j) => filterInMap.has(`${i}:${j}`)), + ) + break + } + } + + // Apply match filters + for (let i = 0; i < dbSpace.matchFilters.length; i++) { + const mf = dbSpace.matchFilters[i] + const resolvedQuery: Record = {} + + for (const { column: colName, value: originalValue } of mf.entries) { + const key = `${i}:${colName}` + resolvedQuery[colName] = matchValueMap.has(key) + ? matchValueMap.get(key) + : originalValue + } + + q = q.match(resolvedQuery) + } + + // Apply not filters + for (let i = 0; i < dbSpace.notFilters.length; i++) { + const nf = dbSpace.notFilters[i] + + if (nf.op === 'in' && Array.isArray(nf.value)) { + const values = nf.value.map((v, j) => + notInMap.has(`${i}:${j}`) ? notInMap.get(`${i}:${j}`) : v, + ) + q = q.not(nf.column, 'in', formatInListOperand(values)) + continue + } + + const wasEncrypted = notValueMap.has(i) + const value = wasEncrypted ? notValueMap.get(i) : nf.value + + // `contains` is a supabase-js METHOD name, not a PostgREST operator, and + // `q.not()` interpolates its operand with `String(value)` — so an array + // arrives brace-less and an object as `[object Object]`. Build the + // containment literal ourselves and emit the `cs` token, exactly as the + // `.or()` path does. A scalar (including the encrypted envelope, already + // serialized) yields `null` and is forwarded untouched. + if (nf.op === 'contains' || nf.op === 'matches') { + const literal = formatContainmentOperand(value) + q = q.not(nf.column, 'cs', literal ?? value) + continue + } + + // Every `FilterOp` except `contains` spells the same as its PostgREST + // operator, and `contains` was handled above (it also needs its operand + // rewritten), so the recorded op is the wire op. + q = q.not(nf.column, nf.op, value) + } + + // Apply or filters + for (let i = 0; i < dbSpace.orFilters.length; i++) { + const of_ = dbSpace.orFilters[i] + + if (of_.kind === 'string') { + // Already parsed (once) and translated by `toDbSpace`. + const parsed = [...of_.conditions] + + for (let j = 0; j < parsed.length; j++) { + const sub = substituteOrValue(orStringConditionMap, i, j, parsed[j]) + if (sub) { + parsed[j] = { ...parsed[j], value: sub.value } + } + } + + // Rebuild whenever a condition REFERENCES an encrypted column — not + // merely when a value was encrypted. An `is`/null operand on an + // encrypted column encrypts nothing, so keying on "was a value + // substituted" would send that condition down the verbatim path below + // and forward the caller's JS property name to a DB that only knows the + // column's real name. `toDbSpace` has already translated `parsed`. + const referencesEncrypted = parsed.some((c) => + isEncryptedColumn(c.column, encryptedColumnNames), + ) + + if (referencesEncrypted) { + q = q.or(rebuildOrString(parsed), { + referencedTable: of_.referencedTable, + }) + } else { + // Every condition names a plaintext column, whose property name IS + // its DB name — nothing to map. Forward the caller's ORIGINAL string + // byte-for-byte: relied on for nested `and()` and quoted values that + // `parseOrString`/`rebuildOrString` cannot round-trip. + q = q.or(of_.original as DbFilterString, { + referencedTable: of_.referencedTable, + }) + } + } else { + // Structured: convert to string + const conditions = of_.conditions.map((cond, j) => { + const sub = substituteOrValue(orStructuredConditionMap, i, j, cond) + return sub ? { ...cond, value: sub.value } : cond + }) + + q = q.or(rebuildOrString(conditions)) + } + } + + // Apply raw filters + for (let i = 0; i < dbSpace.rawFilters.length; i++) { + const rf = dbSpace.rawFilters[i] + + // An encrypted `in` list was encrypted element-wise; reassemble it into + // the quoted PostgREST list literal, exactly as the `not` path does. A + // plaintext column keeps its operand untouched. + if ( + rf.operator === 'in' && + Array.isArray(rf.value) && + isEncryptedColumn(rf.column, encryptedColumnNames) + ) { + const values = rf.value.map((v, j) => + rawInMap.has(`${i}:${j}`) ? rawInMap.get(`${i}:${j}`) : v, + ) + q = q.filter(rf.column, rf.operator, formatInListOperand(values)) + continue + } + + const value = rawValueMap.has(i) ? rawValueMap.get(i) : rf.value + q = q.filter(rf.column, rf.operator, value) + } + + return q +} diff --git a/packages/stack-supabase/src/query-mutation.ts b/packages/stack-supabase/src/query-mutation.ts new file mode 100644 index 00000000..154f3244 --- /dev/null +++ b/packages/stack-supabase/src/query-mutation.ts @@ -0,0 +1,79 @@ +import { logger } from '@cipherstash/stack/adapter-kit' +import type { ColumnMap } from './column-map' +import { + type EncryptionContext, + EncryptionFailedError, + withOpContext, +} from './query-encrypt' +import type { MutationOp } from './types' + +/** + * Encode an encrypted model for the Supabase request body. The native + * `eql_v3.*` domains are plain jsonb, so the raw encrypted payload is sent + * (keyed by DB column name). + */ +function transformEncryptedMutationModel( + model: Record, + columns: ColumnMap, +): Record { + const out: Record = Object.create(null) + for (const [key, value] of Object.entries(model)) { + out[columns.dbNameFor(key)] = value + } + return out +} + +/** + * Encrypt a mutation's row data — the values being STORED, as opposed to the + * filter operands being searched by (`./query-encrypt`). `delete` carries no + * data, and a builder with no recorded mutation encrypts nothing. + */ +export async function encryptMutationData( + mutation: MutationOp | null, + ctx: EncryptionContext, +): Promise | Record[] | null> { + if (!mutation) return null + if (mutation.kind === 'delete') return null + + const data = mutation.data + + if (Array.isArray(data)) { + // Bulk encrypt + const result = await withOpContext( + ctx.encryptionClient.bulkEncryptModels(data, ctx.table), + ctx, + ) + if (result.failure) { + logger.error( + `Supabase: failed to encrypt models for table "${ctx.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to encrypt models: ${result.failure.message}`, + result.failure, + ) + } + + return result.data.map((model) => + transformEncryptedMutationModel(model, ctx.columns), + ) + } + + // Single model + const result = await withOpContext( + ctx.encryptionClient.encryptModel(data, ctx.table), + ctx, + ) + if (result.failure) { + logger.error( + `Supabase: failed to encrypt model for table "${ctx.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to encrypt model: ${result.failure.message}`, + result.failure, + ) + } + + return transformEncryptedMutationModel(result.data, ctx.columns) +} diff --git a/packages/stack-supabase/src/query-results.ts b/packages/stack-supabase/src/query-results.ts new file mode 100644 index 00000000..8c1812bd --- /dev/null +++ b/packages/stack-supabase/src/query-results.ts @@ -0,0 +1,205 @@ +import { DATE_LIKE_CASTS, logger } from '@cipherstash/stack/adapter-kit' +import { selectKeyToDbV3 } from './helpers' +import { + type EncryptionContext, + EncryptionFailedError, + withOpContext, +} from './query-encrypt' +import type { EncryptedSupabaseResponse, ResultMode } from './types' + +/** cast_as kinds that reconstruct to a JS `Date` — shared with the typed v3 + * client's decrypt-model path (see `encryption/v3.ts`). */ +const DATE_LIKE_CAST_SET = new Set(DATE_LIKE_CASTS) + +export type RawSupabaseResult = { + data: unknown + error: { + message: string + details?: string + hint?: string + code?: string + } | null + count?: number | null + status: number + statusText: string +} + +/** What the decrypt step needs beyond the shared encryption context. */ +export type DecryptContext = EncryptionContext & { + selectColumns: string | null + resultMode: ResultMode + hasMutation: boolean +} + +/** + * Post-process a decrypted result row: rebuild `Date` values from the + * encrypt-config `cast_as` (date/timestamp), mirroring the typed v3 client's + * decrypt-model path. + */ +function postprocessDecryptedRow( + row: Record, + ctx: DecryptContext, +): Record { + // Every key an encrypted column can appear under: the keys this select + // actually produces (including caller-chosen aliases like `ts:createdAt`), + // plus the static property and DB names as a fallback for paths that record + // no select. Aliases win. Derived here from `ctx.selectColumns` (the row in + // hand) rather than cached from `buildSelectString`, so a reused builder can + // never postprocess a row with a previous operation's stale select map. + const propToDb = ctx.columns.propToDb + const keyToDb: Record = Object.assign( + Object.create(null), + ctx.selectColumns === null + ? undefined + : selectKeyToDbV3(ctx.selectColumns, propToDb), + ) + for (const [property, dbName] of Object.entries(propToDb)) { + keyToDb[property] ??= dbName + keyToDb[dbName] ??= dbName + } + + const out: Record = { ...row } + for (const [key, dbName] of Object.entries(keyToDb)) { + const castAs = ctx.columns.schemaFor(dbName)?.cast_as + if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue + const value = out[key] + if (value == null || value instanceof Date) continue + if (typeof value === 'string' || typeof value === 'number') { + out[key] = new Date(value) + } + } + return out +} + +/** + * Decrypt a PostgREST response into the caller's row type. + * + * Reads EQL v3 only. A column carrying legacy EQL v2 ciphertext is never in + * this adapter's encrypt config — introspection recognises `public.eql_v3_*` + * domains exclusively — so it is returned as an untouched passthrough rather + * than decrypted. To read v2 data, decrypt fetched rows with the core + * `@cipherstash/stack` client, whose decrypt path is generation-agnostic. + */ +export async function decryptResults>( + result: RawSupabaseResult, + ctx: DecryptContext, +): Promise> { + // If there's an error from Supabase, pass it through + if (result.error) { + return { + data: null, + error: { + message: result.error.message, + details: result.error.details, + hint: result.error.hint, + code: result.error.code, + }, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // No data to decrypt + if (result.data === null || result.data === undefined) { + return { + data: null, + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // Determine if we need to decrypt + const hasSelect = ctx.selectColumns !== null + const hasMutationWithReturning = ctx.hasMutation && hasSelect + + if (!hasSelect && !hasMutationWithReturning) { + // No select means no data to decrypt (e.g., insert without .select()) + return { + data: result.data as T[], + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // Decrypt based on result mode + if (ctx.resultMode === 'single' || ctx.resultMode === 'maybeSingle') { + if (result.data === null) { + return { + data: null, + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // Single result — decrypt one model + const decrypted = await withOpContext( + ctx.encryptionClient.decryptModel(result.data as Record), + ctx, + ) + if (decrypted.failure) { + logger.error( + `Supabase: failed to decrypt model for table "${ctx.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to decrypt model: ${decrypted.failure.message}`, + decrypted.failure, + ) + } + + return { + data: postprocessDecryptedRow( + decrypted.data as Record, + ctx, + ) as unknown as T[], + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // Array result — bulk decrypt + const dataArray = result.data as Record[] + if (dataArray.length === 0) { + return { + data: [] as unknown as T[], + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + const decrypted = await withOpContext( + ctx.encryptionClient.bulkDecryptModels(dataArray), + ctx, + ) + if (decrypted.failure) { + logger.error( + `Supabase: failed to decrypt models for table "${ctx.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to decrypt models: ${decrypted.failure.message}`, + decrypted.failure, + ) + } + + return { + data: decrypted.data.map((row) => + postprocessDecryptedRow(row as Record, ctx), + ) as unknown as T[], + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } +} diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index cc5378d5..c74fc9e6 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -106,7 +106,7 @@ type NonScalarQueryableV3Keys
= { * the table's encrypted columns with no scalar capability (storage-only * columns, and `types.Json` documents — see {@link NonScalarQueryableV3Keys}; * before #650's `searchableJson` arm the two sets coincided). Plaintext - * (non-schema) columns pass through untouched, exactly as in v2. + * (non-schema) columns pass through untouched. */ export type FilterableKeys< Table extends AnyV3Table, @@ -380,11 +380,12 @@ export interface EncryptedQueryBuilder< } /** - * The v3 builder for a table with no declared schema. Without capability + * The builder for a table with no declared schema. Without capability * information `contains` cannot be narrowed to match-indexed columns — the - * runtime guard in the term-encryption path is the only protection — but the - * DIALECT is still v3, so `like`/`ilike` are absent here too. Typing this as - * {@link EncryptedQueryBuilder} would hand back the v2 surface. + * runtime guard in the term-encryption path is the only protection — and + * `order()`/`is(col, true)` cannot be narrowed either, so this surface takes + * {@link EncryptedQueryBuilderCore}'s `OK`/`BK` defaults. `like`/`ilike` are + * absent here as on the typed surface. * * For the same reason nothing here can tell an encrypted match column from a * plaintext jsonb one, so `matches`/`contains` accept the full native operand @@ -631,7 +632,7 @@ export type DbMutationOptions = Record & { // --------------------------------------------------------------------------- // DB-space IR — the recorded query, with every column name translated. // -// `toDbSpace()` (see ./query-builder) maps the property-space IR above into +// `toDbSpace()` (see ./query-dbspace) maps the property-space IR above into // this one, exactly once, before any column name can reach PostgREST. The // branded `column` fields make that translation a compile-time obligation: // `applyFilters`/`buildAndExecuteQuery` consume only these types, so feeding @@ -681,6 +682,19 @@ export type DbMutationOp = | Extract | Extract +/** The whole recorded query, in PROPERTY space — the builder's chained state as + * handed to `toDbSpace()`. The mirror of {@link DbQuerySpace} on the untranslated + * side of that boundary. */ +export type RecordedOps = { + filters: PendingFilter[] + matchFilters: PendingMatchFilter[] + notFilters: PendingNotFilter[] + rawFilters: PendingRawFilter[] + orFilters: PendingOrFilter[] + transforms: TransformOp[] + mutation: MutationOp | null +} + /** The whole recorded query, in DB-space. */ export type DbQuerySpace = { filters: DbPendingFilter[] @@ -771,33 +785,43 @@ export type { type StringKeyOf = Extract /** - * Every builder method shared by the v2 and v3 dialects. `Self` is the concrete - * builder each method returns, so a dialect that omits a method (v3 omits - * `like`/`ilike`) does not have it laundered back in by a chained call whose - * return type widened to the base interface. + * Every builder method shared by the TYPED ({@link EncryptedQueryBuilder}) and + * UNTYPED ({@link EncryptedQueryBuilderUntyped}) surfaces. Both are EQL v3 — + * they differ only in how much they can narrow, not in dialect. + * + * `Self` is the concrete builder each method returns, so a surface that omits a + * method does not have it laundered back in by a chained call whose return type + * widened to the base interface. * - * Free-text search is the ONLY axis on which the two dialects differ: v2 - * matches with SQL wildcards (`like`/`ilike` → `~~`), v3 with token containment - * (`contains` → `@>`). Each adds its own method below. + * Free-text search lives on the sub-interfaces rather than here, because its + * key set differs between the two: `matches()` narrows to the encrypted + * match/search columns on the typed surface, and to every row key on the + * untyped one. Neither surface carries `like`/`ilike` — EQL v3 free-text is + * fuzzy bloom-token matching, not SQL pattern matching, so the builder rewrites + * a `like` on an encrypted column to `matches` at record time (see + * `query-builder.ts`). They survive in this file only as the internal + * {@link FilterOp} union and on the raw {@link SupabaseQueryBuilder} seam, both + * of which still serve plaintext columns. */ export interface EncryptedQueryBuilderCore< T extends Record, FK extends StringKeyOf, Self, - /** Keys `order()` accepts. Defaults to `FK`, so the v2 surface is unchanged; - * v3 narrows it to plaintext columns (see {@link OrderableKeys}). */ + /** Keys `order()` accepts. The typed surface narrows it to the orderable + * columns (see {@link OrderableKeys}); it defaults to `FK` for the untyped + * surface, which has no capability information to narrow with. */ OK extends StringKeyOf = FK, - /** Keys the BOOLEAN form of `is()` accepts. Defaults to `FK`, so the v2 - * surface is unchanged; v3 narrows it to plaintext columns. Distinct from - * `OK` on purpose: "sortable" and "IS TRUE-able" are different capability - * axes that happen to select the same keys today, and narrowing `order()` - * later must not silently narrow `is()` with it. */ + /** Keys the BOOLEAN form of `is()` accepts. The typed surface narrows it to + * plaintext columns; it defaults to `FK` for the untyped surface, as `OK` + * does. Distinct from `OK` on purpose: "sortable" and "IS TRUE-able" are + * different capability axes that happen to select the same keys today, and + * narrowing `order()` later must not silently narrow `is()` with it. */ BK extends StringKeyOf = FK, > extends PromiseLike> { /** `columns` defaults to `'*'`, matching supabase-js. A `'*'` select expands - * to the introspected column list when one is available (v3), and otherwise - * throws — v2 has no column list to cast, so `select()` and `select('*')` - * both throw there. */ + * to the introspected column list; when none is available (a client that + * could not introspect) both `select()` and `select('*')` throw, because an + * unexpanded `*` cannot cast the encrypted columns with `::jsonb`. */ select( columns?: string, options?: { head?: boolean; count?: 'exact' | 'planned' | 'estimated' }, @@ -863,9 +887,10 @@ export interface EncryptedQueryBuilderCore< options?: { referencedTable?: string; foreignTable?: string }, ): Self match(query: Partial): Self - // `OK`, not `FK`: v3 cannot order by ANY encrypted column, because PostgREST - // cannot emit `ORDER BY eql_v3.ord_term(col)` and a bare `ORDER BY` sorts the - // ciphertext envelope. `OK` defaults to `FK`, so the v2 surface is unchanged. + // `OK`, not `FK`: an encrypted column is orderable only when its domain + // carries an OPE term (PostgREST reaches it as `col->op`); a bare `ORDER BY` + // would sort the ciphertext envelope. `OK` defaults to `FK` on the untyped + // surface, where the runtime `validateTransforms` guard is the only check. order( column: K, options?: { From 3d69a59be5617dd1795a703b7702cadf8001dad5 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 12:39:38 +1000 Subject: [PATCH 7/7] fix(stack-supabase)!: type single()/maybeSingle() as one row, not an array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review of the query-builder split. Six findings, all pre-dating the refactor; these are the two worth acting on. `single()`/`maybeSingle()` have always returned ONE object at runtime, but returned `Self`, so the builder kept advertising the array shape it was created with — `data` was typed `T[] | null` while holding a single row. Callers had to launder it, and the test suite documented the lie in a comment while casting through `unknown` to reach the row's fields. Both now return `EncryptedSingleQueryBuilder`, awaiting `EncryptedSupabaseResponse` (`data: T | null`) — which already covers the zero-row case for `maybeSingle()` and the error case for both, so no separate null modelling was needed. The impl class carries the awaited shape as a `TData` parameter so the promise cannot keep advertising `T[]` after the runtime has been switched to single-row mode; `returns()` preserves that shape. Filters and transforms are deliberately absent from the single-row builder, matching supabase-js: applying one after `single()` would change the query the single-row promise was made about. Also drops two unnecessary `as unknown as T[]` bridges in query-results.ts (`[] as T[]` and the bulk-decrypt map both compile directly). Not acted on, with reasons: - The missing `assertPostgrestCanQueryEncryptedOperator` in the not-filter branch is a false positive. The guard fires upstream in `assertTermQueryable` (`contains`/`matches` map to `freeTextSearch`), before encryption, and `supabase-v3-json.test.ts` already covers `.not(col,'contains')` and `.not(col,'matches')` under EQL 3.0.2. The suggested guard keys on `wasEncrypted`, which that path never reaches. - Routing plaintext `in` arrays through `formatInListOperand` would change behaviour: `.filter()` is the raw escape hatch and forwards verbatim, as supabase-js does. The encrypted path only intervenes because it must encrypt element-wise. - The `as never` on `bulkEncrypt` args and the `term.column` double assertion need `ScalarQueryTerm['column']` widened in `@cipherstash/stack` — a different package's public type. --- .changeset/supabase-single-row-typing.md | 32 +++++++++++++++++ .../__tests__/supabase-v3-builder.test.ts | 9 +++-- packages/stack-supabase/src/query-builder.ts | 36 +++++++++++++------ packages/stack-supabase/src/query-results.ts | 17 +++++---- packages/stack-supabase/src/types.ts | 32 +++++++++++++++-- 5 files changed, 103 insertions(+), 23 deletions(-) create mode 100644 .changeset/supabase-single-row-typing.md diff --git a/.changeset/supabase-single-row-typing.md b/.changeset/supabase-single-row-typing.md new file mode 100644 index 00000000..eff4d8e0 --- /dev/null +++ b/.changeset/supabase-single-row-typing.md @@ -0,0 +1,32 @@ +--- +'@cipherstash/stack-supabase': major +--- + +`single()` and `maybeSingle()` now type `data` as the ROW, not an array. + +Both have always returned one object at runtime, but the builder kept +advertising the array shape it was created with, so `data` was typed `T[] | null` +while holding a single row. Every caller had to launder it: + +```typescript +const { data } = await supabase.from('users').select('id, email').single() +// before: data is `User[] | null` — wrong; a cast was the only way through +const user = data as unknown as User +// after: data is `User | null` +data?.email +``` + +`single()`/`maybeSingle()` now return `EncryptedSingleQueryBuilder`, which +awaits to `EncryptedSupabaseResponse` (`data: T | null`). That covers the +zero-row case for `maybeSingle()` and the error case for both, so no separate +null modelling was needed. + +Filters and transforms are not chainable after `single()`/`maybeSingle()`, +matching supabase-js — applying one afterwards would change the query the +single-row promise was made about. `returns()` preserves the awaited shape, +so `.single().returns()` still awaits one row. + +**Migration:** delete the cast. Code that worked around the old typing with +`data as unknown as Row` (or read `data![0]`) should now use `data` directly; +the cast still compiles but is no longer needed, and `data![0]` becomes a type +error. diff --git a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts index 60d26c8f..ce9835a2 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts @@ -949,11 +949,10 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(error).toBeNull() expect(supabase.callsFor('single')).toHaveLength(1) expect(Array.isArray(data)).toBe(false) - // `data` is declared `T[] | null` on the shared builder surface; single() - // narrows it to one row at runtime only. - const single = data as unknown as { email: string; createdAt: Date } - expect(single.email).toBe('a@b.com') - expect(single.createdAt).toBeInstanceOf(Date) + // `single()` narrows the awaited shape to ONE row at the type level too, + // so `data` is the row itself — no cast needed to reach its fields. + expect(data?.email).toBe('a@b.com') + expect(data?.createdAt).toBeInstanceOf(Date) }) it('maybeSingle() returns null for null result data without throwing', async () => { diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index 2b299965..b2c9652e 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -88,6 +88,11 @@ const warnedLikeDelegation = new Set() */ export class EncryptedQueryBuilderImpl< T extends Record = Record, + /** The shape this builder awaits to. `T[]` normally; narrowed to `T` by + * {@link single}/{@link maybeSingle}, which return ONE row. Carried as a + * parameter so the promise cannot keep advertising `T[]` after the runtime + * has been switched to single-row mode. */ + TData = T[], > { private tableName: string private table: AnyV3Table @@ -465,16 +470,19 @@ export class EncryptedQueryBuilderImpl< return this } - single(): this { + single(): EncryptedQueryBuilderImpl { this.resultMode = 'single' this.transforms.push({ kind: 'single' }) - return this + // Type-level narrowing only; builder state is preserved. `TData` appears in + // `then`/`execute` return positions, so the two instantiations are not + // mutually assignable and `this` cannot be re-typed without an assertion. + return this as unknown as EncryptedQueryBuilderImpl } - maybeSingle(): this { + maybeSingle(): EncryptedQueryBuilderImpl { this.resultMode = 'maybeSingle' this.transforms.push({ kind: 'maybeSingle' }) - return this + return this as unknown as EncryptedQueryBuilderImpl } csv(): this { @@ -493,9 +501,17 @@ export class EncryptedQueryBuilderImpl< return this } - returns>(): EncryptedQueryBuilderImpl { + /** Re-type the ROW. The awaited SHAPE is preserved: called after + * `single()`/`maybeSingle()` this still awaits one row, not `U[]`. */ + returns>(): EncryptedQueryBuilderImpl< + U, + TData extends readonly unknown[] ? U[] : U + > { // Type-level cast only; builder state is preserved - return this as unknown as EncryptedQueryBuilderImpl + return this as unknown as EncryptedQueryBuilderImpl< + U, + TData extends readonly unknown[] ? U[] : U + > } // --------------------------------------------------------------------------- @@ -516,10 +532,10 @@ export class EncryptedQueryBuilderImpl< // PromiseLike implementation (deferred execution) // --------------------------------------------------------------------------- - then, TResult2 = never>( + then, TResult2 = never>( onfulfilled?: | (( - value: EncryptedSupabaseResponse, + value: EncryptedSupabaseResponse, ) => TResult1 | PromiseLike) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, @@ -531,7 +547,7 @@ export class EncryptedQueryBuilderImpl< // Core execution // --------------------------------------------------------------------------- - private async execute(): Promise> { + private async execute(): Promise> { try { logger.debug(`Supabase encrypted query on table "${this.tableName}".`) @@ -558,7 +574,7 @@ export class EncryptedQueryBuilderImpl< ) // 6. Decrypt results - return await decryptResults(result, { + return await decryptResults(result, { ...ctx, selectColumns: this.selectColumns, resultMode: this.resultMode, diff --git a/packages/stack-supabase/src/query-results.ts b/packages/stack-supabase/src/query-results.ts index 8c1812bd..5838e012 100644 --- a/packages/stack-supabase/src/query-results.ts +++ b/packages/stack-supabase/src/query-results.ts @@ -80,10 +80,13 @@ function postprocessDecryptedRow( * than decrypted. To read v2 data, decrypt fetched rows with the core * `@cipherstash/stack` client, whose decrypt path is generation-agnostic. */ -export async function decryptResults>( +export async function decryptResults< + T extends Record, + TData = T[], +>( result: RawSupabaseResult, ctx: DecryptContext, -): Promise> { +): Promise> { // If there's an error from Supabase, pass it through if (result.error) { return { @@ -118,7 +121,7 @@ export async function decryptResults>( if (!hasSelect && !hasMutationWithReturning) { // No select means no data to decrypt (e.g., insert without .select()) return { - data: result.data as T[], + data: result.data as TData, error: null, count: result.count ?? null, status: result.status, @@ -155,10 +158,12 @@ export async function decryptResults>( } return { + // ONE row — `TData` is `T` on this path (`single`/`maybeSingle` narrow it), + // so no array wrapping and no cast pretending otherwise. data: postprocessDecryptedRow( decrypted.data as Record, ctx, - ) as unknown as T[], + ) as TData, error: null, count: result.count ?? null, status: result.status, @@ -170,7 +175,7 @@ export async function decryptResults>( const dataArray = result.data as Record[] if (dataArray.length === 0) { return { - data: [] as unknown as T[], + data: [] as TData, error: null, count: result.count ?? null, status: result.status, @@ -196,7 +201,7 @@ export async function decryptResults>( return { data: decrypted.data.map((row) => postprocessDecryptedRow(row as Record, ctx), - ) as unknown as T[], + ) as TData, error: null, count: result.count ?? null, status: result.status, diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index c74fc9e6..dd461127 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -461,6 +461,21 @@ export interface TypedEncryptedSupabaseInstance { // Response // --------------------------------------------------------------------------- +/** + * The builder returned by `single()`/`maybeSingle()`: awaits to a SINGLE row + * (`data: T | null`) instead of an array. + * + * Only the two post-hoc modifiers supabase-js also allows after `.single()` are + * carried over. Filters and transforms are deliberately absent — applying one + * after `single()` would change the query the single-row promise was made + * about. + */ +export interface EncryptedSingleQueryBuilder + extends PromiseLike> { + abortSignal(signal: AbortSignal): EncryptedSingleQueryBuilder + throwOnError(): EncryptedSingleQueryBuilder +} + export type EncryptedSupabaseResponse = { data: T | null error: EncryptedSupabaseError | null @@ -909,8 +924,21 @@ export interface EncryptedQueryBuilderCore< to: number, options?: { referencedTable?: string; foreignTable?: string }, ): Self - single(): Self - maybeSingle(): Self + /** + * Return ONE row rather than an array — so the awaited `data` is `T | null`, + * not `T[]`. Returns {@link EncryptedSingleQueryBuilder} rather than `Self` + * because that change of shape is the whole point of the call: typing it + * `Self` would keep promising `T[]` while the runtime hands back one object, + * forcing every caller through a cast (`data as unknown as Row`). + * + * Filters and transforms are not chainable afterwards, matching supabase-js — + * `single()` is applied last. + */ + single(): EncryptedSingleQueryBuilder + /** As {@link single}, but a zero-row result is `data: null` rather than an + * error. Same `T | null` awaited shape — `single()` reports the missing row + * through `error` instead. */ + maybeSingle(): EncryptedSingleQueryBuilder csv(): Self abortSignal(signal: AbortSignal): Self throwOnError(): Self