Repository navigation
Closure body () => new <ImportedClass>(...) inside a compilePackages module fails to link or silently collapses to the class id #588
Description
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);
}// package.json
{ "perry": { "compilePackages": ["repro-pkg2"] } }// 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(...)→ stilltypeof _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 }→ STILLtypeof _dialect === 'number', same1.26e-321value.
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
PgDialectitself (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
_dialectas 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.)
Activity
fix(hir): closes PerryTS#588 — `export { foo }` separately for functi…