Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
f6b340b
initial design docs, still under review
mathpirate Sep 25, 2025
fa8a659
new design
mathpirate Sep 25, 2025
d3eb404
WIP: Initial closure transformation implementation
mathpirate Sep 25, 2025
5f0e19b
WIP: Debugging capture detection in closure transformer
mathpirate Sep 29, 2025
1f835ef
update docs
mathpirate Sep 29, 2025
de4f166
non-working checkpoint
mathpirate Sep 30, 2025
1fe6f1d
Fix rebase issues: remove unused closure-transformer, fix imports.req…
mathpirate Sep 30, 2025
2a55078
fmt
mathpirate Sep 30, 2025
c511344
non-working checkpoint including a number of elements of what we need
mathpirate Oct 2, 2025
aa9ff0d
somewhat hacky approach to support chained map after filter/slice/oth…
mathpirate Oct 2, 2025
4f53c7c
update cell map example
mathpirate Oct 2, 2025
d027bf8
add new fixtures and fix a bug that one revealed
mathpirate Oct 2, 2025
c8a1e05
more fixtures
mathpirate Oct 2, 2025
9d012cf
final set of fixtures plus another resulting bug fix
mathpirate Oct 2, 2025
3545e91
updates to behavior based on chat with berni, change fixtures and doc…
mathpirate Oct 3, 2025
806bc62
fmt, lint
mathpirate Oct 3, 2025
041bedd
implement map_with_pattern
mathpirate Oct 3, 2025
07be1e1
fmt sigh
mathpirate Oct 3, 2025
0ca9562
update docs
mathpirate Oct 3, 2025
5c34320
add new map_with_pattern to OpaqueRefMethods interface
mathpirate Oct 3, 2025
1c6161f
finish reconciling docs
mathpirate Oct 3, 2025
bcfe9c7
fix how we inject recipe calls, and also fix their generated signatur…
mathpirate Oct 3, 2025
46ed262
fmt sighhh
mathpirate Oct 3, 2025
4e87678
fix final fixture for new recipe injection/invocation format
mathpirate Oct 3, 2025
648ccce
remove unneeded test script
mathpirate Oct 3, 2025
2ec451d
generate schemas for injected map_with_pattern calls; update many fix…
mathpirate Oct 8, 2025
bd80c43
fmt
mathpirate Oct 8, 2025
4587600
update for new import management style from jordan's PR
mathpirate Oct 9, 2025
1fa7761
update docs to be fully current
mathpirate Oct 9, 2025
98a5b72
move from having a new mapWithPattern built-in to unifying with exist…
mathpirate Oct 9, 2025
7905f5b
compile new api type for mapWithPattern
mathpirate Oct 9, 2025
a7b8242
factor things out
mathpirate Oct 9, 2025
a71953a
Handle Opaque<T> union types in schema generator and transformer
mathpirate Oct 10, 2025
77b7b11
first rev of approach to handling schema generation properly for synt…
mathpirate Oct 14, 2025
b6cdfa4
laboriously fix type errors surfaced by changes to createSchemaGenera…
mathpirate Oct 14, 2025
53e2be8
fix final test fixture issue
mathpirate Oct 14, 2025
40c8de1
fmt
mathpirate Oct 14, 2025
6058bc5
use new import style
mathpirate Oct 14, 2025
08b9926
fmt
mathpirate Oct 14, 2025
2870b2b
no-unused-vars yay
mathpirate Oct 15, 2025
7a44fc9
remove now-unneeded doc
mathpirate Oct 15, 2025
0795412
remove import of h
mathpirate Oct 15, 2025
ecc2917
WIP: Simplify map callback parameter handling (synthetic AST nodes st…
mathpirate Oct 15, 2025
5338b0c
handle synthetic JSX expressions in dataflow
mathpirate Oct 15, 2025
497887f
safe way of getting node text that works with synthetic nodes
mathpirate Oct 15, 2025
5bdf5dd
add JSX traversal handling throughout synthetic and non-synthetic pat…
mathpirate Oct 15, 2025
50826df
refactor: unify map closure transformation and fix opaque parameter d…
mathpirate Oct 16, 2025
c04c00b
some fixes for synthetic nodes and some fixture updates
mathpirate Oct 16, 2025
620ae38
fmt
mathpirate Oct 16, 2025
658920a
update another fixture
mathpirate Oct 16, 2025
6f52638
fix for ifElse behaviors
mathpirate Oct 16, 2025
0e1e9fb
Refactor a few things and fix lint
mathpirate Oct 16, 2025
634b8a4
fix formatting and update for new behavior of expected fixture
mathpirate Oct 16, 2025
1eb337c
fix final test
mathpirate Oct 16, 2025
c55da86
implement array destructuring for map elements
mathpirate Oct 17, 2025
ecddca2
properly transform index parameter
mathpirate Oct 17, 2025
d852cf8
fmt
mathpirate Oct 17, 2025
3bf428b
explicit undefined type option for mapFactory
mathpirate Oct 18, 2025
fd4044c
throw an error instead of trying a final fallback
mathpirate Oct 18, 2025
47d3ac0
Add test fixture for element access with both OpaqueRefs
claude Oct 22, 2025
7f72b1d
fix rebase so .map returns OpaqueRef
seefeldb Oct 22, 2025
807bdc3
Add failing test for element access in map with both OpaqueRefs
seefeldb Oct 22, 2025
6a266a5
fix: wrap synthetic element access with opaque refs in derive
mathpirate Oct 23, 2025
35344da
Fix: Mark synthetic element access expressions as requiring rewrite
seefeldb Oct 23, 2025
0ad43ee
chore: Remove unused helper functions after simplifying filterRelevan…
seefeldb Oct 23, 2025
8f260f2
fix(ts-transformers): preserve array parameter in map callbacks
seefeldb Oct 23, 2025
54ccdbd
fix(ts-transformers): preserve property names in shorthand assignments
seefeldb Oct 23, 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: OpaqueRef<T>,
) => Opaque<S>,
): OpaqueRef<S[]>;
mapWithPattern<S>(
op: Recipe,
params: Record<string, any>,
): OpaqueRef<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
6 changes: 5 additions & 1 deletion packages/runner/src/builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,11 @@ declare module "@commontools/api" {
index: OpaqueRef<number>,
array: OpaqueRef<T>,
) => Opaque<S>,
): OpaqueRef<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