diff --git a/package.json b/package.json
index 323c628a..f80a0e66 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "data-monorepo",
- "version": "0.10.24",
+ "version": "0.10.25",
"private": true,
"engines": {
"node": ">=24"
diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json
index 1e0ab986..c2c5ea3b 100644
--- a/packages/data-ai/.claude-plugin/plugin.json
+++ b/packages/data-ai/.claude-plugin/plugin.json
@@ -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"
diff --git a/packages/data-ai/.claude/rules/features/data/index.md b/packages/data-ai/.claude/rules/features/data/index.md
index 36d24ce4..7d14e1f4 100644
--- a/packages/data-ai/.claude/rules/features/data/index.md
+++ b/packages/data-ai/.claude/rules/features/data/index.md
@@ -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.
diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json
index 18a8cf18..154a4b70 100644
--- a/packages/data-ai/package.json
+++ b/packages/data-ai/package.json
@@ -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,
diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json
index cc3384f1..4372d580 100644
--- a/packages/data-gpu/package.json
+++ b/packages/data-gpu/package.json
@@ -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,
diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json
index 544d4acc..f7f1fc99 100644
--- a/packages/data-lit/package.json
+++ b/packages/data-lit/package.json
@@ -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,
diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json
index ea28e722..65cd9d7c 100644
--- a/packages/data-persistence/package.json
+++ b/packages/data-persistence/package.json
@@ -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,
diff --git a/packages/data-react/package.json b/packages/data-react/package.json
index 5a046470..8ad1e6cf 100644
--- a/packages/data-react/package.json
+++ b/packages/data-react/package.json
@@ -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,
diff --git a/packages/data-rpc/package.json b/packages/data-rpc/package.json
index 995ce82e..07c7fba0 100644
--- a/packages/data-rpc/package.json
+++ b/packages/data-rpc/package.json
@@ -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,
diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json
index 991ba0a8..26c1eb92 100644
--- a/packages/data-solid/package.json
+++ b/packages/data-solid/package.json
@@ -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,
diff --git a/packages/data-sync/package.json b/packages/data-sync/package.json
index 6bbdcfc2..02b643c5 100644
--- a/packages/data-sync/package.json
+++ b/packages/data-sync/package.json
@@ -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,
diff --git a/packages/data-testing/package.json b/packages/data-testing/package.json
index b0649e7e..43ab52c8 100644
--- a/packages/data-testing/package.json
+++ b/packages/data-testing/package.json
@@ -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,
diff --git a/packages/data/package.json b/packages/data/package.json
index 6d9c96e5..3ce90d9f 100644
--- a/packages/data/package.json
+++ b/packages/data/package.json
@@ -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,
diff --git a/packages/data/src/ecs/database/public/create-database-from-schema.test.ts b/packages/data/src/ecs/database/public/create-database-from-schema.test.ts
index e3ba2a63..64def6cc 100644
--- a/packages/data/src/ecs/database/public/create-database-from-schema.test.ts
+++ b/packages/data/src/ecs/database/public/create-database-from-schema.test.ts
@@ -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";
@@ -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();
+ });
+});
diff --git a/packages/data/src/ecs/database/public/create-database.ts b/packages/data/src/ecs/database/public/create-database.ts
index cbb781e4..de715940 100644
--- a/packages/data/src/ecs/database/public/create-database.ts
+++ b/packages/data/src/ecs/database/public/create-database.ts
@@ -75,6 +75,8 @@ interface CreateDatabaseOptions
unknown>;
+ /** Component/resource names acknowledged as clashing (see {@link CreateStoreOptions}). */
+ nameClashes?: readonly string[];
}
export function createDatabase(): Database<{}, {}, {}, {}, never, {}, {}, {}>
@@ -88,7 +90,7 @@ export function createDatabase(
plugin?: Database.Plugin,
options?: CreateDatabaseOptions,
): 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;
}
@@ -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 unknown>,
+ nameClashes?: readonly string[],
}): any {
const store = Store.create({
components: {},
resources: {},
archetypes: {},
- }, { allocator, defaultFactories });
+ }, { allocator, defaultFactories, nameClashes });
const observedDatabase = createObservedDatabase(store);
diff --git a/packages/data/src/ecs/store/public/create-store.test.ts b/packages/data/src/ecs/store/public/create-store.test.ts
index dab6904d..5992caf1 100644
--- a/packages/data/src/ecs/store/public/create-store.test.ts
+++ b/packages/data/src/ecs/store/public/create-store.test.ts
@@ -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();
});
@@ -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"]) {
diff --git a/packages/data/src/ecs/store/public/create-store.ts b/packages/data/src/ecs/store/public/create-store.ts
index ba4872dc..ff6e0ace 100644
--- a/packages/data/src/ecs/store/public/create-store.ts
+++ b/packages/data/src/ecs/store/public/create-store.ts
@@ -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();
-
export interface CreateStoreOptions {
/**
* Backing memory allocator for every numeric component column. Omit for the
@@ -46,6 +43,12 @@ export interface CreateStoreOptions {
* archetype is resolved (fail fast, not per insert).
*/
defaultFactories?: Record 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<
@@ -81,6 +84,7 @@ export function createStore<
const resourceSchemas = {} as RS;
const archetypeComponentNames = {} as A;
const componentAndResourceSchemas: { [K in StringKeyof]: Schema } = {} as any;
+ const acknowledgedClashes = new Set(options?.nameClashes);
const core = createCore(
componentAndResourceSchemas,
@@ -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) {
diff --git a/packages/data/src/schema/schema.ts b/packages/data/src/schema/schema.ts
index 34b94536..f8288747 100644
--- a/packages/data/src/schema/schema.ts
+++ b/packages/data/src/schema/schema.ts
@@ -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[];
diff --git a/packages/data/src/schema/to-type.ts b/packages/data/src/schema/to-type.ts
index 860f80f8..a4159545 100644
--- a/packages/data/src/schema/to-type.ts
+++ b/packages/data/src/schema/to-type.ts
@@ -15,7 +15,8 @@ export type ToType =
type FromSchemaInternal = T extends { const: infer Const } ? Const
: T extends { enum: infer Enum } ? Enum extends ReadonlyArray ? 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 ? FromOneOfSchema> : never
: T extends { allOf: infer Schemas }
? Schemas extends ReadonlyArray ? FromAllOfSchema> : never
@@ -52,9 +53,18 @@ type FromSchemaInternal = T extends { const: infer
? AsyncGenerator, Decrement>>
: T extends { type: 'function' }
? FromSchemaFunction>
+ : T extends { typeName: infer N }
+ ? FromTypeName
: 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 extends keyof typeof globalThis
+ ? (typeof globalThis)[N] extends { prototype: infer P } ? P : any
+ : any;
+
type Decrement = ((...x: any[]) => void) extends (
arg: any,
...rest: infer R
@@ -306,6 +316,20 @@ type TestAllOfSingle = ToType<{
}>; // { name?: string }
type CheckAllOfSingle = True>;
+type TestAnyOf = ToType<{ anyOf: [{ type: 'string' }, { type: 'null' }] }>; // string | null
+type CheckAnyOf = True>;
+
+type TestTypeName = ToType<{ typeName: 'Response' }>; // Response
+type CheckTypeName = True>;
+
+type TestTypeNameNested = ToType<{
+ type: 'object', properties: { element: { typeName: 'HTMLElement' } }, required: ['element']
+}>; // { readonly element: HTMLElement }
+type CheckTypeNameNested = True>;
+
+type TestTypeNameUnknown = ToType<{ typeName: 'NotAGlobal' }>; // any
+type CheckTypeNameUnknown = True>;
+
// ============================================================================
// TYPE-CONSTRUCTOR SCHEMAS (data-adjacent types)
// ============================================================================
diff --git a/packages/data/src/schema/to-typescript.test.ts b/packages/data/src/schema/to-typescript.test.ts
index 7b771ead..73b962f7 100644
--- a/packages/data/src/schema/to-typescript.test.ts
+++ b/packages/data/src/schema/to-typescript.test.ts
@@ -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 = 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 = (host: HTMLElement) => fetch(host.id);
+ expect(typed).toBeTypeOf("function");
+ expect(Schema.toTypeScript(schema, "F")).toBe("type F = (host: HTMLElement) => Promise;");
+ });
+
it("renders an action returning a promise inside a service interface", () => {
const schema = {
type: "object",
diff --git a/packages/data/src/schema/to-typescript.ts b/packages/data/src/schema/to-typescript.ts
index adb2c475..852c8d5d 100644
--- a/packages/data/src/schema/to-typescript.ts
+++ b/packages/data/src/schema/to-typescript.ts
@@ -19,14 +19,15 @@ const indent = " ";
* a fixed-length tuple of length ≥2) is pre-declared once at the top as a named
* `interface` and referenced by name everywhere it occurs. Shapes used exactly once
* stay inlined. The name is the shape's `title` (PascalCased) when present, else the
- * referencing property key, else a generated `Shape`.
+ * referencing property key or parameter name, else a generated `Shape`.
* - **Emits `description`s as `//` comments** directly above the declaration, property,
* or function they annotate (one comment line per line of the description).
*
* A schema that resolves to an object is emitted as an `interface`; anything else as a
* `type` alias. The result references the ambient names `Blob`, `Promise`,
* `AsyncGenerator`, `TypedBuffer` (`@adobe/data`) and `Observe` (`@adobe/data/observe`)
- * for the corresponding schema constructors; the caller supplies those imports.
+ * for the corresponding schema constructors, and each `typeName` verbatim; the caller
+ * supplies those imports.
*
* @example
* ```typescript
@@ -116,7 +117,7 @@ function walk(schema: Schema, keyHint: string | undefined, multiplier: number, c
case "function":
if (schema.signature) {
for (const parameter of schema.signature.parameters ?? []) {
- walk(schemaOf(parameter), undefined, multiplier, context);
+ walk(schemaOf(parameter), isNamed(parameter) ? parameter.name : undefined, multiplier, context);
}
if (schema.signature.returns) walk(schema.signature.returns, undefined, multiplier, context);
}
@@ -270,6 +271,7 @@ function typeExpression(schema: Schema, depth: number, context: Context): string
return functionExpression(schema, depth, context);
}
+ if (schema.typeName !== undefined) return schema.typeName;
return "any";
}
diff --git a/packages/data/src/typed-buffer/structs/create-write-struct.ts b/packages/data/src/typed-buffer/structs/create-write-struct.ts
index dd3eb5b3..7bfe66d5 100644
--- a/packages/data/src/typed-buffer/structs/create-write-struct.ts
+++ b/packages/data/src/typed-buffer/structs/create-write-struct.ts
@@ -12,7 +12,8 @@ const generateStructBody = (
parentOffset = '',
valueRef = 'value',
indent = ' ',
- usedViews: ViewTypes = { f32: false, i32: false, u32: false }
+ usedViews: ViewTypes = { f32: false, i32: false, u32: false },
+ fills: unknown[] = [],
): [string, ViewTypes] => {
if (typeof layout === 'string') {
usedViews[layout as ViewType] = true;
@@ -26,13 +27,22 @@ const generateStructBody = (
let body = '';
for (const [name, field] of entries) {
const fieldOffset = getFieldOffset(field, parentOffset);
- const nextValueRef = layout.type === 'array' ? `${valueRef}[${name}]` : `${valueRef}.${name}`;
+ let nextValueRef = layout.type === 'array' ? `${valueRef}[${name}]` : `${valueRef}.${name}`;
if (typeof field.type === 'string') {
usedViews[field.type as ViewType] = true;
- body += `\n${indent}__${field.type}[${fieldOffset}] = ${nextValueRef};`;
+ // An optional primitive falls back to its fill, inlined as a literal.
+ const value = field.fill === undefined ? nextValueRef : `(${nextValueRef} ?? ${JSON.stringify(field.fill)})`;
+ body += `\n${indent}__${field.type}[${fieldOffset}] = ${value};`;
} else {
- const [nestedBody] = generateStructBody(field.type, fieldOffset, nextValueRef, indent + ' ', usedViews);
+ // An optional nested field falls back to its fill, bound once in the closure.
+ if (field.fill !== undefined) {
+ const local = `__v${fills.length}`;
+ body += `\n${indent}const ${local} = ${nextValueRef} ?? __fill[${fills.length}];`;
+ fills.push(field.fill);
+ nextValueRef = local;
+ }
+ const [nestedBody] = generateStructBody(field.type, fieldOffset, nextValueRef, indent + ' ', usedViews, fills);
body += `\n${indent}${nestedBody}`;
}
}
@@ -40,7 +50,8 @@ const generateStructBody = (
};
export const createWriteStruct = memoizeFactory((layout: StructLayout): WriteStruct => {
- const [body, usedViews] = generateStructBody(layout);
+ const fills: unknown[] = [];
+ const [body, usedViews] = generateStructBody(layout, '', 'value', ' ', { f32: false, i32: false, u32: false }, fills);
const views = Object.entries(usedViews)
.filter(([, used]) => used)
.map(([type]) => `${type}: __${type}`)
@@ -49,5 +60,5 @@ export const createWriteStruct = memoizeFactory((layout: StructLayo
const code = `const { ${views} } = data;
index *= ${layout.size / 4};
${body};`;
- return new Function('data', 'index', 'value', code) as WriteStruct;
+ return new Function('__fill', `return function(data, index, value) {\n${code}\n}`)(fills) as WriteStruct;
});
diff --git a/packages/data/src/typed-buffer/structs/get-struct-layout.test.ts b/packages/data/src/typed-buffer/structs/get-struct-layout.test.ts
index 282a1a40..f4b7a1c1 100644
--- a/packages/data/src/typed-buffer/structs/get-struct-layout.test.ts
+++ b/packages/data/src/typed-buffer/structs/get-struct-layout.test.ts
@@ -433,4 +433,21 @@ describe("getStructLayout", () => {
});
});
-});
\ No newline at end of file
+});
+describe("getStructLayout memoization", () => {
+ it("returns the same layout for the same schema, so struct read/write compile once", () => {
+ const schema = { type: "object", properties: { x: { type: "number", precision: 1 } }, required: ["x"] } as const;
+ expect(getStructLayout(schema)).toBe(getStructLayout(schema));
+ expect(getStructLayout(schema, false)).toBe(getStructLayout(schema));
+ });
+});
+
+describe("getStructLayout optional fields", () => {
+ it("gives a fill only to fields not listed in required", () => {
+ const vec2 = { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } as const;
+ const layout = getStructLayout({ type: "object", properties: { a: vec2, b: { ...vec2, default: [1, 2] } }, required: ["a"] });
+ expect(layout.fields.a!.fill).toBeUndefined();
+ expect(layout.fields.b!.fill).toEqual([1, 2]);
+ expect(Object.isFrozen(layout.fields.b!.fill)).toBe(true);
+ });
+});
diff --git a/packages/data/src/typed-buffer/structs/get-struct-layout.ts b/packages/data/src/typed-buffer/structs/get-struct-layout.ts
index cb9012fb..e3ba2496 100644
--- a/packages/data/src/typed-buffer/structs/get-struct-layout.ts
+++ b/packages/data/src/typed-buffer/structs/get-struct-layout.ts
@@ -1,5 +1,4 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
-import { memoizeFactory } from "../../internal/function/memoize-factory.js";
import { I32 } from "../../math/i32/index.js";
import { type Schema } from "../../schema/index.js";
import { U32 } from "../../math/u32/index.js";
@@ -112,9 +111,33 @@ const getPrimitiveType = (schema: Schema): StructFieldPrimitiveType | null => {
* Analyzes a Schema and returns a StructLayout.
* Returns null if schema is not a valid struct schema.
*/
-const getStructLayoutInternal = memoizeFactory(
- ({ schema, layout }: { schema: Schema; layout: Layout }): StructLayout | null => getStructLayoutInternalImpl(schema, layout, false)
-);
+// The value an unwritten struct field reads back as: zeros of the field's shape.
+const zeroValue = (type: StructFieldPrimitiveType | StructLayout): unknown => {
+ if (typeof type === "string") return 0;
+ if (type.type === "array") {
+ return Object.keys(type.fields).sort((a, b) => +a - +b).map((index) => zeroValue(type.fields[index]!.type));
+ }
+ return Object.fromEntries(Object.entries(type.fields).map(([name, field]) => [name, zeroValue(field.type)]));
+};
+
+const deepFreeze = (value: T): T => {
+ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
+ for (const child of Object.values(value)) deepFreeze(child);
+ Object.freeze(value);
+ }
+ return value;
+};
+
+// Memoized per schema object and layout mode, so one schema always yields the same
+// layout and the read/write factories memoized on that layout compile once.
+const layoutCache = new WeakMap>>();
+const getStructLayoutInternal = ({ schema, layout }: { schema: Schema; layout: Layout }): StructLayout | null => {
+ let byLayout = layoutCache.get(schema);
+ if (byLayout === undefined) layoutCache.set(schema, (byLayout = {}));
+ const cached = byLayout[layout];
+ if (cached !== undefined) return cached;
+ return (byLayout[layout] = getStructLayoutInternalImpl(schema, layout, false));
+};
const getStructLayoutInternalImpl = (schema: Schema, layout: Layout = "std140", throwsOnError: boolean = false): StructLayout | null => {
// Handle root array/tuple case
@@ -194,6 +217,8 @@ const getStructLayoutInternalImpl = (schema: Schema, layout: Layout = "std140",
const fields: StructLayout["fields"] = {};
let currentOffset = 0;
+ // Without `required`, every property is optional (as in `Schema.ToType`).
+ const required = schema.required ? new Set(schema.required) : undefined;
// First pass: create all fields and calculate alignments
for (const [name, fieldSchema] of Object.entries(schema.properties)) {
@@ -207,10 +232,9 @@ const getStructLayoutInternalImpl = (schema: Schema, layout: Layout = "std140",
// Align field to its required alignment
currentOffset = roundUpToAlignment(currentOffset, alignment);
- fields[name] = {
- offset: currentOffset,
- type: fieldType
- };
+ fields[name] = required?.has(name)
+ ? { offset: currentOffset, type: fieldType }
+ : { offset: currentOffset, type: fieldType, fill: deepFreeze(structuredClone(fieldSchema.default ?? zeroValue(fieldType))) };
currentOffset += getFieldSize(fieldType);
}
@@ -234,11 +258,9 @@ export function getStructLayout(
// Read layout from schema with fallback to "std140"
const layout = schema.layout ?? "std140";
- // If we need to throw errors, call the implementation directly with throwError=true
- // Otherwise, use the memoized version for better performance
- if (throwError) {
- return getStructLayoutInternalImpl(schema, layout, true);
- } else {
- return getStructLayoutInternal({ schema, layout });
- }
+ // Always return the memoized layout, so the read/write factories memoized on it are
+ // reused; only an invalid schema reruns the implementation to throw its reason.
+ const result = getStructLayoutInternal({ schema, layout });
+ if (result !== null || !throwError) return result;
+ return getStructLayoutInternalImpl(schema, layout, true);
}
\ No newline at end of file
diff --git a/packages/data/src/typed-buffer/structs/struct-layout.ts b/packages/data/src/typed-buffer/structs/struct-layout.ts
index 35a0fdab..b8bd36d9 100644
--- a/packages/data/src/typed-buffer/structs/struct-layout.ts
+++ b/packages/data/src/typed-buffer/structs/struct-layout.ts
@@ -10,6 +10,12 @@ export type StructFieldPrimitiveType = "i32" | "u32" | "f32";
export interface StructLayoutField {
offset: number;
type: StructFieldPrimitiveType | StructLayout;
+ /**
+ * Present on an object field that is not `required`: the value written when the
+ * field is absent (its schema `default`, else zeros). A struct stores every field,
+ * so an omitted one reads back as this value.
+ */
+ fill?: unknown;
}
/**
diff --git a/packages/data/src/typed-buffer/structs/write-struct.test.ts b/packages/data/src/typed-buffer/structs/write-struct.test.ts
index b40ce72c..d57d9c64 100644
--- a/packages/data/src/typed-buffer/structs/write-struct.test.ts
+++ b/packages/data/src/typed-buffer/structs/write-struct.test.ts
@@ -3,6 +3,10 @@ import { describe, it, expect } from "vitest";
import { createWriteStruct } from "./create-write-struct.js";
import { createDataView32 } from "../../internal/data-view-32/create-data-view-32.js";
import type { StructLayout } from "./struct-layout.js";
+import { getStructLayout } from "./get-struct-layout.js";
+import { createReadStruct } from "./create-read-struct.js";
+import type { Schema } from "../../schema/index.js";
+import { F32 } from "../../math/f32/index.js";
describe("WriteStruct", () => {
it("Vec2 array root", () => {
@@ -172,4 +176,31 @@ describe("WriteStruct", () => {
expect(writeMixed.toString()).not.toMatch(/f32: __f32/);
expect(writeMixed.toString()).toMatch(/const { (?=.*i32: __i32)(?=.*u32: __u32).*? } = data/);
});
-});
\ No newline at end of file
+});
+describe("WriteStruct optional fields", () => {
+ const vec2 = { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } as const;
+ const roundTrip = (schema: Schema, value: unknown) => {
+ const layout = getStructLayout(schema);
+ const data = createDataView32(new ArrayBuffer(layout.size));
+ createWriteStruct(layout)(data, 0, value);
+ return createReadStruct(layout)(data, 0);
+ };
+
+ it("writes zeros for absent nested fields", () => {
+ expect(roundTrip({ type: "object", properties: { a: vec2, b: vec2 } }, {})).toEqual({ a: [0, 0], b: [0, 0] });
+ });
+
+ it("writes a field's own default when it is absent", () => {
+ const schema = { type: "object", properties: { a: { ...vec2, default: [3, 4] }, b: vec2 } } as const;
+ expect(roundTrip(schema, { b: [1, 2] })).toEqual({ a: [3, 4], b: [1, 2] });
+ });
+
+ it("writes 0, not NaN, for an absent primitive field", () => {
+ expect(roundTrip({ type: "object", properties: { x: F32.schema, y: F32.schema } }, { y: 2 })).toEqual({ x: 0, y: 2 });
+ });
+
+ it("leaves required fields unguarded", () => {
+ const layout = getStructLayout({ type: "object", properties: { x: F32.schema }, required: ["x"] });
+ expect(createWriteStruct(layout).toString()).not.toContain("??");
+ });
+});