mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
✨ 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:
parent
9704e88e78
commit
f1518e8c39
14 changed files with 3046 additions and 0 deletions
152
packages/spiral-db/demo.ts
Normal file
152
packages/spiral-db/demo.ts
Normal 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!');
|
||||||
54
packages/spiral-db/package.json
Normal file
54
packages/spiral-db/package.json
Normal 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"
|
||||||
|
}
|
||||||
121
packages/spiral-db/src/constants.ts
Normal file
121
packages/spiral-db/src/constants.ts
Normal 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
|
||||||
664
packages/spiral-db/src/database.ts
Normal file
664
packages/spiral-db/src/database.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
293
packages/spiral-db/src/encoding.ts
Normal file
293
packages/spiral-db/src/encoding.ts
Normal 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;
|
||||||
|
}
|
||||||
231
packages/spiral-db/src/image.ts
Normal file
231
packages/spiral-db/src/image.ts
Normal 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');
|
||||||
|
}
|
||||||
180
packages/spiral-db/src/index.ts
Normal file
180
packages/spiral-db/src/index.ts
Normal 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';
|
||||||
535
packages/spiral-db/src/png.ts
Normal file
535
packages/spiral-db/src/png.ts
Normal 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);
|
||||||
|
}
|
||||||
181
packages/spiral-db/src/schema.ts
Normal file
181
packages/spiral-db/src/schema.ts
Normal 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);
|
||||||
|
}
|
||||||
252
packages/spiral-db/src/spiral.test.ts
Normal file
252
packages/spiral-db/src/spiral.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
189
packages/spiral-db/src/spiral.ts
Normal file
189
packages/spiral-db/src/spiral.ts
Normal 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;
|
||||||
|
}
|
||||||
165
packages/spiral-db/src/types.ts
Normal file
165
packages/spiral-db/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
20
packages/spiral-db/tsconfig.json
Normal file
20
packages/spiral-db/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
9
packages/spiral-db/vitest.config.ts
Normal file
9
packages/spiral-db/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue