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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/stack-cjs-uuid-and-column-builders.md
Original file line number Diff line number Diff line change
@@ -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).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add something to tests/CI to validate this and prevent regressions?


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.
148 changes: 148 additions & 0 deletions packages/stack/__tests__/cjs-require.test.ts
Original file line number Diff line number Diff line change
@@ -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',
})
}
Comment on lines +73 to +78
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Move build execution into a beforeAll() hook.

Running execFileSync at module-load time (outside any test lifecycle hook) can cause race conditions when tests run in parallel and triggers even when executing unrelated test files in the workspace.

🔧 Proposed fix to move build into test lifecycle

Move the freshness check and build inside the test suite:

-if (!distIsFresh()) {
-  execFileSync('pnpm', ['run', 'build'], {
-    cwd: packageRoot,
-    stdio: 'inherit',
-  })
-}
-
-const cjsEntries = findCjsEntries()
-
 describe('CJS consumers can require the built bundles', () => {
+  let cjsEntries: string[]
+
+  beforeAll(() => {
+    if (!distIsFresh()) {
+      execFileSync('pnpm', ['run', 'build'], {
+        cwd: packageRoot,
+        stdio: 'inherit',
+      })
+    }
+    cjsEntries = findCjsEntries()
+  })
+
   it('discovers at least the public entry points', () => {

Then remove the const cjsEntries = findCjsEntries() line from the top level (line 80).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!distIsFresh()) {
execFileSync('pnpm', ['run', 'build'], {
cwd: packageRoot,
stdio: 'inherit',
})
}
describe('CJS consumers can require the built bundles', () => {
let cjsEntries: string[]
beforeAll(() => {
if (!distIsFresh()) {
execFileSync('pnpm', ['run', 'build'], {
cwd: packageRoot,
stdio: 'inherit',
})
}
cjsEntries = findCjsEntries()
})
it('discovers at least the public entry points', () => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/__tests__/cjs-require.test.ts` around lines 73 - 78, The
top-level build run using execFileSync guarded by distIsFresh() should be moved
into a test lifecycle hook: wrap the distIsFresh() check and
execFileSync('pnpm', ['run', 'build'], { cwd: packageRoot, stdio: 'inherit' })
inside a beforeAll() in the test suite so builds only run during test setup (and
avoid running at module-load time), and remove the top-level const cjsEntries =
findCjsEntries() so findCjsEntries() is invoked after the build inside the
beforeAll or in tests as needed; reference distIsFresh(), execFileSync,
packageRoot, findCjsEntries(), cjsEntries, and beforeAll() when making the
change.


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<string>()
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 ?? ''}`,
)
}
},
)
})
17 changes: 17 additions & 0 deletions packages/stack/__tests__/schema-builders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

// -------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ export class EncryptedTable<T extends EncryptedTableColumn> {

constructor(
public readonly tableName: string,
private readonly columnBuilders: T,
public readonly columnBuilders: T,
) {}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ export default defineConfig([
target: 'es2022',
tsconfig: './tsconfig.json',
external: ['drizzle-orm', '@supabase/supabase-js'],
noExternal: ['evlog'],
noExternal: ['evlog', 'uuid'],
},
])
Loading