Skip to content

Schema.bind takes the game's root, always - #221

Merged
SandroMaglione merged 3 commits into
mainfrom
core/root-mandatory
Nov 10, 2026
Merged

SandroMaglione merged 3 commits into
mainfrom
core/root-mandatory

Conversation

@SandroMaglione

Copy link
Copy Markdown
Member

Schema.bind takes the game's root, always. The unrooted overloads are gone.

Why

Without a root, a game was branded by its schema type, which is covariant. A game and a game whose schema is a superset were interchangeable: rb.tick(GA.Schedule(SystemOfA)) compiled and ran a system that spawned and queried Velocity inside a runtime with no Velocity, and queries.q.get(idOfGameA) compiled in a system of the other game. The JSDoc on bind said games "cannot be connected accidentally", which was false for that form. packages/core/README.md also showed the unrooted form while SETUP.md taught the rooted one: two ways to do one job, one of them unsafe.

API

  • Schema.bind(fragment, Root) and Schema.bind(Core, Combat, Root). The root is a const Root inferred from the last argument and checked by RootArgument<Root>. A call without one fails, and the message names the fix:
    Property '"Schema.bind takes the game's root last: const Root = Schema.defineRoot('Game'), then Schema.bind(...fragments, Root)"' is missing ...
    This works for bind(A) (a third signature that no value matches, needed because a one-argument call matches no rooted signature by arity), for bind(A, B), and for bind(A, B, A). The implementation throws the same message when types are erased.
  • Schema.Schema.Game<S, Root> no longer defaults Root to S.
  • Entity.isHandle constrains its root to RootToken, so the unrooted way of naming a root, Entity.isHandle(Game.schema), no longer compiles.
  • The bind JSDoc describes one form. The defineRoot JSDoc gives the guarantee and the rule below.
  • Type tests: new file packages/core/dtslint/Roots.tst.ts. It covers the inferred type of both forms, the missing-root message for each call shape, the snippet above with roots for each kind of value, isHandle, and same-named roots. The fix entry is in .changeset/root-mandatory.md, with diffs.

What a root guarantees, and same-named roots

Games with different roots do not mix. Schedules, systems, runtimes, queries, entity ids, handles, and conditions or machines of one game are each rejected by the other, with Type 'RootToken<"Roots/A">' is not assignable to type 'RootToken<"Roots/B">'. The snippet from the issue, with roots, fails at every line.

Same-named roots: RootToken<Name> is structural, so two defineRoot("Game") calls make the same type, whether they are in one module or in two. Games bound under them mix exactly as unrooted games did; I checked that rs2.tick(S1.Schedule(SystemOfS1)) and queries.q.get(idOfS1) compile. This cannot be closed in types with the current defineRoot(name). A call returns the same type for the same argument, and TypeScript is nominal only for declarations: a unique symbol from a const, or class private members. Closing it would mean changing how roots are declared, for example const GameKey = Symbol("Game") and then Schema.defineRoot(GameKey), which touches every root in every game. That decision is the maintainer's, so instead the defineRoot JSDoc states the rule: "Declare one root per game, under a name no other game in the program uses ... Share the one Root constant instead of declaring it again." A type test pins the behaviour: expect(Second).type.toBe<typeof First>(). A runtime check is also not possible as things stand: makeGame ignores the root at runtime, and a runtime does not know which game a schedule came from.

Conversion

  • Script (commit "Pass a root to every Schema.bind"): 282 calls in 160 files. It added 211 root declarations: one per file, or one per doc code block. Fourteen of them are a second game's own root. 65 types that spelled the schema in the root's place now say typeof Root.
    • Each root is named after the file's descriptor prefix ("Storage/Position" becomes defineRoot("Storage")), and falls back to the file name. Doc blocks use the block's prefix (Arena, Example) or Game.
    • The root is declared before the statement that first needs it. It goes above that statement's own comments (so it never steals a JSDoc) and below Twoslash directives (// ---cut---, // @errors).
    • A second game bound in the same scope (const Other = Schema.bind(...)) gets its own root, OtherRoot = defineRoot("<Prefix>/Other"). Otherwise the tests that keep two games apart would stop testing anything: about a dozen @ts-expect-errors went unused when every game in a file shared one root.
    • It skips binds under a @ts-expect-error about the root, and the comments in JSDoc. Nothing is left declaring a root it does not use: every declared root is passed to a bind.
  • By hand (commit "Name the root where the script cannot"), 21 files:
    • six JSDoc @example blocks that bind their own game, and Query.ts's two EntityRef<typeof Game.schema, typeof Game.schema, ...> examples;
    • the generator of the type bench, the consumer programs pack-check.ts writes from strings, and the merge comment in SETUP.md step 4 (now Schema.bind(Core, Combat, Root).schema, with const Root declared). The same fix went into the unreleased .changeset/name-the-schema.md, so the changelog shows code that compiles;
    • root type arguments that sit on their own line, and Entity.isHandle(Game.schema);
    • the erased-types storage test passes a root. I added a test that an erased call without one throws the fix.
    • the expected messages of StateMachine.tst.ts and Condition.tst.ts: the other game's values now fail on the root, before any schema difference;
    • type-safety.mdx expects 2345 2769 for its duplicate-name snippet. A rooted one-fragment call matches both the one- and the many-fragment signatures, so a schema error there is "No overload matches", as it already was for rooted games on main. I tried a many-fragment signature that needs three arguments, but TypeScript does not count a generic rest tuple's minimum length, and that version lost the missing-root message for bind(A, B). I reverted it.
  • Edits are limited to the lines the root needs, so the other open pull requests should merge cleanly. I did not touch pauseWhen or Game.Command.entry call sites. RenderSync.ts changes only in its example.
  • I rebased on origin/main before pushing, through Netcode: inputs stay in their epoch, hides survive loss, a restarted seat ends the match #218, RenderSync makes a node again when its renderable changes #219, and Core: pauseWhile pauses while a condition holds; entries are exact tuples and Command.entry is gone #220. Core: pauseWhile pauses while a condition holds; entries are exact tuples and Command.entry is gone #220 conflicted in CommandEntries.tst.ts only; I resolved it by taking main's file and re-running the script on it (one call). After that, a run over the whole repo finds 0 calls left.

To convert files that land on main after this branch, run the script below from anywhere: node --import tsx root-mandatory.ts <repo> [files...]. The hand edits are listed further down and only apply once.

root-mandatory.ts
/**
 * Converts unrooted `Schema.bind(...)` calls to pass a root last.
 *
 *   node --import tsx root-mandatory.ts <repo> [files...]
 *
 * With no files, converts every file under packages, integration, website/src,
 * bench, scripts, and the root README.md that calls `Schema.bind(` (CHANGELOG
 * files excluded).
 *
 * - `.ts` / `.tsx` files: every call outside comments whose last argument is
 *   not a root gets `, Root`. One `const Root = Schema.defineRoot("<Name>")` is
 *   declared before the first top-level statement that needs it, unless the
 *   module already declares a top-level `Root`. `<Name>` is the prefix most of
 *   the file's descriptor names use ("Storage/Position" -> "Storage"), else
 *   the file name.
 * - `.md` / `.mdx` files: each ```ts code block is converted on its own, with
 *   its root (named after the block's descriptor prefix, else "Game") declared right before the statement
 *   of its first unrooted bind, unless the block already declares `Root`.
 * - A call under `// @ts-expect-error <...root...>` is left as it is.
 * - A line that spells a type with the schema as the root
 *   (`EntityId<typeof Game.schema, typeof Game.schema>`) names `typeof Root`.
 * - JSDoc `@example` blocks are not touched: they are listed for a hand edit.
 * - A second game bound in the same scope (`const Other = Schema.bind(...)`)
 *   gets its own root, declared right before it (`OtherRoot`, named
 *   "<Name>/Other"), so tests that keep two games apart still do.
 * - A bind inside a scope that declares its own `Root` is listed: the added
 *   `Root` would name that one.
 */
import { execFileSync } from "node:child_process"
import { readFileSync, writeFileSync } from "node:fs"
import { createRequire } from "node:module"
import { basename, join } from "node:path"

const repo = process.argv[2]!
const require = createRequire(join(repo, "package.json"))
const ts: typeof import("typescript") = require("typescript6")

const files = process.argv.length > 3
  ? process.argv.slice(3)
  : execFileSync("git", ["grep", "-l", "Schema.bind(", "--", "packages", "integration", "website/src", "bench", "scripts", "README.md"], { cwd: repo, encoding: "utf8" })
    .split("\n").filter((file) => file !== "" && !file.endsWith("CHANGELOG.md"))

const review: Array<string> = []
let converted = 0
let declared = 0

const isBind = (node: ts.Node): node is ts.CallExpression =>
  ts.isCallExpression(node)
  && ts.isPropertyAccessExpression(node.expression)
  && ts.isIdentifier(node.expression.expression)
  && node.expression.expression.text === "Schema"
  && node.expression.name.text === "bind"

const isRootArgument = (node: ts.Expression): boolean => {
  if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "defineRoot") return true
  const text = ts.isIdentifier(node) ? node.text : ts.isPropertyAccessExpression(node) ? node.name.text : ""
  return /root/i.test(text)
}

const declaresRoot = (statements: ts.NodeArray<ts.Statement>): boolean =>
  statements.some((statement) =>
    ts.isVariableStatement(statement)
    && statement.declarationList.declarations.some((declaration) => ts.isIdentifier(declaration.name) && declaration.name.text === "Root"))

/** A function or block between the call and the module that declares its own `Root`. */
const shadowed = (node: ts.Node): boolean => {
  for (let current = node.parent; current !== undefined && !ts.isSourceFile(current); current = current.parent) {
    if (ts.isBlock(current) && declaresRoot(current.statements)) return true
    if (ts.isFunctionLike(current) && current.parameters.some((parameter) => ts.isIdentifier(parameter.name) && parameter.name.text === "Root")) return true
  }
  return false
}

const topLevel = (node: ts.Node): ts.Node => {
  let current = node
  while (!ts.isSourceFile(current.parent)) current = current.parent
  return current
}

const prefixOf = (text: string, file: string): string => {
  const counts = new Map<string, number>()
  for (const match of text.matchAll(/\)\(\s*"([A-Z][A-Za-z0-9-]*)\//g)) counts.set(match[1]!, (counts.get(match[1]!) ?? 0) + 1)
  const best = [...counts].sort((a, b) => b[1] - a[1])[0]
  if (best !== undefined) return best[0]
  const stem = basename(file).replace(/\..*$/, "")
  return stem.charAt(0).toUpperCase() + stem.slice(1)
}

/** A call under `// @ts-expect-error ... root ...` tests the missing root on purpose. */
const expectsMissingRoot = (code: string, at: number): boolean => {
  const start = lineStart(code, at)
  const previous = code.slice(lineStart(code, start - 1), start)
  return /@ts-expect-error.*\broot\b/i.test(previous)
}

/** `, Root` after the last argument, or `,\n<indent>Root` when the arguments are on their own lines. */
const rootArgument = (code: string, source: ts.SourceFile, call: ts.CallExpression, root: string): string => {
  const last = call.arguments[call.arguments.length - 1]!
  const multiline = code.slice(last.end, call.end).includes("\n")
  return multiline ? `,\n${indentAt(code, last.getStart(source))}${root}` : `, ${root}`
}

/**
 * Where a declaration goes so it does not split a statement from its own
 * comments (a JSDoc, a note): above them, but below the Twoslash directives
 * (`// ---cut---`, `// @errors: ...`) that only look like leading comments.
 */
const startWithComments = (code: string, statement: ts.Node): number => {
  const comments = ts.getLeadingCommentRanges(code, statement.getFullStart()) ?? []
  let at = statement.getStart()
  for (let index = comments.length - 1; index >= 0; index--) {
    const comment = code.slice(comments[index]!.pos, comments[index]!.end)
    if (/^\/\/\s*(---cut|@|\^\?)/.test(comment)) break
    at = comments[index]!.pos
  }
  return at
}

type Edit = { readonly at: number; readonly text: string }

const apply = (text: string, edits: ReadonlyArray<Edit>): string =>
  [...edits].sort((a, b) => b.at - a.at).reduce((current, edit) => current.slice(0, edit.at) + edit.text + current.slice(edit.at), text)

const lineStart = (text: string, at: number): number => text.lastIndexOf("\n", at - 1) + 1
const indentAt = (text: string, at: number): string => /^[ \t]*/.exec(text.slice(lineStart(text, at)))![0]

/**
 * Converts one program. `rootName` is the name of the root to declare;
 * `declareAt` says where: before the top-level statement ("module") or before
 * the statement holding the first bind ("statement").
 */
const convert = (code: string, label: string, rootName: string, declareAt: "module" | "statement", offsetLine: number): string => {
  const source = ts.createSourceFile(label, code, ts.ScriptTarget.Latest, true, /\.tsx$/.test(label) ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
  const edits: Array<Edit> = []
  const needRoot: Array<ts.CallExpression> = []
  /** The games bound so far in each scope: the first is the scope's game, any other is another game. */
  const boundIn = new Map<ts.Node, number>()
  const visit = (node: ts.Node): void => {
    if (isBind(node)) {
      const last = node.arguments[node.arguments.length - 1]
      let scope: ts.Node = node.parent
      while (!ts.isSourceFile(scope) && !ts.isBlock(scope)) scope = scope.parent
      const earlier = boundIn.get(scope) ?? 0
      boundIn.set(scope, earlier + 1)
      const variable = ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name) ? node.parent.name.text : undefined
      if (last === undefined) review.push(`${label}: Schema.bind() with no argument`)
      else if (node.arguments.some((argument) => ts.isSpreadElement(argument))) review.push(`${label}:${offsetLine + source.getLineAndCharacterOfPosition(node.getStart()).line + 1}: spread arguments`)
      else if (expectsMissingRoot(code, node.getStart(source))) {}
      else if (!isRootArgument(last) && earlier > 0 && variable !== undefined) {
        // Another game in the same scope: its own root, so the two do not mix.
        const base = variable.replace(/Game/, "")
        const own = variable.startsWith("Game") ? `Root${base}` : `${base}Root`
        const statement = node.parent.parent.parent
        const at = lineStart(code, startWithComments(code, statement))
        edits.push({ at: last.end, text: rootArgument(code, source, node, own) })
        edits.push({ at, text: `${indentAt(code, at)}const ${own} = Schema.defineRoot("${declareAt === "statement" ? base : `${rootName}/${base}`}")\n` })
        converted++
        declared++
      }
      else if (!isRootArgument(last)) {
        edits.push({ at: last.end, text: rootArgument(code, source, node, "Root") })
        needRoot.push(node)
        if (shadowed(node)) review.push(`${label}:${offsetLine + source.getLineAndCharacterOfPosition(node.getStart()).line + 1}: a nested Root is in scope`)
      }
    }
    ts.forEachChild(node, visit)
  }
  visit(source)
  if (needRoot.length === 0) return code
  converted += needRoot.length
  if (!declaresRoot(source.statements)) {
    declared++
    const first = needRoot[0]!
    let anchor: ts.Node = topLevel(first)
    if (declareAt === "statement") {
      anchor = first
      while (!ts.isSourceFile(anchor.parent) && !ts.isBlock(anchor.parent)) anchor = anchor.parent
    }
    const at = lineStart(code, startWithComments(code, anchor))
    // Next to a statement that binds a game; a blank line before anything else (a `describe(...)`).
    const bindsHere = ts.isVariableStatement(anchor) && anchor.declarationList.declarations.some((declaration) => declaration.initializer !== undefined && isBind(declaration.initializer))
    edits.push({ at, text: `${indentAt(code, at)}const Root = Schema.defineRoot("${rootName}")\n${bindsHere || declareAt === "statement" ? "" : "\n"}` })
  }
  return apply(code, edits)
}

/**
 * Types written by hand with the schema as the root
 * (`Entity.EntityId<typeof Game.schema, typeof Game.schema>`): every
 * `typeof <x>schema` after the first on such a line names the root, and
 * becomes `typeof Root`, like the root argument of `BoundStateMachine<Root, ...>`
 * and `EntityScope<Name, Root>`.
 */
const schemaAsRoot = (text: string): string =>
  text
    // Types whose root is a fixed type argument: BoundStateMachine<Root, ...>, EntityScope<Name, Root>.
    .replace(/(BoundStateMachine<\s*)typeof [A-Za-z.]*schema(?=\s*[,>])/g, "$1typeof Root")
    .replace(/(EntityScope<"[^"]*", )typeof [A-Za-z.]*schema(?=\s*>)/g, "$1typeof Root")
    .split("\n").map((line) => {
    // A whole type argument: followed by `,` or `>`, not `.resources`.
    const argument = /typeof [A-Za-z.]*schema(?=\s*[,>])/g
    if ((line.match(argument) ?? []).length < 2) return line
    let seen = 0
    return line.replace(argument, (match) => seen++ === 0 ? match : "typeof Root")
  }).join("\n")

const inComment = (text: string, at: number): boolean => {
  const line = text.slice(lineStart(text, at), at)
  return /^\s*(\*|\/\/)/.test(line)
}

for (const file of files) {
  const path = join(repo, file)
  const text = readFileSync(path, "utf8")
  let next = text
  if (/\.(md|mdx)$/.test(file)) {
    next = text.replace(/(```(?:ts|tsx|typescript)[^\n]*\n)([\s\S]*?)(\n[ \t]*```)/g, (_, open: string, body: string, close: string, offset: number) =>
      open + convert(body, file, prefixOf(body, "Game"), "statement", text.slice(0, offset).split("\n").length) + close)
  } else {
    for (const match of text.matchAll(/Schema\.bind\(/g)) {
      if (inComment(text, match.index)) review.push(`${file}:${text.slice(0, match.index).split("\n").length}: in a comment (JSDoc example?)`)
    }
    next = convert(text, file, prefixOf(text, file), "module", 0)
  }
  next = schemaAsRoot(next)
  if (next !== text) writeFileSync(path, next)
}

console.log(`converted ${converted} calls, declared ${declared} roots, in ${files.length} files`)
if (review.length > 0) console.log(["review by hand:", ...review.map((line) => `  ${line}`)].join("\n"))
by-hand.py (the edits the script leaves, as applied here)
# The edits root-mandatory.ts leaves to a hand: JSDoc examples, and three
# places that named the schema as a root in a way the script does not see.
#   python3 by-hand.py   (from the repo root, after root-mandatory.ts)
import re

def edit(path, old, new, count=1):
    text = open(path).read()
    assert text.count(old) == count, (path, old, text.count(old))
    open(path, "w").write(text.replace(old, new))

# JSDoc examples that bind their own game: declare its root, pass it last.
for path in ["packages/browser/src/Controls.ts", "packages/browser/src/InputCapture.ts", "packages/core/src/Clock.ts",
             "packages/core/src/Descriptor.ts", "packages/core/src/Random.ts", "packages/render/src/RenderSync.ts"]:
    text = open(path).read()
    text, n = re.subn(r'( \* )const Game = Schema\.bind\((Schema\.fragment\(\{[^\n]*\})\)\)\n',
                      lambda m: f'{m.group(1)}const Root = Schema.defineRoot("Game")\n{m.group(1)}const Game = Schema.bind({m.group(2)}), Root)\n', text)
    assert n == 1, path
    open(path, "w").write(text)

# JSDoc examples on the prelude's game, which is bound under the prelude's Root.
edit("packages/core/src/Query.ts", "Entity.EntityRef<typeof Game.schema, typeof Game.schema, ", "Entity.EntityRef<typeof Game.schema, typeof Root, ", 2)
# A query type whose root is the last of many type arguments, on its own line.
edit("packages/core/dtslint/Schema.tst.ts", "readonly [], readonly [], readonly [], typeof Game.schema>>()", "readonly [], readonly [], readonly [], typeof Root>>()")
# Entity.isHandle takes the root.
edit("packages/core/test/Runtime.host.test.ts", "Entity.isHandle(Game.schema)(raw)", "Entity.isHandle(Root)(raw)")
# The merge for naming the schema is a code comment: it takes the root too.
edit("packages/core/SETUP.md",
     "   ```ts\n   const schema = Schema.fragment({ components: { Position, Velocity } }) // or Schema.bind(Core, Combat).schema\n",
     "   ```ts\n   const Root = Schema.defineRoot(\"Game\")\n   const schema = Schema.fragment({ components: { Position, Velocity } }) // or Schema.bind(Core, Combat, Root).schema\n")
# The unreleased changeset that teaches it, so the changelog shows code that compiles.
edit(".changeset/name-the-schema.md", "// or Schema.bind(Core, Combat).schema", "// or Schema.bind(Core, Combat, Root).schema")
# Root arguments on their own line, after a multi-line type argument.
edit("packages/core/dtslint/Command.tst.ts", "    }, typeof schema>>()", "    }, typeof Root>>()")
edit("packages/core/dtslint/Command.tst.ts", "    }, typeof schema>, readonly [", "    }, typeof Root>, readonly [", 2)
edit("packages/core/dtslint/Schema.tst.ts", "            typeof schema,\n            typeof Game.schema\n          >>>()", "            typeof schema,\n            typeof Root\n          >>>()", 2)
edit("packages/core/dtslint/DroppedRelations.tst.ts", "Relation.Relation.Dropped<typeof Targeting, S, S>", "Relation.Relation.Dropped<typeof Targeting, S, typeof Root>")
# Values of another game now fail on its root, before any schema difference.
edit("packages/core/dtslint/Condition.tst.ts",
     "// @ts-expect-error Type 'BoundStateMachine<SchemaDefinition<{}, { readonly OtherFreeze: Descriptor<\"resource\", \"CheckTypes/OtherFreeze\"",
     "// @ts-expect-error Type 'RootToken<\"CheckTypes/Other\">' is not assignable to type 'RootToken<\"CheckTypes\">'")
edit("packages/core/dtslint/StateMachine.tst.ts",
     "@ts-expect-error Property 'Position' is missing in type '{}' but required in type '{ readonly Position",
     "@ts-expect-error Type 'RootToken<\"StateMachine/Other\">' is not assignable to type 'RootToken<\"StateMachine\">'", 5)
edit("packages/core/dtslint/StateMachine.tst.ts",
     "@ts-expect-error Type 'ApplyStateTransitionsStep<undefined, SchemaDefinition<{ readonly Position: Descriptor<\"component\"",
     "@ts-expect-error Type 'ApplyStateTransitionsStep<undefined, RootToken<\"StateMachine\">>' is not assignable to type 'BoundTransitionEntry<")
edit("packages/core/dtslint/StateMachine.tst.ts",
     "@ts-expect-error Argument of type '[]' is not assignable to parameter of type 'readonly [Condition<SchemaDefinition<{ readonly",
     "@ts-expect-error Argument of type '[]' is not assignable to parameter of type 'readonly [Condition<RootToken<\"StateMachine\">>, ...Condition<RootToken<\"StateMachine\">>[]]'")
# The erased bind takes the root too; without one, it throws the fix.
edit("packages/core/test/Runtime.storage.test.ts",
     '''    const bindErased = Schema.bind as (...fragments: ReadonlyArray<Schema.Schema.Any>) => unknown
    expect(() => bindErased(
      Schema.fragment({ components: { Position } }),
      Schema.fragment({ components: { Other } })
    )).toThrow("Duplicate descriptor name: Storage/Position")
  })
''', '''    const bindErased = Schema.bind as (...args: ReadonlyArray<Schema.Schema.Any | Schema.RootToken>) => unknown
    expect(() => bindErased(
      Schema.fragment({ components: { Position } }),
      Schema.fragment({ components: { Other } }),
      Root
    )).toThrow("Duplicate descriptor name: Storage/Position")
  })

  it("needs a root when types are erased", () => {
    const bindErased = Schema.bind as (...args: ReadonlyArray<Schema.Schema.Any | Schema.RootToken>) => unknown
    expect(() => bindErased(Schema.fragment({ components: { Position } })))
      .toThrow("Schema.bind takes the game's root last: const Root = Schema.defineRoot('Game'), then Schema.bind(...fragments, Root)")
  })
''')
# The type bench's generator emits the rooted bind the conversion wrote into stress.ts.
edit("packages/core/bench/types/generate.ts", 'emit("const Game = Schema.bind(")', 'emit("const Root = Schema.defineRoot(\\"Stress\\")")\nemit("const Game = Schema.bind(")')
edit("packages/core/bench/types/generate.ts",
     'emit("  Schema.fragment({ requires: [Schema.requires(C28, C29, C30), Schema.requires(C29, C31), Schema.requires(C30, C31)] })")',
     'emit("  Schema.fragment({ requires: [Schema.requires(C28, C29, C30), Schema.requires(C29, C31), Schema.requires(C30, C31)] }),")\nemit("  Root")')
# With a root, a one-fragment bind matches the one- and the many-fragment
# signatures, so a schema error there is "No overload matches" (2769), as it
# already was for rooted games.
edit("website/src/pages/docs/type-safety.mdx",
     "// @errors: 2345\nconst Root = Schema.defineRoot(\"Arena\")\nconst Game = Schema.bind(Schema.fragment({ components: { Position, Speed } }), Root)",
     "// @errors: 2345 2769\nconst Root = Schema.defineRoot(\"Arena\")\nconst Game = Schema.bind(Schema.fragment({ components: { Position, Speed } }), Root)")
# pack-check writes consumer programs from strings: each game gets its root.
edit("scripts/pack-check.ts",
     "const Sim = Schema.bind(Schema.fragment({ resources: { Time } }))\n",
     "const SimRoot = Schema.defineRoot(\"Consumer/Sim\")\nconst Sim = Schema.bind(Schema.fragment({ resources: { Time } }), SimRoot)\n")
edit("scripts/pack-check.ts",
     "const Game = Schema.bind(Schema.fragment({ components: { Seed } }))\n",
     "const Root = Schema.defineRoot(\"Consumer\")\nconst Game = Schema.bind(Schema.fragment({ components: { Seed } }), Root)\n")
edit("scripts/pack-check.ts",
     "const Game = Schema.bind(Schema.fragment({})); export const lintTarget",
     "const Game = Schema.bind(Schema.fragment({}), Schema.defineRoot(\"Pack\")); export const lintTarget")
edit("scripts/pack-check.ts",
     "  `const Game = Schema.bind(Schema.fragment({ resources: { Time } }))`,\n",
     "  `const Root = Schema.defineRoot(\"Pack\")`,\n  `const Game = Schema.bind(Schema.fragment({ resources: { Time } }), Root)`,\n")

Verification (local, on the rebased branch)

  • pnpm run check: passes. That is typecheck, lint, type tests on TS 5.9.3 and 6.0.3 (1160 passed), 149 examples, and 1929 vitest tests in 168 files. In an earlier run under load (load average ~23), packages/tools/test/music.test.ts "plays every instrument in tune" timed out at 5 s. This PR does not touch that package, and the test passes alone (46/46).

  • pnpm --dir website build: passes.

  • pnpm pack:check: passes.

  • pnpm bench:check: "No blocking performance regressions". Type metrics went down:

    origin/main at Core: pauseWhile pauses while a condition holds; entries are exact tuples and Command.entry is gone #220 (measured here) this branch
    Types 139099 138656 (−0.3%)
    Instantiations 1543814 1516132 (−1.8%)

    Against the recorded baseline, the branch is +2.9% types and +1.6% instantiations: main itself moved there with Core: pauseWhile pauses while a condition holds; entries are exact tuples and Command.entry is gone #220, and both are within the tolerance.

    Part of the drop comes from the stress program itself: its root is now a small RootToken<"Stress"> instead of the whole schema type. The first bench:check run, at load average ~23, flagged five runtime cases: snapshot json-roundtrip, checkpoint take-tick, and three render/sync cases. No runtime code changed, so I compared those cases against origin/main on the same machine: -2.1%, +0.2%, -4.1%, -2.6%, and +2.4%, with identical allocations. The re-run alone passed. I did not run pnpm bench:update.

Left out, and why

  • The Root = unknown defaults on about 40 types (Query.Any<Root = unknown>, Entity.EntityId<S, Root = unknown>, Machine.Condition<Root = unknown>, ...). The unrooted form did not use them: it set the root to S. They mean "any root" in generic positions, and removing them is a wide refactor outside this change.
  • Schema.Game's Root is not constrained to RootToken. The plugins InputCapture.system, Clock.system, and RenderSync.* take a Schema.Game<S, Root> with an unconstrained Root, and RenderSync is being changed in another PR.
  • Same-named roots: see above. The rule is documented; closing it in types is a separate decision.
  • Test files share root names where they share a descriptor prefix: for example "Runtime" in five runtime test files, or "Data" in a type test and a runtime test. They never meet, so this is harmless, but strictly it goes against the rule now in the JSDoc.

Bug found, not fixed

  • packages/core/bench/types/generate.ts is out of sync with the committed stress.ts on main. pnpm bench:types:generate writes the older API: tuple states ["Menu", ...], nextMachines.flow.set("Paused"), and Game.Runtime.make(...) without .value. I updated the generator's bind lines to match the converted stress.ts, and did not regenerate.

🤖 Generated with Claude Code

SandroMaglione and others added 3 commits November 10, 2026 15:16
A game bound without a root was branded by its schema type, which is
covariant: a game and one whose schema is a superset were interchangeable,
so a system, schedule, query, or entity id of one compiled against the
other. The unrooted overloads are gone; a call without a root fails with a
message that names the fix. Entity.isHandle takes a root, not a schema.
defineRoot says why a root's name must be unique in the program: two roots
of one name are one type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mechanical part, by a script kept out of the repo (it is in the pull
request): each unrooted call gets the file's root, declared once before
the statement that first needs it and named after the file's descriptor
prefix. A second game bound in the same scope gets its own root, so tests
that keep two games apart still do. Types spelled with the schema as the
root name typeof Root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JSDoc examples that bind their own game, the type bench's generator, the
consumer programs pack-check writes, SETUP's merge for naming the schema,
types whose root argument sits on its own line, Entity.isHandle(Game.schema),
and the expected messages of tests whose other game now fails on its root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@SandroMaglione
SandroMaglione merged commit 5dad440 into main Nov 10, 2026
5 checks passed
@SandroMaglione
SandroMaglione deleted the core/root-mandatory branch November 10, 2026 06:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant