Skip to content

Commit c36954f

Browse files
authored
Merge pull request #583 from cipherstash/feat/eql-v3-bigint-restack
feat(stack,cli): EQL v3 bigint + CLI install path, re-expressed on protect-ffi 0.28
2 parents d9fb482 + a72a113 commit c36954f

72 files changed

Lines changed: 87763 additions & 255 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@cipherstash/stack': minor
3+
---
4+
5+
Add the EQL v3 bigint domain family to the public DSL: `types.Bigint`,
6+
`types.BigintEq`, `types.BigintOrdOre`, and `types.BigintOrd`, backed by the
7+
`public.bigint*` concrete domains. Plaintext is a JS `bigint`, round-tripped
8+
losslessly across the protect-ffi 0.28 boundary (i64 bounds enforced at the
9+
FFI — out-of-range values surface as encryption errors). Index emission follows
10+
the numeric rule: `bigint_eq` → unique (hm); `bigint_ord`/`bigint_ord_ore`
11+
ore (equality answered via ob).

.changeset/eql-v3-cli-install.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"stash": minor
3+
---
4+
5+
Add an EQL v3 install path to `stash eql install` via a new `--eql-version <2|3>`
6+
flag (default `2`). v3 installs the native concrete-domain schema (`public.*`
7+
type domains, `eql_v3` operators, `eql_v3_internal` constructors) from bundles
8+
vendored into `packages/cli/src/sql` by `scripts/build-eql-v3-sql.mjs` (full
9+
bundle + a Supabase variant with the two superuser-only operator-class chunks
10+
stripped). v3 currently supports the direct install path only —
11+
`--drizzle`/`--migration`/`--migrations-dir`/`--latest` are rejected — and the
12+
installer keys `isInstalled`/version checks and Supabase grants to the `eql_v3`
13+
schema.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"stash": minor
3+
"@cipherstash/wizard": minor
4+
---
5+
6+
Rename `stash db install` to `stash eql install`. The command scaffolds
7+
`stash.config.ts` and installs the EQL extensions, so it now lives under a
8+
dedicated `eql` command group. `stash db install` keeps working as a
9+
deprecated alias that prints a warning pointing at the new name. All help
10+
text, hints, generated migration headers, and wizard steps now reference
11+
`stash eql install`.

.changeset/skip-v3-bigint.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

e2e/tests/package-managers.e2e.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,18 @@ describe('CLI init providers — package-manager-aware Next Steps', () => {
5151
{
5252
label: 'base',
5353
create: createBaseProvider,
54-
firstStep: (r) => `Set up your database: ${r} stash db install`,
54+
firstStep: (r) => `Set up your database: ${r} stash eql install`,
5555
},
5656
{
5757
label: 'drizzle',
5858
create: createDrizzleProvider,
59-
firstStep: (r) => `Set up your database: ${r} stash db install --drizzle`,
59+
firstStep: (r) => `Set up your database: ${r} stash eql install --drizzle`,
6060
},
6161
{
6262
label: 'supabase',
6363
create: createSupabaseProvider,
6464
firstStep: (r) =>
65-
`Install EQL: ${r} stash db install --supabase (prompts for migration vs direct)`,
65+
`Install EQL: ${r} stash eql install --supabase (prompts for migration vs direct)`,
6666
},
6767
]
6868

@@ -158,12 +158,12 @@ describe.skipIf(!authConfigured)(
158158
{ pm: 'yarn' as const, lockfile: 'yarn.lock' },
159159
])('uses $pm runner when $lockfile is present', ({ pm, lockfile }) => {
160160
const out = runWizard({ lockfile })
161-
expect(out).toContain(`Run: ${RUNNER[pm]} stash db install`)
161+
expect(out).toContain(`Run: ${RUNNER[pm]} stash eql install`)
162162
})
163163

164164
it('falls back to npx when no lockfile and no user agent', () => {
165165
const out = runWizard({})
166-
expect(out).toContain('Run: npx stash db install')
166+
expect(out).toContain('Run: npx stash eql install')
167167
})
168168
})
169169

@@ -174,7 +174,7 @@ describe.skipIf(!authConfigured)(
174174
{ pm: 'yarn' as const, userAgent: 'yarn/4.0.0 npm/? node/v20.0.0' },
175175
])('uses $pm runner when UA is $userAgent', ({ pm, userAgent }) => {
176176
const out = runWizard({ userAgent })
177-
expect(out).toContain(`Run: ${RUNNER[pm]} stash db install`)
177+
expect(out).toContain(`Run: ${RUNNER[pm]} stash eql install`)
178178
})
179179
})
180180

@@ -184,15 +184,15 @@ describe.skipIf(!authConfigured)(
184184
lockfile: 'pnpm-lock.yaml',
185185
userAgent: 'bun/1.1.40 npm/? node/v22.3.0',
186186
})
187-
expect(out).toContain('Run: bunx stash db install')
187+
expect(out).toContain('Run: bunx stash eql install')
188188
})
189189

190190
it('npm user agent is ignored in favour of a lockfile', () => {
191191
const out = runWizard({
192192
lockfile: 'bun.lock',
193193
userAgent: 'npm/10.2.4 node/v20.0.0',
194194
})
195-
expect(out).toContain('Run: bunx stash db install')
195+
expect(out).toContain('Run: bunx stash eql install')
196196
})
197197
})
198198
},

packages/cli/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ exercise the same code paths.
7171
tweak only needs to land in one place. Add to `messages.ts` only when a
7272
test actually asserts on the string — premature extraction is worse
7373
than copy-paste here. For literals tests don't touch (e.g. command
74-
names like `init`, `db install`), keep them inline.
74+
names like `init`, `eql install`), keep them inline.
7575
- **Telemetry.** The CLI source no longer imports `posthog-node` (analytics
7676
moved to `packages/wizard`). The dep is still listed in `package.json`
7777
and should be removed in a follow-up. If you re-introduce telemetry to

packages/cli/README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export default defineConfig({
6161

6262
The CLI loads `.env` files automatically before reading the config, so `process.env` references work without extra setup. The config file is resolved by walking up from the current working directory.
6363

64-
Commands that consume `stash.config.ts`: `db install`, `db upgrade`, `db push`, `db validate`, `db status`, `db test-connection`, `schema build`. `db install` will scaffold `stash.config.ts` for you if it's missing.
64+
Commands that consume `stash.config.ts`: `eql install`, `db upgrade`, `db push`, `db validate`, `db status`, `db test-connection`, `schema build`. `eql install` will scaffold `stash.config.ts` for you if it's missing.
6565

6666
---
6767

@@ -83,10 +83,10 @@ npx stash init [--supabase] [--drizzle]
8383
What `init` does, in order:
8484

8585
1. **Authenticate** — re-uses an existing token if found, otherwise opens the browser device-code flow.
86-
2. **Resolve `DATABASE_URL`** — flag → env → `supabase status` → interactive prompt → hard-fail. The same resolver `db install` uses.
86+
2. **Resolve `DATABASE_URL`** — flag → env → `supabase status` → interactive prompt → hard-fail. The same resolver `eql install` uses.
8787
3. **Generate the encryption client** — connects to your database, lists tables, and prompts you to multi-select which columns to encrypt. Writes `./src/encryption/index.ts` with the right shape for the detected ORM (Drizzle / Supabase / plain Postgres). Falls back to a placeholder if the database has no tables yet.
8888
4. **Install dependencies**`@cipherstash/stack` (runtime) and `stash` (dev), with a confirmation prompt.
89-
5. **Install EQL** — runs `stash db install` against the resolved URL after a y/N confirm.
89+
5. **Install EQL** — runs `stash eql install` against the resolved URL after a y/N confirm.
9090
6. **Hand off** — four-option menu (Claude Code / Codex / CipherStash Agent / write `AGENTS.md`). See the Quickstart section above for what each option writes and spawns.
9191

9292
The full pipeline state — integration, columns, env-key names, paths, versions — is captured in `.cipherstash/context.json`. The action plan at `.cipherstash/setup-prompt.md` tells whichever agent picks up next what's already done and what's left.
@@ -156,14 +156,14 @@ npx stash secrets delete -n DATABASE_URL -e production -y
156156

157157
---
158158

159-
### `npx stash db install`
159+
### `npx stash eql install`
160160

161-
Configure your database and install CipherStash EQL extensions in a single command. Run this after `npx stash init`.
161+
Configure your database and install CipherStash EQL extensions in a single command. Run this after `npx stash init`. (`npx stash db install` is a deprecated alias — it still works but prints a warning.)
162162

163163
When `stash.config.ts` is missing, the command auto-detects your encryption client file (or asks for the path) and writes the config before installing. Supabase and Drizzle are detected from your `DATABASE_URL` and project files, so the matching flags default on. Install uses bundled SQL for offline, deterministic runs.
164164

165165
```bash
166-
npx stash db install [options]
166+
npx stash eql install [options]
167167
```
168168

169169
| Flag | Description |
@@ -198,7 +198,7 @@ npx stash db upgrade [options]
198198
| `--exclude-operator-family` | Skip operator family creation |
199199
| `--latest` | Fetch the latest EQL from GitHub |
200200

201-
The install SQL is idempotent and safe to re-run. If EQL is not installed, the command suggests running `npx stash db install` instead.
201+
The install SQL is idempotent and safe to re-run. If EQL is not installed, the command suggests running `npx stash eql install` instead.
202202

203203
---
204204

@@ -300,10 +300,10 @@ Reads `databaseUrl` from `stash.config.ts`.
300300

301301
## Drizzle migration mode
302302

303-
Use `--drizzle` with `npx stash db install` to add EQL installation to your Drizzle migration history instead of applying it directly. `--drizzle` is auto-detected when your project has `drizzle-orm`, `drizzle-kit`, or a `drizzle.config.*` file, so you usually don't need to pass it explicitly.
303+
Use `--drizzle` with `npx stash eql install` to add EQL installation to your Drizzle migration history instead of applying it directly. `--drizzle` is auto-detected when your project has `drizzle-orm`, `drizzle-kit`, or a `drizzle.config.*` file, so you usually don't need to pass it explicitly.
304304

305305
```bash
306-
npx stash db install --drizzle
306+
npx stash eql install --drizzle
307307
npx drizzle-kit migrate
308308
```
309309

@@ -315,7 +315,7 @@ How it works:
315315
With a custom name or output directory:
316316

317317
```bash
318-
npx stash db install --drizzle --name setup-eql --out ./migrations
318+
npx stash eql install --drizzle --name setup-eql --out ./migrations
319319
npx drizzle-kit migrate
320320
```
321321

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Vendor the EQL v3 SQL bundles into packages/cli/src/sql/.
4+
*
5+
* Source of truth: the generated monolith checked in at
6+
* packages/stack/__tests__/fixtures/eql-v3/cipherstash-encrypt-v3.sql
7+
* (itself generated from the upstream encrypt-query-language repo).
8+
*
9+
* Outputs:
10+
* - cipherstash-encrypt-v3.sql — full bundle, byte-identical copy
11+
* - cipherstash-encrypt-v3-supabase.sql — Supabase variant with the two
12+
* `CREATE OPERATOR CLASS`/`FAMILY` chunks removed (they need superuser,
13+
* which Supabase does not grant)
14+
*
15+
* The Supabase strip mirrors the upstream build's `**\/*operator_class.sql`
16+
* exclusion glob: the monolith annotates every constituent file with a
17+
* `--! @file <path>` marker, so the variant drops each
18+
* `--! @file .../operator_class.sql` chunk up to the next `--! @file` marker.
19+
*
20+
* TEMPORARY vendoring strategy (sync risk): once upstream publishes v3 release
21+
* artifacts (like the eql-2.x `cipherstash-encrypt[-supabase].sql` assets),
22+
* regenerate these from the release instead and record the version.
23+
*
24+
* Usage: node packages/cli/scripts/build-eql-v3-sql.mjs
25+
*/
26+
import { readFileSync, writeFileSync } from 'node:fs'
27+
import { dirname, resolve } from 'node:path'
28+
import { fileURLToPath } from 'node:url'
29+
30+
const here = dirname(fileURLToPath(import.meta.url))
31+
const source = resolve(
32+
here,
33+
'../../stack/__tests__/fixtures/eql-v3/cipherstash-encrypt-v3.sql',
34+
)
35+
const outDir = resolve(here, '../src/sql')
36+
37+
const FILE_MARKER = /^--! @file (.+)$/
38+
const EXCLUDE = /operator_class\.sql$/
39+
40+
function stripOperatorClassChunks(sql) {
41+
const lines = sql.split('\n')
42+
const out = []
43+
let skipping = false
44+
let removedChunks = 0
45+
46+
for (const line of lines) {
47+
const marker = line.match(FILE_MARKER)
48+
if (marker) {
49+
skipping = EXCLUDE.test(marker[1])
50+
if (skipping) removedChunks++
51+
}
52+
if (!skipping) out.push(line)
53+
}
54+
55+
if (removedChunks !== 2) {
56+
throw new Error(
57+
`Expected to remove exactly 2 operator_class chunks, removed ${removedChunks} — the bundle layout changed; review the strip logic.`,
58+
)
59+
}
60+
61+
const stripped = out.join('\n')
62+
if (/CREATE OPERATOR (CLASS|FAMILY)/.test(stripped)) {
63+
throw new Error(
64+
'Stripped bundle still contains CREATE OPERATOR CLASS/FAMILY statements.',
65+
)
66+
}
67+
68+
return stripped
69+
}
70+
71+
const sql = readFileSync(source, 'utf8')
72+
73+
writeFileSync(resolve(outDir, 'cipherstash-encrypt-v3.sql'), sql)
74+
writeFileSync(
75+
resolve(outDir, 'cipherstash-encrypt-v3-supabase.sql'),
76+
stripOperatorClassChunks(sql),
77+
)
78+
79+
console.log(
80+
'Wrote cipherstash-encrypt-v3.sql and cipherstash-encrypt-v3-supabase.sql',
81+
)

packages/cli/src/__tests__/installer.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,91 @@ describe('EQLInstaller', () => {
204204
expect(SUPABASE_PERMISSIONS_SQL).toContain('service_role')
205205
})
206206

207+
it('installs the v3 bundle and grants eql_v3 permissions with eqlVersion: 3 + supabase', async () => {
208+
mockConnect.mockResolvedValue(undefined)
209+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
210+
mockEnd.mockResolvedValue(undefined)
211+
212+
const { EQLInstaller, SUPABASE_PERMISSIONS_SQL_V3 } = await import(
213+
'@/installer/index.ts'
214+
)
215+
const installer = new EQLInstaller({
216+
databaseUrl: 'postgresql://localhost:5432/test',
217+
})
218+
219+
await installer.install({ eqlVersion: 3, supabase: true })
220+
221+
const otherCalls = mockQuery.mock.calls
222+
.map((call: unknown[]) => call[0])
223+
.filter(
224+
(sql: unknown): sql is string =>
225+
typeof sql === 'string' &&
226+
sql !== 'BEGIN' &&
227+
sql !== 'COMMIT' &&
228+
sql !== 'ROLLBACK',
229+
)
230+
231+
expect(otherCalls).toHaveLength(2)
232+
// The bundled SQL is the v3 Supabase variant: creates eql_v3, no
233+
// operator classes/families (they need superuser).
234+
expect(otherCalls[0]).toContain('eql_v3')
235+
expect(otherCalls[0]).not.toContain('CREATE OPERATOR CLASS')
236+
expect(otherCalls[0]).not.toContain('CREATE OPERATOR FAMILY')
237+
// The grants are keyed to eql_v3, not eql_v2.
238+
expect(otherCalls[1]).toBe(SUPABASE_PERMISSIONS_SQL_V3)
239+
expect(SUPABASE_PERMISSIONS_SQL_V3).toContain('eql_v3')
240+
expect(SUPABASE_PERMISSIONS_SQL_V3).not.toContain('eql_v2')
241+
})
242+
243+
it('installs the full v3 bundle (with operator classes) without supabase', async () => {
244+
mockConnect.mockResolvedValue(undefined)
245+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
246+
mockEnd.mockResolvedValue(undefined)
247+
248+
const { EQLInstaller } = await import('@/installer/index.ts')
249+
const installer = new EQLInstaller({
250+
databaseUrl: 'postgresql://localhost:5432/test',
251+
})
252+
253+
await installer.install({ eqlVersion: 3 })
254+
255+
const sqlCall = mockQuery.mock.calls.find(
256+
(call: string[]) =>
257+
typeof call[0] === 'string' &&
258+
call[0] !== 'BEGIN' &&
259+
call[0] !== 'COMMIT',
260+
)
261+
expect(sqlCall).toBeDefined()
262+
expect(sqlCall?.[0]).toContain('eql_v3')
263+
expect(sqlCall?.[0]).toContain('CREATE OPERATOR CLASS')
264+
})
265+
266+
it('rejects latest: true for eqlVersion: 3', async () => {
267+
const { EQLInstaller } = await import('@/installer/index.ts')
268+
const installer = new EQLInstaller({
269+
databaseUrl: 'postgresql://localhost:5432/test',
270+
})
271+
272+
await expect(
273+
installer.install({ eqlVersion: 3, latest: true }),
274+
).rejects.toThrow('not supported for EQL v3')
275+
})
276+
277+
it('checks the eql_v3 schema for isInstalled({ eqlVersion: 3 })', async () => {
278+
mockConnect.mockResolvedValue(undefined)
279+
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
280+
mockEnd.mockResolvedValue(undefined)
281+
282+
const { EQLInstaller } = await import('@/installer/index.ts')
283+
const installer = new EQLInstaller({
284+
databaseUrl: 'postgresql://localhost:5432/test',
285+
})
286+
287+
await installer.isInstalled({ eqlVersion: 3 })
288+
289+
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['eql_v3'])
290+
})
291+
207292
it('rolls back on SQL execution failure', async () => {
208293
mockConnect.mockResolvedValue(undefined)
209294
mockEnd.mockResolvedValue(undefined)

0 commit comments

Comments
 (0)