diff --git a/e2e/package.json b/e2e/package.json index bf5f0577c..03d3bf485 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -14,7 +14,7 @@ "@cipherstash/wizard": "workspace:*" }, "devDependencies": { - "@types/semver": "7.7.1", + "@types/semver": "7.8.0", "semver": "^7.8.0", "typescript": "catalog:repo", "vitest": "catalog:repo", diff --git a/package.json b/package.json index 87769b6c8..18f1d96bc 100644 --- a/package.json +++ b/package.json @@ -42,12 +42,12 @@ "test:scripts": "vitest run --config scripts/vitest.config.mjs" }, "devDependencies": { - "@biomejs/biome": "^2.5.3", - "@changesets/cli": "^2.31.0", + "@biomejs/biome": "^2.5.9", + "@changesets/cli": "^2.31.1", "@types/node": "^22.20.1", "js-yaml": "^4.3.1", "rimraf": "^6.1.3", - "turbo": "2.10.4", + "turbo": "2.10.10", "vitest": "catalog:repo" }, "packageManager": "pnpm@10.33.2", diff --git a/packages/cli/src/__tests__/config.test.ts b/packages/cli/src/__tests__/config.test.ts index 5189bc2f7..13fe63c5c 100644 --- a/packages/cli/src/__tests__/config.test.ts +++ b/packages/cli/src/__tests__/config.test.ts @@ -54,34 +54,37 @@ 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 {}') diff --git a/packages/cli/src/__tests__/database-url.test.ts b/packages/cli/src/__tests__/database-url.test.ts index e5afb2f58..1d0ee564b 100644 --- a/packages/cli/src/__tests__/database-url.test.ts +++ b/packages/cli/src/__tests__/database-url.test.ts @@ -258,25 +258,23 @@ describe('resolveDatabaseUrl — prompt source', () => { }) describe('resolveDatabaseUrl — CI guard', () => { - it.each([ - 'true', - 'TRUE', - '1', - ' true ', - ])('does not prompt and exits 1 when CI=%j (truthy)', async (ciValue) => { - process.env.CI = ciValue - Object.defineProperty(process.stdin, 'isTTY', { - value: true, - configurable: true, - }) - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { - throw new Error('process.exit') - }) as never) - await expect(resolveDatabaseUrl()).rejects.toThrow('process.exit') - expect(exitSpy).toHaveBeenCalledWith(1) - expect(clack.text).not.toHaveBeenCalled() - expect(clack.log.error).toHaveBeenCalledWith(messages.db.urlMissingCi) - }) + it.each(['true', 'TRUE', '1', ' true '])( + 'does not prompt and exits 1 when CI=%j (truthy)', + async (ciValue) => { + process.env.CI = ciValue + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + await expect(resolveDatabaseUrl()).rejects.toThrow('process.exit') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(clack.text).not.toHaveBeenCalled() + expect(clack.log.error).toHaveBeenCalledWith(messages.db.urlMissingCi) + }, + ) it('does not prompt when stdin is not a TTY (e.g. piped)', async () => { Object.defineProperty(process.stdin, 'isTTY', { diff --git a/packages/cli/src/__tests__/release-train.test.ts b/packages/cli/src/__tests__/release-train.test.ts index 4a322eac1..800b7d0dd 100644 --- a/packages/cli/src/__tests__/release-train.test.ts +++ b/packages/cli/src/__tests__/release-train.test.ts @@ -93,32 +93,31 @@ describe('release train coverage', () => { expect(skillFiles.length).toBeGreaterThan(0) }) - it.each( - skillFiles, - )('$skill pins release-train packages at a stable version on the current major', ({ - body, - }) => { - // Exact pins only (`pkg@1.2.3`, optionally `npm:`-prefixed and with a - // trailing subpath). A range spec (`^1.0.0`) carries its own semantics - // and is not a pin to check. - const PIN = - /(?:npm:)?(stash|@cipherstash\/[a-z0-9-]+)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g - - for (const [, pkg, pinned] of body.matchAll(PIN)) { - if (!isReleaseTrainPackage(pkg)) continue - const current = manifestVersion(pkg) - - expect( - pinned, - `${pkg}@${pinned} pins a prerelease — skills ship to customers, so pin the stable release (${stableVersion(current)})`, - ).not.toContain('-') - - expect( - pinned.split('.')[0], - `${pkg}@${pinned} is off this release line (${current}) — update the pin and the prose around it`, - ).toBe(stableVersion(current).split('.')[0]) - } - }) + it.each(skillFiles)( + '$skill pins release-train packages at a stable version on the current major', + ({ body }) => { + // Exact pins only (`pkg@1.2.3`, optionally `npm:`-prefixed and with a + // trailing subpath). A range spec (`^1.0.0`) carries its own semantics + // and is not a pin to check. + const PIN = + /(?:npm:)?(stash|@cipherstash\/[a-z0-9-]+)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g + + for (const [, pkg, pinned] of body.matchAll(PIN)) { + if (!isReleaseTrainPackage(pkg)) continue + const current = manifestVersion(pkg) + + expect( + pinned, + `${pkg}@${pinned} pins a prerelease — skills ship to customers, so pin the stable release (${stableVersion(current)})`, + ).not.toContain('-') + + expect( + pinned.split('.')[0], + `${pkg}@${pinned} is off this release line (${current}) — update the pin and the prose around it`, + ).toBe(stableVersion(current).split('.')[0]) + } + }, + ) }) it('every train manifest exists and carries a version (what tsup will embed)', () => { diff --git a/packages/cli/src/__tests__/rewrite-migrations.test.ts b/packages/cli/src/__tests__/rewrite-migrations.test.ts index c3ca5f5af..0eda4a28d 100644 --- a/packages/cli/src/__tests__/rewrite-migrations.test.ts +++ b/packages/cli/src/__tests__/rewrite-migrations.test.ts @@ -238,26 +238,26 @@ describe('rewriteEncryptedAlterColumns', () => { // DOMAIN_RE is derived from ENCRYPTED_DOMAIN, so drift between the two can't // silently leave a domain unrewritten. Prove every domain the alternation // recognises is actually extracted into the emitted ADD COLUMN. - it.each([ - ...V3_DOMAINS, - 'eql_v2_encrypted', - ])('extracts the bare domain %s from a mangled ALTER', async (domain) => { - declarePlaintext('"t"', 'c') - const filePath = path.join(tmpDir, '0015_drift.sql') - fs.writeFileSync( - filePath, - `ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE "undefined"."${domain}";\n`, - ) + it.each([...V3_DOMAINS, 'eql_v2_encrypted'])( + 'extracts the bare domain %s from a mangled ALTER', + async (domain) => { + declarePlaintext('"t"', 'c') + const filePath = path.join(tmpDir, '0015_drift.sql') + fs.writeFileSync( + filePath, + `ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE "undefined"."${domain}";\n`, + ) - const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) - expect(rewritten).toEqual([filePath]) - const updated = fs.readFileSync(filePath, 'utf-8') - expect(updated).toContain( - `ALTER TABLE "t" ADD COLUMN "c_encrypted" "public"."${domain}";`, - ) - expect(updated).not.toContain('SET DATA TYPE') - }) + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + `ALTER TABLE "t" ADD COLUMN "c_encrypted" "public"."${domain}";`, + ) + expect(updated).not.toContain('SET DATA TYPE') + }, + ) // The mangled forms are the cross product of what `dataType()` returns and // which drizzle-kit era renders it. Verified against drizzle-kit 0.24.2, @@ -312,22 +312,25 @@ describe('rewriteEncryptedAlterColumns', () => { 'dotted inside "undefined", drizzle-kit >=0.31.0', '"undefined"."public.eql_v2_encrypted"', ], - ])('rewrites the previously unmatched v2 %s form', async (_label, emitted) => { - declarePlaintext('"users"', 'email') - const filePath = path.join(tmpDir, '0009_v2form.sql') - fs.writeFileSync( - filePath, - `ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE ${emitted};\n`, - ) + ])( + 'rewrites the previously unmatched v2 %s form', + async (_label, emitted) => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, '0009_v2form.sql') + fs.writeFileSync( + filePath, + `ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE ${emitted};\n`, + ) - await rewriteEncryptedAlterColumns(tmpDir) + await rewriteEncryptedAlterColumns(tmpDir) - const updated = fs.readFileSync(filePath, 'utf-8') - expect(updated).toContain( - 'ALTER TABLE "users" ADD COLUMN "email_encrypted" "public"."eql_v2_encrypted";', - ) - expect(updated).not.toContain('SET DATA TYPE') - }) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "users" ADD COLUMN "email_encrypted" "public"."eql_v2_encrypted";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }, + ) it('names the target domain in the guidance comment', async () => { declarePlaintext('"users"', 'email') @@ -925,26 +928,30 @@ describe('rewriteEncryptedAlterColumns', () => { it.each([ ['tagged', 'price$usd$cents'], ['untagged', 'price$$cents'], - ])('does not treat a %s dollar delimiter inside an unquoted identifier as a dollar-quoted body', async (_kind, identifier) => { - declarePlaintext('"users"', 'email') - const filePath = path.join(tmpDir, `0033_${_kind}-identifier.sql`) - fs.writeFileSync( - filePath, - [ - `SELECT ${identifier} FROM "prices";`, - 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', - '', - ].join('\n'), - ) - - const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) - - expect(rewritten).toEqual([filePath]) - expect(skipped).toEqual([]) - const updated = fs.readFileSync(filePath, 'utf-8') - expect(updated).toContain('ADD COLUMN "email_encrypted"') - expect(updated).not.toContain('SET DATA TYPE') - }) + ])( + 'does not treat a %s dollar delimiter inside an unquoted identifier as a dollar-quoted body', + async (_kind, identifier) => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, `0033_${_kind}-identifier.sql`) + fs.writeFileSync( + filePath, + [ + `SELECT ${identifier} FROM "prices";`, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n'), + ) + + const { rewritten, skipped } = + await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain('ADD COLUMN "email_encrypted"') + expect(updated).not.toContain('SET DATA TYPE') + }, + ) }) /** diff --git a/packages/cli/src/__tests__/skill-supabase-apply.test.ts b/packages/cli/src/__tests__/skill-supabase-apply.test.ts index 68f79fac9..7883340ab 100644 --- a/packages/cli/src/__tests__/skill-supabase-apply.test.ts +++ b/packages/cli/src/__tests__/skill-supabase-apply.test.ts @@ -36,30 +36,29 @@ describe('skills — Supabase apply commands', () => { expect(SKILL_FILES.length).toBeGreaterThan(0) }) - it.each( - SKILL_FILES, - )('$skill never presents a bare `supabase migration up` as the remote apply', ({ - body, - }) => { - // Collapse wrapping and drop markdown emphasis first: the qualifier - // routinely lands on the next source line or arrives as `**local**`, and - // either would fail the match on formatting rather than content. - const prose = body.replace(/\s+/g, ' ').replace(/[*`_]/g, '') + it.each(SKILL_FILES)( + '$skill never presents a bare `supabase migration up` as the remote apply', + ({ body }) => { + // Collapse wrapping and drop markdown emphasis first: the qualifier + // routinely lands on the next source line or arrives as `**local**`, and + // either would fail the match on formatting rather than content. + const prose = body.replace(/\s+/g, ' ').replace(/[*`_]/g, '') - for (const match of prose.matchAll(/supabase migration up/g)) { - // Only what immediately follows the command counts. A window wide - // enough to find a "local database" elsewhere in the sentence accepts - // the very wording this guard rejects — "apply with supabase migration - // up (or supabase db reset locally, once the local database exists)" - // qualifies the other command, not this one. - const following = prose.slice(match.index + match[0].length) + for (const match of prose.matchAll(/supabase migration up/g)) { + // Only what immediately follows the command counts. A window wide + // enough to find a "local database" elsewhere in the sentence accepts + // the very wording this guard rejects — "apply with supabase migration + // up (or supabase db reset locally, once the local database exists)" + // qualifies the other command, not this one. + const following = prose.slice(match.index + match[0].length) - expect( - following.slice(0, 60), - '`supabase migration up` applies to the LOCAL database — add `--linked`, say "applies to the local database", or use `supabase db push` for remote', - ).toMatch(/^(?: --linked\b| applies to the local database\b)/i) - } - }) + expect( + following.slice(0, 60), + '`supabase migration up` applies to the LOCAL database — add `--linked`, say "applies to the local database", or use `supabase db push` for remote', + ).toMatch(/^(?: --linked\b| applies to the local database\b)/i) + } + }, + ) /** * `supabase migration repair --status applied ` writes a ledger row @@ -78,26 +77,30 @@ describe('skills — Supabase apply commands', () => { * because it is created by the bundle's closing statements and so cannot * resolve on a half-applied install, unlike the `eql_v3` schema itself. */ - it.each( - SKILL_FILES, - )('$skill never recommends the ledger-only repair without a check above it', ({ - body, - }) => { - // Same wrap-collapsing as above, minus the `_` strip: the marker here is - // `eql_v3.version()`, which that strip would turn into `eqlv3.version()`. - const prose = body.replace(/\s+/g, ' ').replace(/[*`]/g, '') + it.each(SKILL_FILES)( + '$skill never recommends the ledger-only repair without a check above it', + ({ body }) => { + // Same wrap-collapsing as above, minus the `_` strip: the marker here is + // `eql_v3.version()`, which that strip would turn into `eqlv3.version()`. + const prose = body.replace(/\s+/g, ' ').replace(/[*`]/g, '') - for (const match of prose.matchAll(/migration repair --status applied/g)) { - // A paragraph's worth of lead-in. Wide enough for the sentence that - // introduces the check plus the one that explains what its output means, - // narrow enough that an `eql_v3.version()` mention elsewhere in the - // document cannot stand in for one attached to this recommendation. - const preceding = prose.slice(Math.max(0, match.index - 700), match.index) + for (const match of prose.matchAll( + /migration repair --status applied/g, + )) { + // A paragraph's worth of lead-in. Wide enough for the sentence that + // introduces the check plus the one that explains what its output means, + // narrow enough that an `eql_v3.version()` mention elsewhere in the + // document cannot stand in for one attached to this recommendation. + const preceding = prose.slice( + Math.max(0, match.index - 700), + match.index, + ) - expect( - preceding, - '`migration repair --status applied` writes a ledger row for SQL that may never have run — an unrecoverable state on a remote without EQL. Print the `select eql_v3.version()` check above this recommendation, not after it', - ).toContain('eql_v3.version()') - } - }) + expect( + preceding, + '`migration repair --status applied` writes a ledger row for SQL that may never have run — an unrecoverable state on a remote without EQL. Print the `select eql_v3.version()` check above this recommendation, not after it', + ).toContain('eql_v3.version()') + } + }, + ) }) diff --git a/packages/cli/src/commands/eql/__tests__/migration.test.ts b/packages/cli/src/commands/eql/__tests__/migration.test.ts index d46fda8aa..eb0ada154 100644 --- a/packages/cli/src/commands/eql/__tests__/migration.test.ts +++ b/packages/cli/src/commands/eql/__tests__/migration.test.ts @@ -1151,29 +1151,28 @@ describe('eqlMigrationCommand — Drizzle', () => { // work a partial sweep did complete (#786). A throw that is not an object — // or not one carrying those arrays — must fall through to the plain "could // not sweep" message and still fail closed, not crash on a property read. - it.each([ - null, - undefined, - 'rewrite failed', - ])('handles a non-object sweep failure without masking it: %s', async (failure) => { - const out = join(tmp, 'drizzle') - mkdirSync(out, { recursive: true }) - spawnMock.mockImplementation(() => { - writeFileSync(join(out, '0000_install-eql.sql'), '') - return { status: 0, stdout: '', stderr: '' } - }) - rewriteMock.spy.mockRejectedValueOnce(failure) + it.each([null, undefined, 'rewrite failed'])( + 'handles a non-object sweep failure without masking it: %s', + async (failure) => { + const out = join(tmp, 'drizzle') + mkdirSync(out, { recursive: true }) + spawnMock.mockImplementation(() => { + writeFileSync(join(out, '0000_install-eql.sql'), '') + return { status: 0, stdout: '', stderr: '' } + }) + rewriteMock.spy.mockRejectedValueOnce(failure) - await expect( - eqlMigrationCommand({ drizzle: true, out }), - ).rejects.toBeInstanceOf(CliExit) - expect(clack.log.warn).toHaveBeenCalledWith( - expect.stringContaining('Could not sweep'), - ) - expect(clack.log.error).toHaveBeenCalledWith( - expect.stringContaining('unsafe or unverified SQL'), - ) - }) + await expect( + eqlMigrationCommand({ drizzle: true, out }), + ).rejects.toBeInstanceOf(CliExit) + expect(clack.log.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not sweep'), + ) + expect(clack.log.error).toHaveBeenCalledWith( + expect.stringContaining('unsafe or unverified SQL'), + ) + }, + ) /** * A sweep that threw PART WAY through has already written staged twins to diff --git a/packages/cli/src/commands/eql/__tests__/repair.test.ts b/packages/cli/src/commands/eql/__tests__/repair.test.ts index a3eda6313..5f1c38ab4 100644 --- a/packages/cli/src/commands/eql/__tests__/repair.test.ts +++ b/packages/cli/src/commands/eql/__tests__/repair.test.ts @@ -548,28 +548,33 @@ describe('eqlRepairCommand — applied migrations', () => { it.each([ ['undefined_table', '42P01'], ['invalid_schema_name', '3F000'], - ])('does not claim nothing is applied when the ledger (%s) is absent', async (_l, code) => { - const out = join(tmp, 'drizzle') - writeBrokenCorpus(out) - pgMock.query.mockRejectedValue( - Object.assign(new Error('relation does not exist'), { code }), - ) - - await eqlRepairCommand({ - drizzle: true, - out, - databaseUrl: 'postgres://user:pw@localhost:5432/app', - }) - - expect(clack.log.info).not.toHaveBeenCalledWith( - messages.eql.repairNothingApplied, - ) - const warned = clack.log.warn.mock.calls.map((c) => String(c[0])).join('\n') - // Names where it looked, and the config key that would move it — a - // warning the user cannot act on is not much better than silence. - expect(warned).toContain('drizzle.__drizzle_migrations') - expect(warned).toContain('--migrations-table') - }) + ])( + 'does not claim nothing is applied when the ledger (%s) is absent', + async (_l, code) => { + const out = join(tmp, 'drizzle') + writeBrokenCorpus(out) + pgMock.query.mockRejectedValue( + Object.assign(new Error('relation does not exist'), { code }), + ) + + await eqlRepairCommand({ + drizzle: true, + out, + databaseUrl: 'postgres://user:pw@localhost:5432/app', + }) + + expect(clack.log.info).not.toHaveBeenCalledWith( + messages.eql.repairNothingApplied, + ) + const warned = clack.log.warn.mock.calls + .map((c) => String(c[0])) + .join('\n') + // Names where it looked, and the config key that would move it — a + // warning the user cannot act on is not much better than silence. + expect(warned).toContain('drizzle.__drizzle_migrations') + expect(warned).toContain('--migrations-table') + }, + ) /** * Applied files carry TWO kinds of statement, and they need different words. @@ -647,23 +652,26 @@ describe('eqlRepairCommand — applied migrations', () => { ['too many parts', 'db.drizzle.__drizzle_migrations'], ['an empty part', 'drizzle.'], ['a quoted identifier', '"drizzle"."__drizzle_migrations"'], - ])('rejects a --migrations-table containing %s', async (_label, migrationsTable) => { - const out = join(tmp, 'drizzle') - const broken = writeBrokenCorpus(out) - - await expect( - eqlRepairCommand({ - drizzle: true, - out, - databaseUrl: 'postgres://user:pw@localhost:5432/app', - migrationsTable, - }), - ).rejects.toBeInstanceOf(CliExit) - - // Never reached the database, and never touched the migration. - expect(pgMock.query).not.toHaveBeenCalled() - expect(readFileSync(broken, 'utf-8')).toBe(BROKEN_ALTER) - }) + ])( + 'rejects a --migrations-table containing %s', + async (_label, migrationsTable) => { + const out = join(tmp, 'drizzle') + const broken = writeBrokenCorpus(out) + + await expect( + eqlRepairCommand({ + drizzle: true, + out, + databaseUrl: 'postgres://user:pw@localhost:5432/app', + migrationsTable, + }), + ).rejects.toBeInstanceOf(CliExit) + + // Never reached the database, and never touched the migration. + expect(pgMock.query).not.toHaveBeenCalled() + expect(readFileSync(broken, 'utf-8')).toBe(BROKEN_ALTER) + }, + ) // A bare table name is the common case — drizzle's `migrations.table` without // a `migrations.schema`. It must resolve via search_path, not be forced into diff --git a/packages/cli/src/commands/init/__tests__/placeholder-client-fixture.test.ts b/packages/cli/src/commands/init/__tests__/placeholder-client-fixture.test.ts index c3ddbf548..718ecaaee 100644 --- a/packages/cli/src/commands/init/__tests__/placeholder-client-fixture.test.ts +++ b/packages/cli/src/commands/init/__tests__/placeholder-client-fixture.test.ts @@ -50,25 +50,25 @@ describe('the scaffolded client fixtures match the generator', () => { }) describe('the scaffold compiles because it declares a table', () => { - it.each([ - 'generic.generated.ts', - 'drizzle.generated.ts', - ])('%s passes a non-empty schema set', (file) => { - const body = fixture(file) - // The empty form is a hard TS2769 against both overloads — `Encryption` - // requires at least one table by design (S-6), so the scaffold cannot go - // back to `schemas: []` without breaking every project it is written into. - expect(body).not.toContain('Encryption({ schemas: [] })') - expect(body).toContain('schemas: [placeholderTable]') - }) + it.each(['generic.generated.ts', 'drizzle.generated.ts'])( + '%s passes a non-empty schema set', + (file) => { + const body = fixture(file) + // The empty form is a hard TS2769 against both overloads — `Encryption` + // requires at least one table by design (S-6), so the scaffold cannot go + // back to `schemas: []` without breaking every project it is written into. + expect(body).not.toContain('Encryption({ schemas: [] })') + expect(body).toContain('schemas: [placeholderTable]') + }, + ) - it.each([ - 'generic.generated.ts', - 'drizzle.generated.ts', - ])('%s uses the sentinel name the config loader refuses', (file) => { - // `loadEncryptConfig` exits 1 when this is the only table left, so the - // two must agree — otherwise the user gets a confusing "table not found" - // from whichever command runs next instead of "you never replaced this". - expect(fixture(file)).toContain(`'${PLACEHOLDER_TABLE_NAME}'`) - }) + it.each(['generic.generated.ts', 'drizzle.generated.ts'])( + '%s uses the sentinel name the config loader refuses', + (file) => { + // `loadEncryptConfig` exits 1 when this is the only table left, so the + // two must agree — otherwise the user gets a confusing "table not found" + // from whichever command runs next instead of "you never replaced this". + expect(fixture(file)).toContain(`'${PLACEHOLDER_TABLE_NAME}'`) + }, + ) }) diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts index d30246f14..53d1ad0c9 100644 --- a/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts @@ -34,24 +34,25 @@ const GENERATED_SOURCES: Array<[string, string]> = [ ['generatePlaceholderClient', generatePlaceholderClient('drizzle')], ] -describe.each( - GENERATED_SOURCES, -)('drizzle scaffold — %s', (_name, generated) => { - it('names the drizzle package root, never the removed ./v3 subpath', () => { - expect(generated).toContain('@cipherstash/stack-drizzle') - expect(generated).not.toContain('@cipherstash/stack-drizzle/v3') - }) +describe.each(GENERATED_SOURCES)( + 'drizzle scaffold — %s', + (_name, generated) => { + it('names the drizzle package root, never the removed ./v3 subpath', () => { + expect(generated).toContain('@cipherstash/stack-drizzle') + expect(generated).not.toContain('@cipherstash/stack-drizzle/v3') + }) - it('uses the de-suffixed export names, never the removed *V3 aliases', () => { - expect(generated).not.toContain('extractEncryptionSchemaV3') - expect(generated).not.toContain('createEncryptionOperatorsV3') - }) + it('uses the de-suffixed export names, never the removed *V3 aliases', () => { + expect(generated).not.toContain('extractEncryptionSchemaV3') + expect(generated).not.toContain('createEncryptionOperatorsV3') + }) - it('never scaffolds the removed EQL v2 authoring surface', () => { - expect(generated).not.toContain('encryptedType') - expect(generated).not.toContain('eql_v2_encrypted') - }) -}) + it('never scaffolds the removed EQL v2 authoring surface', () => { + expect(generated).not.toContain('encryptedType') + expect(generated).not.toContain('eql_v2_encrypted') + }) + }, +) describe('generatePlaceholderClient (drizzle)', () => { const placeholder = generatePlaceholderClient('drizzle') diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts index 21d0d929c..68e134514 100644 --- a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts @@ -94,16 +94,16 @@ const ALL_DOMAINS: import('../types.js').V3Domain[] = [ 'Json', ] -describe.each([ - 'postgresql', - 'drizzle', -] as const)('generateClientFromSchemas domain round-trip (%s)', (integration) => { - it.each(ALL_DOMAINS)('emits types.%s verbatim', (domain) => { - const s: SchemaDef[] = [ - { tableName: 'x', columns: [{ name: 'c', domain }] }, - ] - expect(generateClientFromSchemas(integration, s)).toContain( - `c: types.${domain}('c'),`, - ) - }) -}) +describe.each(['postgresql', 'drizzle'] as const)( + 'generateClientFromSchemas domain round-trip (%s)', + (integration) => { + it.each(ALL_DOMAINS)('emits types.%s verbatim', (domain) => { + const s: SchemaDef[] = [ + { tableName: 'x', columns: [{ name: 'c', domain }] }, + ] + expect(generateClientFromSchemas(integration, s)).toContain( + `c: types.${domain}('c'),`, + ) + }) + }, +) diff --git a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts index 46940b53e..770f415f0 100644 --- a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts @@ -104,22 +104,22 @@ describe('SKILL_MAP', () => { // `dist/*.d.ts` and the Postgres catalog. `postgresql` is that path; // Supabase shares it (Edge Functions are the flagship WASM-entry use, and // its migrations/RPC are hand-written SQL). - it.each([ - 'postgresql', - 'supabase', - ] as const)('%s includes the raw-SQL and edge skills', (integration) => { - expect(SKILL_MAP[integration]).toContain('stash-postgres') - expect(SKILL_MAP[integration]).toContain('stash-edge') - }) + it.each(['postgresql', 'supabase'] as const)( + '%s includes the raw-SQL and edge skills', + (integration) => { + expect(SKILL_MAP[integration]).toContain('stash-postgres') + expect(SKILL_MAP[integration]).toContain('stash-edge') + }, + ) // The ORM integrations emit correctly-typed operands themselves, so they // get cross-links from their own skills rather than the full install. - it.each([ - 'drizzle', - 'prisma-next', - ] as const)('%s does not install the raw-SQL skill', (integration) => { - expect(SKILL_MAP[integration]).not.toContain('stash-postgres') - }) + it.each(['drizzle', 'prisma-next'] as const)( + '%s does not install the raw-SQL skill', + (integration) => { + expect(SKILL_MAP[integration]).not.toContain('stash-postgres') + }, + ) }) describe('skillsFor', () => { diff --git a/packages/cli/src/commands/init/lib/__tests__/rollout-state.test.ts b/packages/cli/src/commands/init/lib/__tests__/rollout-state.test.ts index f7a2f5105..1c4f6903f 100644 --- a/packages/cli/src/commands/init/lib/__tests__/rollout-state.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/rollout-state.test.ts @@ -26,13 +26,12 @@ describe('classifyPhase', () => { expect(classifyPhase('dual-writing')).toBe('cutover') }) - it.each([ - 'backfilling', - 'backfilled', - 'cut-over', - ] as MigrationPhase[])('%s classifies as cutover (mid-cutover work)', (phase) => { - expect(classifyPhase(phase)).toBe('cutover') - }) + it.each(['backfilling', 'backfilled', 'cut-over'] as MigrationPhase[])( + '%s classifies as cutover (mid-cutover work)', + (phase) => { + expect(classifyPhase(phase)).toBe('cutover') + }, + ) it('dropped classifies as completed', () => { expect(classifyPhase('dropped')).toBe('completed') diff --git a/packages/cli/src/commands/init/steps/__tests__/install-skills.test.ts b/packages/cli/src/commands/init/steps/__tests__/install-skills.test.ts index 751f84e05..7b3fb1916 100644 --- a/packages/cli/src/commands/init/steps/__tests__/install-skills.test.ts +++ b/packages/cli/src/commands/init/steps/__tests__/install-skills.test.ts @@ -113,18 +113,17 @@ describe('skillDestinations', () => { // These handoffs inline the skill bodies into AGENTS.md instead of copying // directories, and `init` performs no handoff — so there is nothing to write. - it.each([ - 'agents-md', - 'lovable', - 'wizard', - ] as const)('installs no directories for --target %s', (target) => { - expect( - skillDestinations( - env({ cli: { claudeCode: true, codex: false } }), - target, - ), - ).toEqual([]) - }) + it.each(['agents-md', 'lovable', 'wizard'] as const)( + 'installs no directories for --target %s', + (target) => { + expect( + skillDestinations( + env({ cli: { claudeCode: true, codex: false } }), + target, + ), + ).toEqual([]) + }, + ) }) describe('guessIntegration', () => { diff --git a/packages/cli/src/db/__tests__/config.test.ts b/packages/cli/src/db/__tests__/config.test.ts index 38b8a1dba..63c6059b6 100644 --- a/packages/cli/src/db/__tests__/config.test.ts +++ b/packages/cli/src/db/__tests__/config.test.ts @@ -73,18 +73,16 @@ describe('buildPgClientConfig', () => { expect(String(stderr.mock.calls[0]?.[0])).toContain('NOT authenticated') }) - it.each([ - 'require', - 'prefer', - 'verify-ca', - 'verify-full', - ])('sslmode=%s verifies fully and strips the param', (mode) => { - const config = buildPgClientConfig( - `postgres://u@db.example.com/app?sslmode=${mode}`, - ) - expect(ssl(config).rejectUnauthorized).toBe(true) - expect(config.connectionString).not.toContain('sslmode') - }) + it.each(['require', 'prefer', 'verify-ca', 'verify-full'])( + 'sslmode=%s verifies fully and strips the param', + (mode) => { + const config = buildPgClientConfig( + `postgres://u@db.example.com/app?sslmode=${mode}`, + ) + expect(ssl(config).rejectUnauthorized).toBe(true) + expect(config.connectionString).not.toContain('sslmode') + }, + ) it('honours sslrootcert= as the sole trust anchor (libpq semantics)', () => { const dir = mkdtempSync(join(tmpdir(), 'stash-ca-')) diff --git a/packages/cli/tests/e2e/doctor-missing-binary.e2e.test.ts b/packages/cli/tests/e2e/doctor-missing-binary.e2e.test.ts index aa144e647..ad7c3bd19 100644 --- a/packages/cli/tests/e2e/doctor-missing-binary.e2e.test.ts +++ b/packages/cli/tests/e2e/doctor-missing-binary.e2e.test.ts @@ -105,31 +105,32 @@ Module.syncBuiltinESMExports() const currentTarget = `${process.platform}-${process.arch}` describe('stash doctor — a platform binary is missing', () => { - it.each( - TARGETS.map((t) => [t.pkg, t] as const), - )('fails the %s row, names the package and exits non-zero', async (_pkg, target) => { - // Wider than the 100-col default so the note's `Missing package:` line is - // asserted as written rather than as clack happened to wrap it. - const r = render(['doctor'], { - env: { NODE_OPTIONS: hideBinaryOf(target) }, - cols: 140, - }) - const { exitCode } = await r.exit - - expect( - exitCode, - `stash doctor passed with no ${target.pkg} binary installed:\n${r.output}`, - ).toBe(1) - expect(r.output).toContain( - `${target.label} — ${messages.doctor.nativeBinaryMissing}`, - ) - expect(r.output).toContain(messages.doctor.problemsFound) - - // The guidance, not just a red row: the platform package by name is the - // one piece of it a user cannot work out for themselves. - expect(r.output).toContain(`${target.pkg}-${currentTarget}`) - expect(r.output).not.toContain('Fatal error') - }) + it.each(TARGETS.map((t) => [t.pkg, t] as const))( + 'fails the %s row, names the package and exits non-zero', + async (_pkg, target) => { + // Wider than the 100-col default so the note's `Missing package:` line is + // asserted as written rather than as clack happened to wrap it. + const r = render(['doctor'], { + env: { NODE_OPTIONS: hideBinaryOf(target) }, + cols: 140, + }) + const { exitCode } = await r.exit + + expect( + exitCode, + `stash doctor passed with no ${target.pkg} binary installed:\n${r.output}`, + ).toBe(1) + expect(r.output).toContain( + `${target.label} — ${messages.doctor.nativeBinaryMissing}`, + ) + expect(r.output).toContain(messages.doctor.problemsFound) + + // The guidance, not just a red row: the platform package by name is the + // one piece of it a user cannot work out for themselves. + expect(r.output).toContain(`${target.pkg}-${currentTarget}`) + expect(r.output).not.toContain('Fatal error') + }, + ) it('fails only the row whose binary is missing', async () => { // Non-vacuity for the pair above. Both probes went through diff --git a/packages/cli/tests/e2e/runner-aware-help.e2e.test.ts b/packages/cli/tests/e2e/runner-aware-help.e2e.test.ts index e8b1fa093..aacf94ea6 100644 --- a/packages/cli/tests/e2e/runner-aware-help.e2e.test.ts +++ b/packages/cli/tests/e2e/runner-aware-help.e2e.test.ts @@ -23,35 +23,31 @@ const cases = [ ] as const describe('--help — runner-aware Usage + Examples', () => { - it.each( - cases, - )('with npm_config_user_agent=$ua, renders "$label stash"', async ({ - ua, - label, - }) => { - const r = await run(['--help'], { env: { npm_config_user_agent: ua } }) - expect(r.exitCode).toBe(0) - // Usage line must use the right runner. The leader is stable - // (`messages.cli.usagePrefix === 'Usage: '`) so we assert on the - // suffix the renderer composes at runtime. - expect(r.output).toContain(`Usage: ${label} stash`) - // At least one of the Examples lines must surface the same runner. - expect(r.output).toContain(`${label} stash init`) - expect(r.output).toContain(`${label} stash eql install`) - }) + it.each(cases)( + 'with npm_config_user_agent=$ua, renders "$label stash"', + async ({ ua, label }) => { + const r = await run(['--help'], { env: { npm_config_user_agent: ua } }) + expect(r.exitCode).toBe(0) + // Usage line must use the right runner. The leader is stable + // (`messages.cli.usagePrefix === 'Usage: '`) so we assert on the + // suffix the renderer composes at runtime. + expect(r.output).toContain(`Usage: ${label} stash`) + // At least one of the Examples lines must surface the same runner. + expect(r.output).toContain(`${label} stash init`) + expect(r.output).toContain(`${label} stash eql install`) + }, + ) }) describe('auth — runner-aware Usage + Examples', () => { - it.each( - cases, - )('with npm_config_user_agent=$ua, renders "$label stash auth"', async ({ - ua, - label, - }) => { - // `auth` with no subcommand prints the auth HELP and exits 0. - const r = await run(['auth'], { env: { npm_config_user_agent: ua } }) - expect(r.exitCode).toBe(0) - expect(r.output).toContain(`Usage: ${label} stash auth`) - expect(r.output).toContain(`${label} stash auth login`) - }) + it.each(cases)( + 'with npm_config_user_agent=$ua, renders "$label stash auth"', + async ({ ua, label }) => { + // `auth` with no subcommand prints the auth HELP and exits 0. + const r = await run(['auth'], { env: { npm_config_user_agent: ua } }) + expect(r.exitCode).toBe(0) + expect(r.output).toContain(`Usage: ${label} stash auth`) + expect(r.output).toContain(`${label} stash auth login`) + }, + ) }) diff --git a/packages/cli/tests/e2e/v2-retirement.e2e.test.ts b/packages/cli/tests/e2e/v2-retirement.e2e.test.ts index eeaa73dca..3f0f9f8fc 100644 --- a/packages/cli/tests/e2e/v2-retirement.e2e.test.ts +++ b/packages/cli/tests/e2e/v2-retirement.e2e.test.ts @@ -30,15 +30,18 @@ describe('retired EQL v2 CLI surface', () => { ['--no-proxy', '--no-proxy'], ['--proxy=true', '--proxy'], ['--no-proxy=true', '--no-proxy'], - ] as const)('rejects retired init flag `%s` before init work', async (arg, flag) => { - const result = await runPiped(['init', arg], { timeoutMs: 2_000 }) + ] as const)( + 'rejects retired init flag `%s` before init work', + async (arg, flag) => { + const result = await runPiped(['init', arg], { timeoutMs: 2_000 }) - expect(result.timedOut).toBe(false) - expect(result.exitCode).toBe(1) - expect(output(result)).toContain(flag) - expect(output(result)).toMatch(/removed|retired/i) - expect(output(result)).toMatch(/EQL v3/i) - }) + expect(result.timedOut).toBe(false) + expect(result.exitCode).toBe(1) + expect(output(result)).toContain(flag) + expect(output(result)).toMatch(/removed|retired/i) + expect(output(result)).toMatch(/EQL v3/i) + }, + ) it.each([ '--eql-version=2', diff --git a/packages/protect-ffi/integration-tests/tests/eql-v3.test.ts b/packages/protect-ffi/integration-tests/tests/eql-v3.test.ts index c6d890196..2eecfee4b 100644 --- a/packages/protect-ffi/integration-tests/tests/eql-v3.test.ts +++ b/packages/protect-ffi/integration-tests/tests/eql-v3.test.ts @@ -219,35 +219,35 @@ describe('eql v3 scalar round-trips', async () => { }, ] - test.each(cases)('round-trips $column as eql_v3.$domain', async ({ - column, - plaintext, - expected, - domain, - keys, - }) => { - const ciphertext = await encrypt(client, { - plaintext, - column, - table: 'v3users', - }) - - const payload = expectV3Scalar(ciphertext) - expect( - Object.keys(payload).sort(), - `the config must map ${column} onto eql_v3.${domain}`, - ).toEqual(keys) - - const decrypted = await decrypt(client, { ciphertext }) - if (expected !== undefined) { - expect(decrypted).toBe(expected) - } else if (typeof plaintext === 'number' && !Number.isInteger(plaintext)) { - // decimal decrypts to a string representation - expect(Number(decrypted)).toBeCloseTo(plaintext) - } else { - expect(decrypted).toBe(plaintext) - } - }) + test.each(cases)( + 'round-trips $column as eql_v3.$domain', + async ({ column, plaintext, expected, domain, keys }) => { + const ciphertext = await encrypt(client, { + plaintext, + column, + table: 'v3users', + }) + + const payload = expectV3Scalar(ciphertext) + expect( + Object.keys(payload).sort(), + `the config must map ${column} onto eql_v3.${domain}`, + ).toEqual(keys) + + const decrypted = await decrypt(client, { ciphertext }) + if (expected !== undefined) { + expect(decrypted).toBe(expected) + } else if ( + typeof plaintext === 'number' && + !Number.isInteger(plaintext) + ) { + // decimal decrypts to a string representation + expect(Number(decrypted)).toBeCloseTo(plaintext) + } else { + expect(decrypted).toBe(plaintext) + } + }, + ) test('round-trips a date column as eql_v3.date_ord_ore', async () => { const d = new Date('2024-03-01T00:00:00.000Z') @@ -488,28 +488,23 @@ describe('eql v3 query encryption', async () => { }, ] - test.each( - scalarQueryCases, - )('scalar $indexType query on $column returns a term-only eql_v3.query_$domain operand', async ({ - column, - plaintext, - indexType, - domain, - keys, - }) => { - const result = await encryptQuery(client, { - plaintext, - column, - table: 'v3users', - indexType, - }) - - const operand = expectV3QueryOperand(result) - expect( - Object.keys(operand).sort(), - `the operand must target eql_v3.query_${domain}`, - ).toEqual(keys) - }) + test.each(scalarQueryCases)( + 'scalar $indexType query on $column returns a term-only eql_v3.query_$domain operand', + async ({ column, plaintext, indexType, domain, keys }) => { + const result = await encryptQuery(client, { + plaintext, + column, + table: 'v3users', + indexType, + }) + + const operand = expectV3QueryOperand(result) + expect( + Object.keys(operand).sort(), + `the operand must target eql_v3.query_${domain}`, + ).toEqual(keys) + }, + ) test('selector query returns the bare selector hash as a string', async () => { const selector = await encryptQuery(client, { diff --git a/packages/protect-ffi/integration-tests/tests/json-bulk.test.ts b/packages/protect-ffi/integration-tests/tests/json-bulk.test.ts index 15e1f2b5b..bd8f309d7 100644 --- a/packages/protect-ffi/integration-tests/tests/json-bulk.test.ts +++ b/packages/protect-ffi/integration-tests/tests/json-bulk.test.ts @@ -28,68 +28,68 @@ const userProfile: UserColumn = { describe.each([ { encryptConfig: jsonOpaque, description: 'opaque' }, { encryptConfig: jsonSteVec, description: 'ste_vec' }, -])('Can round-trip encrypt & decrypt JSON', async ({ - encryptConfig, - description, -}) => { - describe(`using ${description} config`, () => { - test('object', async () => { - const client = await newClient({ encryptConfig }) - const plaintexts = [ - { foo: 'bar', baz: 123 }, - { foo: 'baz', baz: 456 }, - ] +])( + 'Can round-trip encrypt & decrypt JSON', + async ({ encryptConfig, description }) => { + describe(`using ${description} config`, () => { + test('object', async () => { + const client = await newClient({ encryptConfig }) + const plaintexts = [ + { foo: 'bar', baz: 123 }, + { foo: 'baz', baz: 456 }, + ] - const ciphertexts = await encryptBulk(client, { - plaintexts: plaintexts.map((plaintext) => ({ - plaintext, - ...userProfile, - })), + const ciphertexts = await encryptBulk(client, { + plaintexts: plaintexts.map((plaintext) => ({ + plaintext, + ...userProfile, + })), + }) + const decrypted = await decryptBulk(client, { + ciphertexts: ciphertexts.map((ciphertext) => ({ ciphertext })), + }) + expect(decrypted).toEqual(plaintexts) }) - const decrypted = await decryptBulk(client, { - ciphertexts: ciphertexts.map((ciphertext) => ({ ciphertext })), - }) - expect(decrypted).toEqual(plaintexts) - }) - test('array object mixed', async () => { - const client = await newClient({ encryptConfig }) - const plaintexts = [[1, 2, 3], { foo: 'baz', baz: 456 }] + test('array object mixed', async () => { + const client = await newClient({ encryptConfig }) + const plaintexts = [[1, 2, 3], { foo: 'baz', baz: 456 }] - const ciphertexts = await encryptBulk(client, { - plaintexts: plaintexts.map((plaintext) => ({ - plaintext, - ...userProfile, - })), - }) + const ciphertexts = await encryptBulk(client, { + plaintexts: plaintexts.map((plaintext) => ({ + plaintext, + ...userProfile, + })), + }) + + const decrypted = await decryptBulk(client, { + ciphertexts: ciphertexts.map((ciphertext) => ({ ciphertext })), + }) - const decrypted = await decryptBulk(client, { - ciphertexts: ciphertexts.map((ciphertext) => ({ ciphertext })), + expect(decrypted).toEqual(plaintexts) }) - expect(decrypted).toEqual(plaintexts) - }) + test('nested variants', async () => { + const client = await newClient({ encryptConfig }) + const plaintexts = [ + { foo: 'bar', baz: [1, 2, 3] }, + { foo: 'bar', baz: [{ qux: 'quux' }] }, + { foo: 'bar', baz: { qux: 'quux' } }, + ] - test('nested variants', async () => { - const client = await newClient({ encryptConfig }) - const plaintexts = [ - { foo: 'bar', baz: [1, 2, 3] }, - { foo: 'bar', baz: [{ qux: 'quux' }] }, - { foo: 'bar', baz: { qux: 'quux' } }, - ] + const ciphertexts = await encryptBulk(client, { + plaintexts: plaintexts.map((plaintext) => ({ + plaintext, + ...userProfile, + })), + }) - const ciphertexts = await encryptBulk(client, { - plaintexts: plaintexts.map((plaintext) => ({ - plaintext, - ...userProfile, - })), - }) + const decrypted = await decryptBulk(client, { + ciphertexts: ciphertexts.map((ciphertext) => ({ ciphertext })), + }) - const decrypted = await decryptBulk(client, { - ciphertexts: ciphertexts.map((ciphertext) => ({ ciphertext })), + expect(decrypted).toEqual(plaintexts) }) - - expect(decrypted).toEqual(plaintexts) }) - }) -}) + }, +) diff --git a/packages/protect-ffi/integration-tests/tests/json.test.ts b/packages/protect-ffi/integration-tests/tests/json.test.ts index 32d7aa6ba..a92d42ad5 100644 --- a/packages/protect-ffi/integration-tests/tests/json.test.ts +++ b/packages/protect-ffi/integration-tests/tests/json.test.ts @@ -27,82 +27,82 @@ const userProfile: UserColumn = { describe.each([ { encryptConfig: jsonOpaque, description: 'opaque' }, { encryptConfig: jsonSteVec, description: 'ste_vec' }, -])('Can round-trip encrypt & decrypt JSON', ({ - encryptConfig, - description, -}) => { - describe(`using ${description} config`, () => { - test('object', async ({ annotate }) => { - const client = await newClient({ encryptConfig }) - const originalPlaintext = { foo: 'bar', baz: 123 } - - const ciphertext = await encrypt(client, { - plaintext: originalPlaintext, - ...userProfile, +])( + 'Can round-trip encrypt & decrypt JSON', + ({ encryptConfig, description }) => { + describe(`using ${description} config`, () => { + test('object', async ({ annotate }) => { + const client = await newClient({ encryptConfig }) + const originalPlaintext = { foo: 'bar', baz: 123 } + + const ciphertext = await encrypt(client, { + plaintext: originalPlaintext, + ...userProfile, + }) + + const decrypted = await decrypt(client, { ciphertext }) + + expect(decrypted).toEqual(originalPlaintext) }) - const decrypted = await decrypt(client, { ciphertext }) + test('array', async () => { + const client = await newClient({ encryptConfig }) + const originalPlaintext = [1, 2, 3] - expect(decrypted).toEqual(originalPlaintext) - }) + const ciphertext = await encrypt(client, { + plaintext: originalPlaintext, + ...userProfile, + }) - test('array', async () => { - const client = await newClient({ encryptConfig }) - const originalPlaintext = [1, 2, 3] + const decrypted = await decrypt(client, { ciphertext }) - const ciphertext = await encrypt(client, { - plaintext: originalPlaintext, - ...userProfile, + expect(decrypted).toEqual(originalPlaintext) }) - const decrypted = await decrypt(client, { ciphertext }) + test('nested array within object', async () => { + const client = await newClient({ encryptConfig }) + const originalPlaintext = { foo: 'bar', baz: [1, 2, 3] } - expect(decrypted).toEqual(originalPlaintext) - }) + const ciphertext = await encrypt(client, { + plaintext: originalPlaintext, + ...userProfile, + }) - test('nested array within object', async () => { - const client = await newClient({ encryptConfig }) - const originalPlaintext = { foo: 'bar', baz: [1, 2, 3] } + const decrypted = await decrypt(client, { ciphertext }) - const ciphertext = await encrypt(client, { - plaintext: originalPlaintext, - ...userProfile, + expect(decrypted).toEqual(originalPlaintext) }) - const decrypted = await decrypt(client, { ciphertext }) + test('nested object within object', async () => { + const client = await newClient({ encryptConfig }) + const originalPlaintext = { foo: 'bar', baz: { qux: 'quux' } } - expect(decrypted).toEqual(originalPlaintext) - }) + const ciphertext = await encrypt(client, { + plaintext: originalPlaintext, + ...userProfile, + }) - test('nested object within object', async () => { - const client = await newClient({ encryptConfig }) - const originalPlaintext = { foo: 'bar', baz: { qux: 'quux' } } + const decrypted = await decrypt(client, { ciphertext }) - const ciphertext = await encrypt(client, { - plaintext: originalPlaintext, - ...userProfile, + expect(decrypted).toEqual(originalPlaintext) }) - const decrypted = await decrypt(client, { ciphertext }) + test('nested object within array', async () => { + const client = await newClient({ encryptConfig }) + const originalPlaintext = { foo: 'bar', baz: [{ qux: 'quux' }] } - expect(decrypted).toEqual(originalPlaintext) - }) + const ciphertext = await encrypt(client, { + plaintext: originalPlaintext, + ...userProfile, + }) - test('nested object within array', async () => { - const client = await newClient({ encryptConfig }) - const originalPlaintext = { foo: 'bar', baz: [{ qux: 'quux' }] } + const decrypted = await decrypt(client, { ciphertext }) - const ciphertext = await encrypt(client, { - plaintext: originalPlaintext, - ...userProfile, + expect(decrypted).toEqual(originalPlaintext) }) - - const decrypted = await decrypt(client, { ciphertext }) - - expect(decrypted).toEqual(originalPlaintext) }) - }) -}) + }, +) describe('SteVec output structure', () => { test('encrypted output has expected fields', async () => { diff --git a/packages/protect-ffi/integration-tests/tests/scalar.test.ts b/packages/protect-ffi/integration-tests/tests/scalar.test.ts index 7c93669f9..be7dc7a6a 100644 --- a/packages/protect-ffi/integration-tests/tests/scalar.test.ts +++ b/packages/protect-ffi/integration-tests/tests/scalar.test.ts @@ -50,45 +50,44 @@ const cases: { { identifier: numberColumn, plaintext: 123.456, expected: 123.456 }, ] -describe.each(cases)('encrypt and decrypt', ({ - identifier, - plaintext, - expected, -}) => { - describe(`using column ${identifier.column} with ${typeof plaintext} value`, () => { - test('can round-trip encrypt and decrypt a string', async () => { - const client = await newClient({ encryptConfig }) - const ciphertext = await encrypt(client, { - plaintext, - ...identifier, +describe.each(cases)( + 'encrypt and decrypt', + ({ identifier, plaintext, expected }) => { + describe(`using column ${identifier.column} with ${typeof plaintext} value`, () => { + test('can round-trip encrypt and decrypt a string', async () => { + const client = await newClient({ encryptConfig }) + const ciphertext = await encrypt(client, { + plaintext, + ...identifier, + }) + + expect(isEncrypted(ciphertext)).toBe(true) + + const decrypted = await decrypt(client, { ciphertext }) + expect(decrypted).toBe(expected) }) - expect(isEncrypted(ciphertext)).toBe(true) + test('can explicitly pass in undefined for optional fields', async () => { + const client = await newClient({ encryptConfig }) - const decrypted = await decrypt(client, { ciphertext }) - expect(decrypted).toBe(expected) - }) + const ciphertext = await encrypt(client, { + plaintext, + lockContext: undefined, + unverifiedContext: undefined, + ...identifier, + }) - test('can explicitly pass in undefined for optional fields', async () => { - const client = await newClient({ encryptConfig }) + const decrypted = await decrypt(client, { + ciphertext, + lockContext: undefined, + unverifiedContext: undefined, + }) - const ciphertext = await encrypt(client, { - plaintext, - lockContext: undefined, - unverifiedContext: undefined, - ...identifier, + expect(decrypted).toBe(expected) }) - - const decrypted = await decrypt(client, { - ciphertext, - lockContext: undefined, - unverifiedContext: undefined, - }) - - expect(decrypted).toBe(expected) }) - }) -}) + }, +) describe('bigint plaintexts', () => { const I64_MAX = 2n ** 63n - 1n diff --git a/packages/protect-ffi/package.json b/packages/protect-ffi/package.json index 0df409c8c..0edef5ffa 100644 --- a/packages/protect-ffi/package.json +++ b/packages/protect-ffi/package.json @@ -91,9 +91,9 @@ "prefix": "protect-ffi-" }, "devDependencies": { - "@biomejs/biome": "^2.5.3", - "@neon-rs/cli": "^0.1.82", - "@tsconfig/node22": "^22.0.0", + "@biomejs/biome": "^2.5.9", + "@neon-rs/cli": "^0.2.6", + "@tsconfig/node22": "^22.0.6", "@types/node": "^22.20.1", "typescript": "catalog:repo", "vitest": "catalog:repo" diff --git a/packages/stack-drizzle/__tests__/codec.test.ts b/packages/stack-drizzle/__tests__/codec.test.ts index 6d44985a4..0abfb1dd9 100644 --- a/packages/stack-drizzle/__tests__/codec.test.ts +++ b/packages/stack-drizzle/__tests__/codec.test.ts @@ -83,44 +83,39 @@ describe('v3 codec', () => { // wrong-shape payload was returned as-is — `v3FromDriver('5')` yielded `5` // typed as an envelope, which then reached `decrypt` as garbage. describe('malformed and non-envelope payloads', () => { - it.each([ - '{bad', - '', - '{"v":3,', - ])('throws EqlV3CodecError on unparseable jsonb %j', (raw) => { - expect(() => v3FromDriver(raw)).toThrow(EqlV3CodecError) - expect(() => v3FromDriver(raw)).toThrow(/Failed to parse/) - }) + it.each(['{bad', '', '{"v":3,'])( + 'throws EqlV3CodecError on unparseable jsonb %j', + (raw) => { + expect(() => v3FromDriver(raw)).toThrow(EqlV3CodecError) + expect(() => v3FromDriver(raw)).toThrow(/Failed to parse/) + }, + ) // The parse failure is the only codec path with an upstream error to // preserve. Flattening it to a message string discards the SyntaxError and // its stack, so a caller debugging a corrupt column loses where parsing // actually broke. - it.each([ - '{bad', - '', - '{"v":3,', - ])('preserves the underlying SyntaxError as `cause` for %j', (raw) => { - let thrown: unknown - try { - v3FromDriver(raw) - } catch (error) { - thrown = error - } - - expect(thrown).toBeInstanceOf(EqlV3CodecError) - expect((thrown as EqlV3CodecError).cause).toBeInstanceOf(SyntaxError) - }) + it.each(['{bad', '', '{"v":3,'])( + 'preserves the underlying SyntaxError as `cause` for %j', + (raw) => { + let thrown: unknown + try { + v3FromDriver(raw) + } catch (error) { + thrown = error + } + + expect(thrown).toBeInstanceOf(EqlV3CodecError) + expect((thrown as EqlV3CodecError).cause).toBeInstanceOf(SyntaxError) + }, + ) - it.each([ - '5', - '"a string"', - 'true', - '[]', - '[{"v":3,"c":"ct"}]', - ])('throws on valid JSON %j that is not an envelope', (raw) => { - expect(() => v3FromDriver(raw)).toThrow(EqlV3CodecError) - }) + it.each(['5', '"a string"', 'true', '[]', '[{"v":3,"c":"ct"}]'])( + 'throws on valid JSON %j that is not an envelope', + (raw) => { + expect(() => v3FromDriver(raw)).toThrow(EqlV3CodecError) + }, + ) it('throws on an object missing the schema version', () => { expect(() => v3FromDriver({ c: 'ct' })).toThrow(/missing "v"/) diff --git a/packages/stack-drizzle/__tests__/column.test.ts b/packages/stack-drizzle/__tests__/column.test.ts index dfca7f9b8..1fee78d19 100644 --- a/packages/stack-drizzle/__tests__/column.test.ts +++ b/packages/stack-drizzle/__tests__/column.test.ts @@ -158,27 +158,28 @@ describe('makeEqlV3Column', () => { expect(builder?.getEqlType()).toBe('public.eql_v3_integer_ord') }) - it.each( - typedEntries(V3_MATRIX), - )('%s round-trips through makeEqlV3Column and pgTable', (eqlType, spec) => { - const columnName = slug(eqlType) - const builder = spec.builder(columnName) - const column = makeEqlV3Column(builder) - - expect(builder.getEqlType()).toBe(eqlType) - expect(builder).toBeInstanceOf(spec.ColumnClass) - expect(isEqlV3Column(column)).toBe(true) - expect(getEqlV3Column(columnName, column)?.getEqlType()).toBe(eqlType) - - const table = pgTable('users', { [columnName]: column } as never) - const pgColumn = (table as Record)[ - columnName - ] - // getSQLType() is the bare (unqualified) domain — what drizzle-kit emits — - // while recovery still yields the qualified builder identity. - expect(pgColumn?.getSQLType()).toBe(slug(eqlType)) - expect(getEqlV3Column(columnName, pgColumn)?.getEqlType()).toBe(eqlType) - }) + it.each(typedEntries(V3_MATRIX))( + '%s round-trips through makeEqlV3Column and pgTable', + (eqlType, spec) => { + const columnName = slug(eqlType) + const builder = spec.builder(columnName) + const column = makeEqlV3Column(builder) + + expect(builder.getEqlType()).toBe(eqlType) + expect(builder).toBeInstanceOf(spec.ColumnClass) + expect(isEqlV3Column(column)).toBe(true) + expect(getEqlV3Column(columnName, column)?.getEqlType()).toBe(eqlType) + + const table = pgTable('users', { [columnName]: column } as never) + const pgColumn = (table as Record)[ + columnName + ] + // getSQLType() is the bare (unqualified) domain — what drizzle-kit emits — + // while recovery still yields the qualified builder identity. + expect(pgColumn?.getSQLType()).toBe(slug(eqlType)) + expect(getEqlV3Column(columnName, pgColumn)?.getEqlType()).toBe(eqlType) + }, + ) // The `toDriver`/`fromDriver` closures wired into `customType` (column.ts) are // otherwise only tested via the standalone codec functions, so the wiring diff --git a/packages/stack-drizzle/__tests__/operators.test.ts b/packages/stack-drizzle/__tests__/operators.test.ts index bc2456173..f9709b19e 100644 --- a/packages/stack-drizzle/__tests__/operators.test.ts +++ b/packages/stack-drizzle/__tests__/operators.test.ts @@ -139,21 +139,22 @@ const users = pgTable('users', { }) describe('createEncryptionOperators - equality', () => { - it.each( - equalityDomains, - )('%s eq emits the latest two-arg eql_v3.eq with a query-term operand', async (eqlType, spec) => { - const { ops, encryptQuery, render } = setup() - const q = render(await ops.eq(matrixColumn(eqlType), sampleFor(spec))) + it.each(equalityDomains)( + '%s eq emits the latest two-arg eql_v3.eq with a query-term operand', + async (eqlType, spec) => { + const { ops, encryptQuery, render } = setup() + const q = render(await ops.eq(matrixColumn(eqlType), sampleFor(spec))) - expect(q.sql).toContain( - `eql_v3.eq("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, - ) - expect(q.params).toEqual([TERM_JSON]) - expect(encryptQuery.mock.calls[0]?.[1]?.column.getName()).toBe( - slug(eqlType), - ) - expect(encryptQuery.mock.calls[0]?.[1]?.queryType).toBe('equality') - }) + expect(q.sql).toContain( + `eql_v3.eq("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, + ) + expect(q.params).toEqual([TERM_JSON]) + expect(encryptQuery.mock.calls[0]?.[1]?.column.getName()).toBe( + slug(eqlType), + ) + expect(encryptQuery.mock.calls[0]?.[1]?.queryType).toBe('equality') + }, + ) it.each(equalityDomains)('%s ne emits eql_v3.neq', async (eqlType, spec) => { const { ops, encryptQuery, render } = setup() @@ -250,38 +251,42 @@ describe('createEncryptionOperators - comparison & range', () => { } }) - it.each( - orderDomains, - )('%s between emits a bounded range with two query-term operands', async (eqlType, spec) => { - const { ops, render } = setup() - const value = sampleFor(spec) - const q = render(await ops.between(matrixColumn(eqlType), value, value)) - - expect(q.sql).toContain( - `eql_v3.gte("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, - ) - expect(q.sql).toContain( - `eql_v3.lte("matrix_users"."${slug(eqlType)}", $2::${qcast(eqlType)})`, - ) - expect(q.params).toEqual([TERM_JSON, TERM_JSON]) - }) + it.each(orderDomains)( + '%s between emits a bounded range with two query-term operands', + async (eqlType, spec) => { + const { ops, render } = setup() + const value = sampleFor(spec) + const q = render(await ops.between(matrixColumn(eqlType), value, value)) - it.each( - orderDomains, - )('%s notBetween wraps the range in NOT (...)', async (eqlType, spec) => { - const { ops, render } = setup() - const value = sampleFor(spec) - const q = render(await ops.notBetween(matrixColumn(eqlType), value, value)) + expect(q.sql).toContain( + `eql_v3.gte("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, + ) + expect(q.sql).toContain( + `eql_v3.lte("matrix_users"."${slug(eqlType)}", $2::${qcast(eqlType)})`, + ) + expect(q.params).toEqual([TERM_JSON, TERM_JSON]) + }, + ) + + it.each(orderDomains)( + '%s notBetween wraps the range in NOT (...)', + async (eqlType, spec) => { + const { ops, render } = setup() + const value = sampleFor(spec) + const q = render( + await ops.notBetween(matrixColumn(eqlType), value, value), + ) - expect(q.sql).toMatch(/^not \(/i) - expect(q.sql).toContain( - `eql_v3.gte("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, - ) - expect(q.sql).toContain( - `eql_v3.lte("matrix_users"."${slug(eqlType)}", $2::${qcast(eqlType)})`, - ) - expect(q.params).toEqual([TERM_JSON, TERM_JSON]) - }) + expect(q.sql).toMatch(/^not \(/i) + expect(q.sql).toContain( + `eql_v3.gte("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, + ) + expect(q.sql).toContain( + `eql_v3.lte("matrix_users"."${slug(eqlType)}", $2::${qcast(eqlType)})`, + ) + expect(q.params).toEqual([TERM_JSON, TERM_JSON]) + }, + ) // Every other `between` case passes identical bounds against a constant // encrypt stub, so the operand never reaches an assertion and a min/max @@ -334,59 +339,64 @@ describe('createEncryptionOperators - comparison & range', () => { ) }) - it.each( - orderDomains, - )('%s asc / desc extract the ord term', (eqlType, spec) => { - const { ops, render } = setup() - // eql-3.0.0 splits the extractor by ordering flavour: `ord_term` for the - // OPE-backed `_ord` domains, `ord_term_ore` for the block-ORE `_ord_ore`. - const fn = spec.indexes.ore ? 'ord_term_ore' : 'ord_term' - const ascq = render(ops.asc(matrixColumn(eqlType))) - expect(ascq.sql).toContain( - `eql_v3.${fn}("matrix_users"."${slug(eqlType)}")`, - ) - expect(ascq.sql.toLowerCase()).toContain('asc') + it.each(orderDomains)( + '%s asc / desc extract the ord term', + (eqlType, spec) => { + const { ops, render } = setup() + // eql-3.0.0 splits the extractor by ordering flavour: `ord_term` for the + // OPE-backed `_ord` domains, `ord_term_ore` for the block-ORE `_ord_ore`. + const fn = spec.indexes.ore ? 'ord_term_ore' : 'ord_term' + const ascq = render(ops.asc(matrixColumn(eqlType))) + expect(ascq.sql).toContain( + `eql_v3.${fn}("matrix_users"."${slug(eqlType)}")`, + ) + expect(ascq.sql.toLowerCase()).toContain('asc') - const descq = render(ops.desc(matrixColumn(eqlType))) - expect(descq.sql).toContain( - `eql_v3.${fn}("matrix_users"."${slug(eqlType)}")`, - ) - expect(descq.sql.toLowerCase()).toContain('desc') - }) + const descq = render(ops.desc(matrixColumn(eqlType))) + expect(descq.sql).toContain( + `eql_v3.${fn}("matrix_users"."${slug(eqlType)}")`, + ) + expect(descq.sql.toLowerCase()).toContain('desc') + }, + ) }) describe('createEncryptionOperators - free-text match', () => { - it.each( - matchDomains, - )('%s matches emits latest eql_v3.matches with a query-term operand', async (eqlType, spec) => { - const { ops, encryptQuery, render } = setup() - const q = render(await ops.matches(matrixColumn(eqlType), needleFor(spec))) + it.each(matchDomains)( + '%s matches emits latest eql_v3.matches with a query-term operand', + async (eqlType, spec) => { + const { ops, encryptQuery, render } = setup() + const q = render( + await ops.matches(matrixColumn(eqlType), needleFor(spec)), + ) - expect(q.sql).toContain( - `eql_v3.matches("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, - ) - expect(q.params).toEqual([TERM_JSON]) - expect(encryptQuery.mock.calls[0]?.[1]?.column.getName()).toBe( - slug(eqlType), - ) - expect(encryptQuery.mock.calls[0]?.[1]?.queryType).toBe('freeTextSearch') - }) + expect(q.sql).toContain( + `eql_v3.matches("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, + ) + expect(q.params).toEqual([TERM_JSON]) + expect(encryptQuery.mock.calls[0]?.[1]?.column.getName()).toBe( + slug(eqlType), + ) + expect(encryptQuery.mock.calls[0]?.[1]?.queryType).toBe('freeTextSearch') + }, + ) // A needle shorter than the tokenizer's `token_length` produces an empty // bloom filter, and `stored_bf @> '{}'` is true for every row — so this must // throw rather than silently return the whole table. - it.each( - matchDomains, - )('%s matches rejects a needle shorter than token_length before encrypting', async (eqlType) => { - const { ops, encryptQuery } = setup() - await expect(ops.matches(matrixColumn(eqlType), 'ad')).rejects.toThrow( - /at least 3 characters/, - ) - await expect(ops.matches(matrixColumn(eqlType), '')).rejects.toThrow( - EncryptionOperatorError, - ) - expect(encryptQuery).not.toHaveBeenCalled() - }) + it.each(matchDomains)( + '%s matches rejects a needle shorter than token_length before encrypting', + async (eqlType) => { + const { ops, encryptQuery } = setup() + await expect(ops.matches(matrixColumn(eqlType), 'ad')).rejects.toThrow( + /at least 3 characters/, + ) + await expect(ops.matches(matrixColumn(eqlType), '')).rejects.toThrow( + EncryptionOperatorError, + ) + expect(encryptQuery).not.toHaveBeenCalled() + }, + ) it('matches accepts a needle exactly at token_length', async () => { const { ops, render } = setup() diff --git a/packages/stack-drizzle/__tests__/sql-dialect.test.ts b/packages/stack-drizzle/__tests__/sql-dialect.test.ts index bd67750b3..d9f473c9c 100644 --- a/packages/stack-drizzle/__tests__/sql-dialect.test.ts +++ b/packages/stack-drizzle/__tests__/sql-dialect.test.ts @@ -81,15 +81,18 @@ describe('v3Dialect', () => { ], // `$1` in a title is consumed by vitest as an index into the case tuple, // so it cannot be used to name the SQL placeholder here. - ])('%s binds a hostile operand as a positional parameter', (_name, build) => { - const query = renderFull(build()) - - expect(query.params).toEqual([hostile]) - expect(query.sql).toContain('$1') - // The raw value must appear nowhere in the SQL text. - expect(query.sql).not.toContain('OR 1=1') - expect(query.sql).not.toContain('back\\slash') - }) + ])( + '%s binds a hostile operand as a positional parameter', + (_name, build) => { + const query = renderFull(build()) + + expect(query.params).toEqual([hostile]) + expect(query.sql).toContain('$1') + // The raw value must appear nowhere in the SQL text. + expect(query.sql).not.toContain('OR 1=1') + expect(query.sql).not.toContain('back\\slash') + }, + ) it('range binds both bounds positionally, min first', () => { const query = renderFull( diff --git a/packages/stack-drizzle/__tests__/types.test.ts b/packages/stack-drizzle/__tests__/types.test.ts index b82251a9a..c9b0e0ee9 100644 --- a/packages/stack-drizzle/__tests__/types.test.ts +++ b/packages/stack-drizzle/__tests__/types.test.ts @@ -8,15 +8,16 @@ describe('v3 drizzle types namespace', () => { expect(Object.keys(types).sort()).toEqual(Object.keys(v3Types).sort()) }) - it.each( - Object.entries(types), - )('%s mirrors the authoring DSL and recovers the concrete eql type', (factoryName, factory) => { - const drizzleColumn = factory(factoryName) - const authoredColumn = - v3Types[factoryName as keyof typeof v3Types](factoryName) + it.each(Object.entries(types))( + '%s mirrors the authoring DSL and recovers the concrete eql type', + (factoryName, factory) => { + const drizzleColumn = factory(factoryName) + const authoredColumn = + v3Types[factoryName as keyof typeof v3Types](factoryName) - expect(getEqlV3Column(factoryName, drizzleColumn)?.getEqlType()).toBe( - authoredColumn.getEqlType(), - ) - }) + expect(getEqlV3Column(factoryName, drizzleColumn)?.getEqlType()).toBe( + authoredColumn.getEqlType(), + ) + }, + ) }) diff --git a/packages/stack-drizzle/integration/null-persistence.integration.test.ts b/packages/stack-drizzle/integration/null-persistence.integration.test.ts index 76c2bbc46..098b792ac 100644 --- a/packages/stack-drizzle/integration/null-persistence.integration.test.ts +++ b/packages/stack-drizzle/integration/null-persistence.integration.test.ts @@ -141,42 +141,54 @@ afterAll(async () => { }, 30000) describe('v3 drizzle NULL persistence across tiers (live pg)', () => { - it.each(TIERS)('$domain isNull selects the NULL row', async (tier) => { - expect(await selectRowKeys(ops.isNull(columnFor(tier.key)))).toEqual([ - ROW_A, - ]) - }, 30000) - - it.each(TIERS)('$domain isNotNull selects the present row', async (tier) => { - expect(await selectRowKeys(ops.isNotNull(columnFor(tier.key)))).toEqual([ - ROW_B, - ]) - }, 30000) - - it.each( - TIERS, - )('$domain stores a real NULL for the null row', async (tier) => { - const [row] = await sqlClient.unsafe>( - `SELECT "${tier.db}"::jsonb AS value FROM ${TABLE_NAME} + it.each(TIERS)( + '$domain isNull selects the NULL row', + async (tier) => { + expect(await selectRowKeys(ops.isNull(columnFor(tier.key)))).toEqual([ + ROW_A, + ]) + }, + 30000, + ) + + it.each(TIERS)( + '$domain isNotNull selects the present row', + async (tier) => { + expect(await selectRowKeys(ops.isNotNull(columnFor(tier.key)))).toEqual([ + ROW_B, + ]) + }, + 30000, + ) + + it.each(TIERS)( + '$domain stores a real NULL for the null row', + async (tier) => { + const [row] = await sqlClient.unsafe>( + `SELECT "${tier.db}"::jsonb AS value FROM ${TABLE_NAME} WHERE test_run_id = $1 AND row_key = $2`, - [RUN, ROW_A], - ) - // Without this, a missing fixture makes `row.value` raise a TypeError and - // the failure reads as a null-handling bug rather than an absent row. - expect(row).toBeDefined() - expect(row.value).toBeNull() - }, 30000) - - it.each( - TIERS, - )('$domain present cell decrypts to its plaintext', async (tier) => { - const [row] = await sqlClient.unsafe>( - `SELECT "${tier.db}"::jsonb AS value FROM ${TABLE_NAME} + [RUN, ROW_A], + ) + // Without this, a missing fixture makes `row.value` raise a TypeError and + // the failure reads as a null-handling bug rather than an absent row. + expect(row).toBeDefined() + expect(row.value).toBeNull() + }, + 30000, + ) + + it.each(TIERS)( + '$domain present cell decrypts to its plaintext', + async (tier) => { + const [row] = await sqlClient.unsafe>( + `SELECT "${tier.db}"::jsonb AS value FROM ${TABLE_NAME} WHERE test_run_id = $1 AND row_key = $2`, - [RUN, ROW_B], - ) - expect(row.value).toHaveProperty('c') - const decrypted = unwrap(await client.decrypt(row.value as never)) - expect(decrypted).toBe(tier.sample) - }, 30000) + [RUN, ROW_B], + ) + expect(row.value).toHaveProperty('c') + const decrypted = unwrap(await client.decrypt(row.value as never)) + expect(decrypted).toBe(tier.sample) + }, + 30000, + ) }) diff --git a/packages/stack-drizzle/package.json b/packages/stack-drizzle/package.json index 3684ab1c1..738969ef2 100644 --- a/packages/stack-drizzle/package.json +++ b/packages/stack-drizzle/package.json @@ -65,7 +65,7 @@ "devDependencies": { "@cipherstash/protect-ffi": "workspace:*", "@cipherstash/test-kit": "workspace:*", - "fta-cli": "3.0.0", + "fta-cli": "3.0.1", "dotenv": "17.4.2", "drizzle-orm": "^0.45.2", "postgres": "^3.4.8", diff --git a/packages/stack-prisma/test/v3/operator-lowering-v3-order-range.test.ts b/packages/stack-prisma/test/v3/operator-lowering-v3-order-range.test.ts index 09b53b15f..82394449c 100644 --- a/packages/stack-prisma/test/v3/operator-lowering-v3-order-range.test.ts +++ b/packages/stack-prisma/test/v3/operator-lowering-v3-order-range.test.ts @@ -41,22 +41,27 @@ describe('cipherstash v3 operator lowering — comparisons', () => { ['eqlGte', 'gte'], ['eqlLt', 'lt'], ['eqlLte', 'lte'], - ] as const)('%s lowers to eql_v3.%s(col, $1::eql_v3.query_integer_ord)', (method, fn) => { - const predicate = callOperator( - getOperator(method), - columnAccessorV3(TABLE, 'score', INTEGER_ORD_CODEC_ID), - 10, - ) - const lowered = makeV3Adapter().lower(selectWithWhere(predicate), { - contract: contractV3, - }) - expect(lowered.sql).toBe( - `SELECT "user"."id" AS "id" FROM "user" WHERE eql_v3.${fn}("user"."score", $1::eql_v3.query_integer_ord)`, - ) - const envelope = literalParamValue(lowered.params[0]) - expect(envelope).toBeInstanceOf(EncryptedNumber) - expect(v3QueryTermTypeOf(envelope as EncryptedNumber)).toBe('orderAndRange') - }) + ] as const)( + '%s lowers to eql_v3.%s(col, $1::eql_v3.query_integer_ord)', + (method, fn) => { + const predicate = callOperator( + getOperator(method), + columnAccessorV3(TABLE, 'score', INTEGER_ORD_CODEC_ID), + 10, + ) + const lowered = makeV3Adapter().lower(selectWithWhere(predicate), { + contract: contractV3, + }) + expect(lowered.sql).toBe( + `SELECT "user"."id" AS "id" FROM "user" WHERE eql_v3.${fn}("user"."score", $1::eql_v3.query_integer_ord)`, + ) + const envelope = literalParamValue(lowered.params[0]) + expect(envelope).toBeInstanceOf(EncryptedNumber) + expect(v3QueryTermTypeOf(envelope as EncryptedNumber)).toBe( + 'orderAndRange', + ) + }, + ) it('casts to the ord_ore query domain on a block-ORE column (bigint envelope dispatch)', () => { const predicate = callOperator( diff --git a/packages/stack-supabase/__tests__/supabase-helpers.test.ts b/packages/stack-supabase/__tests__/supabase-helpers.test.ts index 52aa1a73e..08db16385 100644 --- a/packages/stack-supabase/__tests__/supabase-helpers.test.ts +++ b/packages/stack-supabase/__tests__/supabase-helpers.test.ts @@ -245,21 +245,19 @@ describe('parseOrStringWithSpans / rebuildOrString round-trip', () => { // The emit side must never produce a string its own parser mis-reads. A scalar // brace was the one character that escaped quoting, so rebuild → parse dropped // the condition behind it. - it.each([ - 'a{b', - 'a}b', - 'a(b', - 'a)b', - ])('round-trips a scalar value containing %s', (value) => { - const conditions = [ - { column: 'note', op: 'eq', negate: false, value }, - { column: 'id', op: 'eq', negate: false, value: '7' }, - ] - const s = rebuildOrString( - conditions.map((c) => cond(c.column, c.op, c.value)), - ) - expect(parseConditions(s)).toEqual(conditions) - }) + it.each(['a{b', 'a}b', 'a(b', 'a)b'])( + 'round-trips a scalar value containing %s', + (value) => { + const conditions = [ + { column: 'note', op: 'eq', negate: false, value }, + { column: 'id', op: 'eq', negate: false, value: '7' }, + ] + const s = rebuildOrString( + conditions.map((c) => cond(c.column, c.op, c.value)), + ) + expect(parseConditions(s)).toEqual(conditions) + }, + ) }) // --------------------------------------------------------------------------- @@ -329,20 +327,18 @@ describe('parseOrStringWithSpans structural characters inside values', () => { // column ahead of the literal and a plaintext condition behind it — the shape // that actually reaches `rebuildOrString`, since the encrypted `email` is what // forces the group to be rebuilt rather than forwarded verbatim. - it.each([ - '}', - '{', - ')', - '(', - ])('keeps a quoted %s inside a jsonb literal out of the depth count', (char) => { - expect( - parseConditions(`email.eq.x,meta.cs.{"a":"${char}"},note.eq.y`), - ).toEqual([ - { column: 'email', op: 'eq', negate: false, value: 'x' }, - { column: 'meta', op: 'cs', negate: false, value: `{"a":"${char}"}` }, - { column: 'note', op: 'eq', negate: false, value: 'y' }, - ]) - }) + it.each(['}', '{', ')', '('])( + 'keeps a quoted %s inside a jsonb literal out of the depth count', + (char) => { + expect( + parseConditions(`email.eq.x,meta.cs.{"a":"${char}"},note.eq.y`), + ).toEqual([ + { column: 'email', op: 'eq', negate: false, value: 'x' }, + { column: 'meta', op: 'cs', negate: false, value: `{"a":"${char}"}` }, + { column: 'note', op: 'eq', negate: false, value: 'y' }, + ]) + }, + ) it('keeps an escaped quote inside a jsonb value opaque', () => { // `\"` must not close the element, or the `}` behind it decrements depth. @@ -718,22 +714,18 @@ describe('parseOrStringWithSpans in-list elements', () => { // The range operators take a paren-delimited operand too. Excluding them would // re-emit `period.ov.(1,10)` as a quoted scalar — a wire-format change on a // plaintext column that merely shares an `.or()` with an encrypted one. - it.each([ - 'ov', - 'sl', - 'sr', - 'nxr', - 'nxl', - 'adj', - ])('round-trips a paren-delimited %s operand', (op) => { - const s = `period.${op}.(1,10)` - expect(parseConditions(s)).toEqual([ - { column: 'period', op, negate: false, value: ['1', '10'] }, - ]) - expect( - rebuildOrString(parseOrStringWithSpans(s) as DbPendingOrCondition[]), - ).toBe(s) - }) + it.each(['ov', 'sl', 'sr', 'nxr', 'nxl', 'adj'])( + 'round-trips a paren-delimited %s operand', + (op) => { + const s = `period.${op}.(1,10)` + expect(parseConditions(s)).toEqual([ + { column: 'period', op, negate: false, value: ['1', '10'] }, + ]) + expect( + rebuildOrString(parseOrStringWithSpans(s) as DbPendingOrCondition[]), + ).toBe(s) + }, + ) }) // PostgREST reads a bare `null` / `true` / `false` operand as the SQL value, not diff --git a/packages/stack-supabase/package.json b/packages/stack-supabase/package.json index 86411a45e..b6a304817 100644 --- a/packages/stack-supabase/package.json +++ b/packages/stack-supabase/package.json @@ -78,9 +78,9 @@ "devDependencies": { "@cipherstash/protect-ffi": "workspace:*", "@cipherstash/test-kit": "workspace:*", - "fta-cli": "3.0.0", - "@supabase/postgrest-js": "2.110.2", - "@supabase/supabase-js": "^2.110.2", + "fta-cli": "3.0.1", + "@supabase/postgrest-js": "2.112.3", + "@supabase/supabase-js": "^2.112.3", "@types/pg": "^8.23.1", "fast-check": "^4.9.0", "dotenv": "17.4.2", diff --git a/packages/stack/__tests__/cjs-require.test.ts b/packages/stack/__tests__/cjs-require.test.ts index 0b530fd58..23be34a17 100644 --- a/packages/stack/__tests__/cjs-require.test.ts +++ b/packages/stack/__tests__/cjs-require.test.ts @@ -106,62 +106,64 @@ describe('CJS consumers can require the built bundles', () => { ).not.toThrow() }) - it.each( - cjsEntries, - )('dist bundle does not externalize an ESM-only module: %s', (entry) => { - const bundlePath = path.join(packageRoot, entry) - const source = readFileSync(bundlePath, 'utf8') + it.each(cjsEntries)( + 'dist bundle does not externalize an ESM-only module: %s', + (entry) => { + const bundlePath = path.join(packageRoot, entry) + const source = readFileSync(bundlePath, 'utf8') - // Match `require("foo")` or `require('foo')` where `foo` is a bare - // specifier (not a relative path or `node:` builtin) — those are the - // cases that hit Node's CJS module resolver at runtime. - const requireRegex = /\brequire\(\s*['"]([^'".\\/][^'"]*)['"]\s*\)/g - const externalized = new Set() - for (const match of source.matchAll(requireRegex)) { - externalized.add(match[1]) - } + // Match `require("foo")` or `require('foo')` where `foo` is a bare + // specifier (not a relative path or `node:` builtin) — those are the + // cases that hit Node's CJS module resolver at runtime. + const requireRegex = /\brequire\(\s*['"]([^'".\\/][^'"]*)['"]\s*\)/g + const externalized = new Set() + for (const match of source.matchAll(requireRegex)) { + externalized.add(match[1]) + } - for (const dep of ESM_ONLY_DEPENDENCIES) { - expect( - externalized.has(dep), - `${entry} externalizes "${dep}" via require(), which is ESM-only and will crash CJS consumers with ERR_REQUIRE_ESM. Add it to noExternal in packages/stack/tsup.config.ts.`, - ).toBe(false) - } - }) + for (const dep of ESM_ONLY_DEPENDENCIES) { + expect( + externalized.has(dep), + `${entry} externalizes "${dep}" via require(), which is ESM-only and will crash CJS consumers with ERR_REQUIRE_ESM. Add it to noExternal in packages/stack/tsup.config.ts.`, + ).toBe(false) + } + }, + ) - it.each( - cjsEntries, - )('CJS bundle loads in a real Node CJS process: %s', (entry) => { - const bundlePath = path.join(packageRoot, entry) - // Spawn a fresh Node process so this is a true CJS load — vitest's - // own module graph and any test-time aliasing don't apply. - // `--no-experimental-require-module` forces the legacy CJS-cannot- - // require-ESM behavior, reproducing the exact ERR_REQUIRE_ESM that - // downstream apps hit in production runtimes that don't enable - // require(esm) (Node <22.12, Bun, Deno-as-Node, locked-down CI). - try { - execFileSync( - process.execPath, - [ - '--no-experimental-require-module', - '-e', - `require(${JSON.stringify(bundlePath)})`, - ], - { - cwd: packageRoot, - stdio: 'pipe', - encoding: 'utf8', - }, - ) - } catch (err) { - const e = err as NodeJS.ErrnoException & { - stderr?: string - stdout?: string + it.each(cjsEntries)( + 'CJS bundle loads in a real Node CJS process: %s', + (entry) => { + const bundlePath = path.join(packageRoot, entry) + // Spawn a fresh Node process so this is a true CJS load — vitest's + // own module graph and any test-time aliasing don't apply. + // `--no-experimental-require-module` forces the legacy CJS-cannot- + // require-ESM behavior, reproducing the exact ERR_REQUIRE_ESM that + // downstream apps hit in production runtimes that don't enable + // require(esm) (Node <22.12, Bun, Deno-as-Node, locked-down CI). + try { + execFileSync( + process.execPath, + [ + '--no-experimental-require-module', + '-e', + `require(${JSON.stringify(bundlePath)})`, + ], + { + cwd: packageRoot, + stdio: 'pipe', + encoding: 'utf8', + }, + ) + } catch (err) { + const e = err as NodeJS.ErrnoException & { + stderr?: string + stdout?: string + } + throw new Error( + `require("${entry}") failed in a Node CJS process:\n` + + `${e.stderr ?? ''}\n${e.stdout ?? ''}`, + ) } - throw new Error( - `require("${entry}") failed in a Node CJS process:\n` + - `${e.stderr ?? ''}\n${e.stdout ?? ''}`, - ) - } - }) + }, + ) }) diff --git a/packages/stack/__tests__/diagnostics-entry.test.ts b/packages/stack/__tests__/diagnostics-entry.test.ts index b9d5886c7..22089701f 100644 --- a/packages/stack/__tests__/diagnostics-entry.test.ts +++ b/packages/stack/__tests__/diagnostics-entry.test.ts @@ -256,22 +256,25 @@ describe('@cipherstash/stack/diagnostics', () => { it.each([ ['require', false], ['import', true], - ])('probes through an export the published protect-ffi has, so %s survives it', (_label, esm) => { - const call = `let raised = 'none' + ])( + 'probes through an export the published protect-ffi has, so %s survives it', + (_label, esm) => { + const call = `let raised = 'none' try { d.assertNativeBindingAvailable() } catch (e) { raised = e.message } process.stdout.write('imported ' + raised)` - const script = esm - ? `const d = await import('@cipherstash/stack/diagnostics')\n${call}` - : `const d = require('@cipherstash/stack/diagnostics')\n${call}` + const script = esm + ? `const d = await import('@cipherstash/stack/diagnostics')\n${call}` + : `const d = require('@cipherstash/stack/diagnostics')\n${call}` - const output = runNode(script, { esm, publishedFfi: true }) + const output = runNode(script, { esm, publishedFfi: true }) - // Reaching stdout at all means the entry loaded against that surface. - expect(output).toMatch(/^imported /) - // And that the probe ran, rather than resolving to `undefined` and - // throwing a TypeError the CLI would report as an unknown failure. - expect(output).toContain(PUBLISHED_SURFACE_MARKER) - }) + // Reaching stdout at all means the entry loaded against that surface. + expect(output).toMatch(/^imported /) + // And that the probe ran, rather than resolving to `undefined` and + // throwing a TypeError the CLI would report as an unknown failure. + expect(output).toContain(PUBLISHED_SURFACE_MARKER) + }, + ) it('is importable from both module systems', () => { expect( @@ -298,28 +301,31 @@ describe('@cipherstash/stack/diagnostics', () => { it.each([ ['require', false], ['import', true], - ])('with no binding installed, %s succeeds and only the call throws', (_label, esm) => { - const script = esm - ? `const d = await import('@cipherstash/stack/diagnostics') + ])( + 'with no binding installed, %s succeeds and only the call throws', + (_label, esm) => { + const script = esm + ? `const d = await import('@cipherstash/stack/diagnostics') let raised = 'none' try { d.assertNativeBindingAvailable() } catch (e) { raised = e.code + ' ' + e.message.split('\\n')[0] } process.stdout.write('imported ' + raised)` - : `const d = require('@cipherstash/stack/diagnostics') + : `const d = require('@cipherstash/stack/diagnostics') let raised = 'none' try { d.assertNativeBindingAvailable() } catch (e) { raised = e.code + ' ' + e.message.split('\\n')[0] } process.stdout.write('imported ' + raised)` - const output = runNode(script, { esm, hideBinding: true }) + const output = runNode(script, { esm, hideBinding: true }) - // "imported" at all means the import did not throw — the property under - // test. A child that died during import produces no stdout and fails the - // execFileSync above. - expect(output).toMatch(/^imported /) - // Unwrapped: same `code` and the platform package by name, which is what - // `packages/cli/src/native.ts` classifies on. - expect(output).toContain('MODULE_NOT_FOUND') - expect(output).toMatch( - /Cannot find module '@cipherstash\/protect-ffi-(darwin|linux|win32)-/, - ) - }) + // "imported" at all means the import did not throw — the property under + // test. A child that died during import produces no stdout and fails the + // execFileSync above. + expect(output).toMatch(/^imported /) + // Unwrapped: same `code` and the platform package by name, which is what + // `packages/cli/src/native.ts` classifies on. + expect(output).toContain('MODULE_NOT_FOUND') + expect(output).toMatch( + /Cannot find module '@cipherstash\/protect-ffi-(darwin|linux|win32)-/, + ) + }, + ) }) diff --git a/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts b/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts index 0d4f0bd6b..73e5e5a52 100644 --- a/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts +++ b/packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts @@ -326,21 +326,22 @@ describe('a legacy DynamoDB read reconstructs every plaintext axis', () => { } }) - it.each( - matrixCases, - )('%s: rebuilds a v2 envelope and reconstructs the plaintext', (label, slug, castAs, sample, row) => { - // THE guard for this file. Rebuilt as v2 — `k: 'ct'` and `v: 2` — around - // the CURRENT v3 descriptor's identifier, carrying the stored ciphertext - // untouched. Drop the version branch and this goes red per domain. - expect(handedToFfi[row]?.[slug]).toEqual({ - i: { c: slug, t: matrix.tableName }, - v: 2, - k: 'ct', - c: ciphertext({ slug, row }), - }) + it.each(matrixCases)( + '%s: rebuilds a v2 envelope and reconstructs the plaintext', + (label, slug, castAs, sample, row) => { + // THE guard for this file. Rebuilt as v2 — `k: 'ct'` and `v: 2` — around + // the CURRENT v3 descriptor's identifier, carrying the stored ciphertext + // untouched. Drop the version branch and this goes red per domain. + expect(handedToFfi[row]?.[slug]).toEqual({ + i: { c: slug, t: matrix.tableName }, + v: 2, + k: 'ct', + c: ciphertext({ slug, row }), + }) - expectReconstructed(decrypted[row]?.[slug], castAs, sample, label) - }) + expectReconstructed(decrypted[row]?.[slug], castAs, sample, label) + }, + ) /** * `decryptModel` and `bulkDecryptModels` are separate wrappers over the same diff --git a/packages/stack/__tests__/encrypt-lock-context-guards.test.ts b/packages/stack/__tests__/encrypt-lock-context-guards.test.ts index ec8dd92df..0df450196 100644 --- a/packages/stack/__tests__/encrypt-lock-context-guards.test.ts +++ b/packages/stack/__tests__/encrypt-lock-context-guards.test.ts @@ -80,50 +80,57 @@ const clientFor = (variant: 'v2' | 'v3'): EncryptionClient => describe.each([ ['v2', { column: users.score, table: users }], ['v3', { column: usersV3.score, table: usersV3 }], -] as const)('encrypt with lock context rejects non-finite numbers (%s domain)', (variant, target) => { - it('rejects NaN and never reaches the FFI', async () => { - const result = await clientFor(variant) - .encrypt(Number.NaN, target) - .withLockContext(new LockContext()) - - expect(failure(result)).toBeDefined() - expect(failure(result)?.message).toContain('Cannot encrypt NaN value') - expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled() - }) - - it('rejects +Infinity and never reaches the FFI', async () => { - const result = await clientFor(variant) - .encrypt(Number.POSITIVE_INFINITY, target) - .withLockContext(new LockContext()) - - expect(failure(result)).toBeDefined() - expect(failure(result)?.message).toContain('Cannot encrypt Infinity value') - expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled() - }) - - it('rejects -Infinity and never reaches the FFI', async () => { - const result = await clientFor(variant) - .encrypt(Number.NEGATIVE_INFINITY, target) - .withLockContext(new LockContext()) - - expect(failure(result)).toBeDefined() - expect(failure(result)?.message).toContain('Cannot encrypt Infinity value') - expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled() - }) - - it('accepts a finite number and forwards it to the FFI', async () => { - // Positive control: proves the guards above reject *because* of the value, - // not because the lock-context path is broken for all numbers. - const result = await clientFor(variant) - .encrypt(42, target) - .withLockContext(new LockContext()) - - expect(failure(result)).toBeUndefined() - expect(vi.mocked(ffi.encrypt)).toHaveBeenCalledTimes(1) - const opts = vi.mocked(ffi.encrypt).mock.calls[0][1] - expect(opts.plaintext).toBe(42) - }) -}) +] as const)( + 'encrypt with lock context rejects non-finite numbers (%s domain)', + (variant, target) => { + it('rejects NaN and never reaches the FFI', async () => { + const result = await clientFor(variant) + .encrypt(Number.NaN, target) + .withLockContext(new LockContext()) + + expect(failure(result)).toBeDefined() + expect(failure(result)?.message).toContain('Cannot encrypt NaN value') + expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled() + }) + + it('rejects +Infinity and never reaches the FFI', async () => { + const result = await clientFor(variant) + .encrypt(Number.POSITIVE_INFINITY, target) + .withLockContext(new LockContext()) + + expect(failure(result)).toBeDefined() + expect(failure(result)?.message).toContain( + 'Cannot encrypt Infinity value', + ) + expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled() + }) + + it('rejects -Infinity and never reaches the FFI', async () => { + const result = await clientFor(variant) + .encrypt(Number.NEGATIVE_INFINITY, target) + .withLockContext(new LockContext()) + + expect(failure(result)).toBeDefined() + expect(failure(result)?.message).toContain( + 'Cannot encrypt Infinity value', + ) + expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled() + }) + + it('accepts a finite number and forwards it to the FFI', async () => { + // Positive control: proves the guards above reject *because* of the value, + // not because the lock-context path is broken for all numbers. + const result = await clientFor(variant) + .encrypt(42, target) + .withLockContext(new LockContext()) + + expect(failure(result)).toBeUndefined() + expect(vi.mocked(ffi.encrypt)).toHaveBeenCalledTimes(1) + const opts = vi.mocked(ffi.encrypt).mock.calls[0][1] + expect(opts.plaintext).toBe(42) + }) + }, +) // The `bigint` analog of the NaN/±Infinity guard above. `bigint` domains map to // Postgres `int8`, so a plaintext outside the signed 64-bit range cannot be @@ -144,26 +151,32 @@ describe('encrypt rejects out-of-range bigint (i64 bounds)', () => { ['i64::MIN', INT64_MIN], ['zero', 0n], ['a negative', -42n], - ] as const)('accepts in-range bigint %s and forwards it to the FFI', async (_label, value) => { - const result = await clientV3.encrypt(value, target) + ] as const)( + 'accepts in-range bigint %s and forwards it to the FFI', + async (_label, value) => { + const result = await clientV3.encrypt(value, target) - expect(failure(result)).toBeUndefined() - expect(vi.mocked(ffi.encrypt)).toHaveBeenCalledTimes(1) - expect(vi.mocked(ffi.encrypt).mock.calls[0][1].plaintext).toBe(value) - }) + expect(failure(result)).toBeUndefined() + expect(vi.mocked(ffi.encrypt)).toHaveBeenCalledTimes(1) + expect(vi.mocked(ffi.encrypt).mock.calls[0][1].plaintext).toBe(value) + }, + ) it.each([ ['2^63 (i64::MAX + 1)', 9223372036854775808n], ['-(2^63) - 1 (i64::MIN - 1)', -9223372036854775809n], - ] as const)('rejects out-of-range bigint %s and never reaches the FFI', async (_label, value) => { - const result = await clientV3.encrypt(value, target) - - expect(failure(result)).toBeDefined() - expect(failure(result)?.message).toContain( - 'Cannot encrypt bigint value out of int64 range', - ) - expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled() - }) + ] as const)( + 'rejects out-of-range bigint %s and never reaches the FFI', + async (_label, value) => { + const result = await clientV3.encrypt(value, target) + + expect(failure(result)).toBeDefined() + expect(failure(result)?.message).toContain( + 'Cannot encrypt bigint value out of int64 range', + ) + expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled() + }, + ) it('rejects out-of-range bigint on the lock-context arm too', async () => { const result = await clientV3 diff --git a/packages/stack/__tests__/encrypt-query-searchable-json.test.ts b/packages/stack/__tests__/encrypt-query-searchable-json.test.ts index d65e791ed..51074cd53 100644 --- a/packages/stack/__tests__/encrypt-query-searchable-json.test.ts +++ b/packages/stack/__tests__/encrypt-query-searchable-json.test.ts @@ -48,26 +48,31 @@ describe('encryptQuery with searchableJson', () => { { role: 'admin' }, { user: { profile: { role: 'admin' } } }, ['admin', 'user'], - ])('uses default structural containment for %j', async (value) => { - const result = await client.encryptQuery(value, { - column: documents.metadata, - table: documents, - queryType: 'searchableJson', - }) - expectContainment(unwrapResult(result)) - }, 30000) + ])( + 'uses default structural containment for %j', + async (value) => { + const result = await client.encryptQuery(value, { + column: documents.metadata, + table: documents, + queryType: 'searchableJson', + }) + expectContainment(unwrapResult(result)) + }, + 30000, + ) - it.each([ - 42, - true, - ])('rejects a top-level JSON scalar containment needle (%j)', async (value) => { - const result = await client.encryptQuery(value, { - column: documents.metadata, - table: documents, - queryType: 'searchableJson', - }) - expectFailure(result) - }, 30000) + it.each([42, true])( + 'rejects a top-level JSON scalar containment needle (%j)', + async (value) => { + const result = await client.encryptQuery(value, { + column: documents.metadata, + table: documents, + queryType: 'searchableJson', + }) + expectFailure(result) + }, + 30000, + ) it('infers the same operation when queryType is omitted', async () => { const explicit = await client.encryptQuery( @@ -108,21 +113,22 @@ describe('encryptQuery with searchableJson', () => { expectContainment(data[1]) }, 30000) - it.each([ - 'composite-literal', - 'escaped-composite-literal', - ] as const)('supports %s formatting for containment needles', async (returnType) => { - const result = await client.encryptQuery( - { role: 'admin' }, - { - column: documents.metadata, - table: documents, - queryType: 'searchableJson', - returnType, - }, - ) - expect(typeof unwrapResult(result)).toBe('string') - }, 30000) + it.each(['composite-literal', 'escaped-composite-literal'] as const)( + 'supports %s formatting for containment needles', + async (returnType) => { + const result = await client.encryptQuery( + { role: 'admin' }, + { + column: documents.metadata, + table: documents, + queryType: 'searchableJson', + returnType, + }, + ) + expect(typeof unwrapResult(result)).toBe('string') + }, + 30000, + ) it('supports lock-context chaining for JSON query terms', async () => { const operation = client.encryptQuery( diff --git a/packages/stack/__tests__/encrypt-query-stevec.test.ts b/packages/stack/__tests__/encrypt-query-stevec.test.ts index 894592878..7695ddc5c 100644 --- a/packages/stack/__tests__/encrypt-query-stevec.test.ts +++ b/packages/stack/__tests__/encrypt-query-stevec.test.ts @@ -63,29 +63,31 @@ describe('encryptQuery with protect-ffi 0.30 SteVec operations', () => { expectQueryJson(unwrapResult(result)) }, 30000) - it.each([ - 'zoe', - 42, - ])('returns a ciphertext-free selector ordering term for %j', async (value) => { - const result = await client.encryptQuery(value, { - column: documents.metadata, - table: documents, - queryType: 'steVecTerm', - }) - expectOrderingTerm(unwrapResult(result)) - }, 30000) + it.each(['zoe', 42])( + 'returns a ciphertext-free selector ordering term for %j', + async (value) => { + const result = await client.encryptQuery(value, { + column: documents.metadata, + table: documents, + queryType: 'steVecTerm', + }) + expectOrderingTerm(unwrapResult(result)) + }, + 30000, + ) - it.each([ - { role: 'admin' }, - ['admin', 'user'], - ])('uses default structural containment for %j', async (value) => { - const result = await client.encryptQuery(value, { - column: documents.metadata, - table: documents, - queryType: 'searchableJson', - }) - expectQueryJson(unwrapResult(result)) - }, 30000) + it.each([{ role: 'admin' }, ['admin', 'user']])( + 'uses default structural containment for %j', + async (value) => { + const result = await client.encryptQuery(value, { + column: documents.metadata, + table: documents, + queryType: 'searchableJson', + }) + expectQueryJson(unwrapResult(result)) + }, + 30000, + ) it('infers selector versus containment when queryType is omitted', async () => { const selector = await client.encryptQuery('$.user.email', { diff --git a/packages/stack/__tests__/match-bloom-live.test.ts b/packages/stack/__tests__/match-bloom-live.test.ts index e139b0e94..79369c5de 100644 --- a/packages/stack/__tests__/match-bloom-live.test.ts +++ b/packages/stack/__tests__/match-bloom-live.test.ts @@ -89,19 +89,19 @@ describe('protect-ffi match bloom', () => { // config and ignored: the bloom is the tokenizer's trigrams, nothing more. // If a future ffi starts honouring the flag, THIS fails — and the v3 domains // already emit `false` (see `eql/v3/columns.ts`), so `contains` keeps working. - it.each([ - 'Ada Lovelace', - 'ada@example.com', - ])('emits the same bloom for %j whether include_original is on or off', async (plaintext) => { - const on = await bloomOf(withOriginal, plaintext) - const off = await bloomOf(withoutOriginal, plaintext) - - expect(sorted(on)).toEqual(sorted(off)) - // A trigram-only bloom, not one carrying an extra whole-value token: 6 - // bits (k) per DISTINCT trigram, so never more than 6 × the trigram count. - expect(on.length).toBeLessThanOrEqual(6 * (plaintext.length - 2)) - expect(on.length).toBeGreaterThan(0) - }) + it.each(['Ada Lovelace', 'ada@example.com'])( + 'emits the same bloom for %j whether include_original is on or off', + async (plaintext) => { + const on = await bloomOf(withOriginal, plaintext) + const off = await bloomOf(withoutOriginal, plaintext) + + expect(sorted(on)).toEqual(sorted(off)) + // A trigram-only bloom, not one carrying an extra whole-value token: 6 + // bits (k) per DISTINCT trigram, so never more than 6 × the trigram count. + expect(on.length).toBeLessThanOrEqual(6 * (plaintext.length - 2)) + expect(on.length).toBeGreaterThan(0) + }, + ) // The corollary, and the reason `matchNeedleError` must reject short needles // instead of trusting `include_original` to make them matchable: below @@ -110,11 +110,14 @@ describe('protect-ffi match bloom', () => { it.each([ ['with include_original', true], ['without include_original', false], - ] as const)('blooms a sub-trigram value to nothing, %s', async (_label, on) => { - const bloom = await bloomOf(on ? withOriginal : withoutOriginal, 'ad') + ] as const)( + 'blooms a sub-trigram value to nothing, %s', + async (_label, on) => { + const bloom = await bloomOf(on ? withOriginal : withoutOriginal, 'ad') - expect(bloom).toEqual([]) - }) + expect(bloom).toEqual([]) + }, + ) // protect-ffi 0.30 closes the core fail-open path: query encryption itself // rejects empty-token needles, so callers that bypass every ORM guard still @@ -122,11 +125,14 @@ describe('protect-ffi match bloom', () => { it.each([ [1, 'a'], [2, 'ad'], - ] as const)('rejects the %i-character query needle in the core FFI', async (_length, needle) => { - await expect(queryBloomOf(withOriginal, needle)).rejects.toThrow( - /short|token|3|ngram/i, - ) - }) + ] as const)( + 'rejects the %i-character query needle in the core FFI', + async (_length, needle) => { + await expect(queryBloomOf(withOriginal, needle)).rejects.toThrow( + /short|token|3|ngram/i, + ) + }, + ) // The query needle's bloom is a subset of the STORED value's bloom — which is // exactly what `eql_v3.matches` (`match_term(stored) @> query_term(needle)`) diff --git a/packages/stack/__tests__/schema-v3.test.ts b/packages/stack/__tests__/schema-v3.test.ts index 56f66f5e8..3c4d46192 100644 --- a/packages/stack/__tests__/schema-v3.test.ts +++ b/packages/stack/__tests__/schema-v3.test.ts @@ -185,13 +185,16 @@ describe('eql_v3 encryptedTable', () => { 'toString', 'valueOf', 'hasOwnProperty', - ])('throws when a column name (%s) collides with a reserved property', (reserved) => { - expect(() => - encryptedTable('users', { - [reserved]: types.TextSearch(reserved), - }), - ).toThrow(/reserved EncryptedTable property/) - }) + ])( + 'throws when a column name (%s) collides with a reserved property', + (reserved) => { + expect(() => + encryptedTable('users', { + [reserved]: types.TextSearch(reserved), + }), + ).toThrow(/reserved EncryptedTable property/) + }, + ) it('build() assembles { tableName, columns } with built column configs', () => { const users = encryptedTable('users', { @@ -364,28 +367,34 @@ describe('eql_v3 catalog-driven query capability sweep', () => { (queryType) => [eqlType, spec, queryType] as const, ), ), - )('%s + queryType=%s: gating matches configured indexes', (_eqlType, spec, queryType) => { - const col = spec.builder('value') - if (queryTypeAllowed(spec.indexes, queryType)) { - expect(() => resolveIndexType(col as never, queryType)).not.toThrow() - } else { - // Broad message match: for a blocked equality the resolver reports the - // missing `unique`; for orderAndRange/freeTextSearch the missing ore/match. - expect(() => resolveIndexType(col as never, queryType)).toThrow( - /not configured/, - ) - } - }) + )( + '%s + queryType=%s: gating matches configured indexes', + (_eqlType, spec, queryType) => { + const col = spec.builder('value') + if (queryTypeAllowed(spec.indexes, queryType)) { + expect(() => resolveIndexType(col as never, queryType)).not.toThrow() + } else { + // Broad message match: for a blocked equality the resolver reports the + // missing `unique`; for orderAndRange/freeTextSearch the missing ore/match. + expect(() => resolveIndexType(col as never, queryType)).toThrow( + /not configured/, + ) + } + }, + ) it.each( typedEntries(V3_MATRIX).filter( ([, spec]) => Object.keys(spec.indexes ?? {}).length === 0, ), - )('%s: querying a storage-only column with no queryType throws', (_eqlType, spec) => { - expect(() => resolveIndexType(spec.builder('value') as never)).toThrow( - /no indexes configured/, - ) - }) + )( + '%s: querying a storage-only column with no queryType throws', + (_eqlType, spec) => { + expect(() => resolveIndexType(spec.builder('value') as never)).toThrow( + /no indexes configured/, + ) + }, + ) // Spot-check the exact messages for a queryable-but-misused column, so the // broad regex above doesn't let a message regression slip through. @@ -415,11 +424,14 @@ describe('eql_v3 equality via the ordering index on order-capable columns (regre ['date_ord', types.DateOrd, 'ope'], ['numeric_ord', types.NumericOrd, 'ope'], ['IntegerOrdOre', types.IntegerOrdOre, 'ore'], - ] as const)('%s resolves equality to its ordering index (%s)', (_name, builder, ordering) => { - expect(resolveIndexType(builder('value'), 'equality')).toEqual({ - indexType: ordering, - }) - }) + ] as const)( + '%s resolves equality to its ordering index (%s)', + (_name, builder, ordering) => { + expect(resolveIndexType(builder('value'), 'equality')).toEqual({ + indexType: ordering, + }) + }, + ) it('preserves v2: an orderAndRange-only column still throws on equality (no-v2-change)', () => { // v2 EncryptedColumn has no getQueryCapabilities, so the equality-via-ORE @@ -445,29 +457,38 @@ describe('eql_v3 text order domains carry the hm (unique) index (regression)', ( { unique: { token_filters: [] }, ore: {} }, ], ['text_ord', types.TextOrd, { unique: { token_filters: [] }, ope: {} }], - ] as const)('%s emits both unique (hm) and its ordering index', (_name, builder, expected) => { - expect(builder('c').build().indexes).toStrictEqual(expected) - }) + ] as const)( + '%s emits both unique (hm) and its ordering index', + (_name, builder, expected) => { + expect(builder('c').build().indexes).toStrictEqual(expected) + }, + ) it.each([ ['IntegerOrdOre', types.IntegerOrdOre, { ore: {} }], ['IntegerOrd', types.IntegerOrd, { ope: {} }], ['date_ord_ore', types.DateOrdOre, { ore: {} }], ['numeric_ord', types.NumericOrd, { ope: {} }], - ] as const)('%s (numeric/date order) emits its ordering index only — no unique', (_name, builder, expected) => { - expect(builder('c').build().indexes).toStrictEqual(expected) - }) + ] as const)( + '%s (numeric/date order) emits its ordering index only — no unique', + (_name, builder, expected) => { + expect(builder('c').build().indexes).toStrictEqual(expected) + }, + ) // With `unique` present, text order equality resolves to the hm index (not // ORE): `resolvesEqualityViaOre` only fires when `unique` is ABSENT. it.each([ ['text_ord_ore', types.TextOrdOre], ['text_ord', types.TextOrd], - ] as const)('%s resolves equality to the unique (hm) index', (_name, builder) => { - expect(resolveIndexType(builder('value'), 'equality')).toEqual({ - indexType: 'unique', - }) - }) + ] as const)( + '%s resolves equality to the unique (hm) index', + (_name, builder) => { + expect(resolveIndexType(builder('value'), 'equality')).toEqual({ + indexType: 'unique', + }) + }, + ) }) describe('eql_v3 timestamp domains emit cast_as "timestamp" (time-of-day preserved)', () => { diff --git a/packages/stack/__tests__/selector-path.test.ts b/packages/stack/__tests__/selector-path.test.ts index b32a9fc8f..8c9ed808d 100644 --- a/packages/stack/__tests__/selector-path.test.ts +++ b/packages/stack/__tests__/selector-path.test.ts @@ -2,20 +2,19 @@ import { describe, expect, it } from 'vitest' import { unsupportedLeafReason } from '@/eql/v3/selector-path' describe('unsupportedLeafReason', () => { - it.each([ - 'value', - 42, - true, - ])('accepts the JSON scalar %j for equality', (value) => { - expect(unsupportedLeafReason(value, false)).toBeNull() - }) + it.each(['value', 42, true])( + 'accepts the JSON scalar %j for equality', + (value) => { + expect(unsupportedLeafReason(value, false)).toBeNull() + }, + ) - it.each([ - 'value', - 42, - ])('accepts the orderable JSON scalar %j for ordering', (value) => { - expect(unsupportedLeafReason(value, true)).toBeNull() - }) + it.each(['value', 42])( + 'accepts the orderable JSON scalar %j for ordering', + (value) => { + expect(unsupportedLeafReason(value, true)).toBeNull() + }, + ) it.each([ ['null', null, /non-null scalar leaf/], diff --git a/packages/stack/__tests__/v3-matrix/matrix.test.ts b/packages/stack/__tests__/v3-matrix/matrix.test.ts index 8b985fa23..c0c1a30b1 100644 --- a/packages/stack/__tests__/v3-matrix/matrix.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix.test.ts @@ -13,34 +13,38 @@ import { typedEntries, V3_MATRIX } from './catalog' describe('eql_v3 type-driven domain matrix (runtime)', () => { // `typedEntries` keeps `eqlType` as `EqlV3TypeName` rather than widening to // `string`, so the key stays precisely typed through the callback. - it.each( - typedEntries(V3_MATRIX), - )('%s: builder, eqlType, capabilities and build() are consistent', (eqlType, spec) => { - const col = spec.builder('value') + it.each(typedEntries(V3_MATRIX))( + '%s: builder, eqlType, capabilities and build() are consistent', + (eqlType, spec) => { + const col = spec.builder('value') - expect(col).toBeInstanceOf(spec.ColumnClass) - expect(col.getName()).toBe('value') - expect(col.getEqlType()).toBe(eqlType) - expect(col.getQueryCapabilities()).toStrictEqual(spec.capabilities) - expect(col.isQueryable()).toBe( - Object.values(spec.capabilities).some(Boolean), - ) + expect(col).toBeInstanceOf(spec.ColumnClass) + expect(col.getName()).toBe('value') + expect(col.getEqlType()).toBe(eqlType) + expect(col.getQueryCapabilities()).toStrictEqual(spec.capabilities) + expect(col.isQueryable()).toBe( + Object.values(spec.capabilities).some(Boolean), + ) - // Full-fidelity `build()` check: exactly `{ cast_as, indexes }`, no extra - // keys — so SDK-facing metadata (eqlType/capabilities) can never leak. - expect(col.build()).toStrictEqual({ - cast_as: spec.castAs, - indexes: spec.indexes, - }) - }) + // Full-fidelity `build()` check: exactly `{ cast_as, indexes }`, no extra + // keys — so SDK-facing metadata (eqlType/capabilities) can never leak. + expect(col.build()).toStrictEqual({ + cast_as: spec.castAs, + indexes: spec.indexes, + }) + }, + ) it.each([ 'public.eql_v3_text_ord_ore', 'public.eql_v3_text_ord', 'public.eql_v3_text_search', - ] as const)('%s uses non-empty positive samples for live Postgres inserts', (eqlType) => { - expect(V3_MATRIX[eqlType].samples[0]).not.toBe('') - }) + ] as const)( + '%s uses non-empty positive samples for live Postgres inserts', + (eqlType) => { + expect(V3_MATRIX[eqlType].samples[0]).not.toBe('') + }, + ) // `timestamp` domains set `cast_as: 'timestamp'` (not `'date'`) precisely to // preserve the time-of-day. The live matrix can only PROVE that preservation diff --git a/packages/stack/__tests__/wasm-inline-result-contract.test.ts b/packages/stack/__tests__/wasm-inline-result-contract.test.ts index 34d24b594..23bd4429c 100644 --- a/packages/stack/__tests__/wasm-inline-result-contract.test.ts +++ b/packages/stack/__tests__/wasm-inline-result-contract.test.ts @@ -92,33 +92,36 @@ describe('wasm-inline Result contract — failure path', () => { ['bulkEncrypt', 'encryptBulk', 'EncryptionError'], ['decrypt', 'decrypt', 'DecryptionError'], ['bulkDecrypt', 'decryptBulkFallible', 'DecryptionError'], - ])('%s surfaces a %s FFI throw as { failure } typed %s', async (method, ffiName, expectedType) => { - ;(ffi as Record>)[ - ffiName - ].mockRejectedValueOnce(new Error('ffi exploded')) - - const c = await client() - const opts = { table: users, column: users.email } - const call: Record Promise> = { - encrypt: () => c.encrypt('a@b.com', opts), - encryptQuery: () => c.encryptQuery('a@b.com', opts), - bulkEncrypt: () => c.bulkEncrypt([{ plaintext: 'a@b.com', ...opts }]), - decrypt: () => c.decrypt(ct()), - bulkDecrypt: () => c.bulkDecrypt([ct()]), - } - - // Resolves — it must NOT reject. That is the regression this guards. - const result = (await call[method]()) as { - data?: unknown - failure?: { type: string; message: string } - } - - expect(result.data).toBeUndefined() - expect(result.failure).toMatchObject({ - type: expectedType, - message: 'ffi exploded', - }) - }) + ])( + '%s surfaces a %s FFI throw as { failure } typed %s', + async (method, ffiName, expectedType) => { + ;(ffi as Record>)[ + ffiName + ].mockRejectedValueOnce(new Error('ffi exploded')) + + const c = await client() + const opts = { table: users, column: users.email } + const call: Record Promise> = { + encrypt: () => c.encrypt('a@b.com', opts), + encryptQuery: () => c.encryptQuery('a@b.com', opts), + bulkEncrypt: () => c.bulkEncrypt([{ plaintext: 'a@b.com', ...opts }]), + decrypt: () => c.decrypt(ct()), + bulkDecrypt: () => c.bulkDecrypt([ct()]), + } + + // Resolves — it must NOT reject. That is the regression this guards. + const result = (await call[method]()) as { + data?: unknown + failure?: { type: string; message: string } + } + + expect(result.data).toBeUndefined() + expect(result.failure).toMatchObject({ + type: expectedType, + message: 'ffi exploded', + }) + }, + ) // Non-Error rejections must keep their detail. `withResult`'s default // `ensureError` would replace them with `new Error('Something went wrong')`, @@ -137,19 +140,22 @@ describe('wasm-inline Result contract — failure path', () => { { code: 'EQL_X', detail: 'bad domain' }, '{"code":"EQL_X","detail":"bad domain"}', ], - ])('preserves the detail of %s rejection', async (_label, thrown, expected) => { - ffi.encrypt.mockRejectedValueOnce(thrown) - - const c = await client() - const result = await c.encrypt('a@b.com', { - table: users, - column: users.email, - }) - - expect(result.failure?.type).toBe('EncryptionError') - expect(result.failure?.message).toBe(expected) - expect(result.failure?.message).not.toBe('Something went wrong') - }) + ])( + 'preserves the detail of %s rejection', + async (_label, thrown, expected) => { + ffi.encrypt.mockRejectedValueOnce(thrown) + + const c = await client() + const result = await c.encrypt('a@b.com', { + table: users, + column: users.email, + }) + + expect(result.failure?.type).toBe('EncryptionError') + expect(result.failure?.message).toBe(expected) + expect(result.failure?.message).not.toBe('Something went wrong') + }, + ) it('falls back to String() for a value JSON cannot serialize', async () => { // A cycle makes JSON.stringify throw; the catch must still yield a diff --git a/packages/stack/__tests__/wasm-inline-v3.test.ts b/packages/stack/__tests__/wasm-inline-v3.test.ts index 4e63b561f..67410fe82 100644 --- a/packages/stack/__tests__/wasm-inline-v3.test.ts +++ b/packages/stack/__tests__/wasm-inline-v3.test.ts @@ -127,18 +127,19 @@ describe('wasm-inline is EQL v3 only (#614)', () => { // had no equivalent, so a JS/JSON caller carrying `eqlVersion: 2` got a hard // error on one entry and silence on the other — the exact entry-disagreement // #815 exists to close. - it.each([ - 2, 3, - ])('rejects config.eqlVersion (%i) the way the native entry does', async (eqlVersion) => { - await expect( - Encryption({ - schemas: [users], - // A JS caller can carry this key even though the type omits it. - config: { ...config, eqlVersion } as unknown as typeof config, - }), - ).rejects.toThrow(/`config\.eqlVersion` has been removed/) - expect(vi.mocked(wasmNewClient)).not.toHaveBeenCalled() - }) + it.each([2, 3])( + 'rejects config.eqlVersion (%i) the way the native entry does', + async (eqlVersion) => { + await expect( + Encryption({ + schemas: [users], + // A JS caller can carry this key even though the type omits it. + config: { ...config, eqlVersion } as unknown as typeof config, + }), + ).rejects.toThrow(/`config\.eqlVersion` has been removed/) + expect(vi.mocked(wasmNewClient)).not.toHaveBeenCalled() + }, + ) // `eqlVersion?: never` accepts `eqlVersion: undefined` without // `exactOptionalPropertyTypes` (not enabled in this repo) and cannot be made to diff --git a/packages/stack/integration/shared/match-bloom.integration.test.ts b/packages/stack/integration/shared/match-bloom.integration.test.ts index b9c28eb7e..1efc0a6d6 100644 --- a/packages/stack/integration/shared/match-bloom.integration.test.ts +++ b/packages/stack/integration/shared/match-bloom.integration.test.ts @@ -64,21 +64,23 @@ it.each([ { label: 'interior substring, 7 chars', needle: 'a@examp' }, { label: 'trailing substring, 4 chars', needle: '.com' }, { label: 'the whole value', needle: HAYSTACK }, -])('bloom(needle) is a subset of bloom(haystack) for a $label', async ({ - needle, -}) => { - const client = await Encryption({ schemas: [docs] }) - const haystack = await bloomOf(client, HAYSTACK) - const probe = await bloomOf(client, needle) +])( + 'bloom(needle) is a subset of bloom(haystack) for a $label', + async ({ needle }) => { + const client = await Encryption({ schemas: [docs] }) + const haystack = await bloomOf(client, HAYSTACK) + const probe = await bloomOf(client, needle) - expect(HAYSTACK.includes(needle)).toBe(true) - expect( - isSubset(probe, haystack), - `bloom("${needle}") is not contained in bloom("${HAYSTACK}"). ` + - 'The operand carries a token the haystack lacks — most likely the ' + - '`include_original` whole-value token. See the file header.', - ).toBe(true) -}, 120_000) + expect(HAYSTACK.includes(needle)).toBe(true) + expect( + isSubset(probe, haystack), + `bloom("${needle}") is not contained in bloom("${HAYSTACK}"). ` + + 'The operand carries a token the haystack lacks — most likely the ' + + '`include_original` whole-value token. See the file header.', + ).toBe(true) + }, + 120_000, +) it('a needle absent from the haystack is NOT a bloom subset', async () => { // Without this, an implementation that blooms every needle to the empty set diff --git a/packages/stack/integration/shared/matrix-crypto.integration.test.ts b/packages/stack/integration/shared/matrix-crypto.integration.test.ts index 7755d6aa6..21236b1d5 100644 --- a/packages/stack/integration/shared/matrix-crypto.integration.test.ts +++ b/packages/stack/integration/shared/matrix-crypto.integration.test.ts @@ -89,29 +89,30 @@ describe('v3 matrix live round-trip (all domains × samples)', () => { ) as Array> }, 60000) - it.each( - roundTripCases, - )('%s round-trips through the model path', (_label, col, sample, i) => { - // Guard against a false pass: the field must be a real ciphertext, not a - // plaintext value that slipped through un-encrypted. Scalar domains carry it - // at a top-level `c`; a JSON (`eql_v3_json_search`) document is an ste_vec payload - // (`{ k: 'sv', sv: [...] }`) with NO top-level `c`, so it is guarded by the - // presence of its `sv` array instead. - const payload = encrypted[i][col] as { k?: unknown; sv?: unknown } - if (payload?.k === 'sv') { - expect(Array.isArray(payload.sv)).toBe(true) - } else { - expect(payload).toHaveProperty('c') - } + it.each(roundTripCases)( + '%s round-trips through the model path', + (_label, col, sample, i) => { + // Guard against a false pass: the field must be a real ciphertext, not a + // plaintext value that slipped through un-encrypted. Scalar domains carry it + // at a top-level `c`; a JSON (`eql_v3_json_search`) document is an ste_vec payload + // (`{ k: 'sv', sv: [...] }`) with NO top-level `c`, so it is guarded by the + // presence of its `sv` array instead. + const payload = encrypted[i][col] as { k?: unknown; sv?: unknown } + if (payload?.k === 'sv') { + expect(Array.isArray(payload.sv)).toBe(true) + } else { + expect(payload).toHaveProperty('c') + } - const actual = decrypted[i][col] - if (sample instanceof Date) { - expect(actual).toBeInstanceOf(Date) - expect(actual).toEqual(sample) - } else { - expect(actual).toStrictEqual(sample) - } - }) + const actual = decrypted[i][col] + if (sample instanceof Date) { + expect(actual).toBeInstanceOf(Date) + expect(actual).toEqual(sample) + } else { + expect(actual).toStrictEqual(sample) + } + }, + ) // Mirrors number-protect.test.ts: NaN/±Infinity (number domains) and // out-of-range i64 values (bigint domains) must be rejected. The guard diff --git a/packages/stack/integration/shared/matrix-sql.integration.test.ts b/packages/stack/integration/shared/matrix-sql.integration.test.ts index a4f49bb78..f95757b48 100644 --- a/packages/stack/integration/shared/matrix-sql.integration.test.ts +++ b/packages/stack/integration/shared/matrix-sql.integration.test.ts @@ -348,140 +348,146 @@ afterAll(async () => { }, 30000) describe('v3 matrix live Postgres coverage (all covered domains)', () => { - it.each( - eqDomains, - )('%s: eql_v3.eq(col, operand) selects the exact row', async (eqlType) => { - const col = slug(eqlType) - const rows = await sql.unsafe( - `SELECT id FROM ${TABLE_NAME} + it.each(eqDomains)( + '%s: eql_v3.eq(col, operand) selects the exact row', + async (eqlType) => { + const col = slug(eqlType) + const rows = await sql.unsafe( + `SELECT id FROM ${TABLE_NAME} WHERE test_run_id = $1 AND eql_v3.eq("${col}", $2::jsonb)`, - [TEST_RUN_ID, sql.json(eqTerms[col] as never)], - ) - expect(rows.map((r) => r.id)).toEqual([idA]) - }) - - it.each( - eqDomains, - )('%s: eql_v3.neq(col, operand) excludes the matching row', async (eqlType) => { - const col = slug(eqlType) - // Operand encrypts `samples[0]` (row idA). `neq` selects every row whose - // value differs, i.e. row idB (`samples[1]`) only — idA is excluded. - const rows = await sql.unsafe( - `SELECT id FROM ${TABLE_NAME} + [TEST_RUN_ID, sql.json(eqTerms[col] as never)], + ) + expect(rows.map((r) => r.id)).toEqual([idA]) + }, + ) + + it.each(eqDomains)( + '%s: eql_v3.neq(col, operand) excludes the matching row', + async (eqlType) => { + const col = slug(eqlType) + // Operand encrypts `samples[0]` (row idA). `neq` selects every row whose + // value differs, i.e. row idB (`samples[1]`) only — idA is excluded. + const rows = await sql.unsafe( + `SELECT id FROM ${TABLE_NAME} WHERE test_run_id = $1 AND eql_v3.neq("${col}", $2::jsonb)`, - [TEST_RUN_ID, sql.json(eqTerms[col] as never)], - ) - expect(rows.map((r) => r.id)).toEqual([idB]) - }) - - it.each( - ordDomains, - )('%s: equality-via-ORE selects the exact row', async (eqlType, spec) => { - const col = slug(eqlType) - // The proof must exercise the ORDER (`ob`) term, not equality. On text order - // domains (`text_ord`/`text_ord_ore`, which carry BOTH `unique` and `ore`) - // `eql_v3.eq` compares the `hm` term, so pin the ORE term with a degenerate - // range `col >= operand AND col <= operand` (both `ord_term`-based) — it - // selects the equal row via ORE. Pure-ORE numeric/date domains have no `hm`, - // so `eql_v3.eq` resolves to `ord_term`: that IS the equality-via-ORE proof. - const predicate = hasIndex(spec.indexes, 'unique') - ? `eql_v3.gte("${col}", $2::jsonb) AND eql_v3.lte("${col}", $2::jsonb)` - : `eql_v3.eq("${col}", $2::jsonb)` - const rows = await sql.unsafe( - `SELECT id FROM ${TABLE_NAME} + [TEST_RUN_ID, sql.json(eqTerms[col] as never)], + ) + expect(rows.map((r) => r.id)).toEqual([idB]) + }, + ) + + it.each(ordDomains)( + '%s: equality-via-ORE selects the exact row', + async (eqlType, spec) => { + const col = slug(eqlType) + // The proof must exercise the ORDER (`ob`) term, not equality. On text order + // domains (`text_ord`/`text_ord_ore`, which carry BOTH `unique` and `ore`) + // `eql_v3.eq` compares the `hm` term, so pin the ORE term with a degenerate + // range `col >= operand AND col <= operand` (both `ord_term`-based) — it + // selects the equal row via ORE. Pure-ORE numeric/date domains have no `hm`, + // so `eql_v3.eq` resolves to `ord_term`: that IS the equality-via-ORE proof. + const predicate = hasIndex(spec.indexes, 'unique') + ? `eql_v3.gte("${col}", $2::jsonb) AND eql_v3.lte("${col}", $2::jsonb)` + : `eql_v3.eq("${col}", $2::jsonb)` + const rows = await sql.unsafe( + `SELECT id FROM ${TABLE_NAME} WHERE test_run_id = $1 AND ${predicate}`, - [TEST_RUN_ID, sql.json(ordTerms[col] as never)], - ) - expect(rows.map((r) => r.id)).toEqual([idA]) - }) - - it.each( - textOreDomains, - )('%s: encrypted empty string is rejected by the Postgres domain', async (eqlType) => { - const col = slug(eqlType) - const column = (table as unknown as Record)[col] as never - const encrypted = unwrapResult( - // The matrix table is built from a dynamic column map, so `column` is - // already erased above and the plaintext cannot be derived from it. - await client.encrypt('' as never, { - table: table as never, - column, - }), - ) - - await expect( - sql.unsafe(`SELECT $1::${eqlType}`, [sql.json(encrypted as never)]), - ).rejects.toThrow(/violates check constraint/) - }) - - it.each( - textOpeDomains, - )('%s: encrypted empty string clears the Postgres domain (scalar op term)', async (eqlType) => { - const col = slug(eqlType) - const column = (table as unknown as Record)[col] as never - const encrypted = unwrapResult( - // The matrix table is built from a dynamic column map, so `column` is - // already erased above and the plaintext cannot be derived from it. - await client.encrypt('' as never, { - table: table as never, - column, - }), - ) - - const rows = await sql.unsafe(`SELECT $1::${eqlType} AS v`, [ - sql.json(encrypted as never), - ]) - expect(rows).toHaveLength(1) - }) - - it.each( - matchDomains, - )('%s: eql_v3.matches selects the row containing "ada"', async (eqlType, spec) => { - const col = slug(eqlType) - const expectedId = String(spec.samples[0]).includes('ada') ? idA : idB - const rows = await sql.unsafe( - `SELECT id FROM ${TABLE_NAME} + [TEST_RUN_ID, sql.json(ordTerms[col] as never)], + ) + expect(rows.map((r) => r.id)).toEqual([idA]) + }, + ) + + it.each(textOreDomains)( + '%s: encrypted empty string is rejected by the Postgres domain', + async (eqlType) => { + const col = slug(eqlType) + const column = (table as unknown as Record)[col] as never + const encrypted = unwrapResult( + // The matrix table is built from a dynamic column map, so `column` is + // already erased above and the plaintext cannot be derived from it. + await client.encrypt('' as never, { + table: table as never, + column, + }), + ) + + await expect( + sql.unsafe(`SELECT $1::${eqlType}`, [sql.json(encrypted as never)]), + ).rejects.toThrow(/violates check constraint/) + }, + ) + + it.each(textOpeDomains)( + '%s: encrypted empty string clears the Postgres domain (scalar op term)', + async (eqlType) => { + const col = slug(eqlType) + const column = (table as unknown as Record)[col] as never + const encrypted = unwrapResult( + // The matrix table is built from a dynamic column map, so `column` is + // already erased above and the plaintext cannot be derived from it. + await client.encrypt('' as never, { + table: table as never, + column, + }), + ) + + const rows = await sql.unsafe(`SELECT $1::${eqlType} AS v`, [ + sql.json(encrypted as never), + ]) + expect(rows).toHaveLength(1) + }, + ) + + it.each(matchDomains)( + '%s: eql_v3.matches selects the row containing "ada"', + async (eqlType, spec) => { + const col = slug(eqlType) + const expectedId = String(spec.samples[0]).includes('ada') ? idA : idB + const rows = await sql.unsafe( + `SELECT id FROM ${TABLE_NAME} WHERE test_run_id = $1 AND eql_v3.matches("${col}", $2::jsonb)`, - [TEST_RUN_ID, sql.json(matchTerms[col] as never)], - ) - expect(rows.map((r) => r.id)).toEqual([expectedId]) - }) - - it.each( - orderingDomains, - )('%s: ORE pairwise-lt reproduces plaintext order across all distinct samples', async (eqlType, spec) => { - // STRICT ordering proof: seed each of a domain's distinct sample values as - // its OWN row (orderIds[0..L-1] ↔ samples[0..L-1]) and prove the ORE - // comparison reproduces the FULL plaintext order (a orderIds[i]). - const expectedAscending = spec.samples - .map((value, i) => ({ value, id: ids[i] })) - .sort((x, y) => comparePlaintext(x.value, y.value)) - .map((entry) => entry.id) - - const pairs = await sql.unsafe< - Array<{ a: number; b: number; lt: boolean }> - >( - `SELECT x.id AS a, y.id AS b, eql_v3.lt(x."${col}", y."${col}") AS lt + [TEST_RUN_ID, sql.json(matchTerms[col] as never)], + ) + expect(rows.map((r) => r.id)).toEqual([expectedId]) + }, + ) + + it.each(orderingDomains)( + '%s: ORE pairwise-lt reproduces plaintext order across all distinct samples', + async (eqlType, spec) => { + // STRICT ordering proof: seed each of a domain's distinct sample values as + // its OWN row (orderIds[0..L-1] ↔ samples[0..L-1]) and prove the ORE + // comparison reproduces the FULL plaintext order (a orderIds[i]). + const expectedAscending = spec.samples + .map((value, i) => ({ value, id: ids[i] })) + .sort((x, y) => comparePlaintext(x.value, y.value)) + .map((entry) => entry.id) + + const pairs = await sql.unsafe< + Array<{ a: number; b: number; lt: boolean }> + >( + `SELECT x.id AS a, y.id AS b, eql_v3.lt(x."${col}", y."${col}") AS lt FROM ${TABLE_NAME} x CROSS JOIN ${TABLE_NAME} y WHERE x.test_run_id = $1 @@ -489,113 +495,116 @@ describe('v3 matrix live Postgres coverage (all covered domains)', () => { AND x.id = ANY($2) AND y.id = ANY($2) AND x.id <> y.id`, - [ORDER_RUN_ID, ids], - ) - - const lessThanCount = new Map(ids.map((id) => [id, 0])) - for (const pair of pairs) { - if (pair.lt) { - lessThanCount.set(pair.a, (lessThanCount.get(pair.a) ?? 0) + 1) + [ORDER_RUN_ID, ids], + ) + + const lessThanCount = new Map(ids.map((id) => [id, 0])) + for (const pair of pairs) { + if (pair.lt) { + lessThanCount.set(pair.a, (lessThanCount.get(pair.a) ?? 0) + 1) + } } - } - - // Ascending rank = strictly-less-than count, descending (the smallest value - // is < every other row, so it has the highest count and sorts first). - const derivedAscending = [...ids].sort( - (a, b) => (lessThanCount.get(b) ?? 0) - (lessThanCount.get(a) ?? 0), - ) - // Sanity: distinct samples must yield distinct ranks 0..L-1 (a strict total - // order), otherwise the ORE comparison collapsed two values together. - expect(new Set(lessThanCount.values()).size).toBe(L) - expect(derivedAscending).toEqual(expectedAscending) - }) - - it.each( - orderingDomains, - )('%s: eql_v3.gte/lte (inclusive) and gt/lt (exclusive) bound the ORE range', async (eqlType, spec) => { - // Explicit two-bound ORE range over the distinct-sample rows: `[lo, hi]` - // spans the whole set, so gte+lte must select ALL of them and strict gt+lt - // must select only the interior (excluding the lo and hi rows). Bounds are - // the plaintext-min/max samples; membership is compared to the plaintext - // order. Uses the boolean comparison operators (ORE-correct on superuser AND - // non-superuser Postgres) — never `ORDER BY ord_term`. For L=2 domains - // (date/timestamp) the interior is empty: a valid exclusive-bound boundary. - const col = slug(eqlType) - const L = spec.samples.length - const ids = orderIds.slice(0, L) - const operands = orderOperands[col] - - const sortedIdx = spec.samples - .map((value, i) => ({ value, i })) - .sort((a, b) => comparePlaintext(a.value, b.value)) - .map((entry) => entry.i) - const lo = sql.json(operands[sortedIdx[0]] as never) - const hi = sql.json(operands[sortedIdx[L - 1]] as never) - - // Inclusive [lo, hi] → every distinct-sample row. - const inclusive = await sql.unsafe( - `SELECT id FROM ${TABLE_NAME} + // Ascending rank = strictly-less-than count, descending (the smallest value + // is < every other row, so it has the highest count and sorts first). + const derivedAscending = [...ids].sort( + (a, b) => (lessThanCount.get(b) ?? 0) - (lessThanCount.get(a) ?? 0), + ) + + // Sanity: distinct samples must yield distinct ranks 0..L-1 (a strict total + // order), otherwise the ORE comparison collapsed two values together. + expect(new Set(lessThanCount.values()).size).toBe(L) + expect(derivedAscending).toEqual(expectedAscending) + }, + ) + + it.each(orderingDomains)( + '%s: eql_v3.gte/lte (inclusive) and gt/lt (exclusive) bound the ORE range', + async (eqlType, spec) => { + // Explicit two-bound ORE range over the distinct-sample rows: `[lo, hi]` + // spans the whole set, so gte+lte must select ALL of them and strict gt+lt + // must select only the interior (excluding the lo and hi rows). Bounds are + // the plaintext-min/max samples; membership is compared to the plaintext + // order. Uses the boolean comparison operators (ORE-correct on superuser AND + // non-superuser Postgres) — never `ORDER BY ord_term`. For L=2 domains + // (date/timestamp) the interior is empty: a valid exclusive-bound boundary. + const col = slug(eqlType) + const L = spec.samples.length + const ids = orderIds.slice(0, L) + const operands = orderOperands[col] + + const sortedIdx = spec.samples + .map((value, i) => ({ value, i })) + .sort((a, b) => comparePlaintext(a.value, b.value)) + .map((entry) => entry.i) + const lo = sql.json(operands[sortedIdx[0]] as never) + const hi = sql.json(operands[sortedIdx[L - 1]] as never) + + // Inclusive [lo, hi] → every distinct-sample row. + const inclusive = await sql.unsafe( + `SELECT id FROM ${TABLE_NAME} WHERE test_run_id = $1 AND id = ANY($2) AND eql_v3.gte("${col}", $3::jsonb) AND eql_v3.lte("${col}", $4::jsonb)`, - [ORDER_RUN_ID, ids, lo, hi], - ) - expect(new Set(inclusive.map((r) => r.id))).toEqual(new Set(ids)) - - // Exclusive (lo, hi) via strict gt/lt → strictly-interior rows only. - const interiorIds = sortedIdx.slice(1, L - 1).map((i) => ids[i]) - const exclusive = await sql.unsafe( - `SELECT id FROM ${TABLE_NAME} + [ORDER_RUN_ID, ids, lo, hi], + ) + expect(new Set(inclusive.map((r) => r.id))).toEqual(new Set(ids)) + + // Exclusive (lo, hi) via strict gt/lt → strictly-interior rows only. + const interiorIds = sortedIdx.slice(1, L - 1).map((i) => ids[i]) + const exclusive = await sql.unsafe( + `SELECT id FROM ${TABLE_NAME} WHERE test_run_id = $1 AND id = ANY($2) AND eql_v3.gt("${col}", $3::jsonb) AND eql_v3.lt("${col}", $4::jsonb)`, - [ORDER_RUN_ID, ids, lo, hi], - ) - expect(new Set(exclusive.map((r) => r.id))).toEqual(new Set(interiorIds)) - - // The controls. Domains with only two distinct samples (date, timestamp) - // have an empty interior, so the assertion above is satisfied by a - // constant-false `gt`/`lt` as well as by a correct one. One-sided strict - // bounds are non-empty for every domain: `gt(lo)` must return everything - // above the minimum, `lt(hi)` everything below the maximum. Both die on a - // constant-false operator, and on a constant-true one (which would drag in - // the excluded endpoint). - const aboveLo = await sql.unsafe( - `SELECT id FROM ${TABLE_NAME} + [ORDER_RUN_ID, ids, lo, hi], + ) + expect(new Set(exclusive.map((r) => r.id))).toEqual(new Set(interiorIds)) + + // The controls. Domains with only two distinct samples (date, timestamp) + // have an empty interior, so the assertion above is satisfied by a + // constant-false `gt`/`lt` as well as by a correct one. One-sided strict + // bounds are non-empty for every domain: `gt(lo)` must return everything + // above the minimum, `lt(hi)` everything below the maximum. Both die on a + // constant-false operator, and on a constant-true one (which would drag in + // the excluded endpoint). + const aboveLo = await sql.unsafe( + `SELECT id FROM ${TABLE_NAME} WHERE test_run_id = $1 AND id = ANY($2) AND eql_v3.gt("${col}", $3::jsonb)`, - [ORDER_RUN_ID, ids, lo], - ) - expect(new Set(aboveLo.map((r) => r.id))).toEqual( - new Set(sortedIdx.slice(1).map((i) => ids[i])), - ) - - const belowHi = await sql.unsafe( - `SELECT id FROM ${TABLE_NAME} + [ORDER_RUN_ID, ids, lo], + ) + expect(new Set(aboveLo.map((r) => r.id))).toEqual( + new Set(sortedIdx.slice(1).map((i) => ids[i])), + ) + + const belowHi = await sql.unsafe( + `SELECT id FROM ${TABLE_NAME} WHERE test_run_id = $1 AND id = ANY($2) AND eql_v3.lt("${col}", $3::jsonb)`, - [ORDER_RUN_ID, ids, hi], - ) - expect(new Set(belowHi.map((r) => r.id))).toEqual( - new Set(sortedIdx.slice(0, L - 1).map((i) => ids[i])), - ) - }) - - it.each( - storageDomains, - )('%s: ciphertext survives a real INSERT/SELECT and still decrypts', async (eqlType, spec) => { - const col = slug(eqlType) - const [row] = await sql.unsafe>( - `SELECT "${col}"::jsonb AS value FROM ${TABLE_NAME} WHERE id = $1`, - [idA], - ) - const decrypted = unwrapResult(await client.decrypt(row.value as never)) - const expected = spec.samples[0] - expectDecryptedStorageValue(decrypted, expected) - }) + [ORDER_RUN_ID, ids, hi], + ) + expect(new Set(belowHi.map((r) => r.id))).toEqual( + new Set(sortedIdx.slice(0, L - 1).map((i) => ids[i])), + ) + }, + ) + + it.each(storageDomains)( + '%s: ciphertext survives a real INSERT/SELECT and still decrypts', + async (eqlType, spec) => { + const col = slug(eqlType) + const [row] = await sql.unsafe>( + `SELECT "${col}"::jsonb AS value FROM ${TABLE_NAME} WHERE id = $1`, + [idA], + ) + const decrypted = unwrapResult(await client.decrypt(row.value as never)) + const expected = spec.samples[0] + expectDecryptedStorageValue(decrypted, expected) + }, + ) }) diff --git a/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts index 505e94814..858b05286 100644 --- a/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts +++ b/packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts @@ -491,24 +491,25 @@ describe('a v3 client reconstructs every plaintext axis from stored EQL v2 paylo ).toEqual(['bigint', 'boolean', 'date', 'number', 'string', 'timestamp']) }) - it.each( - matrixCases, - )('%s decrypts from a stored EQL v2 payload through the model path', (label, slug, castAs, sample, row) => { - const payload = encrypted[row][slug] - - // THE guard for this file. Without it the whole matrix would pass just as - // happily against v3 fixtures and prove nothing about legacy reads. The - // identifier is checked too: `encryptBulk` correlates positionally, so a - // mis-zipped batch could otherwise assert one domain's sample against - // another domain's ciphertext and go green by coincidence. - expect(payload).toMatchObject({ - v: 2, - i: { t: matrix.tableName, c: slug }, - }) - expect(payload).toHaveProperty('c') - - expectReconstructed(decrypted[row][slug], castAs, sample, label) - }) + it.each(matrixCases)( + '%s decrypts from a stored EQL v2 payload through the model path', + (label, slug, castAs, sample, row) => { + const payload = encrypted[row][slug] + + // THE guard for this file. Without it the whole matrix would pass just as + // happily against v3 fixtures and prove nothing about legacy reads. The + // identifier is checked too: `encryptBulk` correlates positionally, so a + // mis-zipped batch could otherwise assert one domain's sample against + // another domain's ciphertext and go green by coincidence. + expect(payload).toMatchObject({ + v: 2, + i: { t: matrix.tableName, c: slug }, + }) + expect(payload).toHaveProperty('c') + + expectReconstructed(decrypted[row][slug], castAs, sample, label) + }, + ) /** * `decryptModel` and `bulkDecryptModels` are separate wrappers over the same diff --git a/packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts b/packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts index 757f0cd51..5873381f9 100644 --- a/packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts +++ b/packages/stack/integration/wasm/v2-decrypt-compat.integration.test.ts @@ -328,24 +328,25 @@ describe('a wasm-inline client reconstructs every plaintext axis from stored EQL ) }, 60000) - it.each( - matrixCases, - )('%s decrypts from a stored EQL v2 payload through the wasm model path', (label, slug, castAs, sample, row) => { - const payload = encrypted[row][slug] + it.each(matrixCases)( + '%s decrypts from a stored EQL v2 payload through the wasm model path', + (label, slug, castAs, sample, row) => { + const payload = encrypted[row][slug] - // THE guard for this block. Without it the whole matrix would pass just as - // happily against v3 fixtures and prove nothing about legacy reads. The - // identifier is checked too: `encryptBulk` correlates positionally, so a - // mis-zipped batch could otherwise assert one domain's sample against - // another domain's ciphertext and go green by coincidence. - expect(payload).toMatchObject({ - v: 2, - i: { t: matrix.tableName, c: slug }, - }) - expect(payload).toHaveProperty('c') + // THE guard for this block. Without it the whole matrix would pass just as + // happily against v3 fixtures and prove nothing about legacy reads. The + // identifier is checked too: `encryptBulk` correlates positionally, so a + // mis-zipped batch could otherwise assert one domain's sample against + // another domain's ciphertext and go green by coincidence. + expect(payload).toMatchObject({ + v: 2, + i: { t: matrix.tableName, c: slug }, + }) + expect(payload).toHaveProperty('c') - expectReconstructed(decrypted[row][slug], castAs, sample, label) - }) + expectReconstructed(decrypted[row][slug], castAs, sample, label) + }, + ) /** * `decryptModel` and `bulkDecryptModels` are separate wrappers over diff --git a/packages/stack/package.json b/packages/stack/package.json index c84d64096..0b8a954c7 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -205,16 +205,16 @@ "devDependencies": { "@cipherstash/eql": "workspace:^", "@clack/prompts": "^1.7.0", - "@clerk/backend": "3.11.3", - "@supabase/postgrest-js": "2.110.2", - "@supabase/supabase-js": "^2.110.2", + "@clerk/backend": "3.16.7", + "@supabase/postgrest-js": "2.112.3", + "@supabase/supabase-js": "^2.112.3", "@types/pg": "^8.23.1", "@types/uuid": "^11.0.0", "dotenv": "17.4.2", "drizzle-orm": "^0.45.2", "execa": "^9.5.2", "fast-check": "^4.9.0", - "fta-cli": "3.0.0", + "fta-cli": "3.0.1", "json-schema-to-typescript": "^15.0.2", "pg": "8.23.0", "postgres": "^3.4.8", diff --git a/packages/test-kit/package.json b/packages/test-kit/package.json index 60b273a40..7d2fb8ac0 100644 --- a/packages/test-kit/package.json +++ b/packages/test-kit/package.json @@ -19,7 +19,7 @@ "@cipherstash/stack": "workspace:*" }, "devDependencies": { - "@clerk/backend": "3.11.3", + "@clerk/backend": "3.16.7", "typescript": "catalog:repo", "vitest": "catalog:repo" } diff --git a/packages/test-kit/src/run-family-suite.ts b/packages/test-kit/src/run-family-suite.ts index 3f24b6238..4dff2b833 100644 --- a/packages/test-kit/src/run-family-suite.ts +++ b/packages/test-kit/src/run-family-suite.ts @@ -272,21 +272,21 @@ export function runFamilySuite( for (const kind of ['gt', 'gte', 'lt', 'lte'] as const) { if (!positive.has(kind)) continue - it.each([ - min, - max, - ])(`${kind}(%s) matches the oracle`, async (bound) => { - const cmp = { - gt: (c: number) => c > 0, - gte: (c: number) => c >= 0, - lt: (c: number) => c < 0, - lte: (c: number) => c <= 0, - }[kind] - await expectRows( - { kind, column: slug, value: bound }, - keysWhere((v) => cmp(comparePlain(v, bound))), - ) - }) + it.each([min, max])( + `${kind}(%s) matches the oracle`, + async (bound) => { + const cmp = { + gt: (c: number) => c > 0, + gte: (c: number) => c >= 0, + lt: (c: number) => c < 0, + lte: (c: number) => c <= 0, + }[kind] + await expectRows( + { kind, column: slug, value: bound }, + keysWhere((v) => cmp(comparePlain(v, bound))), + ) + }, + ) } if (positive.has('between')) { @@ -315,30 +315,31 @@ export function runFamilySuite( } if (positive.has('matches')) { - it.each(needlesFrom(values))('matches: $label', async ({ - needle, - }) => { - await expectRows( - { kind: 'matches', column: slug, needle }, - keysWhere((v) => matchesPlain(v, needle)), - ) - }) + it.each(needlesFrom(values))( + 'matches: $label', + async ({ needle }) => { + await expectRows( + { kind: 'matches', column: slug, needle }, + keysWhere((v) => matchesPlain(v, needle)), + ) + }, + ) } if (positive.has('order')) { - it.each([ - 'asc', - 'desc', - ] as const)('order(%s) returns rows in plaintext order', async (direction) => { - // Order is the one operation whose ROW ORDER is the assertion, so - // it does not go through `expectRows` (which sorts both sides). - const rows = await adapter.run(table, { - kind: 'order', - column: slug, - direction, - }) - expect(rows).toEqual(sortedKeysFor(spec, rowKeys, direction)) - }) + it.each(['asc', 'desc'] as const)( + 'order(%s) returns rows in plaintext order', + async (direction) => { + // Order is the one operation whose ROW ORDER is the assertion, so + // it does not go through `expectRows` (which sorts both sides). + const rows = await adapter.run(table, { + kind: 'order', + column: slug, + direction, + }) + expect(rows).toEqual(sortedKeysFor(spec, rowKeys, direction)) + }, + ) } // Structural, never capability-gated: a NULL plaintext is a SQL NULL on diff --git a/packages/test-kit/src/run-json-suite.ts b/packages/test-kit/src/run-json-suite.ts index 888780a29..6e9dfe079 100644 --- a/packages/test-kit/src/run-json-suite.ts +++ b/packages/test-kit/src/run-json-suite.ts @@ -109,12 +109,15 @@ export function runJsonSuite(makeAdapter: () => JsonIntegrationAdapter): void { ['lt', 35, ['ada', 'grace']], ['gte', 30, ['ada', 'zoe']], ['gt', 100, []], - ] as const)('%s(%s) matches the plaintext oracle', async (comparison, value, expected) => { - await expectRows( - { kind: 'selector', comparison, path: '$.age', value }, - [...expected], - ) - }) + ] as const)( + '%s(%s) matches the plaintext oracle', + async (comparison, value, expected) => { + await expectRows( + { kind: 'selector', comparison, path: '$.age', value }, + [...expected], + ) + }, + ) it('uses explicit absent-path semantics', async () => { await expectRows( diff --git a/packages/wizard/src/__tests__/interface.test.ts b/packages/wizard/src/__tests__/interface.test.ts index 9368eccf1..247c2a406 100644 --- a/packages/wizard/src/__tests__/interface.test.ts +++ b/packages/wizard/src/__tests__/interface.test.ts @@ -76,22 +76,21 @@ describe('wizardCanUseTool', () => { // The doctrine tells the agent to add placeholder keys to `.env.example`. // The guard covers Edit and Write as well as Read, so a blanket `.env.` // rule made the file the agent is told to write unreachable. - it.each([ - '.env.example', - '.env.sample', - '.env.template', - ])('allows Read/Edit/Write/Glob on %s', (name) => { - expect(wizardCanUseTool('Read', { file_path: `/project/${name}` })).toBe( - true, - ) - expect(wizardCanUseTool('Edit', { file_path: `/project/${name}` })).toBe( - true, - ) - expect(wizardCanUseTool('Write', { file_path: `/project/${name}` })).toBe( - true, - ) - expect(wizardCanUseTool('Glob', { pattern: name })).toBe(true) - }) + it.each(['.env.example', '.env.sample', '.env.template'])( + 'allows Read/Edit/Write/Glob on %s', + (name) => { + expect( + wizardCanUseTool('Read', { file_path: `/project/${name}` }), + ).toBe(true) + expect( + wizardCanUseTool('Edit', { file_path: `/project/${name}` }), + ).toBe(true) + expect( + wizardCanUseTool('Write', { file_path: `/project/${name}` }), + ).toBe(true) + expect(wizardCanUseTool('Glob', { pattern: name })).toBe(true) + }, + ) it('still blocks value-bearing files that only start with a template name', () => { expect( diff --git a/packages/wizard/src/__tests__/rewrite-migrations.test.ts b/packages/wizard/src/__tests__/rewrite-migrations.test.ts index 6a31db3a6..8e2e16933 100644 --- a/packages/wizard/src/__tests__/rewrite-migrations.test.ts +++ b/packages/wizard/src/__tests__/rewrite-migrations.test.ts @@ -258,26 +258,26 @@ describe('rewriteEncryptedAlterColumns', () => { // DOMAIN_RE is derived from ENCRYPTED_DOMAIN, so drift between the two can't // silently leave a domain unrewritten. Prove every domain the alternation // recognises is actually extracted into the emitted ADD COLUMN. - it.each([ - ...V3_DOMAINS, - 'eql_v2_encrypted', - ])('extracts the bare domain %s from a mangled ALTER', async (domain) => { - declarePlaintext('"t"', 'c') - const filePath = path.join(tmpDir, '0015_drift.sql') - fs.writeFileSync( - filePath, - `ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE "undefined"."${domain}";\n`, - ) + it.each([...V3_DOMAINS, 'eql_v2_encrypted'])( + 'extracts the bare domain %s from a mangled ALTER', + async (domain) => { + declarePlaintext('"t"', 'c') + const filePath = path.join(tmpDir, '0015_drift.sql') + fs.writeFileSync( + filePath, + `ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE "undefined"."${domain}";\n`, + ) - const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) - expect(rewritten).toEqual([filePath]) - const updated = fs.readFileSync(filePath, 'utf-8') - expect(updated).toContain( - `ALTER TABLE "t" ADD COLUMN "c_encrypted" "public"."${domain}";`, - ) - expect(updated).not.toContain('SET DATA TYPE') - }) + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + `ALTER TABLE "t" ADD COLUMN "c_encrypted" "public"."${domain}";`, + ) + expect(updated).not.toContain('SET DATA TYPE') + }, + ) // The mangled forms are the cross product of what `dataType()` returns and // which drizzle-kit era renders it (see the file's comment table). @@ -865,26 +865,30 @@ describe('rewriteEncryptedAlterColumns', () => { it.each([ ['tagged', 'price$usd$cents'], ['untagged', 'price$$cents'], - ])('does not treat a %s dollar delimiter inside an unquoted identifier as a dollar-quoted body', async (_kind, identifier) => { - declarePlaintext('"users"', 'email') - const filePath = path.join(tmpDir, `0033_${_kind}-identifier.sql`) - fs.writeFileSync( - filePath, - [ - `SELECT ${identifier} FROM "prices";`, - 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', - '', - ].join('\n'), - ) - - const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) - - expect(rewritten).toEqual([filePath]) - expect(skipped).toEqual([]) - const updated = fs.readFileSync(filePath, 'utf-8') - expect(updated).toContain('ADD COLUMN "email_encrypted"') - expect(updated).not.toContain('SET DATA TYPE') - }) + ])( + 'does not treat a %s dollar delimiter inside an unquoted identifier as a dollar-quoted body', + async (_kind, identifier) => { + declarePlaintext('"users"', 'email') + const filePath = path.join(tmpDir, `0033_${_kind}-identifier.sql`) + fs.writeFileSync( + filePath, + [ + `SELECT ${identifier} FROM "prices";`, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;', + '', + ].join('\n'), + ) + + const { rewritten, skipped } = + await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + expect(skipped).toEqual([]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain('ADD COLUMN "email_encrypted"') + expect(updated).not.toContain('SET DATA TYPE') + }, + ) }) /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 095ae7e59..a3ea84908 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,11 +68,11 @@ importers: .: devDependencies: '@biomejs/biome': - specifier: ^2.5.3 - version: 2.5.3 + specifier: ^2.5.9 + version: 2.5.9 '@changesets/cli': - specifier: ^2.31.0 - version: 2.31.0(@types/node@22.20.1) + specifier: ^2.31.1 + version: 2.31.1(@types/node@22.20.1) '@types/node': specifier: ^22.20.1 version: 22.20.1 @@ -83,8 +83,8 @@ importers: specifier: ^6.1.3 version: 6.1.3 turbo: - specifier: 2.10.4 - version: 2.10.4 + specifier: 2.10.10 + version: 2.10.10 vitest: specifier: catalog:repo version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) @@ -102,8 +102,8 @@ importers: version: link:../packages/cli devDependencies: '@types/semver': - specifier: 7.7.1 - version: 7.7.1 + specifier: 7.8.0 + version: 7.8.0 semver: specifier: ^7.8.0 version: 7.8.5 @@ -368,14 +368,14 @@ importers: version: 0.2.6 devDependencies: '@biomejs/biome': - specifier: ^2.5.3 - version: 2.5.3 + specifier: ^2.5.9 + version: 2.5.9 '@neon-rs/cli': - specifier: ^0.1.82 - version: 0.1.82 + specifier: ^0.2.6 + version: 0.2.6 '@tsconfig/node22': - specifier: ^22.0.0 - version: 22.0.5 + specifier: ^22.0.6 + version: 22.0.6 '@types/node': specifier: ^22.20.1 version: 22.20.1 @@ -445,14 +445,14 @@ importers: specifier: ^1.7.0 version: 1.7.0 '@clerk/backend': - specifier: 3.11.3 - version: 3.11.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 3.16.7 + version: 3.16.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@supabase/postgrest-js': - specifier: 2.110.2 - version: 2.110.2 + specifier: 2.112.3 + version: 2.112.3 '@supabase/supabase-js': - specifier: ^2.110.2 - version: 2.110.2 + specifier: ^2.112.3 + version: 2.112.3 '@types/pg': specifier: ^8.23.1 version: 8.23.1 @@ -472,8 +472,8 @@ importers: specifier: ^4.9.0 version: 4.9.0 fta-cli: - specifier: 3.0.0 - version: 3.0.0 + specifier: 3.0.1 + version: 3.0.1 json-schema-to-typescript: specifier: ^15.0.2 version: 15.0.4 @@ -537,8 +537,8 @@ importers: specifier: ^0.45.2 version: 0.45.2(@types/pg@8.23.1)(gel@2.2.0)(mysql2@3.16.0)(pg@8.23.0)(postgres@3.4.9) fta-cli: - specifier: 3.0.0 - version: 3.0.0 + specifier: 3.0.1 + version: 3.0.1 postgres: specifier: ^3.4.8 version: 3.4.9 @@ -614,11 +614,11 @@ importers: specifier: workspace:* version: link:../test-kit '@supabase/postgrest-js': - specifier: 2.110.2 - version: 2.110.2 + specifier: 2.112.3 + version: 2.112.3 '@supabase/supabase-js': - specifier: ^2.110.2 - version: 2.110.2 + specifier: ^2.112.3 + version: 2.112.3 '@types/pg': specifier: ^8.23.1 version: 8.23.1 @@ -629,8 +629,8 @@ importers: specifier: ^4.9.0 version: 4.9.0 fta-cli: - specifier: 3.0.0 - version: 3.0.0 + specifier: 3.0.1 + version: 3.0.1 pg: specifier: 8.23.0 version: 8.23.0 @@ -654,8 +654,8 @@ importers: version: link:../stack devDependencies: '@clerk/backend': - specifier: 3.11.3 - version: 3.11.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 3.16.7 + version: 3.16.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) typescript: specifier: catalog:repo version: 5.9.3 @@ -801,67 +801,63 @@ packages: '@ark/util@0.56.2': resolution: {integrity: sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ==} - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.3': - resolution: {integrity: sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A==} + '@biomejs/biome@2.5.9': + resolution: {integrity: sha512-KkgCvdHB4IhtpHpF564plA9jo6fDOwWGQ/3jvreLzgOtRLEDoPqr7QO9qejNA8jKwDsSkAKr77hqBHnyUbIw4g==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.3': - resolution: {integrity: sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g==} + '@biomejs/cli-darwin-arm64@2.5.9': + resolution: {integrity: sha512-am22pX2aBqznqq1eMyIj/bZ++riF3Lk6ct7cbv+gQK0csFhr+d8O0RkOi2FF2qSgFgANbqNkIZ0/PxlnW2pLFg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.3': - resolution: {integrity: sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ==} + '@biomejs/cli-darwin-x64@2.5.9': + resolution: {integrity: sha512-l44KWDHLDvEnD0N/XcrVs7VXb3A18xL7QS3WB0eL93wbmk529ffIG55vleGCqaunpRUjLrdnjK05Qki1dsjylg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.3': - resolution: {integrity: sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg==} + '@biomejs/cli-linux-arm64-musl@2.5.9': + resolution: {integrity: sha512-7ImVPwBLCtkmpR5esd8RHhTqW94f0JLJQum6AneYcy94jRm18TaPPm7slaigGzFhfgt3QiD1Vj52LKmBAnKizA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.3': - resolution: {integrity: sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA==} + '@biomejs/cli-linux-arm64@2.5.9': + resolution: {integrity: sha512-ICaK+IYaVZvKbBxX2rwrPT0DdUDMnE9Vm3nQGe+mltQPmUg19pONzkPWGdY4FCsoreDETWDynvdt4ysCbF5gNQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.3': - resolution: {integrity: sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ==} + '@biomejs/cli-linux-x64-musl@2.5.9': + resolution: {integrity: sha512-RXGaD0o1/pTTguYw1aeDJh9ad6Lfrui0fI7mBderTyGr7WuUJkBIttgLkR3XJyoxOkkgfBDspaUT8wXArTqLZw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.3': - resolution: {integrity: sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA==} + '@biomejs/cli-linux-x64@2.5.9': + resolution: {integrity: sha512-z22Q/zFYSvbIJfW1CbfZPu4X8PddS6Qd2ORbc6h+aT6EcwAxUF3m6fA4HjNvA3TU4X0dTJRwNPB165ES3PJXzg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.3': - resolution: {integrity: sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw==} + '@biomejs/cli-win32-arm64@2.5.9': + resolution: {integrity: sha512-nHK+/HHC+D0ogAHUxomgoSTdjImb6fmNNVTKmf0tyu4eDL1DqPKIHc+i+UL8+b0RnAu8224qo8F2tCVnaT0A3w==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.3': - resolution: {integrity: sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q==} + '@biomejs/cli-win32-x64@2.5.9': + resolution: {integrity: sha512-Yiq0H56LjXSSw/hd9YkXgSLQfzyDJzbzU2TezozxyNw+uKWAqOtqGVvBfzKRRDiaFF5avGAhHdWKx7LtDOShUw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -872,56 +868,6 @@ packages: '@byteslice/result@0.3.0': resolution: {integrity: sha512-nIVjzVJ5LEUsj5jZ196OQ+XFYDPw74JVmHrsKs0g/iX1c8llydLWKbseMKmAtcEnpRj8hdsEue5H/naz7wdC4A==} - '@cargo-messages/android-arm-eabi@0.1.81': - resolution: {integrity: sha512-cpRbgb56e9LmAj96Tixtz9/bTlaJAeplWNNv4obu+eqQyZd3ZjX04TJd9fM1bjHDGVyR9GTVlgBsbQEntIGkwg==} - cpu: [arm] - os: [android] - - '@cargo-messages/darwin-arm64@0.1.81': - resolution: {integrity: sha512-OwGqsw+tbJx37a/vH4T8R9qkrrFYoTIOnckbA9+MhQodE2FSWyk3HvLh+z8jjl+QZa1RSOU9Ax6gt/46h0BiTg==} - cpu: [arm64] - os: [darwin] - - '@cargo-messages/darwin-x64@0.1.81': - resolution: {integrity: sha512-Iu761bPk25Ce6yUdDCjjeVuT8/xbBmczyaNB7oYBmAZEE5rshvCJ42TqSShYYP+S7pKkN42nBhkFooaZrqaz9g==} - cpu: [x64] - os: [darwin] - - '@cargo-messages/linux-arm-gnueabihf@0.1.81': - resolution: {integrity: sha512-iIuy7KTJAEhbiqlIlcxQOdW6opI6M9LXlgd/jdsHbP2FjmTyhTLnd3JCJ6JeAeidwknCDs+CFlaVmPxTKSytsg==} - cpu: [arm] - os: [linux] - - '@cargo-messages/linux-arm64-gnu@0.1.81': - resolution: {integrity: sha512-QPQRsHj9m/9ga8wRBlLh8t2AXyr40+/H55FIKVj7zIjV++waY/TbSTPofDZQhMycd5VSGLKztfhahiCO7c/RAQ==} - cpu: [arm64] - os: [linux] - - '@cargo-messages/linux-arm64-musl@0.1.81': - resolution: {integrity: sha512-9O0ATesIOjDTz2L01OtlGHYwP86I31/mzXMC+ryQkzbbIKj7KUiSqmEc29I14I517UYO8/sMeow6q6MVBpehlA==} - cpu: [arm64] - os: [linux] - - '@cargo-messages/linux-x64-gnu@0.1.81': - resolution: {integrity: sha512-wEFYxCdtHNiEvp5KEg17CfRCUdfRTtkL+1GASROyvYEUV6DQf6TXxfHuuWg2xlVxh5fqiTGFSRfiqFrCDL/xrw==} - cpu: [x64] - os: [linux] - - '@cargo-messages/linux-x64-musl@0.1.81': - resolution: {integrity: sha512-zi5pKIh60oPwOCDQapAZ3Mya4y56MI2BoGvY8JtztYaXTorGmAoyf6jjb50Wt+HfoYYjM3OlPt03XMlCFZJnIQ==} - cpu: [x64] - os: [linux] - - '@cargo-messages/win32-arm64-msvc@0.1.81': - resolution: {integrity: sha512-oyiT8AYLoguF7cFOMYDsPv3eirzBcFafOOfRsFyd3+wmaPTl/DdbCq446oThRmSAsEGJpzhzj7TafcnXMBkHbg==} - cpu: [arm64] - os: [win32] - - '@cargo-messages/win32-x64-msvc@0.1.81': - resolution: {integrity: sha512-B5Ukf4AohtIv27uCP/AgM+7vYwQ4RacI6m8ZBr2XKeSrjZXcXguzlZd+wD7bD5+wa0capvXKUskZDnpG/DcYiA==} - cpu: [x64] - os: [win32] - '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -931,8 +877,8 @@ packages: '@changesets/changelog-git@0.2.1': resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} - '@changesets/cli@2.31.0': - resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==} + '@changesets/cli@2.31.1': + resolution: {integrity: sha512-uO05WTcRBwuVOJVSW8Cmpqw6q0WDL53ajGCMyszutvOe5toOnunbpM4jZzf+qxBOz7i0AzopZ8diBuewjmF40w==} hasBin: true '@changesets/config@3.1.4': @@ -1041,10 +987,6 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.11.3': - resolution: {integrity: sha512-takrEE0nliUz39e8Ct1YG471eBcuU2I1ZStRIU9U0UAIuiNAxNTNuaVZSXaZeK4QTuJtdQhoGy8rvzxr/10Btw==} - engines: {node: '>=20.9.0'} - '@clerk/backend@3.16.7': resolution: {integrity: sha512-iPy+vXxDDgBdma6EBQ43ymGxrHKSI2ZwUgY2orF5cWvcG5h61hTZJYapO9TsHp39PbMcgiC/S5U785jq+S3y6Q==} engines: {node: '>=20.9.0'} @@ -1064,18 +1006,6 @@ packages: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.25.2': - resolution: {integrity: sha512-v7zgDh0eVLl3KzRHbKYTsPQ+4Xcx8A2oiU5iZ9WhME024WudwkjjPJRK12RycY0VA1Sj8i4Pi5zEpecqB/gp1Q==} - engines: {node: '>=20.9.0'} - peerDependencies: - react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - '@clerk/shared@4.29.2': resolution: {integrity: sha512-9c9Mc1oqumsqo+JY5R37O1ipwcG3RmwPK9oadBLL9E4VxZXthXVaFI9u+1/I4BSFGA/B9BO+17tkVDL4G0Wpbg==} engines: {node: '>=20.9.0'} @@ -1459,8 +1389,8 @@ packages: '@cfworker/json-schema': optional: true - '@neon-rs/cli@0.1.82': - resolution: {integrity: sha512-QrlGPQp9KOGuMvjjua79lEV2QTcE16m8JatG5ITdQpBAwRQpDw5xab57W9130y2iUEfMzYtp7v6pcN1fUB0Exg==} + '@neon-rs/cli@0.2.6': + resolution: {integrity: sha512-SC8xYNOH1dTnDiB2pykFhREl74gs6TF4jr2qFp0h9fwriRSoxic/CnVN2d7ikpc3XatOK1QnwfQCCmyv31x9/A==} hasBin: true '@neon-rs/load@0.2.6': @@ -1748,69 +1678,74 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@supabase/auth-js@2.110.2': - resolution: {integrity: sha512-Qj7a6EDP+AMMQFWqGv+qFa8r6re//dk+qQI5bA0KK+PZmnI3JPu97TDeNt6SMiQ2FkklP79hP2yDFYSnA989OA==} + '@supabase/auth-js@2.112.3': + resolution: {integrity: sha512-NA0rsgAlWZPvbhw8aUdmgfpHVgUAcd8zK5ov43l++o1bLIPXZhRiAlRobhwF5AatQuovpqxsMH50F4oyyV4XZw==} engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.110.2': - resolution: {integrity: sha512-ZjjqrXpxM9/rE+eAtZxiK45EWy9EBoJQ322Q5Y75LccYQNh212neHTgXP/o4MIzmH0LNXT8UzvTZtQOfOzyoeQ==} + '@supabase/functions-js@2.112.3': + resolution: {integrity: sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==} engines: {node: '>=22.0.0'} - '@supabase/phoenix@0.4.4': - resolution: {integrity: sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==} + '@supabase/phoenix@0.4.5': + resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} - '@supabase/postgrest-js@2.110.2': - resolution: {integrity: sha512-++LBmcIMwCtgO4tISQUmo9+2xkRwHQqS8ZKMCnhXLe9P8k8YQRXuMoh/RiSzQSoev8gqet0W7yOboW0cUxnt0Q==} + '@supabase/postgrest-js@2.112.3': + resolution: {integrity: sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==} engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.110.2': - resolution: {integrity: sha512-z3jTOTPgyn6E3r6dVOOQ10He4yAMB2czjFw7xVdX3s16MHElna5rY1gVaePs0NIo6xvtMYbtmOXlFaFt/ePLpg==} + '@supabase/realtime-js@2.112.3': + resolution: {integrity: sha512-E6wljXWs7DUOloyIB69i3YFInWE6IyCvgTAbQ0KYxOHv26FdA1KzEXTuzxrYEdf70t406Z9BRwUlGyclGF2FXA==} engines: {node: '>=22.0.0'} - '@supabase/storage-js@2.110.2': - resolution: {integrity: sha512-EhsRSwSnmQefKJsAxoRUZ0hvHr92ECM8DDGAKR5z0HdoJx4heI60PjHUTruVNZxKX6XeobLGDyLud020Bw1iwg==} + '@supabase/storage-js@2.112.3': + resolution: {integrity: sha512-oSK61tzlUvg+BWPqpKQCu9qqonsO26btaoAR9D6Gest2aj7xUqToj9rKyaoYOJczkhg9BjqA1REbYy9tPI4bDA==} engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.110.2': - resolution: {integrity: sha512-r9q9w4ZQ6mOjh36aqUNFSisBF611vzpO8JphBESr2Q1SWvmGFQeI7Jq7Y+PaNMZ6Zszz+S2yTlJStCpnaMSnQg==} + '@supabase/supabase-js@2.112.3': + resolution: {integrity: sha512-Jv1bxVQmEJNkjvPEhFaKjPzsh+Ozyew6lWGD+SoYcsclDEP1z7yEvKvfUQfzy0DkxRIQnZNxmmWtAzw5XLTQoA==} engines: {node: '>=22.0.0'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@tanstack/query-core@5.101.2': - resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} - '@tsconfig/node22@22.0.5': - resolution: {integrity: sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==} + '@tsconfig/node22@22.0.6': + resolution: {integrity: sha512-/5thavHAnWDZwH2H6eEn/T8pBEBhtuKaWGMslsj82kJfvzQgOgEMK7X4goBbIUjgVtAzPTtM9Ml64LA0uxO6Iw==} - '@turbo/darwin-64@2.10.4': - resolution: {integrity: sha512-m1MUEI4MJ69r5CwfMYxmHi0H0rrgiYCBOp0tgBZ9x/YVvOb5uu/lRIDyDwdtH054R2yWeQaIigUGu6aCX9f8cA==} + '@turbo/darwin-64@2.10.10': + resolution: {integrity: sha512-gFDD+wRP5hWxBRghGyEbjpbLOY7aIU/wvsnKdMM7odQcp/wHMrnI83p0FyxxMRZnFH9ZD+S59MvcpOC5b+nrCA==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.10.4': - resolution: {integrity: sha512-VQ1Yxs5zkPT+2z7t1P4mvn6JmcKLkOCAsPuK9XbOvuVj0DlTlETfIXNisX0771v/vTWHOQqiwoGi+TtAUq8efw==} + '@turbo/darwin-arm64@2.10.10': + resolution: {integrity: sha512-VZYsxZ6yjyDosUqtiroAVSXPLmx/qBxdHJgIxdMH9RyNmLdOLOWtJnYMnI4qckwCgQMK85G3fu94/xk5+iBCgw==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.10.4': - resolution: {integrity: sha512-IzV1QovmwX7mfGnVinmE++2IB8tbeo38weltiuH5zNqwCTBjLs/DytyRKx+bmnhHdXIq9SheR8p0Nip/LBUPHg==} + '@turbo/linux-64@2.10.10': + resolution: {integrity: sha512-lAvW+yEnmsCKMEIwNugjozawvYytHKPhU0kfLBizu83MIs8OUb9KobYvkZ56L5akSM6K7+gBFLEIfQkaceh90g==} cpu: [x64] - os: [linux] + os: [android, linux] - '@turbo/linux-arm64@2.10.4': - resolution: {integrity: sha512-rfujSQkP5aYiRn0PgTM7F00WkJCP/bKDVZbOx3WmrZwa/vHA0bplhCl328kpX7VI9HH2vI90ISGwuSVgJgoqTw==} + '@turbo/linux-arm64@2.10.10': + resolution: {integrity: sha512-MSJ+NkRTd79Z9+YEZpUV9VOWVOOigFhE+v/ETNYJEuTJp3r00y9YgFvDXrmM+DP8Kal6tk3U6xSugD2/Ojh+Jg==} cpu: [arm64] - os: [linux] + os: [android, linux] - '@turbo/windows-64@2.10.4': - resolution: {integrity: sha512-NnspP7Wd5fa3Wwnqv9bKfhegqZzuHBgbPxdZU/idTLQcazx/vgKu95JlCx2YHY0hdvKCnPcARrDwM+KEUmaO7A==} + '@turbo/windows-64@2.10.10': + resolution: {integrity: sha512-ycWpXDkUfnDFDY9d+4Qna/UZotDB0wj+s9agrlmNt0Q7a3XHORhK8GPKJdzgzeutXu9EW5P/jyabTEHlohuDXw==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.10.4': - resolution: {integrity: sha512-Iv02YgOpaEShc2OkG7mgCJ2pEw1RUKiKbs0h8W5wAf4jZ5vpmraTEjuGTgHRuOORQnC1GN3KHo5WB+hu1abRMA==} + '@turbo/windows-arm64@2.10.10': + resolution: {integrity: sha512-PMk6zQN0csUFklLe+1hz/5G9uU1YmV0cEIey2R/bSeA6o69qcBTlN4A3jOqkgenOO5dOpMHmq2sEUZo8r1+Ssg==} cpu: [arm64] os: [win32] @@ -1844,8 +1779,8 @@ packages: '@types/pg@8.23.1': resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} '@types/uuid@11.0.0': resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==} @@ -2007,8 +1942,8 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} - chardet@2.1.1: - resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} @@ -2418,8 +2353,8 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - fta-cli@3.0.0: - resolution: {integrity: sha512-SBmoqIwbN7PLDmwmrPgjr6Z6/S9jPhNz5TCPmEVFkIaeloc/T2WXLeeXqhG1+C0UQxpOfGrC7CUb4friqbc2kQ==} + fta-cli@3.0.1: + resolution: {integrity: sha512-Nm03+vurWkzWj/SEiOof+8zDAbp/0+gDttW5+gIVe4Qxg4uOTlQm+IbHyXM+QptCfywj0/h4U37NsnybaG9z6Q==} hasBin: true function-bind@1.1.2: @@ -2491,8 +2426,8 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - human-id@4.1.3: - resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} + human-id@4.2.0: + resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} hasBin: true human-signals@8.0.1: @@ -2503,10 +2438,6 @@ packages: resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} engines: {node: '>=20.0.0'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -3397,8 +3328,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo@2.10.4: - resolution: {integrity: sha512-GQpduILaKjoaGljw097ScsSyKTtZSY7cZ3bJktzfTkPMyCf3ShKLuXK2IaOEN2Plziml+ArR7WJ1m+V4VbnaKQ==} + turbo@2.10.10: + resolution: {integrity: sha512-/90KTW+USzvYOPmafRZHVKLBsHXQ5810Ao/HdtJYAqguIhZ+XruS6eIUjqJUDtrSxaZYynNFht68qckGKAOWTA==} hasBin: true typanion@3.14.0: @@ -3639,79 +3570,47 @@ snapshots: '@ark/util@0.56.2': {} - '@babel/runtime@7.29.2': {} - '@babel/runtime@7.29.7': {} - '@biomejs/biome@2.5.3': + '@biomejs/biome@2.5.9': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.3 - '@biomejs/cli-darwin-x64': 2.5.3 - '@biomejs/cli-linux-arm64': 2.5.3 - '@biomejs/cli-linux-arm64-musl': 2.5.3 - '@biomejs/cli-linux-x64': 2.5.3 - '@biomejs/cli-linux-x64-musl': 2.5.3 - '@biomejs/cli-win32-arm64': 2.5.3 - '@biomejs/cli-win32-x64': 2.5.3 + '@biomejs/cli-darwin-arm64': 2.5.9 + '@biomejs/cli-darwin-x64': 2.5.9 + '@biomejs/cli-linux-arm64': 2.5.9 + '@biomejs/cli-linux-arm64-musl': 2.5.9 + '@biomejs/cli-linux-x64': 2.5.9 + '@biomejs/cli-linux-x64-musl': 2.5.9 + '@biomejs/cli-win32-arm64': 2.5.9 + '@biomejs/cli-win32-x64': 2.5.9 - '@biomejs/cli-darwin-arm64@2.5.3': + '@biomejs/cli-darwin-arm64@2.5.9': optional: true - '@biomejs/cli-darwin-x64@2.5.3': + '@biomejs/cli-darwin-x64@2.5.9': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.3': + '@biomejs/cli-linux-arm64-musl@2.5.9': optional: true - '@biomejs/cli-linux-arm64@2.5.3': + '@biomejs/cli-linux-arm64@2.5.9': optional: true - '@biomejs/cli-linux-x64-musl@2.5.3': + '@biomejs/cli-linux-x64-musl@2.5.9': optional: true - '@biomejs/cli-linux-x64@2.5.3': + '@biomejs/cli-linux-x64@2.5.9': optional: true - '@biomejs/cli-win32-arm64@2.5.3': + '@biomejs/cli-win32-arm64@2.5.9': optional: true - '@biomejs/cli-win32-x64@2.5.3': + '@biomejs/cli-win32-x64@2.5.9': optional: true '@byteslice/result@0.2.0': {} '@byteslice/result@0.3.0': {} - '@cargo-messages/android-arm-eabi@0.1.81': - optional: true - - '@cargo-messages/darwin-arm64@0.1.81': - optional: true - - '@cargo-messages/darwin-x64@0.1.81': - optional: true - - '@cargo-messages/linux-arm-gnueabihf@0.1.81': - optional: true - - '@cargo-messages/linux-arm64-gnu@0.1.81': - optional: true - - '@cargo-messages/linux-arm64-musl@0.1.81': - optional: true - - '@cargo-messages/linux-x64-gnu@0.1.81': - optional: true - - '@cargo-messages/linux-x64-musl@0.1.81': - optional: true - - '@cargo-messages/win32-arm64-msvc@0.1.81': - optional: true - - '@cargo-messages/win32-x64-msvc@0.1.81': - optional: true - '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -3741,7 +3640,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.31.0(@types/node@22.20.1)': + '@changesets/cli@2.31.1(@types/node@22.20.1)': dependencies: '@changesets/apply-release-plan': 7.1.1 '@changesets/assemble-release-plan': 6.0.10 @@ -3852,7 +3751,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 fs-extra: 7.0.1 - human-id: 4.1.3 + human-id: 4.2.0 prettier: 2.8.8 '@cipherstash/auth-darwin-arm64@0.42.0': @@ -3896,15 +3795,6 @@ snapshots: fast-wrap-ansi: 0.2.0 sisteransi: 1.0.5 - '@clerk/backend@3.11.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@clerk/shared': 4.25.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - standardwebhooks: 1.0.0 - tslib: 2.8.1 - transitivePeerDependencies: - - react - - react-dom - '@clerk/backend@3.16.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@clerk/shared': 4.29.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -3932,19 +3822,9 @@ snapshots: react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/shared@4.25.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@tanstack/query-core': 5.101.2 - dequal: 2.0.3 - glob-to-regexp: 0.4.1 - js-cookie: 3.0.7 - optionalDependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.29.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/query-core': 5.101.2 + '@tanstack/query-core': 5.101.4 dequal: 2.0.3 glob-to-regexp: 0.4.1 js-cookie: 3.0.7 @@ -4148,8 +4028,8 @@ snapshots: '@inquirer/external-editor@1.0.3(@types/node@22.20.1)': dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.2 + chardet: 2.2.0 + iconv-lite: 0.7.3 optionalDependencies: '@types/node': 22.20.1 @@ -4184,7 +4064,7 @@ snapshots: '@manypkg/get-packages@1.1.3': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@changesets/types': 4.1.0 '@manypkg/find-root': 1.1.0 fs-extra: 8.1.0 @@ -4213,18 +4093,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@neon-rs/cli@0.1.82': - optionalDependencies: - '@cargo-messages/android-arm-eabi': 0.1.81 - '@cargo-messages/darwin-arm64': 0.1.81 - '@cargo-messages/darwin-x64': 0.1.81 - '@cargo-messages/linux-arm-gnueabihf': 0.1.81 - '@cargo-messages/linux-arm64-gnu': 0.1.81 - '@cargo-messages/linux-arm64-musl': 0.1.81 - '@cargo-messages/linux-x64-gnu': 0.1.81 - '@cargo-messages/linux-x64-musl': 0.1.81 - '@cargo-messages/win32-arm64-msvc': 0.1.81 - '@cargo-messages/win32-x64-msvc': 0.1.81 + '@neon-rs/cli@0.2.6': {} '@neon-rs/load@0.2.6': {} @@ -4517,62 +4386,62 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@supabase/auth-js@2.110.2': + '@supabase/auth-js@2.112.3': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.110.2': + '@supabase/functions-js@2.112.3': dependencies: tslib: 2.8.1 - '@supabase/phoenix@0.4.4': {} + '@supabase/phoenix@0.4.5': {} - '@supabase/postgrest-js@2.110.2': + '@supabase/postgrest-js@2.112.3': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.110.2': + '@supabase/realtime-js@2.112.3': dependencies: - '@supabase/phoenix': 0.4.4 + '@supabase/phoenix': 0.4.5 tslib: 2.8.1 - '@supabase/storage-js@2.110.2': + '@supabase/storage-js@2.112.3': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.110.2': + '@supabase/supabase-js@2.112.3': dependencies: - '@supabase/auth-js': 2.110.2 - '@supabase/functions-js': 2.110.2 - '@supabase/postgrest-js': 2.110.2 - '@supabase/realtime-js': 2.110.2 - '@supabase/storage-js': 2.110.2 + '@supabase/auth-js': 2.112.3 + '@supabase/functions-js': 2.112.3 + '@supabase/postgrest-js': 2.112.3 + '@supabase/realtime-js': 2.112.3 + '@supabase/storage-js': 2.112.3 '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 - '@tanstack/query-core@5.101.2': {} + '@tanstack/query-core@5.101.4': {} - '@tsconfig/node22@22.0.5': {} + '@tsconfig/node22@22.0.6': {} - '@turbo/darwin-64@2.10.4': + '@turbo/darwin-64@2.10.10': optional: true - '@turbo/darwin-arm64@2.10.4': + '@turbo/darwin-arm64@2.10.10': optional: true - '@turbo/linux-64@2.10.4': + '@turbo/linux-64@2.10.10': optional: true - '@turbo/linux-arm64@2.10.4': + '@turbo/linux-arm64@2.10.10': optional: true - '@turbo/windows-64@2.10.4': + '@turbo/windows-64@2.10.10': optional: true - '@turbo/windows-arm64@2.10.4': + '@turbo/windows-arm64@2.10.10': optional: true '@types/chai@5.2.3': @@ -4611,7 +4480,7 @@ snapshots: pg-protocol: 1.16.0 pg-types: 2.2.0 - '@types/semver@7.7.1': {} + '@types/semver@7.8.0': {} '@types/uuid@11.0.0': dependencies: @@ -4635,6 +4504,14 @@ snapshots: optionalDependencies: vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + '@vitest/pretty-format@3.2.7': dependencies: tinyrainbow: 2.0.0 @@ -4787,7 +4664,7 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 - chardet@2.1.1: {} + chardet@2.2.0: {} check-error@2.1.3: {} @@ -5106,7 +4983,7 @@ snapshots: fsevents@2.3.3: optional: true - fta-cli@3.0.0: {} + fta-cli@3.0.1: {} function-bind@1.1.2: {} @@ -5195,16 +5072,12 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - human-id@4.1.3: {} + human-id@4.2.0: {} human-signals@8.0.1: {} iceberg-js@0.8.1: {} - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -6036,14 +5909,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo@2.10.4: + turbo@2.10.10: optionalDependencies: - '@turbo/darwin-64': 2.10.4 - '@turbo/darwin-arm64': 2.10.4 - '@turbo/linux-64': 2.10.4 - '@turbo/linux-arm64': 2.10.4 - '@turbo/windows-64': 2.10.4 - '@turbo/windows-arm64': 2.10.4 + '@turbo/darwin-64': 2.10.10 + '@turbo/darwin-arm64': 2.10.10 + '@turbo/linux-64': 2.10.10 + '@turbo/linux-arm64': 2.10.10 + '@turbo/windows-64': 2.10.10 + '@turbo/windows-arm64': 2.10.10 typanion@3.14.0: {} @@ -6197,7 +6070,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 diff --git a/scripts/__tests__/bench-index-expressions.test.mjs b/scripts/__tests__/bench-index-expressions.test.mjs index a92578a4c..ea50f41c3 100644 --- a/scripts/__tests__/bench-index-expressions.test.mjs +++ b/scripts/__tests__/bench-index-expressions.test.mjs @@ -121,29 +121,26 @@ describe('bench index expressions match the EQL v3 operator lowering', () => { ) }) - it.each( - INDEX_CONTRACTS, - )('$index is built on the function $operator inlines to', ({ - index, - operator, - inlinesTo, - }) => { - const body = functionBody(sql, operator) - - // The operator really does lower to this function — if the bundle - // changes shape, this fails here rather than silently passing the - // schema check below against a stale assumption. - expect( - eqlCalls(body), - `${operator} no longer calls eql_v3.${inlinesTo}`, - ).toContain(inlinesTo) - - expect( - eqlCalls(indexes.get(index)), - `${index} must index eql_v3.${inlinesTo}(...) — the expression the ` + - 'operator inlines to — or the planner can never match it', - ).toEqual([inlinesTo]) - }) + it.each(INDEX_CONTRACTS)( + '$index is built on the function $operator inlines to', + ({ index, operator, inlinesTo }) => { + const body = functionBody(sql, operator) + + // The operator really does lower to this function — if the bundle + // changes shape, this fails here rather than silently passing the + // schema check below against a stale assumption. + expect( + eqlCalls(body), + `${operator} no longer calls eql_v3.${inlinesTo}`, + ).toContain(inlinesTo) + + expect( + eqlCalls(indexes.get(index)), + `${index} must index eql_v3.${inlinesTo}(...) — the expression the ` + + 'operator inlines to — or the planner can never match it', + ).toEqual([inlinesTo]) + }, + ) it('the operator wrappers are inlinable (LANGUAGE sql IMMUTABLE STRICT)', () => { for (const { operator } of INDEX_CONTRACTS) { diff --git a/scripts/__tests__/cargo-publish-opt-out.test.mjs b/scripts/__tests__/cargo-publish-opt-out.test.mjs index 7df256be5..77a8b446d 100644 --- a/scripts/__tests__/cargo-publish-opt-out.test.mjs +++ b/scripts/__tests__/cargo-publish-opt-out.test.mjs @@ -78,37 +78,36 @@ function workspaceMembers(WORKSPACE) { .sort() } -describe.each(WORKSPACES)('cargo publish opt-out ($root)', ({ - root, - publishable, - expects, -}) => { - const workspace = join(REPO_ROOT, root) - const members = workspaceMembers(workspace) +describe.each(WORKSPACES)( + 'cargo publish opt-out ($root)', + ({ root, publishable, expects }) => { + const workspace = join(REPO_ROOT, root) + const members = workspaceMembers(workspace) - // The guard on the scan: a discovery test that enumerates nothing passes - // while checking nothing. - it('finds the workspace members it means to check', () => { - expect(members).toContain(expects) - }) - - // The guard on the allowlist: an entry naming a member that no longer - // exists is an exemption excusing nothing, and it would go on reading as a - // deliberate decision. - it('allowlists only real members', () => { - expect([...publishable].filter((name) => !members.includes(name))).toEqual( - [], - ) - }) + // The guard on the scan: a discovery test that enumerates nothing passes + // while checking nothing. + it('finds the workspace members it means to check', () => { + expect(members).toContain(expects) + }) - for (const member of members) { - it(`${member} declares publish = false unless allowlisted`, () => { - if (publishable.has(member)) return - const manifest = readFileSync( - join(workspace, member, 'Cargo.toml'), - 'utf8', - ) - expect(manifest).toMatch(/^publish = false$/m) + // The guard on the allowlist: an entry naming a member that no longer + // exists is an exemption excusing nothing, and it would go on reading as a + // deliberate decision. + it('allowlists only real members', () => { + expect( + [...publishable].filter((name) => !members.includes(name)), + ).toEqual([]) }) - } -}) + + for (const member of members) { + it(`${member} declares publish = false unless allowlisted`, () => { + if (publishable.has(member)) return + const manifest = readFileSync( + join(workspace, member, 'Cargo.toml'), + 'utf8', + ) + expect(manifest).toMatch(/^publish = false$/m) + }) + } + }, +) diff --git a/scripts/__tests__/cli-vitest-alias.test.mjs b/scripts/__tests__/cli-vitest-alias.test.mjs index 5d93db5e7..0c12c0055 100644 --- a/scripts/__tests__/cli-vitest-alias.test.mjs +++ b/scripts/__tests__/cli-vitest-alias.test.mjs @@ -57,9 +57,10 @@ describe('packages/cli vitest alias map', () => { ) }) - it.each( - targets, - )('%s resolves to a file that exists', (_specifier, target) => { - expect(existsSync(target), `missing alias target: ${target}`).toBe(true) - }) + it.each(targets)( + '%s resolves to a file that exists', + (_specifier, target) => { + expect(existsSync(target), `missing alias target: ${target}`).toBe(true) + }, + ) }) diff --git a/scripts/__tests__/frozen-publisher-docs.test.mjs b/scripts/__tests__/frozen-publisher-docs.test.mjs index 556701cdc..7d9c59d28 100644 --- a/scripts/__tests__/frozen-publisher-docs.test.mjs +++ b/scripts/__tests__/frozen-publisher-docs.test.mjs @@ -186,19 +186,19 @@ describe('frozen-publisher docs track the map', () => { expect(FROZEN_PUBLISHERS.size).toBeGreaterThan(0) }) - it.each(DOCS)('$file documents the eql freeze iff the map carries it', ({ - file, - instruction, - }) => { - expect( - satisfies(instruction, read(file)), - FROZEN_PUBLISHERS.has(EQL) - ? `${file} no longer tells an agent about the ${EQL} freeze, but ` + - 'FROZEN_PUBLISHERS still carries it.' - : `${EQL} has left FROZEN_PUBLISHERS (Phase-5 cutover), so ${file} ` + - 'must stop instructing agents about the freeze.', - ).toBe(FROZEN_PUBLISHERS.has(EQL)) - }) + it.each(DOCS)( + '$file documents the eql freeze iff the map carries it', + ({ file, instruction }) => { + expect( + satisfies(instruction, read(file)), + FROZEN_PUBLISHERS.has(EQL) + ? `${file} no longer tells an agent about the ${EQL} freeze, but ` + + 'FROZEN_PUBLISHERS still carries it.' + : `${EQL} has left FROZEN_PUBLISHERS (Phase-5 cutover), so ${file} ` + + 'must stop instructing agents about the freeze.', + ).toBe(FROZEN_PUBLISHERS.has(EQL)) + }, + ) it.each(DOCS)('$file freezes only what the map freezes', ({ file }) => { const claimed = [...new Set(foreignPublishClaims(read(file)))] diff --git a/scripts/__tests__/lint-no-eql-registry-pins.test.mjs b/scripts/__tests__/lint-no-eql-registry-pins.test.mjs index 1e0abd7aa..e59ee499c 100644 --- a/scripts/__tests__/lint-no-eql-registry-pins.test.mjs +++ b/scripts/__tests__/lint-no-eql-registry-pins.test.mjs @@ -376,15 +376,14 @@ describe('npm: what counts as in-tree', () => { ).toMatchObject({ inTree: false, form: 'version' }) }) - it.each([ - 'devDependencies', - 'peerDependencies', - 'optionalDependencies', - ])('reads %s', (table) => { - expect(pkg({ [table]: { '@cipherstash/eql': '3.0.2' } })[0].table).toBe( - table, - ) - }) + it.each(['devDependencies', 'peerDependencies', 'optionalDependencies'])( + 'reads %s', + (table) => { + expect(pkg({ [table]: { '@cipherstash/eql': '3.0.2' } })[0].table).toBe( + table, + ) + }, + ) it('reads resolutions and both overrides spellings', () => { // The quietest way back to a registry: every `workspace:^` in the tree diff --git a/scripts/__tests__/neon-dist-crate-name.test.mjs b/scripts/__tests__/neon-dist-crate-name.test.mjs index f544c25d5..b944ebdbe 100644 --- a/scripts/__tests__/neon-dist-crate-name.test.mjs +++ b/scripts/__tests__/neon-dist-crate-name.test.mjs @@ -115,16 +115,15 @@ describe('neon dist through pnpm exec', () => { expect(invocations.length).toBeGreaterThan(0) }) - it.each( - invocations, - )('$file › $jobId › $name › call $nth passes the crate name', ({ - command, - }) => { - expect( - crateNameFlag(command), - 'neon dist needs -n; $npm_package_name is unset under pnpm exec', - ).toBe(crateName()) - }) + it.each(invocations)( + '$file › $jobId › $name › call $nth passes the crate name', + ({ command }) => { + expect( + crateNameFlag(command), + 'neon dist needs -n; $npm_package_name is unset under pnpm exec', + ).toBe(crateName()) + }, + ) // The scanner's own coverage. Both of these describe a workflow that does not // exist yet, so nothing above would notice if the splitting regressed to a diff --git a/scripts/__tests__/turbo-skills-inputs.test.mjs b/scripts/__tests__/turbo-skills-inputs.test.mjs index a69d2edea..a7c0998e1 100644 --- a/scripts/__tests__/turbo-skills-inputs.test.mjs +++ b/scripts/__tests__/turbo-skills-inputs.test.mjs @@ -52,25 +52,26 @@ describe('turbo build inputs cover the skills directory', () => { ) }) - it.each( - packagesCopyingSkills().map((name) => [name]), - )('%s#build declares skills as an input', (name) => { - const task = turbo.tasks[`${name}#build`] + it.each(packagesCopyingSkills().map((name) => [name]))( + '%s#build declares skills as an input', + (name) => { + const task = turbo.tasks[`${name}#build`] - expect( - task, - `turbo.json has no "${name}#build" task, so it inherits the generic ` + - '`build` inputs, which do not include the repo-root skills/ directory', - ).toBeDefined() + expect( + task, + `turbo.json has no "${name}#build" task, so it inherits the generic ` + + '`build` inputs, which do not include the repo-root skills/ directory', + ).toBeDefined() - const inputs = task.inputs ?? [] - expect( - inputs.some((i) => i.includes('skills')), - `"${name}#build".inputs must name the repo-root skills/ directory ` + - '(e.g. "$TURBO_ROOT$/skills/**") or a skills-only edit will not ' + - 'invalidate the build, and `outputs: ["dist/**"]` will restore a stale copy', - ).toBe(true) - }) + const inputs = task.inputs ?? [] + expect( + inputs.some((i) => i.includes('skills')), + `"${name}#build".inputs must name the repo-root skills/ directory ` + + '(e.g. "$TURBO_ROOT$/skills/**") or a skills-only edit will not ' + + 'invalidate the build, and `outputs: ["dist/**"]` will restore a stale copy', + ).toBe(true) + }, + ) it('keeps the generic build outputs on the overrides', () => { // An override replaces the generic task wholesale — dropping `outputs` diff --git a/scripts/__tests__/vitest-shared-alias.test.mjs b/scripts/__tests__/vitest-shared-alias.test.mjs index 771d68f8a..17892e25b 100644 --- a/scripts/__tests__/vitest-shared-alias.test.mjs +++ b/scripts/__tests__/vitest-shared-alias.test.mjs @@ -26,9 +26,10 @@ describe('vitest.shared.ts alias targets exist on disk', () => { expect(existsSync(target), `missing alias target: ${target}`).toBe(true) }) - it.each( - targets(stackSourceAlias), - )('stackSourceAlias %s', (_specifier, target) => { - expect(existsSync(target), `missing alias target: ${target}`).toBe(true) - }) + it.each(targets(stackSourceAlias))( + 'stackSourceAlias %s', + (_specifier, target) => { + expect(existsSync(target), `missing alias target: ${target}`).toBe(true) + }, + ) })