Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/data/scripts/emit-stripinternal/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
57 changes: 57 additions & 0 deletions packages/data/scripts/emit-stripinternal/README.md
Original file line number Diff line number Diff line change
@@ -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<typeof plugin>` / `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<service["archetypes"]["Track"]>`
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<typeof
schema, "X">`), 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.
80 changes: 80 additions & 0 deletions packages/data/scripts/emit-stripinternal/check.mjs
Original file line number Diff line number Diff line change
@@ -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);
10 changes: 10 additions & 0 deletions packages/data/scripts/emit-stripinternal/consumer/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
10 changes: 10 additions & 0 deletions packages/data/scripts/emit-stripinternal/consumer/use.ts
Original file line number Diff line number Diff line change
@@ -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<TrackService["archetypes"]["Track"]>;
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];
17 changes: 17 additions & 0 deletions packages/data/scripts/emit-stripinternal/fixture/plugin.ts
Original file line number Diff line number Diff line change
@@ -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<typeof plugin>;
13 changes: 13 additions & 0 deletions packages/data/scripts/emit-stripinternal/fixture/schema.ts
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions packages/data/scripts/emit-stripinternal/fixture/service.ts
Original file line number Diff line number Diff line change
@@ -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<typeof plugin>),
// archetype handle derived inline from the PUBLIC schema.
export interface TrackService {
readonly archetypes: {
readonly Track: ArchetypeHandleOf<typeof trackSchema, "Track">;
};
}
10 changes: 10 additions & 0 deletions packages/data/scripts/emit-stripinternal/fixture/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
55 changes: 55 additions & 0 deletions packages/data/src/ecs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof plugin>`. 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<typeof trackSchema, "Track">;
};
}
```

`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.<name>` and `t.indexes.<name>` (inside transactions).
Expand Down
27 changes: 27 additions & 0 deletions packages/data/src/ecs/store/archetype-row.ts
Original file line number Diff line number Diff line change
@@ -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<any>;
};

export type ArchetypeRowOf<
S extends ArchetypeSchema,
K extends StringKeyof<S["archetypes"]>,
> = RequiredComponents & {
readonly [Col in S["archetypes"][K][number]]:
(FromSchemas<S["components"]> & RequiredComponents & OptionalComponents)[Col]
};

export type ArchetypeHandleOf<
S extends ArchetypeSchema,
K extends StringKeyof<S["archetypes"]>,
> = ReadonlyArchetype<ArchetypeRowOf<S, K>>;
1 change: 1 addition & 0 deletions packages/data/src/ecs/store/index.ts
Original file line number Diff line number Diff line change
@@ -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";
15 changes: 15 additions & 0 deletions packages/data/src/ecs/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<C extends object = never> {
select<
Expand Down Expand Up @@ -228,3 +229,17 @@ type CheckDynamicParticle = Assert<Equal<typeof testStore.archetypes.DynamicPart
type CheckParticle = Assert<Equal<typeof testStore.archetypes.Particle, Archetype<RequiredComponents & {
particle: boolean;
}>>>;

// 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<Equal<ArchetypeRowOf<typeof checkRowSchema, "DynamicParticle">, RequiredComponents & {
readonly particle: boolean;
readonly velocity: number;
}>>;
type CheckSchemaRowParticle = Assert<Equal<ArchetypeRowOf<typeof checkRowSchema, "Particle">, RequiredComponents & {
readonly particle: boolean;
}>>;