Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "data-monorepo",
"version": "0.10.24",
"version": "0.10.25",
"private": true,
"engines": {
"node": ">=24"
Expand Down
2 changes: 1 addition & 1 deletion packages/data-ai/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "adobe-data-ai",
"version": "0.10.24",
"version": "0.10.25",
"description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.",
"author": {
"name": "Adobe"
Expand Down
2 changes: 1 addition & 1 deletion packages/data-ai/.claude/rules/features/data/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ fields, it takes them structurally
(`Iterable<{ readonly mark: PlayerMark; readonly cellIndex: CellIndex }>`).

Components and resources share **one namespace** across every feature in an app:
a name is one column or one resource, never both (the store warns on a clash, and the resource's schema replaces the component's), and
a name is one column or one resource, never both (the store warns on a clash, and the resource's schema replaces the component's; an app mid-rename lists it in the `nameClashes` create option), and
has one owning feature.
2 changes: 1 addition & 1 deletion packages/data-ai/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data-ai",
"version": "0.10.24",
"version": "0.10.25",
"description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).",
"type": "module",
"private": false,
Expand Down
2 changes: 1 addition & 1 deletion packages/data-gpu/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data-gpu",
"version": "0.10.24",
"version": "0.10.25",
"description": "Adobe data WebGPU plugins and types for graphics and compute",
"type": "module",
"private": false,
Expand Down
2 changes: 1 addition & 1 deletion packages/data-lit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data-lit",
"version": "0.10.24",
"version": "0.10.25",
"description": "Adobe data Lit bindings - hooks, elements, decorators",
"type": "module",
"private": false,
Expand Down
2 changes: 1 addition & 1 deletion packages/data-persistence/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data-persistence",
"version": "0.10.24",
"version": "0.10.25",
"description": "Worker-based incremental persistence layer for @adobe/data ECS over OPFS (browser) and node:fs (server).",
"type": "module",
"sideEffects": false,
Expand Down
2 changes: 1 addition & 1 deletion packages/data-react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data-react",
"version": "0.10.24",
"version": "0.10.25",
"description": "Adobe data React bindings — hooks and context for ECS database",
"type": "module",
"private": false,
Expand Down
2 changes: 1 addition & 1 deletion packages/data-rpc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data-rpc",
"version": "0.10.24",
"version": "0.10.25",
"description": "Schema-driven, bidirectional projection of @adobe/data async data services across a boundary (iframe / MessagePort / Worker). Only Data crosses the wire.",
"type": "module",
"sideEffects": false,
Expand Down
2 changes: 1 addition & 1 deletion packages/data-solid/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data-solid",
"version": "0.10.24",
"version": "0.10.25",
"description": "Adobe data SolidJS bindings — context and provider for ECS database",
"type": "module",
"private": false,
Expand Down
2 changes: 1 addition & 1 deletion packages/data-sync/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data-sync",
"version": "0.10.24",
"version": "0.10.25",
"description": "Multi-user real-time synchronisation for @adobe/data ECS — server, client, and in-process loopback.",
"type": "module",
"sideEffects": false,
Expand Down
2 changes: 1 addition & 1 deletion packages/data-testing/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data-testing",
"version": "0.10.24",
"version": "0.10.25",
"description": "Conformance-testing utilities (Match + Conformance runners) for @adobe/data ECS features",
"type": "module",
"sideEffects": false,
Expand Down
2 changes: 1 addition & 1 deletion packages/data/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data",
"version": "0.10.24",
"version": "0.10.25",
"description": "Adobe data oriented programming library",
"type": "module",
"sideEffects": false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.

import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { Database } from "../database.js";
import { Store } from "../../store/index.js";
import { F32 } from "../../../math/f32/index.js";
Expand Down Expand Up @@ -154,3 +154,17 @@ describe("Database.create from plugin", () => {
});
});


describe("Database.create nameClashes", () => {
it("passes the acknowledged clashes through to the store", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const base = Database.Plugin.create({ components: { mode: { type: "number", default: 0 } } });
const plugin = Database.Plugin.create({ extends: base, resources: { mode: { type: "number", default: 7 } } });
const clashes = () => warn.mock.calls.filter(([message]) => String(message).includes("both a component and a resource"));
Database.create(plugin);
expect(clashes()).toHaveLength(1);
Database.create(plugin, { nameClashes: ["mode"] });
expect(clashes()).toHaveLength(1);
warn.mockRestore();
});
});
9 changes: 6 additions & 3 deletions packages/data/src/ecs/database/public/create-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ interface CreateDatabaseOptions<P extends Database.Plugin<any, any, any, any, an
* absent from this map throws when its first archetype is resolved.
*/
defaultFactories?: Record<string, () => unknown>;
/** Component/resource names acknowledged as clashing (see {@link CreateStoreOptions}). */
nameClashes?: readonly string[];
}

export function createDatabase(): Database<{}, {}, {}, {}, never, {}, {}, {}>
Expand All @@ -88,7 +90,7 @@ export function createDatabase(
plugin?: Database.Plugin<any, any, any, any, any, any, any, any>,
options?: CreateDatabaseOptions<any>,
): any {
const db = createEmptyDatabase({ concurrency: options?.concurrency, versioning: options?.versioning, allocator: options?.allocator, defaultFactories: options?.defaultFactories });
const db = createEmptyDatabase({ concurrency: options?.concurrency, versioning: options?.versioning, allocator: options?.allocator, defaultFactories: options?.defaultFactories, nameClashes: options?.nameClashes });
if (plugin === undefined) {
return db;
}
Expand Down Expand Up @@ -141,17 +143,18 @@ function scopedSchemas(schemas: StoreSchemas, scope: PersistenceScope | undefine
* Creates a database with empty store, no transactions, actions, services, computed, or systems.
* All content is added via .extend(plugin). Single code path for extension.
*/
function createEmptyDatabase({ concurrency, versioning, allocator, defaultFactories }: {
function createEmptyDatabase({ concurrency, versioning, allocator, defaultFactories, nameClashes }: {
concurrency: ConcurrencyStrategyFactory | undefined,
versioning?: DatabaseVersioning,
allocator?: MemoryAllocator,
defaultFactories?: Record<string, () => unknown>,
nameClashes?: readonly string[],
}): any {
const store = Store.create({
components: {},
resources: {},
archetypes: {},
}, { allocator, defaultFactories });
}, { allocator, defaultFactories, nameClashes });

const observedDatabase = createObservedDatabase(store);

Expand Down
50 changes: 40 additions & 10 deletions packages/data/src/ecs/store/public/create-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,28 @@ describe("createStore", () => {
);

describe("component/resource name clashes", () => {
it("warns once and lets the resource schema replace the component's", () => {
const component = { type: "number", default: 0 } as const;
const resource = { type: "number", default: 7, nonPersistent: true } as const;
const clashWarnings = (warn: { mock: { calls: unknown[][] } }) =>
warn.mock.calls.filter(([message]) => String(message).includes("both a component and a resource"));

it("warns on every store with an unacknowledged clash and lets the resource schema win", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
for (let i = 0; i < 2; i++) {
const store = createStore({ components: { mode: component }, resources: {}, archetypes: {} });
store.extend({ components: {}, resources: { mode: resource }, archetypes: {} });
expect(store.componentSchemas.mode).toBe(resource);
}
expect(clashWarnings(warn)).toHaveLength(2);
warn.mockRestore();
});

it("does not warn for a clash listed in nameClashes", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const component = { type: "number", default: 0 } as const;
const resource = { type: "number", default: 7, nonPersistent: true } as const;
const clash = "clashWarnsOnce";
const first = createStore({ components: { [clash]: component }, resources: {}, archetypes: {} });
first.extend({ components: {}, resources: { [clash]: resource }, archetypes: {} });
expect(first.componentSchemas[clash]).toBe(resource);
const second = createStore({ components: { [clash]: component }, resources: {}, archetypes: {} });
second.extend({ components: {}, resources: { [clash]: resource }, archetypes: {} });
expect(warn.mock.calls.filter(([message]) => String(message).includes(clash))).toHaveLength(1);
const store = createStore({ components: { mode: component }, resources: {}, archetypes: {} }, { nameClashes: ["mode"] });
store.extend({ components: {}, resources: { mode: resource }, archetypes: {} });
expect(store.componentSchemas.mode).toBe(resource);
expect(clashWarnings(warn)).toHaveLength(0);
warn.mockRestore();
});

Expand All @@ -35,6 +46,25 @@ describe("createStore", () => {
});
});

describe("struct schemas with optional fields", () => {
const vec2 = { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } as const;
const pair = { type: "object", properties: { a: vec2, b: vec2 }, default: {} } as const;

it("creates a resource whose default omits every field", () => {
const store = createStore({ components: {}, resources: { pair }, archetypes: {} });
expect(store.resources.pair).toEqual({ a: [0, 0], b: [0, 0] });
});

it("inserts a component with absent fields and round-trips it", () => {
const store = createStore({ components: { pair }, resources: {}, archetypes: { Pair: ["pair"] } });
const id = store.archetypes.Pair.insert({ pair: { a: [1, 2] } });
expect(store.read(id)?.pair).toEqual({ a: [1, 2], b: [0, 0] });
const restored = createStore({ components: { pair }, resources: {}, archetypes: { Pair: ["pair"] } });
restored.fromData(store.toData());
expect(restored.read(id)?.pair).toEqual({ a: [1, 2], b: [0, 0] });
});
});

describe("reserved names", () => {
it("throws when a schema defines a reserved component name", () => {
for (const reserved of ["id", "nonPersistent", "nonShared"]) {
Expand Down
17 changes: 10 additions & 7 deletions packages/data/src/ecs/store/public/create-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@ import { DefaultFactoryKeys } from "../../default-factory-keys.js";
import { IndexDeclarations } from "../index-types.js";
import { MemoryAllocator } from "../../../cache/memory-allocator.js";

// Names already reported by the component/resource clash warning (once per process).
const warnedClashes = new Set<string>();

export interface CreateStoreOptions {
/**
* Backing memory allocator for every numeric component column. Omit for the
Expand All @@ -46,6 +43,12 @@ export interface CreateStoreOptions {
* archetype is resolved (fail fast, not per insert).
*/
defaultFactories?: Record<string, () => unknown>;
/**
* Names acknowledged as both a component and a resource, so they don't warn. The
* resource schema replaces the component's (the 0.10.20 behaviour). Transitional:
* rename one side and drop it from this list; an unlisted clash warns.
*/
nameClashes?: readonly string[];
}

export function createStore<
Expand Down Expand Up @@ -81,6 +84,7 @@ export function createStore<
const resourceSchemas = {} as RS;
const archetypeComponentNames = {} as A;
const componentAndResourceSchemas: { [K in StringKeyof<C | R>]: Schema } = {} as any;
const acknowledgedClashes = new Set(options?.nameClashes);

const core = createCore(
componentAndResourceSchemas,
Expand Down Expand Up @@ -383,10 +387,9 @@ export function createStore<
}
// Components and resources share one schema map, so a same-named resource
// replaces the component's schema there (its column takes on the resource's
// flags). Kept for compatibility; rename one of them.
if (name in componentSchemas && !warnedClashes.has(name)) {
warnedClashes.add(name);
console.warn(`@adobe/data: "${name}" is both a component and a resource; the resource schema replaces the component's. Rename one of them.`);
// flags). Kept for compatibility; rename one, or acknowledge it in `nameClashes`.
if (name in componentSchemas && !acknowledgedClashes.has(name)) {
console.warn(`@adobe/data: "${name}" is both a component and a resource; the resource schema replaces the component's. Rename one, or list it in the store's nameClashes option.`);
}
if (name in resourceSchemas) {
if (resourceSchemas[name as keyof typeof resourceSchemas] !== newResourceSchema) {
Expand Down
8 changes: 8 additions & 0 deletions packages/data/src/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ export interface Schema {
// the schema it wraps.
readonly schema?: never;
type?: keyof typeof schemaTypes;
// Names a TypeScript type that JSON Schema cannot describe (`Response`,
// `HTMLElement`, `ReadableStream`, …), for service schemas only. Set it on a
// schema with no `type`. `toTypeScript` emits the name verbatim; `ToType`
// resolves a global class name to its instance type, and any other name to `any`.
typeName?: string;
// Not supported: OpenAPI's `nullable`. Use `oneOf: [schema, { type: "null" }]`
// (see `Nullable`).
nullable?: never;
title?: string;
description?: string;
conditionals?: readonly Conditional[];
Expand Down
26 changes: 25 additions & 1 deletion packages/data/src/schema/to-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export type ToType<T, Depth extends number = 5> =

type FromSchemaInternal<T, Depth extends number = 5> = T extends { const: infer Const } ? Const
: T extends { enum: infer Enum } ? Enum extends ReadonlyArray<any> ? Enum[number] : never
: T extends { oneOf: infer Schemas }
// `anyOf` shares the `oneOf` branch: both are a union, and one branch adds no depth.
: T extends { oneOf: infer Schemas } | { anyOf: infer Schemas }
? Schemas extends ReadonlyArray<Schema> ? FromOneOfSchema<Schemas, Decrement<Depth>> : never
: T extends { allOf: infer Schemas }
? Schemas extends ReadonlyArray<Schema> ? FromAllOfSchema<Schemas, Decrement<Depth>> : never
Expand Down Expand Up @@ -52,9 +53,18 @@ type FromSchemaInternal<T, Depth extends number = 5> = T extends { const: infer
? AsyncGenerator<ToType<ValueSchema<T>, Decrement<Depth>>>
: T extends { type: 'function' }
? FromSchemaFunction<T, Decrement<Depth>>
: T extends { typeName: infer N }
? FromTypeName<N>
: any
;

// A global class name (`Response`, `HTMLElement`) resolves to its instance type;
// any other name cannot be resolved here and is `any`.
type FromTypeName<N> =
N extends keyof typeof globalThis
? (typeof globalThis)[N] extends { prototype: infer P } ? P : any
: any;

type Decrement<N extends number> = ((...x: any[]) => void) extends (
arg: any,
...rest: infer R
Expand Down Expand Up @@ -306,6 +316,20 @@ type TestAllOfSingle = ToType<{
}>; // { name?: string }
type CheckAllOfSingle = True<EquivalentTypes<TestAllOfSingle, { name?: string }>>;

type TestAnyOf = ToType<{ anyOf: [{ type: 'string' }, { type: 'null' }] }>; // string | null
type CheckAnyOf = True<EquivalentTypes<TestAnyOf, string | null>>;

type TestTypeName = ToType<{ typeName: 'Response' }>; // Response
type CheckTypeName = True<EquivalentTypes<TestTypeName, Response>>;

type TestTypeNameNested = ToType<{
type: 'object', properties: { element: { typeName: 'HTMLElement' } }, required: ['element']
}>; // { readonly element: HTMLElement }
type CheckTypeNameNested = True<EquivalentTypes<TestTypeNameNested, { readonly element: HTMLElement }>>;

type TestTypeNameUnknown = ToType<{ typeName: 'NotAGlobal' }>; // any
type CheckTypeNameUnknown = True<EquivalentTypes<TestTypeNameUnknown, any>>;

// ============================================================================
// TYPE-CONSTRUCTOR SCHEMAS (data-adjacent types)
// ============================================================================
Expand Down
48 changes: 48 additions & 0 deletions packages/data/src/schema/to-typescript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,54 @@ describe("Schema.toTypeScript", () => {
expect(Schema.toTypeScript({ type: "function" }, "F")).toBe("type F = () => void;");
});

it("renders anyOf as a union, matching ToType", () => {
const schema = { anyOf: [{ type: "string" }, { type: "null" }] } as const satisfies Schema;
const typed: Schema.ToType<typeof schema> = null;
expect(typed).toBeNull();
expect(Schema.toTypeScript(schema, "X")).toBe("type X = string | null;");
});

it("names a repeated parameter shape after the parameter", () => {
const opts = { type: "object", properties: { x: { type: "number" } } } as const satisfies Schema;
const fn = { type: "function", signature: { parameters: [{ name: "opts", schema: opts }] } } as const satisfies Schema;
const schema = {
type: "object",
properties: { a: fn, b: fn },
required: ["a", "b"],
} as const satisfies Schema;
expect(Schema.toTypeScript(schema, "S")).toBe(
[
"interface Opts {",
" readonly x?: number;",
"}",
"",
"interface S {",
" readonly a: (opts: Opts) => void;",
" readonly b: (opts: Opts) => void;",
"}",
].join("\n"),
);
});

it("rejects OpenAPI nullable at compile time", () => {
// @ts-expect-error nullable is not supported; use oneOf with { type: "null" }
const schema: Schema = { type: "boolean", nullable: true };
expect(schema.type).toBe("boolean");
});

it("renders a typeName verbatim as a type reference", () => {
const schema = {
type: "function",
signature: {
parameters: [{ name: "host", schema: { typeName: "HTMLElement" } }],
returns: { type: "promise", value: { typeName: "Response" } },
},
} as const satisfies Schema;
const typed: Schema.ToType<typeof schema> = (host: HTMLElement) => fetch(host.id);
expect(typed).toBeTypeOf("function");
expect(Schema.toTypeScript(schema, "F")).toBe("type F = (host: HTMLElement) => Promise<Response>;");
});

it("renders an action returning a promise inside a service interface", () => {
const schema = {
type: "object",
Expand Down
Loading
Loading