diff --git a/docs/specs/recipe-construction/rollout-plan.md b/docs/specs/recipe-construction/rollout-plan.md index 0061dc1f4..230432723 100644 --- a/docs/specs/recipe-construction/rollout-plan.md +++ b/docs/specs/recipe-construction/rollout-plan.md @@ -1,27 +1,25 @@ # Implementation plan -- [ ] Disable ShadowRef/unsafe_ and see what breaks, ideally remove it +- [x] Disable ShadowRef/unsafe_ and see what breaks, ideally remove it (will + merge later as it'll break a few patterns) - [ ] Update Cell API types to already unify them - - [ ] Create an `AnyCell<>` type with a symbol based brand, with the value be - `Record` - - [ ] Factor out parts of the cell interfaces along reading, writing, .send + - [x] Create an `BrandedCell<>` type with a symbol based brand, with the value + be `string` + - [x] Factor out parts of the cell interfaces along reading, writing, .send (for stream-like) and derives (which is currently just .map) - - [ ] Define `OpaqueCell<>`, `Cell<>` and `Stream<>` by using these factored - out parts, combined with the brand set to `{ opaque: true, read: false, - write: false, stream: false }` for `OpaqueRef`, `{ opaque: false, read: - true, write: true, stream: true }` for `Cell`, and `{ opaque: false, read: - false, write: false, stream: true }` for `Stream`. - - [ ] Add `ComparableCell<>` that is all `false` above. - - [ ] Add `ReadonlyCell` and `WriteonlyCell`. - - [ ] Make `OpaqueRef` a variant of `OpaqueCell` with the current proxy + - [x] Define `OpaqueCell<>`, `Cell<>` and `Stream<>` by using these factored + out parts. + - [x] Add `ComparableCell<>`. + - [x] Add `ReadonlyCell` and `WriteonlyCell`. + - [x] Make `OpaqueRef` a variant of `OpaqueCell` with the current proxy behavior, i.e. each key is an `OpaqueRef` again. That's just for now, until the AST does a .key transformation under the hood. - - [ ] Update `CellLike` to be based on `AnyCell` but allow nesting. + - [x] Update `CellLike` to be based on `BrandedCell` but allow nesting. - [ ] `Opaque` accepts `T` or any `CellLike` at any nesting level - [ ] Simplify most wrap/unwrap types to use `CellLike`. We need - [ ] "Accept any T where any sub part of T can be wrapped in one or more - `AnyCell`" (for inputs to node factories) - - [ ] "Strip any `AnyCell` from T and then wrap it in OpaqueRef<>" (for + `BrandedCell`" (for inputs to node factories) + - [ ] "Strip any `BrandedCell` from T and then wrap it in OpaqueRef<>" (for outputs of node factories, where T is the output of the inner function) - [ ] Make passing the output of the second into the first work. Tricky because we're doing almost opposite expansions on the type. diff --git a/packages/api/index.ts b/packages/api/index.ts index 33908de43..c717cc911 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -16,49 +16,202 @@ export const TYPE = "$TYPE"; export const NAME = "$NAME"; export const UI = "$UI"; -// Cell type with only public methods -export interface Cell { - // Public methods available in spell code and system +// ============================================================================ +// Cell Brand System +// ============================================================================ + +/** + * Brand symbol for identifying different cell types at compile-time. + * Each cell variant has a unique combination of capability flags. + */ +export declare const CELL_BRAND: unique symbol; + +/** + * Minimal cell type with just the brand, no methods. + * Used for type-level operations like unwrapping nested cells without + * creating circular dependencies. + */ +export type BrandedCell = { + [CELL_BRAND]: Brand; +}; + +// ============================================================================ +// Cell Capability Interfaces +// ============================================================================ + +// To constrain methods that only exists on objects +type IsThisObject = + | IsThisArray + | BrandedCell + | BrandedCell>; + +type IsThisArray = + | BrandedCell + | BrandedCell> + | BrandedCell> + | BrandedCell + | BrandedCell; + +/* + * IAnyCell is an interface that is used by all calls and to which the runner + * attaches the internal methods.. + */ +// deno-lint-ignore no-empty-interface +export interface IAnyCell { +} + +/** + * Readable cells can retrieve their current value. + */ +export interface IReadable { get(): Readonly; - set(value: T): void; - send(value: T): void; // alias for set - update(values: Partial): void; - push(...value: T extends (infer U)[] ? U[] : never): void; - equals(other: Cell): boolean; - key(valueKey: K): Cell; - resolveAsCell(): Cell; } -// Cell type with only public methods -export interface Stream { - send(event: T): void; +/** + * Writable cells can update their value. + */ +export interface IWritable { + set(value: T | AnyCellWrapping): void; + update | AnyCellWrapping>)>( + this: IsThisObject, + values: V extends object ? AnyCellWrapping : never, + ): void; + push( + this: IsThisArray, + ...value: T extends (infer U)[] ? (U | AnyCellWrapping)[] : never + ): void; } -export type OpaqueRef = - & OpaqueRefMethods - & (T extends Array ? Array> - : T extends object ? { [K in keyof T]: OpaqueRef } - : T); +/** + * Streamable cells can send events. + */ +export interface IStreamable { + send(event: AnyCellWrapping): void; +} -// Any OpaqueRef is also an Opaque, but can also have static values. -// Use Opaque in APIs that get inputs from the developer and use OpaqueRef -// when data gets passed into what developers see (either recipe inputs or -// module outputs). -export type Opaque = - | OpaqueRef - | (T extends Array ? Array> - : T extends object ? { [K in keyof T]: Opaque } - : T); +// Lightweight HKT, so we can pass cell types to IKeyable<>. +interface HKT { + _A: unknown; + type: unknown; +} +type Apply = (F & { _A: A })["type"]; -// OpaqueRefMethods type with only public methods -export interface OpaqueRefMethods { - get(): T; - set(value: Opaque | T): void; - key(key: K): OpaqueRef; - setDefault(value: Opaque | T): void; - setName(name: string): void; - setSchema(schema: JSONSchema): void; +/** + * A key-addressable, **covariant** view over a structured value `T`. + * + * `IKeyableCell` exposes a single method, {@link IKeyableCell.key}, which selects a + * property from the (possibly branded) value `T` and returns it wrapped in a + * user-provided type constructor `Wrap` (default: `Cell<…>`). The interface is + * declared `out T` (covariant) and is designed so that calling `key` preserves + * both type inference and variance soundness. + * + * @template T + * The underlying (possibly branded) value type. `T` is treated **covariantly**: + * `IKeyableCell` is assignable to `IKeyableCell` when `Sub` is + * assignable to `Super`. + * + * @template Wrap extends HKT + * A lightweight higher-kinded “wrapper” that determines the return container for + * selected fields. For example, `AsCell` wraps as `Cell`, while other wrappers + * can project to `ReadonlyCell`, `Stream`, etc. Defaults to `AsCell`. + * + * @template Any + * The “fallback” return type used when the provided key does not match a known + * key (or is widened to `any`). This should usually be `Apply`. + * + * @remarks + * ### Variance & soundness + * The `key` signature is crafted to remain **covariant in `T`**. Internally, + * it guards the instantiation `K = any` with `unknown extends K ? … : …`, so + * the return type becomes `Any` (independent of `T`) in that case. For real keys + * (`K extends keyof UnwrapCell`), the return type is precise and fully inferred. + * + * ### Branded / nested cells + * If a selected property is itself a branded cell (e.g., `BrandedCell`), + * the return value is a wrapped branded cell, i.e. `Wrap>`. + * + * ### Key inference + * Passing a string/number/symbol that is a literal and a member of + * `keyof UnwrapCell` yields precise field types; non-literal or unknown keys + * fall back to `Any` (e.g., `Cell`). + * + * @example + * // Basic usage with the default wrapper (Cell) + * declare const userCell: IKeyableCell<{ id: string; profile: { name: string } }>; + * const idCell = userCell.key("id"); // Cell + * const profileCell = userCell.key("profile"); // Cell<{ name: string }> + * + * // Unknown key falls back to Any (default: Cell) + * const whatever = userCell.key(Symbol()); // Cell + * + * @example + * // Using a custom wrapper, e.g., ReadonlyCell + * interface AsReadonlyCell extends HKT { type: ReadonlyCell } + * type ReadonlyUserCell = IKeyableCell<{ id: string }, AsReadonlyCell, Apply>; + * declare const ro: ReadonlyUserCell; + * const idRO = ro.key("id"); // ReadonlyCell + * + * @example + * // Covariance works: + * declare const sub: IKeyableCell<{ a: string }>; + * const superCell: IKeyableCell = sub; // OK (out T) + */ +export interface IKeyable { + key( + this: IsThisObject, + valueKey: K, + ): KeyResultType; +} + +export type KeyResultType = [unknown] extends [K] + ? Apply // variance guard for K = any + : [0] extends [1 & T] ? Apply // keep any as-is + : T extends BrandedCell // wrapping a cell? delegate to it's .key + ? (T extends { key(k: K): infer R } ? R : Apply) + : Apply; // select key, fallback to any + +/** + * Cells that support key() for property access - OpaqueCell variant. + * OpaqueCell is "sticky" and always returns OpaqueCell<>. + * + * Note: And for now it always returns an OpaqueRef<>, until we clean this up. + */ +export interface IKeyableOpaque { + key( + this: IsThisObject, + valueKey: K, + ): unknown extends K ? OpaqueRef + : K extends keyof UnwrapCell ? (0 extends (1 & T) ? OpaqueRef + : UnwrapCell[K] extends never ? OpaqueRef + : UnwrapCell[K] extends BrandedCell ? OpaqueRef + : OpaqueRef[K]>) + : OpaqueRef; +} + +/** + * Cells that can be resolved back to a Cell. + * Only available on full Cell, not on OpaqueCell or Stream. + */ +export interface IResolvable> { + resolveAsCell(): C; +} + +/** + * Comparable cells have equals() method. + * Available on comparable and readable cells. + */ +export interface IEquatable { + equals(other: AnyCell | object): boolean; +} + +/** + * Cells that allow deriving new cells from existing cells. Currently just + * .map(), but this will eventually include all Array, String and Number + * methods. + */ +export interface IDerivable { map( + this: IsThisObject, fn: ( element: T extends Array ? OpaqueRef : OpaqueRef, index: OpaqueRef, @@ -66,11 +219,220 @@ export interface OpaqueRefMethods { ) => Opaque, ): OpaqueRef; mapWithPattern( + this: IsThisObject, op: Recipe, params: Record, ): OpaqueRef; } +export interface IOpaquable { + /** deprecated */ + get(): T; + /** deprecated */ + set(newValue: Opaque>): void; + /** deprecated */ + setDefault(value: Opaque | T): void; + /** deprecated */ + setPreExisting(ref: any): void; + /** deprecated */ + setName(name: string): void; + /** deprecated */ + setSchema(schema: JSONSchema): void; +} + +// ============================================================================ +// Cell Type Definitions +// ============================================================================ + +/** + * Base type for all cell variants that has methods. Internal API augments this + * interface with internal only API. Uses a second symbol brand to distinguish + * from core cell brand without any methods. + */ +export interface AnyCell extends BrandedCell, IAnyCell { +} + +/** + * Opaque cell reference - only supports keying and derivation, not direct I/O. + * Has .key(), .map(), .mapWithPattern() + * Does NOT have .get()/.set()/.send()/.equals()/.resolveAsCell() + */ +export interface IOpaqueCell + extends IKeyableOpaque, IDerivable, IOpaquable {} + +export interface OpaqueCell + extends BrandedCell, IOpaqueCell {} + +/** + * Full cell with read, write capabilities. + * Has .get(), .set(), .update(), .push(), .equals(), .key(), .resolveAsCell() + * + * Note: This is an interface (not a type) to allow module augmentation by the runtime. + */ +export interface AsCell extends HKT { + type: Cell; +} + +export interface ICell + extends + IAnyCell, + IReadable, + IWritable, + IStreamable, + IEquatable, + IKeyable, + IResolvable> {} + +export interface Cell extends BrandedCell, ICell {} + +/** + * Stream-only cell - can only send events, not read or write. + * Has .send() only + * Does NOT have .key()/.equals()/.get()/.set()/.resolveAsCell() + * + * Note: This is an interface (not a type) to allow module augmentation by the runtime. + */ +export interface Stream + extends BrandedCell, IAnyCell, IStreamable {} + +/** + * Comparable-only cell - just for equality checks and keying. + * Has .equals(), .key() + * Does NOT have .resolveAsCell()/.get()/.set()/.send() + */ +interface AsComparableCell extends HKT { + type: ComparableCell; +} + +export interface ComparableCell + extends + BrandedCell, + IAnyCell, + IEquatable, + IKeyable {} + +/** + * Read-only cell variant. + * Has .get(), .equals(), .key() + * Does NOT have .resolveAsCell()/.set()/.send() + */ +interface AsReadonlyCell extends HKT { + type: ReadonlyCell; +} + +export interface ReadonlyCell + extends + BrandedCell, + IAnyCell, + IReadable, + IEquatable, + IKeyable {} + +/** + * Write-only cell variant. + * Has .set(), .update(), .push(), .key() + * Does NOT have .resolveAsCell()/.get()/.equals()/.send() + */ +interface AsWriteonlyCell extends HKT { + type: WriteonlyCell; +} + +export interface WriteonlyCell + extends + BrandedCell, + IAnyCell, + IWritable, + IKeyable {} + +// ============================================================================ +// OpaqueRef - Proxy-based variant of OpaqueCell +// ============================================================================ + +/** + * OpaqueRef is a variant of OpaqueCell with recursive proxy behavior. + * Each key access returns another OpaqueRef, allowing chained property access. + * This is temporary until AST transformation handles .key() automatically. + */ +export type OpaqueRef = + & OpaqueCell + & (T extends Array ? Array> + : T extends object ? { [K in keyof T]: OpaqueRef } + : T); + +// ============================================================================ +// CellLike and Opaque - Utility types for accepting cells +// ============================================================================ + +/** + * CellLike is a cell (AnyCell) whose nested values are Opaque. + * The top level must be AnyCell, but nested values can be plain or wrapped. + * + * Note: This is primarily used for type constraints that require a cell. + */ +export type CellLike = BrandedCell>; +type MaybeCellWrapped = + | T + | BrandedCell + | (T extends Array ? Array> + : T extends object ? { [K in keyof T]: MaybeCellWrapped } + : never); + +/** + * Opaque accepts T or any cell wrapping T, recursively at any nesting level. + * Used in APIs that accept inputs from developers - can be static values + * or wrapped in cells (OpaqueRef, Cell, etc). + * + * Conceptually: T | AnyCell at any nesting level, but we use OpaqueRef + * for backward compatibility since it has the recursive proxy behavior that + * allows property access (e.g., Opaque<{foo: string}> includes {foo: Opaque}). + */ +export type Opaque = + | T + | OpaqueRef + | (T extends Array ? Array> + : T extends object ? { [K in keyof T]: Opaque } + : T); + +/** + * Recursively unwraps BrandedCell types at any nesting level. + * UnwrapCell>> = string + * UnwrapCell }>> = { a: BrandedCell } + * + * Special cases: + * - UnwrapCell = any + * - UnwrapCell = unknown (preserves unknown) + */ +export type UnwrapCell = + // Preserve any + 0 extends (1 & T) ? T + // Unwrap BrandedCell + : T extends BrandedCell ? UnwrapCell + // Otherwise return as-is + : T; + +/** + * AnyCellWrapping is used for write operations (.set(), .push(), .update()). It + * is a type utility that allows any part of type T to be wrapped in AnyCell<>, + * and allow any part of T that is currently wrapped in AnyCell<> to be used + * unwrapped. This is designed for use with cell method parameters, allowing + * flexibility in how values are passed. The ID and ID_FIELD metadata symbols + * allows controlling id generation and can only be passed to write operations. + */ +export type AnyCellWrapping = + // Handle existing BrandedCell<> types, allowing unwrapping + T extends BrandedCell + ? AnyCellWrapping | BrandedCell> + // Handle arrays + : T extends Array + ? Array> | BrandedCell>> + // Handle objects (excluding null) + : T extends object ? + | { [K in keyof T]: AnyCellWrapping } + & { [ID]?: AnyCellWrapping; [ID_FIELD]?: string } + | BrandedCell<{ [K in keyof T]: AnyCellWrapping }> + // Handle primitives + : T | BrandedCell; + // Factory types // TODO(seefeld): Subset of internal type, just enough to make it @@ -522,7 +884,7 @@ export type FetchDataFunction = ( options?: FetchOptions; result?: T; }>, -) => Opaque<{ pending: boolean; result: T; error: any }>; +) => OpaqueRef<{ pending: boolean; result: T; error: any }>; export type StreamDataFunction = ( params: Opaque<{ @@ -530,7 +892,7 @@ export type StreamDataFunction = ( options?: FetchOptions; result?: T; }>, -) => Opaque<{ pending: boolean; result: T; error: any }>; +) => OpaqueRef<{ pending: boolean; result: T; error: any }>; export type CompileAndRunFunction = ( params: Opaque>, @@ -707,151 +1069,113 @@ type MergeSchemas< : never; }; -/** - * Merge ref site schema with resolved target, then process with Schema<>. - * Implements JSON Schema spec: ref site siblings override target. - */ -type MergeRefSiteWithTarget< +type MergeRefSiteWithTargetGeneric< RefSite extends JSONSchema, Target extends JSONSchema, Root extends JSONSchema, Depth extends DepthLevel, + WrapCells extends boolean, > = RefSite extends { $ref: string } ? MergeSchemas, Target> extends - infer Merged extends JSONSchema ? Schema + infer Merged extends JSONSchema + ? SchemaInner : never : never; -/** - * Merge ref site schema with resolved target, then process with SchemaWithoutCell<>. - * Same as MergeRefSiteWithTarget but doesn't wrap in Cell/Stream. - */ -type MergeRefSiteWithTargetWithoutCell< - RefSite extends JSONSchema, - Target extends JSONSchema, +type SchemaAnyOf< + Schemas extends readonly JSONSchema[], Root extends JSONSchema, Depth extends DepthLevel, -> = RefSite extends { $ref: string } - ? MergeSchemas, Target> extends - infer Merged extends JSONSchema ? SchemaWithoutCell - : never - : never; + WrapCells extends boolean, +> = { + [I in keyof Schemas]: Schemas[I] extends JSONSchema + ? SchemaInner, WrapCells> + : never; +}[number]; + +type SchemaArrayItems< + Items, + Root extends JSONSchema, + Depth extends DepthLevel, + WrapCells extends boolean, +> = Items extends JSONSchema + ? Array, WrapCells>> + : unknown[]; + +type SchemaCore< + T extends JSONSchema, + Root extends JSONSchema, + Depth extends DepthLevel, + WrapCells extends boolean, +> = T extends { $ref: "#" } ? SchemaInner< + Omit, + Root, + DecrementDepth, + WrapCells + > + : T extends { $ref: infer RefStr extends string } + ? MergeRefSiteWithTargetGeneric< + T, + ResolveRef>, + Root, + DecrementDepth, + WrapCells + > + : T extends { enum: infer E extends readonly any[] } ? E[number] + : T extends { anyOf: infer U extends readonly JSONSchema[] } + ? SchemaAnyOf + : T extends { type: "string" } ? string + : T extends { type: "number" | "integer" } ? number + : T extends { type: "boolean" } ? boolean + : T extends { type: "null" } ? null + : T extends { type: "array" } + ? T extends { items: infer I } ? SchemaArrayItems + : unknown[] + : T extends { type: "object" } + ? T extends { properties: infer P } + ? P extends Record ? ObjectFromProperties< + P, + T extends { required: readonly string[] } ? T["required"] : [], + Root, + Depth, + T extends { additionalProperties: infer AP extends JSONSchema } ? AP + : false, + GetDefaultKeys, + WrapCells + > + : Record + : T extends { additionalProperties: infer AP } + ? AP extends false ? Record + : AP extends true ? Record + : AP extends JSONSchema ? Record< + string | number | symbol, + SchemaInner, WrapCells> + > + : Record + : Record + : any; + +type SchemaInner< + T extends JSONSchema, + Root extends JSONSchema = T, + Depth extends DepthLevel = 9, + WrapCells extends boolean = true, +> = Depth extends 0 ? unknown + : T extends { asCell: true } + ? WrapCells extends true + ? Cell, Root, Depth, WrapCells>> + : SchemaInner, Root, Depth, WrapCells> + : T extends { asStream: true } + ? WrapCells extends true + ? Stream, Root, Depth, WrapCells>> + : SchemaInner, Root, Depth, WrapCells> + : SchemaCore; -/** - * Convert a JSON Schema to its TypeScript type equivalent. - * - * Supports: - * - Primitive types (string, number, boolean, null) - * - Objects with properties (required/optional) - * - Arrays with items - * - anyOf unions - * - $ref resolution (including JSON Pointers) - * - asCell/asStream reactive wrappers - * - default values (makes properties required) - * - * $ref Support: - * - "#" (self-reference to root schema) - * - "#/$defs/Name" (JSON Pointer to definition) - * - "#/properties/field" (JSON Pointer to any schema location) - * - External refs (http://...) return type `any` - * - * Default Precedence: - * When both ref site and target have `default`, ref site takes precedence - * per JSON Schema 2020-12 specification. - * - * Limitations: - * - JSON Pointer escaping (~0, ~1) not supported at type level - * - Depth limited to 9 levels to prevent infinite recursion - * - Complex allOf/oneOf logic may not match runtime exactly - * - * @template T - The JSON Schema to convert - * @template Root - Root schema for $ref resolution - * @template Depth - Recursion depth limit (0-9) - */ export type Schema< T extends JSONSchema, Root extends JSONSchema = T, Depth extends DepthLevel = 9, -> = - // If we're out of depth, short-circuit - Depth extends 0 ? unknown - // Handle asCell attribute - wrap the result in Cell - : T extends { asCell: true } ? Cell, Root, Depth>> - // Handle asStream attribute - wrap the result in Stream - : T extends { asStream: true } - ? Stream, Root, Depth>> - // Handle $ref: "#" (self-reference) specially to preserve recursion - : T extends { $ref: "#" } - ? Schema, Root, DecrementDepth> - // Handle $ref - resolve and merge with ref site schema - : T extends { $ref: infer RefStr extends string } ? MergeRefSiteWithTarget< - T, - ResolveRef>, - Root, - DecrementDepth - > - // Handle enum values - : T extends { enum: infer E extends readonly any[] } ? E[number] - // Handle oneOf (not yet supported in schema.ts, so commenting out) - // : T extends { oneOf: infer U extends readonly JSONSchema[] } - // ? U extends readonly [infer F, ...infer R extends JSONSchema[]] - // ? F extends JSONSchema ? - // | Schema> - // | Schema<{ oneOf: R }, Root, Depth> - // : never - // : never - // Handle anyOf - : T extends { anyOf: infer U extends readonly JSONSchema[] } - ? U extends readonly [infer F, ...infer R extends JSONSchema[]] - ? F extends JSONSchema ? - | Schema> - | Schema<{ anyOf: R }, Root, Depth> - : never - : never - // Handle allOf (merge all types) (not yet supported in schema.ts, so commenting out) - // : T extends { allOf: infer U extends readonly JSONSchema[] } - // ? U extends readonly [infer F, ...infer R extends JSONSchema[]] - // ? F extends JSONSchema - // ? Schema & Schema<{ allOf: R }, Root, Depth> - // : never - // : Record - // Handle different primitive types - : T extends { type: "string" } ? string - : T extends { type: "number" | "integer" } ? number - : T extends { type: "boolean" } ? boolean - : T extends { type: "null" } ? null - // Handle array type - : T extends { type: "array" } - ? T extends { items: infer I } - ? I extends JSONSchema ? Array>> - : unknown[] - : unknown[] // No items specified, allow any items - // Handle object type - : T extends { type: "object" } - ? T extends { properties: infer P } - ? P extends Record ? ObjectFromProperties< - P, - T extends { required: readonly string[] } ? T["required"] : [], - Root, - Depth, - T extends { additionalProperties: infer AP extends JSONSchema } ? AP - : false, - GetDefaultKeys - > - : Record - // Object without properties - check additionalProperties - : T extends { additionalProperties: infer AP } - ? AP extends false ? Record // Empty object - : AP extends true ? Record - : AP extends JSONSchema ? Record< - string | number | symbol, - Schema> - > - : Record - // Default for object with no properties and no additionalProperties specified - : Record - // Default case - : any; +> = SchemaInner; // Get keys from the default object type GetDefaultKeys = T extends { default: infer D } @@ -867,32 +1191,33 @@ type ObjectFromProperties< Depth extends DepthLevel, AP extends JSONSchema = false, DK extends string = never, + WrapCells extends boolean = true, > = - // Required properties (either explicitly required or has a default value) & { [ K in keyof P as K extends string ? K extends R[number] | DK ? K : never : never - ]: Schema>; + ]: SchemaInner, WrapCells>; } - // Optional properties (not required and no default) & { [ K in keyof P as K extends string ? K extends R[number] | DK ? never : K : never - ]?: Schema>; + ]?: SchemaInner, WrapCells>; } - // Additional properties & ( - AP extends false - // Additional properties off => no-op instead of empty record - ? Record + AP extends false ? Record : AP extends true ? { [key: string]: unknown } - : AP extends JSONSchema - ? { [key: string]: Schema> } + : AP extends JSONSchema ? { + [key: string]: SchemaInner< + AP, + Root, + DecrementDepth, + WrapCells + >; + } : Record - ) - & IDFields; + ); // Restrict Depth to these numeric literal types type DepthLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; @@ -914,139 +1239,11 @@ type Decrement = { // Helper function to safely get decremented depth type DecrementDepth = Decrement[D] & DepthLevel; -// Same as above, but ignoring asCell, so we never get cells. This is used for -// calls of lifted functions and handlers, since they can pass either cells or -// values. - export type SchemaWithoutCell< T extends JSONSchema, Root extends JSONSchema = T, Depth extends DepthLevel = 9, -> = - // If we're out of depth, short-circuit - Depth extends 0 ? unknown - // Handle asCell attribute - but DON'T wrap in Cell, just use the inner type - : T extends { asCell: true } - ? SchemaWithoutCell, Root, Depth> - // Handle asStream attribute - but DON'T wrap in Stream, just use the inner type - : T extends { asStream: true } - ? SchemaWithoutCell, Root, Depth> - // Handle $ref: "#" (self-reference) specially to preserve recursion - : T extends { $ref: "#" } ? SchemaWithoutCell< - Omit, - Root, - DecrementDepth - > - // Handle $ref - resolve and merge with ref site schema - : T extends { $ref: infer RefStr extends string } - ? MergeRefSiteWithTargetWithoutCell< - T, - ResolveRef>, - Root, - DecrementDepth - > - // Handle enum values - : T extends { enum: infer E extends readonly any[] } ? E[number] - // Handle oneOf (not yet supported in schema.ts, so commenting out) - // : T extends { oneOf: infer U extends readonly JSONSchema[] } - // ? U extends readonly [infer F, ...infer R extends JSONSchema[]] - // ? F extends JSONSchema ? - // | SchemaWithoutCell> - // | SchemaWithoutCell<{ oneOf: R }, Root, Depth> - // : never - // : never - // Handle anyOf - : T extends { anyOf: infer U extends readonly JSONSchema[] } - ? U extends readonly [infer F, ...infer R extends JSONSchema[]] - ? F extends JSONSchema ? - | SchemaWithoutCell> - | SchemaWithoutCell<{ anyOf: R }, Root, Depth> - : never - : never - // Handle allOf (merge all types) (not yet supported in schema.ts, so commenting out) - // : T extends { allOf: infer U extends readonly JSONSchema[] } - // ? U extends readonly [infer F, ...infer R extends JSONSchema[]] - // ? F extends JSONSchema - // ? - // & SchemaWithoutCell - // & MergeAllOfWithoutCell<{ allOf: R }, Root, Depth> - // : never - // : Record - // Handle different primitive types - : T extends { type: "string" } ? string - : T extends { type: "number" | "integer" } ? number - : T extends { type: "boolean" } ? boolean - : T extends { type: "null" } ? null - // Handle array type - : T extends { type: "array" } - ? T extends { items: infer I } - ? I extends JSONSchema - ? SchemaWithoutCell>[] - : unknown[] - : unknown[] // No items specified, allow any items - // Handle object type - : T extends { type: "object" } - ? T extends { properties: infer P } - ? P extends Record - ? ObjectFromPropertiesWithoutCell< - P, - T extends { required: readonly string[] } ? T["required"] : [], - Root, - Depth, - T extends { additionalProperties: infer AP extends JSONSchema } ? AP - : false, - GetDefaultKeys - > - : Record - // Object without properties - check additionalProperties - : T extends { additionalProperties: infer AP } - ? AP extends false ? Record // Empty object - : AP extends true ? Record - : AP extends JSONSchema ? Record< - string | number | symbol, - SchemaWithoutCell> - > - : Record - // Default for object with no properties and no additionalProperties specified - : Record - // Default case - : any; - -type ObjectFromPropertiesWithoutCell< - P extends Record, - R extends readonly string[] | never, - Root extends JSONSchema, - Depth extends DepthLevel, - AP extends JSONSchema = false, - DK extends string = never, -> = - // Required properties (either explicitly required or has a default value) - & { - [ - K in keyof P as K extends string ? K extends R[number] | DK ? K : never - : never - ]: SchemaWithoutCell>; - } - // Optional properties (not required and no default) - & { - [ - K in keyof P as K extends string ? K extends R[number] | DK ? never : K - : never - ]?: SchemaWithoutCell>; - } - // Additional properties - & ( - AP extends false - // Additional properties off => no-op instead of empty record - ? Record - : AP extends true - // Additional properties on => unknown - ? { [key: string]: unknown } - : AP extends JSONSchema - // Additional properties is another schema => map them - ? { [key: string]: SchemaWithoutCell> } - : Record - ); +> = SchemaInner; /** * Fragment element name used for JSX fragments. diff --git a/packages/api/perf/.gitignore b/packages/api/perf/.gitignore new file mode 100644 index 000000000..ff6c9301f --- /dev/null +++ b/packages/api/perf/.gitignore @@ -0,0 +1 @@ +traces/ diff --git a/packages/api/perf/README.md b/packages/api/perf/README.md new file mode 100644 index 000000000..32f1212ea --- /dev/null +++ b/packages/api/perf/README.md @@ -0,0 +1,100 @@ +# API Type Profiling Harness + +This directory contains self‑contained TypeScript projects that measure how +expensive particular exported types from `packages/api/index.ts` are to check. +Each scenario lives in its own `.ts` file with a matching `tsconfig.*.json` so +that we can profile the types independently. + +## Quick Metrics + +Run the compiler with `--extendedDiagnostics` to get counts of type +instantiations, memory usage, etc. + +```bash +deno run -A npm:typescript@5.8.3/bin/tsc \ + --project packages/api/perf/tsconfig.key.json \ + --extendedDiagnostics --pretty false +``` + +Available projects: + +- `tsconfig.baseline.json` – empty control case for the load cost of + `packages/api/index.ts`. +- `tsconfig.key.json` – stresses `KeyResultType` + branded cell keying. +- `tsconfig.anycell.json` – focuses on `AnyCellWrapping` write helpers. +- `tsconfig.schema.json` – exercises the JSON schema conversion helpers. +- `tsconfig.ikeyable-cell.json` – heavy `IKeyable>` stress case. +- `tsconfig.ikeyable-schema.json` – `IKeyable` over `Cell>`. + +Each run prints metrics; compare the “Instantiations”, “Types”, and “Check time” +fields against the baseline to see relative cost. + +## CPU Profiles + +Use `--generateCpuProfile` to capture where the checker spends time. The profile +is a Chromium CPU profile you can open via DevTools ▸ Performance ▸ “Load +profile…”. + +```bash +NODE_OPTIONS=--max-old-space-size=4096 \ +deno run -A npm:typescript@5.8.3/bin/tsc \ + --project packages/api/perf/tsconfig.ikeyable-cell.json \ + --generateCpuProfile packages/api/perf/traces/ikeyable-cell.cpuprofile +``` + +Generated profiles are stored under `packages/api/perf/traces/`. Existing ones +include: + +- `ikeyable-cell.cpuprofile` +- `ikeyable-schema.cpuprofile` + +## Event Traces (Caution: Large) + +`--generateTrace ` produces Chrome trace JSON (`trace.json`) plus a +snapshot of instantiated types (`types.json`). These files grow quickly and can +exceed V8’s heap limit on the heavy scenarios. + +```bash +mkdir -p packages/api/perf/traces/ikeyable-cell \ + && NODE_OPTIONS=--max-old-space-size=4096 \ + deno run -A npm:typescript@5.8.3/bin/tsc \ + --project packages/api/perf/tsconfig.ikeyable-cell.json \ + --generateTrace packages/api/perf/traces/ikeyable-cell +``` + +If you hit an “out of memory” crash, try: + +- Lowering `max-old-space-size` so the compiler bails faster (you still get + partial traces), or +- Splitting the stress test into smaller files and tracing each individually. + +The lighter `tsconfig.ikeyable-cell-trace.json` target exists specifically for +trace generation; it keeps the scenario minimal enough to succeed. + +## Scripts / Analysis + +To run every scenario in one go (and print a short summary for each), run: + +```bash +deno run -A packages/api/perf/run-benchmarks.ts +``` + +For ad-hoc inspection of trace files you can also use Deno directly: + +```bash +deno eval 'import trace from "./packages/api/perf/traces/ikeyable-cell/trace.json" assert { type: "json" };\ +const totals = new Map();\ +for (const e of trace) if (e.ph === "X") totals.set(e.name, (totals.get(e.name) ?? 0) + e.dur);\ +console.log([...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10));' +``` + +Feel free to add your own utilities here if repeated analyses become necessary. + +## Tips + +- Always compare against `tsconfig.baseline.json` to understand the fixed cost + of loading the module. +- When experimenting with type changes, re-run the relevant scenario(s) to watch + how instantiation counts and profile hotspots move. +- For long-running traces, add `NODE_OPTIONS=--max-old-space-size=` to keep + Node from running out of memory mid-run. diff --git a/packages/api/perf/any_cell_wrapping.ts b/packages/api/perf/any_cell_wrapping.ts new file mode 100644 index 000000000..5f769191b --- /dev/null +++ b/packages/api/perf/any_cell_wrapping.ts @@ -0,0 +1,168 @@ +import type { AnyCellWrapping, Cell } from "../index.ts"; + +type Contacts = { + primaryEmail: string; + secondaryEmails: string[]; + phones: Array<{ label: string; value: string }>; +}; + +type Address = { + line1: string; + line2?: string; + city: string; + region: string; + postalCode: string; + location: { + lat: number; + lng: number; + }; +}; + +type Preference = { + marketing: boolean; + notifications: { + email: boolean; + sms: boolean; + push: boolean; + }; +}; + +type AuditEntry = { + id: string; + actor: string; + summary: string; + changes: Array<{ + field: string; + from: string | number | boolean | null; + to: string | number | boolean | null; + }>; +}; + +type Profile = { + displayName: string; + contacts: Contacts; + address: Address; + preference: Cell; +}; + +type InventoryItem = { + sku: string; + quantity: number; + supplier: Cell<{ + id: string; + rating: number; + contact: Contacts; + }>; + history: Array>; +}; + +type DomainModel = { + profile: Cell; + inventory: Array; + metadata: { + flags: Record; + contributors: Array; + version: number; + }; + logs: Array< + Cell<{ + timestamp: string; + scope: "profile" | "inventory" | "system"; + payload: { + before: Cell; + after: Cell; + diff: Array; + }; + }> + >; +} & { [key: string]: unknown }; + +type PrimaryWritePaths = AnyCellWrapping; + +type HistoryWritePaths = AnyCellWrapping< + Array< + Cell<{ + id: string; + snapshot: DomainModel; + related: Array>; + }> + > +>; + +type ParallelWritePaths = [ + AnyCellWrapping, + AnyCellWrapping, + AnyCellWrapping, +]; + +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; + +type SegmentKey = `segment_${Digit}${Digit}`; + +type MassiveDomain = { + [K in SegmentKey]: { + base: DomainModel; + variants: Array; + timeline: Array<{ + before: DomainModel; + after: DomainModel; + delta: Array; + }>; + }; +}; + +type StressWriteMatrix = { + [K in SegmentKey]: AnyCellWrapping<{ + key: K; + target: DomainModel; + history: MassiveDomain[K]; + neighborhood: { + [P in SegmentKey]: MassiveDomain[P]; + }; + layers: Array< + [ + AnyCellWrapping, + AnyCellWrapping, + AnyCellWrapping, + ] + >; + }>; +}; + +type StressWriteUnion = AnyCellWrapping<{ + seed: DomainModel; + mirror: MassiveDomain; + replicas: Array; + matrix: StressWriteMatrix; + ledger: Array<{ + id: string; + current: StressWriteMatrix[keyof StressWriteMatrix]; + previous: StressWriteMatrix[keyof StressWriteMatrix]; + }>; +}>; + +type StressWriteGrid = { + [K in SegmentKey]: { + [P in SegmentKey]: AnyCellWrapping<{ + source: MassiveDomain[K]; + target: MassiveDomain[P]; + pair: [DomainModel, DomainModel]; + diff: Array<{ + before: MassiveDomain[K]; + after: MassiveDomain[P]; + audit: Array; + }>; + }>; + }; +}; + +type StressWriteCross = + StressWriteGrid[keyof StressWriteGrid][keyof StressWriteGrid]; + +type StressWriteExpansion = AnyCellWrapping<{ + grid: StressWriteGrid; + cross: StressWriteCross; + matrix: StressWriteMatrix; + mirror: MassiveDomain; + registry: Record; +}>; diff --git a/packages/api/perf/baseline.ts b/packages/api/perf/baseline.ts new file mode 100644 index 000000000..28581a944 --- /dev/null +++ b/packages/api/perf/baseline.ts @@ -0,0 +1,3 @@ +import type { Cell } from "../index.ts"; + +type Baseline = Cell; diff --git a/packages/api/perf/ikeyable_cell.ts b/packages/api/perf/ikeyable_cell.ts new file mode 100644 index 000000000..f5c30f3b1 --- /dev/null +++ b/packages/api/perf/ikeyable_cell.ts @@ -0,0 +1,141 @@ +import type { AsCell, Cell, IKeyable } from "../index.ts"; + +type Reaction = { + type: Cell; + count: Cell; + lastUpdated: Cell; +}; + +type CommentThread = { + author: Cell; + text: Cell; + reactions: Cell>>; + replies: Cell>>; +}; + +type Profile = { + id: Cell; + info: Cell<{ + displayName: Cell; + biography: Cell; + location: Cell<{ + city: Cell; + region: Cell; + coordinates: Cell<{ lat: Cell; lng: Cell }>; + }>; + }>; + preferences: Cell<{ + notifications: Cell>>; + shortcuts: Cell>>; + }>; +}; + +type Post = { + id: Cell; + content: Cell; + tags: Cell>>; + comments: Cell>>; + analytics: Cell<{ + impressions: Cell; + conversions: Cell; + breakdown: Cell>>; + }>; +}; + +type RegistryEntry = { + version: Cell; + notes: Cell; + author: Cell; + metadata: Cell>>; +}; + +type ComplexValue = { + profile: Profile; + posts: Cell>>; + analytics: Cell<{ + totals: Cell<{ + views: Cell; + visitors: Cell; + watchTime: Cell; + }>; + trends: Cell; delta: Cell }>>>; + segments: Cell< + Record; score: Cell }>> + >; + }>; + registry: Cell<{ + active: Cell>>; + archived: Cell>>; + settings: Cell<{ + flags: Cell>>; + categories: Cell>>; + }>; + }>; + timeline: Cell< + Array< + Cell<{ + at: Cell; + state: Cell<{ + profile: Profile; + headline: Cell; + metrics: Cell<{ score: Cell; level: Cell }>; + }>; + }> + > + >; +}; + +type ComplexKeyable = IKeyable, AsCell>; + +type KeyAccess = ComplexKeyable["key"] extends + (key: K) => infer R ? R : never; + +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; + +type StressLiteral = + | keyof ComplexValue + | `alias_${keyof ComplexValue & string}` + | `shadow_${Digit}${Digit}` + | `custom_${Digit}${Digit}`; + +type StressKeyMatrix = { + [K in StressLiteral]: { + direct: KeyAccess; + widened: KeyAccess; + propertyKey: KeyAccess; + doubleAlias: KeyAccess; + cross: { + [P in StressLiteral]: KeyAccess< + K | P | "profile" | "analytics" | "registry" | `${P & string}_extra` + >; + }; + }; +}; + +type StressKeyUnion = + StressKeyMatrix[keyof StressKeyMatrix]["cross"][keyof StressKeyMatrix]; + +type StressKeySummary = { + entries: StressKeyUnion; + literal: KeyAccess<"profile" | "posts" | "analytics" | "registry">; + unionized: KeyAccess; + fallback: KeyAccess; + nested: KeyAccess<`${keyof ComplexValue & string}_${Digit}${Digit}`>; +}; + +type StressKeyGrid = { + [K in StressLiteral]: [ + KeyAccess, + KeyAccess, + KeyAccess, + KeyAccess, + KeyAccess, + ]; +}; + +type StressKeyExpansion = [ + StressKeyMatrix, + StressKeyUnion, + StressKeySummary, + StressKeyGrid[keyof StressKeyGrid], +]; diff --git a/packages/api/perf/ikeyable_cell_trace.ts b/packages/api/perf/ikeyable_cell_trace.ts new file mode 100644 index 000000000..c7c2a41b2 --- /dev/null +++ b/packages/api/perf/ikeyable_cell_trace.ts @@ -0,0 +1,34 @@ +import type { AsCell, Cell, IKeyable } from "../index.ts"; + +type SampleValue = { + profile: { + name: Cell; + stats: Cell<{ + followers: Cell; + score: Cell; + }>; + }; + posts: Cell< + Array< + Cell<{ + title: Cell; + reactions: Cell<{ + likes: Cell; + dislikes: Cell; + }>; + }> + > + >; + registry: Cell>>; +}; + +type SampleKeyable = IKeyable, AsCell>; + +type Access = SampleKeyable["key"] extends + (key: K) => infer R ? R : never; + +type ProfileAccess = Access<"profile">; +type PostsAccess = Access<"posts">; +type RegistryAccess = Access<"registry">; +type UnionAccess = Access<"profile" | "posts">; +type FallbackAccess = Access; diff --git a/packages/api/perf/ikeyable_realistic.ts b/packages/api/perf/ikeyable_realistic.ts new file mode 100644 index 000000000..64a48b8c5 --- /dev/null +++ b/packages/api/perf/ikeyable_realistic.ts @@ -0,0 +1,186 @@ +import type { + AsCell, + Cell, + KeyResultType, + ReadonlyCell, + Stream, +} from "../index.ts"; + +type NotificationPrefs = { + email: boolean; + push: boolean; + sms: boolean; +}; + +type UserProfile = { + id: string; + name: string; + email: string; + isAdmin: boolean; + avatarUrl?: string; + stats: { + followers: number; + following: number; + posts: number; + lastLogin: string; + engagementScore: ReadonlyCell; + liveFeed: Stream; + }; + settings: { + theme: "light" | "dark"; + locale: string; + }; + notificationPrefs: Cell; + preferences: ReadonlyCell<{ + compactMode: boolean; + language: string; + }>; + nested: { + audit: Cell< + ReadonlyCell<{ + lastUpdatedBy: string; + lastUpdatedAt: string; + }> + >; + }; +}; + +type UserProfileCell = Cell; + +declare const user: UserProfileCell; + +// Literal keys – most common usage +const _literalId = user.key("id"); +const _literalStats = user.key("stats"); +const _literalSettings = user.key("settings"); +const _literalPrefs = user.key("preferences"); +const _literalNotificationPrefs = user.key("notificationPrefs"); + +// Nested literal access +const _statsFollowers = user.key("stats").key("followers"); +const _statsEngagement = user.key("stats").key("engagementScore"); +const _statsLiveFeed = user.key("stats").key("liveFeed"); +const _settingsTheme = user.key("settings").key("theme"); +const _notificationEmail = user.key("notificationPrefs").key("email"); +const _nestedAuditUser = user.key("nested").key("audit").key("lastUpdatedBy"); + +// Dynamic string keys (fallback to any) +declare const stringKey: string; +const _stringAccess = user.key(stringKey); +const _nestedViaString = user.key(stringKey).key("theme"); + +// Random access of flags to simulate loops with string inputs +declare const runtimeKeys: string[]; +for (const key of runtimeKeys) { + user.key(key); +} + +// Helper types to amplify compile-time KeyResultType usage +type AmplifyKeys< + Source, + Keys extends readonly PropertyKey[], +> = { + [Index in Extract]: KeyResultType< + Source, + Keys[Index], + AsCell + >; +}; + +type TopLevelKeys = [ + "id", + "name", + "email", + "stats", + "preferences", + "notificationPrefs", +]; +type AmplifiedTopLevel = AmplifyKeys; + +type StatsKeys = ["followers", "engagementScore", "liveFeed", "lastLogin"]; +type AmplifiedStats = AmplifyKeys< + KeyResultType, + StatsKeys +>; + +type NotificationKeys = ["email", "push", "sms"]; +type AmplifiedNotifications = AmplifyKeys< + KeyResultType, + NotificationKeys +>; + +// Recursive helper to exercise longer key chains +type KeyChain< + Source, + Keys extends readonly PropertyKey[], +> = Keys extends + readonly [infer Head extends PropertyKey, ...infer Tail extends PropertyKey[]] + ? KeyChain, Tail> + : Source; + +type FrequentPaths = [ + ["stats", "followers"], + ["stats", "engagementScore"], + ["stats", "liveFeed"], + ["notificationPrefs", "email"], + ["notificationPrefs", "push"], + ["preferences", "language"], + ["nested", "audit", "lastUpdatedBy"], + ["nested", "audit", "lastUpdatedAt"], +]; + +type AmplifiedPaths = [ + KeyChain, + KeyChain, + KeyChain, + KeyChain, + KeyChain, + KeyChain, + KeyChain, + KeyChain, +]; + +// Variation with ReadonlyCell/Stream wrapped in Cells +type Timeline = { + timestamp: string; + action: string; + actor: string; +}; + +type ActivityCell = Cell<{ + recent: ReadonlyCell; + events: Stream; +}>; + +type Account = { + profile: UserProfile; + activity: ActivityCell; + emergencyContacts: Cell[]>; +}; + +type AccountCell = Cell; +declare const account: AccountCell; + +const _accountProfile = account.key("profile"); +const _accountActivityRecent = account.key("activity").key("recent"); +const _accountActivityEvents = account.key("activity").key("events"); +const _accountEmergencyContacts = account.key("emergencyContacts"); + +// Additional amplification on account structure +type AccountPaths = [ + ["profile", "stats", "followers"], + ["profile", "notificationPrefs", "email"], + ["profile", "preferences", "language"], + ["activity", "recent"], + ["activity", "events"], + ["emergencyContacts"], +]; + +type AmplifiedAccountPaths = [ + KeyChain, + KeyChain, + KeyChain, + KeyChain, + KeyChain, + KeyChain, +]; diff --git a/packages/api/perf/ikeyable_schema.ts b/packages/api/perf/ikeyable_schema.ts new file mode 100644 index 000000000..ce3d40e94 --- /dev/null +++ b/packages/api/perf/ikeyable_schema.ts @@ -0,0 +1,241 @@ +import type { AsCell, Cell, IKeyable, JSONSchema, Schema } from "../index.ts"; + +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; +type OverrideKey = `override_${Digit}${Digit}`; +type VariantKey = `variant_${Digit}${Digit}`; +type ConfigKey = `config_${Digit}${Digit}`; + +type OverrideProperties = { + readonly [K in OverrideKey]: { readonly $ref: "#/$defs/config" }; +}; + +type ModuleProperties = { + readonly id: { readonly type: "string" }; + readonly config: { + readonly anyOf: readonly [ + { readonly $ref: "#/$defs/config" }, + { readonly $ref: "#/$defs/advancedConfig" }, + { + readonly type: "object"; + readonly properties: { + readonly fallback: { readonly type: "boolean" }; + readonly ref: { readonly $ref: "#/$defs/config" }; + }; + readonly additionalProperties: false; + }, + ]; + }; + readonly overrides: { + readonly type: "object"; + readonly properties: OverrideProperties; + }; +}; + +type ModulesSchema = { + readonly type: "array"; + readonly minItems: 1; + readonly items: { + readonly type: "object"; + readonly required: readonly ["id", "config"]; + readonly properties: ModuleProperties; + }; +}; + +type RegistryPattern = Readonly< + Record<`^mod-${Digit}${Digit}$`, { readonly $ref: "#/$defs/advancedConfig" }> +>; + +type RegistrySchema = { + readonly type: "object"; + readonly properties: { + readonly latest: { readonly $ref: "#/$defs/config" }; + readonly archived: { + readonly type: "array"; + readonly items: { readonly $ref: "#/$defs/history" }; + }; + }; + readonly additionalProperties: { readonly $ref: "#/$defs/config" }; + readonly patternProperties: RegistryPattern; +}; + +type StressDefs = + & { + readonly config: { + readonly type: "object"; + readonly properties: { + readonly mode: { readonly type: "string" }; + readonly retries: { readonly type: "number" }; + readonly enabled: { readonly type: "boolean" }; + }; + readonly required: readonly ["mode"]; + }; + readonly advancedConfig: { + readonly allOf: readonly [ + { readonly $ref: "#/$defs/config" }, + { + readonly type: "object"; + readonly properties: { + readonly timeout: { readonly type: "number" }; + readonly tags: { + readonly type: "array"; + readonly items: { readonly type: "string" }; + }; + }; + }, + ]; + }; + readonly history: { + readonly type: "array"; + readonly items: { + readonly type: "object"; + readonly properties: { + readonly version: { readonly type: "number" }; + readonly snapshot: { readonly $ref: "#/$defs/config" }; + }; + }; + }; + } + & { + readonly [K in ConfigKey]: { + readonly type: "object"; + readonly properties: { + readonly ref: { readonly $ref: "#/properties/registry" }; + readonly next: { readonly $ref: "#/$defs/config" }; + readonly extended: { + readonly anyOf: readonly [ + { readonly $ref: "#/$defs/config" }, + { readonly $ref: "#/$defs/advancedConfig" }, + ]; + }; + }; + }; + }; + +type StressSchema = { + readonly type: "object"; + readonly required: readonly ["modules", "registry"]; + readonly properties: { + readonly modules: ModulesSchema; + readonly registry: RegistrySchema; + readonly states: { + readonly type: "array"; + readonly items: { readonly $ref: "#/$defs/history" }; + }; + readonly variants: { + readonly type: "object"; + readonly properties: { + readonly [K in VariantKey]: { + readonly $ref: "#/$defs/config"; + }; + }; + }; + }; + readonly additionalProperties: { + readonly anyOf: readonly [ + { readonly type: "null" }, + { readonly $ref: "#/$defs/config" }, + ]; + }; + readonly allOf: readonly [ + { + readonly if: { + readonly properties: { + readonly registry: { + readonly properties: { + readonly latest: { readonly const: "legacy" }; + }; + }; + }; + }; + readonly then: { + readonly properties: { + readonly modules: { readonly maxItems: 1 }; + }; + }; + }, + { + readonly anyOf: readonly [ + { readonly required: readonly ["modules"] }, + { readonly required: readonly ["registry"] }, + ]; + }, + ]; + readonly $defs: StressDefs; +} & JSONSchema; + +type SchemaValue = Schema; +type SchemaCell = Cell; +type SchemaKeyable = IKeyable; + +type SchemaKeyAccess = SchemaKeyable["key"] extends + (key: K) => infer R ? R : never; + +type SchemaDirectKeys = keyof SchemaValue & string; + +type SchemaStressLiteral = + | SchemaDirectKeys + | OverrideKey + | VariantKey + | ConfigKey + | `dynamic_${Digit}${Digit}`; + +type VariantSchemas = { + [K in VariantKey]: Schema; +}; + +type VariantKeyables = { + [K in VariantKey]: IKeyable, AsCell>; +}; + +type SchemaStressMatrix = { + [K in SchemaStressLiteral]: { + direct: SchemaKeyAccess; + widened: SchemaKeyAccess; + propertyKey: SchemaKeyAccess; + composed: SchemaKeyAccess; + variant: { + [P in keyof VariantKeyables]: VariantKeyables[P]["key"] extends ( + key: K | P, + ) => infer R ? R + : never; + }; + cascade: { + [P in SchemaStressLiteral]: SchemaKeyAccess< + K | P | "modules" | "registry" | "states" + >; + }; + }; +}; + +type SchemaStressUnion = + SchemaStressMatrix[keyof SchemaStressMatrix]["cascade"][ + keyof SchemaStressMatrix + ]; + +type SchemaStressSummary = { + entries: SchemaStressUnion; + literal: SchemaKeyAccess<"modules" | "registry" | "states" | "variants">; + variantUnion: VariantKeyables[keyof VariantKeyables]["key"] extends ( + key: infer K, + ) => infer R ? (K extends PropertyKey ? R : never) + : never; + fallback: SchemaKeyAccess; + dynamic: SchemaKeyAccess; +}; + +type SchemaStressGrid = { + [K in SchemaStressLiteral]: [ + SchemaKeyAccess, + SchemaKeyAccess, + SchemaKeyAccess, + SchemaKeyAccess, + SchemaKeyAccess, + ]; +}; + +type SchemaStressExpansion = [ + SchemaStressMatrix, + SchemaStressUnion, + SchemaStressSummary, + SchemaStressGrid[keyof SchemaStressGrid], +]; diff --git a/packages/api/perf/key_result_type.ts b/packages/api/perf/key_result_type.ts new file mode 100644 index 000000000..ac7206371 --- /dev/null +++ b/packages/api/perf/key_result_type.ts @@ -0,0 +1,143 @@ +import type { AsCell, Cell, KeyResultType } from "../index.ts"; + +type ReactionCell = Cell<{ + type: Cell; + count: Cell; +}>; + +type PostCell = Cell<{ + id: Cell; + content: Cell; + metadata: Cell<{ + createdAt: Cell; + reactions: Cell>; + history: Cell< + Array< + Cell<{ + version: Cell; + summary: Cell; + }> + > + >; + }>; +}>; + +type AddressCell = Cell<{ + street: Cell; + city: Cell; + coordinates: Cell<{ + lat: Cell; + lng: Cell; + }>; +}>; + +type ProfileCell = Cell<{ + displayName: Cell; + biography: Cell; + addresses: Cell>; + preferences: Cell<{ + notifications: Cell<{ + email: Cell; + push: Cell; + sms: Cell; + }>; + theme: Cell; + }>; +}>; + +type ComplexCellValue = { + profile: ProfileCell; + posts: Cell>; + stats: Cell<{ + followers: Cell; + following: Cell; + tags: Cell>>; + }>; + misc: Cell<{ + flags: Cell>>; + lastUpdated: Cell; + }>; +}; + +type ComplexCell = Cell; + +type LiteralKeys = { + profile: KeyResultType; + posts: KeyResultType; + stats: KeyResultType; + miscFlags: KeyResultType< + Cell<{ misc: ComplexCellValue["misc"] }>, + "misc", + AsCell + >; +}; + +type UnionKeys = KeyResultType; + +type FallbackKeys = KeyResultType; + +type SymbolKeys = KeyResultType; + +type PropertyKeyAccess = KeyResultType; + +type NestedProfiles = KeyResultType< + Cell<{ + users: Cell< + Array< + Cell<{ + profile: ProfileCell; + posts: Cell>; + }> + > + >; + }>, + "users", + AsCell +>; + +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; + +type StressLiteral = + | keyof ComplexCellValue + | `alias_${keyof ComplexCellValue & string}` + | `shadow_${Digit}${Digit}` + | `custom_${Digit}${Digit}`; + +type StressRecordCell = Cell<{ [K in StressLiteral]: ComplexCellValue }>; + +type StressMatrix = { + [K in StressLiteral]: { + direct: KeyResultType; + spread: KeyResultType; + cross: { + [P in StressLiteral]: KeyResultType< + Cell<{ + primary: ComplexCellValue; + secondary: ComplexCellValue; + registry: Record; + }>, + K | P | "primary" | "secondary" | "registry", + AsCell + >; + }; + }; +}; + +type StressCrossUnion = + StressMatrix[keyof StressMatrix]["cross"][keyof StressMatrix]; + +type StressSummary = { + entries: StressCrossUnion; + mapped: { + [K in StressLiteral]: KeyResultType< + Cell>, + K, + AsCell + >; + }; + fallback: KeyResultType< + ComplexCell, + StressLiteral | `${StressLiteral & string}_fallback`, + AsCell + >; +}; diff --git a/packages/api/perf/run-benchmarks.ts b/packages/api/perf/run-benchmarks.ts new file mode 100644 index 000000000..90f530d50 --- /dev/null +++ b/packages/api/perf/run-benchmarks.ts @@ -0,0 +1,75 @@ +#!/usr/bin/env -S deno run -A + +const CONFIGS = [ + "tsconfig.baseline.json", + "tsconfig.key.json", + "tsconfig.anycell.json", + "tsconfig.schema.json", + "tsconfig.ikeyable-cell.json", + "tsconfig.ikeyable-schema.json", + "tsconfig.ikeyable-realistic.json", +] as const; + +const scriptDir = new URL(".", import.meta.url); +const cwd = scriptDir.pathname; + +function fromFileUrl(url: URL): string { + if (url.protocol !== "file:") throw new TypeError("URL must be a file URL"); + const path = decodeURIComponent(url.pathname); + if (Deno.build.os === "windows") { + return path.slice(1).replaceAll("/", "\\"); + } + return path; +} + +const tscPath = fromFileUrl( + new URL( + "../../../node_modules/.deno/typescript@5.8.3/node_modules/typescript/bin/tsc", + import.meta.url, + ), +); + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +async function runScenario(config: string) { + console.log(`# ${config}`); + + const command = new Deno.Command(tscPath, { + args: ["--project", config, "--extendedDiagnostics", "--pretty", "false"], + cwd, + }); + + const { code, stdout, stderr } = await command.output(); + if (code !== 0) { + const err = decoder.decode(stderr); + console.error(err); + throw new Error(`Benchmark failed for ${config}`); + } + + const output = decoder.decode(stdout); + console.log(output); + + const summary: string[] = []; + for (const line of output.split("\n")) { + if (line.startsWith("Instantiations:")) { + summary.push(line.trim()); + } else if (line.startsWith("Check time:")) { + summary.push(line.trim()); + } + } + + if (summary.length > 0) { + await Deno.stdout.write( + encoder.encode( + `${summary.join(" | ")}\n----------------------------------------\n\n`, + ), + ); + } else { + console.log("----------------------------------------\n"); + } +} + +for (const config of CONFIGS) { + await runScenario(config); +} diff --git a/packages/api/perf/schema.ts b/packages/api/perf/schema.ts new file mode 100644 index 000000000..e80fe58b1 --- /dev/null +++ b/packages/api/perf/schema.ts @@ -0,0 +1,329 @@ +import type { JSONSchema, Schema, SchemaWithoutCell } from "../index.ts"; + +type ComplexSchema = { + readonly $id: "Root"; + readonly type: "object"; + readonly required: readonly ["profile", "items", "status"]; + readonly properties: { + readonly profile: { + readonly type: "object"; + readonly required: readonly ["name", "address"]; + readonly properties: { + readonly name: { + readonly type: "string"; + readonly default: "Anonymous"; + }; + readonly address: { readonly $ref: "#/$defs/address" }; + readonly preferences: { + readonly $ref: "#/$defs/preferences"; + readonly asCell: true; + }; + }; + }; + readonly items: { + readonly type: "array"; + readonly items: { readonly $ref: "#/$defs/item" }; + }; + readonly status: { + readonly enum: readonly ["active", "inactive", "pending"]; + }; + readonly timeline: { + readonly anyOf: readonly [ + { + readonly type: "array"; + readonly items: { readonly $ref: "#/$defs/event" }; + }, + { readonly type: "null" }, + ]; + }; + }; + readonly additionalProperties: { readonly type: "string" }; + readonly $defs: { + readonly address: { + readonly type: "object"; + readonly required: readonly ["street", "city", "coordinates"]; + readonly properties: { + readonly street: { readonly type: "string" }; + readonly city: { readonly type: "string" }; + readonly coordinates: { readonly $ref: "#/$defs/coordinates" }; + }; + }; + readonly coordinates: { + readonly type: "object"; + readonly properties: { + readonly lat: { readonly type: "number" }; + readonly lng: { readonly type: "number" }; + }; + }; + readonly preferences: { + readonly type: "object"; + readonly properties: { + readonly notifications: { + readonly type: "object"; + readonly required: readonly ["email", "sms", "push"]; + readonly properties: { + readonly email: { + readonly type: "boolean"; + readonly default: false; + }; + readonly sms: { readonly type: "boolean"; readonly default: false }; + readonly push: { readonly type: "boolean"; readonly default: true }; + }; + }; + readonly tags: { + readonly type: "array"; + readonly items: { readonly type: "string" }; + }; + }; + readonly asStream: true; + }; + readonly item: { + readonly type: "object"; + readonly required: readonly ["id", "quantity", "metadata"]; + readonly properties: { + readonly id: { readonly type: "string" }; + readonly quantity: { readonly type: "number" }; + readonly metadata: { + readonly anyOf: readonly [ + { readonly $ref: "#/$defs/itemMetadata" }, + { readonly type: "null" }, + ]; + }; + }; + }; + readonly event: { + readonly type: "object"; + readonly properties: { + readonly kind: { + readonly enum: readonly ["created", "updated", "deleted"]; + }; + readonly at: { readonly type: "string" }; + readonly payload: { + readonly type: "object"; + readonly properties: { + readonly summary: { readonly type: "string" }; + readonly actor: { readonly type: "string" }; + }; + }; + }; + }; + readonly itemMetadata: { + readonly type: "object"; + readonly properties: { + readonly manufacturer: { readonly type: "string" }; + readonly warrantyMonths: { readonly type: "number" }; + readonly extras: { + readonly type: "array"; + readonly items: { + readonly type: "object"; + readonly properties: { + readonly code: { readonly type: "string" }; + readonly expires: { readonly type: "string" }; + }; + }; + }; + }; + }; + }; +} & JSONSchema; + +type ReactiveResult = Schema; + +type PlainResult = SchemaWithoutCell; + +type NestedRefResult = Schema< + { + readonly type: "object"; + readonly properties: { + readonly root: { readonly $ref: "#/$defs/root" }; + }; + readonly $defs: { + readonly root: { + readonly $ref: "#/$defs/node"; + }; + readonly node: { + readonly type: "object"; + readonly properties: { + readonly value: { readonly type: "string" }; + readonly children: { + readonly type: "array"; + readonly items: { readonly $ref: "#/$defs/node" }; + }; + }; + }; + }; + } & JSONSchema +>; + +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; + +type OverrideKey = `override_${Digit}${Digit}`; +type VariantKey = `variant_${Digit}${Digit}`; +type ConfigKey = `config_${Digit}${Digit}`; + +type OverrideProperties = { + readonly [K in OverrideKey]: { readonly $ref: "#/$defs/config" }; +}; + +type ModuleProperties = { + readonly id: { readonly type: "string" }; + readonly config: { + readonly anyOf: readonly [ + { readonly $ref: "#/$defs/config" }, + { readonly $ref: "#/$defs/advancedConfig" }, + { + readonly type: "object"; + readonly properties: { + readonly fallback: { readonly type: "boolean" }; + readonly ref: { readonly $ref: "#/$defs/config" }; + }; + readonly additionalProperties: false; + }, + ]; + }; + readonly overrides: { + readonly type: "object"; + readonly properties: OverrideProperties; + }; +}; + +type ModulesSchema = { + readonly type: "array"; + readonly minItems: 1; + readonly items: { + readonly type: "object"; + readonly required: readonly ["id", "config"]; + readonly properties: ModuleProperties; + }; +}; + +type RegistryPattern = Readonly< + Record<`^mod-${Digit}${Digit}$`, { readonly $ref: "#/$defs/advancedConfig" }> +>; + +type RegistrySchema = { + readonly type: "object"; + readonly properties: { + readonly latest: { readonly $ref: "#/$defs/config" }; + readonly archived: { + readonly type: "array"; + readonly items: { readonly $ref: "#/$defs/history" }; + }; + }; + readonly additionalProperties: { readonly $ref: "#/$defs/config" }; + readonly patternProperties: RegistryPattern; +}; + +type StressDefs = + & { + readonly config: { + readonly type: "object"; + readonly properties: { + readonly mode: { readonly type: "string" }; + readonly retries: { readonly type: "number" }; + readonly enabled: { readonly type: "boolean" }; + }; + readonly required: readonly ["mode"]; + }; + readonly advancedConfig: { + readonly allOf: readonly [ + { readonly $ref: "#/$defs/config" }, + { + readonly type: "object"; + readonly properties: { + readonly timeout: { readonly type: "number" }; + readonly tags: { + readonly type: "array"; + readonly items: { readonly type: "string" }; + }; + }; + }, + ]; + }; + readonly history: { + readonly type: "array"; + readonly items: { + readonly type: "object"; + readonly properties: { + readonly version: { readonly type: "number" }; + readonly snapshot: { readonly $ref: "#/$defs/config" }; + }; + }; + }; + } + & { + readonly [K in ConfigKey]: { + readonly type: "object"; + readonly properties: { + readonly ref: { readonly $ref: "#/properties/registry" }; + readonly next: { readonly $ref: "#/$defs/config" }; + readonly extended: { + readonly anyOf: readonly [ + { readonly $ref: "#/$defs/config" }, + { readonly $ref: "#/$defs/advancedConfig" }, + ]; + }; + }; + }; + }; + +type StressSchema = { + readonly type: "object"; + readonly required: readonly ["modules", "registry"]; + readonly properties: { + readonly modules: ModulesSchema; + readonly registry: RegistrySchema; + readonly states: { + readonly type: "array"; + readonly items: { readonly $ref: "#/$defs/history" }; + }; + }; + readonly allOf: readonly [ + { + readonly if: { + readonly properties: { + readonly registry: { + readonly properties: { + readonly latest: { readonly const: "legacy" }; + }; + }; + }; + }; + readonly then: { + readonly properties: { + readonly modules: { readonly maxItems: 1 }; + }; + }; + }, + { + readonly anyOf: readonly [ + { readonly required: readonly ["modules"] }, + { readonly required: readonly ["registry"] }, + ]; + }, + ]; + readonly $defs: StressDefs; +} & JSONSchema; + +type StressSchemaResult = Schema; +type StressSchemaVariants = { [K in VariantKey]: Schema }; +type StressSchemaUnion = StressSchemaVariants[keyof StressSchemaVariants]; +type StressSchemaWithoutCells = SchemaWithoutCell; +type StressSchemaCombined = [ + StressSchemaResult, + StressSchemaUnion, + StressSchemaWithoutCells, +]; + +type ParameterizedSchema = Schema< + StressSchema & { readonly title: L } +>; + +type StressSchemaMatrix = { + [K in VariantKey]: { + [P in VariantKey]: ParameterizedSchema<`${K}-${P}`>; + }; +}; + +type StressSchemaCross = + StressSchemaMatrix[keyof StressSchemaMatrix][keyof StressSchemaMatrix]; diff --git a/packages/api/perf/tsconfig.anycell.json b/packages/api/perf/tsconfig.anycell.json new file mode 100644 index 000000000..d684eea72 --- /dev/null +++ b/packages/api/perf/tsconfig.anycell.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./any_cell_wrapping.ts" + ] +} diff --git a/packages/api/perf/tsconfig.base.json b/packages/api/perf/tsconfig.base.json new file mode 100644 index 000000000..928f1dc3e --- /dev/null +++ b/packages/api/perf/tsconfig.base.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "Node16", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "pretty": false, + "incremental": false + } +} diff --git a/packages/api/perf/tsconfig.baseline.json b/packages/api/perf/tsconfig.baseline.json new file mode 100644 index 000000000..89b82a5fd --- /dev/null +++ b/packages/api/perf/tsconfig.baseline.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./baseline.ts" + ] +} diff --git a/packages/api/perf/tsconfig.ikeyable-cell-trace.json b/packages/api/perf/tsconfig.ikeyable-cell-trace.json new file mode 100644 index 000000000..d242a9d88 --- /dev/null +++ b/packages/api/perf/tsconfig.ikeyable-cell-trace.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./ikeyable_cell_trace.ts" + ] +} diff --git a/packages/api/perf/tsconfig.ikeyable-cell.json b/packages/api/perf/tsconfig.ikeyable-cell.json new file mode 100644 index 000000000..7c94a2dd9 --- /dev/null +++ b/packages/api/perf/tsconfig.ikeyable-cell.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./ikeyable_cell.ts" + ] +} diff --git a/packages/api/perf/tsconfig.ikeyable-realistic.json b/packages/api/perf/tsconfig.ikeyable-realistic.json new file mode 100644 index 000000000..dcfa24ad1 --- /dev/null +++ b/packages/api/perf/tsconfig.ikeyable-realistic.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./ikeyable_realistic.ts" + ] +} diff --git a/packages/api/perf/tsconfig.ikeyable-schema.json b/packages/api/perf/tsconfig.ikeyable-schema.json new file mode 100644 index 000000000..8734d7c11 --- /dev/null +++ b/packages/api/perf/tsconfig.ikeyable-schema.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./ikeyable_schema.ts" + ] +} diff --git a/packages/api/perf/tsconfig.key.json b/packages/api/perf/tsconfig.key.json new file mode 100644 index 000000000..c98dc647c --- /dev/null +++ b/packages/api/perf/tsconfig.key.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./key_result_type.ts" + ] +} diff --git a/packages/api/perf/tsconfig.schema.json b/packages/api/perf/tsconfig.schema.json new file mode 100644 index 000000000..231a0be0a --- /dev/null +++ b/packages/api/perf/tsconfig.schema.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./schema.ts" + ] +} diff --git a/packages/background-charm-service/src/worker.ts b/packages/background-charm-service/src/worker.ts index 816986c5e..093b6d455 100644 --- a/packages/background-charm-service/src/worker.ts +++ b/packages/background-charm-service/src/worker.ts @@ -181,7 +181,7 @@ async function runCharm(data: RunData): Promise { } // Find the updater stream - const updater = runningCharm.key("bgUpdater") as Stream; + const updater = runningCharm.key("bgUpdater") as unknown as Stream; if (!updater || !isStream(updater)) { throw new Error(`No updater stream found for charm: ${charmId}`); } diff --git a/packages/charm/src/iterate.ts b/packages/charm/src/iterate.ts index a31a41936..cd7dce1fd 100644 --- a/packages/charm/src/iterate.ts +++ b/packages/charm/src/iterate.ts @@ -210,7 +210,7 @@ export const generateNewRecipeVersion = async ( newCharm.withTx(tx).getSourceCell(charmSourceCellSchema)?.key("lineage") .push( { - charm: parent, + charm: parent as Cell<{ [x: string]: unknown }>, relation: "iterate", timestamp: Date.now(), }, diff --git a/packages/cli/lib/charm.ts b/packages/cli/lib/charm.ts index dc8f937d9..7481c98a3 100644 --- a/packages/cli/lib/charm.ts +++ b/packages/cli/lib/charm.ts @@ -1,7 +1,13 @@ import { ANYONE, Identity, Session } from "@commontools/identity"; import { ensureDir } from "@std/fs"; import { loadIdentity } from "./identity.ts"; -import { isStream, Runtime, RuntimeProgram, UI } from "@commontools/runner"; +import { + isStream, + Runtime, + RuntimeProgram, + type Stream, + UI, +} from "@commontools/runner"; import { StorageManager } from "@commontools/runner/storage/cache"; import { charmId, CharmManager, extractUserCode } from "@commontools/charm"; import { CharmsController } from "@commontools/charm/ops"; @@ -490,7 +496,9 @@ export async function callCharmHandler( }, required: [handlerName], }); - const handlerStream = cell.key(handlerName); + + // Manual cast because typescript can't infer this automatically + const handlerStream = cell.key(handlerName) as unknown as Stream; // The handlerStream should be the actual stream object if (!isStream(handlerStream)) { diff --git a/packages/html/src/jsx.d.ts b/packages/html/src/jsx.d.ts index 982f79587..419d490f9 100644 --- a/packages/html/src/jsx.d.ts +++ b/packages/html/src/jsx.d.ts @@ -1,16 +1,7 @@ // Upstream DOM types use "{}" intentionally -- // disable lint for this type // deno-lint-ignore-file ban-types -import type { - Cell, - Opaque, - OpaqueRef, - OpaqueRefMethods, - Props, - RenderNode, - Stream, - VNode, -} from "commontools"; +import type { Cell, CellLike, Props, RenderNode, VNode } from "commontools"; // DOM-ish types for the CT runtime. // The DOM is not directly available within the runtime, but the JSX @@ -2789,19 +2780,6 @@ declare namespace CTDOM { } } -// Helper type that allows any combination of OpaqueRef, Cell, and Stream wrappers -// Supports arbitrary nesting like OpaqueRef>> -type InnerCellLike = - | OpaqueRefMethods - | Opaque - | OpaqueRef - | Cell - | Stream; -type CellLike = - | InnerCellLike - | InnerCellLike> - | InnerCellLike; - interface CTHTMLElement extends CTDOM.HTMLElement {} // Extend this to add attributes to only the CT elements. interface CTHTMLAttributes extends CTDOM.HTMLAttributes {} diff --git a/packages/patterns/llm.tsx b/packages/patterns/llm.tsx index 5604a880b..594ba4096 100644 --- a/packages/patterns/llm.tsx +++ b/packages/patterns/llm.tsx @@ -1,6 +1,7 @@ /// import { BuiltInLLMContent, + BuiltInLLMMessage, Cell, cell, Default, @@ -39,7 +40,10 @@ export default recipe("LLM Test", ({ title }) => { const llmResponse = llm({ system: "You are a helpful assistant. Answer questions clearly and concisely.", - messages: derive(question, (q) => q ? [{ role: "user", content: q }] : []), + messages: derive( + question, + (q) => q ? [{ role: "user", content: q }] : [], + ), }); return { diff --git a/packages/patterns/note.tsx b/packages/patterns/note.tsx index bf271f246..95992645a 100644 --- a/packages/patterns/note.tsx +++ b/packages/patterns/note.tsx @@ -1,15 +1,16 @@ /// import { - Cell, + type BuiltInLLMMessage, + type Cell, cell, - Default, + type Default, derive, handler, llm, NAME, navigateTo, - Opaque, - OpaqueRef, + type Opaque, + type OpaqueRef, patternTool, recipe, str, @@ -176,12 +177,13 @@ const Note = recipe( ) => { const result = llm({ system: str`Translate the content to ${language}.`, - messages: derive(content, (c) => [ - { - role: "user", - content: c, - }, - ]), + messages: derive(content, (c) => + [ + { + role: "user", + content: c, + }, + ] satisfies BuiltInLLMMessage[]), }); return derive(result, ({ pending, result }) => { diff --git a/packages/runner/integration/array_push.test.ts b/packages/runner/integration/array_push.test.ts index 17f734a62..8700280e0 100644 --- a/packages/runner/integration/array_push.test.ts +++ b/packages/runner/integration/array_push.test.ts @@ -9,7 +9,7 @@ import { ANYONE, Identity, Session } from "@commontools/identity"; import { env } from "@commontools/integration"; import { StorageManager } from "../src/storage/cache.ts"; -import { Runtime } from "../src/index.ts"; +import { Runtime, Stream } from "../src/index.ts"; import { CharmManager, compileRecipe } from "@commontools/charm"; (Error as any).stackTraceLimit = 100; @@ -114,8 +114,12 @@ async function runTest() { // await new Promise((resolve) => setTimeout(resolve, 10000)); // Get the handler stream and send some numbers - const pushNumbersHandlerStream = charm.key("pushNumbersHandler"); - const pushObjectsHandlerStream = charm.key("pushObjectsHandler"); + const pushNumbersHandlerStream = charm.key( + "pushNumbersHandler", + ) as unknown as Stream<{ value: number }>; + const pushObjectsHandlerStream = charm.key( + "pushObjectsHandler", + ) as unknown as Stream<{ value: { count: number } }>; const expectedNumbers = []; const expectedObjects = []; diff --git a/packages/runner/src/builder/built-in.ts b/packages/runner/src/builder/built-in.ts index a32b1a46e..02c1b912f 100644 --- a/packages/runner/src/builder/built-in.ts +++ b/packages/runner/src/builder/built-in.ts @@ -19,6 +19,7 @@ import type { BuiltInLLMParams, BuiltInLLMState, FetchOptions, + PatternToolFunction, } from "commontools"; export const compileAndRun = createNodeFactory({ @@ -59,7 +60,7 @@ export const fetchData = createNodeFactory({ options?: FetchOptions; result?: T; }>, -) => Opaque<{ pending: boolean; result: T; error: unknown }>; +) => OpaqueRef<{ pending: boolean; result: T; error: unknown }>; export const streamData = createNodeFactory({ type: "ref", @@ -70,7 +71,7 @@ export const streamData = createNodeFactory({ options?: FetchOptions; result?: T; }>, -) => Opaque<{ pending: boolean; result: T; error: unknown }>; +) => OpaqueRef<{ pending: boolean; result: T; error: unknown }>; export function ifElse( condition: Opaque, @@ -191,10 +192,13 @@ export type { createCell }; * // Both result in type: OpaqueRef<{ query: string }> * ``` */ -export function patternTool>( +export const patternTool = (< + T, + E extends Partial = Record, +>( fnOrRecipe: ((input: OpaqueRef>) => any) | RecipeFactory, extraParams?: Opaque, -): OpaqueRef> { +): OpaqueRef> => { const pattern = isRecipe(fnOrRecipe) ? fnOrRecipe : recipe("tool", fnOrRecipe); @@ -203,4 +207,4 @@ export function patternTool>( pattern, extraParams: extraParams ?? {}, } as any as OpaqueRef>; -} +}) as PatternToolFunction; diff --git a/packages/runner/src/builder/json-utils.ts b/packages/runner/src/builder/json-utils.ts index a474e7ed9..2128a80c6 100644 --- a/packages/runner/src/builder/json-utils.ts +++ b/packages/runner/src/builder/json-utils.ts @@ -3,7 +3,7 @@ import { type LegacyAlias } from "../sigil-types.ts"; import { isLegacyAlias, isLink } from "../link-utils.ts"; import { canBeOpaqueRef, - isOpaqueRef, + isOpaqueCell, isRecipe, isShadowRef, type JSONSchema, @@ -36,26 +36,26 @@ export function toJSONWithLegacyAliases( if (canBeOpaqueRef(value)) value = makeOpaqueRef(value); // Verify that opaque refs are not in a parent frame - if (isOpaqueRef(value) && value.export().frame !== getTopFrame()) { + if (isOpaqueCell(value) && value.export().frame !== getTopFrame()) { throw new Error( `Opaque ref with parent cell not found in current frame. Should have been converted to a shadow ref.`, ); } // If this is an external reference, just copy the reference as is. - if (isOpaqueRef(value)) { + if (isOpaqueCell(value)) { const { external } = value.export(); if (external) return external as JSONValue; } // Otherwise it's an internal reference. Extract the schema and output a link. - if (isOpaqueRef(value) || isShadowRef(value)) { + if (isOpaqueCell(value) || isShadowRef(value)) { const pathToCell = paths.get(value); if (pathToCell) { if (ignoreSelfAliases && deepEqual(path, pathToCell)) return undefined; // Add schema from exported value if available - const exported = isOpaqueRef(value) ? value.export() : undefined; + const exported = isOpaqueCell(value) ? value.export() : undefined; return { $alias: { ...(isShadowRef(value) ? { cell: value } : {}), diff --git a/packages/runner/src/builder/node-utils.ts b/packages/runner/src/builder/node-utils.ts index f798ffb57..9351fa557 100644 --- a/packages/runner/src/builder/node-utils.ts +++ b/packages/runner/src/builder/node-utils.ts @@ -2,7 +2,7 @@ import { isObject, isRecord } from "@commontools/utils/types"; import { createShadowRef } from "./opaque-ref.ts"; import { canBeOpaqueRef, - isOpaqueRef, + isOpaqueCell, type JSONSchema, makeOpaqueRef, type NodeRef, @@ -15,7 +15,7 @@ import { traverseValue } from "./traverse-utils.ts"; export function connectInputAndOutputs(node: NodeRef) { function connect(value: any): any { if (canBeOpaqueRef(value)) value = makeOpaqueRef(value); - if (isOpaqueRef(value)) { + if (isOpaqueCell(value)) { // Return shadow ref it this is a parent opaque ref. Note: No need to // connect to the cell. The connection is there to traverse the graph to // find all other nodes, but this points to the parent graph instead. @@ -55,7 +55,7 @@ export function applyInputIfcToOutput( const collectedClassifications = new Set(); const cfc = new ContextualFlowControl(); traverseValue(inputs, (item: unknown) => { - if (isOpaqueRef(item)) { + if (isOpaqueCell(item)) { const { schema: inputSchema, rootSchema } = (item as OpaqueRef) .export(); if (inputSchema !== undefined) { @@ -80,7 +80,7 @@ function attachCfcToOutputs( cfc: ContextualFlowControl, lubClassification: string, ) { - if (isOpaqueRef(outputs)) { + if (isOpaqueCell(outputs)) { const exported = (outputs as OpaqueRef).export(); const outputSchema = exported.schema ?? true; // we may have fields in the output schema, so incorporate those diff --git a/packages/runner/src/builder/opaque-ref.ts b/packages/runner/src/builder/opaque-ref.ts index 927d92d27..f6841a57a 100644 --- a/packages/runner/src/builder/opaque-ref.ts +++ b/packages/runner/src/builder/opaque-ref.ts @@ -1,12 +1,13 @@ import { isRecord } from "@commontools/utils/types"; import { + type IOpaqueCell, isOpaqueRefMarker, type JSONSchema, type NodeFactory, type NodeRef, type Opaque, + type OpaqueCell, type OpaqueRef, - type OpaqueRefMethods, type Recipe, type SchemaWithoutCell, type ShadowRef, @@ -64,8 +65,8 @@ export function opaqueRef( nestedSchema: JSONSchema | undefined, rootSchema: JSONSchema | undefined, ): OpaqueRef { - const methods: OpaqueRefMethods = { - get: () => unsafe_materialize(unsafe_binding, path), + const methods: IOpaqueCell = { + get: () => unsafe_materialize(unsafe_binding, path) as T, set: (newValue: Opaque) => { if (unsafe_binding) { unsafe_materialize(unsafe_binding, path); // TODO(seefeld): Set value @@ -78,7 +79,7 @@ export function opaqueRef( : cfc.getSchemaAtPath(nestedSchema, [key.toString()], rootSchema); return createNestedProxy( [...path, key], - key in methods ? methods[key as keyof OpaqueRefMethods] : store, + key in methods ? methods[key as keyof IOpaqueCell] : store, childSchema, childSchema === undefined ? undefined : rootSchema, ); @@ -120,11 +121,11 @@ export function opaqueRef( }, map: ( fn: ( - element: OpaqueRef ? U : T>>, + element: T extends Array ? OpaqueRef : OpaqueRef, index: OpaqueRef, - array: T, + array: OpaqueRef, ) => Opaque, - ) => { + ): OpaqueRef => { // Create the factory if it doesn't exist. Doing it here to avoid // circular dependency. mapFactory ||= createNodeFactory({ @@ -198,13 +199,13 @@ export function opaqueRef( const proxy = new Proxy(target || {}, { get(_, prop) { if (typeof prop === "symbol") { - return methods[prop as keyof OpaqueRefMethods]; + return methods[prop as keyof IOpaqueCell]; } else { - return methods.key(prop); + return (methods as unknown as OpaqueCell).key(prop); } }, set(_, prop, value) { - methods.set({ [prop]: value }); + methods.set({ [prop]: value } as Opaque>); return true; }, }); diff --git a/packages/runner/src/builder/recipe.ts b/packages/runner/src/builder/recipe.ts index 10a94e77d..1433ad416 100644 --- a/packages/runner/src/builder/recipe.ts +++ b/packages/runner/src/builder/recipe.ts @@ -2,7 +2,7 @@ import { isObject, isRecord } from "@commontools/utils/types"; import { canBeOpaqueRef, type Frame, - isOpaqueRef, + isOpaqueCell, isShadowRef, type JSONSchema, type JSONSchemaMutable, @@ -142,7 +142,7 @@ export function recipeFromFrame( function factoryFromRecipe( argumentSchemaArg: string | JSONSchema, resultSchemaArg: JSONSchema | undefined, - inputs: OpaqueRef, + inputs: OpaqueRef>, outputs: Opaque, ): RecipeFactory { // Traverse the value, collect all mentioned nodes and cells @@ -153,23 +153,23 @@ function factoryFromRecipe( const collectCellsAndNodes = (value: Opaque) => traverseValue(value, (value) => { if (canBeOpaqueRef(value)) value = makeOpaqueRef(value); - if (isOpaqueRef(value)) value = value.unsafe_getExternal(); + if (isOpaqueCell(value)) value = value.unsafe_getExternal(); if ( - (isOpaqueRef(value) || isShadowRef(value)) && !cells.has(value) && + (isOpaqueCell(value) || isShadowRef(value)) && !cells.has(value) && !shadows.has(value as ShadowRef) ) { - if (isOpaqueRef(value) && value.export().frame !== getTopFrame()) { + if (isOpaqueCell(value) && value.export().frame !== getTopFrame()) { value = createShadowRef(value.export().value); } if (isShadowRef(value)) { shadows.add(value); if ( - isOpaqueRef(value.shadowOf) && + isOpaqueCell(value.shadowOf) && value.shadowOf.export().frame === getTopFrame() ) { cells.add(value.shadowOf); } - } else if (isOpaqueRef(value)) { + } else if (isOpaqueCell(value)) { cells.add(value); value.export().nodes.forEach((node: NodeRef) => { if (!nodes.has(node)) { @@ -199,7 +199,7 @@ function factoryFromRecipe( // First from results if (isRecord(outputs)) { Object.entries(outputs).forEach(([key, value]: [string, unknown]) => { - if (isOpaqueRef(value)) { + if (isOpaqueCell(value)) { const ref = value; // Typescript needs this to avoid type errors const exported = ref.export(); if ( @@ -221,7 +221,7 @@ function factoryFromRecipe( if (isRecord(node.inputs)) { Object.entries(node.inputs).forEach(([key, input]) => { if ( - isOpaqueRef(input) && input.export().cell === cell && + isOpaqueCell(input) && input.export().cell === cell && !cell.export().name && !usedNames.has(key) ) { cell.setName(key); diff --git a/packages/runner/src/builder/traverse-utils.ts b/packages/runner/src/builder/traverse-utils.ts index cda4fd214..e9c8892c2 100644 --- a/packages/runner/src/builder/traverse-utils.ts +++ b/packages/runner/src/builder/traverse-utils.ts @@ -1,7 +1,7 @@ import { isRecord } from "@commontools/utils/types"; import { canBeOpaqueRef, - isOpaqueRef, + isOpaqueCell, isRecipe, isShadowRef, type Opaque, @@ -30,7 +30,7 @@ export function traverseValue( // Traverse value if ( - !isOpaqueRef(value) && + !isOpaqueCell(value) && !canBeOpaqueRef(value) && !isShadowRef(value) && (isRecord(value) || isRecipe(value)) diff --git a/packages/runner/src/builder/types.ts b/packages/runner/src/builder/types.ts index feb34cba4..675566a03 100644 --- a/packages/runner/src/builder/types.ts +++ b/packages/runner/src/builder/types.ts @@ -24,8 +24,8 @@ import type { Module, NavigateToFunction, Opaque, + OpaqueCell, OpaqueRef, - OpaqueRefMethods, PatternToolFunction, Recipe, RecipeFunction, @@ -49,23 +49,27 @@ import { import { AuthSchema } from "./schema-lib.ts"; export { AuthSchema } from "./schema-lib.ts"; export { + h, ID, ID_FIELD, - type IDFields, NAME, type Schema, schema, - type SchemaWithoutCell, toSchema, TYPE, UI, } from "@commontools/api"; -export { h } from "@commontools/api"; export type { + AnyCell, Cell, CreateCellFunction, Handler, HandlerFactory, + IDerivable, + IDFields, + IKeyableOpaque, + IOpaquable, + IOpaqueCell, JSONObject, JSONSchema, JSONSchemaTypes, @@ -74,14 +78,17 @@ export type { ModuleFactory, NodeFactory, Opaque, + OpaqueCell, OpaqueRef, Props, Recipe, RecipeFactory, RenderNode, + SchemaWithoutCell, Stream, StripCell, toJSON, + UnwrapCell, VNode, } from "@commontools/api"; import { @@ -93,23 +100,14 @@ import { type RuntimeProgram } from "../harness/types.ts"; export type JSONSchemaMutable = Mutable; // Augment the public interface with the internal OpaqueRefMethods interface. -// Deliberately repeating the original interface to catch any inconsistencies: -// This here then reflects the entire interface the internal implementation -// implements. +// This adds runtime-specific methods beyond what the public API defines. declare module "@commontools/api" { - interface OpaqueRefMethods { - get(): OpaqueRef; - set(value: Opaque | T): void; - key(key: K): OpaqueRef; - setDefault(value: Opaque | T): void; - setPreExisting(ref: unknown): void; - setName(name: string): void; - setSchema(schema: JSONSchema): void; - connect(node: NodeRef): void; + interface IOpaquable { + // Export method for introspection export(): { - cell: OpaqueRef; + cell: OpaqueCell; path: readonly PropertyKey[]; - value?: Opaque; + value?: Opaque | T; defaultValue?: Opaque; nodes: Set; external?: unknown; @@ -118,22 +116,17 @@ declare module "@commontools/api" { rootSchema?: JSONSchema; frame: Frame; }; + + connect(node: NodeRef): void; + + // Unsafe methods for internal use unsafe_bindToRecipeAndPath( recipe: Recipe, path: readonly PropertyKey[], ): void; - unsafe_getExternal(): OpaqueRef; - map( - fn: ( - element: T extends Array ? OpaqueRef : OpaqueRef, - index: OpaqueRef, - array: OpaqueRef, - ) => Opaque, - ): Opaque; - mapWithPattern( - op: Recipe, - params: Record, - ): Opaque; + unsafe_getExternal(): OpaqueCell; + + // Additional utility methods toJSON(): unknown; [Symbol.iterator](): Iterator; [Symbol.toPrimitive](hint: string): T; @@ -141,19 +134,18 @@ declare module "@commontools/api" { } } -export type { OpaqueRefMethods }; - export const isOpaqueRefMarker = Symbol("isOpaqueRef"); -export function isOpaqueRef( +export function isOpaqueCell( value: unknown, -): value is OpaqueRefMethods { +): value is OpaqueCell { return !!value && - typeof (value as OpaqueRef)[isOpaqueRefMarker] === "boolean"; + typeof (value as { [isOpaqueRefMarker]: true })[isOpaqueRefMarker] === + "boolean"; } export type NodeRef = { - module: Module | Recipe | OpaqueRef; + module: Module | Recipe | OpaqueCell; inputs: Opaque; outputs: OpaqueRef; frame: Frame | undefined; @@ -248,7 +240,7 @@ export function isShadowRef(value: unknown): value is ShadowRef { !!value && typeof value === "object" && "shadowOf" in value && - (isOpaqueRef((value as ShadowRef).shadowOf) || + (isOpaqueCell((value as ShadowRef).shadowOf) || isShadowRef((value as ShadowRef).shadowOf)) ); } diff --git a/packages/runner/src/cell.ts b/packages/runner/src/cell.ts index 4abd2488d..c75550701 100644 --- a/packages/runner/src/cell.ts +++ b/packages/runner/src/cell.ts @@ -2,6 +2,7 @@ import { type Immutable, isObject, isRecord } from "@commontools/utils/types"; import type { MemorySpace } from "@commontools/memory/interface"; import { getTopFrame } from "./builder/recipe.ts"; import { + type AnyCell, type Cell, ID, ID_FIELD, @@ -53,145 +54,36 @@ import { fromURI } from "./uri-utils.ts"; import { ContextualFlowControl } from "./cfc.ts"; /** - * This is the regular Cell interface. - * - * This abstracts away the paths behind an interface that e.g. the UX code or - * modules that prefer cell interfaces can use. - * - * These methods are available in the system and in spell code: - * - * @method get Returns the current value of the cell. - * @returns {T} - * - * @method set Alias for `send`. Sets a new value for the cell. - * @method send Sets a new value for the cell. - * @param {T} value - The new value to set. - * @returns {void} - * - * @method update Updates multiple properties of an object cell at once. - * @param {Partial} values - The properties to update. - * @returns {void} - * - * @method push Adds an item to the end of an array cell. - * @param {U | Cell} value - The value to add, where U is the array element - * type. - * @returns {void} - * - * @method equals Compares two cells for equality. - * @param {Cell} other - The cell to compare with. - * @returns {boolean} - * - * @method key Returns a new cell for the specified key path. - * @param {K} valueKey - The key to access in the cell's value. - * @returns {Cell} - * - * Everything below is only available in the system, not in spell code: - * - * @method asSchema Creates a new cell with a specific schema. - * @param {JSONSchema} schema - The schema to apply. - * @returns {Cell} - A cell with the specified schema. - * - * @method withTx Creates a new cell with a specific transaction. - * @param {IExtendedStorageTransaction} tx - The transaction to use. - * @returns {Cell} - * - * @method sink Adds a callback that is called immediately and on cell changes. - * @param {function} callback - The callback to be called when the cell changes. - * @returns {function} - A function to Cleanup the callback. - * - * @method sync Syncs the cell to the storage. - * @returns {Promise} - * - * @method resolveAsCell Resolves the cell to a new cell with the resolved link. - * Equivalent to `cell.getAsSchema().get()` - * @returns {Cell} - * - * @method getAsQueryResult Returns a query result for the cell. - * @param {Path} path - The optional path to follow. - * @param {ReactivityLog} log - Optional reactivity log. - * @returns {QueryResult>} - * - * @method getAsNormalizedFullLink Returns a normalized full link for the cell. - * @returns {NormalizedFullLink} - * - * @method getAsLink Returns a cell link for the cell (new sigil format). - * @returns {SigilLink} - * - * @method getRaw Raw access method, without following aliases (which would - * write to the destination instead of the cell itself). - * @param {IReadOptions} options - Optional read options. - * @returns {Immutable | undefined} - Raw readonly document data - * - * @method setRaw Raw write method that bypasses Cell validation, - * transformation, and alias resolution. Writes directly to the cell without - * following aliases. - * @param {any} value - Raw value to write directly to document - * - * @method getSourceCell Returns the source cell with optional schema. - * @param {JSONSchema} schema - Optional schema to apply. - * @returns {Cell} - * - * @method toJSON Returns a serializable cell link (not the contents) to the - * cell. This is used e.g. when creating merkle references that refer to cells. - * It currentlly doesn't contain the space. We'll eventually want to get a - * relative link here, but that will require context toJSON doesn't get. - * @returns {{cell: {"/": string} | undefined, path: PropertyKey[]}} - * - * @property entityId Returns the current entity ID of the cell. - * @returns {EntityId} - * - * @property sourceURI Returns the source URI of the cell. - * @returns {URI} - * - * @property schema Optional schema for the cell. - * @returns {JSONSchema | undefined} - * - * @property runtime The runtime that was used to create the cell. - * @returns {IRuntime} - * - * @property tx The transaction that was used to create the cell. - * @returns {IExtendedStorageTransaction} - * - * @property rootSchema Optional root schema for cell's schema. This differs - * from `schema` when the cell represents a child of the original cell (e.g. via - * `key()`). We need to keep the root schema to resolve `$ref` in the schema. - * @returns {JSONSchema | undefined} - * - * The following are just for debugging and might disappear: (This allows - * clicking on a property in the debugger and getting the value) - * - * @method value Returns the current value of the cell. - * @returns {T} - * - * @property cellLink The cell link representing this cell. - * @returns {SigilLink} + * Module augmentation for runtime-specific cell methods. + * These augmentations add implementation details specific to the runner runtime. */ + declare module "@commontools/api" { - interface Cell { - get(): Readonly; + /** + * Augment Writable to add runtime-specific write methods with onCommit callbacks + */ + interface IWritable { set( - value: Cellify | T, + value: AnyCellWrapping | T, onCommit?: (tx: IExtendedStorageTransaction) => void, ): void; + } + + /** + * Augment Streamable to add onCommit callback support + */ + interface IStreamable { send( - value: Cellify | T, + value: AnyCellWrapping | T, onCommit?: (tx: IExtendedStorageTransaction) => void, ): void; - update | Partial>>( - values: V extends object ? V : never, - ): void; - push( - ...value: Array< - | (T extends Array ? (Cellify | U) : any) - | Cell - > - ): void; - equals(other: any): boolean; - key ? keyof S : keyof T>( - valueKey: K, - ): Cell< - T extends Cell ? S[K & keyof S] : T[K] extends never ? any : T[K] - >; + } + + /** + * Augment Cell to add all internal/system methods that are available + * on Cell in the runner runtime. + */ + interface IAnyCell { asSchema( schema: S, ): Cell>; @@ -201,7 +93,6 @@ declare module "@commontools/api" { withTx(tx?: IExtendedStorageTransaction): Cell; sink(callback: (value: Readonly) => Cancel | undefined | void): Cancel; sync(): Promise> | Cell; - resolveAsCell(): Cell; getAsQueryResult( path?: Readonly, tx?: IExtendedStorageTransaction, @@ -228,8 +119,6 @@ declare module "@commontools/api" { ): | Cell< & T - // Add default types for TYPE and `argument`. A more specific type in T will - // take precedence. & { [TYPE]: string | undefined } & ("argument" extends keyof T ? unknown : { argument: any }) > @@ -239,15 +128,11 @@ declare module "@commontools/api" { ): | Cell< & Schema - // Add default types for TYPE and `argument`. A more specific type in - // `schema` will take precedence. & { [TYPE]: string | undefined } - & ("argument" extends keyof Schema ? unknown - : { argument: any }) + & ("argument" extends keyof Schema ? unknown : { argument: any }) > | undefined; setSourceCell(sourceCell: Cell): void; - // This just flags as frozen. It does not prevent modification freeze(reason: string): void; isFrozen(): boolean; toJSON(): LegacyJSONCellLink; @@ -264,53 +149,19 @@ declare module "@commontools/api" { copyTrap: boolean; [toOpaqueRef]: () => OpaqueRef; } - - interface Stream { - send(event: T, onCommit?: (tx: IExtendedStorageTransaction) => void): void; - sink(callback: (event: Readonly) => Cancel | undefined | void): Cancel; - sync(): Promise> | Stream; - getRaw(options?: IReadOptions): any; - getAsNormalizedFullLink(): NormalizedFullLink; - getAsLink( - options?: { - base?: Cell; - baseSpace?: MemorySpace; - includeSchema?: boolean; - }, - ): SigilLink; - withTx(tx?: IExtendedStorageTransaction): Stream; - schema?: JSONSchema; - rootSchema?: JSONSchema; - runtime: IRuntime; - } } -export type { Cell, Stream } from "@commontools/api"; +export type { AnyCell, Cell, Stream } from "@commontools/api"; +import type { + AnyCellWrapping, + AsCell, + ICell, + IStreamable, + KeyResultType, +} from "@commontools/api"; export type { MemorySpace } from "@commontools/memory/interface"; -/** - * Cellify is a type utility that allows any part of type T to be wrapped in - * Cell<>, and allow any part of T that is currently wrapped in Cell<> to be - * used unwrapped. This is designed for use with Cell method parameters, - * allowing flexibility in how values are passed. - */ -export type Cellify = - // Handle existing Cell<> types, allowing unwrapping - T extends Cell ? Cellify | Cell> - // Handle Stream<> types, allowing unwrapping - : T extends Stream ? Cellify | Stream> - // Handle arrays - : T extends Array ? Array> | Cell>> - // Handle objects (excluding null), adding optional ID fields - : T extends object ? - | ({ [K in keyof T]: Cellify } & { [ID]?: any; [ID_FIELD]?: any }) - | Cell< - { [K in keyof T]: Cellify } & { [ID]?: any; [ID_FIELD]?: any } - > - // Handle primitives - : T | Cell; - export function createCell( runtime: IRuntime, link: NormalizedFullLink, @@ -345,12 +196,14 @@ export function createCell( { ...link, schema, rootSchema }, tx, synced, - ); + ) as unknown as Cell; } } -class StreamCell implements Stream { - private listeners = new Set<(event: T) => Cancel | undefined>(); +class StreamCell implements IStreamable { + private listeners = new Set< + (event: AnyCellWrapping) => Cancel | undefined + >(); private cleanup: Cancel | undefined; constructor( @@ -367,8 +220,11 @@ class StreamCell implements Stream { return this.link.rootSchema; } - send(event: T, onCommit?: (tx: IExtendedStorageTransaction) => void): void { - event = convertCellsToLinks(event) as T; + send( + event: AnyCellWrapping, + onCommit?: (tx: IExtendedStorageTransaction) => void, + ): void { + event = convertCellsToLinks(event) as AnyCellWrapping; // Use runtime from doc if available this.runtime.scheduler.queueEvent(this.link, event, undefined, onCommit); @@ -380,7 +236,7 @@ class StreamCell implements Stream { this.listeners.forEach((callback) => addCancel(callback(event))); } - sink(callback: (value: Readonly) => Cancel | undefined): Cancel { + sink(callback: (event: AnyCellWrapping) => Cancel | undefined): Cancel { this.listeners.add(callback); return () => this.listeners.delete(callback); } @@ -388,7 +244,7 @@ class StreamCell implements Stream { // sync: No-op for streams, but maybe eventually it might mean wait for all // events to have been processed sync(): Stream { - return this; + return this as unknown as Stream; } getRaw(options?: IReadOptions): Immutable | undefined { @@ -414,11 +270,11 @@ class StreamCell implements Stream { } withTx(_tx?: IExtendedStorageTransaction): Stream { - return this; // No-op for streams + return this as unknown as Stream; // No-op for streams } } -export class RegularCell implements Cell { +export class RegularCell implements ICell { private readOnlyReason: string | undefined; constructor( @@ -450,7 +306,7 @@ export class RegularCell implements Cell { } set( - newValue: Cellify | T, + newValue: AnyCellWrapping | T, onCommit?: (tx: IExtendedStorageTransaction) => void, ): void { if (!this.tx) throw new Error("Transaction required for set"); @@ -478,14 +334,14 @@ export class RegularCell implements Cell { } send( - newValue: Cellify | T, + newValue: AnyCellWrapping | T, onCommit?: (tx: IExtendedStorageTransaction) => void, ): void { this.set(newValue, onCommit); } - update | Partial>>( - values: V extends object ? V : never, + update | AnyCellWrapping>)>( + values: V extends object ? AnyCellWrapping : never, ): void { if (!this.tx) throw new Error("Transaction required for update"); if (!isRecord(values)) { @@ -529,19 +385,12 @@ export class RegularCell implements Cell { // Now update each property for (const [key, value] of Object.entries(values)) { - (this as Cell).key(key).set(value); + (this as unknown as Cell).key(key).set(value); } } - push(...value: T extends Array ? U[] : never): void; push( - ...value: Array< - | (T extends Array ? (Cellify | U) : any) - | Cell - > - ): void; - push( - ...value: any[] + ...value: T extends (infer U)[] ? (U | AnyCellWrapping)[] : never ): void { if (!this.tx) throw new Error("Transaction required for push"); @@ -596,11 +445,9 @@ export class RegularCell implements Cell { return areLinksSame(this, other); } - key ? keyof S : keyof T>( + key( valueKey: K, - ): Cell< - T extends Cell ? S[K & keyof S] : T[K] extends never ? any : T[K] - > { + ): KeyResultType { const childSchema = this.runtime.cfc.getSchemaAtPath( this.schema, [valueKey.toString()], @@ -616,9 +463,7 @@ export class RegularCell implements Cell { this.tx, false, this.synced, - ) as Cell< - T extends Cell ? S[K & keyof S] : T[K] extends never ? any : T[K] - >; + ) as unknown as KeyResultType; } asSchema( @@ -633,11 +478,16 @@ export class RegularCell implements Cell { { ...this.link, schema: schema, rootSchema: schema }, this.tx, false, // Reset synced flag, since schmema is changing - ) as Cell; + ) as unknown as Cell; } withTx(newTx?: IExtendedStorageTransaction): Cell { - return new RegularCell(this.runtime, this.link, newTx, this.synced); + return new RegularCell( + this.runtime, + this.link, + newTx, + this.synced, + ) as unknown as Cell; } sink(callback: (value: Readonly) => Cancel | undefined): Cancel { @@ -647,8 +497,8 @@ export class RegularCell implements Cell { sync(): Promise> | Cell { this.synced = true; - if (this.link.id.startsWith("data:")) return this; - return this.runtime.storageManager.syncCell(this); + if (this.link.id.startsWith("data:")) return this as unknown as Cell; + return this.runtime.storageManager.syncCell(this as unknown as Cell); } resolveAsCell(): Cell { @@ -1026,6 +876,16 @@ export function isCell(value: any): value is Cell { return value instanceof RegularCell; } +/** + * Check if value is any kind of cell. + * + * @param {any} value - The value to check. + * @returns {boolean} + */ +export function isAnyCell(value: any): value is AnyCell { + return value instanceof RegularCell || value instanceof StreamCell; +} + /** * Type guard to check if a value is a Stream. * @param value - The value to check diff --git a/packages/runner/src/create-ref.ts b/packages/runner/src/create-ref.ts index 1fcdae505..b10770db4 100644 --- a/packages/runner/src/create-ref.ts +++ b/packages/runner/src/create-ref.ts @@ -1,6 +1,6 @@ import { refer } from "merkle-reference"; import { isRecord } from "@commontools/utils/types"; -import { isOpaqueRef } from "./builder/types.ts"; +import { isOpaqueCell } from "./builder/types.ts"; import { getCellOrThrow, isQueryResultForDereferencing, @@ -48,7 +48,7 @@ export function createRef( obj = obj.toJSON() ?? obj; } - if (isOpaqueRef(obj)) return obj.export().value ?? crypto.randomUUID(); + if (isOpaqueCell(obj)) return obj.export().value ?? crypto.randomUUID(); if (isQueryResultForDereferencing(obj)) { // It'll traverse this and call .toJSON on the doc in the reference. diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts index 78c648d0e..647821f49 100644 --- a/packages/runner/src/index.ts +++ b/packages/runner/src/index.ts @@ -84,7 +84,7 @@ export { ID, ID_FIELD, isModule, - isOpaqueRef, + isOpaqueCell as isOpaqueRef, isRecipe, isStreamValue, type JSONObject, @@ -97,7 +97,6 @@ export { type NodeFactory, type Opaque, type OpaqueRef, - type OpaqueRefMethods, type Props, type Recipe, type RecipeFactory, diff --git a/packages/runner/src/link-utils.ts b/packages/runner/src/link-utils.ts index 7a063db33..484909fcc 100644 --- a/packages/runner/src/link-utils.ts +++ b/packages/runner/src/link-utils.ts @@ -1,7 +1,8 @@ import { isObject, isRecord } from "@commontools/utils/types"; -import { type JSONSchema } from "./builder/types.ts"; +import { type AnyCell, type JSONSchema } from "./builder/types.ts"; import { type Cell, + isAnyCell, isCell, isStream, type MemorySpace, @@ -207,28 +208,27 @@ export function isLegacyAlias(value: any): value is LegacyAlias { * in various combinations. */ export function parseLink( - value: Cell | Stream, - base?: Cell | NormalizedLink, + value: AnyCell | Cell | Stream, ): NormalizedFullLink; export function parseLink( value: CellLink, - base: Cell | NormalizedFullLink, + base: AnyCell | NormalizedFullLink, ): NormalizedFullLink; export function parseLink( value: CellLink, - base?: Cell | NormalizedLink, + base?: AnyCell | NormalizedLink, ): NormalizedLink; export function parseLink( value: any, - base: Cell | NormalizedFullLink, + base: AnyCell | NormalizedFullLink, ): NormalizedFullLink | undefined; export function parseLink( value: any, - base?: Cell | NormalizedLink, + base?: AnyCell | NormalizedLink, ): NormalizedLink | undefined; export function parseLink( value: any, - base?: Cell | NormalizedLink, + base?: AnyCell | NormalizedLink, ): NormalizedLink | undefined { // Has to be first, since below we check for "/" in value and we don't want to // see userland "/". @@ -247,7 +247,7 @@ export function parseLink( // If no id provided, use base cell's document if (!id && base) { - id = isCell(base) ? toURI(base.entityId) : base.id; + id = isAnyCell(base) ? toURI(base.entityId) : base.id; } return { @@ -294,7 +294,7 @@ export function parseLink( // If no cell provided, use base cell's document if (!id && base) { - id = isCell(base) ? toURI(base.entityId) : base.id; + id = isAnyCell(base) ? toURI(base.entityId) : base.id; } return { diff --git a/packages/runner/src/runner.ts b/packages/runner/src/runner.ts index 0a89b63e1..d618fa8b0 100644 --- a/packages/runner/src/runner.ts +++ b/packages/runner/src/runner.ts @@ -4,7 +4,7 @@ import { isObject, isRecord, type Mutable } from "@commontools/utils/types"; import { vdomSchema } from "@commontools/html"; import { isModule, - isOpaqueRef, + isOpaqueCell, isRecipe, isShadowRef, isStreamValue, @@ -779,7 +779,7 @@ export class Runner implements IRunner { } if ( - !isRecord(value) || isOpaqueRef(value) || isShadowRef(value) || + !isRecord(value) || isOpaqueCell(value) || isShadowRef(value) || isCell(value) ) { return; @@ -1270,7 +1270,7 @@ function getSpellLink(recipeId: string): SigilLink { } function containsOpaqueRef(value: unknown): boolean { - if (isOpaqueRef(value)) return true; + if (isOpaqueCell(value)) return true; if (isLink(value)) return false; if (isRecord(value)) { return Object.values(value).some(containsOpaqueRef); diff --git a/packages/runner/src/runtime.ts b/packages/runner/src/runtime.ts index dcb8ead04..cccc33b3f 100644 --- a/packages/runner/src/runtime.ts +++ b/packages/runner/src/runtime.ts @@ -5,6 +5,7 @@ import { } from "@commontools/static"; import { RuntimeTelemetry } from "@commontools/runner"; import type { + AnyCell, JSONSchema, Module, NodeFactory, @@ -145,12 +146,12 @@ export interface IRuntime { ): Cell; getCellFromLink( - cellLink: CellLink | NormalizedLink, + cellLink: CellLink | NormalizedLink | AnyCell, schema: S, tx?: IExtendedStorageTransaction, ): Cell>; getCellFromLink( - cellLink: CellLink | NormalizedLink, + cellLink: CellLink | NormalizedLink | AnyCell, schema?: JSONSchema, tx?: IExtendedStorageTransaction, ): Cell; @@ -520,17 +521,17 @@ export class Runtime implements IRuntime { ); } getCellFromLink( - cellLink: CellLink | NormalizedLink, + cellLink: CellLink | NormalizedLink | AnyCell, schema?: JSONSchema, tx?: IExtendedStorageTransaction, ): Cell; getCellFromLink( - cellLink: CellLink | NormalizedLink, + cellLink: CellLink | NormalizedLink | AnyCell, schema: S, tx?: IExtendedStorageTransaction, ): Cell>; getCellFromLink( - cellLink: CellLink | NormalizedLink, + cellLink: CellLink | NormalizedLink | AnyCell, schema?: JSONSchema, tx?: IExtendedStorageTransaction, ): Cell { diff --git a/packages/runner/test/cell.test.ts b/packages/runner/test/cell.test.ts index fbafcdea5..5ecae1d4b 100644 --- a/packages/runner/test/cell.test.ts +++ b/packages/runner/test/cell.test.ts @@ -1978,6 +1978,7 @@ describe("asCell with schema", () => { const arrayCell = c.key("items"); expect(arrayCell.get()).toBeNull(); + // @ts-ignore - types correctly disallowed pushing to non-array expect(() => arrayCell.push(1)).toThrow(); }); @@ -2160,6 +2161,7 @@ describe("asCell with schema", () => { c.set({ value: "not an array" }); const cell = c.key("value"); + // @ts-ignore - types correctly disallowed pushing to non-array expect(() => cell.push(42)).toThrow(); }); diff --git a/packages/runner/test/module.test.ts b/packages/runner/test/module.test.ts index c9d867d6c..9c2f5e1f5 100644 --- a/packages/runner/test/module.test.ts +++ b/packages/runner/test/module.test.ts @@ -4,7 +4,7 @@ import { JSONSchemaObj } from "@commontools/api"; import { type Frame, isModule, - isOpaqueRef, + isOpaqueCell, type JSONSchema, type Module, type OpaqueRef, @@ -39,7 +39,7 @@ describe("module", () => { it("creates a opaque ref when called", () => { const add = lift<{ a: number; b: number }, number>(({ a, b }) => a + b); const result = add({ a: opaqueRef(1), b: opaqueRef(2) }); - expect(isOpaqueRef(result)).toBe(true); + expect(isOpaqueCell(result)).toBe(true); }); it("supports JSON Schema validation", () => { @@ -120,7 +120,7 @@ describe("module", () => { { proxy: true }, ); const stream = clickHandler({ x: opaqueRef(10), y: opaqueRef(20) }); - expect(isOpaqueRef(stream)).toBe(true); + expect(isOpaqueCell(stream)).toBe(true); const { value, nodes } = (stream as unknown as OpaqueRef<{ $stream: true }>).export(); expect(value).toEqual({ $stream: true }); @@ -195,7 +195,7 @@ describe("module", () => { const elements = opaqueRef({ button1: true, button2: false }); const result = toggleHandler({ elements } as any); - expect(isOpaqueRef(result)).toBe(true); + expect(isOpaqueCell(result)).toBe(true); const { nodes } = result.export(); expect(nodes.size).toBe(1); const handlerNode = [...nodes][0]; @@ -212,7 +212,7 @@ describe("module", () => { { proxy: true }, ); const stream = clickHandler.with({ x: opaqueRef(10), y: opaqueRef(20) }); - expect(isOpaqueRef(stream)).toBe(true); + expect(isOpaqueCell(stream)).toBe(true); const { value, nodes } = (stream as unknown as OpaqueRef<{ $stream: true }>).export(); expect(value).toEqual({ $stream: true }); diff --git a/packages/runner/test/opaque-ref-schema.test.ts b/packages/runner/test/opaque-ref-schema.test.ts index 1d0096d59..107e2b7c1 100644 --- a/packages/runner/test/opaque-ref-schema.test.ts +++ b/packages/runner/test/opaque-ref-schema.test.ts @@ -137,7 +137,7 @@ describe("OpaqueRef Schema Support", () => { // Access array element const itemsRef = ref.key("items"); - const firstItemRef = itemsRef[0]; + const firstItemRef = itemsRef.key(0); const idRef = firstItemRef.key("id"); // Check schema for array element property diff --git a/packages/runner/test/opaque-ref.test.ts b/packages/runner/test/opaque-ref.test.ts index 67b43a2ec..060fc5044 100644 --- a/packages/runner/test/opaque-ref.test.ts +++ b/packages/runner/test/opaque-ref.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; -import { type Frame, isOpaqueRef, isShadowRef } from "../src/builder/types.ts"; +import { type Frame, isOpaqueCell, isShadowRef } from "../src/builder/types.ts"; import { createShadowRef, opaqueRef } from "../src/builder/opaque-ref.ts"; import { popFrame, pushFrame } from "../src/builder/recipe.ts"; @@ -17,7 +17,7 @@ describe("opaqueRef function", () => { it("creates an opaque ref", () => { const c = opaqueRef(); - expect(isOpaqueRef(c)).toBe(true); + expect(isOpaqueCell(c)).toBe(true); }); it("supports set methods", () => { diff --git a/packages/runner/test/schema.test.ts b/packages/runner/test/schema.test.ts index 85eb5f3bb..b6f118861 100644 --- a/packages/runner/test/schema.test.ts +++ b/packages/runner/test/schema.test.ts @@ -117,7 +117,9 @@ describe("Schema Support", () => { ); innerCell.withTx(tx).set({ label: "first" }); - const cell = runtime.getCell( + const cell = runtime.getCell< + { value: string; current: Cell<{ label: string }> } + >( space, "should support nested sinks 2", { @@ -164,7 +166,7 @@ describe("Schema Support", () => { cell.key("current") .key("label") .sink((value) => { - currentByKeyValues.push(value); + currentByKeyValues.push(value as unknown as string); }); // .get() the currently selected cell @@ -305,7 +307,9 @@ describe("Schema Support", () => { tx.commit(); tx = runtime.edit(); - const root = docCell.asSchema(schema); + const root = docCell.asSchema< + { value: string; current: Cell<{ label: string }> } + >(schema); const rootValues: any[] = []; const currentValues: any[] = []; @@ -326,8 +330,8 @@ describe("Schema Support", () => { // Querying for a value tied to the currently selected sub-document const current = root.key("current").key("label"); - current.sink((value: string) => { - currentByKeyValues.push(value); + current.sink((value) => { + currentByKeyValues.push(value as unknown as string); }); // Make sure the schema is correct and it is still anchored at the root @@ -1033,8 +1037,74 @@ describe("Schema Support", () => { const profileCell = userCell.key("profile"); const value = profileCell.get(); + // Runtime checks expect(value.name).toBe("John"); expect(isCell(value.metadata)).toBe(true); + + // TypeScript type checks - these will fail to compile if types are 'any' + type IsAny = 0 extends (1 & T) ? true : false; + + // Check that userCell is NOT any + type UserCellIsAny = IsAny; + const _assertUserCellNotAny: UserCellIsAny extends false ? true : never = + true; + + // Check that profileCell is NOT any + type ProfileCellIsAny = IsAny; + const _assertProfileCellNotAny: ProfileCellIsAny extends false ? true + : never = true; + + // Check that value is NOT any + type ValueIsAny = IsAny; + const _assertValueNotAny: ValueIsAny extends false ? true : never = true; + }); + + it("should preserve types through key() with explicit Cell types", () => { + // Create a cell with explicit nested Cell type (not using Schema<>) + const cell = runtime.getCell< + { value: string; current: Cell<{ label: string }> } + >( + space, + "should preserve types through key 1", + { + type: "object", + properties: { + current: { + type: "object", + properties: { label: { type: "string" } }, + required: ["label"], + asCell: true, + }, + }, + required: ["current"], + } as const satisfies JSONSchema, + tx, + ); + + // Navigate using .key() + const currentCell = cell.key("current"); + const currentValue = currentCell.get(); + const labelCell = currentValue.key("label"); + const labelValue = labelCell.get(); + + // Type checks - verify types are NOT any + type IsAny = 0 extends (1 & T) ? true : false; + + type CurrentCellIsAny = IsAny; + const _assertCurrentCellNotAny: CurrentCellIsAny extends false ? true + : never = true; + + type LabelCellIsAny = IsAny; + const _assertLabelCellNotAny: LabelCellIsAny extends false ? true + : never = true; + + // Verify that currentCell is Cell> (nested Cell, not unwrapped) + type CurrentCellUnwrapped = typeof currentCell extends Cell ? U + : never; + type CurrentIsCell = CurrentCellUnwrapped extends Cell ? true + : false; + const _assertCurrentIsNestedCell: CurrentIsCell extends true ? true + : never = true; }); }); diff --git a/packages/schema-generator/src/formatters/common-tools-formatter.ts b/packages/schema-generator/src/formatters/common-tools-formatter.ts index e8d4c6c9e..0a0388590 100644 --- a/packages/schema-generator/src/formatters/common-tools-formatter.ts +++ b/packages/schema-generator/src/formatters/common-tools-formatter.ts @@ -277,7 +277,7 @@ export class CommonToolsFormatter implements TypeFormatter { } // If this is an OpaqueRef type, extract its type argument and recurse - if (this.isOpaqueRefType(type)) { + if (this.isOpaqueRefType(type, checker)) { const innerType = this.extractOpaqueRefTypeArgument(type, checker); if (innerType) { return this.recursivelyUnwrapOpaqueRef( @@ -327,7 +327,7 @@ export class CommonToolsFormatter implements TypeFormatter { for (const member of members) { // Check if this member is an OpaqueRef type (it will be an intersection) - const isOpaqueRef = this.isOpaqueRefType(member); + const isOpaqueRef = this.isOpaqueRefType(member, checker); if (isOpaqueRef) { opaqueRefMember = member; } else { @@ -364,23 +364,61 @@ export class CommonToolsFormatter implements TypeFormatter { } /** - * Check if a type is an OpaqueRef type (intersection with OpaqueRefMethods) + * Check if a type has a CELL_BRAND property (is a cell type) */ - private isOpaqueRefType(type: ts.Type): boolean { - // OpaqueRef types are intersection types - if (!(type.flags & ts.TypeFlags.Intersection)) { - return false; + private isCellType(type: ts.Type): boolean { + return type.getProperty("CELL_BRAND") !== undefined; + } + + /** + * Get the CELL_BRAND string value from a type, if it has one. + * Returns the brand string ("opaque", "cell", "stream", etc.) or undefined. + */ + private getCellBrand( + type: ts.Type, + checker: ts.TypeChecker, + ): string | undefined { + const brandSymbol = type.getProperty("CELL_BRAND"); + if (brandSymbol && brandSymbol.valueDeclaration) { + const brandType = checker.getTypeOfSymbolAtLocation( + brandSymbol, + brandSymbol.valueDeclaration, + ); + if (brandType.flags & ts.TypeFlags.StringLiteral) { + return (brandType as ts.StringLiteralType).value; + } } + 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") { - return true; + /** + * Check if a type is an OpaqueRef type by checking the CELL_BRAND property. + * All cell types (OpaqueCell, Cell, Stream) are intersections with CELL_BRAND, + * but only OpaqueCell has brand "opaque". + */ + private isOpaqueRefType(type: ts.Type, checker: ts.TypeChecker): boolean { + // Try CELL_BRAND first - most reliable method + const brand = this.getCellBrand(type, checker); + if (brand === "opaque") { + return true; + } + + // Fallback: check by constituent interface names for backward compatibility + if (type.flags & ts.TypeFlags.Intersection) { + 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; + // Check for OpaqueRef-specific interface names (old and new) + if ( + name === "OpaqueRefMethods" || name === "OpaqueCell" || + name === "IOpaqueCell" + ) { + return true; + } } } } @@ -389,7 +427,7 @@ export class CommonToolsFormatter implements TypeFormatter { } /** - * Extract the type argument T from OpaqueRef + * Extract the type argument T from OpaqueRef or OpaqueCell */ private extractOpaqueRefTypeArgument( type: ts.Type, @@ -406,8 +444,12 @@ export class CommonToolsFormatter implements TypeFormatter { if (objectType.objectFlags & ts.ObjectFlags.Reference) { const typeRef = objectType as ts.TypeReference; const name = typeRef.target?.symbol?.name; - if (name === "OpaqueRefMethods") { - // Found OpaqueRefMethods, extract T + // Check for both old (OpaqueRefMethods) and new (OpaqueCell, IOpaqueCell, BrandedCell) names + if ( + name === "OpaqueRefMethods" || name === "OpaqueCell" || + name === "IOpaqueCell" || name === "BrandedCell" + ) { + // Found wrapper type with type argument, extract T const typeArgs = checker.getTypeArguments(typeRef); if (typeArgs && typeArgs.length > 0) { return typeArgs[0]; @@ -433,15 +475,20 @@ export class CommonToolsFormatter implements TypeFormatter { if (objectType.objectFlags & ts.ObjectFlags.Reference) { const typeRef = objectType as ts.TypeReference; const name = typeRef.target?.symbol?.name; - if (name === "Cell" || name === "Stream" || name === "OpaqueRef") { - return { kind: name, typeRef }; + if ( + name === "Cell" || name === "Stream" || name === "OpaqueRef" || + name === "OpaqueCell" + ) { + // OpaqueCell should be treated as OpaqueRef + const kind = name === "OpaqueCell" ? "OpaqueRef" : name; + return { kind, typeRef }; } } } - // OpaqueRef with literal type arguments becomes an intersection - // e.g., OpaqueRef<"initial"> expands to: OpaqueRefMethods<"initial"> & "initial" - // We need to detect OpaqueRefMethods to handle this case + // OpaqueRef/OpaqueCell with literal type arguments becomes an intersection + // e.g., OpaqueRef<"initial"> expands to: OpaqueCell<"initial"> & IOpaqueCell<"initial"> + // We need to detect these internal types to handle this case if (type.flags & ts.TypeFlags.Intersection) { const intersectionType = type as ts.IntersectionType; for (const constituent of intersectionType.types) { @@ -450,8 +497,11 @@ export class CommonToolsFormatter implements TypeFormatter { if (objectType.objectFlags & ts.ObjectFlags.Reference) { const typeRef = objectType as ts.TypeReference; const name = typeRef.target?.symbol?.name; - // OpaqueRefMethods is the internal type that OpaqueRef expands to - if (name === "OpaqueRefMethods") { + // Check for both old (OpaqueRefMethods) and new (OpaqueCell, IOpaqueCell) internal types + if ( + name === "OpaqueRefMethods" || name === "OpaqueCell" || + name === "IOpaqueCell" + ) { return { kind: "OpaqueRef", typeRef }; } } diff --git a/packages/schema-generator/src/formatters/intersection-formatter.ts b/packages/schema-generator/src/formatters/intersection-formatter.ts index c639090de..7c157f9be 100644 --- a/packages/schema-generator/src/formatters/intersection-formatter.ts +++ b/packages/schema-generator/src/formatters/intersection-formatter.ts @@ -17,7 +17,19 @@ const DOC_CONFLICT_COMMENT = export class IntersectionFormatter implements TypeFormatter { constructor(private schemaGenerator: SchemaGenerator) {} + /** + * Check if a type has a CELL_BRAND property (is a cell type) + */ + private isCellType(type: ts.Type): boolean { + const brandSymbol = type.getProperty("CELL_BRAND"); + return brandSymbol !== undefined; + } + supportsType(type: ts.Type, _context: GenerationContext): boolean { + // Don't handle cell types - they are intersection types but should be handled by CommonToolsFormatter + if (this.isCellType(type)) { + return false; + } return (type.flags & ts.TypeFlags.Intersection) !== 0; } diff --git a/packages/static/assets/types/commontools.d.ts b/packages/static/assets/types/commontools.d.ts index 7e5416ea1..6ff02cf56 100644 --- a/packages/static/assets/types/commontools.d.ts +++ b/packages/static/assets/types/commontools.d.ts @@ -9,35 +9,286 @@ export declare const ID_FIELD: unique symbol; export declare const TYPE = "$TYPE"; export declare const NAME = "$NAME"; export declare const UI = "$UI"; -export interface Cell { +/** + * Brand symbol for identifying different cell types at compile-time. + * Each cell variant has a unique combination of capability flags. + */ +export declare const CELL_BRAND: unique symbol; +/** + * Minimal cell type with just the brand, no methods. + * Used for type-level operations like unwrapping nested cells without + * creating circular dependencies. + */ +export type BrandedCell = { + [CELL_BRAND]: Brand; +}; +type IsThisObject = IsThisArray | BrandedCell | BrandedCell>; +type IsThisArray = BrandedCell | BrandedCell> | BrandedCell> | BrandedCell | BrandedCell; +export interface IAnyCell { +} +/** + * Readable cells can retrieve their current value. + */ +export interface IReadable { get(): Readonly; - set(value: T): void; - send(value: T): void; - update(values: Partial): void; - push(...value: T extends (infer U)[] ? U[] : never): void; - equals(other: Cell): boolean; - key(valueKey: K): Cell; - resolveAsCell(): Cell; -} -export interface Stream { - send(event: T): void; -} -export type OpaqueRef = OpaqueRefMethods & (T extends Array ? Array> : T extends object ? { - [K in keyof T]: OpaqueRef; -} : T); -export type Opaque = OpaqueRef | (T extends Array ? Array> : T extends object ? { - [K in keyof T]: Opaque; -} : T); -export interface OpaqueRefMethods { +} +/** + * Writable cells can update their value. + */ +export interface IWritable { + set(value: T | AnyCellWrapping): void; + update | AnyCellWrapping>)>(this: IsThisObject, values: V extends object ? AnyCellWrapping : never): void; + push(this: IsThisArray, ...value: T extends (infer U)[] ? (U | AnyCellWrapping)[] : never): void; +} +/** + * Streamable cells can send events. + */ +export interface IStreamable { + send(event: AnyCellWrapping): void; +} +interface HKT { + _A: unknown; + type: unknown; +} +type Apply = (F & { + _A: A; +})["type"]; +/** + * A key-addressable, **covariant** view over a structured value `T`. + * + * `IKeyableCell` exposes a single method, {@link IKeyableCell.key}, which selects a + * property from the (possibly branded) value `T` and returns it wrapped in a + * user-provided type constructor `Wrap` (default: `Cell<…>`). The interface is + * declared `out T` (covariant) and is designed so that calling `key` preserves + * both type inference and variance soundness. + * + * @template T + * The underlying (possibly branded) value type. `T` is treated **covariantly**: + * `IKeyableCell` is assignable to `IKeyableCell` when `Sub` is + * assignable to `Super`. + * + * @template Wrap extends HKT + * A lightweight higher-kinded “wrapper” that determines the return container for + * selected fields. For example, `AsCell` wraps as `Cell`, while other wrappers + * can project to `ReadonlyCell`, `Stream`, etc. Defaults to `AsCell`. + * + * @template Any + * The “fallback” return type used when the provided key does not match a known + * key (or is widened to `any`). This should usually be `Apply`. + * + * @remarks + * ### Variance & soundness + * The `key` signature is crafted to remain **covariant in `T`**. Internally, + * it guards the instantiation `K = any` with `unknown extends K ? … : …`, so + * the return type becomes `Any` (independent of `T`) in that case. For real keys + * (`K extends keyof UnwrapCell`), the return type is precise and fully inferred. + * + * ### Branded / nested cells + * If a selected property is itself a branded cell (e.g., `BrandedCell`), + * the return value is a wrapped branded cell, i.e. `Wrap>`. + * + * ### Key inference + * Passing a string/number/symbol that is a literal and a member of + * `keyof UnwrapCell` yields precise field types; non-literal or unknown keys + * fall back to `Any` (e.g., `Cell`). + * + * @example + * // Basic usage with the default wrapper (Cell) + * declare const userCell: IKeyableCell<{ id: string; profile: { name: string } }>; + * const idCell = userCell.key("id"); // Cell + * const profileCell = userCell.key("profile"); // Cell<{ name: string }> + * + * // Unknown key falls back to Any (default: Cell) + * const whatever = userCell.key(Symbol()); // Cell + * + * @example + * // Using a custom wrapper, e.g., ReadonlyCell + * interface AsReadonlyCell extends HKT { type: ReadonlyCell } + * type ReadonlyUserCell = IKeyableCell<{ id: string }, AsReadonlyCell, Apply>; + * declare const ro: ReadonlyUserCell; + * const idRO = ro.key("id"); // ReadonlyCell + * + * @example + * // Covariance works: + * declare const sub: IKeyableCell<{ a: string }>; + * const superCell: IKeyableCell = sub; // OK (out T) + */ +export interface IKeyable { + key(this: IsThisObject, valueKey: K): KeyResultType; +} +export type KeyResultType = [unknown] extends [K] ? Apply : [0] extends [1 & T] ? Apply : T extends BrandedCell ? (T extends { + key(k: K): infer R; +} ? R : Apply) : Apply; +/** + * Cells that support key() for property access - OpaqueCell variant. + * OpaqueCell is "sticky" and always returns OpaqueCell<>. + * + * Note: And for now it always returns an OpaqueRef<>, until we clean this up. + */ +export interface IKeyableOpaque { + key(this: IsThisObject, valueKey: K): unknown extends K ? OpaqueRef : K extends keyof UnwrapCell ? (0 extends (1 & T) ? OpaqueRef : UnwrapCell[K] extends never ? OpaqueRef : UnwrapCell[K] extends BrandedCell ? OpaqueRef : OpaqueRef[K]>) : OpaqueRef; +} +/** + * Cells that can be resolved back to a Cell. + * Only available on full Cell, not on OpaqueCell or Stream. + */ +export interface IResolvable> { + resolveAsCell(): C; +} +/** + * Comparable cells have equals() method. + * Available on comparable and readable cells. + */ +export interface IEquatable { + equals(other: AnyCell | object): boolean; +} +/** + * Cells that allow deriving new cells from existing cells. Currently just + * .map(), but this will eventually include all Array, String and Number + * methods. + */ +export interface IDerivable { + map(this: IsThisObject, fn: (element: T extends Array ? OpaqueRef : OpaqueRef, index: OpaqueRef, array: OpaqueRef) => Opaque): OpaqueRef; + mapWithPattern(this: IsThisObject, op: Recipe, params: Record): OpaqueRef; +} +export interface IOpaquable { + /** deprecated */ get(): T; - set(value: Opaque | T): void; - key(key: K): OpaqueRef; + /** deprecated */ + set(newValue: Opaque>): void; + /** deprecated */ setDefault(value: Opaque | T): void; + /** deprecated */ + setPreExisting(ref: any): void; + /** deprecated */ setName(name: string): void; + /** deprecated */ setSchema(schema: JSONSchema): void; - map(fn: (element: T extends Array ? OpaqueRef : OpaqueRef, index: OpaqueRef, array: OpaqueRef) => Opaque): OpaqueRef; - mapWithPattern(op: Recipe, params: Record): OpaqueRef; } +/** + * Base type for all cell variants that has methods. Internal API augments this + * interface with internal only API. Uses a second symbol brand to distinguish + * from core cell brand without any methods. + */ +export interface AnyCell extends BrandedCell, IAnyCell { +} +/** + * Opaque cell reference - only supports keying and derivation, not direct I/O. + * Has .key(), .map(), .mapWithPattern() + * Does NOT have .get()/.set()/.send()/.equals()/.resolveAsCell() + */ +export interface IOpaqueCell extends IKeyableOpaque, IDerivable, IOpaquable { +} +export interface OpaqueCell extends BrandedCell, IOpaqueCell { +} +/** + * Full cell with read, write capabilities. + * Has .get(), .set(), .update(), .push(), .equals(), .key(), .resolveAsCell() + * + * Note: This is an interface (not a type) to allow module augmentation by the runtime. + */ +export interface AsCell extends HKT { + type: Cell; +} +export interface ICell extends IAnyCell, IReadable, IWritable, IStreamable, IEquatable, IKeyable, IResolvable> { +} +export interface Cell extends BrandedCell, ICell { +} +/** + * Stream-only cell - can only send events, not read or write. + * Has .send() only + * Does NOT have .key()/.equals()/.get()/.set()/.resolveAsCell() + * + * Note: This is an interface (not a type) to allow module augmentation by the runtime. + */ +export interface Stream extends BrandedCell, IAnyCell, IStreamable { +} +/** + * Comparable-only cell - just for equality checks and keying. + * Has .equals(), .key() + * Does NOT have .resolveAsCell()/.get()/.set()/.send() + */ +interface AsComparableCell extends HKT { + type: ComparableCell; +} +export interface ComparableCell extends BrandedCell, IAnyCell, IEquatable, IKeyable { +} +/** + * Read-only cell variant. + * Has .get(), .equals(), .key() + * Does NOT have .resolveAsCell()/.set()/.send() + */ +interface AsReadonlyCell extends HKT { + type: ReadonlyCell; +} +export interface ReadonlyCell extends BrandedCell, IAnyCell, IReadable, IEquatable, IKeyable { +} +/** + * Write-only cell variant. + * Has .set(), .update(), .push(), .key() + * Does NOT have .resolveAsCell()/.get()/.equals()/.send() + */ +interface AsWriteonlyCell extends HKT { + type: WriteonlyCell; +} +export interface WriteonlyCell extends BrandedCell, IAnyCell, IWritable, IKeyable { +} +/** + * OpaqueRef is a variant of OpaqueCell with recursive proxy behavior. + * Each key access returns another OpaqueRef, allowing chained property access. + * This is temporary until AST transformation handles .key() automatically. + */ +export type OpaqueRef = OpaqueCell & (T extends Array ? Array> : T extends object ? { + [K in keyof T]: OpaqueRef; +} : T); +/** + * CellLike is a cell (AnyCell) whose nested values are Opaque. + * The top level must be AnyCell, but nested values can be plain or wrapped. + * + * Note: This is primarily used for type constraints that require a cell. + */ +export type CellLike = BrandedCell>; +type MaybeCellWrapped = T | BrandedCell | (T extends Array ? Array> : T extends object ? { + [K in keyof T]: MaybeCellWrapped; +} : never); +/** + * Opaque accepts T or any cell wrapping T, recursively at any nesting level. + * Used in APIs that accept inputs from developers - can be static values + * or wrapped in cells (OpaqueRef, Cell, etc). + * + * Conceptually: T | AnyCell at any nesting level, but we use OpaqueRef + * for backward compatibility since it has the recursive proxy behavior that + * allows property access (e.g., Opaque<{foo: string}> includes {foo: Opaque}). + */ +export type Opaque = T | OpaqueRef | (T extends Array ? Array> : T extends object ? { + [K in keyof T]: Opaque; +} : T); +/** + * Recursively unwraps BrandedCell types at any nesting level. + * UnwrapCell>> = string + * UnwrapCell }>> = { a: BrandedCell } + * + * Special cases: + * - UnwrapCell = any + * - UnwrapCell = unknown (preserves unknown) + */ +export type UnwrapCell = 0 extends (1 & T) ? T : T extends BrandedCell ? UnwrapCell : T; +/** + * AnyCellWrapping is used for write operations (.set(), .push(), .update()). It + * is a type utility that allows any part of type T to be wrapped in AnyCell<>, + * and allow any part of T that is currently wrapped in AnyCell<> to be used + * unwrapped. This is designed for use with cell method parameters, allowing + * flexibility in how values are passed. The ID and ID_FIELD metadata symbols + * allows controlling id generation and can only be passed to write operations. + */ +export type AnyCellWrapping = T extends BrandedCell ? AnyCellWrapping | BrandedCell> : T extends Array ? Array> | BrandedCell>> : T extends object ? { + [K in keyof T]: AnyCellWrapping; +} & { + [ID]?: AnyCellWrapping; + [ID_FIELD]?: string; +} | BrandedCell<{ + [K in keyof T]: AnyCellWrapping; +}> : T | BrandedCell; export interface Recipe { argumentSchema: JSONSchema; resultSchema: JSONSchema; @@ -286,7 +537,7 @@ export type FetchDataFunction = (params: Opaque<{ mode?: "json" | "text"; options?: FetchOptions; result?: T; -}>) => Opaque<{ +}>) => OpaqueRef<{ pending: boolean; result: T; error: any; @@ -295,7 +546,7 @@ export type StreamDataFunction = (params: Opaque<{ url: string; options?: FetchOptions; result?: T; -}>) => Opaque<{ +}>) => OpaqueRef<{ pending: boolean; result: T; error: any; @@ -395,66 +646,22 @@ type ResolveRef = Left extends boolean ? Left : Right extends boolean ? Right extends true ? Left : false : { [K in keyof Left | keyof Right]: K extends keyof Left ? Left[K] : K extends keyof Right ? Right[K] : never; }; -/** - * Merge ref site schema with resolved target, then process with Schema<>. - * Implements JSON Schema spec: ref site siblings override target. - */ -type MergeRefSiteWithTarget = RefSite extends { - $ref: string; -} ? MergeSchemas, Target> extends infer Merged extends JSONSchema ? Schema : never : never; -/** - * Merge ref site schema with resolved target, then process with SchemaWithoutCell<>. - * Same as MergeRefSiteWithTarget but doesn't wrap in Cell/Stream. - */ -type MergeRefSiteWithTargetWithoutCell = RefSite extends { +type MergeRefSiteWithTargetGeneric = RefSite extends { $ref: string; -} ? MergeSchemas, Target> extends infer Merged extends JSONSchema ? SchemaWithoutCell : never : never; -/** - * Convert a JSON Schema to its TypeScript type equivalent. - * - * Supports: - * - Primitive types (string, number, boolean, null) - * - Objects with properties (required/optional) - * - Arrays with items - * - anyOf unions - * - $ref resolution (including JSON Pointers) - * - asCell/asStream reactive wrappers - * - default values (makes properties required) - * - * $ref Support: - * - "#" (self-reference to root schema) - * - "#/$defs/Name" (JSON Pointer to definition) - * - "#/properties/field" (JSON Pointer to any schema location) - * - External refs (http://...) return type `any` - * - * Default Precedence: - * When both ref site and target have `default`, ref site takes precedence - * per JSON Schema 2020-12 specification. - * - * Limitations: - * - JSON Pointer escaping (~0, ~1) not supported at type level - * - Depth limited to 9 levels to prevent infinite recursion - * - Complex allOf/oneOf logic may not match runtime exactly - * - * @template T - The JSON Schema to convert - * @template Root - Root schema for $ref resolution - * @template Depth - Recursion depth limit (0-9) - */ -export type Schema = Depth extends 0 ? unknown : T extends { - asCell: true; -} ? Cell, Root, Depth>> : T extends { - asStream: true; -} ? Stream, Root, Depth>> : T extends { +} ? MergeSchemas, Target> extends infer Merged extends JSONSchema ? SchemaInner : never : never; +type SchemaAnyOf = { + [I in keyof Schemas]: Schemas[I] extends JSONSchema ? SchemaInner, WrapCells> : never; +}[number]; +type SchemaArrayItems = Items extends JSONSchema ? Array, WrapCells>> : unknown[]; +type SchemaCore = T extends { $ref: "#"; -} ? Schema, Root, DecrementDepth> : T extends { +} ? SchemaInner, Root, DecrementDepth, WrapCells> : T extends { $ref: infer RefStr extends string; -} ? MergeRefSiteWithTarget>, Root, DecrementDepth> : T extends { +} ? MergeRefSiteWithTargetGeneric>, Root, DecrementDepth, WrapCells> : T extends { enum: infer E extends readonly any[]; } ? E[number] : T extends { anyOf: infer U extends readonly JSONSchema[]; -} ? U extends readonly [infer F, ...infer R extends JSONSchema[]] ? F extends JSONSchema ? Schema> | Schema<{ - anyOf: R; -}, Root, Depth> : never : never : T extends { +} ? SchemaAnyOf : T extends { type: "string"; } ? string : T extends { type: "number" | "integer"; @@ -466,7 +673,7 @@ export type Schema>> : unknown[] : unknown[] : T extends { +} ? SchemaArrayItems : unknown[] : T extends { type: "object"; } ? T extends { properties: infer P; @@ -474,21 +681,27 @@ export type Schema> : Record : T extends { +} ? AP : false, GetDefaultKeys, WrapCells> : Record : T extends { additionalProperties: infer AP; -} ? AP extends false ? Record : AP extends true ? Record : AP extends JSONSchema ? Record>> : Record : Record : any; +} ? AP extends false ? Record : AP extends true ? Record : AP extends JSONSchema ? Record, WrapCells>> : Record : Record : any; +type SchemaInner = Depth extends 0 ? unknown : T extends { + asCell: true; +} ? WrapCells extends true ? Cell, Root, Depth, WrapCells>> : SchemaInner, Root, Depth, WrapCells> : T extends { + asStream: true; +} ? WrapCells extends true ? Stream, Root, Depth, WrapCells>> : SchemaInner, Root, Depth, WrapCells> : SchemaCore; +export type Schema = SchemaInner; type GetDefaultKeys = T extends { default: infer D; } ? D extends Record ? keyof D & string : never : never; -type ObjectFromProperties

, R extends readonly string[] | never, Root extends JSONSchema, Depth extends DepthLevel, AP extends JSONSchema = false, DK extends string = never> = { - [K in keyof P as K extends string ? K extends R[number] | DK ? K : never : never]: Schema>; +type ObjectFromProperties

, R extends readonly string[] | never, Root extends JSONSchema, Depth extends DepthLevel, AP extends JSONSchema = false, DK extends string = never, WrapCells extends boolean = true> = { + [K in keyof P as K extends string ? K extends R[number] | DK ? K : never : never]: SchemaInner, WrapCells>; } & { - [K in keyof P as K extends string ? K extends R[number] | DK ? never : K : never]?: Schema>; + [K in keyof P as K extends string ? K extends R[number] | DK ? never : K : never]?: SchemaInner, WrapCells>; } & (AP extends false ? Record : AP extends true ? { [key: string]: unknown; } : AP extends JSONSchema ? { - [key: string]: Schema>; -} : Record) & IDFields; + [key: string]: SchemaInner, WrapCells>; +} : Record); type DepthLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; type Decrement = { 0: 0; @@ -503,52 +716,7 @@ type Decrement = { 9: 8; }; type DecrementDepth = Decrement[D] & DepthLevel; -export type SchemaWithoutCell = Depth extends 0 ? unknown : T extends { - asCell: true; -} ? SchemaWithoutCell, Root, Depth> : T extends { - asStream: true; -} ? SchemaWithoutCell, Root, Depth> : T extends { - $ref: "#"; -} ? SchemaWithoutCell, Root, DecrementDepth> : T extends { - $ref: infer RefStr extends string; -} ? MergeRefSiteWithTargetWithoutCell>, Root, DecrementDepth> : T extends { - enum: infer E extends readonly any[]; -} ? E[number] : T extends { - anyOf: infer U extends readonly JSONSchema[]; -} ? U extends readonly [infer F, ...infer R extends JSONSchema[]] ? F extends JSONSchema ? SchemaWithoutCell> | SchemaWithoutCell<{ - anyOf: R; -}, Root, Depth> : never : never : T extends { - type: "string"; -} ? string : T extends { - type: "number" | "integer"; -} ? number : T extends { - type: "boolean"; -} ? boolean : T extends { - type: "null"; -} ? null : T extends { - type: "array"; -} ? T extends { - items: infer I; -} ? I extends JSONSchema ? SchemaWithoutCell>[] : unknown[] : unknown[] : T extends { - type: "object"; -} ? T extends { - properties: infer P; -} ? P extends Record ? ObjectFromPropertiesWithoutCell> : Record : T extends { - additionalProperties: infer AP; -} ? AP extends false ? Record : AP extends true ? Record : AP extends JSONSchema ? Record>> : Record : Record : any; -type ObjectFromPropertiesWithoutCell

, R extends readonly string[] | never, Root extends JSONSchema, Depth extends DepthLevel, AP extends JSONSchema = false, DK extends string = never> = { - [K in keyof P as K extends string ? K extends R[number] | DK ? K : never : never]: SchemaWithoutCell>; -} & { - [K in keyof P as K extends string ? K extends R[number] | DK ? never : K : never]?: SchemaWithoutCell>; -} & (AP extends false ? Record : AP extends true ? { - [key: string]: unknown; -} : AP extends JSONSchema ? { - [key: string]: SchemaWithoutCell>; -} : Record); +export type SchemaWithoutCell = SchemaInner; /** * JSX factory function for creating virtual DOM nodes. * @param name - The element name or component function diff --git a/packages/ts-transformers/src/transformers/opaque-ref/opaque-ref.ts b/packages/ts-transformers/src/transformers/opaque-ref/opaque-ref.ts index 03658b739..9cb82cb76 100644 --- a/packages/ts-transformers/src/transformers/opaque-ref/opaque-ref.ts +++ b/packages/ts-transformers/src/transformers/opaque-ref/opaque-ref.ts @@ -1,10 +1,120 @@ import ts from "typescript"; -import { - resolvesToCommonToolsSymbol, - symbolDeclaresCommonToolsDefault, -} from "../../core/mod.ts"; +import { symbolDeclaresCommonToolsDefault } from "../../core/mod.ts"; import { getMemberSymbol } from "../../ast/mod.ts"; +/** + * Get the CELL_BRAND string value from a type, if it has one. + * Returns the brand string ("opaque", "cell", "stream", etc.) or undefined. + */ +function getCellBrand( + type: ts.Type, + checker: ts.TypeChecker, +): string | undefined { + const brandSymbol = findCellBrandSymbol(type, checker, new Set()); + if (!brandSymbol) return undefined; + + const declaration = brandSymbol.valueDeclaration ?? + brandSymbol.declarations?.[0]; + if (!declaration) return undefined; + + const brandType = checker.getTypeOfSymbolAtLocation(brandSymbol, declaration); + if (brandType && (brandType.flags & ts.TypeFlags.StringLiteral)) { + return (brandType as ts.StringLiteralType).value; + } + + return undefined; +} + +function findCellBrandSymbol( + type: ts.Type, + checker: ts.TypeChecker, + seen: Set, +): ts.Symbol | undefined { + if (seen.has(type)) return undefined; + seen.add(type); + + const direct = getBrandSymbolFromType(type, checker); + if (direct) return direct; + + const apparent = checker.getApparentType(type); + if (apparent !== type) { + const fromApparent = findCellBrandSymbol(apparent, checker, seen); + if (fromApparent) return fromApparent; + } + + if (type.flags & (ts.TypeFlags.Union | ts.TypeFlags.Intersection)) { + const compound = type as ts.UnionOrIntersectionType; + for (const child of compound.types) { + const childSymbol = findCellBrandSymbol(child, checker, seen); + if (childSymbol) return childSymbol; + } + } + + if (!(type.flags & ts.TypeFlags.Object)) { + return undefined; + } + + const objectType = type as ts.ObjectType; + + if (objectType.objectFlags & ts.ObjectFlags.Reference) { + const typeRef = objectType as ts.TypeReference; + if (typeRef.target) { + const fromTarget = findCellBrandSymbol(typeRef.target, checker, seen); + if (fromTarget) return fromTarget; + } + } + + if (objectType.objectFlags & ts.ObjectFlags.ClassOrInterface) { + const baseTypes = checker.getBaseTypes(objectType as ts.InterfaceType) ?? + []; + for (const base of baseTypes) { + const fromBase = findCellBrandSymbol(base, checker, seen); + if (fromBase) return fromBase; + } + } + + return undefined; +} + +function getBrandSymbolFromType( + type: ts.Type, + checker: ts.TypeChecker, +): ts.Symbol | undefined { + for (const prop of checker.getPropertiesOfType(type)) { + if (isCellBrandSymbol(prop)) { + return prop; + } + } + return undefined; +} + +function isCellBrandSymbol(symbol: ts.Symbol): boolean { + const name = symbol.getName(); + if (name === "CELL_BRAND" || name.startsWith("__@CELL_BRAND")) { + return true; + } + + const declarations = symbol.getDeclarations() ?? []; + for (const declaration of declarations) { + if ( + (ts.isPropertySignature(declaration) || + ts.isPropertyDeclaration(declaration)) && + ts.isComputedPropertyName(declaration.name) + ) { + const expr = declaration.name.expression; + if (ts.isIdentifier(expr) && expr.text === "CELL_BRAND") { + return true; + } + } + } + + return false; +} + +/** + * Check if a type is a cell type by looking for the CELL_BRAND property. + * This includes OpaqueCell, Cell, Stream, and other cell variants. + */ export function isOpaqueRefType( type: ts.Type, checker: ts.TypeChecker, @@ -22,64 +132,47 @@ export function isOpaqueRefType( isOpaqueRefType(t, checker) ); } - if (type.flags & ts.TypeFlags.Object) { - const objectType = type as ts.ObjectType; - if (objectType.objectFlags & ts.ObjectFlags.Reference) { - const typeRef = objectType as ts.TypeReference; - const target = typeRef.target; - if (target && target.symbol) { - const symbolName = target.symbol.getName(); - if (symbolName === "OpaqueRef" || symbolName === "Cell") return true; - if ( - resolvesToCommonToolsSymbol(target.symbol, checker, "Default") - ) { - return true; - } - const qualified = checker.getFullyQualifiedName(target.symbol); - if (qualified.includes("OpaqueRef") || qualified.includes("Cell")) { - return true; - } - } - } - const symbol = type.getSymbol(); - if (symbol) { - if ( - symbol.name === "OpaqueRef" || - symbol.name === "OpaqueRefMethods" || - symbol.name === "OpaqueRefBase" || - symbol.name === "Cell" - ) { - return true; - } - if (resolvesToCommonToolsSymbol(symbol, checker, "Default")) { - return true; - } - const qualified = checker.getFullyQualifiedName(symbol); - if (qualified.includes("OpaqueRef") || qualified.includes("Cell")) { - return true; - } - } - } - if (type.aliasSymbol) { - const aliasName = type.aliasSymbol.getName(); - if ( - aliasName === "OpaqueRef" || - aliasName === "Opaque" || - aliasName === "Cell" - ) { - return true; - } - if (resolvesToCommonToolsSymbol(type.aliasSymbol, checker, "Default")) { - return true; - } - const qualified = checker.getFullyQualifiedName(type.aliasSymbol); - if (qualified.includes("OpaqueRef") || qualified.includes("Cell")) { - return true; - } + + // Primary method: look for the CELL_BRAND unique symbol on the type. + const brand = getCellBrand(type, checker); + if (brand !== undefined) { + // Valid cell brands: "opaque", "cell", "stream", "comparable", "readonly", "writeonly" + return ["opaque", "cell", "stream", "comparable", "readonly", "writeonly"] + .includes(brand); } + return false; } +/** + * Get the cell kind from a type ("opaque", "cell", or "stream"). + * Maps other cell types to their logical category. + * Returns undefined if not a cell type. + */ +export function getCellKind( + type: ts.Type, + checker: ts.TypeChecker, +): "opaque" | "cell" | "stream" | undefined { + const brand = getCellBrand(type, checker); + if (brand === undefined) return undefined; + + // Map brands to their logical categories + switch (brand) { + case "opaque": + return "opaque"; + case "cell": + case "comparable": + case "readonly": + case "writeonly": + // All these are variants of Cell + return "cell"; + case "stream": + return "stream"; + default: + return undefined; + } +} + export function containsOpaqueRef( node: ts.Node, checker: ts.TypeChecker, diff --git a/packages/ts-transformers/test/opaque-ref/harness.ts b/packages/ts-transformers/test/opaque-ref/harness.ts index 4fadcb3fd..d02bdea24 100644 --- a/packages/ts-transformers/test/opaque-ref/harness.ts +++ b/packages/ts-transformers/test/opaque-ref/harness.ts @@ -19,13 +19,19 @@ export function analyzeExpression( ): AnalysisHarnessResult { const fileName = "/analysis.ts"; const programSource = ` +declare const CELL_BRAND: unique symbol; + +type BrandedCell = { + readonly [CELL_BRAND]: Brand; +}; + interface OpaqueRefMethods { map(fn: (...args: unknown[]) => S): OpaqueRef; } -type OpaqueRef = { - readonly __opaque: T; -} & OpaqueRefMethods; +type OpaqueRef = + & BrandedCell + & OpaqueRefMethods; declare const state: { readonly count: OpaqueRef; diff --git a/packages/ui/src/v2/components/ct-render/ct-render.ts b/packages/ui/src/v2/components/ct-render/ct-render.ts index 2a6ae8daa..a35157436 100644 --- a/packages/ui/src/v2/components/ct-render/ct-render.ts +++ b/packages/ui/src/v2/components/ct-render/ct-render.ts @@ -3,7 +3,7 @@ import { BaseElement } from "../../core/base-element.ts"; import { render } from "@commontools/html"; import type { Cell } from "@commontools/runner"; import { getRecipeIdFromCharm } from "@commontools/charm"; -import { UI } from "@commontools/api"; +import { UI, type VNode } from "@commontools/api"; // Set to true to enable debug logging const DEBUG_LOGGING = false; @@ -172,7 +172,7 @@ export class CTRender extends BaseElement { } this._log("rendering UI"); - this._cleanup = render(this._renderContainer, uiCell as Cell); + this._cleanup = render(this._renderContainer, uiCell as Cell); } private _isSubPath(cell: Cell): boolean { diff --git a/packages/ui/src/v2/core/cell-controller.ts b/packages/ui/src/v2/core/cell-controller.ts index 0b1f76e37..8956bd422 100644 --- a/packages/ui/src/v2/core/cell-controller.ts +++ b/packages/ui/src/v2/core/cell-controller.ts @@ -390,7 +390,8 @@ export class ArrayCellController extends CellController { // Must wrap in transaction like other Cell operations const cell = this.getCell()!; const tx = cell.runtime.edit(); - cell.withTx(tx).key(index).set(newItem); + const itemCell = cell.key(index); + itemCell.withTx(tx).set(newItem); tx.commit(); } else { // Fallback for plain arrays