Repro
Minimal package under node_modules/repro-pkg2/:
// node_modules/repro-pkg2/dialect.js
export class PgDialect {
constructor(opts) { this.casing = opts ? opts.casing : undefined; }
}
// node_modules/repro-pkg2/db.js
export class PgDatabase {
constructor(dialect, session, schema) {
this.dialect = dialect;
this.session = session;
this.schema = schema;
}
}
// node_modules/repro-pkg2/session.js
export class PgRemoteSession {
constructor(callback, dialect, schema, opts) {
this.callback = callback;
this.dialect = dialect;
}
}
// node_modules/repro-pkg2/index.js
import { PgDatabase } from './db.js';
import { PgDialect } from './dialect.js';
import { PgRemoteSession } from './session.js';
class PgRemoteDatabase extends PgDatabase {
static x = "PgRemoteDatabase";
}
export function drizzle(
callback,
config = {},
_dialect = () => new PgDialect({ casing: config.casing })
) {
const dialect = _dialect();
const session = new PgRemoteSession(callback, dialect, undefined, {});
return new PgRemoteDatabase(dialect, session, undefined);
}
// probe_repro2.ts
import { drizzle } from 'repro-pkg2';
const db = drizzle(async (q, params, _m) => ({ rows: [] }));
console.log('db.dialect=', db.dialect);
Perry compile:
Undefined symbols for architecture arm64:
"_node_modules_repro_pkg2_dialect_js__PgDialect_constructor", referenced from:
_perry_closure_node_modules_repro_pkg2_index_js__1 in node_modules_repro_pkg2_index_js.o
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1
Bun output (correct):
[drizzle] _dialect typeof= function
[drizzle] dialect= PgDialect { casing: undefined } truthy= true
db.dialect= PgDialect { casing: undefined }
What I think is going on
The default-param arrow () => new PgDialect(...) becomes a closure (_perry_closure_..._index_js__1). Its emitted body calls _node_modules_repro_pkg2_dialect_js__PgDialect_constructor, but that symbol isn't being declared / emitted on the source-module side under compilePackages. Compare to a top-level new PgDialect(...) in the same module which links fine — the constructor emit only fails when the call site is inside a closure body.
Different but related symptom in drizzle-orm
In a real drizzle-orm 0.45.2 compile (drizzle-orm/pg-proxy/driver.js has the exact same shape — function drizzle(callback, config = {}, _dialect = () => new PgDialect({ casing: config.casing }))), the link succeeds, but at runtime typeof _dialect === 'number' and its value is 1.26e-321, which is f64::from_bits(269) where 269 is PgDialect's class id. Calling _dialect() returns undefined. As if the closure was replaced by the raw class-id-as-f64 of the class whose constructor it was supposed to call.
Diagnostic edits I tried in drizzle's compiled file didn't change the value:
- Move the assignment out of the default param:
if (_dialect === undefined) _dialect = () => new PgDialect(...) → still typeof _dialect === 'number'.
- Replace with a regular function expression:
function() { return new PgDialect(...) } — same.
- Replace with a closure that doesn't reference PgDialect at all:
function() { return 42 } → STILL typeof _dialect === 'number', same 1.26e-321 value.
Last point is the smoking gun: regardless of what we assign to _dialect, it always reads back as f64::from_bits(269) (PgDialect's class id). So storage for _dialect is being aliased to PgDialect — assignments to _dialect are no-op'd because perry's HIR / codegen statically pre-bound it to PgDialect.
I suspect a default-param desugar pass that:
- Sees
_dialect = () => new PgDialect(...)
- "Constant-folds" the arrow to a reference to
PgDialect itself (treating arrow-returning-new C(...) as alias-to-C)
- Stores
PgDialect's class id (NaN-unboxed f64 raw bits) into _dialect's slot at function entry
- Marks
_dialect as static-bound, so subsequent assignments (_dialect = anything else) get DCE'd
But I haven't located the offending pass.
Where I'd start digging
crates/perry-hir/src/lower_decl.rs — build_default_param_stmts and the function-decl param-default desugar paths. Especially anything that pattern-matches () => new <Class>(...) as a special shape. Compare to lambda-body lowering for closures inside compilePackages packages.
crates/perry-codegen/src/... — closure emit when the body is new <ImportedClass>(...) and the source class lives in a sibling .js file under the same compilePackages package.
The link-failure form (_node_modules_repro_pkg2_dialect_js__PgDialect_constructor) is the easier entry point — it's a missing extern declaration that should be straightforward to trace from the closure's emitted IR back to the symbol it references.
Why it matters for #488
Blocks the drizzle + @perryts/postgres acceptance test (#488). After #585 (cross-module instanceof) and #586 (thenable assimilation) landed in v0.5.692, drizzle's db.insert(...) / db.select(...) paths now reach the dialect-call layer, where this bug surfaces because db.dialect is undefined.
Workaround
Patch drizzle to construct the dialect directly (no closure):
// in drizzle-orm/pg-proxy/driver.js
function drizzle(callback, config = {}) {
const dialect = new PgDialect({ casing: config.casing });
// ...
}
Confirmed working with this patch — db.dialect is set, insert proceeds past the dialect lookup. (Then runs into a separate hang in the actual query-execute path that I haven't dug into yet — likely the next blocker but distinct.)
Repro
Minimal package under
node_modules/repro-pkg2/:Perry compile:
Bun output (correct):
What I think is going on
The default-param arrow
() => new PgDialect(...)becomes a closure (_perry_closure_..._index_js__1). Its emitted body calls_node_modules_repro_pkg2_dialect_js__PgDialect_constructor, but that symbol isn't being declared / emitted on the source-module side undercompilePackages. Compare to a top-levelnew PgDialect(...)in the same module which links fine — the constructor emit only fails when the call site is inside a closure body.Different but related symptom in drizzle-orm
In a real drizzle-orm 0.45.2 compile (
drizzle-orm/pg-proxy/driver.jshas the exact same shape —function drizzle(callback, config = {}, _dialect = () => new PgDialect({ casing: config.casing }))), the link succeeds, but at runtimetypeof _dialect === 'number'and its value is1.26e-321, which isf64::from_bits(269)where 269 isPgDialect's class id. Calling_dialect()returns undefined. As if the closure was replaced by the raw class-id-as-f64 of the class whose constructor it was supposed to call.Diagnostic edits I tried in drizzle's compiled file didn't change the value:
if (_dialect === undefined) _dialect = () => new PgDialect(...)→ stilltypeof _dialect === 'number'.function() { return new PgDialect(...) }— same.function() { return 42 }→ STILLtypeof _dialect === 'number', same1.26e-321value.Last point is the smoking gun: regardless of what we assign to
_dialect, it always reads back asf64::from_bits(269)(PgDialect's class id). So storage for_dialectis being aliased to PgDialect — assignments to_dialectare no-op'd because perry's HIR / codegen statically pre-bound it to PgDialect.I suspect a default-param desugar pass that:
_dialect = () => new PgDialect(...)PgDialectitself (treating arrow-returning-new C(...)as alias-to-C)PgDialect's class id (NaN-unboxed f64 raw bits) into_dialect's slot at function entry_dialectas static-bound, so subsequent assignments (_dialect = anything else) get DCE'dBut I haven't located the offending pass.
Where I'd start digging
crates/perry-hir/src/lower_decl.rs—build_default_param_stmtsand the function-decl param-default desugar paths. Especially anything that pattern-matches() => new <Class>(...)as a special shape. Compare to lambda-body lowering for closures inside compilePackages packages.crates/perry-codegen/src/...— closure emit when the body isnew <ImportedClass>(...)and the source class lives in a sibling.jsfile under the same compilePackages package.The link-failure form (
_node_modules_repro_pkg2_dialect_js__PgDialect_constructor) is the easier entry point — it's a missing extern declaration that should be straightforward to trace from the closure's emitted IR back to the symbol it references.Why it matters for #488
Blocks the drizzle + @perryts/postgres acceptance test (#488). After #585 (cross-module instanceof) and #586 (thenable assimilation) landed in v0.5.692, drizzle's
db.insert(...)/db.select(...)paths now reach the dialect-call layer, where this bug surfaces becausedb.dialectis undefined.Workaround
Patch drizzle to construct the dialect directly (no closure):
Confirmed working with this patch —
db.dialectis set, insert proceeds past the dialect lookup. (Then runs into a separate hang in the actual query-execute path that I haven't dug into yet — likely the next blocker but distinct.)