mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 01:19:40 +02:00
Add spiral-db integration to Contacts as the third app using pixel-based spiral visualization. Contacts are encoded with name, company, city, and email/phone flags. Changes: - Add createContactSchema() to spiral-db with bool fields for hasEmail/hasPhone and nullable company/city - Create Svelte 5 spiral store with importContacts from contactsStore - Add SpiralCanvas component and /spiral route - Wire up navigation (Ctrl+5) with auto-import on mount - Favorites show as starred entries with gold border Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
221 lines
6.1 KiB
TypeScript
221 lines
6.1 KiB
TypeScript
/**
|
|
* 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
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a schema for Contact items (Contacts app)
|
|
*/
|
|
export function createContactSchema(): SchemaDefinition {
|
|
return {
|
|
version: 1,
|
|
name: 'contact',
|
|
fields: [
|
|
{ name: 'id', type: 'int', maxLength: 12 }, // 0-4095
|
|
{ name: 'status', type: 'int', maxLength: 3 }, // 0=active, 2=favorite, 4=archived
|
|
{ name: 'hasEmail', type: 'bool', maxLength: 1 },
|
|
{ name: 'hasPhone', type: 'bool', maxLength: 1 },
|
|
{ name: 'createdAt', type: 'timestamp', maxLength: 24 },
|
|
{ name: 'name', type: 'string', maxLength: 100 },
|
|
{ name: 'company', type: 'string', maxLength: 100, nullable: true },
|
|
{ name: 'city', type: 'string', maxLength: 50, nullable: true },
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a schema for Quote items (Zitare app)
|
|
*/
|
|
export function createQuoteSchema(): SchemaDefinition {
|
|
return {
|
|
version: 1,
|
|
name: 'quote',
|
|
fields: [
|
|
{ name: 'id', type: 'int', maxLength: 12 }, // 0-4095
|
|
{ name: 'status', type: 'int', maxLength: 3 }, // 0=active, 2=favorited, 4=removed
|
|
{ name: 'category', type: 'int', maxLength: 4 }, // 10 categories (0-15)
|
|
{ name: 'language', type: 'int', maxLength: 3 }, // 6 languages (0-7)
|
|
{ name: 'createdAt', type: 'timestamp', maxLength: 24 },
|
|
{ name: 'quoteId', type: 'string', maxLength: 100 }, // Reference to content package
|
|
{ name: 'author', type: 'string', maxLength: 100 },
|
|
{ name: 'text', type: 'string', maxLength: 255 },
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|