rspace-online/lib/holon-service.ts

264 lines
8.2 KiB
TypeScript

/**
* Holon Service — Local-first data layer for H3 geospatial holons.
*
* Replaces the dead HoloSphereService (GunDB stub) with Automerge-backed
* CRDT storage via window.__rspaceOfflineRuntime.
*
* Data model:
* Registry doc: `{space}:holons:registry:{holonId}` — HolonData
* Lens doc: `{space}:holons:lenses:{holonId}` — all lens data
*
* H3 hierarchy methods use pure h3-js (no external service).
* Designed so data could bridge to AD4M Perspectives later.
*/
import * as h3 from 'h3-js';
import type { DocSchema, DocumentId } from '../shared/local-first/document';
import type { RSpaceOfflineRuntime } from '../shared/local-first/runtime';
// ── Types ──
export interface HolonData {
id: string;
name: string;
description: string;
latitude: number;
longitude: number;
resolution: number;
timestamp: number;
}
export interface HolonLens {
name: string;
data: Record<string, any>;
}
export interface HolonConnection {
id: string;
name: string;
type: 'federation' | 'reference';
targetSpace: string;
status: 'connected' | 'disconnected' | 'error';
}
// ── Standard lenses ──
export const STANDARD_LENSES = [
'active_users', 'users', 'rankings', 'stats', 'tasks', 'progress',
'events', 'activities', 'items', 'shopping', 'active_items',
'proposals', 'offers', 'requests', 'checklists', 'roles',
] as const;
export type LensName = (typeof STANDARD_LENSES)[number] | string;
// ── Lens icons ──
export const LENS_ICONS: Record<string, string> = {
active_users: '👥', users: '👤', rankings: '🏆', stats: '📊',
tasks: '✅', progress: '📈', events: '📅', activities: '🔔',
items: '📦', shopping: '🛒', active_items: '⚡', proposals: '💡',
offers: '🤝', requests: '📋', checklists: '☑️', roles: '🎭',
};
// ── Resolution names ──
const RESOLUTION_NAMES = [
'Country', 'State/Province', 'Metropolitan Area', 'City', 'District',
'Neighborhood', 'Block', 'Building', 'Room', 'Desk', 'Chair', 'Point',
];
const RESOLUTION_DESCRIPTIONS = [
'Country level — covers entire countries',
'State/Province level — covers states and provinces',
'Metropolitan area level — covers large urban areas',
'City level — covers individual cities',
'District level — covers city districts',
'Neighborhood level — covers neighborhoods',
'Block level — covers city blocks',
'Building level — covers individual buildings',
'Room level — covers individual rooms',
'Desk level — covers individual desks',
'Chair level — covers individual chairs',
'Point level — covers individual points',
];
export function getResolutionName(resolution: number): string {
if (resolution < 0) return 'Workspace / Group';
return RESOLUTION_NAMES[resolution] ?? `Level ${resolution}`;
}
export function getResolutionDescription(resolution: number): string {
if (resolution < 0) return 'Non-geospatial workspace or group';
return RESOLUTION_DESCRIPTIONS[resolution] ?? `Geographic level ${resolution}`;
}
// ── Automerge schemas ──
interface HolonRegistryDoc {
holon: HolonData;
}
interface HolonLensesDoc {
lenses: Record<string, Record<string, any>>;
}
const registrySchema: DocSchema<HolonRegistryDoc> = {
module: 'holons',
collection: 'registry',
version: 1,
init: () => ({
holon: { id: '', name: '', description: '', latitude: 0, longitude: 0, resolution: 0, timestamp: 0 },
}),
};
const lensesSchema: DocSchema<HolonLensesDoc> = {
module: 'holons',
collection: 'lenses',
version: 1,
init: () => ({ lenses: {} }),
};
// ── Helper: get runtime ──
function getRuntime(): RSpaceOfflineRuntime | null {
return (window as any).__rspaceOfflineRuntime ?? null;
}
function getSpace(): string {
return getRuntime()?.space ?? 'demo';
}
// ── H3 validation ──
export function isValidHolonId(id: string): boolean {
if (!id || !id.trim()) return false;
const trimmed = id.trim();
try { if (h3.isValidCell(trimmed)) return true; } catch { /* not h3 */ }
if (/^\d{6,20}$/.test(trimmed)) return true;
if (/^[a-zA-Z0-9_-]{3,50}$/.test(trimmed)) return true;
return false;
}
export function isH3CellId(id: string): boolean {
if (!id || !id.trim()) return false;
try { return h3.isValidCell(id.trim()); } catch { return false; }
}
// ── H3 hierarchy (pure h3-js) ──
export function getHolonHierarchy(holonId: string): { parent?: string; children: string[] } {
try {
const resolution = h3.getResolution(holonId);
const parent = resolution > 0 ? h3.cellToParent(holonId, resolution - 1) : undefined;
const children = resolution < 15 ? h3.cellToChildren(holonId, resolution + 1) : [];
return { parent, children };
} catch {
return { children: [] };
}
}
export function getHolonScalespace(holonId: string): string[] {
try {
const resolution = h3.getResolution(holonId);
const scales: string[] = [holonId];
let current = holonId;
for (let r = resolution - 1; r >= 0; r--) {
current = h3.cellToParent(current, r);
scales.unshift(current);
}
return scales;
} catch {
return [];
}
}
// ── CRDT-backed data operations ──
/** Get or create the registry doc for a holon. */
async function ensureRegistryDoc(holonId: string): Promise<DocumentId> {
const runtime = getRuntime();
if (!runtime) throw new Error('Offline runtime not available');
const docId = `${getSpace()}:holons:registry:${holonId}` as DocumentId;
await runtime.subscribe(docId, registrySchema);
return docId;
}
/** Get or create the lenses doc for a holon. */
async function ensureLensesDoc(holonId: string): Promise<DocumentId> {
const runtime = getRuntime();
if (!runtime) throw new Error('Offline runtime not available');
const docId = `${getSpace()}:holons:lenses:${holonId}` as DocumentId;
await runtime.subscribe(docId, lensesSchema);
return docId;
}
/** Save holon metadata to Automerge. */
export async function saveHolon(data: HolonData): Promise<void> {
const runtime = getRuntime();
if (!runtime) return;
const docId = await ensureRegistryDoc(data.id);
runtime.change<HolonRegistryDoc>(docId, 'Update holon metadata', (doc) => {
doc.holon.id = data.id;
doc.holon.name = data.name;
doc.holon.description = data.description;
doc.holon.latitude = data.latitude;
doc.holon.longitude = data.longitude;
doc.holon.resolution = data.resolution;
doc.holon.timestamp = data.timestamp;
});
}
/** Load holon metadata from Automerge. */
export async function loadHolon(holonId: string): Promise<HolonData | null> {
const runtime = getRuntime();
if (!runtime) return null;
const docId = await ensureRegistryDoc(holonId);
const doc = runtime.get<HolonRegistryDoc>(docId);
if (!doc || !doc.holon.id) return null;
return { ...doc.holon };
}
/** Put data into a lens. */
export async function putLensData(holonId: string, lens: string, data: Record<string, any>): Promise<void> {
const runtime = getRuntime();
if (!runtime) return;
const docId = await ensureLensesDoc(holonId);
runtime.change<HolonLensesDoc>(docId, `Update lens: ${lens}`, (doc) => {
if (!doc.lenses[lens]) doc.lenses[lens] = {};
Object.assign(doc.lenses[lens], data);
});
}
/** Get data from a lens. */
export async function getLensData(holonId: string, lens: string): Promise<Record<string, any> | null> {
const runtime = getRuntime();
if (!runtime) return null;
const docId = await ensureLensesDoc(holonId);
const doc = runtime.get<HolonLensesDoc>(docId);
if (!doc?.lenses?.[lens]) return null;
return { ...doc.lenses[lens] };
}
/** Get all lenses with data for a holon. */
export async function getAvailableLenses(holonId: string): Promise<string[]> {
const runtime = getRuntime();
if (!runtime) return [];
const docId = await ensureLensesDoc(holonId);
const doc = runtime.get<HolonLensesDoc>(docId);
if (!doc?.lenses) return [];
return Object.keys(doc.lenses).filter((k) => {
const v = doc.lenses[k];
return v && Object.keys(v).length > 0;
});
}
/** Subscribe to changes on a holon's lens data. Returns unsubscribe function. */
export function subscribeLenses(holonId: string, cb: (lenses: Record<string, Record<string, any>>) => void): () => void {
const runtime = getRuntime();
if (!runtime) return () => {};
const docId = `${getSpace()}:holons:lenses:${holonId}` as DocumentId;
return runtime.onChange<HolonLensesDoc>(docId, (doc) => {
cb(doc.lenses ?? {});
});
}