Repro
Same-module
function sql(strings: any, ...params: any[]) {
return { kind: 'sql', s: strings };
}
((sql2: any) => {
sql2.identifier = function (v: any) {
return { kind: 'name', v };
};
})(sql);
console.log('sql.identifier:', (sql as any).identifier);
console.log('sql.identifier("foo"):', (sql as any).identifier("foo"));
Bun (correct):
sql.identifier: [Function]
sql.identifier("foo"): { kind: "name", v: "foo" }
Perry (bug):
sql.identifier: undefined
sql.identifier("foo"): [object Object]
The property read (sql.identifier) returns undefined, but the call (sql.identifier("foo")) somehow produces an object that prints as [object Object]. Both perry behaviors are wrong, and they're inconsistent with each other.
Cross-module
Move the function + IIFE into a separate compilePackages module, import the function:
// node_modules/repro-pkg2/static_iife.js
export function sql(strings, ...params) { return { kind: 'sql', s: strings }; }
((sql2) => {
sql2.identifier = function (v) { return { kind: 'name', v }; };
})(sql);
// probe_fn_static_xmod.ts
import { sql } from 'repro-pkg2/static_iife.js';
console.log((sql as any).identifier("foo"));
Perry:
sql.identifier: undefined
sql.identifier("foo"): 0 ← raw NaN-box of class id / nothing
Cross-module makes it worse: the call returns 0 (a raw integer, possibly a class id reinterpreted as f64) instead of an object.
What this pattern is
This is how TypeScript compiles namespace declarations. tsc translates:
function sql(...) { ... }
namespace sql {
export function identifier(v: any) { ... }
export function param(v: any, encoder: any) { ... }
export function raw(s: string) { ... }
// ...
}
into:
function sql(...) { ... }
((sql2) => {
function identifier(v) { ... }
sql2.identifier = identifier;
function param2(v, encoder) { ... }
sql2.param = param2;
// ...
})(sql || (sql = {}));
Every TS library that exports a "callable with static methods" surface (drizzle's sql, lodash chains, jQuery-style libs, custom validators with helper methods) ships this exact shape in its compiled JS.
Why it matters for #488
drizzle-orm's sql tag function is exactly this pattern (drizzle-orm/sql/sql.js:283-329):
function sql(strings, ...params) { ... }
((sql2) => {
sql2.empty = empty;
sql2.fromList = fromList;
sql2.raw = raw;
sql2.join = join;
sql2.identifier = identifier;
sql2.placeholder = placeholder2;
sql2.param = param2;
})(sql || (sql = {}));
drizzle uses these throughout: sql.identifier(name), sql.raw("..."), sql.join(chunks), sql.param(value, encoder). Each call returns 0 instead of the expected SQL chunk, so colEntries.map(...) produces [0, 0] instead of [Name("id"), Name("name")], and the eventual SQL template contains numbers instead of chunks.
Filed after #608 landed and the next acceptance run surfaced this as the next layer.
Where I'd start digging
crates/perry-hir/src/lower/... — the IIFE call ((sql2) => { sql2.identifier = ...; })(sql) lowers to:
- Allocate a closure for the arrow
- Call it with
sql as the arg
- Inside the body,
sql2.identifier = function(...) assigns a property
Step 3 is where it breaks: assigning a property onto a parameter that holds a function value. perry's HIR/codegen probably treats sql2 as a function (typed as Function) and silently drops property assignments, since Function isn't a "real" object in perry's static type system.
Two paths:
- Treat function values as plain objects for the purpose of property reads/writes — same as Node/Bun. Functions ARE objects in JS; perry should track per-function side-table state for any properties added at runtime.
- At least for the IIFE-namespace pattern (recognizable shape:
((x) => { x.foo = ...; ... })(fn)), lift the assignments to a side-table keyed by the function pointer, and route fn.foo lookups through that table.
Option 1 is cleaner and matches spec; Option 2 is a narrower fix but covers the dominant real-world use.
Workaround
Patch user-side libraries to use a wrapper class instead of a function with static methods. Not realistic for drizzle without forking.
Refs
Blocks #488. Surfaced from acceptance run on v0.5.712 after #608 landed.
Repro
Same-module
Bun (correct):
Perry (bug):
The property read (
sql.identifier) returnsundefined, but the call (sql.identifier("foo")) somehow produces an object that prints as[object Object]. Both perry behaviors are wrong, and they're inconsistent with each other.Cross-module
Move the function + IIFE into a separate
compilePackagesmodule, import the function:Perry:
Cross-module makes it worse: the call returns
0(a raw integer, possibly a class id reinterpreted as f64) instead of an object.What this pattern is
This is how TypeScript compiles namespace declarations.
tsctranslates:into:
Every TS library that exports a "callable with static methods" surface (drizzle's
sql, lodash chains, jQuery-style libs, custom validators with helper methods) ships this exact shape in its compiled JS.Why it matters for #488
drizzle-orm's
sqltag function is exactly this pattern (drizzle-orm/sql/sql.js:283-329):drizzle uses these throughout:
sql.identifier(name),sql.raw("..."),sql.join(chunks),sql.param(value, encoder). Each call returns0instead of the expected SQL chunk, socolEntries.map(...)produces[0, 0]instead of[Name("id"), Name("name")], and the eventual SQL template contains numbers instead of chunks.Filed after #608 landed and the next acceptance run surfaced this as the next layer.
Where I'd start digging
crates/perry-hir/src/lower/...— the IIFE call((sql2) => { sql2.identifier = ...; })(sql)lowers to:sqlas the argsql2.identifier = function(...)assigns a propertyStep 3 is where it breaks: assigning a property onto a parameter that holds a function value. perry's HIR/codegen probably treats
sql2as a function (typed as Function) and silently drops property assignments, since Function isn't a "real" object in perry's static type system.Two paths:
((x) => { x.foo = ...; ... })(fn)), lift the assignments to a side-table keyed by the function pointer, and routefn.foolookups through that table.Option 1 is cleaner and matches spec; Option 2 is a narrower fix but covers the dominant real-world use.
Workaround
Patch user-side libraries to use a wrapper class instead of a function with static methods. Not realistic for drizzle without forking.
Refs
Blocks #488. Surfaced from acceptance run on v0.5.712 after #608 landed.