diff --git a/.changeset/stack-cjs-uuid-and-column-builders.md b/.changeset/stack-cjs-uuid-and-column-builders.md new file mode 100644 index 00000000..2d2ed535 --- /dev/null +++ b/.changeset/stack-cjs-uuid-and-column-builders.md @@ -0,0 +1,7 @@ +--- +"@cipherstash/stack": minor +--- + +Fix CJS consumers crashing with `Must use import to load ES Module: .../uuid/dist-node/index.js`. The `uuid` package is pure ESM and has no CJS entry point, so the CJS build of `@cipherstash/stack` could not `require()` it at runtime. `uuid` is now bundled into the CJS output (the ESM build is unchanged). + +Expose `EncryptedTable.columnBuilders` as a public, read-only field so consumers can iterate the typed column-builder map of an encrypted table without reaching into the built `TableDefinition` (`schema.build().columns`) or the private internal. diff --git a/packages/stack/__tests__/cjs-require.test.ts b/packages/stack/__tests__/cjs-require.test.ts new file mode 100644 index 00000000..74b273a7 --- /dev/null +++ b/packages/stack/__tests__/cjs-require.test.ts @@ -0,0 +1,148 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +// Regression test for CJS consumers. `uuid` is pure ESM, so externalizing it +// in the CJS build causes downstream apps to crash at runtime with +// `ERR_REQUIRE_ESM: Must use import to load ES Module: .../uuid/dist-node/index.js`. +// This file proves the published CJS bundles (a) don't contain an +// unresolved `require("uuid")` and (b) can be loaded from a real Node CJS +// process. See packages/stack/tsup.config.ts (noExternal: ['uuid']). + +const packageRoot = path.resolve(__dirname, '..') +const distDir = path.join(packageRoot, 'dist') +const srcDir = path.join(packageRoot, 'src') + +// All `.cjs` files in dist/ that aren't tsup's internal shared chunks +// (chunk-XXXXXX.cjs). Discovered after build so the test automatically +// covers any new entry point added to tsup.config.ts. +function findCjsEntries(): string[] { + const entries: string[] = [] + const stack: string[] = [distDir] + while (stack.length > 0) { + const current = stack.pop() as string + for (const name of readdirSync(current)) { + const full = path.join(current, name) + const stat = statSync(full) + if (stat.isDirectory()) { + stack.push(full) + } else if ( + name.endsWith('.cjs') && + !name.endsWith('.cjs.map') && + !/^chunk-[A-Z0-9]+\.cjs$/.test(name) + ) { + entries.push(path.relative(packageRoot, full)) + } + } + } + return entries.sort() +} + +// ESM-only packages we depend on. If any of these ever ends up as a runtime +// `require("...")` in the CJS bundle, downstream apps crash with +// ERR_REQUIRE_ESM. Add new ESM-only deps here as we adopt them. +const ESM_ONLY_DEPENDENCIES = ['uuid'] + +function newestMtime(dir: string): number { + let newest = 0 + const stack: string[] = [dir] + while (stack.length > 0) { + const current = stack.pop() as string + const stat = statSync(current) + if (stat.isDirectory()) { + for (const entry of readdirSync(current)) { + stack.push(path.join(current, entry)) + } + } else if (stat.mtimeMs > newest) { + newest = stat.mtimeMs + } + } + return newest +} + +function distIsFresh(): boolean { + if (!existsSync(distDir)) return false + const tsupConfigMtime = statSync( + path.join(packageRoot, 'tsup.config.ts'), + ).mtimeMs + const distMtime = newestMtime(distDir) + return distMtime >= newestMtime(srcDir) && distMtime >= tsupConfigMtime +} + +if (!distIsFresh()) { + execFileSync('pnpm', ['run', 'build'], { + cwd: packageRoot, + stdio: 'inherit', + }) +} + +const cjsEntries = findCjsEntries() + +describe('CJS consumers can require the built bundles', () => { + it('discovers at least the public entry points', () => { + expect(cjsEntries).toContain('dist/index.cjs') + expect(cjsEntries).toContain('dist/encryption/index.cjs') + }) + + 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]) + } + + 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 + } + throw new Error( + `require("${entry}") failed in a Node CJS process:\n` + + `${e.stderr ?? ''}\n${e.stdout ?? ''}`, + ) + } + }, + ) +}) diff --git a/packages/stack/__tests__/schema-builders.test.ts b/packages/stack/__tests__/schema-builders.test.ts index b74e3a6a..e5e9a649 100644 --- a/packages/stack/__tests__/schema-builders.test.ts +++ b/packages/stack/__tests__/schema-builders.test.ts @@ -237,6 +237,23 @@ describe('schema builders', () => { expect(table.name).toBeInstanceOf(EncryptedColumn) expect(table.age).toBeInstanceOf(EncryptedColumn) }) + + it('exposes columnBuilders as a public, read-only map of the typed builders', () => { + const emailCol = encryptedColumn('email').equality() + const ageCol = encryptedColumn('age').dataType('number').orderAndRange() + const table = encryptedTable('users', { + email: emailCol, + age: ageCol, + }) + + // Consumers should be able to iterate the builders without reaching + // into the built TableDefinition (`table.build().columns`) or a + // private internal. + expect(Object.keys(table.columnBuilders).sort()).toEqual(['age', 'email']) + expect(table.columnBuilders.email).toBe(emailCol) + expect(table.columnBuilders.age).toBe(ageCol) + expect(table.columnBuilders).toBe(table.columnBuilders) + }) }) // ------------------------------------------------------- diff --git a/packages/stack/src/schema/index.ts b/packages/stack/src/schema/index.ts index c87526b6..a8c5873d 100644 --- a/packages/stack/src/schema/index.ts +++ b/packages/stack/src/schema/index.ts @@ -417,7 +417,7 @@ export class EncryptedTable { constructor( public readonly tableName: string, - private readonly columnBuilders: T, + public readonly columnBuilders: T, ) {} /** diff --git a/packages/stack/tsup.config.ts b/packages/stack/tsup.config.ts index 2bcb6f2c..a3f888a3 100644 --- a/packages/stack/tsup.config.ts +++ b/packages/stack/tsup.config.ts @@ -22,6 +22,6 @@ export default defineConfig([ target: 'es2022', tsconfig: './tsconfig.json', external: ['drizzle-orm', '@supabase/supabase-js'], - noExternal: ['evlog'], + noExternal: ['evlog', 'uuid'], }, ])