import { Parser, ParseError, FunctionInputError, type FunctionInputResult } from "./parser.ts";
import {
generate,
generateUpdateFilter,
generateWithCtx,
tryRewriteMutatorCall,
CodegenError,
EMPTY_CTX,
UnknownIdentifierError,
withBindings,
isOpaqueBsonValue,
type GenerateCtx,
} from "./codegen.ts";
import { isPipelineAst, generatePipeline, generateImplicitPipeline } from "./pipeline.ts";
import { translateMatchBody, mergeTranslatedQuery } from "./match-translation.ts";
import { lookupStage } from "./stages.ts";
import { LexError } from "./lexer.ts";
import { containsLookupCall } from "./lookup-translation.ts";
import { containsUnionPush, detectUnionPush } from "./union-translation.ts";
import { containsOutAssign } from "./out-translation.ts";
import { isSystemStageCall } from "./system-stage-translation.ts";
import type { Program, Expr, Pipeline } from "./ast.ts";
// Re-exported so users can `import { FunctionInputError } from "@koresar/jsmql"`
// even though the class itself lives in parser.ts (where it is thrown).
export { FunctionInputError };
export type ValidationError = { message: string; pos: number; code: "SYNTAX_ERROR" | "CODEGEN_ERROR" };
export type ValidationResult = { valid: boolean; errors: ValidationError[] };
// Raised by the template-tag invocation of `jsmql` when an interpolated value
// cannot be safely embedded as a JSON literal. Covers the three cases
// JSON.stringify mishandles: values it returns `undefined` for (functions,
// Symbols, plain `undefined`), the non-finite numbers it silently coerces to
// `null` (NaN/Infinity/-Infinity), and values it throws on (BigInt, circular
// references). Surfacing these as a dedicated error means the caller learns
// about the problem at interpolation time instead of getting a confusing parse
// error or silent data loss downstream.
//
// `slot` is the 1-based template-tag interpolation index for the template-tag
// path. `key` is the param-binding name for the `jsmql.compile()` call path.
// Exactly one of the two is set depending on which surface raised the error.
export class JsmqlInterpolationError extends Error {
readonly slot: number;
readonly key?: string;
constructor(message: string, slot: number, key?: string) {
super(message);
this.name = "JsmqlInterpolationError";
this.slot = slot;
this.key = key;
}
}
// Accept any callable shape: the canonical idiom is `($) => …` (one
// parameter named `$`, used as the document-context placeholder), but `()
// => …` and `(doc) => …` are equally valid — the parameter list is
// stripped at extraction time. `any` for the `$` parameter lets users
// write unannotated `$` and still get IDE autocomplete (`$.foo.bar`)
// without `noImplicitAny` complaining.
//
// The optional second parameter is types-only: it gives users a destructure
// site for escape-hatch operators (`($, { $dateDiff }) => $dateDiff(…)`) so
// IDEs don't flag `$dateDiff` as an unknown identifier. The parameter list
// is stripped before the parser runs, so this never reaches the runtime.
export type JsmqlOps = Record<`$${string}`, (...args: any[]) => any>;
type JsmqlFn = ($: any, ops: JsmqlOps) => unknown;
export type JsmqlInput = string | JsmqlFn;
// Function-form parameter binding (used by `jsmql.compile`). The arrow's
// first slot is a destructure pattern that names the bindings; the same names
// must appear as keys on the params object passed at call time. The `$` and
// ops-hint slots remain optional and order-disambiguated by shape — see
// `Parser.parseParameterList` for the rule and docs/LANGUAGE.md for the
// user-facing reference.
//
// `$` and `ops` are declared as required (not `?:`) so that users who
// explicitly annotate them with a destructure type — `({ $match }: JsmqlOps)`
// — get clean type inference. TypeScript already lets users omit trailing
// parameters when assigning to a function type, so `(params) => …` and
// `(params, $) => …` still work; the parser also strips the parameter list
// at extraction time, so the runtime never sees any of these declarations.
type JsmqlCompileFn
= (params: P, $: any, ops: JsmqlOps) => unknown;
// `jsmql()` returns either a single compiled MQL expression object, or — when
// the input is a top-level aggregation pipeline `[ { $stage: ... }, ... ]` —
// the corresponding stage array. The union is widened from the historical
// `object` to make pipeline mode visible in the type. Both runtime values are
// objects, so existing call sites keep type-checking.
export type JsmqlOutput = object | object[];
// `jsmql` has three call shapes — string, arrow function, and template tag —
// dispatched on the first argument. The template-tag form is detected by the
// standard "frozen `TemplateStringsArray`" discriminator: an Array whose `raw`
// property is also an Array. The current `JsmqlInput` excludes arrays, so this
// check collides with nothing in the typed surface. Note: if `JsmqlInput` is
// ever widened to include plain pipeline arrays, the discriminator must still
// run first — `Array.isArray` alone would no longer disambiguate.
function isTemplateStringsArray(x: unknown): x is TemplateStringsArray {
return Array.isArray(x) && Array.isArray((x as { raw?: unknown }).raw);
}
/** An arrow-function input's source text (its `.toString()`), trimmed. */
function fnSource(fn: (...args: never[]) => unknown): string {
return Function.prototype.toString.call(fn).trim();
}
/**
* Parse arrow-function source and run `body` on the `(program, bindings)`
* result, routing any error — from the parse OR from whatever `body` does — through
* `augmentForFunctionInput`, so closure-reference / param-binding errors pick up
* the template-tag hint regardless of which entry point triggered them. The
* one-shot (`jsmql()`) and `validate` paths wrap parse + lower together; `compile`
* uses it parse-only (its lowering happens later, inside the returned closure).
*/
function withFunctionInput(src: string, body: (parsed: FunctionInputResult) => T): T {
try {
return body(new Parser(src).parseFunctionInput());
} catch (err) {
throw augmentForFunctionInput(err);
}
}
/**
* Single shared dispatcher for every public entry point that accepts the three
* call shapes (string, arrow, template tag). The caller supplies its own
* `lower` (Filter for `jsmql()`, expression for `jsmql.expr()`) and an
* `apiName` for error messages — everything else (parsing, function-input
* binding-rejection, function-input error augmentation, type guard) stays in
* one place. Less code = better DX inside the codebase too.
*/
function dispatchInput(
input: JsmqlInput | TemplateStringsArray,
values: unknown[],
apiName: string,
lower: (program: Program, ctx: GenerateCtx) => JsmqlOutput,
): JsmqlOutput {
if (isTemplateStringsArray(input)) {
// Opaque BSON instances (Date, RegExp, Buffer, ObjectId) can't be embedded
// as JSON literals — `JSON.stringify(new Date(...))` yields an ISO string,
// `JSON.stringify(/x/)` yields `"{}"`. Route them through a synthesized
// ParamRef binding instead, so the value reaches MQL output as-is. Plain
// JSON-shaped values still go through `JSON.stringify` (unchanged).
let src = "";
const opaqueBindings = new Map();
for (let i = 0; i < input.length; i++) {
src += input[i];
if (i < values.length) {
src += stringifyInterpolation(values[i], i + 1, opaqueBindings);
}
}
const ctx = opaqueBindings.size > 0 ? withBindings(EMPTY_CTX, opaqueBindings) : EMPTY_CTX;
return lower(new Parser(src).parse(), ctx);
}
if (typeof input === "function") {
// The wrapper covers both parsing AND lowering: `augmentForFunctionInput`
// must also fire when codegen throws an `UnknownIdentifierError` (closure
// ref) mid-lowering, not only when the arrow itself fails to parse.
return withFunctionInput(fnSource(input), ({ program, bindings }) => {
if (bindings.length > 0) {
throw new FunctionInputError(
`${apiName}() in its one-shot form does not accept a parameter-bindings destructure. ` +
"Use `jsmql.compile(fn)(params)` to supply values to a parameterised query, " +
"or remove the destructure pattern from the arrow's first slot.",
);
}
return lower(program, EMPTY_CTX);
});
}
if (typeof input === "string") {
return lower(new Parser(input).parse(), EMPTY_CTX);
}
// Polymorphism widens the runtime input space — without this guard, a
// wrong-typed call (e.g. `jsmql(42)`, `jsmql({})`) would crash deep inside
// the parser with a confusing message. DX priority #1 says vague errors are
// not acceptable, so name the contract here.
const ty = input === null ? "null" : typeof input;
throw new TypeError(`${apiName}() expects a string, an arrow function, or a template literal — got ${ty}.`);
}
function jsmqlDispatch(input: JsmqlInput): JsmqlOutput;
function jsmqlDispatch(strings: TemplateStringsArray, ...values: unknown[]): JsmqlOutput;
function jsmqlDispatch(input: JsmqlInput | TemplateStringsArray, ...values: unknown[]): JsmqlOutput {
return dispatchInput(input, values, "jsmql", lowerWithCtx);
}
/**
* `jsmql.expr(input)` — compile a partial / "unfinished" expression in
* aggregation-expression form, the shape used inside Pipeline stage bodies
* (`$project`, `$addFields`, `$group`, …) and as the second argument to
* `db.coll.updateOne(filter, update)` when the update is a update op.
*
* Differs from `jsmql()` only in the no-`;` bare-expression branch: instead of
* Filter dispatch (`{ $expr: ... }` wrap for non-predicates), the expression
* lowers directly to its aggregation-expression form. `;`-separated input is
* still a Pipeline, update op chains still produce `$set`/`$unset`, and array-
* literal Pipelines still pass through — the three other branches are shared
* with `jsmql()` via `lowerProgram`. Same three input shapes as `jsmql()`.
*/
function exprDispatch(input: JsmqlInput): JsmqlOutput;
function exprDispatch(strings: TemplateStringsArray, ...values: unknown[]): JsmqlOutput;
function exprDispatch(input: JsmqlInput | TemplateStringsArray, ...values: unknown[]): JsmqlOutput {
return dispatchInput(input, values, "jsmql.expr", lowerExprWithCtx);
}
/**
* `jsmql.filter(input)` — compile to a Filter document only.
*
* Same three input shapes as `jsmql()` (string / arrow / template tag), but
* rejects any input that would lower to a Pipeline. Use this when the call
* site is `db.coll.find(filter)` (or `deleteOne` / `countDocuments` / etc.)
* and you want a compile-time guarantee that you won't accidentally hand the
* driver a stage array. `jsmql()` is the polymorphic counterpart — call it
* when the same source string might legitimately produce either shape.
*
* Pipeline-shaped inputs that throw here: `;`-separated statements, update-op
* chains (`$.x = …`, `delete $.x`), top-level stage calls (`$match(...)`),
* single-key stage-object literals (`{ $match: ... }`), and array-literal
* Pipelines (`[{ $stage: ... }, ...]`). Each error names the shape it found
* and suggests the right call (`jsmql.pipeline()`, `jsmql.update()`,
* `jsmql()`).
*/
function filterDispatch(input: JsmqlInput): object;
function filterDispatch(strings: TemplateStringsArray, ...values: unknown[]): object;
function filterDispatch(input: JsmqlInput | TemplateStringsArray, ...values: unknown[]): object {
return dispatchInput(input, values, "jsmql.filter", lowerFilterStrict) as object;
}
/**
* `jsmql.pipeline(input)` — compile to a Pipeline (stage array) only.
*
* Same three input shapes as `jsmql()`, but rejects any bare expression that
* would lower to a Filter document. Use this when the call site is
* `db.coll.aggregate(pipeline)` and you want a compile-time guarantee that
* you won't accidentally hand the driver a single document where it expects
* a stage array. Accepts every shape that already produces a Pipeline through
* `jsmql()`:
*
* - `;`-separated statements
* - a single top-level stage call (`$match(...)`) — auto-wraps to one stage
* - a single stage-object literal (`{ $match: ... }`) — auto-wraps too
* - update-op chains (`$.x = …`) — compile to `$set` / `$unset` stages
* - array-literal Pipelines (`[{ $stage: ... }, ...]`)
*
* A bare expression like `$.age > 18` throws — the error suggests `jsmql.filter()`
* if you wanted a Filter, or wrapping in `$match(...)` to make it a Pipeline.
*/
function pipelineDispatch(input: JsmqlInput): object[];
function pipelineDispatch(strings: TemplateStringsArray, ...values: unknown[]): object[];
function pipelineDispatch(input: JsmqlInput | TemplateStringsArray, ...values: unknown[]): object[] {
return dispatchInput(input, values, "jsmql.pipeline", lowerPipelineStrict) as object[];
}
/**
* `jsmql.update(input)` — compile to an aggregation-pipeline update (the
* second argument to `db.coll.updateOne(filter, update)` /
* `db.coll.updateMany(filter, update)` in its array form, called an
* `UpdateFilter` in the Node.js MongoDB driver's typings).
*
* The shorter public name (`update` rather than `updateFilter`) avoids the
* trap where developers read "filter" as the *query* document — the first
* argument to `updateOne(filter, update)` — and reach for this when they
* meant `jsmql.filter()`. The MongoDB driver type stays `UpdateFilter`;
* we just don't repeat the confusing half of its name in the function.
*
* Output is always a stage array, never the legacy bare-document update form
* — that form silently treats RHS expressions as object literals, which is
* exactly the kind of footgun this entry point exists to avoid. Passing this
* to the driver guarantees that expressions like `$.name.toUpperCase()`
* evaluate server-side instead of landing in the document as the literal
* object `{ $toUpper: "$name" }`.
*
* Accepts the same Pipeline-shaped inputs as `jsmql.pipeline()`, but
* additionally rejects any stage outside MongoDB's update-pipeline whitelist
* (`$addFields`, `$set`, `$project`, `$unset`, `$replaceRoot`, `$replaceWith`).
* If you write `$match` or `$sort` inside an update pipeline, MongoDB rejects
* it at runtime with a generic error; this entry point catches it at compile
* time and names the offending stage.
*/
function updateDispatch(input: JsmqlInput): object[];
function updateDispatch(strings: TemplateStringsArray, ...values: unknown[]): object[];
function updateDispatch(input: JsmqlInput | TemplateStringsArray, ...values: unknown[]): object[] {
return dispatchInput(input, values, "jsmql.update", lowerUpdateStrict) as object[];
}
/**
* Compile a parameterised arrow function to a reusable MQL builder.
*
* The arrow's first slot is a destructure pattern naming the bindings: a
* `Record` whose keys must be supplied as the params
* object at call time. The returned function does no parsing on each
* invocation — it walks the cached AST with the bound values substituted in
* place of `ParamRef` nodes, emitting an inline MQL literal for each.
*
* The MQL output shape matches the template-tag form: each binding value
* appears as a JSON literal, never wrapped in `$let`. This makes parameter
* bindings compose uniformly across expression mode, pipeline mode, and
* sub-pipelines (`$lookup`, `$unionWith`, `$facet`), and avoids `$$name`
* collisions with lambda parameters.
*
* The input may also be a **string** containing the arrow source text — e.g.
* `jsmql.compile("({ minAge }, $) => $.age > minAge")`. This is useful when
* the query is stored externally (config, file, database) and the caller
* still wants the parse-once-bind-many semantics. The destructure pattern is
* the only parameter-declaration mechanism for both call shapes; placeholder
* syntaxes like `${name}` are deliberately not supported (they would break
* the strict-JS-subset rule and collide with real template literals).
*/
function compileFunction
>(fn: JsmqlCompileFn
): (params: P) => JsmqlOutput;
function compileFunction(src: string): (params: Record) => JsmqlOutput;
function compileFunction
>(
input: JsmqlCompileFn
| string,
): (params: P) => JsmqlOutput {
let src: string;
if (typeof input === "function") {
src = fnSource(input);
} else if (typeof input === "string") {
src = input.trim();
} else {
const ty = input === null ? "null" : typeof input;
throw new TypeError(`jsmql.compile() expects an arrow function or a string containing one — got ${ty}.`);
}
// Parse-only: lowering happens later, inside the returned closure (it needs
// the per-call params), so only the parse runs under the function-input augment.
const { program, bindings } = withFunctionInput(src, (r) => r);
return (params: P): JsmqlOutput => {
const resolved = new Map();
for (const b of bindings) {
if (!Object.prototype.hasOwnProperty.call(params, b.key)) {
// The body refers to `b.name`; the user's missing key is `b.key`. When
// aliased, mention both so the user can find either.
const expected = b.key === b.name ? b.key : `${b.key}' (bound to '${b.name}' in the function body)`;
throw new UnknownIdentifierError(expected);
}
const value = (params as Record)[b.key];
validateInterpolatable(value, 0, b.key);
resolved.set(b.name, value);
}
const ctx = withBindings(EMPTY_CTX, resolved);
return lowerWithCtx(program, ctx);
};
}
/**
* Parse-and-validate any input shape `jsmql()` or `jsmql.compile()` accepts.
* Returns a `ValidationResult` with structured errors instead of throwing,
* so callers can drive editor tooling, form validation, and similar use cases.
*
* The overload set is the union of `jsmql.compile`'s and `jsmql()`'s — the
* compile-form arrow comes first so IDEs contextually type `({ minAge }, $)`
* against `(params: P, $: any, ops: JsmqlOps)` rather than the one-shot
* `($: any, ops: JsmqlOps)` shape (which would mis-type the second slot as
* `JsmqlOps`).
*/
function validateInput
>(fn: JsmqlCompileFn
): ValidationResult;
function validateInput(input: JsmqlInput): ValidationResult;
function validateInput(strings: TemplateStringsArray, ...values: unknown[]): ValidationResult;
function validateInput(
// The implementation signature must be assignable from every overload, so it
// widens to include the compile-form arrow shape (`JsmqlCompileFn`)
// alongside `JsmqlInput` and the template-tag array. The compile-form arrow
// doesn't fit `JsmqlInput`'s `JsmqlFn = ($: any, ops: JsmqlOps) => unknown`
// shape because its first slot is the params destructure, not `$`.
input: JsmqlInput | TemplateStringsArray | JsmqlCompileFn,
...values: unknown[]
): ValidationResult {
try {
if (isTemplateStringsArray(input)) {
jsmql(input, ...values);
} else if (typeof input === "function") {
// Inline the function-input path here (instead of delegating to
// `jsmql(input)`) so compile-form arrows — those carrying a params
// destructure — are accepted for validation. `jsmqlDispatch` rejects
// them outright in the one-shot path; validate is the structured-result
// surface for both shapes, so it parses the arrow and binds null
// placeholders for each declared binding so codegen resolves them as
// ParamRefs (the values don't affect validation, only their resolution).
// Mirror `jsmqlDispatch`'s function-input branch (parse + lower under the
// same augment), but bind each declared binding to a null placeholder so
// codegen resolves the ParamRefs — the values don't affect validation.
withFunctionInput(fnSource(input), ({ program, bindings }) => {
const resolved = new Map();
for (const b of bindings) resolved.set(b.name, null);
lowerWithCtx(program, withBindings(EMPTY_CTX, resolved));
});
} else {
jsmql(input);
}
return { valid: true, errors: [] };
} catch (err) {
return errorToValidationResult(err);
}
}
// `jsmql` is exposed as a callable with attached properties:
// - `jsmql.compile` — parameterised, reusable Filter / Pipeline builder
// - `jsmql.validate` — structured-result form of `jsmql()`
// - `jsmql.expr` — compile a partial / "unfinished" aggregation expression
// (the shape that goes inside `$project`, `$addFields`,
// `db.coll.updateOne(filter, update)`, etc.)
// - `jsmql.filter` — strict-Filter form (throws on Pipeline-shaped input)
// - `jsmql.pipeline` — strict-Pipeline form (throws on bare-expression input)
// - `jsmql.update` — strict aggregation-pipeline update (whitelists stages).
// Named `update` rather than `updateFilter` to avoid the
// "filter ≠ query doc" confusion at the call site; the
// underlying driver type is still `UpdateFilter`.
// The strippable-TS rule (see src/CLAUDE.md) forbids `namespace` declarations,
// so we build the shape with `Object.assign` and an explicit intersection type.
type Jsmql = typeof jsmqlDispatch & {
compile: typeof compileFunction;
validate: typeof validateInput;
expr: typeof exprDispatch;
filter: typeof filterDispatch;
pipeline: typeof pipelineDispatch;
update: typeof updateDispatch;
};
export const jsmql: Jsmql = Object.assign(jsmqlDispatch, {
compile: compileFunction,
validate: validateInput,
expr: exprDispatch,
filter: filterDispatch,
pipeline: pipelineDispatch,
update: updateDispatch,
});
// Wrap JSON.stringify with the validation needed to keep the template-tag
// invocation of `jsmql` a safe boundary. Three failure modes that
// JSON.stringify quietly hides:
// - returns `undefined` for unsupported value types (function/Symbol/the
// literal `undefined`); concatenating that into the source produces the
// bare text "undefined" which the parser then misreads as an identifier.
// - silently coerces non-finite numbers to "null", losing the user's intent.
// - throws TypeError for BigInt values and circular references.
//
// A fourth failure mode is fidelity loss: `JSON.stringify(new Date(...))`
// returns an ISO string (which BSON compares as a string, not a date),
// `JSON.stringify(/x/)` returns `"{}"`, etc. For opaque BSON instances —
// `Date`, `RegExp`, `Uint8Array`/Buffer, duck-typed `ObjectId` — the
// original JS instance is routed through a synthesized `ParamRef` binding
// so the MQL output carries it verbatim. Works at the top of an
// interpolation slot *and* arbitrarily deep inside an interpolated
// object/array — e.g. `jsmql\`… ${{ since: new Date(...) }}\`` keeps the
// nested Date as a Date.
// U+E000 is a Unicode Private Use Area code point — Unicode reserves these
// for private-use sentinels with no defined meaning, JSON.stringify passes
// them through unescaped, and they essentially never appear in real user
// data. Used to wrap an injected binding-name string so we can find-and-
// replace markers after JSON.stringify produces the source fragment.
const OPAQUE_INTERP_MARKER = "";
function stringifyInterpolation(value: unknown, slot: number, opaqueBindings: Map): string {
if (!containsOpaqueBsonAnywhere(value)) {
// Fast path: pure JSON-shaped value. After validateInterpolatable, JSON.stringify
// is guaranteed to produce a string (no `undefined` return, no throw, no silent
// NaN→null coercion at top level).
validateInterpolatable(value, slot);
return JSON.stringify(value)!;
}
// Slow path: at least one opaque BSON instance lives somewhere in the value.
// Substitute each instance with a marker string carrying a unique binding
// name, JSON-stringify the rewritten tree (so the surrounding JSON-shaped
// parts get the same serialization as the fast path), then replace the
// markers in the output with bare identifiers — which the parser resolves
// as `ParamRef`s and the binding map carries through to codegen.
const state = { counter: 0, seen: new WeakSet