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("??"); + }); +});