diff --git a/packages/data/package.json b/packages/data/package.json index f42375f..45d2c20 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -38,6 +38,7 @@ "prepublishOnly": "node scripts/copy-references.js", "publish-public": "pnpm build && pnpm publish --no-git-checks --access public", "perftest": "node dist/perftest/index.js", + "check:emit": "node scripts/emit-stripinternal/check.mjs", "test": "vitest --run", "test:ci": "SKIP_PERF=1 vitest --run", "asbuild:debug": "asc assembly/index.ts -o dist/assembly/index.wasm --target debug --enable simd && echo built dist/assembly/index.wasm", diff --git a/packages/data/scripts/emit-stripinternal/.gitignore b/packages/data/scripts/emit-stripinternal/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/packages/data/scripts/emit-stripinternal/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/data/scripts/emit-stripinternal/README.md b/packages/data/scripts/emit-stripinternal/README.md new file mode 100644 index 0000000..f433159 --- /dev/null +++ b/packages/data/scripts/emit-stripinternal/README.md @@ -0,0 +1,57 @@ +# Archetype rows under `stripInternal` — emit acceptance gate + +An end-to-end check that a consumer which keeps its plugin **database type +`@internal`** can still expose archetype handles on a **hand-written public +service interface**, and that the emitted `.d.ts` is *self-contained*. + +## Run + +```sh +pnpm build # or: tsc -b (the fixture resolves @adobe/data/ecs to dist) +node scripts/emit-stripinternal/check.mjs +``` + +## Why this exists + +When a consumer derives archetype types from the plugin **database** type +(`Database.Plugin.ToDatabase` / `Database.Archetype.RowOf`), +declaration emit breaks in one of two ways: + +1. **Plugin db type referenced publicly → TS7056.** The emitter must serialize + `typeof plugin` into the consumer's `.d.ts`, which overflows. +2. **Plugin db type `@internal` → dropped/dangling.** TypeScript never resolves + a row *through* a stripped symbol; it preserves the reference, leaving a + dangling `import type { … }` → downstream `TS2305`. + +The fix is to resolve archetype rows from a small **public schema** +(`{ components, archetypes }`) instead of the plugin database type, via +[`ArchetypeRowOf` / `ArchetypeHandleOf`](../../src/ecs/store/archetype-row.ts). +The emitted type then references only public symbols. + +## What the gate asserts + +- The package `dist` exports `ArchetypeSchema` / `ArchetypeRowOf` / `ArchetypeHandleOf`. +- `fixture/` emits under `stripInternal: true` with **no TS7056**. +- `plugin.d.ts` strips the `@internal` plugin const and `SquirrelDatabase` type. +- `service.d.ts` declares `TrackService`, references **no** `@internal`/plugin + symbol, and is small (no serialized plugin type). +- `consumer/` type-checks: `FromArchetype` + resolves to the **exact concrete columns** (mutual-assignability gate), with + no dangling import. + +## Authoring rules the fixture encodes (declaration-emit footguns) + +These are TypeScript declaration-emit quirks observed while building this gate; +the fixture is shaped to avoid them, and `src/ecs/README.md` documents them for +consumers: + +- **Reference the handle inline** in the interface (`ArchetypeHandleOf`), or import it with `import { type ArchetypeHandleOf }`. A pure + `import type` alias reached through the package barrel and used only as a + nested type argument can be elided. +- **Don't put fenced ` ```ts ` code blocks containing `import`/`export`/`interface` + in JSDoc** immediately above an exported type — it can silently drop the + following declaration. +- **Don't let the schema be the lone export** of a `stripInternal`-emitted + module; pair it with another export (e.g. the `components` object) so the + declaration survives emit. diff --git a/packages/data/scripts/emit-stripinternal/check.mjs b/packages/data/scripts/emit-stripinternal/check.mjs new file mode 100644 index 0000000..ae80527 --- /dev/null +++ b/packages/data/scripts/emit-stripinternal/check.mjs @@ -0,0 +1,80 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// Acceptance gate for archetype-row types under `stripInternal: true` + +// hand-written public service interfaces. See ./README.md. +// +// Proves that a consumer which keeps its plugin database type `@internal` can +// still expose archetype handles on a hand-written interface — and that the +// emitted .d.ts is self-contained: no TS7056, no serialized plugin type, no +// reference to the stripped @internal symbol, and downstream the rows resolve +// to concrete columns (no dangling import). +// +// Usage: node scripts/emit-stripinternal/check.mjs +// Exits non-zero on any failed assertion. + +import { execFileSync } from "node:child_process"; +import { readFileSync, rmSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkg = join(here, "..", ".."); +const fixtureDist = join(here, "fixture", "dist"); + +const fail = []; +const pass = []; +const assert = (cond, msg) => (cond ? pass : fail).push(msg); +const tsc = (args, cwd) => + execFileSync("npx", ["tsc", ...args], { cwd, encoding: "utf8", stdio: "pipe" }); + +// 0. The package dist must be built and export the extractor types (the +// real deliverable). The fixture resolves @adobe/data/ecs to this dist. +const ecsStoreDts = join(pkg, "dist", "ecs", "store", "archetype-row.d.ts"); +if (!existsSync(ecsStoreDts)) { + console.error("dist not built — run `pnpm build` (or `tsc -b`) first."); + process.exit(2); +} +const storeDts = readFileSync(ecsStoreDts, "utf8"); +for (const t of ["ArchetypeSchema", "ArchetypeRowOf", "ArchetypeHandleOf"]) { + assert(new RegExp(`export type ${t}\\b`).test(storeDts), `dist exports ${t}`); +} + +// 1. Emit the consumer fixture (stripInternal). Must not error / TS7056. +rmSync(fixtureDist, { recursive: true, force: true }); +let emitOk = true; +try { + tsc(["-p", "tsconfig.json"], join(here, "fixture")); +} catch (e) { + emitOk = false; + const out = `${e.stdout ?? ""}${e.stderr ?? ""}`; + assert(!/TS7056/.test(out), "no TS7056 on emit"); + console.error(out); +} +assert(emitOk, "fixture emits without error"); + +// 2. The @internal plugin + database type are stripped entirely. +const pluginDts = readFileSync(join(fixtureDist, "plugin.d.ts"), "utf8"); +assert(!/SquirrelDatabase|\bplugin\b/.test(pluginDts), "plugin.d.ts strips the @internal plugin + db type"); + +// 3. The public service .d.ts is self-contained. +const serviceDts = readFileSync(join(fixtureDist, "service.d.ts"), "utf8"); +assert(/TrackService/.test(serviceDts), "service.d.ts declares TrackService"); +assert(!/SquirrelDatabase|ToDatabase|FromPlugin/.test(serviceDts), "service.d.ts has no @internal/plugin reference"); +assert(serviceDts.length < 2000, "service.d.ts is small (no serialized plugin type)"); + +// 4. Downstream consumer type-checks: rows resolve to concrete columns, no +// dangling import. (The fixture's mutual-assignability gate is the check.) +let consumerOk = true; +try { + tsc(["-p", "tsconfig.json"], join(here, "consumer")); +} catch (e) { + consumerOk = false; + console.error(`${e.stdout ?? ""}${e.stderr ?? ""}`); +} +assert(consumerOk, "downstream consumer type-checks (concrete rows, no dangling import)"); + +// Report +for (const m of pass) console.log(` ✓ ${m}`); +for (const m of fail) console.log(` ✗ ${m}`); +console.log(fail.length === 0 ? "\nemit-stripinternal: PASS" : `\nemit-stripinternal: FAIL (${fail.length})`); +process.exit(fail.length === 0 ? 0 : 1); diff --git a/packages/data/scripts/emit-stripinternal/consumer/tsconfig.json b/packages/data/scripts/emit-stripinternal/consumer/tsconfig.json new file mode 100644 index 0000000..b13ee70 --- /dev/null +++ b/packages/data/scripts/emit-stripinternal/consumer/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, "skipLibCheck": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": ["@webgpu/types"], + "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ESNext", + "paths": { "@adobe/data/ecs": ["../../../dist/ecs/index.d.ts"] } + }, + "include": ["use.ts"] +} diff --git a/packages/data/scripts/emit-stripinternal/consumer/use.ts b/packages/data/scripts/emit-stripinternal/consumer/use.ts new file mode 100644 index 0000000..ce24b54 --- /dev/null +++ b/packages/data/scripts/emit-stripinternal/consumer/use.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { FromArchetype, Entity } from "@adobe/data/ecs"; +import type { TrackService } from "../fixture/dist/service.js"; +declare const svc: TrackService; +const has: boolean = svc.archetypes.Track.components.has("trackKind"); +type Row = FromArchetype; +type Expected = { readonly id: Entity; readonly trackKind: string; readonly editingMode: string; readonly muted: boolean }; +declare const row: Row; declare const expected: Expected; +const a: Expected = row; const b: Row = expected; +void [has, a, b]; diff --git a/packages/data/scripts/emit-stripinternal/fixture/plugin.ts b/packages/data/scripts/emit-stripinternal/fixture/plugin.ts new file mode 100644 index 0000000..98504dc --- /dev/null +++ b/packages/data/scripts/emit-stripinternal/fixture/plugin.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "@adobe/data/ecs"; +import { trackSchema } from "./schema.js"; + +// The plugin is built from the public schema but carries internal-only +// transactions; its type is large and must NOT leak into public emit. +/** @internal */ +export const plugin = Database.Plugin.create({ + ...trackSchema, + transactions: { + addTrack: (t, kind: string) => + t.archetypes.Track.insert({ trackKind: kind, editingMode: "off", muted: false }), + }, +}); + +/** @internal */ +export type SquirrelDatabase = Database.Plugin.ToDatabase; diff --git a/packages/data/scripts/emit-stripinternal/fixture/schema.ts b/packages/data/scripts/emit-stripinternal/fixture/schema.ts new file mode 100644 index 0000000..67e7b1c --- /dev/null +++ b/packages/data/scripts/emit-stripinternal/fixture/schema.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export const trackComponents = { + trackKind: { type: "string" }, + editingMode: { type: "string" }, + muted: { type: "boolean" }, +} as const; + +export const trackSchema = { + components: trackComponents, + archetypes: { + Track: ["trackKind", "editingMode", "muted"], + }, +} as const; diff --git a/packages/data/scripts/emit-stripinternal/fixture/service.ts b/packages/data/scripts/emit-stripinternal/fixture/service.ts new file mode 100644 index 0000000..35d3b3a --- /dev/null +++ b/packages/data/scripts/emit-stripinternal/fixture/service.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ArchetypeHandleOf } from "@adobe/data/ecs"; +import { trackSchema } from "./schema.js"; + +// Hand-written public interface (NOT Database.Plugin.ToDatabase), +// archetype handle derived inline from the PUBLIC schema. +export interface TrackService { + readonly archetypes: { + readonly Track: ArchetypeHandleOf; + }; +} diff --git a/packages/data/scripts/emit-stripinternal/fixture/tsconfig.json b/packages/data/scripts/emit-stripinternal/fixture/tsconfig.json new file mode 100644 index 0000000..db98121 --- /dev/null +++ b/packages/data/scripts/emit-stripinternal/fixture/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "declaration": true, "emitDeclarationOnly": true, "stripInternal": true, "outDir": "dist", + "strict": true, "skipLibCheck": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": ["@webgpu/types"], + "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ESNext", + "paths": { "@adobe/data/ecs": ["../../../dist/ecs/index.d.ts"] } + }, + "include": ["schema.ts", "plugin.ts", "service.ts"] +} diff --git a/packages/data/src/ecs/README.md b/packages/data/src/ecs/README.md index 91e284b..914a472 100644 --- a/packages/data/src/ecs/README.md +++ b/packages/data/src/ecs/README.md @@ -156,6 +156,61 @@ narrower archetype row and `as`-cast `db.archetypes` to fit it — a hand-writte type drifts from the real columns, and the cast hides that drift. Let the row follow from the declaration. +### Naming archetype rows under `stripInternal` (hand-written service interfaces) + +`Database.Archetype.RowOf` derives from the plugin **database** type. That is +the right tool when your public service type *is* +`Database.Plugin.ToDatabase`. It does **not** work when you keep +a hand-written public service interface and mark the plugin database type +`@internal` (common to keep `.d.ts` emit small and to hide internals): + +- Referencing the plugin database type from a public type forces the emitter to + serialize `typeof plugin` into your `.d.ts` → **TS7056**. +- Marking it `@internal` and deriving from it leaves a **dangling reference** to + the stripped symbol in your emitted `.d.ts` → downstream **TS2305**. + TypeScript never resolves an archetype row *through* an `@internal` symbol; it + preserves the reference. + +Instead, expose a small **public schema** (just `components` + `archetypes`) and +derive rows from it with `ArchetypeRowOf` / `ArchetypeHandleOf`. The emitted +type then references only public symbols — no plugin type, no `@internal` +symbol: + +```ts +import type { ArchetypeHandleOf } from "@adobe/data/ecs"; + +// public — small, emits cleanly +export const trackComponents = { trackKind: { type: "string" }, muted: { type: "boolean" } } as const; +export const trackSchema = { + components: trackComponents, + archetypes: { Track: ["trackKind", "muted"] }, +} as const; + +// the plugin built from `trackSchema` may stay @internal +export interface TrackService { + readonly archetypes: { + // reference the handle INLINE (see emit notes below) + readonly Track: ArchetypeHandleOf; + }; +} +``` + +`db.archetypes.Track` (from the `@internal` db) is assignable to +`TrackService["archetypes"]["Track"]` with no cast, and downstream consumers +resolve `Track` to its concrete columns. + +**Declaration-emit footguns** (TypeScript quirks; the +`scripts/emit-stripinternal` gate guards them): + +- Reference `ArchetypeHandleOf<…>` **inline** in the interface, or import it as + `import { type ArchetypeHandleOf }`. A pure `import type` alias reached + through the package barrel and used *only* as a nested type argument can be + silently elided from emit. +- Don't put fenced ` ```ts ` code blocks containing `import`/`export`/`interface` + in JSDoc directly above an exported type. +- Don't let the schema be the **lone** export of a `stripInternal`-emitted + module — pair it with another export (e.g. the `components` object above). + ## Indexes Indexes give O(1) lookup by some derived or column-valued key. Declare them on the plugin alongside components and archetypes; the runtime maintains them automatically on every insert/update/delete and exposes typed lookup handles at `db.indexes.` and `t.indexes.` (inside transactions). diff --git a/packages/data/src/ecs/store/archetype-row.ts b/packages/data/src/ecs/store/archetype-row.ts new file mode 100644 index 0000000..dfb0c24 --- /dev/null +++ b/packages/data/src/ecs/store/archetype-row.ts @@ -0,0 +1,27 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { StringKeyof } from "../../types/types.js"; +import type { FromSchemas } from "../../schema/from-schemas.js"; +import type { ComponentSchemas } from "../component-schemas.js"; +import type { ArchetypeComponents } from "./archetype-components.js"; +import type { RequiredComponents } from "../required-components.js"; +import type { OptionalComponents } from "../optional-components.js"; +import type { ReadonlyArchetype } from "../archetype/archetype.js"; + +/** Schema shape consumed by the archetype-row extractors: component schemas + archetype name-lists. */ +export type ArchetypeSchema = { + readonly components: ComponentSchemas; + readonly archetypes: ArchetypeComponents; +}; + +export type ArchetypeRowOf< + S extends ArchetypeSchema, + K extends StringKeyof, +> = RequiredComponents & { + readonly [Col in S["archetypes"][K][number]]: + (FromSchemas & RequiredComponents & OptionalComponents)[Col] +}; + +export type ArchetypeHandleOf< + S extends ArchetypeSchema, + K extends StringKeyof, +> = ReadonlyArchetype>; diff --git a/packages/data/src/ecs/store/index.ts b/packages/data/src/ecs/store/index.ts index 1313c1f..df807cc 100644 --- a/packages/data/src/ecs/store/index.ts +++ b/packages/data/src/ecs/store/index.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export * from "./store.js"; export * from "./archetype-components.js"; +export type { ArchetypeSchema, ArchetypeRowOf, ArchetypeHandleOf } from "./archetype-row.js"; export type { EntityReadValues, EntityUpdateValues, ArchetypeQueryOptions } from "./core/core.js"; export type { EntitySelectOptions } from "./entity-select-options.js"; export * from "./action-functions.js"; diff --git a/packages/data/src/ecs/store/store.ts b/packages/data/src/ecs/store/store.ts index 13f1254..39d1483 100644 --- a/packages/data/src/ecs/store/store.ts +++ b/packages/data/src/ecs/store/store.ts @@ -17,6 +17,7 @@ import { ResourceSchemas } from "../resource-schemas.js"; import { createStore } from "./public/create-store.js"; import { OptionalComponents } from "../optional-components.js"; import { Index, IndexDeclarations } from "./index-types.js"; +import type { ArchetypeRowOf } from "./archetype-row.js"; interface BaseStore { select< @@ -228,3 +229,17 @@ type CheckDynamicParticle = Assert>>; + +// ArchetypeRowOf resolves an archetype's row from a plain public schema +// (components + archetype name-lists), matching the store's archetype row. +const checkRowSchema = { + components: { particle: { type: "boolean" }, velocity: { type: "number" } }, + archetypes: { Particle: ["particle"], DynamicParticle: ["particle", "velocity"] }, +} as const; +type CheckSchemaRowDynamic = Assert, RequiredComponents & { + readonly particle: boolean; + readonly velocity: number; +}>>; +type CheckSchemaRowParticle = Assert, RequiredComponents & { + readonly particle: boolean; +}>>;