diff --git a/src/index.ts b/src/index.ts index 7c5f1c1..ccb6ffa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ export { WriteGuard, slab, SharedStruct, + shared, } from './shared/index.ts'; export { int8, uint8, int16, uint16, int32, uint32, int64, uint64, bool, diff --git a/src/shared/index.ts b/src/shared/index.ts index 7a96b50..e47870e 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 { shared } from './shared.ts'; export { int8, uint8, int16, uint16, int32, uint32, int64, uint64, bool, int8atomic, uint8atomic, int16atomic, uint16atomic, int32atomic, uint32atomic, int64atomic, uint64atomic, boolatomic, diff --git a/src/shared/shared-struct.ts b/src/shared/shared-struct.ts index 9e9aab3..e33c7e9 100644 --- a/src/shared/shared-struct.ts +++ b/src/shared/shared-struct.ts @@ -1,12 +1,25 @@ import type { Loadable } from './loadable.ts'; -type FieldValues>> = { - [K in keyof T]: T[K] extends Loadable ? V : never; +type LoadableKeys> = { + [K in keyof T]: T[K] extends Loadable ? K : never; +}[keyof T]; + +type FieldValues> = { + [K in LoadableKeys]: T[K] extends Loadable ? V : never; }; +function isLoadable(value: unknown): value is Loadable { + return ( + typeof value === 'object' && + value !== null && + typeof (value as any).load === 'function' && + typeof (value as any).store === 'function' + ); +} + const SHARED = Symbol.for('moroutine.shared'); -export class SharedStruct>> implements Loadable> { +export class SharedStruct> implements Loadable> { readonly fields: T; constructor(fields: T) { @@ -14,16 +27,24 @@ export class SharedStruct>> implements Lo } load(): FieldValues { - const result = {} as FieldValues; + const result = {} as any; for (const key in this.fields) { - (result as any)[key] = this.fields[key].load(); + const field = this.fields[key]; + if (isLoadable(field)) { + result[key] = field.load(); + } } return result; } store(values: FieldValues): void { for (const key in this.fields) { - this.fields[key].store((values as any)[key]); + const field = this.fields[key]; + if (isLoadable(field)) { + if (key in (values as any)) { + field.store((values as any)[key]); + } + } } } diff --git a/src/shared/shared.ts b/src/shared/shared.ts new file mode 100644 index 0000000..f09710a --- /dev/null +++ b/src/shared/shared.ts @@ -0,0 +1,65 @@ +import type { Descriptor } from './descriptors.ts'; +import { SharedStruct } from './shared-struct.ts'; + +function isDescriptor(value: unknown): value is Descriptor { + return typeof value === 'function' && 'byteSize' in value && '_class' in value; +} + +function isStructSchema(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function align(offset: number, alignment: number): number { + const remainder = offset % alignment; + return remainder === 0 ? offset : offset + (alignment - remainder); +} + +interface LeafEntry { + path: string[]; + descriptor: Descriptor; + offset: number; +} + +function collectLeaves(schema: Record, path: string[], leaves: LeafEntry[], cursor: { offset: number }): void { + for (const key in schema) { + const value = schema[key]; + if (isDescriptor(value)) { + cursor.offset = align(cursor.offset, value.byteAlignment); + leaves.push({ path: [...path, key], descriptor: value, offset: cursor.offset }); + cursor.offset += value.byteSize; + } else if (isStructSchema(value)) { + collectLeaves(value as Record, [...path, key], leaves, cursor); + } + } +} + +function buildStructTree(schema: Record, leaves: LeafEntry[], buffer: SharedArrayBuffer, leafIndex: { i: number }): SharedStruct { + const fields: Record = {}; + for (const key in schema) { + const value = schema[key]; + if (isDescriptor(value)) { + const leaf = leaves[leafIndex.i++]; + fields[key] = new leaf.descriptor._class(buffer, leaf.offset); + } else if (isStructSchema(value)) { + fields[key] = buildStructTree(value as Record, leaves, buffer, leafIndex); + } + } + return new SharedStruct(fields); +} + +export function shared(schema: unknown): any { + if (isDescriptor(schema)) { + return schema(); + } + + if (isStructSchema(schema)) { + const leaves: LeafEntry[] = []; + const cursor = { offset: 0 }; + collectLeaves(schema as Record, [], leaves, cursor); + const buffer = new SharedArrayBuffer(cursor.offset); + const leafIndex = { i: 0 }; + return buildStructTree(schema as Record, leaves, buffer, leafIndex); + } + + throw new Error('Invalid schema'); +} diff --git a/test/shared/shared.test.ts b/test/shared/shared.test.ts new file mode 100644 index 0000000..22b7364 --- /dev/null +++ b/test/shared/shared.test.ts @@ -0,0 +1,71 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { shared, int32, int64, bool, mutex } from 'moroutine'; + +describe('shared()', () => { + it('shared(descriptor) creates a standalone instance', () => { + const x = shared(int32); + assert.equal(x.load(), 0); + x.store(42); + assert.equal(x.load(), 42); + }); + + it('shared(struct schema) creates a struct with fields', () => { + const point = shared({ x: int32, y: int32 }); + assert.deepEqual(point.load(), { x: 0, y: 0 }); + point.store({ x: 10, y: 20 }); + assert.deepEqual(point.load(), { x: 10, y: 20 }); + }); + + it('struct fields are accessible via .fields', () => { + const point = shared({ x: int32, y: int32 }); + point.fields.x.store(42); + assert.equal(point.fields.x.load(), 42); + assert.equal(point.fields.y.load(), 0); + }); + + it('struct with mixed types', () => { + const state = shared({ hp: int32, alive: bool }); + state.store({ hp: 100, alive: true }); + assert.deepEqual(state.load(), { hp: 100, alive: true }); + }); + + it('nested struct schema', () => { + const rect = shared({ + pos: { x: int32, y: int32 }, + size: { w: int32, h: int32 }, + }); + rect.store({ pos: { x: 1, y: 2 }, size: { w: 100, h: 50 } }); + assert.deepEqual(rect.load(), { pos: { x: 1, y: 2 }, size: { w: 100, h: 50 } }); + }); + + it('nested struct fields accessible', () => { + const rect = shared({ + pos: { x: int32, y: int32 }, + w: int32, + }); + rect.fields.pos.fields.x.store(99); + rect.fields.w.store(200); + assert.deepEqual(rect.load(), { pos: { x: 99, y: 0 }, w: 200 }); + }); + + it('struct with lock excludes lock from load/store', () => { + const state = shared({ x: int32, lock: mutex }); + state.store({ x: 42 }); + assert.deepEqual(state.load(), { x: 42 }); + }); + + it('struct lock accessible via fields', async () => { + const state = shared({ x: int32, lock: mutex }); + const guard = await state.fields.lock.lock(); + guard[Symbol.dispose](); + }); + + it('struct fields share one SharedArrayBuffer', () => { + const point = shared({ x: int32, y: int32 }); + const SHARED = Symbol.for('moroutine.shared'); + const xSync = (point.fields.x as any)[SHARED](); + const ySync = (point.fields.y as any)[SHARED](); + assert.equal(xSync.buffer, ySync.buffer); + }); +});