diff --git a/packages/spiral-db/demo.ts b/packages/spiral-db/demo.ts new file mode 100644 index 000000000..c40196824 --- /dev/null +++ b/packages/spiral-db/demo.ts @@ -0,0 +1,152 @@ +/** + * SpiralDB Demo + * Run with: npx tsx demo.ts + */ + +import { + SpiralDB, + createTodoSchema, + visualizeImageEmoji, + visualizeSpiralOrder, + saveToPngFile, + loadFromPngFile, + exportToPngBytes, +} from './src/index.js'; +import { existsSync } from 'fs'; + +console.log('='.repeat(60)); +console.log('šŸŒ€ SpiralDB Demo - Pixel-Based Spiral Database'); +console.log('='.repeat(60)); + +// Show spiral order for a 7x7 image +console.log('\nšŸ“ Spiral Index Order (7x7):'); +console.log(visualizeSpiralOrder(7)); + +// Create database +console.log('\nšŸ“¦ Creating SpiralDB with Todo schema...'); +const db = new SpiralDB({ + schema: createTodoSchema(), + compression: true, +}); + +console.log('\nšŸ“Š Initial Stats:'); +console.log(db.getStats()); + +// Insert some todos +const todos = [ + { + id: 0, + status: 0, + priority: 2, // high + createdAt: new Date('2025-01-01'), + dueDate: new Date('2025-01-15'), + completedAt: null, + title: 'Build SpiralDB', + description: 'Create a pixel-based database', + tags: [1, 2], + }, + { + id: 0, + status: 0, + priority: 1, // medium + createdAt: new Date('2025-01-02'), + dueDate: new Date('2025-01-20'), + completedAt: null, + title: 'Write tests', + description: 'Add unit tests', + tags: [1], + }, + { + id: 0, + status: 0, + priority: 0, // low + createdAt: new Date('2025-01-03'), + dueDate: null, + completedAt: null, + title: 'Documentation', + description: null, + tags: [], + }, +]; + +console.log('\nāœļø Inserting todos...'); +for (const todo of todos) { + const result = db.insert(todo); + console.log(` → ID ${result.recordId}: ${todo.title}`); +} + +console.log('\nšŸ“Š Stats after inserts:'); +const stats = db.getStats(); +console.log(` Image size: ${stats.imageSize}Ɨ${stats.imageSize}`); +console.log(` Total pixels: ${stats.totalPixels}`); +console.log(` Used pixels: ${stats.usedPixels}`); +console.log(` Active records: ${stats.activeRecords}`); +console.log(` Current ring: ${stats.currentRing}`); + +// Complete one todo +console.log('\nāœ… Completing todo #1...'); +db.complete(1); + +// Read back +console.log('\nšŸ“– Reading all todos:'); +const allTodos = db.getAll(); +for (const record of allTodos) { + const statusIcon = record.meta.status === 'completed' ? 'āœ…' : '⬜'; + console.log(` ${statusIcon} [${record.meta.id}] ${record.data.title}`); +} + +// Visualize the image +console.log('\nšŸŽØ Database Image (emoji visualization):'); +const image = db.getImage(); +console.log(visualizeImageEmoji(image)); + +// Legend +console.log('\nšŸ“š Color Legend:'); +console.log(' ⬛ Black (000) - Null/Empty/Active'); +console.log(' 🟦 Blue (001) - Data Type 1'); +console.log(' 🟩 Green (010) - Completed/True'); +console.log(' šŸ”· Cyan (011) - Data Type 3'); +console.log(' 🟄 Red (100) - Deleted/Important'); +console.log(' 🟪 Magenta (101) - Data Type 5'); +console.log(' 🟨 Yellow (110) - Warning/Archived'); +console.log(' ⬜ White (111) - Magic/Separator/End'); + +// Calculate storage efficiency +const jsonSize = JSON.stringify(todos).length; +const pixelBits = stats.usedPixels * 3; +const pixelBytes = Math.ceil(pixelBits / 8); + +console.log('\nšŸ“ˆ Storage Comparison:'); +console.log(` JSON size: ${jsonSize} bytes`); +console.log(` Pixel size: ${pixelBytes} bytes (${stats.usedPixels} pixels Ɨ 3 bits)`); +console.log(` Compression: ${((1 - pixelBytes / jsonSize) * 100).toFixed(1)}%`); + +// PNG Export Demo +console.log('\nšŸ“ø PNG Export Demo:'); + +const pngPath = './demo-output.png'; +const pngBytes = exportToPngBytes(image); +console.log(` Raw PNG size: ${pngBytes.length} bytes`); + +// Save to file +await saveToPngFile(image, pngPath); +console.log(` Saved to: ${pngPath}`); + +// Verify by loading back +if (existsSync(pngPath)) { + const loadedImage = await loadFromPngFile(pngPath); + console.log(` Loaded back: ${loadedImage.width}Ɨ${loadedImage.height} pixels`); + + // Verify data integrity + const loadedDb = SpiralDB.fromImage(loadedImage, createTodoSchema()); + const loadedTodos = loadedDb.getAll(); + console.log(` Verified: ${loadedTodos.length} todos recovered`); + + for (const record of loadedTodos) { + const statusIcon = record.meta.status === 'completed' ? 'āœ…' : '⬜'; + console.log(` ${statusIcon} [${record.meta.id}] ${record.data.title}`); + } +} + +console.log('\n' + '='.repeat(60)); +console.log('Demo complete!'); diff --git a/packages/spiral-db/package.json b/packages/spiral-db/package.json new file mode 100644 index 000000000..592a6dac2 --- /dev/null +++ b/packages/spiral-db/package.json @@ -0,0 +1,54 @@ +{ + "name": "@manacore/spiral-db", + "version": "0.1.0", + "description": "Pixel-based spiral database - store structured data in images", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean", + "dev": "tsup src/index.ts --format esm --dts --watch", + "test": "vitest", + "test:run": "vitest run", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "pako": "^2.1.0" + }, + "devDependencies": { + "@types/node": "^20.19.25", + "@types/pako": "^2.0.4", + "tsup": "^8.5.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^1.6.1" + }, + "peerDependencies": { + "sharp": "^0.33.0" + }, + "peerDependenciesMeta": { + "sharp": { + "optional": true + } + }, + "files": [ + "dist", + "src" + ], + "keywords": [ + "pixel", + "database", + "spiral", + "image", + "storage", + "encoding" + ], + "license": "MIT" +} diff --git a/packages/spiral-db/src/constants.ts b/packages/spiral-db/src/constants.ts new file mode 100644 index 000000000..3e25d3737 --- /dev/null +++ b/packages/spiral-db/src/constants.ts @@ -0,0 +1,121 @@ +/** + * SpiralDB Constants + * 8-color palette and magic values + */ + +import type { ColorDefinition, ColorIndex, FieldType, RecordStatus } from './types.js'; + +// ============================================================================= +// 8-COLOR PALETTE (3-bit) +// ============================================================================= + +export const COLORS: Record = { + 0: { index: 0, name: 'black', rgb: { r: 0, g: 0, b: 0 }, bits: [0, 0, 0] }, + 1: { index: 1, name: 'blue', rgb: { r: 0, g: 0, b: 255 }, bits: [0, 0, 1] }, + 2: { index: 2, name: 'green', rgb: { r: 0, g: 255, b: 0 }, bits: [0, 1, 0] }, + 3: { index: 3, name: 'cyan', rgb: { r: 0, g: 255, b: 255 }, bits: [0, 1, 1] }, + 4: { index: 4, name: 'red', rgb: { r: 255, g: 0, b: 0 }, bits: [1, 0, 0] }, + 5: { index: 5, name: 'magenta', rgb: { r: 255, g: 0, b: 255 }, bits: [1, 0, 1] }, + 6: { index: 6, name: 'yellow', rgb: { r: 255, g: 255, b: 0 }, bits: [1, 1, 0] }, + 7: { index: 7, name: 'white', rgb: { r: 255, g: 255, b: 255 }, bits: [1, 1, 1] }, +}; + +export const COLOR_BY_NAME: Record = { + black: 0, + blue: 1, + green: 2, + cyan: 3, + red: 4, + magenta: 5, + yellow: 6, + white: 7, +}; + +// ============================================================================= +// MAGIC VALUES +// ============================================================================= + +export const MAGIC_VALID = 7; // White = valid DB +export const MAGIC_CORRUPT = 4; // Red = corrupt +export const MAGIC_EMPTY = 0; // Black = empty/new + +// ============================================================================= +// FIELD TYPE ENCODING (3-bit) +// ============================================================================= + +export const FIELD_TYPE_BITS: Record = { + end: 0b000, + int: 0b001, + string: 0b010, + bool: 0b011, + timestamp: 0b100, + ref: 0b101, + array: 0b110, + reserved: 0b111, +}; + +export const BITS_TO_FIELD_TYPE: Record = { + 0b000: 'end', + 0b001: 'int', + 0b010: 'string', + 0b011: 'bool', + 0b100: 'timestamp', + 0b101: 'ref', + 0b110: 'array', + 0b111: 'reserved', +}; + +// ============================================================================= +// RECORD STATUS ENCODING (3-bit) +// ============================================================================= + +export const STATUS_BITS: Record = { + active: 0b000, // Black + completed: 0b010, // Green + deleted: 0b100, // Red + archived: 0b110, // Yellow +}; + +export const BITS_TO_STATUS: Record = { + 0b000: 'active', + 0b010: 'completed', + 0b100: 'deleted', + 0b110: 'archived', +}; + +// ============================================================================= +// DATABASE FLAGS ENCODING +// ============================================================================= + +export const FLAG_EMPTY = 0b000; // Black +export const FLAG_READABLE = 0b010; // Green +export const FLAG_WRITING = 0b110; // Yellow +export const FLAG_ERROR = 0b100; // Red + +// ============================================================================= +// RING LAYOUT +// ============================================================================= + +export const RING_MAGIC = 0; // Ring 0: Magic byte (1 pixel) +export const RING_HEADER = 1; // Ring 1: Header (8 pixels) +export const RING_SCHEMA = 2; // Ring 2: Schema (16 pixels) +export const RING_INDEX = 3; // Ring 3: Master index (24 pixels) +export const RING_DATA_START = 4; // Ring 4+: Record data + +// ============================================================================= +// LIMITS +// ============================================================================= + +export const MAX_VERSION = 511; // 9-bit version number +export const MAX_RECORD_COUNT = 4095; // 12-bit record count +export const MAX_RECORD_LENGTH = 511; // 9-bit record length (pixels) +export const MAX_STRING_LENGTH = 511; // Max chars per string field +export const MAX_ARRAY_LENGTH = 255; // Max items per array + +// ============================================================================= +// ENCODING HELPERS +// ============================================================================= + +export const BITS_PER_PIXEL = 3; +export const END_MARKER = 7; // White pixel = end of record +export const SEPARATOR = 7; // White pixel = separator diff --git a/packages/spiral-db/src/database.ts b/packages/spiral-db/src/database.ts new file mode 100644 index 000000000..0ed81a8b7 --- /dev/null +++ b/packages/spiral-db/src/database.ts @@ -0,0 +1,664 @@ +/** + * SpiralDB - Main Database Class + * Manages the spiral pixel database with CRUD operations + */ + +import type { + SpiralDBOptions, + SchemaDefinition, + SpiralImage, + MasterIndex, + IndexEntry, + RecordStatus, + SpiralRecord, + WriteResult, + ReadResult, + ColorIndex, + BitStream, +} from './types.js'; +import { + MAGIC_VALID, + FLAG_READABLE, + RING_HEADER, + RING_SCHEMA, + RING_INDEX, + RING_DATA_START, + STATUS_BITS, + BITS_TO_STATUS, + END_MARKER, + MAX_RECORD_LENGTH, +} from './constants.js'; +import { + createBitStream, + writeBits, + readBits, + bitsToPixels, + pixelsToBits, + encodeString, + decodeString, + encodeInt, + decodeInt, + encodeBool, + decodeBool, + encodeTimestamp, + decodeTimestamp, + encodeIntArray, + decodeIntArray, +} from './encoding.js'; +import { + createImageForRing, + getPixelByIndex, + setPixelByIndex, + readPixelRange, + writePixelRange, + expandImage, +} from './image.js'; +import { getRingInfo, findSpaceForRecord, getTotalPixelsForRing } from './spiral.js'; +import { encodeSchema } from './schema.js'; + +export class SpiralDB = Record> { + private image: SpiralImage; + private schema: SchemaDefinition; + private index: MasterIndex; + private currentRing: number; + private currentOffset: number; + private compression: boolean; + + constructor(options: SpiralDBOptions) { + this.schema = options.schema; + this.compression = options.compression ?? false; + + // Initialize with minimum size for header + schema + index + const initialRing = Math.max(RING_DATA_START, options.initialSize ?? RING_DATA_START); + this.image = createImageForRing(initialRing); + + // Initialize empty index + this.index = { + records: [], + deletedIds: new Set(), + nextId: 0, + }; + + // Start writing data after index ring + this.currentRing = RING_DATA_START; + this.currentOffset = 0; + + // Write initial structure + this.initializeDatabase(); + } + + /** + * Initialize the database structure (magic, header, schema) + */ + private initializeDatabase(): void { + // Ring 0: Magic byte + setPixelByIndex(this.image, 0, MAGIC_VALID as ColorIndex); + + // Ring 1: Header + this.writeHeader(); + + // Ring 2: Schema + this.writeSchema(); + + // Ring 3: Index (initially empty) + this.writeIndex(); + } + + /** + * Write the database header to Ring 1 + */ + private writeHeader(): void { + const stream = createBitStream(); + + // Version (9 bits) + writeBits(stream, this.schema.version, 9); + + // Flags (3 bits) + writeBits(stream, FLAG_READABLE, 3); + + // Record count (12 bits) + writeBits(stream, this.index.records.length, 12); + + const pixels = bitsToPixels(stream.bits); + const ringInfo = getRingInfo(RING_HEADER); + writePixelRange(this.image, ringInfo.startIndex, pixels); + } + + /** + * Write the schema to Ring 2 + */ + private writeSchema(): void { + const schemaPixels = encodeSchema(this.schema); + const ringInfo = getRingInfo(RING_SCHEMA); + writePixelRange(this.image, ringInfo.startIndex, schemaPixels); + } + + /** + * Write the master index to Ring 3+ + * Index can span multiple rings if needed + */ + private writeIndex(): void { + const stream = createBitStream(); + + // Record count (12 bits) + writeBits(stream, this.index.records.length, 12); + + // Next ID (12 bits) + writeBits(stream, this.index.nextId, 12); + + // Each index entry: [id:12][ring:8][offset:8][length:9][status:3] = 40 bits + for (const entry of this.index.records) { + writeBits(stream, entry.id, 12); + writeBits(stream, entry.ring, 8); + writeBits(stream, entry.offset, 8); + writeBits(stream, entry.length, 9); + writeBits(stream, STATUS_BITS[entry.status], 3); + } + + const pixels = bitsToPixels(stream.bits); + + // Write index pixels starting at Ring 3 + // May span multiple rings if needed + let pixelIndex = 0; + let currentRing = RING_INDEX; + + while (pixelIndex < pixels.length) { + const ringInfo = getRingInfo(currentRing); + const pixelsInRing = Math.min(pixels.length - pixelIndex, ringInfo.pixelCount); + + writePixelRange( + this.image, + ringInfo.startIndex, + pixels.slice(pixelIndex, pixelIndex + pixelsInRing) + ); + + pixelIndex += pixelsInRing; + currentRing++; + } + + // Store how many rings the index spans (for loading) + // We use the last pixel of Ring 2 (schema ring) to store this + const indexRingCount = currentRing - RING_INDEX; + const ring2Info = getRingInfo(RING_SCHEMA); + const countPixelIndex = ring2Info.startIndex + ring2Info.pixelCount - 1; + setPixelByIndex(this.image, countPixelIndex, indexRingCount as ColorIndex); + } + + /** + * Serialize a record to pixels + */ + private serializeRecord(id: number, status: RecordStatus, data: T): ColorIndex[] { + const stream = createBitStream(); + + // Record ID (12 bits) + writeBits(stream, id, 12); + + // Status (3 bits) + writeBits(stream, STATUS_BITS[status], 3); + + // Encode each field according to schema + for (const field of this.schema.fields) { + const value = data[field.name]; + + // Null flag for nullable fields + if (field.nullable) { + const isNull = value === null || value === undefined; + encodeBool(stream, isNull); + if (isNull) continue; + } + + switch (field.type) { + case 'int': + encodeInt(stream, value as number, field.maxLength); + break; + case 'string': + encodeString(stream, value as string, this.compression); + break; + case 'bool': + encodeBool(stream, value as boolean); + break; + case 'timestamp': + encodeTimestamp(stream, value as Date | null); + break; + case 'array': + if (Array.isArray(value) && typeof value[0] === 'number') { + encodeIntArray(stream, value as number[], 12); + } else if (Array.isArray(value) && typeof value[0] === 'string') { + // For string arrays, encode each string + writeBits(stream, value.length, 8); + for (const str of value as string[]) { + encodeString(stream, str, this.compression); + } + } else { + writeBits(stream, 0, 8); // Empty array + } + break; + } + } + + // End marker + writeBits(stream, END_MARKER, 3); + + return bitsToPixels(stream.bits); + } + + /** + * Deserialize pixels to a record + */ + private deserializeRecord(pixels: ColorIndex[]): { id: number; status: RecordStatus; data: T } { + const bits = pixelsToBits(pixels); + const stream: BitStream = { bits, position: 0 }; + + // Record ID + const id = readBits(stream, 12); + + // Status + const statusBits = readBits(stream, 3); + const status = BITS_TO_STATUS[statusBits] || 'active'; + + // Decode each field + const data: Record = {}; + + for (const field of this.schema.fields) { + // Null flag for nullable fields + if (field.nullable) { + const isNull = decodeBool(stream); + if (isNull) { + data[field.name] = null; + continue; + } + } + + switch (field.type) { + case 'int': + data[field.name] = decodeInt(stream, field.maxLength); + break; + case 'string': + data[field.name] = decodeString(stream); + break; + case 'bool': + data[field.name] = decodeBool(stream); + break; + case 'timestamp': + data[field.name] = decodeTimestamp(stream); + break; + case 'array': + if (field.name === 'tags') { + data[field.name] = decodeIntArray(stream, 12); + } else { + const count = readBits(stream, 8); + const arr: string[] = []; + for (let i = 0; i < count; i++) { + arr.push(decodeString(stream)); + } + data[field.name] = arr; + } + break; + } + } + + return { id, status, data: data as T }; + } + + /** + * Insert a new record + */ + insert(data: T): WriteResult { + try { + const id = this.index.nextId++; + const pixels = this.serializeRecord(id, 'active', data); + + if (pixels.length > MAX_RECORD_LENGTH) { + return { success: false, error: 'Record too large' }; + } + + // Find space for the record + const space = findSpaceForRecord(this.currentRing, this.currentOffset, pixels.length); + + // Expand image if needed + if (space.needsExpansion) { + this.image = expandImage(this.image, space.ring); + } + + // Calculate absolute pixel index + const ringInfo = getRingInfo(space.ring); + const startIndex = ringInfo.startIndex + space.offset; + + // Write record pixels + writePixelRange(this.image, startIndex, pixels); + + // Update index + const entry: IndexEntry = { + id, + ring: space.ring, + offset: space.offset, + length: pixels.length, + status: 'active', + }; + this.index.records.push(entry); + + // Update position + this.currentRing = space.ring; + this.currentOffset = space.offset + pixels.length; + + // Check if we filled this ring + if (this.currentOffset >= ringInfo.pixelCount) { + this.currentRing++; + this.currentOffset = 0; + } + + // Update header and index in image + this.writeHeader(); + this.writeIndex(); + + return { + success: true, + recordId: id, + newImageSize: space.needsExpansion ? this.image.width : undefined, + }; + } catch (error) { + return { success: false, error: String(error) }; + } + } + + /** + * Read a record by ID + */ + read(id: number): ReadResult { + const entry = this.index.records.find((r) => r.id === id); + + if (!entry) { + return { success: false, error: 'Record not found' }; + } + + if (entry.status === 'deleted') { + return { success: false, error: 'Record has been deleted' }; + } + + try { + const ringInfo = getRingInfo(entry.ring); + const startIndex = ringInfo.startIndex + entry.offset; + const pixels = readPixelRange(this.image, startIndex, entry.length); + + const { id: recordId, status, data } = this.deserializeRecord(pixels); + + const record: SpiralRecord = { + meta: { + id: recordId, + status, + createdAt: entry.ring, + ringStart: entry.ring, + pixelOffset: entry.offset, + length: entry.length, + }, + data, + }; + + return { success: true, record }; + } catch (error) { + return { success: false, error: String(error) }; + } + } + + /** + * Update a record (creates new version, marks old as deleted) + */ + update(id: number, data: Partial): WriteResult { + const readResult = this.read(id); + if (!readResult.success || !readResult.record) { + return { success: false, error: readResult.error }; + } + + // Mark old record as deleted + this.delete(id); + + // Insert new record with same ID (reuse ID) + this.index.nextId--; // Revert increment from delete + const mergedData = { ...readResult.record.data, ...data } as T; + + // Need to manually set the ID + const pixels = this.serializeRecord(id, 'active', mergedData); + + const space = findSpaceForRecord(this.currentRing, this.currentOffset, pixels.length); + + if (space.needsExpansion) { + this.image = expandImage(this.image, space.ring); + } + + const ringInfo = getRingInfo(space.ring); + const startIndex = ringInfo.startIndex + space.offset; + writePixelRange(this.image, startIndex, pixels); + + // Add new index entry + const entry: IndexEntry = { + id, + ring: space.ring, + offset: space.offset, + length: pixels.length, + status: 'active', + }; + this.index.records.push(entry); + + this.currentRing = space.ring; + this.currentOffset = space.offset + pixels.length; + + this.writeHeader(); + this.writeIndex(); + + return { success: true, recordId: id }; + } + + /** + * Delete a record (marks as deleted, doesn't remove) + */ + delete(id: number): WriteResult { + const entryIndex = this.index.records.findIndex((r) => r.id === id && r.status !== 'deleted'); + + if (entryIndex === -1) { + return { success: false, error: 'Record not found' }; + } + + // Update status in index + this.index.records[entryIndex].status = 'deleted'; + this.index.deletedIds.add(id); + + // Update status pixel in record + const entry = this.index.records[entryIndex]; + const ringInfo = getRingInfo(entry.ring); + const statusPixelIndex = ringInfo.startIndex + entry.offset + 4; // After 12-bit ID + setPixelByIndex(this.image, statusPixelIndex, STATUS_BITS['deleted'] as ColorIndex); + + this.writeHeader(); + this.writeIndex(); + + return { success: true, recordId: id }; + } + + /** + * Mark a record as completed + */ + complete(id: number): WriteResult { + const entryIndex = this.index.records.findIndex((r) => r.id === id && r.status === 'active'); + + if (entryIndex === -1) { + return { success: false, error: 'Active record not found' }; + } + + this.index.records[entryIndex].status = 'completed'; + + // Update status pixel + const entry = this.index.records[entryIndex]; + const ringInfo = getRingInfo(entry.ring); + const statusPixelIndex = ringInfo.startIndex + entry.offset + 4; + setPixelByIndex(this.image, statusPixelIndex, STATUS_BITS['completed'] as ColorIndex); + + this.writeIndex(); + + return { success: true, recordId: id }; + } + + /** + * Get all records (optionally filtered by status) + */ + getAll(status?: RecordStatus): SpiralRecord[] { + const records: SpiralRecord[] = []; + + for (const entry of this.index.records) { + if (status && entry.status !== status) continue; + if (entry.status === 'deleted') continue; + + const result = this.read(entry.id); + if (result.success && result.record) { + records.push(result.record); + } + } + + return records; + } + + /** + * Get the current image + */ + getImage(): SpiralImage { + return this.image; + } + + /** + * Get database statistics + */ + getStats(): { + imageSize: number; + totalPixels: number; + usedPixels: number; + totalRecords: number; + activeRecords: number; + deletedRecords: number; + currentRing: number; + } { + const activeRecords = this.index.records.filter((r) => r.status === 'active').length; + const deletedRecords = this.index.records.filter((r) => r.status === 'deleted').length; + + const usedPixels = this.index.records.reduce((sum, r) => sum + r.length, 0); + const headerPixels = getTotalPixelsForRing(RING_INDEX); + + return { + imageSize: this.image.width, + totalPixels: this.image.width * this.image.height, + usedPixels: usedPixels + headerPixels, + totalRecords: this.index.records.length, + activeRecords, + deletedRecords, + currentRing: this.currentRing, + }; + } + + /** + * Compact the database (remove deleted records) + */ + compact(): SpiralImage { + const activeRecords = this.getAll('active'); + const completedRecords = this.getAll('completed'); + const allRecords = [...activeRecords, ...completedRecords]; + + // Create new database with same schema + const newDb = new SpiralDB({ + schema: this.schema, + compression: this.compression, + }); + + // Re-insert all records + for (const record of allRecords) { + newDb.insert(record.data); + if (record.meta.status === 'completed') { + newDb.complete(record.meta.id); + } + } + + this.image = newDb.image; + this.index = newDb.index; + this.currentRing = newDb.currentRing; + this.currentOffset = newDb.currentOffset; + + return this.image; + } + + /** + * Load database from an existing image + */ + static fromImage>( + image: SpiralImage, + schema: SchemaDefinition + ): SpiralDB { + const db = new SpiralDB({ schema }); + db.image = image; + + // Validate magic byte + const magic = getPixelByIndex(image, 0); + if (magic !== MAGIC_VALID) { + throw new Error('Invalid SpiralDB image (magic byte mismatch)'); + } + + // Read index from Ring 3 + db.loadIndex(); + + return db; + } + + /** + * Load index from image + */ + private loadIndex(): void { + // Read index ring count from last pixel of Ring 2 + const ring2Info = getRingInfo(RING_SCHEMA); + const countPixelIndex = ring2Info.startIndex + ring2Info.pixelCount - 1; + const indexRingCount = getPixelByIndex(this.image, countPixelIndex) || 1; + + // Read pixels from all index rings + const allPixels: ColorIndex[] = []; + for (let r = 0; r < indexRingCount; r++) { + const ringInfo = getRingInfo(RING_INDEX + r); + const ringPixels = readPixelRange(this.image, ringInfo.startIndex, ringInfo.pixelCount); + allPixels.push(...ringPixels); + } + + const bits = pixelsToBits(allPixels); + const stream: BitStream = { bits, position: 0 }; + + const recordCount = readBits(stream, 12); + const nextId = readBits(stream, 12); + + this.index = { + records: [], + deletedIds: new Set(), + nextId, + }; + + for (let i = 0; i < recordCount; i++) { + const id = readBits(stream, 12); + const ring = readBits(stream, 8); + const offset = readBits(stream, 8); + const length = readBits(stream, 9); + const statusBits = readBits(stream, 3); + const status = BITS_TO_STATUS[statusBits] || 'active'; + + this.index.records.push({ id, ring, offset, length, status }); + + if (status === 'deleted') { + this.index.deletedIds.add(id); + } + } + + // Find current write position + if (this.index.records.length > 0) { + const lastRecord = this.index.records[this.index.records.length - 1]; + this.currentRing = lastRecord.ring; + this.currentOffset = lastRecord.offset + lastRecord.length; + + const lastRingInfo = getRingInfo(this.currentRing); + if (this.currentOffset >= lastRingInfo.pixelCount) { + this.currentRing++; + this.currentOffset = 0; + } + } + } +} diff --git a/packages/spiral-db/src/encoding.ts b/packages/spiral-db/src/encoding.ts new file mode 100644 index 000000000..736266f55 --- /dev/null +++ b/packages/spiral-db/src/encoding.ts @@ -0,0 +1,293 @@ +/** + * Encoding/Decoding between bits, colors, and pixel data + */ + +import type { ColorIndex, RGB, BitStream, SerializedRecord } from './types.js'; +import { COLORS, BITS_PER_PIXEL } from './constants.js'; +import pako from 'pako'; + +// ============================================================================= +// COLOR ↔ BITS CONVERSION +// ============================================================================= + +/** + * Convert 3 bits to a color index + */ +export function bitsToColor(b0: number, b1: number, b2: number): ColorIndex { + return ((b0 << 2) | (b1 << 1) | b2) as ColorIndex; +} + +/** + * Convert a color index to 3 bits + */ +export function colorToBits(color: ColorIndex): [number, number, number] { + return [(color >> 2) & 1, (color >> 1) & 1, color & 1]; +} + +/** + * Convert a color index to RGB values + */ +export function colorToRGB(color: ColorIndex): RGB { + return COLORS[color].rgb; +} + +/** + * Find the closest color index for given RGB values + * Uses simple threshold: value >= 128 = 1, else 0 + */ +export function rgbToColor(r: number, g: number, b: number): ColorIndex { + const bit0 = r >= 128 ? 1 : 0; + const bit1 = g >= 128 ? 1 : 0; + const bit2 = b >= 128 ? 1 : 0; + return bitsToColor(bit0, bit1, bit2); +} + +// ============================================================================= +// BIT STREAM +// ============================================================================= + +/** + * Create a new bit stream for writing + */ +export function createBitStream(): BitStream { + return { bits: [], position: 0 }; +} + +/** + * Write bits to a stream + */ +export function writeBits(stream: BitStream, value: number, count: number): void { + for (let i = count - 1; i >= 0; i--) { + stream.bits.push((value >> i) & 1); + } +} + +/** + * Read bits from a stream + */ +export function readBits(stream: BitStream, count: number): number { + let value = 0; + for (let i = 0; i < count; i++) { + if (stream.position >= stream.bits.length) { + throw new Error('Unexpected end of bit stream'); + } + value = (value << 1) | stream.bits[stream.position++]; + } + return value; +} + +/** + * Peek at bits without consuming them + */ +export function peekBits(stream: BitStream, count: number): number { + const originalPosition = stream.position; + const value = readBits(stream, count); + stream.position = originalPosition; + return value; +} + +/** + * Check if stream has more bits + */ +export function hasMoreBits(stream: BitStream, count = 1): boolean { + return stream.position + count <= stream.bits.length; +} + +// ============================================================================= +// BITS ↔ PIXELS CONVERSION +// ============================================================================= + +/** + * Convert a bit array to color indices (pixels) + * Pads with zeros if necessary to align to 3-bit boundaries + */ +export function bitsToPixels(bits: number[]): ColorIndex[] { + const pixels: ColorIndex[] = []; + const paddedBits = [...bits]; + + // Pad to multiple of 3 + while (paddedBits.length % BITS_PER_PIXEL !== 0) { + paddedBits.push(0); + } + + for (let i = 0; i < paddedBits.length; i += BITS_PER_PIXEL) { + const color = bitsToColor(paddedBits[i], paddedBits[i + 1], paddedBits[i + 2]); + pixels.push(color); + } + + return pixels; +} + +/** + * Convert color indices (pixels) to bits + */ +export function pixelsToBits(pixels: ColorIndex[]): number[] { + const bits: number[] = []; + for (const pixel of pixels) { + const [b0, b1, b2] = colorToBits(pixel); + bits.push(b0, b1, b2); + } + return bits; +} + +/** + * Convert a bit stream to a serialized record + */ +export function streamToRecord(stream: BitStream): SerializedRecord { + return { + pixels: bitsToPixels(stream.bits), + bitLength: stream.bits.length, + }; +} + +// ============================================================================= +// VALUE ENCODING +// ============================================================================= + +/** + * Encode an integer with variable bit length + */ +export function encodeInt(stream: BitStream, value: number, bitLength: number): void { + if (value < 0) { + throw new Error('Negative integers not supported'); + } + if (value >= 2 ** bitLength) { + throw new Error(`Value ${value} too large for ${bitLength} bits`); + } + writeBits(stream, value, bitLength); +} + +/** + * Decode an integer with variable bit length + */ +export function decodeInt(stream: BitStream, bitLength: number): number { + return readBits(stream, bitLength); +} + +/** + * Encode a boolean (1 bit) + */ +export function encodeBool(stream: BitStream, value: boolean): void { + writeBits(stream, value ? 1 : 0, 1); +} + +/** + * Decode a boolean + */ +export function decodeBool(stream: BitStream): boolean { + return readBits(stream, 1) === 1; +} + +/** + * Encode a string as UTF-8 bytes + * Format: [length:9bit][...bytes] + */ +export function encodeString(stream: BitStream, value: string, compress = false): void { + const bytes = new TextEncoder().encode(value); + + if (compress && bytes.length > 20) { + const compressed = pako.deflate(bytes); + if (compressed.length < bytes.length) { + // Use compressed version + writeBits(stream, 1, 1); // compression flag + writeBits(stream, compressed.length, 9); + for (const byte of compressed) { + writeBits(stream, byte, 8); + } + return; + } + } + + // Uncompressed + writeBits(stream, 0, 1); // compression flag + writeBits(stream, bytes.length, 9); + for (const byte of bytes) { + writeBits(stream, byte, 8); + } +} + +/** + * Decode a string + */ +export function decodeString(stream: BitStream): string { + const isCompressed = readBits(stream, 1) === 1; + const length = readBits(stream, 9); + const bytes = new Uint8Array(length); + + for (let i = 0; i < length; i++) { + bytes[i] = readBits(stream, 8); + } + + if (isCompressed) { + const decompressed = pako.inflate(bytes); + return new TextDecoder().decode(decompressed); + } + + return new TextDecoder().decode(bytes); +} + +/** + * Encode a timestamp (days since epoch, 24 bits) + */ +export function encodeTimestamp(stream: BitStream, date: Date | null): void { + if (date === null) { + writeBits(stream, 0, 24); // 0 = null/no date + return; + } + const daysSinceEpoch = Math.floor(date.getTime() / (1000 * 60 * 60 * 24)); + writeBits(stream, daysSinceEpoch, 24); +} + +/** + * Decode a timestamp + */ +export function decodeTimestamp(stream: BitStream): Date | null { + const daysSinceEpoch = readBits(stream, 24); + if (daysSinceEpoch === 0) return null; + return new Date(daysSinceEpoch * 24 * 60 * 60 * 1000); +} + +/** + * Encode an array of integers + * Format: [count:8bit][...values] + */ +export function encodeIntArray(stream: BitStream, values: number[], itemBitLength: number): void { + writeBits(stream, values.length, 8); + for (const value of values) { + encodeInt(stream, value, itemBitLength); + } +} + +/** + * Decode an array of integers + */ +export function decodeIntArray(stream: BitStream, itemBitLength: number): number[] { + const count = readBits(stream, 8); + const values: number[] = []; + for (let i = 0; i < count; i++) { + values.push(decodeInt(stream, itemBitLength)); + } + return values; +} + +/** + * Encode an array of strings + */ +export function encodeStringArray(stream: BitStream, values: string[], compress = false): void { + writeBits(stream, values.length, 8); + for (const value of values) { + encodeString(stream, value, compress); + } +} + +/** + * Decode an array of strings + */ +export function decodeStringArray(stream: BitStream): string[] { + const count = readBits(stream, 8); + const values: string[] = []; + for (let i = 0; i < count; i++) { + values.push(decodeString(stream)); + } + return values; +} diff --git a/packages/spiral-db/src/image.ts b/packages/spiral-db/src/image.ts new file mode 100644 index 000000000..630756191 --- /dev/null +++ b/packages/spiral-db/src/image.ts @@ -0,0 +1,231 @@ +/** + * Image handling for SpiralDB + * Converts between SpiralImage and raw pixel data + */ + +import type { ColorIndex, SpiralImage } from './types.js'; +import { colorToRGB, rgbToColor } from './encoding.js'; +import { spiralToXY, xyToSpiral, getImageSizeForRing } from './spiral.js'; + +/** + * Create an empty spiral image + */ +export function createImage(size: number): SpiralImage { + if (size % 2 === 0) { + throw new Error('Image size must be odd'); + } + const pixels = new Uint8Array(size * size * 3); + // Initialize all pixels to black (0, 0, 0) + return { width: size, height: size, pixels }; +} + +/** + * Create an image that can hold a specific ring + */ +export function createImageForRing(ring: number): SpiralImage { + const size = getImageSizeForRing(ring); + return createImage(size); +} + +/** + * Get a pixel's color at a specific spiral index + */ +export function getPixelByIndex(image: SpiralImage, index: number): ColorIndex { + const point = spiralToXY(index, image.width); + return getPixelByXY(image, point.x, point.y); +} + +/** + * Set a pixel's color at a specific spiral index + */ +export function setPixelByIndex(image: SpiralImage, index: number, color: ColorIndex): void { + const point = spiralToXY(index, image.width); + setPixelByXY(image, point.x, point.y, color); +} + +/** + * Get a pixel's color at XY coordinates + */ +export function getPixelByXY(image: SpiralImage, x: number, y: number): ColorIndex { + if (x < 0 || x >= image.width || y < 0 || y >= image.height) { + throw new Error(`Coordinates out of bounds: (${x}, ${y})`); + } + const offset = (y * image.width + x) * 3; + const r = image.pixels[offset]; + const g = image.pixels[offset + 1]; + const b = image.pixels[offset + 2]; + return rgbToColor(r, g, b); +} + +/** + * Set a pixel's color at XY coordinates + */ +export function setPixelByXY(image: SpiralImage, x: number, y: number, color: ColorIndex): void { + if (x < 0 || x >= image.width || y < 0 || y >= image.height) { + throw new Error(`Coordinates out of bounds: (${x}, ${y})`); + } + const rgb = colorToRGB(color); + const offset = (y * image.width + x) * 3; + image.pixels[offset] = rgb.r; + image.pixels[offset + 1] = rgb.g; + image.pixels[offset + 2] = rgb.b; +} + +/** + * Read a range of pixels starting at a spiral index + */ +export function readPixelRange( + image: SpiralImage, + startIndex: number, + length: number +): ColorIndex[] { + const colors: ColorIndex[] = []; + for (let i = 0; i < length; i++) { + colors.push(getPixelByIndex(image, startIndex + i)); + } + return colors; +} + +/** + * Write a range of pixels starting at a spiral index + */ +export function writePixelRange( + image: SpiralImage, + startIndex: number, + colors: ColorIndex[] +): void { + for (let i = 0; i < colors.length; i++) { + setPixelByIndex(image, startIndex + i, colors[i]); + } +} + +/** + * Expand an image to accommodate more rings + * Centers the existing data in the new larger image + */ +export function expandImage(image: SpiralImage, newRing: number): SpiralImage { + const newSize = getImageSizeForRing(newRing); + if (newSize <= image.width) { + return image; // No expansion needed + } + + const newImage = createImage(newSize); + + // Copy existing pixels to center of new image + const offset = Math.floor((newSize - image.width) / 2); + + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + const oldOffset = (y * image.width + x) * 3; + const newOffset = ((y + offset) * newSize + (x + offset)) * 3; + + newImage.pixels[newOffset] = image.pixels[oldOffset]; + newImage.pixels[newOffset + 1] = image.pixels[oldOffset + 1]; + newImage.pixels[newOffset + 2] = image.pixels[oldOffset + 2]; + } + } + + return newImage; +} + +/** + * Get the current ring count based on image size + */ +export function getMaxRingForImage(image: SpiralImage): number { + return Math.floor(image.width / 2); +} + +/** + * Convert SpiralImage to raw RGBA buffer (for canvas/web) + */ +export function imageToRGBA(image: SpiralImage): Uint8Array { + const rgba = new Uint8Array(image.width * image.height * 4); + for (let i = 0; i < image.width * image.height; i++) { + rgba[i * 4] = image.pixels[i * 3]; // R + rgba[i * 4 + 1] = image.pixels[i * 3 + 1]; // G + rgba[i * 4 + 2] = image.pixels[i * 3 + 2]; // B + rgba[i * 4 + 3] = 255; // A (fully opaque) + } + return rgba; +} + +/** + * Create SpiralImage from raw RGBA buffer + */ +export function rgbaToImage(rgba: Uint8Array, width: number, height: number): SpiralImage { + if (width !== height) { + throw new Error('Image must be square'); + } + if (width % 2 === 0) { + throw new Error('Image size must be odd'); + } + + const pixels = new Uint8Array(width * height * 3); + for (let i = 0; i < width * height; i++) { + pixels[i * 3] = rgba[i * 4]; // R + pixels[i * 3 + 1] = rgba[i * 4 + 1]; // G + pixels[i * 3 + 2] = rgba[i * 4 + 2]; // B + // Ignore alpha + } + + return { width, height, pixels }; +} + +/** + * Convert SpiralImage to a 2D array of color indices (for visualization) + */ +export function imageToColorGrid(image: SpiralImage): ColorIndex[][] { + const grid: ColorIndex[][] = []; + for (let y = 0; y < image.height; y++) { + const row: ColorIndex[] = []; + for (let x = 0; x < image.width; x++) { + row.push(getPixelByXY(image, x, y)); + } + grid.push(row); + } + return grid; +} + +/** + * Create a visual representation of the spiral order + * Returns a string showing the spiral indices + */ +export function visualizeSpiralOrder(size: number): string { + const grid: string[][] = []; + for (let y = 0; y < size; y++) { + const row: string[] = []; + for (let x = 0; x < size; x++) { + const index = xyToSpiral(x, y, size); + row.push(index.toString().padStart(3, ' ')); + } + grid.push(row); + } + return grid.map((row) => row.join(' ')).join('\n'); +} + +/** + * Create a visual representation using emoji colors + */ +export function visualizeImageEmoji(image: SpiralImage): string { + const emoji: Record = { + 0: '⬛', + 1: '🟦', + 2: '🟩', + 3: 'šŸ”·', + 4: '🟄', + 5: '🟪', + 6: '🟨', + 7: '⬜', + }; + + const lines: string[] = []; + for (let y = 0; y < image.height; y++) { + let line = ''; + for (let x = 0; x < image.width; x++) { + const color = getPixelByXY(image, x, y); + line += emoji[color]; + } + lines.push(line); + } + return lines.join('\n'); +} diff --git a/packages/spiral-db/src/index.ts b/packages/spiral-db/src/index.ts new file mode 100644 index 000000000..0146f8769 --- /dev/null +++ b/packages/spiral-db/src/index.ts @@ -0,0 +1,180 @@ +/** + * SpiralDB - Pixel-based Spiral Database + * + * Store structured data in images using an 8-color palette. + * Data grows in a spiral pattern from the center outward. + * + * @example + * ```typescript + * import { SpiralDB, createTodoSchema } from '@manacore/spiral-db'; + * + * // Create database with todo schema + * const db = new SpiralDB({ + * schema: createTodoSchema(), + * compression: true, + * }); + * + * // Insert a todo + * const result = db.insert({ + * id: 0, + * status: 0, + * priority: 1, + * createdAt: new Date(), + * dueDate: new Date('2025-12-31'), + * completedAt: null, + * title: 'Build SpiralDB', + * description: 'Create a pixel-based database', + * tags: [1, 2, 3], + * }); + * + * // Get the image + * const image = db.getImage(); + * console.log(visualizeImageEmoji(image)); + * ``` + */ + +// Main database class +export { SpiralDB } from './database.js'; + +// Types +export type { + // Color system + ColorIndex, + RGB, + ColorDefinition, + // Schema + FieldType, + FieldDefinition, + SchemaDefinition, + // Records + RecordStatus, + RecordMetadata, + SpiralRecord, + // Database + DatabaseHeader, + DatabaseFlags, + MasterIndex, + IndexEntry, + // Image + SpiralImage, + Point, + RingInfo, + // Options + SpiralDBOptions, + WriteResult, + ReadResult, + // Encoding + BitStream, + SerializedRecord, +} from './types.js'; + +// Constants +export { + COLORS, + COLOR_BY_NAME, + MAGIC_VALID, + MAGIC_EMPTY, + MAGIC_CORRUPT, + BITS_PER_PIXEL, + END_MARKER, + MAX_VERSION, + MAX_RECORD_COUNT, + MAX_RECORD_LENGTH, + MAX_STRING_LENGTH, + MAX_ARRAY_LENGTH, + RING_MAGIC, + RING_HEADER, + RING_SCHEMA, + RING_INDEX, + RING_DATA_START, +} from './constants.js'; + +// Schema utilities +export { + createTodoSchema, + encodeSchema, + decodeSchema, + getSchemaPixelCount, + validateRecord, + getFieldNames, +} from './schema.js'; + +// Spiral coordinate utilities +export { + spiralToXY, + xyToSpiral, + getRingForIndex, + getRingInfo, + getImageSizeForRing, + getTotalPixelsForRing, + getRingPixels, + findSpaceForRecord, + getSpiralRange, +} from './spiral.js'; + +// Image utilities +export { + createImage, + createImageForRing, + getPixelByIndex, + setPixelByIndex, + getPixelByXY, + setPixelByXY, + readPixelRange, + writePixelRange, + expandImage, + getMaxRingForImage, + imageToRGBA, + rgbaToImage, + imageToColorGrid, + visualizeSpiralOrder, + visualizeImageEmoji, +} from './image.js'; + +// Encoding utilities +export { + bitsToColor, + colorToBits, + colorToRGB, + rgbToColor, + createBitStream, + writeBits, + readBits, + peekBits, + hasMoreBits, + bitsToPixels, + pixelsToBits, + streamToRecord, + encodeInt, + decodeInt, + encodeBool, + decodeBool, + encodeString, + decodeString, + encodeTimestamp, + decodeTimestamp, + encodeIntArray, + decodeIntArray, + encodeStringArray, + decodeStringArray, +} from './encoding.js'; + +// PNG export/import utilities +export { + // Pure JS (works everywhere) + exportToPngBytes, + exportToPngBytesCompressed, + importFromPngBytes, + // Node.js file operations + saveToPngFile, + loadFromPngFile, + // Sharp integration (optional) + exportWithSharp, + importWithSharp, + // Browser support + exportToBlob, + exportToDataUrl, + exportToCanvas, + importFromCanvas, + downloadPng, +} from './png.js'; diff --git a/packages/spiral-db/src/png.ts b/packages/spiral-db/src/png.ts new file mode 100644 index 000000000..ea2954aa4 --- /dev/null +++ b/packages/spiral-db/src/png.ts @@ -0,0 +1,535 @@ +/** + * PNG Export/Import for SpiralDB + * Supports both Node.js (sharp) and browser (Canvas) environments + */ + +import type { SpiralImage } from './types.js'; +import { createImage } from './image.js'; + +// ============================================================================= +// PNG ENCODING (Pure JavaScript - works everywhere) +// ============================================================================= + +/** + * CRC32 lookup table for PNG + */ +const CRC_TABLE: number[] = []; +for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + CRC_TABLE[n] = c; +} + +function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) { + crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8); + } + return crc ^ 0xffffffff; +} + +/** + * Create a PNG chunk + */ +function createChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + const length = data.length; + + // Chunk: length (4) + type (4) + data + crc (4) + const chunk = new Uint8Array(12 + length); + const view = new DataView(chunk.buffer); + + // Length (big-endian) + view.setUint32(0, length, false); + + // Type + chunk.set(typeBytes, 4); + + // Data + chunk.set(data, 8); + + // CRC (over type + data) + const crcData = new Uint8Array(4 + length); + crcData.set(typeBytes, 0); + crcData.set(data, 4); + view.setUint32(8 + length, crc32(crcData) >>> 0, false); + + return chunk; +} + +/** + * Adler-32 checksum for zlib + */ +function adler32(data: Uint8Array): number { + let a = 1; + let b = 0; + for (let i = 0; i < data.length; i++) { + a = (a + data[i]) % 65521; + b = (b + a) % 65521; + } + return (b << 16) | a; +} + +/** + * Simple zlib compression (store only, no actual compression) + * For small images this is fine; for larger ones, use pako + */ +function zlibCompress(data: Uint8Array): Uint8Array { + // For simplicity, we use uncompressed deflate blocks + // This works but doesn't actually compress + const maxBlockSize = 65535; + const blocks: Uint8Array[] = []; + + for (let i = 0; i < data.length; i += maxBlockSize) { + const blockData = data.slice(i, Math.min(i + maxBlockSize, data.length)); + const isLast = i + maxBlockSize >= data.length; + + // Block header: 1 byte (BFINAL=1 for last, BTYPE=00 for no compression) + const header = isLast ? 0x01 : 0x00; + + // Length and complement + const len = blockData.length; + const nlen = len ^ 0xffff; + + const block = new Uint8Array(5 + blockData.length); + block[0] = header; + block[1] = len & 0xff; + block[2] = (len >> 8) & 0xff; + block[3] = nlen & 0xff; + block[4] = (nlen >> 8) & 0xff; + block.set(blockData, 5); + + blocks.push(block); + } + + // Calculate total size + const totalBlockSize = blocks.reduce((sum, b) => sum + b.length, 0); + + // zlib header (2 bytes) + blocks + adler32 (4 bytes) + const result = new Uint8Array(2 + totalBlockSize + 4); + const view = new DataView(result.buffer); + + // zlib header: CMF=0x78 (deflate, 32K window), FLG=0x01 (no dict, check bits) + result[0] = 0x78; + result[1] = 0x01; + + // Copy blocks + let offset = 2; + for (const block of blocks) { + result.set(block, offset); + offset += block.length; + } + + // Adler-32 checksum (big-endian) + view.setUint32(offset, adler32(data), false); + + return result; +} + +/** + * Export SpiralImage to PNG bytes (pure JavaScript, no dependencies) + */ +export function exportToPngBytes(image: SpiralImage): Uint8Array { + const { width, height } = image; + + // PNG signature + const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + + // IHDR chunk + const ihdrData = new Uint8Array(13); + const ihdrView = new DataView(ihdrData.buffer); + ihdrView.setUint32(0, width, false); // Width + ihdrView.setUint32(4, height, false); // Height + ihdrData[8] = 8; // Bit depth + ihdrData[9] = 2; // Color type (RGB) + ihdrData[10] = 0; // Compression method + ihdrData[11] = 0; // Filter method + ihdrData[12] = 0; // Interlace method + const ihdrChunk = createChunk('IHDR', ihdrData); + + // Create raw image data with filter bytes + // Each row: 1 filter byte + width * 3 RGB bytes + const rawData = new Uint8Array(height * (1 + width * 3)); + let rawOffset = 0; + + for (let y = 0; y < height; y++) { + // Filter byte (0 = no filter) + rawData[rawOffset++] = 0; + + for (let x = 0; x < width; x++) { + const pixelOffset = (y * width + x) * 3; + rawData[rawOffset++] = image.pixels[pixelOffset]; // R + rawData[rawOffset++] = image.pixels[pixelOffset + 1]; // G + rawData[rawOffset++] = image.pixels[pixelOffset + 2]; // B + } + } + + // Compress and create IDAT chunk + const compressedData = zlibCompress(rawData); + const idatChunk = createChunk('IDAT', compressedData); + + // IEND chunk + const iendChunk = createChunk('IEND', new Uint8Array(0)); + + // Combine all chunks + const png = new Uint8Array( + signature.length + ihdrChunk.length + idatChunk.length + iendChunk.length + ); + let offset = 0; + png.set(signature, offset); + offset += signature.length; + png.set(ihdrChunk, offset); + offset += ihdrChunk.length; + png.set(idatChunk, offset); + offset += idatChunk.length; + png.set(iendChunk, offset); + + return png; +} + +/** + * Export SpiralImage to PNG with pako compression (smaller files) + */ +export async function exportToPngBytesCompressed(image: SpiralImage): Promise { + // Try to use pako for better compression + try { + const pakoModule = await import('pako'); + const pako = pakoModule.default || pakoModule; + + const { width, height } = image; + + const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + + // IHDR + const ihdrData = new Uint8Array(13); + const ihdrView = new DataView(ihdrData.buffer); + ihdrView.setUint32(0, width, false); + ihdrView.setUint32(4, height, false); + ihdrData[8] = 8; + ihdrData[9] = 2; + ihdrData[10] = 0; + ihdrData[11] = 0; + ihdrData[12] = 0; + const ihdrChunk = createChunk('IHDR', ihdrData); + + // Raw data with filter bytes + const rawData = new Uint8Array(height * (1 + width * 3)); + let rawOffset = 0; + for (let y = 0; y < height; y++) { + rawData[rawOffset++] = 0; // Filter byte + for (let x = 0; x < width; x++) { + const pixelOffset = (y * width + x) * 3; + rawData[rawOffset++] = image.pixels[pixelOffset]; + rawData[rawOffset++] = image.pixels[pixelOffset + 1]; + rawData[rawOffset++] = image.pixels[pixelOffset + 2]; + } + } + + // Use pako.deflate which returns zlib-wrapped data (header + compressed + adler32) + const zlibData = pako.deflate(rawData); + + const idatChunk = createChunk('IDAT', zlibData); + const iendChunk = createChunk('IEND', new Uint8Array(0)); + + const png = new Uint8Array( + signature.length + ihdrChunk.length + idatChunk.length + iendChunk.length + ); + let offset = 0; + png.set(signature, offset); + offset += signature.length; + png.set(ihdrChunk, offset); + offset += ihdrChunk.length; + png.set(idatChunk, offset); + offset += idatChunk.length; + png.set(iendChunk, offset); + + return png; + } catch { + // Fall back to uncompressed + return exportToPngBytes(image); + } +} + +// ============================================================================= +// PNG DECODING +// ============================================================================= + +/** + * Parse PNG bytes to SpiralImage + */ +export async function importFromPngBytes(pngData: Uint8Array): Promise { + // Verify PNG signature + const signature = [137, 80, 78, 71, 13, 10, 26, 10]; + for (let i = 0; i < 8; i++) { + if (pngData[i] !== signature[i]) { + throw new Error('Invalid PNG signature'); + } + } + + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = 0; + const idatChunks: Uint8Array[] = []; + + // Parse chunks + let offset = 8; + while (offset < pngData.length) { + const view = new DataView(pngData.buffer, pngData.byteOffset + offset); + const length = view.getUint32(0, false); + const type = String.fromCharCode( + pngData[offset + 4], + pngData[offset + 5], + pngData[offset + 6], + pngData[offset + 7] + ); + const data = pngData.slice(offset + 8, offset + 8 + length); + + if (type === 'IHDR') { + const ihdrView = new DataView(data.buffer, data.byteOffset); + width = ihdrView.getUint32(0, false); + height = ihdrView.getUint32(4, false); + bitDepth = data[8]; + colorType = data[9]; + + if (bitDepth !== 8 || colorType !== 2) { + throw new Error('Only 8-bit RGB PNGs are supported'); + } + } else if (type === 'IDAT') { + idatChunks.push(data); + } else if (type === 'IEND') { + break; + } + + offset += 12 + length; + } + + if (width === 0 || height === 0) { + throw new Error('Invalid PNG: no IHDR chunk'); + } + + // Combine IDAT chunks + const compressedLength = idatChunks.reduce((sum, c) => sum + c.length, 0); + const compressed = new Uint8Array(compressedLength); + let compOffset = 0; + for (const chunk of idatChunks) { + compressed.set(chunk, compOffset); + compOffset += chunk.length; + } + + // Decompress using pako + let rawData: Uint8Array; + try { + const pakoModule = await import('pako'); + const pako = pakoModule.default || pakoModule; + // Decompress the zlib data (includes header) + rawData = pako.inflate(compressed); + } catch (e) { + throw new Error(`PNG decompression failed: ${e}`); + } + + // Parse raw data (with filter bytes) + const image = createImage(width); + if (width !== height || width % 2 === 0) { + throw new Error('SpiralDB requires odd square images'); + } + + let rawOffset = 0; + for (let y = 0; y < height; y++) { + const filterByte = rawData[rawOffset++]; + if (filterByte !== 0) { + throw new Error(`Unsupported PNG filter: ${filterByte}`); + } + + for (let x = 0; x < width; x++) { + const pixelOffset = (y * width + x) * 3; + image.pixels[pixelOffset] = rawData[rawOffset++]; + image.pixels[pixelOffset + 1] = rawData[rawOffset++]; + image.pixels[pixelOffset + 2] = rawData[rawOffset++]; + } + } + + return image; +} + +// ============================================================================= +// FILE OPERATIONS (Node.js) +// ============================================================================= + +/** + * Save SpiralImage to PNG file (Node.js only) + */ +export async function saveToPngFile(image: SpiralImage, filePath: string): Promise { + const fs = await import('fs/promises'); + const pngBytes = await exportToPngBytesCompressed(image); + await fs.writeFile(filePath, pngBytes); +} + +/** + * Load SpiralImage from PNG file (Node.js only) + */ +export async function loadFromPngFile(filePath: string): Promise { + const fs = await import('fs/promises'); + const pngBytes = await fs.readFile(filePath); + return await importFromPngBytes(new Uint8Array(pngBytes)); +} + +// ============================================================================= +// SHARP INTEGRATION (optional, higher quality) +// ============================================================================= + +/** + * Export using sharp (if available) for better compression + */ +export async function exportWithSharp(image: SpiralImage, filePath: string): Promise { + try { + const sharp = (await import('sharp')).default; + + // Create RGB buffer + const buffer = Buffer.from(image.pixels); + + await sharp(buffer, { + raw: { + width: image.width, + height: image.height, + channels: 3, + }, + }) + .png({ compressionLevel: 9 }) + .toFile(filePath); + } catch { + // Fall back to pure JS implementation + await saveToPngFile(image, filePath); + } +} + +/** + * Import using sharp (if available) + */ +export async function importWithSharp(filePath: string): Promise { + try { + const sharp = (await import('sharp')).default; + + const { data, info } = await sharp(filePath) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + if (info.width !== info.height || info.width % 2 === 0) { + throw new Error('SpiralDB requires odd square images'); + } + + return { + width: info.width, + height: info.height, + pixels: new Uint8Array(data), + }; + } catch (error) { + if (String(error).includes('SpiralDB requires')) { + throw error; + } + // Fall back to pure JS implementation + return loadFromPngFile(filePath); + } +} + +// ============================================================================= +// BROWSER SUPPORT +// ============================================================================= + +/** + * Export to Blob (browser) + */ +export function exportToBlob(image: SpiralImage): Blob { + const pngBytes = exportToPngBytes(image); + // Copy to a regular ArrayBuffer to avoid SharedArrayBuffer issues + const buffer = new ArrayBuffer(pngBytes.length); + new Uint8Array(buffer).set(pngBytes); + return new Blob([buffer], { type: 'image/png' }); +} + +/** + * Export to Data URL (browser) + */ +export function exportToDataUrl(image: SpiralImage): string { + const pngBytes = exportToPngBytes(image); + const base64 = btoa(String.fromCharCode(...pngBytes)); + return `data:image/png;base64,${base64}`; +} + +/** + * Export to Canvas (browser) + */ +export function exportToCanvas(image: SpiralImage, canvas: HTMLCanvasElement, scale = 1): void { + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Could not get canvas context'); + + canvas.width = image.width * scale; + canvas.height = image.height * scale; + + const imageData = ctx.createImageData(image.width, image.height); + for (let i = 0; i < image.width * image.height; i++) { + imageData.data[i * 4] = image.pixels[i * 3]; // R + imageData.data[i * 4 + 1] = image.pixels[i * 3 + 1]; // G + imageData.data[i * 4 + 2] = image.pixels[i * 3 + 2]; // B + imageData.data[i * 4 + 3] = 255; // A + } + + // Draw at original size then scale + if (scale === 1) { + ctx.putImageData(imageData, 0, 0); + } else { + // Create temp canvas for scaling + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = image.width; + tempCanvas.height = image.height; + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) throw new Error('Could not get temp canvas context'); + tempCtx.putImageData(imageData, 0, 0); + + // Scale with nearest-neighbor for crisp pixels + ctx.imageSmoothingEnabled = false; + ctx.drawImage(tempCanvas, 0, 0, canvas.width, canvas.height); + } +} + +/** + * Import from Canvas (browser) + */ +export function importFromCanvas(canvas: HTMLCanvasElement): SpiralImage { + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Could not get canvas context'); + + const { width, height } = canvas; + if (width !== height || width % 2 === 0) { + throw new Error('SpiralDB requires odd square images'); + } + + const imageData = ctx.getImageData(0, 0, width, height); + const pixels = new Uint8Array(width * height * 3); + + for (let i = 0; i < width * height; i++) { + pixels[i * 3] = imageData.data[i * 4]; // R + pixels[i * 3 + 1] = imageData.data[i * 4 + 1]; // G + pixels[i * 3 + 2] = imageData.data[i * 4 + 2]; // B + } + + return { width, height, pixels }; +} + +/** + * Download image in browser + */ +export function downloadPng(image: SpiralImage, filename = 'spiraldb.png'): void { + const blob = exportToBlob(image); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/packages/spiral-db/src/schema.ts b/packages/spiral-db/src/schema.ts new file mode 100644 index 000000000..f59a99bb2 --- /dev/null +++ b/packages/spiral-db/src/schema.ts @@ -0,0 +1,181 @@ +/** + * Schema handling for SpiralDB + * Encodes/decodes field definitions in Ring 2 + */ + +import type { SchemaDefinition, FieldDefinition, BitStream, ColorIndex } from './types.js'; +import { createBitStream, writeBits, readBits, bitsToPixels, pixelsToBits } from './encoding.js'; +import { FIELD_TYPE_BITS, BITS_TO_FIELD_TYPE } from './constants.js'; + +/** + * Encode a schema definition to pixels + * Format per field: [type:3bit][maxLength:9bit][nullable:1bit] = 13 bits + */ +export function encodeSchema(schema: SchemaDefinition): ColorIndex[] { + const stream = createBitStream(); + + // Schema version (9 bits) + writeBits(stream, schema.version, 9); + + // Number of fields (6 bits, max 63 fields) + writeBits(stream, schema.fields.length, 6); + + // Each field definition + for (const field of schema.fields) { + // Type (3 bits) + writeBits(stream, FIELD_TYPE_BITS[field.type], 3); + + // Max length (9 bits) + writeBits(stream, field.maxLength, 9); + + // Nullable flag (1 bit) + writeBits(stream, field.nullable ? 1 : 0, 1); + } + + // End marker + writeBits(stream, FIELD_TYPE_BITS['end'], 3); + + return bitsToPixels(stream.bits); +} + +/** + * Decode pixels to a schema definition + */ +export function decodeSchema(pixels: ColorIndex[], fieldNames: string[]): SchemaDefinition { + const bits = pixelsToBits(pixels); + const stream: BitStream = { bits, position: 0 }; + + // Schema version + const version = readBits(stream, 9); + + // Number of fields + const fieldCount = readBits(stream, 6); + + const fields: FieldDefinition[] = []; + for (let i = 0; i < fieldCount; i++) { + // Type + const typeBits = readBits(stream, 3); + const type = BITS_TO_FIELD_TYPE[typeBits]; + + if (type === 'end') break; + + // Max length + const maxLength = readBits(stream, 9); + + // Nullable + const nullable = readBits(stream, 1) === 1; + + fields.push({ + name: fieldNames[i] || `field_${i}`, + type, + maxLength, + nullable, + }); + } + + return { + version, + name: 'decoded_schema', + fields, + }; +} + +/** + * Calculate how many pixels a schema needs + */ +export function getSchemaPixelCount(schema: SchemaDefinition): number { + // Version (9) + field count (6) + fields * 13 + end marker (3) + const totalBits = 9 + 6 + schema.fields.length * 13 + 3; + return Math.ceil(totalBits / 3); +} + +/** + * Create a schema for Todo items + */ +export function createTodoSchema(): SchemaDefinition { + return { + version: 1, + name: 'todo', + fields: [ + { name: 'id', type: 'int', maxLength: 12 }, // 0-4095 + { name: 'status', type: 'int', maxLength: 3 }, // 0-7 + { name: 'priority', type: 'int', maxLength: 3 }, // 0-7 + { name: 'createdAt', type: 'timestamp', maxLength: 24 }, // Days since epoch + { name: 'dueDate', type: 'timestamp', maxLength: 24, nullable: true }, + { name: 'completedAt', type: 'timestamp', maxLength: 24, nullable: true }, + { name: 'title', type: 'string', maxLength: 255 }, + { name: 'description', type: 'string', maxLength: 511, nullable: true }, + { name: 'tags', type: 'array', maxLength: 8 }, // Max 8 tag IDs + ], + }; +} + +/** + * Validate that a record matches a schema + */ +export function validateRecord( + schema: SchemaDefinition, + record: Record +): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + for (const field of schema.fields) { + const value = record[field.name]; + + // Check nullable + if (value === null || value === undefined) { + if (!field.nullable) { + errors.push(`Field '${field.name}' is required`); + } + continue; + } + + // Type-specific validation + switch (field.type) { + case 'int': + if (typeof value !== 'number' || !Number.isInteger(value)) { + errors.push(`Field '${field.name}' must be an integer`); + } else if (value < 0 || value >= 2 ** field.maxLength) { + errors.push(`Field '${field.name}' out of range (max ${2 ** field.maxLength - 1})`); + } + break; + + case 'string': + if (typeof value !== 'string') { + errors.push(`Field '${field.name}' must be a string`); + } else if (value.length > field.maxLength) { + errors.push(`Field '${field.name}' too long (max ${field.maxLength} chars)`); + } + break; + + case 'bool': + if (typeof value !== 'boolean') { + errors.push(`Field '${field.name}' must be a boolean`); + } + break; + + case 'timestamp': + if (!(value instanceof Date)) { + errors.push(`Field '${field.name}' must be a Date`); + } + break; + + case 'array': + if (!Array.isArray(value)) { + errors.push(`Field '${field.name}' must be an array`); + } else if (value.length > field.maxLength) { + errors.push(`Field '${field.name}' has too many items (max ${field.maxLength})`); + } + break; + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Get field names from a schema + */ +export function getFieldNames(schema: SchemaDefinition): string[] { + return schema.fields.map((f) => f.name); +} diff --git a/packages/spiral-db/src/spiral.test.ts b/packages/spiral-db/src/spiral.test.ts new file mode 100644 index 000000000..3dc8bf797 --- /dev/null +++ b/packages/spiral-db/src/spiral.test.ts @@ -0,0 +1,252 @@ +/** + * SpiralDB Tests + */ + +import { describe, it, expect } from 'vitest'; +import { + SpiralDB, + createTodoSchema, + spiralToXY, + xyToSpiral, + getRingInfo, + getImageSizeForRing, + visualizeImageEmoji, + visualizeSpiralOrder, +} from './index.js'; + +describe('Spiral Coordinates', () => { + it('should convert index 0 to center', () => { + const point = spiralToXY(0, 5); + expect(point).toEqual({ x: 2, y: 2 }); + }); + + it('should handle first ring correctly', () => { + // Ring 1 starts at index 1 and has 8 pixels + const points = []; + for (let i = 1; i <= 8; i++) { + points.push(spiralToXY(i, 5)); + } + // Should form a square around center + expect(points).toHaveLength(8); + }); + + it('should round-trip coordinates', () => { + const size = 11; + for (let i = 0; i < 50; i++) { + const point = spiralToXY(i, size); + const index = xyToSpiral(point.x, point.y, size); + expect(index).toBe(i); + } + }); + + it('should calculate ring info correctly', () => { + expect(getRingInfo(0)).toEqual({ + ring: 0, + startIndex: 0, + endIndex: 0, + pixelCount: 1, + }); + expect(getRingInfo(1)).toEqual({ + ring: 1, + startIndex: 1, + endIndex: 8, + pixelCount: 8, + }); + expect(getRingInfo(2)).toEqual({ + ring: 2, + startIndex: 9, + endIndex: 24, + pixelCount: 16, + }); + }); + + it('should calculate image size for ring', () => { + expect(getImageSizeForRing(0)).toBe(1); + expect(getImageSizeForRing(1)).toBe(3); + expect(getImageSizeForRing(2)).toBe(5); + expect(getImageSizeForRing(5)).toBe(11); + }); +}); + +describe('SpiralDB', () => { + it('should create database with schema', () => { + const db = new SpiralDB({ + schema: createTodoSchema(), + }); + + const stats = db.getStats(); + expect(stats.totalRecords).toBe(0); + expect(stats.imageSize).toBeGreaterThan(0); + }); + + it('should insert and read a record', () => { + const db = new SpiralDB({ + schema: createTodoSchema(), + }); + + const todo = { + id: 0, + status: 0, + priority: 1, + createdAt: new Date('2025-01-01'), + dueDate: new Date('2025-12-31'), + completedAt: null, + title: 'Test Todo', + description: 'A test description', + tags: [1, 2], + }; + + const insertResult = db.insert(todo); + expect(insertResult.success).toBe(true); + expect(insertResult.recordId).toBe(0); + + const readResult = db.read(0); + expect(readResult.success).toBe(true); + expect(readResult.record?.data.title).toBe('Test Todo'); + expect(readResult.record?.data.priority).toBe(1); + }); + + it('should handle multiple inserts', () => { + const db = new SpiralDB({ + schema: createTodoSchema(), + }); + + for (let i = 0; i < 5; i++) { + const result = db.insert({ + id: 0, + status: 0, + priority: i % 3, + createdAt: new Date(), + dueDate: null, + completedAt: null, + title: `Todo ${i}`, + description: null, + tags: [], + }); + expect(result.success).toBe(true); + } + + const stats = db.getStats(); + expect(stats.totalRecords).toBe(5); + expect(stats.activeRecords).toBe(5); + }); + + it('should delete records', () => { + const db = new SpiralDB({ + schema: createTodoSchema(), + }); + + db.insert({ + id: 0, + status: 0, + priority: 1, + createdAt: new Date(), + dueDate: null, + completedAt: null, + title: 'To Delete', + description: null, + tags: [], + }); + + const deleteResult = db.delete(0); + expect(deleteResult.success).toBe(true); + + const readResult = db.read(0); + expect(readResult.success).toBe(false); + expect(readResult.error).toBe('Record has been deleted'); + }); + + it('should mark records as completed', () => { + const db = new SpiralDB({ + schema: createTodoSchema(), + }); + + db.insert({ + id: 0, + status: 0, + priority: 1, + createdAt: new Date(), + dueDate: null, + completedAt: null, + title: 'To Complete', + description: null, + tags: [], + }); + + const completeResult = db.complete(0); + expect(completeResult.success).toBe(true); + + const readResult = db.read(0); + expect(readResult.success).toBe(true); + expect(readResult.record?.meta.status).toBe('completed'); + }); + + it('should compact database', () => { + const db = new SpiralDB({ + schema: createTodoSchema(), + }); + + // Insert several records + for (let i = 0; i < 10; i++) { + db.insert({ + id: 0, + status: 0, + priority: 1, + createdAt: new Date(), + dueDate: null, + completedAt: null, + title: `Todo ${i}`, + description: null, + tags: [], + }); + } + + // Delete half + for (let i = 0; i < 5; i++) { + db.delete(i); + } + + const statsBefore = db.getStats(); + expect(statsBefore.deletedRecords).toBe(5); + + // Compact + db.compact(); + + const statsAfter = db.getStats(); + expect(statsAfter.activeRecords).toBe(5); + expect(statsAfter.deletedRecords).toBe(0); + }); + + it('should visualize database', () => { + const db = new SpiralDB({ + schema: createTodoSchema(), + }); + + db.insert({ + id: 0, + status: 0, + priority: 1, + createdAt: new Date(), + dueDate: null, + completedAt: null, + title: 'Visual Test', + description: null, + tags: [], + }); + + const image = db.getImage(); + const emoji = visualizeImageEmoji(image); + + // Should contain valid emoji characters + expect(emoji).toContain('⬜'); // Magic byte (white) + expect(emoji.split('\n').length).toBe(image.height); + }); +}); + +describe('Visualization', () => { + it('should visualize spiral order', () => { + const visual = visualizeSpiralOrder(5); + expect(visual).toContain('0'); // Center + expect(visual.split('\n')).toHaveLength(5); + }); +}); diff --git a/packages/spiral-db/src/spiral.ts b/packages/spiral-db/src/spiral.ts new file mode 100644 index 000000000..237fb36b9 --- /dev/null +++ b/packages/spiral-db/src/spiral.ts @@ -0,0 +1,189 @@ +/** + * Spiral Coordinate System + * Converts between linear indices and 2D spiral coordinates + */ + +import type { Point, RingInfo } from './types.js'; + +/** + * Calculate which ring a given pixel index belongs to + * Ring 0: index 0 (1 pixel) + * Ring 1: indices 1-8 (8 pixels) + * Ring 2: indices 9-24 (16 pixels) + * Ring n: starts at (2n-1)², has 8n pixels + */ +export function getRingForIndex(index: number): number { + if (index === 0) return 0; + // Ring n contains indices from (2n-1)² to (2n+1)² - 1 + // Solving: (2n-1)² <= index gives n <= (sqrt(index) + 1) / 2 + return Math.floor((Math.sqrt(index) + 1) / 2); +} + +/** + * Get info about a specific ring + */ +export function getRingInfo(ring: number): RingInfo { + if (ring === 0) { + return { ring: 0, startIndex: 0, endIndex: 0, pixelCount: 1 }; + } + const startIndex = (2 * ring - 1) ** 2; + const pixelCount = 8 * ring; + const endIndex = startIndex + pixelCount - 1; + return { ring, startIndex, endIndex, pixelCount }; +} + +/** + * Get the image size needed to contain a given ring + */ +export function getImageSizeForRing(ring: number): number { + return 2 * ring + 1; +} + +/** + * Get the total number of pixels up to and including a ring + */ +export function getTotalPixelsForRing(ring: number): number { + return (2 * ring + 1) ** 2; +} + +/** + * Convert a linear spiral index to 2D coordinates + * The spiral starts at center and goes: right → up → left → down → right... + * + * For a 5x5 image (center=2), ring 1 looks like: + * x: 0 1 2 3 4 + * y:0 . 6 5 4 . + * y:1 7 . . . 3 + * y:2 8 . 0 . 2 + * y:3 9 . . . 1 + * y:4 . A B C . (A=10, B=11, C=12 would be ring 2) + */ +export function spiralToXY(index: number, imageSize: number): Point { + const center = Math.floor(imageSize / 2); + + if (index === 0) { + return { x: center, y: center }; + } + + // Find which ring this index belongs to + const ring = getRingForIndex(index); + const ringInfo = getRingInfo(ring); + const posInRing = index - ringInfo.startIndex; + + // Each ring has 4 sides, each side has 2*ring pixels + const sideLength = 2 * ring; + const side = Math.floor(posInRing / sideLength); + const offset = posInRing % sideLength; + + // Ring starts at (center+ring, center+ring-1) and goes: + // Side 0: up along right edge (y decreases) + // Side 1: left along top edge (x decreases) + // Side 2: down along left edge (y increases) + // Side 3: right along bottom edge (x increases) + switch (side) { + case 0: // Right side, going up: x=center+ring, y from center+ring-1 to center-ring + return { x: center + ring, y: center + ring - 1 - offset }; + case 1: // Top side, going left: y=center-ring, x from center+ring-1 to center-ring + return { x: center + ring - 1 - offset, y: center - ring }; + case 2: // Left side, going down: x=center-ring, y from center-ring+1 to center+ring + return { x: center - ring, y: center - ring + 1 + offset }; + case 3: // Bottom side, going right: y=center+ring, x from center-ring+1 to center+ring-1 + return { x: center - ring + 1 + offset, y: center + ring }; + default: + throw new Error( + `Invalid side: ${side} for index ${index} (ring=${ring}, posInRing=${posInRing})` + ); + } +} + +/** + * Convert 2D coordinates to a linear spiral index + * Spiral pattern: center → right → up → left → down → right... + */ +export function xyToSpiral(x: number, y: number, imageSize: number): number { + const center = Math.floor(imageSize / 2); + const dx = x - center; + const dy = y - center; + + // Ring 0 (center) + if (dx === 0 && dy === 0) { + return 0; + } + + // Determine which ring based on Chebyshev distance + const ring = Math.max(Math.abs(dx), Math.abs(dy)); + const ringStart = (2 * ring - 1) ** 2; + const sideLength = 2 * ring; + + // The spiral goes: right side up, top side left, left side down, bottom side right + // Side 0: x = ring, y from ring-1 down to -ring (right side, going up) + // Side 1: y = -ring, x from ring-1 down to -ring (top side, going left) + // Side 2: x = -ring, y from -ring+1 up to ring (left side, going down) + // Side 3: y = ring, x from -ring+1 up to ring-1 (bottom side, going right) + + if (dx === ring && dy <= ring - 1 && dy >= -ring) { + // Right side (going up): y goes from ring-1 to -ring + const offset = ring - 1 - dy; + return ringStart + offset; + } else if (dy === -ring && dx <= ring - 1 && dx >= -ring) { + // Top side (going left): x goes from ring-1 to -ring + const offset = ring - 1 - dx; + return ringStart + sideLength + offset; + } else if (dx === -ring && dy >= -ring + 1 && dy <= ring) { + // Left side (going down): y goes from -ring+1 to ring + const offset = dy - (-ring + 1); + return ringStart + 2 * sideLength + offset; + } else { + // Bottom side (going right): x goes from -ring+1 to ring-1 + const offset = dx - (-ring + 1); + return ringStart + 3 * sideLength + offset; + } +} + +/** + * Get all pixel indices in a specific ring + */ +export function getRingPixels(ring: number): number[] { + const info = getRingInfo(ring); + const pixels: number[] = []; + for (let i = info.startIndex; i <= info.endIndex; i++) { + pixels.push(i); + } + return pixels; +} + +/** + * Find the next available ring that can fit a record of given length + */ +export function findSpaceForRecord( + currentRing: number, + currentOffset: number, + recordLength: number +): { ring: number; offset: number; needsExpansion: boolean } { + let ring = currentRing; + let offset = currentOffset; + + while (true) { + const ringInfo = getRingInfo(ring); + const availableInRing = ringInfo.pixelCount - offset; + + if (availableInRing >= recordLength) { + return { ring, offset, needsExpansion: ring > currentRing }; + } + + // Move to next ring + ring++; + offset = 0; + } +} + +/** + * Calculate coordinates for all pixels in a range + */ +export function getSpiralRange(startIndex: number, length: number, imageSize: number): Point[] { + const points: Point[] = []; + for (let i = 0; i < length; i++) { + points.push(spiralToXY(startIndex + i, imageSize)); + } + return points; +} diff --git a/packages/spiral-db/src/types.ts b/packages/spiral-db/src/types.ts new file mode 100644 index 000000000..365a876e7 --- /dev/null +++ b/packages/spiral-db/src/types.ts @@ -0,0 +1,165 @@ +/** + * SpiralDB Types + * Pixel-based spiral database for storing structured data in images + */ + +// ============================================================================= +// COLOR SYSTEM (3-bit = 8 colors) +// ============================================================================= + +export type ColorIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; + +export interface RGB { + r: number; + g: number; + b: number; +} + +export interface ColorDefinition { + index: ColorIndex; + name: string; + rgb: RGB; + bits: [number, number, number]; // 3-bit representation +} + +// ============================================================================= +// SCHEMA SYSTEM +// ============================================================================= + +export type FieldType = + | 'end' // 000 - End/Padding + | 'int' // 001 - Integer (variable length) + | 'string' // 010 - UTF-8 String + | 'bool' // 011 - Boolean + | 'timestamp' // 100 - Unix timestamp + | 'ref' // 101 - Reference/Pointer + | 'array' // 110 - Array of values + | 'reserved'; // 111 - Reserved for future + +export interface FieldDefinition { + name: string; + type: FieldType; + maxLength: number; // in bits for numbers, chars for strings + nullable?: boolean; +} + +export interface SchemaDefinition { + version: number; + name: string; + fields: FieldDefinition[]; +} + +// ============================================================================= +// RECORD SYSTEM +// ============================================================================= + +export type RecordStatus = 'active' | 'completed' | 'deleted' | 'archived'; + +export interface RecordMetadata { + id: number; + status: RecordStatus; + createdAt: number; // ring index when created + ringStart: number; // which ring this record starts in + pixelOffset: number; // offset within ring + length: number; // total pixels used +} + +export interface SpiralRecord { + meta: RecordMetadata; + data: T; +} + +// ============================================================================= +// DATABASE STRUCTURE +// ============================================================================= + +export interface DatabaseHeader { + magic: number; // Magic byte for validation + version: number; // Schema version (0-511) + flags: DatabaseFlags; + recordCount: number; // Total records (including deleted) + activeRecordCount: number; // Only active records + currentRing: number; // Highest ring with data + checksum: number; // Simple checksum for validation +} + +export interface DatabaseFlags { + isEmpty: boolean; + isReadable: boolean; + isWriting: boolean; + hasError: boolean; + isCompressed: boolean; +} + +export interface MasterIndex { + records: IndexEntry[]; + deletedIds: Set; + nextId: number; +} + +export interface IndexEntry { + id: number; + ring: number; + offset: number; + length: number; + status: RecordStatus; +} + +// ============================================================================= +// IMAGE REPRESENTATION +// ============================================================================= + +export interface SpiralImage { + width: number; + height: number; + pixels: Uint8Array; // RGB values (3 bytes per pixel) +} + +export interface Point { + x: number; + y: number; +} + +export interface RingInfo { + ring: number; + startIndex: number; + endIndex: number; + pixelCount: number; +} + +// ============================================================================= +// DATABASE OPTIONS +// ============================================================================= + +export interface SpiralDBOptions { + schema: SchemaDefinition; + initialSize?: number; // Initial image size (must be odd) + compression?: boolean; // Use gzip compression for strings +} + +export interface WriteResult { + success: boolean; + recordId?: number; + error?: string; + newImageSize?: number; // If image was expanded +} + +export interface ReadResult { + success: boolean; + record?: SpiralRecord; + error?: string; +} + +// ============================================================================= +// SERIALIZATION +// ============================================================================= + +export interface BitStream { + bits: number[]; + position: number; +} + +export interface SerializedRecord { + pixels: ColorIndex[]; + bitLength: number; +} diff --git a/packages/spiral-db/tsconfig.json b/packages/spiral-db/tsconfig.json new file mode 100644 index 000000000..ead46673c --- /dev/null +++ b/packages/spiral-db/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*", "*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/spiral-db/vitest.config.ts b/packages/spiral-db/vitest.config.ts new file mode 100644 index 000000000..e28945bf4 --- /dev/null +++ b/packages/spiral-db/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});