feat(spiral-db): add pixel-based spiral database package

Implement SpiralDB - a novel data storage format that encodes structured
data into PNG images using an 8-color palette. Data grows in a spiral
pattern from the center outward, enabling infinite expansion.

Features:
- 8-color palette (3-bit per pixel) for compression robustness
- Spiral coordinate system with ring-based organization
- Schema-based serialization (int, bool, string, timestamp, arrays)
- Record management with CRUD operations and status tracking
- PNG export/import with pako compression
- Browser support (Canvas, Blob, DataURL)
- Todo schema as reference implementation

Storage structure:
- Ring 0: Magic byte (validation)
- Ring 1: Database header (version, flags, counts)
- Ring 2: Schema definition
- Ring 3+: Master index (spans multiple rings if needed)
- Ring 4+: Record data
This commit is contained in:
Till-JS 2026-02-17 10:42:09 +01:00
parent 9704e88e78
commit f1518e8c39
14 changed files with 3046 additions and 0 deletions

152
packages/spiral-db/demo.ts Normal file
View file

@ -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!');

View file

@ -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"
}

View file

@ -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<ColorIndex, ColorDefinition> = {
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<string, ColorIndex> = {
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<FieldType, number> = {
end: 0b000,
int: 0b001,
string: 0b010,
bool: 0b011,
timestamp: 0b100,
ref: 0b101,
array: 0b110,
reserved: 0b111,
};
export const BITS_TO_FIELD_TYPE: Record<number, FieldType> = {
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<RecordStatus, number> = {
active: 0b000, // Black
completed: 0b010, // Green
deleted: 0b100, // Red
archived: 0b110, // Yellow
};
export const BITS_TO_STATUS: Record<number, RecordStatus> = {
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

View file

@ -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<T extends Record<string, unknown> = Record<string, unknown>> {
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<string, unknown> = {};
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<T> {
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<T> = {
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<T>): 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<T>[] {
const records: SpiralRecord<T>[] = [];
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<T>({
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<T extends Record<string, unknown>>(
image: SpiralImage,
schema: SchemaDefinition
): SpiralDB<T> {
const db = new SpiralDB<T>({ 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;
}
}
}
}

View file

@ -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;
}

View file

@ -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<ColorIndex, string> = {
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');
}

View file

@ -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';

View file

@ -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<Uint8Array> {
// 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<SpiralImage> {
// 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<void> {
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<SpiralImage> {
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<void> {
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<SpiralImage> {
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);
}

View file

@ -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<string, unknown>
): { 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);
}

View file

@ -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);
});
});

View file

@ -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;
}

View file

@ -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<T = unknown> {
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<number>;
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<T> {
success: boolean;
record?: SpiralRecord<T>;
error?: string;
}
// =============================================================================
// SERIALIZATION
// =============================================================================
export interface BitStream {
bits: number[];
position: number;
}
export interface SerializedRecord {
pixels: ColorIndex[];
bitLength: number;
}

View file

@ -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"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});