add architecture docs synced to current source and sdd process

Phase 1 of SDD process: syncing docs/architecture/ to reflect the
existing source code. Eight component documents describe WHAT and WHY
(not HOW) for each module: schema, element factory, reactive layer,
host config, transforms, events, pointers, and build distribution.
Three ADRs capture key decisions (HTML-agnostic core, TypeBox Module
as type registry, Preact signals-core for reactivity). Each doc
documents known reconciler gaps and references the research in
docs/research/reconciler/.

Also adds docs/sdd_process.md (process reference shared across
alkdev projects) matching the taskgraph_ts pattern.
This commit is contained in:
2026-05-18 15:00:33 +00:00
parent 497a01c544
commit 09f32f0c64
13 changed files with 2072 additions and 0 deletions

162
docs/architecture/schema.md Normal file
View File

@@ -0,0 +1,162 @@
---
status: draft
last_updated: 2026-05-18
---
# Schema
TypeBox Module, TypeScript types, type guards, and the design decisions behind them.
## Overview
The UJSX schema defines the shape of the universal tree: `UNode`, `UElement`, `URoot`, and `UPrimitive`. It serves two purposes:
1. **Runtime validation and JSON Schema export** via a TypeBox `Module` — consumed by `Value.Check()` and exportable for consumers that need schema-based contracts.
2. **Static TypeScript types** for clean compiler inference — defined directly, not derived from the TypeBox schema, because `ComponentFn` and function-typed `PropValue` entries are runtime-only and not serializable.
This dual-source approach is intentional. The TypeBox Module handles validation and serialization. The TypeScript types handle ergonomics. They stay in sync through tests, not through derivation.
## TypeBox Module
The `UJSX` constant is a `Type.Module` that defines six interrelated schemas:
```typescript
export const UJSX = Type.Module({
UPrimitive: Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]),
PropValue: Type.Union([
Type.String(), Type.Number(), Type.Boolean(), Type.Null(),
Type.Array(Type.Unknown()),
Type.Ref("UNode"),
Type.Record(Type.String(), Type.Unknown()),
Type.Function([...Type.Rest(Type.Array(Type.Unknown()))], Type.Unknown()),
]),
UniversalProps: Type.Object(
{},
{ additionalProperties: Type.Union([Type.Ref("PropValue"), Type.Undefined()]) },
),
UElement: Type.Object({
type: Type.String(),
props: Type.Ref("UniversalProps"),
children: Type.Array(Type.Ref("UNode")),
}),
URoot: Type.Object({
type: Type.Literal("root"),
props: Type.Ref("UniversalProps"),
children: Type.Array(Type.Ref("UNode")),
}),
UNode: Type.Union([Type.Ref("UPrimitive"), Type.Ref("UElement"), Type.Ref("URoot")]),
});
```
### Design decisions in the Module
- **`UPrimitive`** is `string | number | boolean | null`. No `undefined``undefined` is JavaScript's "absent" and should not appear as a tree node value.
- **`PropValue`** includes `Type.Function(...)`. This means a `PropValue` can be a function (event handlers, component references). The trade-off: props are **not fully serializable**. This is by design — hosts that need serialization strip functions at their boundary.
- **`UniversalProps`** is an open object (`additionalProperties` allowed). Different hosts need different prop shapes. A DOM host needs `className`; a workflow host needs `operationId`. Constraining props to a closed schema would force hosts to extend UJSX's schema, inverting the dependency. The open schema lets hosts define their own prop contracts.
- **`UElement.type`** is `Type.String()`, not a union of known element types. Element types are host-defined — UJSX does not maintain a registry of valid type strings.
- **`URoot`** uses `Type.Literal("root")` as its `type` field. This makes `URoot` a discriminated union member, distinguishable from `UElement` at runtime and in TypeBox validation.
- **`UNode`** is the union `UPrimitive | UElement | URoot`. This is the fundamental tree node type — every value in a UJSX tree is a `UNode`.
### Re-export
```typescript
export { UJSX as schema };
```
The Module is re-exported as `schema` for consumer convenience. Call sites use `UJSX.Import("UElement")` etc. with `Value.Check` for runtime validation.
## TypeScript Types
The TypeScript types are defined directly, not via `Static<typeof UJSX>`, because `ComponentFn` is a runtime function type that doesn't serialize. Deriving from the TypeBox Module would either omit functions (breaking the type) or include non-serializable types in the schema (breaking validation).
```typescript
export type UPrimitive = string | number | boolean | null;
export type PropValue =
| string | number | boolean | null
| unknown[]
| UNode
| Record<string, unknown>
| ((...args: unknown[]) => unknown);
export type UniversalProps = Record<string, PropValue | undefined>;
export type UElement = {
type: string;
props: UniversalProps;
children: UNode[];
};
export type URoot = {
type: "root";
props: UniversalProps;
children: UNode[];
};
export type UNode = UPrimitive | UElement | URoot;
```
### Component types
```typescript
export type ComponentFn = (props: UniversalProps & { children?: UNode[] }) => UNode;
export type UType = string | ComponentFn;
export interface UComponent<P extends UniversalProps = UniversalProps> {
(props: P & { children?: UNode[] }): UNode;
displayName?: string;
targets?: string[];
}
```
- **`ComponentFn`** is the type of a function that accepts props (with optional children) and returns a `UNode`. This is the universal component contract.
- **`UType`** is the union of string element types and component functions — used as the first argument to `h()`.
- **`UComponent`** adds optional `displayName` (for debugging) and `targets` (for host-specific component routing). It extends `ComponentFn` with metadata.
`ComponentFn` and `UType` have no TypeBox representation. They are TypeScript-only. This is why the types are hand-written rather than derived from the schema.
## Type Guards
Three type guards narrow `UNode` at runtime:
```typescript
function isUElement(node: UNode): node is UElement
function isURoot(node: UNode): node is URoot
function isUPrimitive(node: UNode): node is UPrimitive
```
### Discriminators
| Guard | Logic | Discriminator |
|-------|-------|---------------|
| `isUElement` | `typeof === "object" && "type" in node && "props" in node && "children" in node && node.type !== "root"` | Has `type`/`props`/`children` keys AND `type` is not `"root"` |
| `isURoot` | `typeof === "object" && "type" in node && node.type === "root"` | `type === "root"` |
| `isUPrimitive` | `typeof === "string" \|\| typeof === "number" \|\| typeof === "boolean" \|\| node === null` | Not an object |
The `isUElement` guard excludes `URoot` by checking `type !== "root"`. Without this exclusion, `URoot` nodes would match `isUElement` because they have the same structural fields (`type`, `props`, `children`). The `"root"` literal type discriminates them.
## Known Gaps
### `key` field on `UElement`
`UElement` currently has no `key` field. The reconciler needs an identity mechanism to match old children to new children across re-renders. Without `key`, reconciliation is positional-only — the Nth child of the old tree maps to the Nth child of the new tree, which breaks when children are reordered, inserted, or removed.
The reconciler research (`docs/research/reconciler/00-KEY-FIELD-DESIGN.md`) proposes adding `key?: string` to `UElement` as a first-class field. `h()` would extract `key` from props and promote it to the element level, so component functions never receive it. `URoot` does not get `key` — roots are unique per `createRoot()` call and never need reconciliation identity.
**Status**: Research complete, not yet implemented.
## Constraints
- **Props are not fully serializable** — `PropValue` includes functions. Hosts that need serialization must strip function-valued props at their boundary. This is by design: event handlers and component references are first-class prop values.
- **UniversalProps is open** — `additionalProperties` allows any key. This prevents UJSX from being a prop gatekeeper and lets hosts define their own contracts without extending UJSX's schema.
- **TypeScript types are authoritative for type inference** — the TypeBox Module is for runtime validation and JSON Schema export only. Do not use `Static<typeof UJSX>` as the source of truth for TypeScript types; the hand-written types include `ComponentFn` and function-typed `PropValue` entries that the schema cannot express cleanly.
- **No `key` on `UElement`** — see Known Gaps above. Positional reconciliation only until `key` is added.
- **No `key` on `URoot`** — roots are identified by `props.id`, not a `key` field. This is by design; roots are never children of another element.
- **Type guards are mutually exclusive** — `isUElement`, `isURoot`, and `isUPrimitive` partition the `UNode` space. Every `UNode` matches exactly one guard.
## Open Questions
1. **Should `key` accept numbers?** React coerces number keys to strings. The current proposal enforces `string` only — simpler, no implicit coercion. Users can wrap in `String()`. See [00-KEY-FIELD-DESIGN.md](../../research/reconciler/00-KEY-FIELD-DESIGN.md).
2. **Should `UPrimitive` include `undefined`?** Currently `null` represents an explicitly empty value. `undefined` means "absent" and should not appear as a tree node. This is consistent with how JSX treats `undefined` children (rendered to nothing), but some hosts might benefit from an explicit "missing" sentinel. No current use case justifies this.
3. **Should `UniversalProps` constrain value types per-host?** The open schema allows any `PropValue` for any key. A host that wants stricter prop contracts (e.g., `onClick` must be a function, `className` must be a string) must validate at its own boundary. A future host-typed props system could be layered on top without changing the base schema.
## References
- Source: `src/core/schema.ts`
- Key field design: `docs/research/reconciler/00-KEY-FIELD-DESIGN.md`
- TypeBox Module as type registry: `docs/architecture/decisions/002-typebox-module-as-registry.md`
- HTML-agnostic core: `docs/architecture/decisions/001-html-agnostic-core.md`