@affectively/dash 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -0
- package/dist/src/auth/manager.d.ts +8 -0
- package/dist/src/auth/manager.js +39 -0
- package/dist/src/engine/ai.d.ts +10 -0
- package/dist/src/engine/ai.js +76 -0
- package/dist/src/engine/sqlite.d.ts +34 -0
- package/dist/src/engine/sqlite.js +256 -0
- package/dist/src/engine/vec_extension.d.ts +5 -0
- package/dist/src/engine/vec_extension.js +10 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.js +7 -0
- package/dist/src/mcp/server.d.ts +8 -0
- package/dist/src/mcp/server.js +87 -0
- package/dist/src/reactivity/signal.d.ts +3 -0
- package/dist/src/reactivity/signal.js +31 -0
- package/dist/src/schema/lens.d.ts +29 -0
- package/dist/src/schema/lens.js +122 -0
- package/dist/src/sync/backup.d.ts +12 -0
- package/dist/src/sync/backup.js +44 -0
- package/dist/src/sync/connection.d.ts +20 -0
- package/dist/src/sync/connection.js +50 -0
- package/dist/src/sync/hybrid-provider.d.ts +28 -0
- package/dist/src/sync/hybrid-provider.js +194 -0
- package/dist/src/sync/provider.d.ts +11 -0
- package/dist/src/sync/provider.js +67 -0
- package/dist/src/sync/verify.d.ts +1 -0
- package/dist/src/sync/verify.js +23 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +81 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { dash } from '../engine/sqlite.js';
|
|
4
|
+
export class DashMCPServer {
|
|
5
|
+
server;
|
|
6
|
+
constructor() {
|
|
7
|
+
this.server = new Server({
|
|
8
|
+
name: 'dash-local-db',
|
|
9
|
+
version: '2.0.0',
|
|
10
|
+
}, {
|
|
11
|
+
capabilities: {
|
|
12
|
+
resources: {},
|
|
13
|
+
tools: {},
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
this.setupHandlers();
|
|
17
|
+
}
|
|
18
|
+
setupHandlers() {
|
|
19
|
+
// List Resources: Expose all tables as resources
|
|
20
|
+
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
21
|
+
await dash.ready();
|
|
22
|
+
// Get all table names
|
|
23
|
+
const tables = dash.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
|
|
24
|
+
return {
|
|
25
|
+
resources: tables.map((t) => ({
|
|
26
|
+
uri: `mcp://local/dash/${t.name}`,
|
|
27
|
+
name: t.name,
|
|
28
|
+
mimeType: 'application/json',
|
|
29
|
+
description: `Table: ${t.name}`,
|
|
30
|
+
})),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
// Read Resource: Return table content
|
|
34
|
+
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
35
|
+
const uri = request.params.uri;
|
|
36
|
+
const tableName = uri.split('/').pop();
|
|
37
|
+
if (!tableName)
|
|
38
|
+
throw new Error('Invalid URI');
|
|
39
|
+
const rows = dash.execute(`SELECT * FROM ${tableName} LIMIT 100`);
|
|
40
|
+
return {
|
|
41
|
+
contents: [{
|
|
42
|
+
uri: uri,
|
|
43
|
+
mimeType: 'application/json',
|
|
44
|
+
text: JSON.stringify(rows, null, 2),
|
|
45
|
+
}],
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
// List Tools: Expose Semantic Search
|
|
49
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
50
|
+
return {
|
|
51
|
+
tools: [{
|
|
52
|
+
name: 'semantic_search',
|
|
53
|
+
description: 'Search the Dash database using vector similarity (semantic search).',
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
query: { type: 'string', description: 'The search query string.' },
|
|
58
|
+
},
|
|
59
|
+
required: ['query'],
|
|
60
|
+
},
|
|
61
|
+
}],
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
// Call Tool: Execute Semantic Search
|
|
65
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
66
|
+
if (request.params.name === 'semantic_search') {
|
|
67
|
+
const query = request.params.arguments?.query;
|
|
68
|
+
if (!query)
|
|
69
|
+
throw new Error('Missing query argument');
|
|
70
|
+
const results = await dash.search(query);
|
|
71
|
+
return {
|
|
72
|
+
content: [{
|
|
73
|
+
type: 'text',
|
|
74
|
+
text: JSON.stringify(results, null, 2),
|
|
75
|
+
}],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
throw new Error('Tool not found');
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Method to connect to a transport (e.g., SSE for web)
|
|
82
|
+
async connect(transport) {
|
|
83
|
+
await this.server.connect(transport);
|
|
84
|
+
console.log('Dash MCP Server Connected');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export const mcpServer = new DashMCPServer();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { signal } from '@preact/signals-core';
|
|
2
|
+
import { dash } from '../engine/sqlite.js';
|
|
3
|
+
export function liveQuery(sql, bind) {
|
|
4
|
+
const data = signal([]);
|
|
5
|
+
const run = async () => {
|
|
6
|
+
await dash.ready();
|
|
7
|
+
try {
|
|
8
|
+
const result = dash.execute(sql, bind);
|
|
9
|
+
data.value = result;
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
console.error('Dash LiveQuery Error:', e);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
// Run immediately
|
|
16
|
+
run();
|
|
17
|
+
// Subscribe to database changes
|
|
18
|
+
// Parse table name naively
|
|
19
|
+
const match = sql.match(/FROM\s+([a-zA-Z0-9_]+)/i);
|
|
20
|
+
const table = match ? match[1] : null;
|
|
21
|
+
if (table) {
|
|
22
|
+
dash.subscribe(table, () => {
|
|
23
|
+
run();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
console.warn('Dash: Could not identify table for liveQuery', sql);
|
|
28
|
+
}
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
export { signal, effect, computed } from '@preact/signals-core';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type LensOp = {
|
|
2
|
+
kind: 'rename';
|
|
3
|
+
source: string;
|
|
4
|
+
target: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'hoist';
|
|
7
|
+
host: string;
|
|
8
|
+
name: string;
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'wrap';
|
|
11
|
+
name: string;
|
|
12
|
+
};
|
|
13
|
+
export interface SchemaLens {
|
|
14
|
+
from: number;
|
|
15
|
+
to: number;
|
|
16
|
+
ops: LensOp[];
|
|
17
|
+
}
|
|
18
|
+
export declare class LensEngine {
|
|
19
|
+
private lenses;
|
|
20
|
+
private currentVersion;
|
|
21
|
+
constructor(currentVersion: number);
|
|
22
|
+
registerLens(from: number, to: number, ops: LensOp[]): void;
|
|
23
|
+
private findPath;
|
|
24
|
+
migrate(data: any, from: number, to?: number): any;
|
|
25
|
+
private normalizeOps;
|
|
26
|
+
private invertOp;
|
|
27
|
+
private applyOp;
|
|
28
|
+
}
|
|
29
|
+
export declare const schema: LensEngine;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Native Bidirectional Lens Implementation
|
|
2
|
+
// Inspired by Project Cambria
|
|
3
|
+
export class LensEngine {
|
|
4
|
+
lenses = [];
|
|
5
|
+
currentVersion;
|
|
6
|
+
constructor(currentVersion) {
|
|
7
|
+
this.currentVersion = currentVersion;
|
|
8
|
+
}
|
|
9
|
+
registerLens(from, to, ops) {
|
|
10
|
+
this.lenses.push({ from, to, ops });
|
|
11
|
+
}
|
|
12
|
+
// Find path from V_start to V_end
|
|
13
|
+
findPath(from, to) {
|
|
14
|
+
// BFS to find shortest path
|
|
15
|
+
const queue = [{ version: from, path: [] }];
|
|
16
|
+
const visited = new Set([from]);
|
|
17
|
+
while (queue.length > 0) {
|
|
18
|
+
const { version, path } = queue.shift();
|
|
19
|
+
if (version === to)
|
|
20
|
+
return path;
|
|
21
|
+
// Find neighbors
|
|
22
|
+
// Forward lenses
|
|
23
|
+
const forward = this.lenses.filter(l => l.from === version);
|
|
24
|
+
for (const lens of forward) {
|
|
25
|
+
if (!visited.has(lens.to)) {
|
|
26
|
+
visited.add(lens.to);
|
|
27
|
+
queue.push({ version: lens.to, path: [...path, lens] });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Reverse lenses (Implicit)
|
|
31
|
+
const backward = this.lenses.filter(l => l.to === version);
|
|
32
|
+
for (const lens of backward) {
|
|
33
|
+
if (!visited.has(lens.from)) {
|
|
34
|
+
visited.add(lens.from);
|
|
35
|
+
// Create synthetic reverse lens
|
|
36
|
+
const reverseLens = {
|
|
37
|
+
from: lens.to,
|
|
38
|
+
to: lens.from,
|
|
39
|
+
ops: [...lens.ops].reverse() // Ops must be reversed!
|
|
40
|
+
};
|
|
41
|
+
queue.push({ version: lens.from, path: [...path, reverseLens] });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`No translation path found from v${from} to v${to}`);
|
|
46
|
+
}
|
|
47
|
+
migrate(data, from, to = this.currentVersion) {
|
|
48
|
+
if (from === to)
|
|
49
|
+
return data;
|
|
50
|
+
const path = this.findPath(from, to);
|
|
51
|
+
let doc = JSON.parse(JSON.stringify(data)); // Deep clone to avoid mutation
|
|
52
|
+
for (const step of path) {
|
|
53
|
+
const isForward = step.from < step.to; // Heuristic: Assuming localized version jumps
|
|
54
|
+
// Actually we should just apply the ops in the step direction.
|
|
55
|
+
// But we need to know if the step is a "Forward Lens" or a "Reverse Lens"
|
|
56
|
+
// because ops like 'rename' need to swap source/target if reversed.
|
|
57
|
+
// The path finding creates "synthetic" lenses for reverse steps where from > to.
|
|
58
|
+
// So we just need to apply 'step.ops' carefully.
|
|
59
|
+
// But wait! in findPath, I just reversed the ARRAY of ops.
|
|
60
|
+
// I ALSO need to invert the OPERATORS themselves.
|
|
61
|
+
const opsToApply = this.normalizeOps(step);
|
|
62
|
+
for (const op of opsToApply) {
|
|
63
|
+
doc = this.applyOp(doc, op);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return doc;
|
|
67
|
+
}
|
|
68
|
+
normalizeOps(step) {
|
|
69
|
+
// If this lens was registered as from=1, to=2
|
|
70
|
+
// And we are using it as from=1, to=2. No change.
|
|
71
|
+
// If we are traversing 2->1.
|
|
72
|
+
// The findPath constructed a lens { from: 2, to: 1, ops: ops.reverse() }
|
|
73
|
+
// But the ops inside are still { kind: 'rename', source: 'old', target: 'new' }
|
|
74
|
+
// We need to invert them to { kind: 'rename', source: 'new', target: 'old' }
|
|
75
|
+
// We need to know the *registred* direction to know if inversion is needed?
|
|
76
|
+
// Actually findPath constructs a fresh object. We can check if it exists in this.lenses.
|
|
77
|
+
const registered = this.lenses.find(l => l.from === step.from && l.to === step.to);
|
|
78
|
+
if (registered) {
|
|
79
|
+
return step.ops; // Direct match, use as is
|
|
80
|
+
}
|
|
81
|
+
// Must be a reverse step constructed by findPath
|
|
82
|
+
return step.ops.map(op => this.invertOp(op));
|
|
83
|
+
}
|
|
84
|
+
invertOp(op) {
|
|
85
|
+
switch (op.kind) {
|
|
86
|
+
case 'rename': return { kind: 'rename', source: op.target, target: op.source };
|
|
87
|
+
// Invert Hoist: Wrap?
|
|
88
|
+
// Hoist: host='meta', name='key' (moves doc.meta.key -> doc.key)
|
|
89
|
+
// Inverse: Wrap (moves doc.key -> doc.meta.key)
|
|
90
|
+
// Use 'wrap' as simplified inverse of simple hoist
|
|
91
|
+
case 'hoist': return { kind: 'wrap', name: op.name }; // Simplified
|
|
92
|
+
case 'wrap': return { kind: 'hoist', host: 'container', name: op.name }; // Naive inverse
|
|
93
|
+
default: return op;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
applyOp(doc, op) {
|
|
97
|
+
switch (op.kind) {
|
|
98
|
+
case 'rename': {
|
|
99
|
+
if (doc[op.source] !== undefined) {
|
|
100
|
+
doc[op.target] = doc[op.source];
|
|
101
|
+
delete doc[op.source];
|
|
102
|
+
}
|
|
103
|
+
return doc;
|
|
104
|
+
}
|
|
105
|
+
case 'hoist': {
|
|
106
|
+
// Extract property from scalar host? Or object?
|
|
107
|
+
// Assume simple object flattening: { data: { val: 1 } } -> hoist 'data', 'val' -> { val: 1 }
|
|
108
|
+
// doc[op.host][op.name] -> doc[op.name]
|
|
109
|
+
if (doc[op.host] && doc[op.host][op.name] !== undefined) {
|
|
110
|
+
doc[op.name] = doc[op.host][op.name];
|
|
111
|
+
delete doc[op.host][op.name];
|
|
112
|
+
if (Object.keys(doc[op.host]).length === 0)
|
|
113
|
+
delete doc[op.host];
|
|
114
|
+
}
|
|
115
|
+
return doc;
|
|
116
|
+
}
|
|
117
|
+
default: return doc;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export const schema = new LensEngine(1);
|
|
122
|
+
// Default to v1 until configured
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
export interface CloudStorageAdapter {
|
|
3
|
+
upload(name: string, data: Uint8Array): Promise<void>;
|
|
4
|
+
download(name: string): Promise<Uint8Array | null>;
|
|
5
|
+
}
|
|
6
|
+
export declare function generateKey(): Promise<CryptoKey>;
|
|
7
|
+
export declare function exportKey(key: CryptoKey): Promise<string>;
|
|
8
|
+
export declare function importKey(jwkString: string): Promise<CryptoKey>;
|
|
9
|
+
export declare function encrypt(data: Uint8Array, key: CryptoKey): Promise<Uint8Array>;
|
|
10
|
+
export declare function decrypt(data: Uint8Array, key: CryptoKey): Promise<Uint8Array>;
|
|
11
|
+
export declare function backup(roomName: string, doc: Y.Doc, key: CryptoKey, adapter: CloudStorageAdapter): Promise<void>;
|
|
12
|
+
export declare function restore(roomName: string, doc: Y.Doc, key: CryptoKey, adapter: CloudStorageAdapter): Promise<void>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
// AES-GCM Encryption
|
|
3
|
+
export async function generateKey() {
|
|
4
|
+
return await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
5
|
+
}
|
|
6
|
+
export async function exportKey(key) {
|
|
7
|
+
const exported = await crypto.subtle.exportKey('jwk', key);
|
|
8
|
+
return JSON.stringify(exported);
|
|
9
|
+
}
|
|
10
|
+
export async function importKey(jwkString) {
|
|
11
|
+
const jwk = JSON.parse(jwkString);
|
|
12
|
+
return await crypto.subtle.importKey('jwk', jwk, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
|
|
13
|
+
}
|
|
14
|
+
export async function encrypt(data, key) {
|
|
15
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
16
|
+
// Ensure we have a proper ArrayBuffer (not SharedArrayBuffer) by creating a copy
|
|
17
|
+
const dataCopy = new Uint8Array(data);
|
|
18
|
+
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, dataCopy);
|
|
19
|
+
// Combine IV and Encrypted Data
|
|
20
|
+
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
|
21
|
+
combined.set(iv);
|
|
22
|
+
combined.set(new Uint8Array(encrypted), iv.length);
|
|
23
|
+
return combined;
|
|
24
|
+
}
|
|
25
|
+
export async function decrypt(data, key) {
|
|
26
|
+
const iv = data.slice(0, 12);
|
|
27
|
+
const ciphertext = data.slice(12);
|
|
28
|
+
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
|
|
29
|
+
return new Uint8Array(decrypted);
|
|
30
|
+
}
|
|
31
|
+
export async function backup(roomName, doc, key, adapter) {
|
|
32
|
+
const state = Y.encodeStateAsUpdate(doc);
|
|
33
|
+
const encrypted = await encrypt(state, key);
|
|
34
|
+
await adapter.upload(`backup_${roomName}`, encrypted);
|
|
35
|
+
}
|
|
36
|
+
export async function restore(roomName, doc, key, adapter) {
|
|
37
|
+
const encrypted = await adapter.download(`backup_${roomName}`);
|
|
38
|
+
if (!encrypted) {
|
|
39
|
+
console.warn(`[CloudBackup] No backup found for room ${roomName}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const state = await decrypt(encrypted, key);
|
|
43
|
+
Y.applyUpdate(doc, state);
|
|
44
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
export interface SyncConnectionConfig {
|
|
3
|
+
type?: 'webrtc' | 'webtransport';
|
|
4
|
+
roomName: string;
|
|
5
|
+
doc: Y.Doc;
|
|
6
|
+
signalingServers?: string[];
|
|
7
|
+
password?: string | null;
|
|
8
|
+
url?: string;
|
|
9
|
+
awareness?: any;
|
|
10
|
+
}
|
|
11
|
+
export declare class SyncConnection {
|
|
12
|
+
private provider;
|
|
13
|
+
roomName: string;
|
|
14
|
+
constructor(config: SyncConnectionConfig);
|
|
15
|
+
disconnect(): void;
|
|
16
|
+
get awareness(): any;
|
|
17
|
+
}
|
|
18
|
+
export declare class WebRTCConnection extends SyncConnection {
|
|
19
|
+
constructor(config: Omit<SyncConnectionConfig, 'type'>);
|
|
20
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { WebrtcProvider } from 'y-webrtc';
|
|
2
|
+
import { HybridProvider } from './hybrid-provider.js';
|
|
3
|
+
export class SyncConnection {
|
|
4
|
+
provider;
|
|
5
|
+
roomName;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.roomName = config.roomName;
|
|
8
|
+
const type = config.type || 'webrtc'; // Default to WebRTC for backward compatibility
|
|
9
|
+
if (type === 'webtransport') {
|
|
10
|
+
if (!config.url) {
|
|
11
|
+
throw new Error('WebTransport requires a "url" in config');
|
|
12
|
+
}
|
|
13
|
+
this.provider = new HybridProvider(config.url, config.roomName, config.doc, { awareness: config.awareness });
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
// Default signaling servers if none provided
|
|
17
|
+
// Note: In production, users should host their own signaling servers
|
|
18
|
+
const signaling = config.signalingServers || [
|
|
19
|
+
'wss://signaling.yjs.dev',
|
|
20
|
+
'wss://y-webrtc-signaling-eu.herokuapp.com',
|
|
21
|
+
'wss://y-webrtc-signaling-us.herokuapp.com'
|
|
22
|
+
];
|
|
23
|
+
this.provider = new WebrtcProvider(config.roomName, config.doc, {
|
|
24
|
+
signaling,
|
|
25
|
+
password: config.password || undefined,
|
|
26
|
+
awareness: config.awareness
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
this.provider.on('synced', (event) => {
|
|
30
|
+
console.log(`[${type === 'webtransport' ? 'WebTransport' : 'WebRTC'}] Room: ${this.roomName}, Synced: ${event.synced}`);
|
|
31
|
+
});
|
|
32
|
+
// Relay status from provider if available
|
|
33
|
+
this.provider.on('status', (event) => {
|
|
34
|
+
// loose typing for event forwarding
|
|
35
|
+
console.log(`[${type}] Status update:`, event);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
disconnect() {
|
|
39
|
+
this.provider.destroy();
|
|
40
|
+
}
|
|
41
|
+
get awareness() {
|
|
42
|
+
return this.provider.awareness;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Backward compatibility alias
|
|
46
|
+
export class WebRTCConnection extends SyncConnection {
|
|
47
|
+
constructor(config) {
|
|
48
|
+
super({ ...config, type: 'webrtc' });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
import { Observable } from 'lib0/observable';
|
|
3
|
+
import * as awarenessProtocol from 'y-protocols/awareness';
|
|
4
|
+
export declare class HybridProvider extends Observable<string> {
|
|
5
|
+
private doc;
|
|
6
|
+
private ws;
|
|
7
|
+
private wt;
|
|
8
|
+
private connected;
|
|
9
|
+
private url;
|
|
10
|
+
private roomName;
|
|
11
|
+
awareness: awarenessProtocol.Awareness;
|
|
12
|
+
private writer;
|
|
13
|
+
private highFrequencyMode;
|
|
14
|
+
constructor(url: string, roomName: string, doc: Y.Doc, { awareness }?: {
|
|
15
|
+
awareness?: awarenessProtocol.Awareness | undefined;
|
|
16
|
+
});
|
|
17
|
+
private connect;
|
|
18
|
+
private connectWebSocket;
|
|
19
|
+
private getAuthToken;
|
|
20
|
+
enterHighFrequencyMode(): Promise<void>;
|
|
21
|
+
private sendSyncStep1;
|
|
22
|
+
private readLoop;
|
|
23
|
+
private handleMessage;
|
|
24
|
+
private send;
|
|
25
|
+
private onDocUpdate;
|
|
26
|
+
private onAwarenessUpdate;
|
|
27
|
+
destroy(): void;
|
|
28
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Observable } from 'lib0/observable';
|
|
2
|
+
import * as encoding from 'lib0/encoding';
|
|
3
|
+
import * as decoding from 'lib0/decoding';
|
|
4
|
+
// @ts-ignore
|
|
5
|
+
import * as syncProtocol from 'y-protocols/sync';
|
|
6
|
+
// @ts-ignore
|
|
7
|
+
import * as awarenessProtocol from 'y-protocols/awareness';
|
|
8
|
+
export class HybridProvider extends Observable {
|
|
9
|
+
doc;
|
|
10
|
+
ws = null;
|
|
11
|
+
wt = null;
|
|
12
|
+
connected = false;
|
|
13
|
+
url;
|
|
14
|
+
roomName;
|
|
15
|
+
awareness;
|
|
16
|
+
writer = null;
|
|
17
|
+
// "High Frequency" mode uses WebTransport for ephemeral data
|
|
18
|
+
highFrequencyMode = false;
|
|
19
|
+
constructor(url, roomName, doc, { awareness = new awarenessProtocol.Awareness(doc) } = {}) {
|
|
20
|
+
super();
|
|
21
|
+
this.url = url;
|
|
22
|
+
this.roomName = roomName;
|
|
23
|
+
this.doc = doc;
|
|
24
|
+
this.awareness = awareness;
|
|
25
|
+
this.doc.on('update', this.onDocUpdate.bind(this));
|
|
26
|
+
this.awareness.on('update', this.onAwarenessUpdate.bind(this));
|
|
27
|
+
this.connect();
|
|
28
|
+
}
|
|
29
|
+
connect() {
|
|
30
|
+
this.connectWebSocket();
|
|
31
|
+
}
|
|
32
|
+
connectWebSocket() {
|
|
33
|
+
// Protocol: switch http/https to ws/wss
|
|
34
|
+
// Protocol: switch http/https to ws/wss
|
|
35
|
+
// 1. Get Token
|
|
36
|
+
// Ideally await this before connecting, but construction is sync.
|
|
37
|
+
// We'll make connect async internally or fire-and-forget with token wait.
|
|
38
|
+
this.getAuthToken().then(token => {
|
|
39
|
+
const wsUrl = this.url.replace(/^http/, 'ws') + '/sync/' + this.roomName + '?token=' + token;
|
|
40
|
+
this.ws = new WebSocket(wsUrl);
|
|
41
|
+
this.ws.binaryType = 'arraybuffer';
|
|
42
|
+
this.ws.onopen = () => {
|
|
43
|
+
this.connected = true;
|
|
44
|
+
this.emit('status', [{ status: 'connected' }]);
|
|
45
|
+
this.sendSyncStep1();
|
|
46
|
+
};
|
|
47
|
+
// ... rest of event handlers
|
|
48
|
+
this.ws.onmessage = (event) => {
|
|
49
|
+
this.handleMessage(new Uint8Array(event.data));
|
|
50
|
+
};
|
|
51
|
+
this.ws.onclose = () => {
|
|
52
|
+
this.connected = false;
|
|
53
|
+
this.ws = null;
|
|
54
|
+
this.emit('status', [{ status: 'disconnected' }]);
|
|
55
|
+
// Reconnect logic
|
|
56
|
+
setTimeout(() => this.connectWebSocket(), 3000);
|
|
57
|
+
};
|
|
58
|
+
this.ws.onerror = (err) => {
|
|
59
|
+
console.error('WebSocket error', err);
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async getAuthToken() {
|
|
64
|
+
// Lazy load auth to avoid circular dependency issues if any
|
|
65
|
+
const { auth } = await import('../auth/manager.js');
|
|
66
|
+
// For MVP, we assume the Relay allows any DID to connect if they sign it.
|
|
67
|
+
// In prod, you'd know the Relay's DID.
|
|
68
|
+
return auth.issueRoomToken("did:web:relay.buley.dev", this.roomName);
|
|
69
|
+
}
|
|
70
|
+
async enterHighFrequencyMode() {
|
|
71
|
+
if (this.highFrequencyMode)
|
|
72
|
+
return;
|
|
73
|
+
try {
|
|
74
|
+
// Connect WebTransport
|
|
75
|
+
// Just an example URL construction, depends on server routing
|
|
76
|
+
const wtUrl = this.url + '/sync/' + this.roomName;
|
|
77
|
+
this.wt = new WebTransport(wtUrl);
|
|
78
|
+
await this.wt.ready;
|
|
79
|
+
const stream = await this.wt.createBidirectionalStream();
|
|
80
|
+
this.writer = stream.writable;
|
|
81
|
+
this.readLoop(stream.readable);
|
|
82
|
+
this.highFrequencyMode = true;
|
|
83
|
+
console.log('Upgraded to WebTransport (High Frequency Mode)');
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
console.error('Failed to upgrade to WebTransport', e);
|
|
87
|
+
// Fallback: stay on WebSocket
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
sendSyncStep1() {
|
|
91
|
+
const encoder = encoding.createEncoder();
|
|
92
|
+
encoding.writeVarUint(encoder, 0); // MessageType.Sync
|
|
93
|
+
syncProtocol.writeSyncStep1(encoder, this.doc);
|
|
94
|
+
this.send(encoding.toUint8Array(encoder));
|
|
95
|
+
}
|
|
96
|
+
async readLoop(readable) {
|
|
97
|
+
let reader;
|
|
98
|
+
try {
|
|
99
|
+
// @ts-ignore
|
|
100
|
+
reader = readable.getReader({ mode: 'byob' });
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
reader = readable.getReader();
|
|
104
|
+
}
|
|
105
|
+
// Pre-allocate buffer (64KB) to emulate Zero-Copy / WASM Heap view
|
|
106
|
+
// In a future update, this could be a view into `sqlite3.wasm.memory`.
|
|
107
|
+
let buffer = new Uint8Array(65536);
|
|
108
|
+
try {
|
|
109
|
+
while (true) {
|
|
110
|
+
let result;
|
|
111
|
+
if (reader.readAtLeast) {
|
|
112
|
+
// BYOB Reader
|
|
113
|
+
// We pass the view. The reader detaches it and returns a new view (potentially same backing store).
|
|
114
|
+
result = await reader.read(new Uint8Array(buffer.buffer, 0, buffer.byteLength));
|
|
115
|
+
if (result.value) {
|
|
116
|
+
buffer = result.value; // Update our reference to the valid buffer
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// Default Reader
|
|
121
|
+
result = await reader.read();
|
|
122
|
+
}
|
|
123
|
+
if (result.done)
|
|
124
|
+
break;
|
|
125
|
+
if (result.value) {
|
|
126
|
+
// Processing: In true zero-copy, we'd pass the offset/length to SQL directly.
|
|
127
|
+
// Here we pass the view.
|
|
128
|
+
this.handleMessage(result.value);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
console.error('WebTransport Read loop error', e);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
this.highFrequencyMode = false;
|
|
137
|
+
this.writer = null;
|
|
138
|
+
this.wt = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
handleMessage(buf) {
|
|
142
|
+
// Simple protocol: First byte is message type (0=Sync, 1=Awareness)
|
|
143
|
+
// In strict binary mode, we might need a more robust header.
|
|
144
|
+
// For now assuming Y-protocol encoding.
|
|
145
|
+
// Note: The Relay DO sends raw messages back.
|
|
146
|
+
const decoder = decoding.createDecoder(buf);
|
|
147
|
+
const messageType = decoding.readVarUint(decoder);
|
|
148
|
+
switch (messageType) {
|
|
149
|
+
case 0: // Sync
|
|
150
|
+
syncProtocol.readSyncMessage(decoder, encoding.createEncoder(), this.doc, this);
|
|
151
|
+
break;
|
|
152
|
+
case 1: // Awareness
|
|
153
|
+
awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(decoder), this);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async send(message) {
|
|
158
|
+
if (this.writer && this.highFrequencyMode) {
|
|
159
|
+
// High Priority -> WebTransport
|
|
160
|
+
const writer = this.writer.getWriter();
|
|
161
|
+
await writer.write(message);
|
|
162
|
+
writer.releaseLock();
|
|
163
|
+
}
|
|
164
|
+
else if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
165
|
+
// Default -> WebSocket
|
|
166
|
+
this.ws.send(message);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
onDocUpdate(update, origin) {
|
|
170
|
+
if (origin === this)
|
|
171
|
+
return;
|
|
172
|
+
const encoder = encoding.createEncoder();
|
|
173
|
+
encoding.writeVarUint(encoder, 0); // MessageType.Sync
|
|
174
|
+
syncProtocol.writeUpdate(encoder, update);
|
|
175
|
+
this.send(encoding.toUint8Array(encoder));
|
|
176
|
+
}
|
|
177
|
+
onAwarenessUpdate({ added, updated, removed }, origin) {
|
|
178
|
+
if (origin === this)
|
|
179
|
+
return;
|
|
180
|
+
const changedClients = added.concat(updated).concat(removed);
|
|
181
|
+
const encoder = encoding.createEncoder();
|
|
182
|
+
encoding.writeVarUint(encoder, 1); // MessageType.Awareness
|
|
183
|
+
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients));
|
|
184
|
+
this.send(encoding.toUint8Array(encoder));
|
|
185
|
+
}
|
|
186
|
+
destroy() {
|
|
187
|
+
this.doc.off('update', this.onDocUpdate);
|
|
188
|
+
this.awareness.off('update', this.onAwarenessUpdate);
|
|
189
|
+
this.ws?.close();
|
|
190
|
+
this.wt?.close();
|
|
191
|
+
this.connected = false;
|
|
192
|
+
super.destroy();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
export declare class YjsSqliteProvider {
|
|
3
|
+
private doc;
|
|
4
|
+
private roomName;
|
|
5
|
+
whenSynced: Promise<void>;
|
|
6
|
+
constructor(roomName: string, doc: Y.Doc);
|
|
7
|
+
private persistUpdate;
|
|
8
|
+
private loadState;
|
|
9
|
+
private toBase64;
|
|
10
|
+
private fromBase64;
|
|
11
|
+
}
|