|
1 | | -# Capability Wrappers Prototype |
| 1 | +# Capability Types via Branded Types |
| 2 | + |
| 3 | +> **Status:** This design has been **superseded by the branded types approach** |
| 4 | +> described in `rollout-plan.md`. We are using TypeScript branded types with |
| 5 | +> symbol-based brands rather than runtime proxy wrappers. |
2 | 6 |
|
3 | 7 | ## Objectives |
4 | 8 |
|
5 | | -- Collapse `OpaqueRef` and runtime `Cell` APIs into a single object whose |
| 9 | +- Collapse `OpaqueRef` and runtime `Cell` APIs into a single type system whose |
6 | 10 | surface area is gated by explicit capabilities (`Opaque`, `Readonly`, |
7 | 11 | `Mutable`, `Writeonly`). |
| 12 | +- Use TypeScript's type system (branded types) rather than runtime Proxy objects |
| 13 | + to enforce capability boundaries. |
8 | 14 | - Keep recipe ergonomics intact by continuing to expose property proxies and |
9 | | - helpers like `.map()` while steering operations through capability-aware |
10 | | - wrappers. |
| 15 | + helpers like `.map()`. |
11 | 16 | - Provide a migration path that lets existing recipes run during the migration |
12 | 17 | window by adapting the current builder implementation incrementally. |
13 | 18 |
|
|
27 | 32 | nested recipes. That indirection means wrappers must cooperate with module |
28 | 33 | factories that expect builder-time proxies yet execute against runtime cells. |
29 | 34 |
|
30 | | -## Wrapper Shape |
31 | | - |
32 | | -- Introduce a lightweight `CapabilityCell<T, C extends Capability>` interface |
33 | | - that wraps a concrete runtime cell and exposes operations allowed by |
34 | | - capability `C`. Capabilities map to subsets of the current `Cell` API: |
35 | | - - `Opaque`: property proxies, `.map`, `.filter`, `.derive`, structural |
36 | | - equality, read-only helpers. |
37 | | - - `Readonly`: everything in `Opaque` plus `.get`, `.getAsQueryResult`, and |
38 | | - schema navigation helpers that do not mutate state. |
39 | | - - `Mutable`: superset of `Readonly` plus `.set`, `.update`, `.push`, and |
40 | | - `.redirectTo`. |
41 | | - - `Writeonly`: `.send`, `.set`, `.redirectTo`, but intentionally hides `.get` |
42 | | - to encourage event-sink authoring disciplines. |
43 | | -- Use `Proxy` objects to intercept property access and method calls. Each proxy |
44 | | - lazily constructs child proxies with the same capability so nested lookups |
45 | | - remain ergonomic (e.g. `mutable.todos[0].title.set("…")`). |
46 | | -- When a helper like `.map` is invoked, delegate to a capability-aware shim that |
47 | | - wraps the callee recipe factory so the inner function receives wrappers |
48 | | - instead of raw cells or `OpaqueRef`s. Internally reuse the existing |
49 | | - `createNodeFactory` path to avoid reimplementing module registration. |
50 | | - |
51 | | -## Construction Flow |
52 | | - |
53 | | -1. Builder entry points (`recipe`, `lift`, `handler`) push a frame, wrap inputs |
54 | | - in `CapabilityCell` proxies instead of `opaqueRef`, and still record the |
55 | | - graph using existing traversal utilities. |
56 | | -2. For compatibility, supply adapters that allow legacy `OpaqueRef` helper |
57 | | - affordances (`setDefault`, `unsafe_bindToRecipeAndPath`) to continue working |
58 | | - until all call sites migrate. These adapters simply forward to the wrapped |
59 | | - runtime cell or translate to snapshot metadata updates. |
60 | | -3. During runtime execution, `pushFrameFromCause` already seeds the frame with |
61 | | - the `cause` and `unsafe_binding`. Wrappers created while executing a lifted |
62 | | - function can therefore call `runtime.getCell` immediately because the cause |
63 | | - data is ready. |
| 35 | +## Type System Shape (Branded Types Approach) |
| 36 | + |
| 37 | +The actual implementation uses **branded types** rather than runtime Proxy wrappers: |
| 38 | + |
| 39 | +- Create a `CellLike<T>` type with a symbol-based brand where the value is |
| 40 | + `Record<string, boolean>` representing capability flags. |
| 41 | +- Factor out interface parts along: reading, writing, `.send` (for stream-like), |
| 42 | + and derives (currently just `.map`). |
| 43 | +- Define capability types by combining these factored parts with specific brand |
| 44 | + configurations: |
| 45 | + - `OpaqueRef<T>`: `{ opaque: true, read: false, write: false, stream: false }` |
| 46 | + - Supports: property proxies, `.map`, `.filter`, `.derive`, structural equality |
| 47 | + - `Cell<T>` (Mutable): `{ opaque: false, read: true, write: true, stream: true }` |
| 48 | + - Supports: everything (`.get`, `.set`, `.update`, `.push`, `.redirectTo`, `.send`) |
| 49 | + - `Stream<T>`: `{ opaque: false, read: false, write: false, stream: true }` |
| 50 | + - Supports: `.send` only |
| 51 | + - `ReadonlyCell<T>`: `{ opaque: false, read: true, write: false, stream: false }` |
| 52 | + - Supports: `.get`, `.getAsQueryResult`, schema navigation |
| 53 | + - `WriteonlyCell<T>`: `{ opaque: false, read: false, write: true, stream: false }` |
| 54 | + - Supports: `.set`, `.update`, `.redirectTo` but hides `.get` |
| 55 | +- For `OpaqueRef`, keep proxy behavior where each key access returns another |
| 56 | + `OpaqueRef`. |
| 57 | +- Simplify most wrap/unwrap types to use `CellLike`. |
| 58 | + |
| 59 | +### Comparison to Original Proxy Design |
| 60 | + |
| 61 | +The branded types approach provides compile-time safety without runtime overhead. |
| 62 | +The original proxy-based `CapabilityCell<T, C>` design is **not being implemented** |
| 63 | +because TypeScript's type system can enforce the same boundaries more efficiently. |
| 64 | + |
| 65 | +## Construction Flow (Branded Types) |
| 66 | + |
| 67 | +1. Builder entry points (`recipe`, `lift`, `handler`) push a frame and work with |
| 68 | + the unified `CellLike` types instead of separate `opaqueRef` and `Cell` types. |
| 69 | +2. Cell creation is deferred - cells can be created without an immediate link, |
| 70 | + using `.for(cause)` to establish the link later. |
| 71 | +3. For compatibility during migration, legacy `OpaqueRef` helper affordances |
| 72 | + (`setDefault`, `unsafe_bindToRecipeAndPath`) continue working until all call |
| 73 | + sites migrate. |
| 74 | +4. During runtime execution, `pushFrameFromCause` seeds the frame with the |
| 75 | + `cause` and `unsafe_binding`. Created cells can call `runtime.getCell` when |
| 76 | + their cause is ready (either automatically derived or explicitly set via |
| 77 | + `.for()`). |
64 | 78 |
|
65 | 79 | ## Type System Notes |
66 | 80 |
|
67 | | -- Export capability-specific TypeScript types from `@commontools/api` such as |
68 | | - `OpaqueCell<T>`, `ReadonlyCell<T>`, `MutableCell<T>`, and `WriteonlyCell<T>`. |
69 | | - These extend a shared base that keeps the proxy type information available to |
70 | | - recipe authors. |
| 81 | +- Export capability-specific TypeScript types from `@commontools/api`: |
| 82 | + `OpaqueRef<T>`, `Cell<T>`, `ReadonlyCell<T>`, `WriteonlyCell<T>`, and |
| 83 | + `Stream<T>`. |
| 84 | +- All types extend a shared `CellLike<T>` base with branded capability flags. |
71 | 85 | - Extend our JSON Schema annotations so authors can declare capabilities at any |
72 | 86 | depth. When `asCell: true` is present, allow an `opaque`, `readonly`, or |
73 | 87 | `writeonly` flag (or the closest JSON Schema standard equivalent if one |
74 | | - exists). Builder proxies read these flags to choose the capability for the |
75 | | - proxy returned by `key()` or nested property access. |
| 88 | + exists). |
76 | 89 | - Provide conditional helper types to map schema metadata to helper surfaces |
77 | 90 | (e.g., array helpers only appear when `T` extends `readonly any[]`). Reuse the |
78 | 91 | IFC-aware schema lookup utilities to keep helper availability aligned with the |
79 | 92 | JSON schema. |
80 | | -- Augment builders so `recipe` factories default to `OpaqueCell` inputs while |
| 93 | +- Augment builders so `recipe` factories default to `OpaqueRef` inputs while |
81 | 94 | `lift` can declare stronger capabilities for each argument via a typed options |
82 | 95 | bag (e.g., `{ inputs: { item: Capability.Mutable } }`). |
83 | 96 |
|
84 | | -## Cause Defaults |
85 | | - |
86 | | -- Capability helpers (e.g., `.map`, `.filter`) should emit deterministic |
87 | | - default causes that combine the parent cell's cause with a helper-specific |
88 | | - label. Authors can still pass explicit `cause` overrides to `lift` or |
89 | | - `handler`, but most call sites inherit stable defaults automatically. |
90 | | -- Expose an optional `.setCause(cause: CauseDescriptor)` chain on newly created |
91 | | - capability cells. It overrides the derived id before the cell participates in |
92 | | - the graph. If the cell has already been connected to a node or materialized |
93 | | - into a runtime cell, `.setCause` must throw to avoid inconsistencies. |
94 | | - |
95 | | -## Migration Strategy |
96 | | - |
97 | | -- Step 1: Introduce wrappers with shims that forward to existing opaque ref |
98 | | - behavior. Dual-write metadata (`export()`) so `factoryFromRecipe` can continue |
99 | | - serializing the graph until the snapshot pipeline lands. |
100 | | -- Step 2: Convert builtin helpers and modules to consume wrappers. Audit |
101 | | - `packages/runner/src/builder` to replace direct `OpaqueRef` imports with |
102 | | - wrapper types while keeping `opaqueRef` available as a thin adapter. |
103 | | -- Step 3: Update recipes in `recipes/` iteratively. Provide codemods that swap |
104 | | - `cell()` usage for capability-specific constructors when explicit mutability is |
105 | | - required. |
106 | | -- Step 4: Remove legacy-only APIs (`setDefault`, `setPreExisting`) once the |
107 | | - snapshot work replaces path-based aliasing and defaults come from schemas. |
| 97 | +## Cause Defaults and `.for()` Method |
| 98 | + |
| 99 | +- Cause assignment happens in two layers: |
| 100 | + 1. **Automatic derivation**: Default causes are derived from frame context, |
| 101 | + input cell ids, and implementation fingerprints (see `cause-derivation.md`) |
| 102 | + 2. **Explicit override via `.for()`**: Authors can call `.for(cause)` to |
| 103 | + explicitly assign a cause before the cell is linked |
| 104 | +- The `.for()` method provides an explicit layer on top of automation: |
| 105 | + - Optional second parameter makes it flexible (ignores if link already exists, |
| 106 | + adds extension if cause already exists) |
| 107 | + - Throws if cell already connected to a node or materialized into runtime cell |
| 108 | +- Helpers (e.g., `.map`, `.filter`) use automatic derivation by default but can |
| 109 | + be overridden with explicit `cause` parameters to `lift` or `handler`. |
| 110 | + |
| 111 | +## Migration Strategy (In-Place) |
| 112 | + |
| 113 | +This is an **in-place migration** rather than a V1/V2 opt-in system: |
| 114 | + |
| 115 | +- **Step 1**: Unify Cell API types using branded types (see `rollout-plan.md`) |
| 116 | + - Create `CellLike<>` and factor out capability traits |
| 117 | + - Remove `ShadowRef`/`unsafe_` mechanisms |
| 118 | +- **Step 2**: Enable deferred cell creation with `.for()` method |
| 119 | + - Change `RegularCell` constructor to make link optional |
| 120 | + - Add `.for()` method for explicit cause assignment |
| 121 | + - Implement automatic cause derivation as baseline |
| 122 | +- **Step 3**: Update recipe lifecycle to use deferred execution |
| 123 | + - Run recipes like lifts with tracked cell/cause creation |
| 124 | + - Remove JSON recipe representation |
| 125 | +- **Step 4**: Cleanup legacy APIs |
| 126 | + - Remove `setDefault`, `setPreExisting` once defaults come from schemas |
| 127 | + - Deprecate path-based aliasing in favor of graph snapshots (Phase 2) |
108 | 128 |
|
109 | 129 | ## Open Questions |
110 | 130 |
|
|
0 commit comments