Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
f3b4ac5
initial design docs, still under review
mathpirate Sep 25, 2025
41bcddb
new design
mathpirate Sep 25, 2025
8a8c77f
WIP: Initial closure transformation implementation
mathpirate Sep 25, 2025
c7fa3c9
WIP: Debugging capture detection in closure transformer
mathpirate Sep 29, 2025
7caa46c
update docs
mathpirate Sep 29, 2025
89038c9
non-working checkpoint
mathpirate Sep 30, 2025
dd83293
Fix rebase issues: remove unused closure-transformer, fix imports.req…
mathpirate Sep 30, 2025
c427496
fmt
mathpirate Sep 30, 2025
249107a
non-working checkpoint including a number of elements of what we need
mathpirate Oct 2, 2025
b0a2466
somewhat hacky approach to support chained map after filter/slice/oth…
mathpirate Oct 2, 2025
866b967
update cell map example
mathpirate Oct 2, 2025
33c8522
add new fixtures and fix a bug that one revealed
mathpirate Oct 2, 2025
36e2b33
more fixtures
mathpirate Oct 2, 2025
a44415a
final set of fixtures plus another resulting bug fix
mathpirate Oct 2, 2025
c341755
updates to behavior based on chat with berni, change fixtures and doc…
mathpirate Oct 3, 2025
566b860
fmt, lint
mathpirate Oct 3, 2025
420930b
implement map_with_pattern
mathpirate Oct 3, 2025
56aa3ce
fmt sigh
mathpirate Oct 3, 2025
9fa975d
update docs
mathpirate Oct 3, 2025
af8e0b4
add new map_with_pattern to OpaqueRefMethods interface
mathpirate Oct 3, 2025
bd1302b
finish reconciling docs
mathpirate Oct 3, 2025
79415a7
fix how we inject recipe calls, and also fix their generated signatur…
mathpirate Oct 3, 2025
a31bd45
fmt sighhh
mathpirate Oct 3, 2025
2c7aa8e
fix final fixture for new recipe injection/invocation format
mathpirate Oct 3, 2025
772496e
remove unneeded test script
mathpirate Oct 3, 2025
6367739
generate schemas for injected map_with_pattern calls; update many fix…
mathpirate Oct 8, 2025
aa5858d
fmt
mathpirate Oct 8, 2025
97c44ab
update for new import management style from jordan's PR
mathpirate Oct 9, 2025
4523ca2
update docs to be fully current
mathpirate Oct 9, 2025
e9b498c
move from having a new mapWithPattern built-in to unifying with exist…
mathpirate Oct 9, 2025
d2f4ad7
compile new api type for mapWithPattern
mathpirate Oct 9, 2025
08fc6eb
factor things out
mathpirate Oct 9, 2025
ce631e6
Handle Opaque<T> union types in schema generator and transformer
mathpirate Oct 10, 2025
d0431da
first rev of approach to handling schema generation properly for synt…
mathpirate Oct 14, 2025
270de46
laboriously fix type errors surfaced by changes to createSchemaGenera…
mathpirate Oct 14, 2025
7201c3b
fix final test fixture issue
mathpirate Oct 14, 2025
5c8d92e
fmt
mathpirate Oct 14, 2025
f56ffe7
use new import style
mathpirate Oct 14, 2025
d1abbad
fmt
mathpirate Oct 14, 2025
c924afc
no-unused-vars yay
mathpirate Oct 15, 2025
b9fcc94
remove now-unneeded doc
mathpirate Oct 15, 2025
45070cd
remove import of h
mathpirate Oct 15, 2025
a2b1153
WIP: Simplify map callback parameter handling (synthetic AST nodes st…
mathpirate Oct 15, 2025
18ec0cd
handle synthetic JSX expressions in dataflow
mathpirate Oct 15, 2025
2d06e4d
safe way of getting node text that works with synthetic nodes
mathpirate Oct 15, 2025
08d4b5c
add JSX traversal handling throughout synthetic and non-synthetic pat…
mathpirate Oct 15, 2025
3e58ef8
refactor: unify map closure transformation and fix opaque parameter d…
mathpirate Oct 16, 2025
e1214f8
some fixes for synthetic nodes and some fixture updates
mathpirate Oct 16, 2025
e3e79be
fmt
mathpirate Oct 16, 2025
b3eb79b
update another fixture
mathpirate Oct 16, 2025
d65df9a
fix for ifElse behaviors
mathpirate Oct 16, 2025
22a504f
Refactor a few things and fix lint
mathpirate Oct 16, 2025
9bb9175
fix formatting and update for new behavior of expected fixture
mathpirate Oct 16, 2025
a6b4092
fix final test
mathpirate Oct 16, 2025
639f7db
implement array destructuring for map elements
mathpirate Oct 17, 2025
2c50902
properly transform index parameter
mathpirate Oct 17, 2025
0f1abc2
fmt
mathpirate Oct 17, 2025
57d120a
explicit undefined type option for mapFactory
mathpirate Oct 18, 2025
96f2f72
throw an error instead of trying a final fallback
mathpirate Oct 18, 2025
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
4 changes: 4 additions & 0 deletions packages/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export interface OpaqueRefMethods<T> {
array: T,
) => Opaque<S>,
): Opaque<S[]>;
mapWithPattern<S>(
op: Recipe,
params: Record<string, any>,
): Opaque<S[]>;
}

// Factory types
Expand Down
18 changes: 17 additions & 1 deletion packages/runner/src/builder/opaque-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { hasValueAtPath, setValueAtPath } from "../path-utils.ts";
import { getTopFrame, recipe } from "./recipe.ts";
import { createNodeFactory } from "./module.ts";

let mapFactory: NodeFactory<any, any>;
let mapFactory: NodeFactory<any, any> | undefined;

// A opaque ref factory that creates future cells with optional default values.
//
Expand Down Expand Up @@ -140,6 +140,22 @@ export function opaqueRef<T>(
),
});
},
mapWithPattern: <S>(
op: Recipe,
params: Record<string, any>,
) => {
// Create the factory if it doesn't exist. Doing it here to avoid
// circular dependency.
mapFactory ||= createNodeFactory({
type: "ref",
implementation: "map",
});
return mapFactory({
list: proxy,
op: op,
params: params,
});
},
toJSON: () => null, // TODO(seefeld): Merge with Cell and cover doc-less case
/**
* We assume the cell is an array and will provide an infinite iterator.
Expand Down
4 changes: 4 additions & 0 deletions packages/runner/src/builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ declare module "@commontools/api" {
array: T,
) => Opaque<S>,
): Opaque<S[]>;
mapWithPattern<S>(
op: Recipe,
params: Record<string, any>,
): Opaque<S[]>;
toJSON(): unknown;
[Symbol.iterator](): Iterator<T>;
[Symbol.toPrimitive](hint: string): T;
Expand Down
36 changes: 28 additions & 8 deletions packages/runner/src/builtins/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,21 @@ import type { IRuntime } from "../runtime.ts";
import type { IExtendedStorageTransaction } from "../storage/interface.ts";

/**
* Implemention of built-in map module. Unlike regular modules, this will be
* Implementation of built-in map module. Unlike regular modules, this will be
* called once at setup and thus sets up its own actions for the scheduler.
*
* This supports both legacy map calls and closure-transformed map calls:
* - Legacy mode (params === undefined): Passes { element, index, array } to recipe
* - Closure mode (params !== undefined): Passes { element, index, array, params } to recipe
*
* The goal is to keep the output array current without recomputing too much.
*
* Approach:
* 1. Create a doc to store the result.
* 2. Create a handler to update the result doc when the input doc changes.
* 3. Create a handler to update the result doc when the op doc changes.
* 4. For each value in the input doc, create a handler to update the result
* 4. Create a handler to update the result doc when the params doc changes (closure mode).
* 5. For each value in the input doc, create a handler to update the result
* doc when the value changes.
*
* TODO: Optimization depends on javascript objects and not lookslike objects.
Expand All @@ -24,12 +29,14 @@ import type { IExtendedStorageTransaction } from "../storage/interface.ts";
*
* @param list - A doc containing an array of values to map over.
* @param op - A recipe to apply to each value.
* @param params - Optional object containing captured variables from outer scope (closure mode).
* @returns A doc containing the mapped values.
*/
export function map(
inputsCell: Cell<{
list: any[];
op: Recipe;
params?: Record<string, any>;
}>,
sendResult: (tx: IExtendedStorageTransaction, result: any) => void,
addCancel: AddCancel,
Expand Down Expand Up @@ -62,12 +69,13 @@ export function map(
sendResult(tx, result);
}
const resultWithLog = result.withTx(tx);
const { list, op } = inputsCell.asSchema(
const { list, op, params } = inputsCell.asSchema(
{
type: "object",
properties: {
list: { type: "array", items: { asCell: true } },
op: { asCell: true },
params: { type: "object" },
},
required: ["list", "op"],
additionalProperties: false,
Expand Down Expand Up @@ -111,14 +119,26 @@ export function map(
undefined,
tx,
);
runtime.runner.run(
tx,
opRecipe,
{
// Determine which mode we're in based on presence of params
const recipeInputs = params !== undefined
? {
// Closure mode: include params
element: inputsCell.key("list").key(initializedUpTo),
index: initializedUpTo,
array: inputsCell.key("list"),
},
params: inputsCell.key("params"),
}
: {
// Legacy mode: no params
element: inputsCell.key("list").key(initializedUpTo),
index: initializedUpTo,
array: inputsCell.key("list"),
};

runtime.runner.run(
tx,
opRecipe,
recipeInputs,
resultCell,
);
resultCell.getSourceCell()!.setSourceCell(parentCell);
Expand Down
152 changes: 152 additions & 0 deletions packages/schema-generator/src/formatters/common-tools-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export class CommonToolsFormatter implements TypeFormatter {
return true;
}

// Check if this is an Opaque<T> union (T | OpaqueRef<T>)
if (this.isOpaqueUnion(type, context.typeChecker)) {
return true;
}

// Check if this is a wrapper type (Cell/Stream/OpaqueRef) via type structure
const wrapperInfo = this.getWrapperTypeInfo(type);
return wrapperInfo !== undefined;
Expand All @@ -42,6 +47,26 @@ export class CommonToolsFormatter implements TypeFormatter {
formatType(type: ts.Type, context: GenerationContext): SchemaDefinition {
const n = context.typeNode;

// Check if this is an Opaque<T> union and handle it first
// This prevents the UnionFormatter from creating an anyOf
const opaqueUnionInfo = this.getOpaqueUnionInfo(type, context.typeChecker);
if (opaqueUnionInfo) {
// Format the base type T and add asOpaque: true
const innerSchema = this.schemaGenerator.formatChildType(
opaqueUnionInfo.baseType,
context,
undefined, // Don't pass typeNode since we're working with the unwrapped type
);

// Handle boolean schemas
if (typeof innerSchema === "boolean") {
return innerSchema === false
? { asOpaque: true, not: true } as SchemaDefinition // false = "no value is valid"
: { asOpaque: true } as SchemaDefinition; // true = "any value is valid"
}
return { ...innerSchema, asOpaque: true } as SchemaDefinition;
}

// Check via typeNode for all wrapper types (handles both direct usage and aliases)
const resolvedWrapper = n
? resolveWrapperNode(n, context.typeChecker)
Expand Down Expand Up @@ -191,6 +216,133 @@ export class CommonToolsFormatter implements TypeFormatter {
return { ...innerSchema, [propertyName]: true };
}

/**
* Check if a type is an Opaque<T> union (T | OpaqueRef<T>)
*/
private isOpaqueUnion(type: ts.Type, checker: ts.TypeChecker): boolean {
return this.getOpaqueUnionInfo(type, checker) !== undefined;
}

/**
* Extract information from an Opaque<T> union type.
* Opaque<T> is defined as: T | OpaqueRef<T>
* This function detects this pattern and returns the base type T.
*/
private getOpaqueUnionInfo(
type: ts.Type,
checker: ts.TypeChecker,
): { baseType: ts.Type } | undefined {
// Must be a union type
if (!(type.flags & ts.TypeFlags.Union)) {
return undefined;
}

const unionType = type as ts.UnionType;
const members = unionType.types;

// Must have exactly 2 members
if (members.length !== 2) {
return undefined;
}

// One member should be OpaqueRef<T>, the other should be T
let opaqueRefMember: ts.Type | undefined;
let baseMember: ts.Type | undefined;

for (const member of members) {
// Check if this member is an OpaqueRef type (it will be an intersection)
const isOpaqueRef = this.isOpaqueRefType(member);
if (isOpaqueRef) {
opaqueRefMember = member;
} else {
baseMember = member;
}
}

// Both members must be present for this to be an Opaque<T> union
if (!opaqueRefMember || !baseMember) {
return undefined;
}

// Verify that the OpaqueRef's type argument matches the base type
// Extract T from OpaqueRef<T>
const opaqueRefInnerType = this.extractOpaqueRefTypeArgument(
opaqueRefMember,
checker,
);
if (!opaqueRefInnerType) {
return undefined;
}

// The inner type of OpaqueRef should match the base member
// Use type equality check
const innerTypeString = checker.typeToString(opaqueRefInnerType);
const baseTypeString = checker.typeToString(baseMember);

if (innerTypeString !== baseTypeString) {
// Not a matching Opaque<T> pattern
return undefined;
}

return { baseType: baseMember };
}

/**
* Check if a type is an OpaqueRef type (intersection with OpaqueRefMethods)
*/
private isOpaqueRefType(type: ts.Type): boolean {
// OpaqueRef types are intersection types
if (!(type.flags & ts.TypeFlags.Intersection)) {
return false;
}

const intersectionType = type as ts.IntersectionType;
for (const constituent of intersectionType.types) {
if (constituent.flags & ts.TypeFlags.Object) {
const objectType = constituent as ts.ObjectType;
if (objectType.objectFlags & ts.ObjectFlags.Reference) {
const typeRef = objectType as ts.TypeReference;
const name = typeRef.target?.symbol?.name;
if (name === "OpaqueRefMethods") {
return true;
}
}
}
}
return false;
}

/**
* Extract the type argument T from OpaqueRef<T>
*/
private extractOpaqueRefTypeArgument(
type: ts.Type,
checker: ts.TypeChecker,
): ts.Type | undefined {
if (!(type.flags & ts.TypeFlags.Intersection)) {
return undefined;
}

const intersectionType = type as ts.IntersectionType;
for (const constituent of intersectionType.types) {
if (constituent.flags & ts.TypeFlags.Object) {
const objectType = constituent as ts.ObjectType;
if (objectType.objectFlags & ts.ObjectFlags.Reference) {
const typeRef = objectType as ts.TypeReference;
const name = typeRef.target?.symbol?.name;
if (name === "OpaqueRefMethods") {
// Found OpaqueRefMethods<T>, extract T
const typeArgs = checker.getTypeArguments(typeRef);
if (typeArgs && typeArgs.length > 0) {
return typeArgs[0];
}
}
}
}
}
return undefined;
}

/**
* Get wrapper type information (Cell/Stream/OpaqueRef)
* Handles both direct references and intersection types (e.g., OpaqueRef<"literal">)
Expand Down
14 changes: 14 additions & 0 deletions packages/schema-generator/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,18 @@ export interface SchemaGenerator {
checker: ts.TypeChecker,
typeNode?: ts.TypeNode,
): SchemaDefinition;

/**
* Generate schema from a synthetic TypeNode that doesn't resolve to a proper Type.
* Used by transformers that create synthetic type structures programmatically.
*
* @param typeNode - Synthetic TypeNode to analyze
* @param checker - TypeScript type checker
* @param typeRegistry - Optional WeakMap of Node → Type for registered synthetic nodes
*/
generateSchemaFromSyntheticTypeNode(
typeNode: ts.TypeNode,
checker: ts.TypeChecker,
typeRegistry?: WeakMap<ts.Node, ts.Type>,
): SchemaDefinition;
}
32 changes: 23 additions & 9 deletions packages/schema-generator/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,32 @@ import ts from "typescript";
import { SchemaGenerator } from "./schema-generator.ts";

/**
* Plugin function that matches the existing typeToJsonSchema signature
* This allows our new system to be a drop-in replacement
* Plugin function that creates a schema transformer with access to both
* Type-based and synthetic TypeNode-based schema generation
*/
export function createSchemaTransformerV2(): (
type: ts.Type,
checker: ts.TypeChecker,
typeArg?: ts.TypeNode,
) => any {
export function createSchemaTransformerV2() {
const generator = new SchemaGenerator();

return (type: ts.Type, checker: ts.TypeChecker, typeArg?: ts.TypeNode) => {
return generator.generateSchema(type, checker, typeArg);
return {
generateSchema(
type: ts.Type,
checker: ts.TypeChecker,
typeArg?: ts.TypeNode,
) {
return generator.generateSchema(type, checker, typeArg);
},

generateSchemaFromSyntheticTypeNode(
typeNode: ts.TypeNode,
checker: ts.TypeChecker,
typeRegistry?: WeakMap<ts.Node, ts.Type>,
) {
return generator.generateSchemaFromSyntheticTypeNode(
typeNode,
checker,
typeRegistry,
);
},
};
}

Expand Down
Loading