From b416ceaf0e7954d0fdebce62758d9d1aa06eb71c Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 9 Apr 2026 16:27:37 -0400 Subject: [PATCH] feat: shared() tuple schema support Co-Authored-By: Claude Opus 4.6 (1M context) --- src/shared/index.ts | 1 + src/shared/shared.ts | 133 ++++++++++++++++++++++++++++++++++++++ src/shared/tuple.ts | 40 ++++++++++++ test/shared/tuple.test.ts | 57 ++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 src/shared/tuple.ts create mode 100644 test/shared/tuple.test.ts diff --git a/src/shared/index.ts b/src/shared/index.ts index e47870e..68348b1 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -21,6 +21,7 @@ export { Mutex, MutexGuard } from './mutex.ts'; export { RwLock, ReadGuard, WriteGuard } from './rwlock.ts'; export { slab } from './slab.ts'; export { SharedStruct } from './shared-struct.ts'; +export { Tuple } from './tuple.ts'; export { shared } from './shared.ts'; export { int8, uint8, int16, uint16, int32, uint32, int64, uint64, bool, diff --git a/src/shared/shared.ts b/src/shared/shared.ts index f09710a..aa5b013 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -1,5 +1,6 @@ import type { Descriptor } from './descriptors.ts'; import { SharedStruct } from './shared-struct.ts'; +import { Tuple } from './tuple.ts'; function isDescriptor(value: unknown): value is Descriptor { return typeof value === 'function' && 'byteSize' in value && '_class' in value; @@ -20,6 +21,25 @@ interface LeafEntry { offset: number; } +interface ArrayLeafEntry { + type: 'leaf'; + descriptor: Descriptor; + offset: number; +} + +interface ArrayStructEntry { + type: 'struct'; + schema: Record; + leaves: LeafEntry[]; +} + +interface ArrayTupleEntry { + type: 'tuple'; + entries: ArrayEntry[]; +} + +type ArrayEntry = ArrayLeafEntry | ArrayStructEntry | ArrayTupleEntry; + function collectLeaves(schema: Record, path: string[], leaves: LeafEntry[], cursor: { offset: number }): void { for (const key in schema) { const value = schema[key]; @@ -27,12 +47,63 @@ function collectLeaves(schema: Record, path: string[], leaves: cursor.offset = align(cursor.offset, value.byteAlignment); leaves.push({ path: [...path, key], descriptor: value, offset: cursor.offset }); cursor.offset += value.byteSize; + } else if (Array.isArray(value)) { + collectArrayLeaves(value, leaves, cursor); } else if (isStructSchema(value)) { collectLeaves(value as Record, [...path, key], leaves, cursor); } } } +function collectArrayLeaves(schema: unknown[], leaves: LeafEntry[], cursor: { offset: number }): void { + for (const element of schema) { + if (isDescriptor(element)) { + cursor.offset = align(cursor.offset, element.byteAlignment); + leaves.push({ path: [], descriptor: element, offset: cursor.offset }); + cursor.offset += element.byteSize; + } else if (Array.isArray(element)) { + collectArrayLeaves(element, leaves, cursor); + } else if (isStructSchema(element)) { + collectLeaves(element as Record, [], leaves, cursor); + } + } +} + +function processArraySchema(schema: unknown[], cursor: { offset: number }): { entries: ArrayEntry[] } { + const entries: ArrayEntry[] = []; + for (const element of schema) { + if (isDescriptor(element)) { + cursor.offset = align(cursor.offset, element.byteAlignment); + entries.push({ type: 'leaf', descriptor: element, offset: cursor.offset }); + cursor.offset += element.byteSize; + } else if (Array.isArray(element)) { + const nested = processArraySchema(element, cursor); + entries.push({ type: 'tuple', entries: nested.entries }); + } else if (isStructSchema(element)) { + const leaves: LeafEntry[] = []; + collectLeaves(element as Record, [], leaves, cursor); + entries.push({ type: 'struct', schema: element as Record, leaves }); + } + } + return { entries }; +} + +function buildTupleFromEntries(entries: ArrayEntry[], buffer: SharedArrayBuffer): Tuple { + const elements = entries.map((entry) => { + if (entry.type === 'leaf') { + return new entry.descriptor._class(buffer, entry.offset); + } + if (entry.type === 'struct') { + const leafIndex = { i: 0 }; + return buildStructTree(entry.schema, entry.leaves, buffer, leafIndex); + } + if (entry.type === 'tuple') { + return buildTupleFromEntries(entry.entries, buffer); + } + }); + return new Tuple(elements as any); +} + function buildStructTree(schema: Record, leaves: LeafEntry[], buffer: SharedArrayBuffer, leafIndex: { i: number }): SharedStruct { const fields: Record = {}; for (const key in schema) { @@ -40,6 +111,9 @@ function buildStructTree(schema: Record, leaves: LeafEntry[], b if (isDescriptor(value)) { const leaf = leaves[leafIndex.i++]; fields[key] = new leaf.descriptor._class(buffer, leaf.offset); + } else if (Array.isArray(value)) { + const arrayEntries = processArraySchemaFromLeaves(value, leaves, leafIndex); + fields[key] = buildTupleFromEntries(arrayEntries, buffer); } else if (isStructSchema(value)) { fields[key] = buildStructTree(value as Record, leaves, buffer, leafIndex); } @@ -47,11 +121,70 @@ function buildStructTree(schema: Record, leaves: LeafEntry[], b return new SharedStruct(fields); } +function processArraySchemaFromLeaves(schema: unknown[], leaves: LeafEntry[], leafIndex: { i: number }): ArrayEntry[] { + const entries: ArrayEntry[] = []; + for (const element of schema) { + if (isDescriptor(element)) { + const leaf = leaves[leafIndex.i++]; + entries.push({ type: 'leaf', descriptor: leaf.descriptor, offset: leaf.offset }); + } else if (Array.isArray(element)) { + const nested = processArraySchemaFromLeaves(element, leaves, leafIndex); + entries.push({ type: 'tuple', entries: nested }); + } else if (isStructSchema(element)) { + const structLeaves: LeafEntry[] = []; + const startIndex = leafIndex.i; + // Count leaves for this struct + countStructLeaves(element as Record, leaves, leafIndex); + const endIndex = leafIndex.i; + const subLeaves = leaves.slice(startIndex, endIndex); + const subLeafIndex = { i: 0 }; + entries.push({ + type: 'struct', + schema: element as Record, + leaves: subLeaves, + }); + } + } + return entries; +} + +function countStructLeaves(schema: Record, leaves: LeafEntry[], leafIndex: { i: number }): void { + for (const key in schema) { + const value = schema[key]; + if (isDescriptor(value)) { + leafIndex.i++; + } else if (Array.isArray(value)) { + countArrayLeaves(value, leaves, leafIndex); + } else if (isStructSchema(value)) { + countStructLeaves(value as Record, leaves, leafIndex); + } + } +} + +function countArrayLeaves(schema: unknown[], leaves: LeafEntry[], leafIndex: { i: number }): void { + for (const element of schema) { + if (isDescriptor(element)) { + leafIndex.i++; + } else if (Array.isArray(element)) { + countArrayLeaves(element, leaves, leafIndex); + } else if (isStructSchema(element)) { + countStructLeaves(element as Record, leaves, leafIndex); + } + } +} + export function shared(schema: unknown): any { if (isDescriptor(schema)) { return schema(); } + if (Array.isArray(schema)) { + const cursor = { offset: 0 }; + const { entries } = processArraySchema(schema, cursor); + const buffer = new SharedArrayBuffer(cursor.offset); + return buildTupleFromEntries(entries, buffer); + } + if (isStructSchema(schema)) { const leaves: LeafEntry[] = []; const cursor = { offset: 0 }; diff --git a/src/shared/tuple.ts b/src/shared/tuple.ts new file mode 100644 index 0000000..ea134ba --- /dev/null +++ b/src/shared/tuple.ts @@ -0,0 +1,40 @@ +import type { Loadable } from './loadable.ts'; + +const SHARED = Symbol.for('moroutine.shared'); + +export class Tuple[]> implements Loadable<{ [K in keyof T]: T[K] extends Loadable ? V : never }> { + private readonly elements: T; + readonly length: number; + + constructor(elements: T) { + this.elements = elements; + this.length = elements.length; + } + + get(index: number): T[number] { + if (index < 0 || index >= this.length) { + throw new RangeError(`Index ${index} out of bounds for tuple of length ${this.length}`); + } + return this.elements[index]; + } + + load(): { [K in keyof T]: T[K] extends Loadable ? V : never } { + return this.elements.map((el) => el.load()) as any; + } + + store(values: { [K in keyof T]: T[K] extends Loadable ? V : never }): void { + for (let i = 0; i < this.elements.length; i++) { + this.elements[i].store((values as any)[i]); + } + } + + [SHARED](): { tag: string; elements: unknown[] } { + const serializedElements: unknown[] = []; + for (const el of this.elements) { + if (typeof el === 'object' && el !== null && SHARED in el) { + serializedElements.push((el as any)[SHARED]()); + } + } + return { tag: 'Tuple', elements: serializedElements }; + } +} diff --git a/test/shared/tuple.test.ts b/test/shared/tuple.test.ts new file mode 100644 index 0000000..aa08e67 --- /dev/null +++ b/test/shared/tuple.test.ts @@ -0,0 +1,57 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { shared, int32, int64, bool } from 'moroutine'; + +describe('shared() tuple', () => { + it('creates a tuple from array schema', () => { + const t = shared([int32, int64]); + assert.deepEqual(t.load(), [0, 0n]); + }); + + it('store writes all elements', () => { + const t = shared([int32, int64, bool]); + t.store([42, 99n, true]); + assert.deepEqual(t.load(), [42, 99n, true]); + }); + + it('get() returns element Loadable', () => { + const t = shared([int32, int32]); + t.get(0).store(10); + t.get(1).store(20); + assert.deepEqual(t.load(), [10, 20]); + }); + + it('length returns fixed element count', () => { + const t = shared([int32, int32, bool]); + assert.equal(t.length, 3); + }); + + it('get() out of bounds throws', () => { + const t = shared([int32]); + assert.throws(() => t.get(1), /out of bounds/i); + assert.throws(() => t.get(-1), /out of bounds/i); + }); + + it('tuple elements share one buffer', () => { + const t = shared([int32, int32]); + const SHARED = Symbol.for('moroutine.shared'); + const s0 = (t.get(0) as any)[SHARED](); + const s1 = (t.get(1) as any)[SHARED](); + assert.equal(s0.buffer, s1.buffer); + }); + + it('tuple with struct elements', () => { + const t = shared([{ x: int32, y: int32 }, { x: int32, y: int32 }]); + t.store([{ x: 1, y: 2 }, { x: 3, y: 4 }]); + assert.deepEqual(t.load(), [{ x: 1, y: 2 }, { x: 3, y: 4 }]); + }); + + it('struct containing tuple field', () => { + const s = shared({ + points: [int32, int32, int32], + count: int32, + }); + s.store({ points: [1, 2, 3], count: 3 }); + assert.deepEqual(s.load(), { points: [1, 2, 3], count: 3 }); + }); +}); -- 2.51.2