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
37 changes: 37 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,43 @@ on:
- "**"

jobs:
# Biome format + lint. Its own job (no DB, no credentials, node-agnostic) so it
# surfaces as a distinct check and runs once rather than per Node matrix leg.
# Gates on ERRORS only — warnings are allowed, so the `no-type-erasing-assertions`
# plugin (at `warn`) surfaces new `as any`/`never`/`unknown` without blocking.
# Tightening to fail on warnings is tracked in #625.
lint:
name: Lint (Biome)
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout Repo
uses: actions/checkout@v6

- uses: pnpm/action-setup@v6.0.8
name: Install pnpm
with:
run_install: false

- name: Install Node.js
uses: actions/setup-node@v6
with:
# Node 22 is the supply-chain hardening baseline every pnpm-using job
# must pin (enforced by e2e/tests/supply-chain.e2e.test.ts). Lint is
# runtime-agnostic, so a single pinned version is fine.
node-version: 22
cache: 'pnpm'

# node-pty's install hook falls back to `node-gyp rebuild` when no
# linux-x64 prebuild matches; pnpm/action-setup v6 no longer ships it.
- name: Install node-gyp
run: npm install -g node-gyp

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Biome check (format + lint)
run: pnpm run code:check

run-tests:
name: Run Tests (Node ${{ matrix.node-version }})
runs-on: blacksmith-4vcpu-ubuntu-2404
Expand Down
15 changes: 14 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,22 @@ Three rules to remember when editing CI or pnpm config:
- **Formatting/Linting**: Use Biome

```bash
pnpm run code:fix
pnpm run code:fix # format + lint, auto-fixing what it can
pnpm run code:check # read-only; this is what CI runs
```

CI runs `code:check` (in `tests.yml`) and gates on **errors** — warnings are
allowed (tracked for tightening). So `code:fix` must leave the tree
error-free before you push.

A Biome GritQL plugin (`biome-plugins/no-type-erasing-assertions.grit`) warns
on `as any` / `as never` / `as unknown` in `src` — type-erasing assertions that
silence the checker instead of narrowing. Fix the type or use a specific
assertion; suppress a deliberate case with `// biome-ignore lint/plugin:
<reason>`. The plugin is scoped to source via an `overrides` entry in
`biome.json` (test/integration files excluded) — see the plugin file's header
for why it must be scoped-in rather than globally-enabled-and-exempted.

- **Build**: `pnpm run build` (Turborepo + tsup per package)
- **Test**: `pnpm --filter <pkg> test` for targeted iterations
- **Releases**: Use Changesets
Expand Down
33 changes: 33 additions & 0 deletions biome-plugins/no-type-erasing-assertions.grit
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Flags type-erasing assertions: `x as any`, `x as never`, `x as unknown`
// (the last also catches the inner half of `x as unknown as T` double-casts).
//
// These punch a hole through the type system rather than narrowing a value, so
// a real type mismatch can hide behind them (e.g. passing the wrong shape to a
// function whose signature you've silenced). Prefer fixing the underlying type,
// narrowing with a guard, or a precise `as SpecificType`.
//
// NOT flagged: `as const` (a widening-freeze, not an erasure), a plain
// `as SpecificType`, or `never` used as a *type annotation* (e.g. a contravariant
// `param: never`) — those are legitimate.
//
// Warning, not error: the repo has a large existing backlog, and this is a
// ratchet to keep NEW erasures visible. Intentional cases take a
// `// biome-ignore lint/plugin: <reason>` suppression.
//
// Wiring note: this plugin is enabled via an `overrides` entry in biome.json
// that INCLUDES source globs and negates test globs — it is NOT enabled at the
// top level. Biome applies top-level `plugins` to every file, and
// `overrides[].plugins: []` does NOT disable them, so "enable globally + exempt
// tests" silently lints tests too. Scoping the plugin in via the override is the
// only way to exempt test doubles (which legitimately force-cast).
or {
`$e as any`,
`$e as never`,
`$e as unknown`
} where {
register_diagnostic(
span = $e,
message = "Avoid type-erasing assertions (`as any` / `as never` / `as unknown`) — they silence the checker instead of narrowing. Fix the underlying type, use a type guard, or assert a specific type. Suppress an intentional case with `// biome-ignore lint/plugin: <reason>`.",
severity = "warn"
)
}
28 changes: 26 additions & 2 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@
"!**/package.json",
"!**/.turbo/",
"!**/dist",
"!**/nix/store"
"!**/nix/store",
"!**/*.grit",
"!**/*.generated.ts",
"!**/contract.json",
"!**/contract.d.ts",
"!**/end-contract.json",
"!**/end-contract.d.ts",
"!**/migrations/**/migration.json",
"!**/migrations/**/ops.json",
"!**/migrations/**/refs"
]
},
"formatter": {
Expand All @@ -30,5 +39,20 @@
"noThenProperty": "off"
}
}
}
},
"overrides": [
{
"includes": [
"**/src/**",
"examples/**",
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.test-d.ts",
"!**/*.spec.ts",
"!**/integration/**",
"!**/tests/**"
],
"plugins": ["./biome-plugins/no-type-erasing-assertions.grit"]
}
]
}
3 changes: 2 additions & 1 deletion e2e/tests/package-managers.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ describe('CLI init providers — package-manager-aware Next Steps', () => {
{
label: 'drizzle',
create: createDrizzleProvider,
firstStep: (r) => `Set up your database: ${r} stash eql install --drizzle`,
firstStep: (r) =>
`Set up your database: ${r} stash eql install --drizzle`,
},
{
label: 'supabase',
Expand Down
54 changes: 42 additions & 12 deletions examples/prisma/migrations/app/20260709T1034_initial/migration.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
#!/usr/bin/env -S node
import { cipherstashAddSearchConfig } from '@prisma-next/extension-cipherstash/migration';
import { Migration, MigrationCLI, col, primaryKey } from '@prisma-next/postgres/migration';
import { cipherstashAddSearchConfig } from '@prisma-next/extension-cipherstash/migration'
import {
col,
Migration,
MigrationCLI,
primaryKey,
} from '@prisma-next/postgres/migration'

export default class M extends Migration {
override describe() {
return {
from: null,
to: 'sha256:b8c4febc9397cf1b68293cdb3b2afe9f568db6967f428e3f207e32575a2bc2fa',
};
}
}

override get operations() {
Expand All @@ -34,17 +39,30 @@ export default class M extends Migration {
notNull: true,
codecRef: {
codecId: 'cipherstash/string@1',
typeParams: { equality: true, freeTextSearch: true, orderAndRange: true },
typeParams: {
equality: true,
freeTextSearch: true,
orderAndRange: true,
},
},
}),
col('emailverified', 'eql_v2_encrypted', {
notNull: true,
codecRef: { codecId: 'cipherstash/boolean@1', typeParams: { equality: true } },
codecRef: {
codecId: 'cipherstash/boolean@1',
typeParams: { equality: true },
},
}),
col('id', 'text', {
notNull: true,
codecRef: { codecId: 'pg/text@1' },
}),
col('id', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }),
col('preferences', 'eql_v2_encrypted', {
notNull: true,
codecRef: { codecId: 'cipherstash/json@1', typeParams: { searchableJson: true } },
codecRef: {
codecId: 'cipherstash/json@1',
typeParams: { searchableJson: true },
},
}),
col('salary', 'eql_v2_encrypted', {
notNull: true,
Expand Down Expand Up @@ -80,9 +98,21 @@ export default class M extends Migration {
index: 'ore',
castAs: 'date',
}),
cipherstashAddSearchConfig({ table: 'users', column: 'email', index: 'unique' }),
cipherstashAddSearchConfig({ table: 'users', column: 'email', index: 'match' }),
cipherstashAddSearchConfig({ table: 'users', column: 'email', index: 'ore' }),
cipherstashAddSearchConfig({
table: 'users',
column: 'email',
index: 'unique',
}),
cipherstashAddSearchConfig({
table: 'users',
column: 'email',
index: 'match',
}),
cipherstashAddSearchConfig({
table: 'users',
column: 'email',
index: 'ore',
}),
cipherstashAddSearchConfig({
table: 'users',
column: 'emailverified',
Expand All @@ -107,8 +137,8 @@ export default class M extends Migration {
index: 'ore',
castAs: 'double',
}),
];
]
}
}

MigrationCLI.run(import.meta.url, M);
MigrationCLI.run(import.meta.url, M)
8 changes: 6 additions & 2 deletions examples/prisma/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ async function main() {
}

async function clearUsers(): Promise<void> {
const removed = await db.orm.public.User.where((u) => u.id.isNotNull()).deleteCount()
const removed = await db.orm.public.User.where((u) =>
u.id.isNotNull(),
).deleteCount()
if (removed > 0) {
console.log(`--- Cleanup ---\nRemoved ${removed} existing user row(s).\n`)
}
Expand Down Expand Up @@ -212,7 +214,9 @@ async function equalityQueryOnEmailVerified(): Promise<void> {

async function sortByEmailAsc(): Promise<void> {
console.log('\n--- cipherstashAsc (bare-column ORDER BY) ---')
const rows = await db.orm.public.User.orderBy((u) => cipherstashAsc(u.email)).all()
const rows = await db.orm.public.User.orderBy((u) =>
cipherstashAsc(u.email),
).all()
await decryptAll(rows)
for (const row of rows) {
console.log(` ${row.id}: email=${await row.email.decrypt()}`)
Expand Down
20 changes: 7 additions & 13 deletions examples/prisma/test/e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,14 @@ export default async function setup(): Promise<() => Promise<void>> {
// is for the `pnpm start` demo loop).
process.env['DATABASE_URL'] = HARNESS_DATABASE_URL

const apply = spawnSync(
'pnpm',
['exec', 'prisma-next', 'migrate', '--yes'],
{
cwd: exampleDir,
stdio: 'pipe',
env: process.env,
timeout: MIGRATION_APPLY_TIMEOUT_MS,
},
)
const apply = spawnSync('pnpm', ['exec', 'prisma-next', 'migrate', '--yes'], {
cwd: exampleDir,
stdio: 'pipe',
env: process.env,
timeout: MIGRATION_APPLY_TIMEOUT_MS,
})
if (apply.error || apply.signal || apply.status !== 0) {
throw new Error(
describeSpawnFailure('`prisma-next migrate`', apply),
)
throw new Error(describeSpawnFailure('`prisma-next migrate`', apply))
}

// Clean slate for the suite. The `users` table is the only data-bearing
Expand Down
4 changes: 3 additions & 1 deletion examples/prisma/test/e2e/str-range.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ describe('EncryptedString orderAndRange e2e (live PG + EQL + ZeroKMS)', () => {
})

it('cipherstashAsc orders alphabetically (bare-column ORDER BY on string)', async () => {
const rows = await db.orm.public.User.orderBy((u) => cipherstashAsc(u.email)).all()
const rows = await db.orm.public.User.orderBy((u) =>
cipherstashAsc(u.email),
).all()
expect(rows.map((r) => r.id)).toEqual([
'e2e-str-0',
'e2e-str-1',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"dev": "turbo dev --filter './packages/*'",
"clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules",
"code:fix": "biome check --write",
"code:check": "biome check",
"lint:runners": "node scripts/lint-no-hardcoded-runners.mjs",
"lint:workflow-cache": "node scripts/lint-no-workflow-caching.mjs",
"release": "pnpm run build && changeset publish",
Expand Down
63 changes: 31 additions & 32 deletions packages/cli/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,37 +54,34 @@ describe('loadStashConfig', () => {
it.each([
['stash', `Cannot find module 'stash'`],
['@cipherstash/stack', `Cannot find package '@cipherstash/stack'`],
])(
'translates a missing `%s` module into actionable guidance (#579)',
async (pkg, message) => {
fs.writeFileSync(
path.join(tmpDir, 'stash.config.ts'),
`import 'stash'\nexport default {}`,
)
process.cwd = () => tmpDir
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit')
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

const moduleErr = Object.assign(new Error(message), {
code: 'MODULE_NOT_FOUND',
})
const { createJiti } = await import('jiti')
vi.mocked(createJiti).mockReturnValue({
import: vi.fn().mockRejectedValue(moduleErr),
} as never)

const { loadStashConfig } = await import('@/config/index.ts')
await expect(loadStashConfig()).rejects.toThrow('process.exit')

const output = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n')
expect(output).toContain(`\`${pkg}\` is not installed`)
expect(output).toContain('stash init')
// The raw jiti stack trace must NOT be forwarded to the user.
expect(output).not.toContain('Failed to load')
},
)
])('translates a missing `%s` module into actionable guidance (#579)', async (pkg, message) => {
fs.writeFileSync(
path.join(tmpDir, 'stash.config.ts'),
`import 'stash'\nexport default {}`,
)
process.cwd = () => tmpDir
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit')
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

const moduleErr = Object.assign(new Error(message), {
code: 'MODULE_NOT_FOUND',
})
const { createJiti } = await import('jiti')
vi.mocked(createJiti).mockReturnValue({
import: vi.fn().mockRejectedValue(moduleErr),
} as never)

const { loadStashConfig } = await import('@/config/index.ts')
await expect(loadStashConfig()).rejects.toThrow('process.exit')

const output = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n')
expect(output).toContain(`\`${pkg}\` is not installed`)
expect(output).toContain('stash init')
// The raw jiti stack trace must NOT be forwarded to the user.
expect(output).not.toContain('Failed to load')
})

it('still surfaces the raw error for unrelated config load failures', async () => {
fs.writeFileSync(path.join(tmpDir, 'stash.config.ts'), 'export default {}')
Expand Down Expand Up @@ -157,7 +154,9 @@ describe('loadEncryptConfig', () => {
let originalCwd: () => string

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-encrypt-config-test-'))
tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'stash-encrypt-config-test-'),
)
originalCwd = process.cwd
})

Expand Down
Loading
Loading