Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/ore-unavailable-at-install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'stash': minor
---

Report the ORE-unavailable case once, at install time, instead of leaving it to surface as a failing predicate the first time a column is cast.

The EQL bundle skips the ORE btree operator class when the installing role cannot create one and poisons every `_ord_ore` domain with a loud-failure CHECK in its place. That is a supported configuration — but nothing said so where the choice between `types.*Ord` and `types.*OrdOre` is actually made, so the trade was discovered at query time.

- **`stash eql preflight` now probes whether the role can create an operator class** and reports it as a non-blocking `ORE operator class` row (`creatable` / `not creatable` / `unknown`; `canCreateOperatorClass` in `--json`). It is *probed*, not inferred from `superuser`: `CREATE OPERATOR CLASS` is superuser-gated in stock PostgreSQL, but AWS RDS and Aurora let their admin role create one while cloud-hosted Supabase does not, so `rolsuper` is not evidence either way. The probe attempts the DDL inside a transaction it always rolls back, leaving preflight read-only; a probe that could not ask reports `unknown` rather than guessing.
- **`stash eql install` names the consequence and the remedy** on its own line when the fallback was installed, rather than as a parenthetical on the "verified" line.
- **`stash eql status` reports the ORE state** on a v3 install, so the answer survives past the install output.
- **The remedy now names a type that exists.** The previous wording pointed at the `_ord_ope` domains; the bundle creates those, but `@cipherstash/stack` ships no `types.*OrdOpe` factory, so it named a column type no schema author could declare. Every command now says `types.*Ord` (`public.eql_v3_*_ord`), which is the same CLLW-OPE ordering and has a factory behind it.
- The ORE state machine, the catalogue probe, and this copy now live in one module shared by `eql preflight`, `eql install`, `eql status`, `eql verify`, and `eql validate`, so the five commands cannot drift into disagreeing about the same catalogue fact.
- The scaffolded encryption client's type cheat-sheet now says why ordered columns should be `*Ord` rather than `*OrdOre`.
7 changes: 7 additions & 0 deletions packages/cli/__fixtures__/scaffold/drizzle.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@
* types.TextSearch equality + order/range + free-text
* types.Json encrypted-JSONB containment + selectors
*
* Order columns with the `*Ord` factories above, not `*OrdOre`. The ORE
* flavour needs a Postgres operator class that only a privileged role can
* create — where the install could not create it, the EQL bundle poisons every
* `_ord_ore` domain, and each write to one fails a CHECK. `*Ord` orders and
* indexes on any role. `stash eql status` reports which case this database
* is in.
*
* --- Pattern reference (copy into your real schema, do NOT use as-is) ---
*
* Encrypted twin column for an existing populated column (path 3 — lifecycle):
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/__fixtures__/scaffold/generic.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@
* types.TextSearch equality + order/range + free-text
* types.Json encrypted-JSONB containment + selectors
*
* Order columns with the `*Ord` factories above, not `*OrdOre`. The ORE
* flavour needs a Postgres operator class that only a privileged role can
* create — where the install could not create it, the EQL bundle poisons every
* `_ord_ore` domain, and each write to one fails a CHECK. `*Ord` orders and
* indexes on any role. `stash eql status` reports which case this database
* is in.
*
* --- Pattern reference (copy into your real schema, do NOT use as-is) ---
*
* Encrypted twin column for an existing populated column (path 3 — lifecycle):
Expand Down
35 changes: 35 additions & 0 deletions packages/cli/src/commands/db/__tests__/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const CAPABLE: PreflightResult = {
eqlV3InternalSchemaPresent: false,
canDropEqlV3Schema: null,
canDropEqlV3InternalSchema: null,
canCreateOperatorClass: true,
missing: [],
ok: true,
}
Expand Down Expand Up @@ -78,6 +79,40 @@ describe('renderPreflightReport', () => {
expect(report).toContain('<- blocks: not on the EQL search_path')
})

// #891: the ORE trade is reported so it is known before a schema is
// written. It must never read as a blocker — the bundle's fallback makes an
// install without the operator class a complete install.
it('names the ORE consequence for a role that cannot create an operator class', () => {
const report = renderPreflightReport({
...CAPABLE,
currentUser: 'sandbox_exec',
isSuperuser: false,
canCreateOperatorClass: false,
})
expect(report).toMatch(/ORE operator class\s+not creatable/)
expect(report).toContain('<- skips: ORE opclass')
expect(report).toContain('`types.*Ord`, not `types.*OrdOre`')
expect(report).not.toContain('<- blocks')
})

it('leaves the ORE row unannotated for a role that can create one', () => {
const report = renderPreflightReport(CAPABLE)
expect(report).toMatch(/ORE operator class\s+creatable/)
expect(report).not.toContain('<- skips: ORE opclass')
})

// A probe that could not ask the question must not be rendered as either
// answer — "unknown" is the honest third state.
it('renders an unanswerable ORE probe as unknown, not as no', () => {
const report = renderPreflightReport({
...CAPABLE,
canCreateOperatorClass: null,
})
expect(report).toMatch(/ORE operator class\s+unknown/)
expect(report).toContain('`stash eql verify` reports it after install')
expect(report).not.toContain('not creatable')
})

it('adds the drop-ownership row only when an EQL schema exists', () => {
expect(renderPreflightReport(CAPABLE)).not.toContain('can drop EQL schemas')
const blocked = renderPreflightReport({
Expand Down
13 changes: 8 additions & 5 deletions packages/cli/src/commands/db/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { resolveDatabaseUrl } from '@/config/database-url.js'
import { findConfigFile, loadStashConfig } from '@/config/index.js'
import { createPgClient } from '@/db/client.js'
import { EQLInstaller } from '@/installer/index.js'
import { describeOreState } from '@/installer/ore.js'
import { type VerifyReport, verifyEqlSurface } from '@/installer/verify.js'
import { messages } from '@/messages.js'
import { detectPackageManager, runnerCommand } from '../init/utils.js'
Expand Down Expand Up @@ -265,11 +266,13 @@ export async function verifySurfaceOrExit(
return
}
if (report.ok) {
s.stop(
report.ore?.state === 'fallback'
? 'EQL surface verified (ORE operator class skipped — expected for this role; use the `_ord_ope` ordering domains).'
: 'EQL surface verified — install is complete.',
)
s.stop('EQL surface verified — install is complete.')
// Reported once, here, rather than left to surface as a failing predicate
// the first time someone casts a column (#891). `eql status` and
// `eql verify` say the same thing later, so the answer is recoverable.
if (report.ore?.state === 'fallback') {
p.log.info(describeOreState('fallback').message)
}
return
}
s.stop('The installed EQL surface is incomplete.')
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/commands/db/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as p from '@clack/prompts'
import { emitJsonError, emitJsonEvent } from '@/commands/auth/events.js'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { EQLInstaller, type PreflightResult } from '@/installer/index.js'
import { describeOreCreatable, ORE_FALLBACK_REMEDY } from '@/installer/ore.js'
import { resolveDiagnosticDatabaseUrl } from './resolve-diagnostic-url.js'

/** See {@link resolveDiagnosticDatabaseUrl} — preflight keeps config-wins. */
Expand Down Expand Up @@ -90,6 +91,14 @@ export async function preflightCommand(
'Not a member of `postgres`: in Supabase mode the optional `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements are skipped. The install is complete without them — they only cover EQL objects created outside stash tooling, and stash re-grants every object on each install/upgrade.',
)
}
// Said here so the ordering trade is known before a schema is written. The
// same sentence is printed by `eql install` and `eql status` against the
// installed state, so the answer survives past this command's output (#891).
if (result.canCreateOperatorClass === false) {
p.log.info(
`This role cannot create an operator class, so \`eql install\` will skip the ORE one and install its loud-failure fallback instead. That is a complete install. ${ORE_FALLBACK_REMEDY}`,
)
}
p.outro('This role can install EQL.')
}

Expand Down Expand Up @@ -140,6 +149,16 @@ export function renderPreflightReport(result: PreflightResult): string {
? '<- blocks: CREATE EXTENSION pgcrypto'
: undefined,
],
// Never annotated as a blocker: the bundle skips the class and installs
// its loud-failure fallback, which is a complete install (#891).
// Same label `eql verify` uses for the installed state, so the row an
// operator reads before the install and the row they read after it are
// recognisably about the same thing. The value carries the tense.
[
'ORE operator class',
describeOreCreatable(result.canCreateOperatorClass).value,
describeOreCreatable(result.canCreateOperatorClass).annotation,
],
['eql_v3 schema', result.eqlV3SchemaPresent ? 'present' : 'absent'],
[
'eql_v3_internal',
Expand Down
37 changes: 36 additions & 1 deletion packages/cli/src/commands/db/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { loadStashConfig } from '@/config/index.js'
import { createPgClient } from '@/db/client.js'
import { EQLInstaller } from '@/installer/index.js'
import { describeOreState } from '@/installer/ore.js'
import { readOreState } from '@/installer/verify.js'

export async function statusCommand(options: { databaseUrl?: string } = {}) {
const pm = detectPackageManager()
Expand Down Expand Up @@ -93,7 +95,40 @@ export async function statusCommand(options: { databaseUrl?: string } = {}) {
)
}

// 3. Encrypt configuration.
// 3. The ORE half of the install.
//
// Reported here so the trade an operator was told about at install time is
// recoverable afterwards, without re-reading scrollback (#891). Only when
// v3 is installed: the state is a property of the v3 bundle's conditional
// half, and reads as 'fallback' on a database that has no EQL at all.
if (installedV3) {
s.start('Checking ORE operator class...')
const oreClient = createPgClient(config.databaseUrl)
try {
await oreClient.connect()
const ore = await readOreState(oreClient)
s.stop('ORE state checked.')
const described = describeOreState(ore.state)
if (described.severity === 'damage') {
p.log.error(described.message)
} else {
p.log.info(described.message)
}
} catch (error) {
// Advisory, not a gate: a status run that could not read one row should
// still report everything else it read.
s.stop('ORE state check failed.')
p.log.warn(
`Could not determine the ORE operator class state: ${
error instanceof Error ? error.message : String(error)
}`,
)
} finally {
await oreClient.end().catch(() => {})
}
}

// 4. Encrypt configuration.
//
// `public.eql_v2_configuration` is a v2 + CipherStash Proxy artifact: the v2
// install creates it and Proxy reads it. EQL v3 has no configuration table —
Expand Down
19 changes: 5 additions & 14 deletions packages/cli/src/commands/eql/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { fetchPhysicalColumns } from '@/commands/encrypt/lib/db-readers.js'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { loadEncryptSchemas, loadStashConfig } from '@/config/index.js'
import { createPgClient } from '@/db/client.js'
import { ORE_OPCLASS_PRESENT_EXPR } from '@/installer/ore.js'

// ---------------------------------------------------------------------------
// The vocabulary
Expand Down Expand Up @@ -604,21 +605,11 @@ function identifiersIn(args: string): string[] {
}

/**
* Whether the ORE btree operator class exists. Mirrors the EQL bundle's own
* fallback test (`ore_fallback.sql`), with one deliberate difference:
* `to_regtype` returns NULL where the bundle's `::regtype` cast raises, so this
* degrades to `false` on a database with no EQL installed instead of throwing.
* That is why the not-installed case is detected and reported separately.
* Whether the ORE btree operator class exists. The expression is shared with
* `eql verify` and `eql status` (`installer/ore.ts`) so the three commands
* cannot drift into disagreeing about the same catalogue fact.
*/
const ORE_AVAILABLE_SQL = `
SELECT EXISTS (
SELECT 1
FROM pg_catalog.pg_opclass c
JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod
WHERE am.amname = 'btree'
AND c.opcdefault
AND c.opcintype = to_regtype('eql_v3_internal.ore_block_256')
) AS ore_available`
const ORE_AVAILABLE_SQL = `SELECT ${ORE_OPCLASS_PRESENT_EXPR} AS ore_available`

const EQL_INSTALLED_SQL = `
SELECT EXISTS (
Expand Down
10 changes: 2 additions & 8 deletions packages/cli/src/commands/eql/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as p from '@clack/prompts'
import { emitJsonError, emitJsonEvent } from '@/commands/auth/events.js'
import { resolveDiagnosticDatabaseUrl } from '@/commands/db/resolve-diagnostic-url.js'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { describeOreState } from '@/installer/ore.js'
import type { SurfaceFinding, VerifyReport } from '@/installer/verify.js'
import { verifyEqlSurface } from '@/installer/verify.js'

Expand Down Expand Up @@ -144,14 +145,7 @@ export function renderSurfaceCounts(report: VerifyReport): string {
]
},
),
[
'ORE operator class',
ore.state === 'indexable'
? 'present'
: ore.state === 'fallback'
? 'skipped (expected on managed Postgres)'
: 'INCOHERENT',
],
['ORE operator class', describeOreState(ore.state).value],
]
const labelWidth = Math.max(...rows.map(([label]) => label.length))
return rows
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/commands/init/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,13 @@ const DRIZZLE_PLACEHOLDER = `/**
* types.TextSearch equality + order/range + free-text
* types.Json encrypted-JSONB containment + selectors
*
* Order columns with the \`*Ord\` factories above, not \`*OrdOre\`. The ORE
* flavour needs a Postgres operator class that only a privileged role can
* create — where the install could not create it, the EQL bundle poisons every
* \`_ord_ore\` domain, and each write to one fails a CHECK. \`*Ord\` orders and
* indexes on any role. \`stash eql status\` reports which case this database
* is in.
*
* --- Pattern reference (copy into your real schema, do NOT use as-is) ---
*
* Encrypted twin column for an existing populated column (path 3 — lifecycle):
Expand Down Expand Up @@ -468,6 +475,13 @@ const GENERIC_PLACEHOLDER = `/**
* types.TextSearch equality + order/range + free-text
* types.Json encrypted-JSONB containment + selectors
*
* Order columns with the \`*Ord\` factories above, not \`*OrdOre\`. The ORE
* flavour needs a Postgres operator class that only a privileged role can
* create — where the install could not create it, the EQL bundle poisons every
* \`_ord_ore\` domain, and each write to one fails a CHECK. \`*Ord\` orders and
* indexes on any role. \`stash eql status\` reports which case this database
* is in.
*
* --- Pattern reference (copy into your real schema, do NOT use as-is) ---
*
* Encrypted twin column for an existing populated column (path 3 — lifecycle):
Expand Down
Loading
Loading