ai-database 2.1.1 → 2.3.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/CHANGELOG.md +47 -1
- package/README.md +1063 -186
- package/dist/actions.d.ts +2 -2
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +1 -1
- package/dist/actions.js.map +1 -1
- package/dist/ai-promise-db.d.ts +52 -23
- package/dist/ai-promise-db.d.ts.map +1 -1
- package/dist/ai-promise-db.js +185 -164
- package/dist/ai-promise-db.js.map +1 -1
- package/dist/authorization.d.ts.map +1 -1
- package/dist/authorization.js +38 -30
- package/dist/authorization.js.map +1 -1
- package/dist/cascade-orchestrator.d.ts +404 -0
- package/dist/cascade-orchestrator.d.ts.map +1 -0
- package/dist/cascade-orchestrator.js +828 -0
- package/dist/cascade-orchestrator.js.map +1 -0
- package/dist/cascade-write-strategy.d.ts +584 -0
- package/dist/cascade-write-strategy.d.ts.map +1 -0
- package/dist/cascade-write-strategy.js +590 -0
- package/dist/cascade-write-strategy.js.map +1 -0
- package/dist/ch-adapter.d.ts +358 -0
- package/dist/ch-adapter.d.ts.map +1 -0
- package/dist/ch-adapter.js +929 -0
- package/dist/ch-adapter.js.map +1 -0
- package/dist/client/index.d.ts +42 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +43 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client.d.ts +266 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +81 -0
- package/dist/client.js.map +1 -0
- package/dist/constants.d.ts +64 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +52 -2
- package/dist/constants.js.map +1 -1
- package/dist/dataloader.d.ts +99 -0
- package/dist/dataloader.d.ts.map +1 -0
- package/dist/dataloader.js +225 -0
- package/dist/dataloader.js.map +1 -0
- package/dist/db-provider-port.d.ts +501 -0
- package/dist/db-provider-port.d.ts.map +1 -0
- package/dist/db-provider-port.js +113 -0
- package/dist/db-provider-port.js.map +1 -0
- package/dist/digital-objects-provider.d.ts +49 -0
- package/dist/digital-objects-provider.d.ts.map +1 -0
- package/dist/digital-objects-provider.js +55 -0
- package/dist/digital-objects-provider.js.map +1 -0
- package/dist/do-sqlite-adapter.d.ts +402 -0
- package/dist/do-sqlite-adapter.d.ts.map +1 -0
- package/dist/do-sqlite-adapter.js +745 -0
- package/dist/do-sqlite-adapter.js.map +1 -0
- package/dist/docs-rels/custom-types.d.ts +134 -0
- package/dist/docs-rels/custom-types.d.ts.map +1 -0
- package/dist/docs-rels/custom-types.js +70 -0
- package/dist/docs-rels/custom-types.js.map +1 -0
- package/dist/docs-rels/index.d.ts +16 -0
- package/dist/docs-rels/index.d.ts.map +1 -0
- package/dist/docs-rels/index.js +16 -0
- package/dist/docs-rels/index.js.map +1 -0
- package/dist/docs-rels/migrations/index.d.ts +30 -0
- package/dist/docs-rels/migrations/index.d.ts.map +1 -0
- package/dist/docs-rels/migrations/index.js +128 -0
- package/dist/docs-rels/migrations/index.js.map +1 -0
- package/dist/docs-rels/schema.d.ts +2961 -0
- package/dist/docs-rels/schema.d.ts.map +1 -0
- package/dist/docs-rels/schema.js +244 -0
- package/dist/docs-rels/schema.js.map +1 -0
- package/dist/durable-clickhouse.d.ts.map +1 -1
- package/dist/durable-clickhouse.js +16 -13
- package/dist/durable-clickhouse.js.map +1 -1
- package/dist/durable-promise.d.ts.map +1 -1
- package/dist/durable-promise.js +34 -15
- package/dist/durable-promise.js.map +1 -1
- package/dist/errors.d.ts +127 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +210 -0
- package/dist/errors.js.map +1 -0
- package/dist/eventbridge.d.ts +117 -0
- package/dist/eventbridge.d.ts.map +1 -0
- package/dist/eventbridge.js +238 -0
- package/dist/eventbridge.js.map +1 -0
- package/dist/events.d.ts +2 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +1 -1
- package/dist/events.js.map +1 -1
- package/dist/execution-queue.d.ts.map +1 -1
- package/dist/execution-queue.js +4 -5
- package/dist/execution-queue.js.map +1 -1
- package/dist/index.d.ts +37 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +112 -6
- package/dist/index.js.map +1 -1
- package/dist/linguistic.d.ts +3 -108
- package/dist/linguistic.d.ts.map +1 -1
- package/dist/linguistic.js +3 -372
- package/dist/linguistic.js.map +1 -1
- package/dist/logger.d.ts +132 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +137 -0
- package/dist/logger.js.map +1 -0
- package/dist/memory-provider.d.ts +129 -0
- package/dist/memory-provider.d.ts.map +1 -1
- package/dist/memory-provider.js +592 -257
- package/dist/memory-provider.js.map +1 -1
- package/dist/pg-adapter.d.ts +424 -0
- package/dist/pg-adapter.d.ts.map +1 -0
- package/dist/pg-adapter.js +921 -0
- package/dist/pg-adapter.js.map +1 -0
- package/dist/pipelines-iceberg-emitter.d.ts +327 -0
- package/dist/pipelines-iceberg-emitter.d.ts.map +1 -0
- package/dist/pipelines-iceberg-emitter.js +351 -0
- package/dist/pipelines-iceberg-emitter.js.map +1 -0
- package/dist/provider-capabilities.d.ts +146 -0
- package/dist/provider-capabilities.d.ts.map +1 -0
- package/dist/provider-capabilities.js +214 -0
- package/dist/provider-capabilities.js.map +1 -0
- package/dist/rdb-provider-adapter.d.ts +195 -0
- package/dist/rdb-provider-adapter.d.ts.map +1 -0
- package/dist/rdb-provider-adapter.js +291 -0
- package/dist/rdb-provider-adapter.js.map +1 -0
- package/dist/schema/cascade.d.ts +49 -10
- package/dist/schema/cascade.d.ts.map +1 -1
- package/dist/schema/cascade.js +491 -273
- package/dist/schema/cascade.js.map +1 -1
- package/dist/schema/definition-caches.d.ts +24 -0
- package/dist/schema/definition-caches.d.ts.map +1 -0
- package/dist/schema/definition-caches.js +26 -0
- package/dist/schema/definition-caches.js.map +1 -0
- package/dist/schema/dependency-graph.d.ts +45 -0
- package/dist/schema/dependency-graph.d.ts.map +1 -0
- package/dist/schema/dependency-graph.js +47 -0
- package/dist/schema/dependency-graph.js.map +1 -0
- package/dist/schema/diff.d.ts +103 -0
- package/dist/schema/diff.d.ts.map +1 -0
- package/dist/schema/diff.js +329 -0
- package/dist/schema/diff.js.map +1 -0
- package/dist/schema/entity-operations.d.ts +99 -0
- package/dist/schema/entity-operations.d.ts.map +1 -0
- package/dist/schema/entity-operations.js +818 -0
- package/dist/schema/entity-operations.js.map +1 -0
- package/dist/schema/generation-context.d.ts +202 -0
- package/dist/schema/generation-context.d.ts.map +1 -0
- package/dist/schema/generation-context.js +393 -0
- package/dist/schema/generation-context.js.map +1 -0
- package/dist/schema/index.d.ts +32 -34
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +462 -519
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/migration.d.ts +205 -0
- package/dist/schema/migration.d.ts.map +1 -0
- package/dist/schema/migration.js +327 -0
- package/dist/schema/migration.js.map +1 -0
- package/dist/schema/nl-query-generator.d.ts +68 -0
- package/dist/schema/nl-query-generator.d.ts.map +1 -0
- package/dist/schema/nl-query-generator.js +362 -0
- package/dist/schema/nl-query-generator.js.map +1 -0
- package/dist/schema/nl-query.d.ts +65 -0
- package/dist/schema/nl-query.d.ts.map +1 -0
- package/dist/schema/nl-query.js +178 -0
- package/dist/schema/nl-query.js.map +1 -0
- package/dist/schema/parse.d.ts.map +1 -1
- package/dist/schema/parse.js +152 -89
- package/dist/schema/parse.js.map +1 -1
- package/dist/schema/provider.d.ts +38 -0
- package/dist/schema/provider.d.ts.map +1 -1
- package/dist/schema/provider.js +15 -7
- package/dist/schema/provider.js.map +1 -1
- package/dist/schema/resolve.d.ts +46 -5
- package/dist/schema/resolve.d.ts.map +1 -1
- package/dist/schema/resolve.js +334 -117
- package/dist/schema/resolve.js.map +1 -1
- package/dist/schema/search-utils.d.ts +76 -0
- package/dist/schema/search-utils.d.ts.map +1 -0
- package/dist/schema/search-utils.js +86 -0
- package/dist/schema/search-utils.js.map +1 -0
- package/dist/schema/seed.d.ts +53 -0
- package/dist/schema/seed.d.ts.map +1 -0
- package/dist/schema/seed.js +94 -0
- package/dist/schema/seed.js.map +1 -0
- package/dist/schema/semantic.d.ts +11 -0
- package/dist/schema/semantic.d.ts.map +1 -1
- package/dist/schema/semantic.js +262 -68
- package/dist/schema/semantic.js.map +1 -1
- package/dist/schema/sub-apis.d.ts +52 -0
- package/dist/schema/sub-apis.d.ts.map +1 -0
- package/dist/schema/sub-apis.js +216 -0
- package/dist/schema/sub-apis.js.map +1 -0
- package/dist/schema/system-entities.d.ts +42 -0
- package/dist/schema/system-entities.d.ts.map +1 -0
- package/dist/schema/system-entities.js +101 -0
- package/dist/schema/system-entities.js.map +1 -0
- package/dist/schema/types.d.ts +91 -9
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/union-fallback.d.ts +219 -0
- package/dist/schema/union-fallback.d.ts.map +1 -0
- package/dist/schema/union-fallback.js +331 -0
- package/dist/schema/union-fallback.js.map +1 -0
- package/dist/schema/value-generators/ai.d.ts +54 -0
- package/dist/schema/value-generators/ai.d.ts.map +1 -0
- package/dist/schema/value-generators/ai.js +136 -0
- package/dist/schema/value-generators/ai.js.map +1 -0
- package/dist/schema/value-generators/index.d.ts +126 -0
- package/dist/schema/value-generators/index.d.ts.map +1 -0
- package/dist/schema/value-generators/index.js +219 -0
- package/dist/schema/value-generators/index.js.map +1 -0
- package/dist/schema/value-generators/placeholder.d.ts +52 -0
- package/dist/schema/value-generators/placeholder.d.ts.map +1 -0
- package/dist/schema/value-generators/placeholder.js +328 -0
- package/dist/schema/value-generators/placeholder.js.map +1 -0
- package/dist/schema/value-generators/types.d.ts +116 -0
- package/dist/schema/value-generators/types.d.ts.map +1 -0
- package/dist/schema/value-generators/types.js +11 -0
- package/dist/schema/value-generators/types.js.map +1 -0
- package/dist/schema/verb-derivation.d.ts +167 -0
- package/dist/schema/verb-derivation.d.ts.map +1 -0
- package/dist/schema/verb-derivation.js +281 -0
- package/dist/schema/verb-derivation.js.map +1 -0
- package/dist/schema/version.d.ts +111 -0
- package/dist/schema/version.d.ts.map +1 -0
- package/dist/schema/version.js +190 -0
- package/dist/schema/version.js.map +1 -0
- package/dist/schema.d.ts +1095 -23
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2854 -38
- package/dist/schema.js.map +1 -1
- package/dist/semantic-vectors.d.ts +39 -0
- package/dist/semantic-vectors.d.ts.map +1 -0
- package/dist/semantic-vectors.js +334 -0
- package/dist/semantic-vectors.js.map +1 -0
- package/dist/semantic.d.ts +29 -1
- package/dist/semantic.d.ts.map +1 -1
- package/dist/semantic.js +26 -16
- package/dist/semantic.js.map +1 -1
- package/dist/telemetry.d.ts +128 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +305 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/tests.d.ts.map +1 -1
- package/dist/tests.js +30 -22
- package/dist/tests.js.map +1 -1
- package/dist/type-guards.d.ts +212 -0
- package/dist/type-guards.d.ts.map +1 -0
- package/dist/type-guards.js +318 -0
- package/dist/type-guards.js.map +1 -0
- package/dist/types.d.ts +33 -245
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +62 -72
- package/dist/types.js.map +1 -1
- package/dist/validation.d.ts +165 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +639 -0
- package/dist/validation.js.map +1 -0
- package/dist/worker/db-provider.d.ts +168 -0
- package/dist/worker/db-provider.d.ts.map +1 -0
- package/dist/worker/db-provider.js +277 -0
- package/dist/worker/db-provider.js.map +1 -0
- package/dist/worker/index.d.ts +35 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +37 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker.d.ts +779 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +2786 -0
- package/dist/worker.js.map +1 -0
- package/package.json +38 -8
- package/src/docs-rels/migrations/0001-init.sql +125 -0
package/dist/worker.js
ADDED
|
@@ -0,0 +1,2786 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker Export - WorkerEntrypoint for RPC access to AI Database
|
|
3
|
+
*
|
|
4
|
+
* Exposes database operations via Cloudflare RPC.
|
|
5
|
+
* Works both in Cloudflare Workers and standalone (with MemoryProvider).
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // wrangler.jsonc
|
|
10
|
+
* {
|
|
11
|
+
* "services": [
|
|
12
|
+
* { "binding": "AI_DATABASE", "service": "ai-database" }
|
|
13
|
+
* ]
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* // worker.ts - consuming service
|
|
17
|
+
* export default {
|
|
18
|
+
* async fetch(request: Request, env: Env) {
|
|
19
|
+
* const db = env.AI_DATABASE.connect('my-namespace')
|
|
20
|
+
* const post = await db.create('Post', { title: 'Hello' })
|
|
21
|
+
* return Response.json(post)
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @packageDocumentation
|
|
27
|
+
*/
|
|
28
|
+
// ===========================================================================
|
|
29
|
+
// Mock classes for non-Cloudflare environments
|
|
30
|
+
// ===========================================================================
|
|
31
|
+
class MockRpcTarget {
|
|
32
|
+
}
|
|
33
|
+
class MockWorkerEntrypoint {
|
|
34
|
+
env;
|
|
35
|
+
ctx;
|
|
36
|
+
}
|
|
37
|
+
class MockDurableObject {
|
|
38
|
+
ctx;
|
|
39
|
+
env;
|
|
40
|
+
constructor(_state, _env) { }
|
|
41
|
+
}
|
|
42
|
+
// Try to import from cloudflare:workers, fall back to mocks
|
|
43
|
+
let WorkerEntrypoint;
|
|
44
|
+
let RpcTarget;
|
|
45
|
+
let DurableObjectBase;
|
|
46
|
+
try {
|
|
47
|
+
// @ts-expect-error - cloudflare:workers is only available in Cloudflare Workers runtime
|
|
48
|
+
const cfWorkers = await import('cloudflare:workers');
|
|
49
|
+
WorkerEntrypoint = cfWorkers.WorkerEntrypoint;
|
|
50
|
+
RpcTarget = cfWorkers.RpcTarget;
|
|
51
|
+
DurableObjectBase = cfWorkers.DurableObject;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
WorkerEntrypoint = MockWorkerEntrypoint;
|
|
55
|
+
RpcTarget = MockRpcTarget;
|
|
56
|
+
DurableObjectBase = MockDurableObject;
|
|
57
|
+
}
|
|
58
|
+
import { MemoryProvider } from './memory-provider.js';
|
|
59
|
+
/**
|
|
60
|
+
* Global namespace registry for in-memory providers (used when no DO binding is available)
|
|
61
|
+
* This enables namespace isolation and persistence across connect() calls in tests
|
|
62
|
+
*/
|
|
63
|
+
const namespaceProviders = new Map();
|
|
64
|
+
/**
|
|
65
|
+
* Get or create a MemoryProvider for a namespace
|
|
66
|
+
*/
|
|
67
|
+
function getOrCreateProvider(namespace, options) {
|
|
68
|
+
let provider = namespaceProviders.get(namespace);
|
|
69
|
+
if (!provider) {
|
|
70
|
+
provider = new MemoryProvider(options);
|
|
71
|
+
namespaceProviders.set(namespace, provider);
|
|
72
|
+
}
|
|
73
|
+
return provider;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* DatabaseServiceCore - RpcTarget wrapper around MemoryProvider
|
|
77
|
+
*
|
|
78
|
+
* Exposes all required methods as RPC-callable methods.
|
|
79
|
+
* This is the core service class that can be instantiated directly.
|
|
80
|
+
*/
|
|
81
|
+
export class DatabaseServiceCore extends RpcTarget {
|
|
82
|
+
provider;
|
|
83
|
+
constructor(namespace = 'default', options) {
|
|
84
|
+
super();
|
|
85
|
+
this.provider = getOrCreateProvider(namespace, options);
|
|
86
|
+
}
|
|
87
|
+
// ===========================================================================
|
|
88
|
+
// Configuration
|
|
89
|
+
// ===========================================================================
|
|
90
|
+
/**
|
|
91
|
+
* Set embeddings configuration for auto-generation
|
|
92
|
+
*/
|
|
93
|
+
setEmbeddingsConfig(config) {
|
|
94
|
+
this.provider.setEmbeddingsConfig(config);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Enable or disable ai-functions for embeddings
|
|
98
|
+
*/
|
|
99
|
+
setUseAiFunctions(enabled) {
|
|
100
|
+
this.provider.setUseAiFunctions(enabled);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Set a custom embedding provider
|
|
104
|
+
*/
|
|
105
|
+
setEmbeddingProvider(provider) {
|
|
106
|
+
this.provider.setEmbeddingProvider(provider);
|
|
107
|
+
}
|
|
108
|
+
// ===========================================================================
|
|
109
|
+
// CRUD Operations
|
|
110
|
+
// ===========================================================================
|
|
111
|
+
/**
|
|
112
|
+
* Get an entity by type and ID
|
|
113
|
+
*/
|
|
114
|
+
async get(type, id) {
|
|
115
|
+
const result = await this.provider.get(type, id);
|
|
116
|
+
if (!result)
|
|
117
|
+
return null;
|
|
118
|
+
return { $id: id, $type: type, ...result };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* List entities by type
|
|
122
|
+
*/
|
|
123
|
+
async list(type, options) {
|
|
124
|
+
const results = await this.provider.list(type, options);
|
|
125
|
+
return results.map((r) => ({ $type: type, ...r }));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Create an entity
|
|
129
|
+
*/
|
|
130
|
+
async create(type, data, id) {
|
|
131
|
+
const result = await this.provider.create(type, id, data);
|
|
132
|
+
return { $type: type, ...result };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Update an entity
|
|
136
|
+
*/
|
|
137
|
+
async update(type, id, data) {
|
|
138
|
+
const result = await this.provider.update(type, id, data);
|
|
139
|
+
return { $id: id, $type: type, ...result };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Delete an entity
|
|
143
|
+
*/
|
|
144
|
+
async delete(type, id) {
|
|
145
|
+
return this.provider.delete(type, id);
|
|
146
|
+
}
|
|
147
|
+
// ===========================================================================
|
|
148
|
+
// Search Operations
|
|
149
|
+
// ===========================================================================
|
|
150
|
+
/**
|
|
151
|
+
* Full-text search
|
|
152
|
+
*/
|
|
153
|
+
async search(type, query, options) {
|
|
154
|
+
const results = await this.provider.search(type, query, options);
|
|
155
|
+
return results.map((r) => ({ $type: type, ...r }));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Semantic search using vector similarity
|
|
159
|
+
*/
|
|
160
|
+
async semanticSearch(type, query, options) {
|
|
161
|
+
const provider = this.provider;
|
|
162
|
+
// Check if provider supports semantic search
|
|
163
|
+
if (provider.semanticSearch) {
|
|
164
|
+
const results = await provider.semanticSearch(type, query, options);
|
|
165
|
+
return results;
|
|
166
|
+
}
|
|
167
|
+
// Fallback to regular search
|
|
168
|
+
const results = await provider.search(type, query, options);
|
|
169
|
+
return results.map((r, i) => ({
|
|
170
|
+
$type: type,
|
|
171
|
+
$score: 1 - i * 0.1, // Decreasing score based on position
|
|
172
|
+
...r,
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Hybrid search combining FTS and semantic
|
|
177
|
+
*/
|
|
178
|
+
async hybridSearch(type, query, options) {
|
|
179
|
+
const provider = this.provider;
|
|
180
|
+
// Check if provider supports hybrid search
|
|
181
|
+
if (provider.hybridSearch) {
|
|
182
|
+
const results = await provider.hybridSearch(type, query, options);
|
|
183
|
+
return results;
|
|
184
|
+
}
|
|
185
|
+
// Fallback to semantic search
|
|
186
|
+
const results = await this.semanticSearch(type, query, options);
|
|
187
|
+
return results.map((r, i) => ({
|
|
188
|
+
...r,
|
|
189
|
+
$rrfScore: r.$score,
|
|
190
|
+
$ftsRank: i + 1,
|
|
191
|
+
$semanticRank: i + 1,
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
// ===========================================================================
|
|
195
|
+
// Relationship Operations
|
|
196
|
+
// ===========================================================================
|
|
197
|
+
/**
|
|
198
|
+
* Get related entities
|
|
199
|
+
*/
|
|
200
|
+
async related(type, id, relation) {
|
|
201
|
+
const results = await this.provider.related(type, id, relation);
|
|
202
|
+
return results.map((r) => r);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Create a relationship between two entities
|
|
206
|
+
*/
|
|
207
|
+
async relate(fromType, fromId, relation, toType, toId, metadata) {
|
|
208
|
+
return this.provider.relate(fromType, fromId, relation, toType, toId, metadata);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Remove a relationship between two entities
|
|
212
|
+
*/
|
|
213
|
+
async unrelate(fromType, fromId, relation, toType, toId) {
|
|
214
|
+
return this.provider.unrelate(fromType, fromId, relation, toType, toId);
|
|
215
|
+
}
|
|
216
|
+
// ===========================================================================
|
|
217
|
+
// Events API
|
|
218
|
+
// ===========================================================================
|
|
219
|
+
/**
|
|
220
|
+
* Subscribe to events matching a pattern
|
|
221
|
+
* @returns Unsubscribe function ID (use unsubscribe() to remove)
|
|
222
|
+
*/
|
|
223
|
+
on(pattern, handler) {
|
|
224
|
+
if ('on' in this.provider) {
|
|
225
|
+
const providerWithOn = this.provider;
|
|
226
|
+
const unsubscribe = providerWithOn.on(pattern, handler);
|
|
227
|
+
// Store unsubscribe function with unique ID
|
|
228
|
+
const handlerId = crypto.randomUUID();
|
|
229
|
+
this._eventHandlers.set(handlerId, unsubscribe);
|
|
230
|
+
return handlerId;
|
|
231
|
+
}
|
|
232
|
+
return '';
|
|
233
|
+
}
|
|
234
|
+
_eventHandlers = new Map();
|
|
235
|
+
/**
|
|
236
|
+
* Unsubscribe from events
|
|
237
|
+
*/
|
|
238
|
+
unsubscribe(handlerId) {
|
|
239
|
+
const unsubscribe = this._eventHandlers.get(handlerId);
|
|
240
|
+
if (unsubscribe) {
|
|
241
|
+
unsubscribe();
|
|
242
|
+
this._eventHandlers.delete(handlerId);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Emit an event
|
|
247
|
+
*/
|
|
248
|
+
async emit(eventOrOptions, data) {
|
|
249
|
+
if ('emit' in this.provider) {
|
|
250
|
+
const providerWithEmit = this.provider;
|
|
251
|
+
if (typeof eventOrOptions === 'string') {
|
|
252
|
+
return providerWithEmit.emit(eventOrOptions, data);
|
|
253
|
+
}
|
|
254
|
+
return providerWithEmit.emit(eventOrOptions);
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* List events
|
|
260
|
+
*/
|
|
261
|
+
async listEvents(options) {
|
|
262
|
+
if ('listEvents' in this.provider) {
|
|
263
|
+
const providerWithListEvents = this.provider;
|
|
264
|
+
return providerWithListEvents.listEvents(options);
|
|
265
|
+
}
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
// ===========================================================================
|
|
269
|
+
// Actions API
|
|
270
|
+
// ===========================================================================
|
|
271
|
+
/**
|
|
272
|
+
* Create a new action
|
|
273
|
+
*/
|
|
274
|
+
async createAction(options) {
|
|
275
|
+
if ('createAction' in this.provider) {
|
|
276
|
+
const providerWithCreateAction = this.provider;
|
|
277
|
+
return providerWithCreateAction.createAction(options);
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Get an action by ID
|
|
283
|
+
*/
|
|
284
|
+
async getAction(id) {
|
|
285
|
+
if ('getAction' in this.provider) {
|
|
286
|
+
const providerWithGetAction = this.provider;
|
|
287
|
+
return providerWithGetAction.getAction(id);
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Update an action
|
|
293
|
+
*/
|
|
294
|
+
async updateAction(id, updates) {
|
|
295
|
+
if ('updateAction' in this.provider) {
|
|
296
|
+
const providerWithUpdateAction = this.provider;
|
|
297
|
+
return providerWithUpdateAction.updateAction(id, updates);
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* List actions
|
|
303
|
+
*/
|
|
304
|
+
async listActions(options) {
|
|
305
|
+
if ('listActions' in this.provider) {
|
|
306
|
+
const providerWithListActions = this.provider;
|
|
307
|
+
return providerWithListActions.listActions(options);
|
|
308
|
+
}
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
// ===========================================================================
|
|
312
|
+
// Artifacts API
|
|
313
|
+
// ===========================================================================
|
|
314
|
+
/**
|
|
315
|
+
* Get an artifact
|
|
316
|
+
*/
|
|
317
|
+
async getArtifact(url, type) {
|
|
318
|
+
if ('getArtifact' in this.provider) {
|
|
319
|
+
const providerWithGetArtifact = this.provider;
|
|
320
|
+
return providerWithGetArtifact.getArtifact(url, type);
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Set an artifact
|
|
326
|
+
*/
|
|
327
|
+
async setArtifact(url, type, data) {
|
|
328
|
+
if ('setArtifact' in this.provider) {
|
|
329
|
+
const providerWithSetArtifact = this.provider;
|
|
330
|
+
return providerWithSetArtifact.setArtifact(url, type, data);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Delete an artifact
|
|
335
|
+
*/
|
|
336
|
+
async deleteArtifact(url, type) {
|
|
337
|
+
if ('deleteArtifact' in this.provider) {
|
|
338
|
+
const providerWithDeleteArtifact = this.provider;
|
|
339
|
+
return providerWithDeleteArtifact.deleteArtifact(url, type);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* List artifacts for a URL
|
|
344
|
+
*/
|
|
345
|
+
async listArtifacts(url) {
|
|
346
|
+
if ('listArtifacts' in this.provider) {
|
|
347
|
+
const providerWithListArtifacts = this.provider;
|
|
348
|
+
return providerWithListArtifacts.listArtifacts(url);
|
|
349
|
+
}
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
// ===========================================================================
|
|
353
|
+
// Utility Methods
|
|
354
|
+
// ===========================================================================
|
|
355
|
+
/**
|
|
356
|
+
* Clear all data in the provider (useful for testing)
|
|
357
|
+
*/
|
|
358
|
+
clear() {
|
|
359
|
+
if ('clear' in this.provider) {
|
|
360
|
+
const providerWithClear = this.provider;
|
|
361
|
+
providerWithClear.clear();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// =============================================================================
|
|
366
|
+
// DatabaseDO - Durable Object with SQLite storage for core _data and _rels tables
|
|
367
|
+
// =============================================================================
|
|
368
|
+
/**
|
|
369
|
+
* DatabaseDO - Durable Object using SQLite for the core schema layer.
|
|
370
|
+
*
|
|
371
|
+
* Provides two tables:
|
|
372
|
+
* - `_data`: id TEXT PRIMARY KEY, type TEXT, data TEXT (JSON), created_at TEXT, updated_at TEXT
|
|
373
|
+
* - `_rels`: from_id TEXT, relation TEXT, to_id TEXT, metadata TEXT (JSON),
|
|
374
|
+
* PRIMARY KEY(from_id, relation, to_id)
|
|
375
|
+
*
|
|
376
|
+
* Handles HTTP requests for CRUD operations on data records and relationships,
|
|
377
|
+
* plus graph traversal queries.
|
|
378
|
+
*/
|
|
379
|
+
export class DatabaseDO extends DurableObjectBase {
|
|
380
|
+
sql;
|
|
381
|
+
initialized = false;
|
|
382
|
+
// Pipeline state for R2 streaming
|
|
383
|
+
pipelineBuffer = [];
|
|
384
|
+
pipelineConfig = { retryEnabled: false, batchSize: 100 };
|
|
385
|
+
pipelineStats = { eventsProcessed: 0, batchesSent: 0 };
|
|
386
|
+
// Embeddings configuration
|
|
387
|
+
embeddingsConfig = { model: '@cf/baai/bge-base-en-v1.5' };
|
|
388
|
+
embeddingsCacheStats = { cacheHits: 0, cacheMisses: 0 };
|
|
389
|
+
batchJobs = new Map();
|
|
390
|
+
constructor(state, env) {
|
|
391
|
+
super(state, env);
|
|
392
|
+
this.sql = state.storage.sql;
|
|
393
|
+
}
|
|
394
|
+
ensureSchema() {
|
|
395
|
+
if (this.initialized)
|
|
396
|
+
return;
|
|
397
|
+
// Create _data table
|
|
398
|
+
this.sql.exec(`
|
|
399
|
+
CREATE TABLE IF NOT EXISTS _data (
|
|
400
|
+
id TEXT PRIMARY KEY,
|
|
401
|
+
type TEXT NOT NULL,
|
|
402
|
+
data TEXT NOT NULL DEFAULT '{}',
|
|
403
|
+
created_at TEXT NOT NULL,
|
|
404
|
+
updated_at TEXT NOT NULL
|
|
405
|
+
)
|
|
406
|
+
`);
|
|
407
|
+
// Create _rels table with created_at
|
|
408
|
+
this.sql.exec(`
|
|
409
|
+
CREATE TABLE IF NOT EXISTS _rels (
|
|
410
|
+
from_id TEXT NOT NULL,
|
|
411
|
+
relation TEXT NOT NULL,
|
|
412
|
+
to_id TEXT NOT NULL,
|
|
413
|
+
metadata TEXT,
|
|
414
|
+
created_at TEXT NOT NULL,
|
|
415
|
+
PRIMARY KEY(from_id, relation, to_id)
|
|
416
|
+
)
|
|
417
|
+
`);
|
|
418
|
+
// Create _meta table for schema versioning
|
|
419
|
+
this.sql.exec(`
|
|
420
|
+
CREATE TABLE IF NOT EXISTS _meta (
|
|
421
|
+
key TEXT PRIMARY KEY,
|
|
422
|
+
value TEXT NOT NULL
|
|
423
|
+
)
|
|
424
|
+
`);
|
|
425
|
+
// Set initial schema version
|
|
426
|
+
this.sql.exec(`
|
|
427
|
+
INSERT OR IGNORE INTO _meta (key, value) VALUES ('version', '1')
|
|
428
|
+
`);
|
|
429
|
+
// Create indexes for performance
|
|
430
|
+
this.sql.exec(`CREATE INDEX IF NOT EXISTS idx_data_type ON _data(type)`);
|
|
431
|
+
this.sql.exec(`CREATE INDEX IF NOT EXISTS idx_data_created_at ON _data(created_at)`);
|
|
432
|
+
this.sql.exec(`CREATE INDEX IF NOT EXISTS idx_rels_from_id_relation ON _rels(from_id, relation)`);
|
|
433
|
+
this.sql.exec(`CREATE INDEX IF NOT EXISTS idx_rels_to_id_relation ON _rels(to_id, relation)`);
|
|
434
|
+
// Create _events table for event sourcing
|
|
435
|
+
this.sql.exec(`
|
|
436
|
+
CREATE TABLE IF NOT EXISTS _events (
|
|
437
|
+
id TEXT PRIMARY KEY,
|
|
438
|
+
event TEXT NOT NULL,
|
|
439
|
+
actor TEXT,
|
|
440
|
+
object TEXT,
|
|
441
|
+
data TEXT,
|
|
442
|
+
result TEXT,
|
|
443
|
+
previous_data TEXT,
|
|
444
|
+
timestamp TEXT NOT NULL
|
|
445
|
+
)
|
|
446
|
+
`);
|
|
447
|
+
// Create indexes for _events table
|
|
448
|
+
this.sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_event ON _events(event)`);
|
|
449
|
+
this.sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_ts ON _events(timestamp)`);
|
|
450
|
+
this.sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_obj ON _events(object)`);
|
|
451
|
+
// Create _subscriptions table for event subscriptions
|
|
452
|
+
this.sql.exec(`
|
|
453
|
+
CREATE TABLE IF NOT EXISTS _subscriptions (
|
|
454
|
+
id TEXT PRIMARY KEY,
|
|
455
|
+
pattern TEXT NOT NULL,
|
|
456
|
+
webhook TEXT NOT NULL,
|
|
457
|
+
created_at TEXT NOT NULL
|
|
458
|
+
)
|
|
459
|
+
`);
|
|
460
|
+
// Create _embeddings table for semantic search
|
|
461
|
+
this.sql.exec(`
|
|
462
|
+
CREATE TABLE IF NOT EXISTS _embeddings (
|
|
463
|
+
id TEXT PRIMARY KEY,
|
|
464
|
+
entity_type TEXT NOT NULL,
|
|
465
|
+
entity_id TEXT NOT NULL,
|
|
466
|
+
model TEXT NOT NULL,
|
|
467
|
+
vector TEXT NOT NULL,
|
|
468
|
+
content_hash TEXT,
|
|
469
|
+
created_at TEXT NOT NULL,
|
|
470
|
+
updated_at TEXT NOT NULL,
|
|
471
|
+
UNIQUE(entity_type, entity_id)
|
|
472
|
+
)
|
|
473
|
+
`);
|
|
474
|
+
// Create index for _embeddings table
|
|
475
|
+
this.sql.exec(`CREATE INDEX IF NOT EXISTS idx_embeddings_entity ON _embeddings(entity_type, entity_id)`);
|
|
476
|
+
this.sql.exec(`CREATE INDEX IF NOT EXISTS idx_embeddings_type ON _embeddings(entity_type)`);
|
|
477
|
+
this.initialized = true;
|
|
478
|
+
}
|
|
479
|
+
async fetch(request) {
|
|
480
|
+
this.ensureSchema();
|
|
481
|
+
const url = new URL(request.url);
|
|
482
|
+
const path = url.pathname;
|
|
483
|
+
const method = request.method;
|
|
484
|
+
try {
|
|
485
|
+
// Route: /data (list or insert)
|
|
486
|
+
if (path === '/data' && method === 'GET') {
|
|
487
|
+
return this.handleListData(url);
|
|
488
|
+
}
|
|
489
|
+
if (path === '/data' && method === 'POST') {
|
|
490
|
+
return this.handleInsertData(request);
|
|
491
|
+
}
|
|
492
|
+
// Route: /data/:id (get, update, delete)
|
|
493
|
+
const dataMatch = path.match(/^\/data\/(.+)$/);
|
|
494
|
+
if (dataMatch) {
|
|
495
|
+
const id = decodeURIComponent(dataMatch[1]);
|
|
496
|
+
if (method === 'GET')
|
|
497
|
+
return this.handleGetData(id);
|
|
498
|
+
if (method === 'PATCH')
|
|
499
|
+
return this.handleUpdateData(id, request);
|
|
500
|
+
if (method === 'DELETE')
|
|
501
|
+
return this.handleDeleteData(id, url, request);
|
|
502
|
+
}
|
|
503
|
+
// Route: /rels (list or create)
|
|
504
|
+
if (path === '/rels' && method === 'GET') {
|
|
505
|
+
return this.handleQueryRels(url);
|
|
506
|
+
}
|
|
507
|
+
if (path === '/rels' && method === 'POST') {
|
|
508
|
+
return this.handleCreateRel(request);
|
|
509
|
+
}
|
|
510
|
+
// Route: /rels/delete (delete relationship)
|
|
511
|
+
if (path === '/rels/delete' && method === 'DELETE') {
|
|
512
|
+
return this.handleDeleteRel(request);
|
|
513
|
+
}
|
|
514
|
+
// Route: /traverse (graph traversal)
|
|
515
|
+
if (path === '/traverse' && method === 'GET') {
|
|
516
|
+
return this.handleTraverse(url);
|
|
517
|
+
}
|
|
518
|
+
if (path === '/traverse/filter' && method === 'POST') {
|
|
519
|
+
return this.handleTraverseFilter(request);
|
|
520
|
+
}
|
|
521
|
+
// Route: /rels with PATCH for updating metadata
|
|
522
|
+
if (path === '/rels' && method === 'PATCH') {
|
|
523
|
+
return this.handleUpdateRel(request);
|
|
524
|
+
}
|
|
525
|
+
// Route: /meta/indexes (list indexes)
|
|
526
|
+
if (path === '/meta/indexes' && method === 'GET') {
|
|
527
|
+
return this.handleGetIndexes();
|
|
528
|
+
}
|
|
529
|
+
// Route: /meta/version (schema version)
|
|
530
|
+
if (path === '/meta/version' && method === 'GET') {
|
|
531
|
+
return this.handleGetVersion();
|
|
532
|
+
}
|
|
533
|
+
// Route: /query/list (GET for simple, POST for complex)
|
|
534
|
+
if (path === '/query/list' && method === 'GET') {
|
|
535
|
+
return this.handleQueryList(url);
|
|
536
|
+
}
|
|
537
|
+
if (path === '/query/list' && method === 'POST') {
|
|
538
|
+
return this.handleQueryListPost(request);
|
|
539
|
+
}
|
|
540
|
+
// Route: /query/find (POST)
|
|
541
|
+
if (path === '/query/find' && method === 'POST') {
|
|
542
|
+
return this.handleQueryFind(request);
|
|
543
|
+
}
|
|
544
|
+
// Route: /query/search (POST)
|
|
545
|
+
if (path === '/query/search' && method === 'POST') {
|
|
546
|
+
return this.handleQuerySearch(request);
|
|
547
|
+
}
|
|
548
|
+
// Route: /events (list or create custom events)
|
|
549
|
+
if (path === '/events' && method === 'GET') {
|
|
550
|
+
return this.handleListEvents(url);
|
|
551
|
+
}
|
|
552
|
+
if (path === '/events' && method === 'POST') {
|
|
553
|
+
return this.handleCreateEvent(request);
|
|
554
|
+
}
|
|
555
|
+
// Route: /events/replay (replay events)
|
|
556
|
+
if (path === '/events/replay' && method === 'POST') {
|
|
557
|
+
return this.handleReplayEvents(request);
|
|
558
|
+
}
|
|
559
|
+
// Route: /events/rebuild (rebuild entity from events)
|
|
560
|
+
if (path === '/events/rebuild' && method === 'POST') {
|
|
561
|
+
return this.handleRebuildEntity(request);
|
|
562
|
+
}
|
|
563
|
+
// Route: /events/subscribe (create subscription)
|
|
564
|
+
if (path === '/events/subscribe' && method === 'POST') {
|
|
565
|
+
return this.handleSubscribe(request);
|
|
566
|
+
}
|
|
567
|
+
// Route: /events/subscriptions (list subscriptions)
|
|
568
|
+
if (path === '/events/subscriptions' && method === 'GET') {
|
|
569
|
+
return this.handleListSubscriptions();
|
|
570
|
+
}
|
|
571
|
+
// Route: /events/subscriptions/:id (delete subscription)
|
|
572
|
+
const subMatch = path.match(/^\/events\/subscriptions\/([^/]+)$/);
|
|
573
|
+
if (subMatch) {
|
|
574
|
+
const subId = decodeURIComponent(subMatch[1]);
|
|
575
|
+
if (method === 'DELETE')
|
|
576
|
+
return this.handleUnsubscribe(subId);
|
|
577
|
+
}
|
|
578
|
+
// Route: /events/subscriptions/:id/deliveries (list deliveries)
|
|
579
|
+
const deliveriesMatch = path.match(/^\/events\/subscriptions\/([^/]+)\/deliveries$/);
|
|
580
|
+
if (deliveriesMatch && method === 'GET') {
|
|
581
|
+
const subId = decodeURIComponent(deliveriesMatch[1]);
|
|
582
|
+
return this.handleListDeliveries(subId);
|
|
583
|
+
}
|
|
584
|
+
// Route: /pipeline/status (pipeline status)
|
|
585
|
+
if (path === '/pipeline/status' && method === 'GET') {
|
|
586
|
+
return this.handlePipelineStatus();
|
|
587
|
+
}
|
|
588
|
+
// Route: /pipeline/flush (flush pipeline)
|
|
589
|
+
if (path === '/pipeline/flush' && method === 'POST') {
|
|
590
|
+
return this.handlePipelineFlush();
|
|
591
|
+
}
|
|
592
|
+
// Route: /pipeline/r2/list (list R2 objects)
|
|
593
|
+
if (path === '/pipeline/r2/list' && method === 'GET') {
|
|
594
|
+
return this.handlePipelineR2List();
|
|
595
|
+
}
|
|
596
|
+
// Route: /pipeline/config (configure pipeline)
|
|
597
|
+
if (path === '/pipeline/config' && method === 'POST') {
|
|
598
|
+
return this.handlePipelineConfig(request);
|
|
599
|
+
}
|
|
600
|
+
// Route: /pipeline/test-error (test error handling)
|
|
601
|
+
if (path === '/pipeline/test-error' && method === 'POST') {
|
|
602
|
+
return this.handlePipelineTestError(request);
|
|
603
|
+
}
|
|
604
|
+
// ===========================================================================
|
|
605
|
+
// Semantic Search Routes
|
|
606
|
+
// ===========================================================================
|
|
607
|
+
// Route: /config/embeddings (configure embedding model)
|
|
608
|
+
if (path === '/config/embeddings' && method === 'POST') {
|
|
609
|
+
return this.handleConfigureEmbeddings(request);
|
|
610
|
+
}
|
|
611
|
+
// Route: /embeddings (list all embeddings)
|
|
612
|
+
if (path === '/embeddings' && method === 'GET') {
|
|
613
|
+
return this.handleListEmbeddings(url);
|
|
614
|
+
}
|
|
615
|
+
// Route: /embeddings/stats (get cache stats)
|
|
616
|
+
if (path === '/embeddings/stats' && method === 'GET') {
|
|
617
|
+
return this.handleEmbeddingsStats();
|
|
618
|
+
}
|
|
619
|
+
// Route: /embeddings/warmup (warm up embedding cache)
|
|
620
|
+
if (path === '/embeddings/warmup' && method === 'POST') {
|
|
621
|
+
return this.handleEmbeddingsWarmup(request);
|
|
622
|
+
}
|
|
623
|
+
// Route: /embeddings/generate (generate embeddings for all entities of a type)
|
|
624
|
+
if (path === '/embeddings/generate' && method === 'POST') {
|
|
625
|
+
return this.handleEmbeddingsGenerate(request);
|
|
626
|
+
}
|
|
627
|
+
// Route: /embeddings/batch (batch process embeddings)
|
|
628
|
+
if (path === '/embeddings/batch' && method === 'POST') {
|
|
629
|
+
return this.handleEmbeddingsBatch(request);
|
|
630
|
+
}
|
|
631
|
+
// Route: /embeddings/batch/start (start batch job)
|
|
632
|
+
if (path === '/embeddings/batch/start' && method === 'POST') {
|
|
633
|
+
return this.handleEmbeddingsBatchStart(request);
|
|
634
|
+
}
|
|
635
|
+
// Route: /embeddings/batch/:jobId/status (batch job status)
|
|
636
|
+
const batchStatusMatch = path.match(/^\/embeddings\/batch\/([^/]+)\/status$/);
|
|
637
|
+
if (batchStatusMatch && method === 'GET') {
|
|
638
|
+
const jobId = decodeURIComponent(batchStatusMatch[1]);
|
|
639
|
+
return this.handleEmbeddingsBatchStatus(jobId);
|
|
640
|
+
}
|
|
641
|
+
// Route: /embeddings/:type/:id (get or generate embedding for an entity)
|
|
642
|
+
const embeddingMatch = path.match(/^\/embeddings\/([^/]+)\/([^/]+)$/);
|
|
643
|
+
if (embeddingMatch && method === 'GET') {
|
|
644
|
+
const entityType = decodeURIComponent(embeddingMatch[1]);
|
|
645
|
+
const entityId = decodeURIComponent(embeddingMatch[2]);
|
|
646
|
+
return this.handleGetEmbedding(entityType, entityId);
|
|
647
|
+
}
|
|
648
|
+
// Route: /search/semantic (semantic search)
|
|
649
|
+
if (path === '/search/semantic' && method === 'POST') {
|
|
650
|
+
return this.handleSemanticSearch(request);
|
|
651
|
+
}
|
|
652
|
+
// Route: /search/hybrid (hybrid FTS + semantic search)
|
|
653
|
+
if (path === '/search/hybrid' && method === 'POST') {
|
|
654
|
+
return this.handleHybridSearch(request);
|
|
655
|
+
}
|
|
656
|
+
return Response.json({ error: 'Not found' }, { status: 404 });
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
const message = err instanceof Error ? err.message : 'Internal error';
|
|
660
|
+
return Response.json({ error: message }, { status: 500 });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// ===========================================================================
|
|
664
|
+
// _data handlers
|
|
665
|
+
// ===========================================================================
|
|
666
|
+
handleListData(url) {
|
|
667
|
+
const type = url.searchParams.get('type');
|
|
668
|
+
const limit = url.searchParams.get('limit');
|
|
669
|
+
const offset = url.searchParams.get('offset');
|
|
670
|
+
let query = 'SELECT * FROM _data';
|
|
671
|
+
const params = [];
|
|
672
|
+
if (type) {
|
|
673
|
+
query += ' WHERE type = ?';
|
|
674
|
+
params.push(type);
|
|
675
|
+
}
|
|
676
|
+
query += ' ORDER BY rowid ASC';
|
|
677
|
+
if (limit) {
|
|
678
|
+
query += ' LIMIT ?';
|
|
679
|
+
params.push(parseInt(limit, 10));
|
|
680
|
+
}
|
|
681
|
+
if (offset) {
|
|
682
|
+
query += ' OFFSET ?';
|
|
683
|
+
params.push(parseInt(offset, 10));
|
|
684
|
+
}
|
|
685
|
+
const rows = this.sql.exec(query, ...params).toArray();
|
|
686
|
+
const results = rows.map((row) => this.deserializeDataRow(row));
|
|
687
|
+
return Response.json(results);
|
|
688
|
+
}
|
|
689
|
+
async handleInsertData(request) {
|
|
690
|
+
let body;
|
|
691
|
+
try {
|
|
692
|
+
body = (await request.json());
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
696
|
+
}
|
|
697
|
+
const { type, data } = body;
|
|
698
|
+
let { id } = body;
|
|
699
|
+
if (!type) {
|
|
700
|
+
return Response.json({ error: 'type field is required' }, { status: 400 });
|
|
701
|
+
}
|
|
702
|
+
if (!id) {
|
|
703
|
+
id = crypto.randomUUID();
|
|
704
|
+
}
|
|
705
|
+
// Check for duplicate ID
|
|
706
|
+
const existing = this.sql.exec('SELECT id FROM _data WHERE id = ?', id).toArray();
|
|
707
|
+
if (existing.length > 0) {
|
|
708
|
+
return Response.json({ error: 'Record with this id already exists' }, { status: 409 });
|
|
709
|
+
}
|
|
710
|
+
const now = new Date().toISOString();
|
|
711
|
+
const dataJson = JSON.stringify(data ?? {});
|
|
712
|
+
this.sql.exec('INSERT INTO _data (id, type, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', id, type, dataJson, now, now);
|
|
713
|
+
// Emit Type.created event
|
|
714
|
+
const actor = request.headers.get('X-Actor') ?? 'system';
|
|
715
|
+
this.emitEvent({
|
|
716
|
+
event: `${type}.created`,
|
|
717
|
+
actor,
|
|
718
|
+
object: `${type}/${id}`,
|
|
719
|
+
data: data ?? {},
|
|
720
|
+
});
|
|
721
|
+
// Note: Embedding generation happens lazily when first queried or via batch/warmup/search
|
|
722
|
+
// This allows fine-grained control over when embeddings are generated
|
|
723
|
+
const result = {
|
|
724
|
+
id,
|
|
725
|
+
type,
|
|
726
|
+
data: data ?? {},
|
|
727
|
+
created_at: now,
|
|
728
|
+
updated_at: now,
|
|
729
|
+
};
|
|
730
|
+
return Response.json(result);
|
|
731
|
+
}
|
|
732
|
+
handleGetData(id) {
|
|
733
|
+
const rows = this.sql.exec('SELECT * FROM _data WHERE id = ?', id).toArray();
|
|
734
|
+
if (rows.length === 0) {
|
|
735
|
+
return Response.json({ error: 'Not found' }, { status: 404 });
|
|
736
|
+
}
|
|
737
|
+
return Response.json(this.deserializeDataRow(rows[0]));
|
|
738
|
+
}
|
|
739
|
+
async handleUpdateData(id, request) {
|
|
740
|
+
const rows = this.sql.exec('SELECT * FROM _data WHERE id = ?', id).toArray();
|
|
741
|
+
if (rows.length === 0) {
|
|
742
|
+
return Response.json({ error: 'Not found' }, { status: 404 });
|
|
743
|
+
}
|
|
744
|
+
const existing = this.deserializeDataRow(rows[0]);
|
|
745
|
+
const body = (await request.json());
|
|
746
|
+
// Merge data fields (shallow merge)
|
|
747
|
+
const mergedData = { ...existing.data, ...(body.data ?? {}) };
|
|
748
|
+
const now = new Date().toISOString();
|
|
749
|
+
const dataJson = JSON.stringify(mergedData);
|
|
750
|
+
this.sql.exec('UPDATE _data SET data = ?, updated_at = ? WHERE id = ?', dataJson, now, id);
|
|
751
|
+
// Emit Type.updated event
|
|
752
|
+
const actor = request.headers.get('X-Actor') ?? 'system';
|
|
753
|
+
this.emitEvent({
|
|
754
|
+
event: `${existing.type}.updated`,
|
|
755
|
+
actor,
|
|
756
|
+
object: `${existing.type}/${id}`,
|
|
757
|
+
data: mergedData,
|
|
758
|
+
previousData: existing.data,
|
|
759
|
+
});
|
|
760
|
+
// If embedding exists, update it with new content
|
|
761
|
+
const existingEmbedding = this.sql
|
|
762
|
+
.exec('SELECT id FROM _embeddings WHERE entity_type = ? AND entity_id = ?', existing.type, id)
|
|
763
|
+
.toArray();
|
|
764
|
+
if (existingEmbedding.length > 0) {
|
|
765
|
+
await this.generateEmbeddingForEntity(existing.type, id, mergedData).catch(() => {
|
|
766
|
+
// Silently ignore embedding generation errors on update
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
const result = {
|
|
770
|
+
id: existing.id,
|
|
771
|
+
type: existing.type,
|
|
772
|
+
data: mergedData,
|
|
773
|
+
created_at: existing.created_at,
|
|
774
|
+
updated_at: now,
|
|
775
|
+
};
|
|
776
|
+
return Response.json(result);
|
|
777
|
+
}
|
|
778
|
+
handleDeleteData(id, url, request) {
|
|
779
|
+
const rows = this.sql.exec('SELECT * FROM _data WHERE id = ?', id).toArray();
|
|
780
|
+
if (rows.length === 0) {
|
|
781
|
+
return Response.json({ deleted: false });
|
|
782
|
+
}
|
|
783
|
+
const entity = this.deserializeDataRow(rows[0]);
|
|
784
|
+
// Check for cascade option
|
|
785
|
+
const cascade = url?.searchParams.get('cascade') === 'true';
|
|
786
|
+
const cascadeDepth = parseInt(url?.searchParams.get('cascadeDepth') ?? '999', 10);
|
|
787
|
+
let cascadeDeleted;
|
|
788
|
+
if (cascade) {
|
|
789
|
+
// Perform cascade delete with depth control
|
|
790
|
+
cascadeDeleted = this.cascadeDelete(id, cascadeDepth, new Set([id]));
|
|
791
|
+
}
|
|
792
|
+
// Delete the record
|
|
793
|
+
this.sql.exec('DELETE FROM _data WHERE id = ?', id);
|
|
794
|
+
// Cascade: remove relationships involving this id
|
|
795
|
+
this.sql.exec('DELETE FROM _rels WHERE from_id = ? OR to_id = ?', id, id);
|
|
796
|
+
// Delete embedding for this entity
|
|
797
|
+
this.sql.exec('DELETE FROM _embeddings WHERE entity_type = ? AND entity_id = ?', entity['type'], id);
|
|
798
|
+
// Emit Type.deleted event
|
|
799
|
+
const actor = request?.headers.get('X-Actor') ?? 'system';
|
|
800
|
+
this.emitEvent({
|
|
801
|
+
event: `${entity['type']}.deleted`,
|
|
802
|
+
actor,
|
|
803
|
+
object: `${entity['type']}/${id}`,
|
|
804
|
+
data: entity['data'],
|
|
805
|
+
});
|
|
806
|
+
const result = { deleted: true };
|
|
807
|
+
if (cascadeDeleted !== undefined) {
|
|
808
|
+
result['cascadeDeleted'] = cascadeDeleted;
|
|
809
|
+
}
|
|
810
|
+
return Response.json(result);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Cascade delete related entities up to a given depth
|
|
814
|
+
*/
|
|
815
|
+
cascadeDelete(fromId, depth, visited) {
|
|
816
|
+
if (depth <= 0)
|
|
817
|
+
return [];
|
|
818
|
+
const deleted = [];
|
|
819
|
+
// Get all outgoing relationships from this entity
|
|
820
|
+
const rels = this.sql.exec('SELECT to_id FROM _rels WHERE from_id = ?', fromId).toArray();
|
|
821
|
+
for (const rel of rels) {
|
|
822
|
+
const relRow = rel;
|
|
823
|
+
const toId = relRow.to_id;
|
|
824
|
+
if (visited.has(toId))
|
|
825
|
+
continue;
|
|
826
|
+
visited.add(toId);
|
|
827
|
+
// Recursively delete related entities
|
|
828
|
+
const nested = this.cascadeDelete(toId, depth - 1, visited);
|
|
829
|
+
deleted.push(...nested);
|
|
830
|
+
// Delete the entity
|
|
831
|
+
const exists = this.sql.exec('SELECT id FROM _data WHERE id = ?', toId).toArray();
|
|
832
|
+
if (exists.length > 0) {
|
|
833
|
+
this.sql.exec('DELETE FROM _data WHERE id = ?', toId);
|
|
834
|
+
this.sql.exec('DELETE FROM _rels WHERE from_id = ? OR to_id = ?', toId, toId);
|
|
835
|
+
deleted.push(toId);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return deleted;
|
|
839
|
+
}
|
|
840
|
+
// ===========================================================================
|
|
841
|
+
// _rels handlers
|
|
842
|
+
// ===========================================================================
|
|
843
|
+
handleQueryRels(url) {
|
|
844
|
+
const from_id = url.searchParams.get('from_id');
|
|
845
|
+
const to_id = url.searchParams.get('to_id');
|
|
846
|
+
const relation = url.searchParams.get('relation');
|
|
847
|
+
let query = 'SELECT * FROM _rels WHERE 1=1';
|
|
848
|
+
const params = [];
|
|
849
|
+
if (from_id) {
|
|
850
|
+
query += ' AND from_id = ?';
|
|
851
|
+
params.push(from_id);
|
|
852
|
+
}
|
|
853
|
+
if (to_id) {
|
|
854
|
+
query += ' AND to_id = ?';
|
|
855
|
+
params.push(to_id);
|
|
856
|
+
}
|
|
857
|
+
if (relation) {
|
|
858
|
+
query += ' AND relation = ?';
|
|
859
|
+
params.push(relation);
|
|
860
|
+
}
|
|
861
|
+
const rows = this.sql.exec(query, ...params).toArray();
|
|
862
|
+
const results = rows.map((row) => this.deserializeRelRow(row));
|
|
863
|
+
return Response.json(results);
|
|
864
|
+
}
|
|
865
|
+
async handleCreateRel(request) {
|
|
866
|
+
const body = (await request.json());
|
|
867
|
+
const { from_id, relation, to_id, metadata } = body;
|
|
868
|
+
// Validate required fields
|
|
869
|
+
if (!from_id || !to_id) {
|
|
870
|
+
return Response.json({ error: 'from_id, relation, and to_id are required' }, { status: 400 });
|
|
871
|
+
}
|
|
872
|
+
// Validate relation is not empty
|
|
873
|
+
if (!relation || relation.trim() === '') {
|
|
874
|
+
return Response.json({ error: 'relation cannot be empty' }, { status: 400 });
|
|
875
|
+
}
|
|
876
|
+
// Validate that both entities exist
|
|
877
|
+
const fromExists = this.sql.exec('SELECT id FROM _data WHERE id = ?', from_id).toArray();
|
|
878
|
+
const toExists = this.sql.exec('SELECT id FROM _data WHERE id = ?', to_id).toArray();
|
|
879
|
+
if (fromExists.length === 0) {
|
|
880
|
+
return Response.json({ error: `Source entity '${from_id}' does not exist` }, { status: 400 });
|
|
881
|
+
}
|
|
882
|
+
if (toExists.length === 0) {
|
|
883
|
+
return Response.json({ error: `Target entity '${to_id}' does not exist` }, { status: 400 });
|
|
884
|
+
}
|
|
885
|
+
const metadataJson = metadata ? JSON.stringify(metadata) : null;
|
|
886
|
+
const now = new Date().toISOString();
|
|
887
|
+
const actor = request.headers.get('X-Actor') ?? 'system';
|
|
888
|
+
try {
|
|
889
|
+
this.sql.exec('INSERT INTO _rels (from_id, relation, to_id, metadata, created_at) VALUES (?, ?, ?, ?, ?)', from_id, relation, to_id, metadataJson, now);
|
|
890
|
+
// Emit relationship.created event
|
|
891
|
+
this.emitEvent({
|
|
892
|
+
event: 'relationship.created',
|
|
893
|
+
actor,
|
|
894
|
+
object: `${from_id}->${relation}->${to_id}`,
|
|
895
|
+
data: { from_id, relation, to_id, metadata: metadata ?? null },
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
catch (err) {
|
|
899
|
+
// Handle duplicate composite key -- upsert
|
|
900
|
+
const errMsg = err instanceof Error ? err.message : '';
|
|
901
|
+
if (errMsg.includes('UNIQUE constraint')) {
|
|
902
|
+
this.sql.exec('UPDATE _rels SET metadata = ? WHERE from_id = ? AND relation = ? AND to_id = ?', metadataJson, from_id, relation, to_id);
|
|
903
|
+
// Re-fetch to get the original created_at
|
|
904
|
+
const rows = this.sql
|
|
905
|
+
.exec('SELECT created_at FROM _rels WHERE from_id = ? AND relation = ? AND to_id = ?', from_id, relation, to_id)
|
|
906
|
+
.toArray();
|
|
907
|
+
const relRow = rows[0];
|
|
908
|
+
const result = {
|
|
909
|
+
from_id,
|
|
910
|
+
relation,
|
|
911
|
+
to_id,
|
|
912
|
+
metadata: metadata ?? null,
|
|
913
|
+
created_at: relRow?.created_at ?? now,
|
|
914
|
+
};
|
|
915
|
+
return Response.json(result);
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
throw err;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const result = {
|
|
922
|
+
from_id,
|
|
923
|
+
relation,
|
|
924
|
+
to_id,
|
|
925
|
+
metadata: metadata ?? null,
|
|
926
|
+
created_at: now,
|
|
927
|
+
};
|
|
928
|
+
return Response.json(result);
|
|
929
|
+
}
|
|
930
|
+
async handleDeleteRel(request) {
|
|
931
|
+
const body = (await request.json());
|
|
932
|
+
const { from_id, relation, to_id } = body;
|
|
933
|
+
const existing = this.sql
|
|
934
|
+
.exec('SELECT * FROM _rels WHERE from_id = ? AND relation = ? AND to_id = ?', from_id, relation, to_id)
|
|
935
|
+
.toArray();
|
|
936
|
+
if (existing.length === 0) {
|
|
937
|
+
return Response.json({ deleted: false });
|
|
938
|
+
}
|
|
939
|
+
const existingRel = this.deserializeRelRow(existing[0]);
|
|
940
|
+
this.sql.exec('DELETE FROM _rels WHERE from_id = ? AND relation = ? AND to_id = ?', from_id, relation, to_id);
|
|
941
|
+
// Emit relationship.deleted event
|
|
942
|
+
const actor = request.headers.get('X-Actor') ?? 'system';
|
|
943
|
+
this.emitEvent({
|
|
944
|
+
event: 'relationship.deleted',
|
|
945
|
+
actor,
|
|
946
|
+
object: `${from_id}->${relation}->${to_id}`,
|
|
947
|
+
data: { from_id, relation, to_id, metadata: existingRel['metadata'] },
|
|
948
|
+
});
|
|
949
|
+
return Response.json({ deleted: true });
|
|
950
|
+
}
|
|
951
|
+
// ===========================================================================
|
|
952
|
+
// Traverse handler
|
|
953
|
+
// ===========================================================================
|
|
954
|
+
handleTraverse(url) {
|
|
955
|
+
const from_id = url.searchParams.get('from_id');
|
|
956
|
+
const to_id = url.searchParams.get('to_id');
|
|
957
|
+
const id = url.searchParams.get('id'); // for bidirectional traversal
|
|
958
|
+
const relationParam = url.searchParams.get('relation');
|
|
959
|
+
const typeFilter = url.searchParams.get('type');
|
|
960
|
+
const direction = url.searchParams.get('direction'); // 'in', 'out', 'both'
|
|
961
|
+
const maxDepthParam = url.searchParams.get('maxDepth');
|
|
962
|
+
const includeMetadata = url.searchParams.get('includeMetadata') === 'true';
|
|
963
|
+
// Support multi-hop traversal via comma-separated relations
|
|
964
|
+
const relations = relationParam ? relationParam.split(',') : [];
|
|
965
|
+
const maxDepth = maxDepthParam ? parseInt(maxDepthParam, 10) : relations.length;
|
|
966
|
+
// Bidirectional traversal: id + relation + direction=both
|
|
967
|
+
if (id && relations.length > 0 && direction === 'both') {
|
|
968
|
+
return this.handleBidirectionalTraverse(id, relations[0], typeFilter, includeMetadata);
|
|
969
|
+
}
|
|
970
|
+
// Forward traversal with from_id and direction=out or no direction
|
|
971
|
+
if (from_id && relations.length > 0 && direction !== 'in') {
|
|
972
|
+
return this.handleForwardTraverse(from_id, relations, typeFilter, maxDepth, includeMetadata);
|
|
973
|
+
}
|
|
974
|
+
// Reverse traversal: to_id + relation (with direction=in)
|
|
975
|
+
if (to_id && relations.length > 0) {
|
|
976
|
+
return this.handleReverseTraverse(to_id, relations[0], typeFilter, includeMetadata);
|
|
977
|
+
}
|
|
978
|
+
// Forward traversal with from_id (no relation means all outgoing)
|
|
979
|
+
if (from_id && !relationParam) {
|
|
980
|
+
const rows = this.sql.exec('SELECT to_id FROM _rels WHERE from_id = ?', from_id).toArray();
|
|
981
|
+
const toIds = [...new Set(rows.map((r) => r.to_id))];
|
|
982
|
+
if (toIds.length === 0) {
|
|
983
|
+
return Response.json([]);
|
|
984
|
+
}
|
|
985
|
+
return this.fetchEntitiesByIds(toIds, typeFilter, includeMetadata ? from_id : undefined);
|
|
986
|
+
}
|
|
987
|
+
return Response.json([]);
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Handle bidirectional traversal (both incoming and outgoing)
|
|
991
|
+
*/
|
|
992
|
+
handleBidirectionalTraverse(entityId, relation, typeFilter, includeMetadata) {
|
|
993
|
+
// Get outgoing relationships (entity -> X)
|
|
994
|
+
const outgoing = this.sql
|
|
995
|
+
.exec('SELECT to_id, metadata FROM _rels WHERE from_id = ? AND relation = ?', entityId, relation)
|
|
996
|
+
.toArray();
|
|
997
|
+
// Get incoming relationships (X -> entity)
|
|
998
|
+
const incoming = this.sql
|
|
999
|
+
.exec('SELECT from_id, metadata FROM _rels WHERE to_id = ? AND relation = ?', entityId, relation)
|
|
1000
|
+
.toArray();
|
|
1001
|
+
const resultIds = new Set();
|
|
1002
|
+
const metadataMap = new Map();
|
|
1003
|
+
for (const row of outgoing) {
|
|
1004
|
+
const relRow = row;
|
|
1005
|
+
const toId = relRow.to_id;
|
|
1006
|
+
resultIds.add(toId);
|
|
1007
|
+
if (includeMetadata && relRow.metadata) {
|
|
1008
|
+
const meta = typeof relRow.metadata === 'string' ? JSON.parse(relRow.metadata) : relRow.metadata;
|
|
1009
|
+
metadataMap.set(toId, meta);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
for (const row of incoming) {
|
|
1013
|
+
const relRow = row;
|
|
1014
|
+
const fromId = relRow.from_id;
|
|
1015
|
+
resultIds.add(fromId);
|
|
1016
|
+
if (includeMetadata && relRow.metadata) {
|
|
1017
|
+
const meta = typeof relRow.metadata === 'string' ? JSON.parse(relRow.metadata) : relRow.metadata;
|
|
1018
|
+
metadataMap.set(fromId, meta);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
if (resultIds.size === 0) {
|
|
1022
|
+
return Response.json([]);
|
|
1023
|
+
}
|
|
1024
|
+
return this.fetchEntitiesByIds([...resultIds], typeFilter, includeMetadata ? entityId : undefined, includeMetadata ? metadataMap : undefined);
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Handle forward (outgoing) traversal with depth control
|
|
1028
|
+
*/
|
|
1029
|
+
handleForwardTraverse(fromId, relations, typeFilter, maxDepth, includeMetadata) {
|
|
1030
|
+
let currentIds = [fromId];
|
|
1031
|
+
const metadataMap = new Map();
|
|
1032
|
+
// For single-hop traversal, don't add source to visited to allow self-reference
|
|
1033
|
+
const visited = new Set();
|
|
1034
|
+
if (relations.length > 1) {
|
|
1035
|
+
visited.add(fromId);
|
|
1036
|
+
}
|
|
1037
|
+
// Limit traversal depth
|
|
1038
|
+
const effectiveRelations = relations.slice(0, maxDepth);
|
|
1039
|
+
for (let i = 0; i < effectiveRelations.length; i++) {
|
|
1040
|
+
const rel = effectiveRelations[i];
|
|
1041
|
+
if (currentIds.length === 0)
|
|
1042
|
+
break;
|
|
1043
|
+
const placeholders = currentIds.map(() => '?').join(',');
|
|
1044
|
+
const rows = this.sql
|
|
1045
|
+
.exec(`SELECT to_id, metadata FROM _rels WHERE from_id IN (${placeholders}) AND relation = ?`, ...currentIds, rel)
|
|
1046
|
+
.toArray();
|
|
1047
|
+
const nextIds = new Set();
|
|
1048
|
+
for (const row of rows) {
|
|
1049
|
+
const relRow = row;
|
|
1050
|
+
const toId = relRow.to_id;
|
|
1051
|
+
// For the last hop, don't prevent returning visited nodes (allows self-reference results)
|
|
1052
|
+
// For intermediate hops, use cycle detection to prevent infinite loops
|
|
1053
|
+
const isLastHop = i === effectiveRelations.length - 1;
|
|
1054
|
+
if (isLastHop || !visited.has(toId)) {
|
|
1055
|
+
if (!isLastHop) {
|
|
1056
|
+
visited.add(toId);
|
|
1057
|
+
}
|
|
1058
|
+
nextIds.add(toId);
|
|
1059
|
+
if (includeMetadata && relRow.metadata) {
|
|
1060
|
+
const meta = typeof relRow.metadata === 'string' ? JSON.parse(relRow.metadata) : relRow.metadata;
|
|
1061
|
+
metadataMap.set(toId, meta);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
currentIds = [...nextIds];
|
|
1066
|
+
}
|
|
1067
|
+
if (currentIds.length === 0) {
|
|
1068
|
+
return Response.json([]);
|
|
1069
|
+
}
|
|
1070
|
+
return this.fetchEntitiesByIds(currentIds, typeFilter, includeMetadata ? fromId : undefined, includeMetadata ? metadataMap : undefined);
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Handle reverse (incoming) traversal
|
|
1074
|
+
*/
|
|
1075
|
+
handleReverseTraverse(toId, relation, typeFilter, includeMetadata) {
|
|
1076
|
+
const rows = this.sql
|
|
1077
|
+
.exec('SELECT from_id, metadata FROM _rels WHERE to_id = ? AND relation = ?', toId, relation)
|
|
1078
|
+
.toArray();
|
|
1079
|
+
const fromIds = [];
|
|
1080
|
+
const metadataMap = new Map();
|
|
1081
|
+
for (const row of rows) {
|
|
1082
|
+
const relRow = row;
|
|
1083
|
+
const fromId = relRow.from_id;
|
|
1084
|
+
fromIds.push(fromId);
|
|
1085
|
+
if (includeMetadata && relRow.metadata) {
|
|
1086
|
+
const meta = typeof relRow.metadata === 'string' ? JSON.parse(relRow.metadata) : relRow.metadata;
|
|
1087
|
+
metadataMap.set(fromId, meta);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (fromIds.length === 0) {
|
|
1091
|
+
return Response.json([]);
|
|
1092
|
+
}
|
|
1093
|
+
return this.fetchEntitiesByIds([...new Set(fromIds)], typeFilter, includeMetadata ? toId : undefined, includeMetadata ? metadataMap : undefined);
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Fetch entities by IDs with optional type filter and metadata
|
|
1097
|
+
*/
|
|
1098
|
+
fetchEntitiesByIds(ids, typeFilter, _sourceId, metadataMap) {
|
|
1099
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
1100
|
+
let query = `SELECT * FROM _data WHERE id IN (${placeholders})`;
|
|
1101
|
+
const params = [...ids];
|
|
1102
|
+
if (typeFilter) {
|
|
1103
|
+
query += ' AND type = ?';
|
|
1104
|
+
params.push(typeFilter);
|
|
1105
|
+
}
|
|
1106
|
+
const records = this.sql.exec(query, ...params).toArray();
|
|
1107
|
+
const results = records.map((r) => {
|
|
1108
|
+
const entity = this.deserializeDataRow(r);
|
|
1109
|
+
if (metadataMap && metadataMap.has(entity.id)) {
|
|
1110
|
+
const relData = metadataMap.get(entity.id);
|
|
1111
|
+
if (relData !== undefined) {
|
|
1112
|
+
entity.$rel = relData;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return entity;
|
|
1116
|
+
});
|
|
1117
|
+
return Response.json(results);
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Handle POST /traverse/filter - filter traversal by metadata
|
|
1121
|
+
*/
|
|
1122
|
+
async handleTraverseFilter(request) {
|
|
1123
|
+
let body;
|
|
1124
|
+
try {
|
|
1125
|
+
body = (await request.json());
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
1129
|
+
}
|
|
1130
|
+
const { from_id, relation, filter: metadataFilter } = body;
|
|
1131
|
+
if (!from_id || !relation) {
|
|
1132
|
+
return Response.json({ error: 'from_id and relation are required' }, { status: 400 });
|
|
1133
|
+
}
|
|
1134
|
+
// Get all relationships matching from_id and relation
|
|
1135
|
+
const rows = this.sql
|
|
1136
|
+
.exec('SELECT to_id, metadata FROM _rels WHERE from_id = ? AND relation = ?', from_id, relation)
|
|
1137
|
+
.toArray();
|
|
1138
|
+
const matchingIds = [];
|
|
1139
|
+
for (const row of rows) {
|
|
1140
|
+
const relRow = row;
|
|
1141
|
+
const toId = relRow.to_id;
|
|
1142
|
+
const rawMetadata = relRow.metadata;
|
|
1143
|
+
if (!metadataFilter) {
|
|
1144
|
+
matchingIds.push(toId);
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
// Parse metadata
|
|
1148
|
+
const metadata = rawMetadata
|
|
1149
|
+
? typeof rawMetadata === 'string'
|
|
1150
|
+
? JSON.parse(rawMetadata)
|
|
1151
|
+
: rawMetadata
|
|
1152
|
+
: {};
|
|
1153
|
+
// Apply metadata filter
|
|
1154
|
+
if (this.matchesMetadataFilter(metadata, metadataFilter)) {
|
|
1155
|
+
matchingIds.push(toId);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (matchingIds.length === 0) {
|
|
1159
|
+
return Response.json([]);
|
|
1160
|
+
}
|
|
1161
|
+
// Fetch the entities
|
|
1162
|
+
const placeholders = matchingIds.map(() => '?').join(',');
|
|
1163
|
+
const entities = this.sql
|
|
1164
|
+
.exec(`SELECT * FROM _data WHERE id IN (${placeholders})`, ...matchingIds)
|
|
1165
|
+
.toArray();
|
|
1166
|
+
return Response.json(entities.map((r) => this.deserializeDataRow(r)));
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Check if metadata matches a filter with operator support
|
|
1170
|
+
*/
|
|
1171
|
+
matchesMetadataFilter(metadata, filter) {
|
|
1172
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
1173
|
+
const actual = metadata[field];
|
|
1174
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1175
|
+
// Handle operators
|
|
1176
|
+
const ops = value;
|
|
1177
|
+
for (const [op, opValue] of Object.entries(ops)) {
|
|
1178
|
+
switch (op) {
|
|
1179
|
+
case '$gt':
|
|
1180
|
+
if (!(typeof actual === 'number' && actual > opValue))
|
|
1181
|
+
return false;
|
|
1182
|
+
break;
|
|
1183
|
+
case '$gte':
|
|
1184
|
+
if (!(typeof actual === 'number' && actual >= opValue))
|
|
1185
|
+
return false;
|
|
1186
|
+
break;
|
|
1187
|
+
case '$lt':
|
|
1188
|
+
if (!(typeof actual === 'number' && actual < opValue))
|
|
1189
|
+
return false;
|
|
1190
|
+
break;
|
|
1191
|
+
case '$lte':
|
|
1192
|
+
if (!(typeof actual === 'number' && actual <= opValue))
|
|
1193
|
+
return false;
|
|
1194
|
+
break;
|
|
1195
|
+
case '$ne':
|
|
1196
|
+
if (actual === opValue)
|
|
1197
|
+
return false;
|
|
1198
|
+
break;
|
|
1199
|
+
case '$in':
|
|
1200
|
+
if (!Array.isArray(opValue) || !opValue.includes(actual))
|
|
1201
|
+
return false;
|
|
1202
|
+
break;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
else {
|
|
1207
|
+
// Simple equality
|
|
1208
|
+
if (actual !== value)
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return true;
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Handle PATCH /rels - update relationship metadata
|
|
1216
|
+
*/
|
|
1217
|
+
async handleUpdateRel(request) {
|
|
1218
|
+
let body;
|
|
1219
|
+
try {
|
|
1220
|
+
body = (await request.json());
|
|
1221
|
+
}
|
|
1222
|
+
catch {
|
|
1223
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
1224
|
+
}
|
|
1225
|
+
const { from_id, relation, to_id, metadata } = body;
|
|
1226
|
+
if (!from_id || !relation || !to_id) {
|
|
1227
|
+
return Response.json({ error: 'from_id, relation, and to_id are required' }, { status: 400 });
|
|
1228
|
+
}
|
|
1229
|
+
// Check if relationship exists
|
|
1230
|
+
const existing = this.sql
|
|
1231
|
+
.exec('SELECT * FROM _rels WHERE from_id = ? AND relation = ? AND to_id = ?', from_id, relation, to_id)
|
|
1232
|
+
.toArray();
|
|
1233
|
+
if (existing.length === 0) {
|
|
1234
|
+
return Response.json({ error: 'Relationship not found' }, { status: 404 });
|
|
1235
|
+
}
|
|
1236
|
+
const existingRel = this.deserializeRelRow(existing[0]);
|
|
1237
|
+
// Update metadata
|
|
1238
|
+
const metadataJson = metadata ? JSON.stringify(metadata) : null;
|
|
1239
|
+
this.sql.exec('UPDATE _rels SET metadata = ? WHERE from_id = ? AND relation = ? AND to_id = ?', metadataJson, from_id, relation, to_id);
|
|
1240
|
+
// Emit relationship.updated event
|
|
1241
|
+
const actor = request.headers.get('X-Actor') ?? 'system';
|
|
1242
|
+
this.emitEvent({
|
|
1243
|
+
event: 'relationship.updated',
|
|
1244
|
+
actor,
|
|
1245
|
+
object: `${from_id}->${relation}->${to_id}`,
|
|
1246
|
+
data: { from_id, relation, to_id, metadata: metadata ?? null },
|
|
1247
|
+
previousData: { from_id, relation, to_id, metadata: existingRel['metadata'] },
|
|
1248
|
+
});
|
|
1249
|
+
return Response.json({ from_id, relation, to_id, metadata: metadata ?? null });
|
|
1250
|
+
}
|
|
1251
|
+
// ===========================================================================
|
|
1252
|
+
// Meta handlers
|
|
1253
|
+
// ===========================================================================
|
|
1254
|
+
handleGetIndexes() {
|
|
1255
|
+
const rows = this.sql
|
|
1256
|
+
.exec(`
|
|
1257
|
+
SELECT name, tbl_name as table_name, sql
|
|
1258
|
+
FROM sqlite_master
|
|
1259
|
+
WHERE type = 'index' AND sql IS NOT NULL
|
|
1260
|
+
`)
|
|
1261
|
+
.toArray();
|
|
1262
|
+
return Response.json(rows);
|
|
1263
|
+
}
|
|
1264
|
+
handleGetVersion() {
|
|
1265
|
+
const rows = this.sql.exec(`SELECT value FROM _meta WHERE key = 'version'`).toArray();
|
|
1266
|
+
if (rows.length === 0) {
|
|
1267
|
+
return Response.json({ version: 1 });
|
|
1268
|
+
}
|
|
1269
|
+
const metaRow = rows[0];
|
|
1270
|
+
return Response.json({ version: parseInt(metaRow.value, 10) });
|
|
1271
|
+
}
|
|
1272
|
+
// ===========================================================================
|
|
1273
|
+
// Query handlers
|
|
1274
|
+
// ===========================================================================
|
|
1275
|
+
/**
|
|
1276
|
+
* Validate a field name to prevent SQL injection.
|
|
1277
|
+
* Only allows alphanumeric characters, underscores, and dots for nested fields.
|
|
1278
|
+
*/
|
|
1279
|
+
isValidFieldName(fieldName) {
|
|
1280
|
+
return /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*$/.test(fieldName);
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Build SQL WHERE clause from a where object.
|
|
1284
|
+
* Returns [clause, params] tuple where clause includes the WHERE keyword.
|
|
1285
|
+
*/
|
|
1286
|
+
/**
|
|
1287
|
+
* Convert a value for SQLite JSON comparison.
|
|
1288
|
+
* Booleans need to be converted to 1/0 because SQLite stores JSON booleans as integers.
|
|
1289
|
+
*/
|
|
1290
|
+
toSqliteValue(value) {
|
|
1291
|
+
if (typeof value === 'boolean') {
|
|
1292
|
+
return value ? 1 : 0;
|
|
1293
|
+
}
|
|
1294
|
+
return value;
|
|
1295
|
+
}
|
|
1296
|
+
buildWhereClause(type, where) {
|
|
1297
|
+
const conditions = ['type = ?'];
|
|
1298
|
+
const params = [type];
|
|
1299
|
+
if (where) {
|
|
1300
|
+
for (const [field, value] of Object.entries(where)) {
|
|
1301
|
+
// Validate field name to prevent injection
|
|
1302
|
+
if (!this.isValidFieldName(field)) {
|
|
1303
|
+
continue; // Skip invalid field names silently
|
|
1304
|
+
}
|
|
1305
|
+
// Handle nested field paths like "profile.location"
|
|
1306
|
+
const jsonPath = field.includes('.') ? `$.${field}` : `$.${field}`;
|
|
1307
|
+
if (value === null) {
|
|
1308
|
+
conditions.push(`json_extract(data, ?) IS NULL`);
|
|
1309
|
+
params.push(jsonPath);
|
|
1310
|
+
}
|
|
1311
|
+
else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1312
|
+
// Handle operators: $gt, $lt, $gte, $lte, $in, $ne
|
|
1313
|
+
const ops = value;
|
|
1314
|
+
for (const [op, opValue] of Object.entries(ops)) {
|
|
1315
|
+
const sqliteValue = this.toSqliteValue(opValue);
|
|
1316
|
+
switch (op) {
|
|
1317
|
+
case '$gt':
|
|
1318
|
+
conditions.push(`json_extract(data, ?) > ?`);
|
|
1319
|
+
params.push(jsonPath, sqliteValue);
|
|
1320
|
+
break;
|
|
1321
|
+
case '$lt':
|
|
1322
|
+
conditions.push(`json_extract(data, ?) < ?`);
|
|
1323
|
+
params.push(jsonPath, sqliteValue);
|
|
1324
|
+
break;
|
|
1325
|
+
case '$gte':
|
|
1326
|
+
conditions.push(`json_extract(data, ?) >= ?`);
|
|
1327
|
+
params.push(jsonPath, sqliteValue);
|
|
1328
|
+
break;
|
|
1329
|
+
case '$lte':
|
|
1330
|
+
conditions.push(`json_extract(data, ?) <= ?`);
|
|
1331
|
+
params.push(jsonPath, sqliteValue);
|
|
1332
|
+
break;
|
|
1333
|
+
case '$ne':
|
|
1334
|
+
conditions.push(`json_extract(data, ?) != ?`);
|
|
1335
|
+
params.push(jsonPath, sqliteValue);
|
|
1336
|
+
break;
|
|
1337
|
+
case '$in':
|
|
1338
|
+
if (Array.isArray(opValue) && opValue.length > 0) {
|
|
1339
|
+
const placeholders = opValue.map(() => '?').join(',');
|
|
1340
|
+
conditions.push(`json_extract(data, ?) IN (${placeholders})`);
|
|
1341
|
+
params.push(jsonPath, ...opValue.map((v) => this.toSqliteValue(v)));
|
|
1342
|
+
}
|
|
1343
|
+
break;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
// Simple equality - convert booleans to 1/0 for SQLite JSON comparison
|
|
1349
|
+
conditions.push(`json_extract(data, ?) = ?`);
|
|
1350
|
+
params.push(jsonPath, this.toSqliteValue(value));
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
return {
|
|
1355
|
+
clause: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '',
|
|
1356
|
+
params,
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Build ORDER BY clause from orderBy and order parameters.
|
|
1361
|
+
*/
|
|
1362
|
+
buildOrderByClause(orderBy, order) {
|
|
1363
|
+
if (!orderBy || !this.isValidFieldName(orderBy)) {
|
|
1364
|
+
return ' ORDER BY rowid ASC';
|
|
1365
|
+
}
|
|
1366
|
+
const direction = order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
1367
|
+
const jsonPath = `$.${orderBy}`;
|
|
1368
|
+
return ` ORDER BY json_extract(data, '${jsonPath}') ${direction}`;
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Handle GET /query/list - simple query via URL params
|
|
1372
|
+
*/
|
|
1373
|
+
handleQueryList(url) {
|
|
1374
|
+
const type = url.searchParams.get('type');
|
|
1375
|
+
if (!type) {
|
|
1376
|
+
return Response.json({ error: 'type parameter is required' }, { status: 400 });
|
|
1377
|
+
}
|
|
1378
|
+
const limit = url.searchParams.get('limit');
|
|
1379
|
+
const offset = url.searchParams.get('offset');
|
|
1380
|
+
const orderBy = url.searchParams.get('orderBy');
|
|
1381
|
+
const order = url.searchParams.get('order');
|
|
1382
|
+
const { clause, params } = this.buildWhereClause(type);
|
|
1383
|
+
let query = `SELECT * FROM _data${clause}`;
|
|
1384
|
+
query += this.buildOrderByClause(orderBy, order);
|
|
1385
|
+
const limitNum = limit ? parseInt(limit, 10) : null;
|
|
1386
|
+
const offsetNum = offset ? parseInt(offset, 10) : null;
|
|
1387
|
+
// OFFSET requires LIMIT in SQLite, so add a very large default LIMIT if offset is specified without limit
|
|
1388
|
+
if (offsetNum !== null && offsetNum >= 0) {
|
|
1389
|
+
if (limitNum !== null && limitNum >= 0) {
|
|
1390
|
+
query += ' LIMIT ?';
|
|
1391
|
+
params.push(limitNum);
|
|
1392
|
+
}
|
|
1393
|
+
else {
|
|
1394
|
+
// Use a very large limit when only offset is specified
|
|
1395
|
+
query += ' LIMIT -1';
|
|
1396
|
+
}
|
|
1397
|
+
query += ' OFFSET ?';
|
|
1398
|
+
params.push(offsetNum);
|
|
1399
|
+
}
|
|
1400
|
+
else if (limitNum !== null && limitNum >= 0) {
|
|
1401
|
+
query += ' LIMIT ?';
|
|
1402
|
+
params.push(limitNum);
|
|
1403
|
+
}
|
|
1404
|
+
const rows = this.sql.exec(query, ...params).toArray();
|
|
1405
|
+
const results = rows.map((row) => this.deserializeDataRow(row));
|
|
1406
|
+
return Response.json(results);
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Handle POST /query/list - complex query via JSON body
|
|
1410
|
+
*/
|
|
1411
|
+
async handleQueryListPost(request) {
|
|
1412
|
+
let body;
|
|
1413
|
+
try {
|
|
1414
|
+
body = (await request.json());
|
|
1415
|
+
}
|
|
1416
|
+
catch {
|
|
1417
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
1418
|
+
}
|
|
1419
|
+
const { type, where, orderBy, order, limit, offset } = body;
|
|
1420
|
+
if (!type) {
|
|
1421
|
+
return Response.json({ error: 'type field is required' }, { status: 400 });
|
|
1422
|
+
}
|
|
1423
|
+
const { clause, params } = this.buildWhereClause(type, where);
|
|
1424
|
+
let query = `SELECT * FROM _data${clause}`;
|
|
1425
|
+
query += this.buildOrderByClause(orderBy, order);
|
|
1426
|
+
// OFFSET requires LIMIT in SQLite, so add a very large default LIMIT if offset is specified without limit
|
|
1427
|
+
if (typeof offset === 'number' && offset >= 0) {
|
|
1428
|
+
if (typeof limit === 'number' && limit >= 0) {
|
|
1429
|
+
query += ' LIMIT ?';
|
|
1430
|
+
params.push(limit);
|
|
1431
|
+
}
|
|
1432
|
+
else {
|
|
1433
|
+
// Use a very large limit when only offset is specified
|
|
1434
|
+
query += ' LIMIT -1';
|
|
1435
|
+
}
|
|
1436
|
+
query += ' OFFSET ?';
|
|
1437
|
+
params.push(offset);
|
|
1438
|
+
}
|
|
1439
|
+
else if (typeof limit === 'number' && limit >= 0) {
|
|
1440
|
+
query += ' LIMIT ?';
|
|
1441
|
+
params.push(limit);
|
|
1442
|
+
}
|
|
1443
|
+
const rows = this.sql.exec(query, ...params).toArray();
|
|
1444
|
+
const results = rows.map((row) => this.deserializeDataRow(row));
|
|
1445
|
+
return Response.json(results);
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Handle POST /query/find - find first matching record
|
|
1449
|
+
*/
|
|
1450
|
+
async handleQueryFind(request) {
|
|
1451
|
+
let body;
|
|
1452
|
+
try {
|
|
1453
|
+
body = (await request.json());
|
|
1454
|
+
}
|
|
1455
|
+
catch {
|
|
1456
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
1457
|
+
}
|
|
1458
|
+
const { type, where, orderBy, order } = body;
|
|
1459
|
+
if (!type) {
|
|
1460
|
+
return Response.json({ error: 'type field is required' }, { status: 400 });
|
|
1461
|
+
}
|
|
1462
|
+
const { clause, params } = this.buildWhereClause(type, where);
|
|
1463
|
+
let query = `SELECT * FROM _data${clause}`;
|
|
1464
|
+
query += this.buildOrderByClause(orderBy, order);
|
|
1465
|
+
query += ' LIMIT 1';
|
|
1466
|
+
const rows = this.sql.exec(query, ...params).toArray();
|
|
1467
|
+
if (rows.length === 0) {
|
|
1468
|
+
return Response.json(null);
|
|
1469
|
+
}
|
|
1470
|
+
return Response.json(this.deserializeDataRow(rows[0]));
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Escape special characters in LIKE patterns
|
|
1474
|
+
*/
|
|
1475
|
+
escapeLikePattern(pattern) {
|
|
1476
|
+
// Escape %, _, and \ for LIKE
|
|
1477
|
+
return pattern.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Calculate a simple relevance score based on term matches
|
|
1481
|
+
*/
|
|
1482
|
+
calculateRelevanceScore(text, query) {
|
|
1483
|
+
const lowerText = text.toLowerCase();
|
|
1484
|
+
const lowerQuery = query.toLowerCase();
|
|
1485
|
+
const terms = lowerQuery.split(/\s+/).filter((t) => t.length > 0);
|
|
1486
|
+
if (terms.length === 0)
|
|
1487
|
+
return 0;
|
|
1488
|
+
let matchCount = 0;
|
|
1489
|
+
let exactMatch = lowerText.includes(lowerQuery);
|
|
1490
|
+
for (const term of terms) {
|
|
1491
|
+
if (lowerText.includes(term)) {
|
|
1492
|
+
matchCount++;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
// Score: exact match gets bonus, then percentage of terms matched
|
|
1496
|
+
const baseScore = matchCount / terms.length;
|
|
1497
|
+
return exactMatch ? Math.min(1, baseScore + 0.3) : baseScore;
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Handle POST /query/search - full-text search
|
|
1501
|
+
*/
|
|
1502
|
+
async handleQuerySearch(request) {
|
|
1503
|
+
let body;
|
|
1504
|
+
try {
|
|
1505
|
+
body = (await request.json());
|
|
1506
|
+
}
|
|
1507
|
+
catch {
|
|
1508
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
1509
|
+
}
|
|
1510
|
+
const { type, query: searchQuery, fields, limit, minScore } = body;
|
|
1511
|
+
if (!type) {
|
|
1512
|
+
return Response.json({ error: 'type field is required' }, { status: 400 });
|
|
1513
|
+
}
|
|
1514
|
+
if (!searchQuery || typeof searchQuery !== 'string') {
|
|
1515
|
+
return Response.json({ error: 'query field is required' }, { status: 400 });
|
|
1516
|
+
}
|
|
1517
|
+
// Escape the search query for LIKE
|
|
1518
|
+
const escapedQuery = this.escapeLikePattern(searchQuery);
|
|
1519
|
+
const likePattern = `%${escapedQuery}%`;
|
|
1520
|
+
// Get all records of this type
|
|
1521
|
+
const rows = this.sql.exec('SELECT * FROM _data WHERE type = ?', type).toArray();
|
|
1522
|
+
// Filter and score results
|
|
1523
|
+
const results = [];
|
|
1524
|
+
for (const row of rows) {
|
|
1525
|
+
const record = this.deserializeDataRow(row);
|
|
1526
|
+
const data = record.data;
|
|
1527
|
+
// Determine which fields to search
|
|
1528
|
+
const searchFields = Array.isArray(fields) && fields.length > 0
|
|
1529
|
+
? fields
|
|
1530
|
+
: Object.keys(data).filter((k) => typeof data[k] === 'string' || typeof data[k] === 'number');
|
|
1531
|
+
// Search across fields
|
|
1532
|
+
let maxScore = 0;
|
|
1533
|
+
let hasMatch = false;
|
|
1534
|
+
for (const field of searchFields) {
|
|
1535
|
+
const value = data[field];
|
|
1536
|
+
if (value === undefined || value === null)
|
|
1537
|
+
continue;
|
|
1538
|
+
const stringValue = String(value);
|
|
1539
|
+
// Case-insensitive comparison
|
|
1540
|
+
if (stringValue.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
1541
|
+
hasMatch = true;
|
|
1542
|
+
const fieldScore = this.calculateRelevanceScore(stringValue, searchQuery);
|
|
1543
|
+
maxScore = Math.max(maxScore, fieldScore);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
if (hasMatch) {
|
|
1547
|
+
// Apply minScore filter
|
|
1548
|
+
if (typeof minScore === 'number' && maxScore < minScore) {
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
results.push({ row: record, score: maxScore });
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
// Sort by score descending
|
|
1555
|
+
results.sort((a, b) => b.score - a.score);
|
|
1556
|
+
// Apply limit
|
|
1557
|
+
const limitNum = typeof limit === 'number' && limit > 0 ? limit : results.length;
|
|
1558
|
+
const limited = results.slice(0, limitNum);
|
|
1559
|
+
// Add score to results
|
|
1560
|
+
const finalResults = limited.map(({ row, score }) => ({
|
|
1561
|
+
...row,
|
|
1562
|
+
$score: score,
|
|
1563
|
+
}));
|
|
1564
|
+
return Response.json(finalResults);
|
|
1565
|
+
}
|
|
1566
|
+
// ===========================================================================
|
|
1567
|
+
// Events handlers
|
|
1568
|
+
// ===========================================================================
|
|
1569
|
+
/**
|
|
1570
|
+
* Emit an event to the _events table and pipeline
|
|
1571
|
+
*/
|
|
1572
|
+
emitEvent(options) {
|
|
1573
|
+
const id = crypto.randomUUID();
|
|
1574
|
+
const timestamp = new Date().toISOString();
|
|
1575
|
+
const actor = options.actor ?? 'system';
|
|
1576
|
+
this.sql.exec(`INSERT INTO _events (id, event, actor, object, data, result, previous_data, timestamp)
|
|
1577
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, id, options.event, actor, options.object ?? null, options.data ? JSON.stringify(options.data) : null, options.result ?? null, options.previousData ? JSON.stringify(options.previousData) : null, timestamp);
|
|
1578
|
+
const eventRecord = {
|
|
1579
|
+
id,
|
|
1580
|
+
event: options.event,
|
|
1581
|
+
actor,
|
|
1582
|
+
object: options.object ?? null,
|
|
1583
|
+
data: options.data ?? null,
|
|
1584
|
+
result: options.result ?? null,
|
|
1585
|
+
previousData: options.previousData ?? null,
|
|
1586
|
+
timestamp,
|
|
1587
|
+
};
|
|
1588
|
+
// Add to pipeline buffer
|
|
1589
|
+
this.pipelineBuffer.push(eventRecord);
|
|
1590
|
+
this.pipelineStats.eventsProcessed++;
|
|
1591
|
+
// Auto-flush if buffer reaches batch size
|
|
1592
|
+
if (this.pipelineBuffer.length >= this.pipelineConfig.batchSize) {
|
|
1593
|
+
this.flushPipeline();
|
|
1594
|
+
}
|
|
1595
|
+
return eventRecord;
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Handle GET /events - list events with filtering
|
|
1599
|
+
*/
|
|
1600
|
+
handleListEvents(url) {
|
|
1601
|
+
const event = url.searchParams.get('event');
|
|
1602
|
+
const object = url.searchParams.get('object');
|
|
1603
|
+
const since = url.searchParams.get('since');
|
|
1604
|
+
const until = url.searchParams.get('until');
|
|
1605
|
+
const limit = url.searchParams.get('limit');
|
|
1606
|
+
const offset = url.searchParams.get('offset');
|
|
1607
|
+
const cursor = url.searchParams.get('cursor');
|
|
1608
|
+
const order = url.searchParams.get('order') ?? 'desc';
|
|
1609
|
+
let query = 'SELECT * FROM _events WHERE 1=1';
|
|
1610
|
+
const params = [];
|
|
1611
|
+
// Filter by event type (with wildcard support)
|
|
1612
|
+
if (event) {
|
|
1613
|
+
if (event.startsWith('*.')) {
|
|
1614
|
+
// Wildcard at start: *.created matches Post.created, User.created, etc.
|
|
1615
|
+
const suffix = event.slice(2); // Remove '*.'
|
|
1616
|
+
query += ' AND event LIKE ?';
|
|
1617
|
+
params.push(`%.${suffix}`);
|
|
1618
|
+
}
|
|
1619
|
+
else if (event.endsWith('.*')) {
|
|
1620
|
+
// Wildcard at end: Post.* matches Post.created, Post.updated, etc.
|
|
1621
|
+
const prefix = event.slice(0, -2); // Remove '.*'
|
|
1622
|
+
query += ' AND event LIKE ?';
|
|
1623
|
+
params.push(`${prefix}.%`);
|
|
1624
|
+
}
|
|
1625
|
+
else {
|
|
1626
|
+
query += ' AND event = ?';
|
|
1627
|
+
params.push(event);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
// Filter by object
|
|
1631
|
+
if (object) {
|
|
1632
|
+
query += ' AND object = ?';
|
|
1633
|
+
params.push(object);
|
|
1634
|
+
}
|
|
1635
|
+
// Filter by time range
|
|
1636
|
+
if (since) {
|
|
1637
|
+
query += ' AND timestamp >= ?';
|
|
1638
|
+
params.push(since);
|
|
1639
|
+
}
|
|
1640
|
+
if (until) {
|
|
1641
|
+
query += ' AND timestamp <= ?';
|
|
1642
|
+
params.push(until);
|
|
1643
|
+
}
|
|
1644
|
+
// Cursor-based pagination (events after the cursor ID)
|
|
1645
|
+
if (cursor) {
|
|
1646
|
+
// Get the timestamp of the cursor event
|
|
1647
|
+
const cursorRows = this.sql
|
|
1648
|
+
.exec('SELECT timestamp FROM _events WHERE id = ?', cursor)
|
|
1649
|
+
.toArray();
|
|
1650
|
+
if (cursorRows.length > 0) {
|
|
1651
|
+
const cursorEvent = cursorRows[0];
|
|
1652
|
+
const cursorTs = cursorEvent.timestamp;
|
|
1653
|
+
if (order === 'asc') {
|
|
1654
|
+
query += ' AND (timestamp > ? OR (timestamp = ? AND id > ?))';
|
|
1655
|
+
params.push(cursorTs, cursorTs, cursor);
|
|
1656
|
+
}
|
|
1657
|
+
else {
|
|
1658
|
+
query += ' AND (timestamp < ? OR (timestamp = ? AND id < ?))';
|
|
1659
|
+
params.push(cursorTs, cursorTs, cursor);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
// Order by timestamp
|
|
1664
|
+
query +=
|
|
1665
|
+
order === 'asc' ? ' ORDER BY timestamp ASC, id ASC' : ' ORDER BY timestamp DESC, id DESC';
|
|
1666
|
+
// Apply limit
|
|
1667
|
+
if (limit) {
|
|
1668
|
+
query += ' LIMIT ?';
|
|
1669
|
+
params.push(parseInt(limit, 10));
|
|
1670
|
+
}
|
|
1671
|
+
// Apply offset
|
|
1672
|
+
if (offset && !cursor) {
|
|
1673
|
+
query += ' OFFSET ?';
|
|
1674
|
+
params.push(parseInt(offset, 10));
|
|
1675
|
+
}
|
|
1676
|
+
const rows = this.sql.exec(query, ...params).toArray();
|
|
1677
|
+
const results = rows.map((row) => this.deserializeEventRow(row));
|
|
1678
|
+
return Response.json(results);
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Handle POST /events - create custom event
|
|
1682
|
+
*/
|
|
1683
|
+
async handleCreateEvent(request) {
|
|
1684
|
+
let body;
|
|
1685
|
+
try {
|
|
1686
|
+
body = (await request.json());
|
|
1687
|
+
}
|
|
1688
|
+
catch {
|
|
1689
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
1690
|
+
}
|
|
1691
|
+
const { event, actor, object, data, result } = body;
|
|
1692
|
+
if (!event || typeof event !== 'string') {
|
|
1693
|
+
return Response.json({ error: 'event field is required' }, { status: 400 });
|
|
1694
|
+
}
|
|
1695
|
+
const eventRecord = this.emitEvent({
|
|
1696
|
+
event: event,
|
|
1697
|
+
actor: actor ?? 'system',
|
|
1698
|
+
...(object !== undefined && { object: object }),
|
|
1699
|
+
data,
|
|
1700
|
+
...(result !== undefined && { result: result }),
|
|
1701
|
+
});
|
|
1702
|
+
return Response.json(eventRecord);
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* Handle POST /events/replay - replay events
|
|
1706
|
+
*/
|
|
1707
|
+
async handleReplayEvents(request) {
|
|
1708
|
+
let body;
|
|
1709
|
+
try {
|
|
1710
|
+
body = (await request.json());
|
|
1711
|
+
}
|
|
1712
|
+
catch {
|
|
1713
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
1714
|
+
}
|
|
1715
|
+
const { source, object, event: eventFilter, since } = body;
|
|
1716
|
+
let query = 'SELECT * FROM _events WHERE 1=1';
|
|
1717
|
+
const params = [];
|
|
1718
|
+
if (object) {
|
|
1719
|
+
query += ' AND object = ?';
|
|
1720
|
+
params.push(object);
|
|
1721
|
+
}
|
|
1722
|
+
if (eventFilter) {
|
|
1723
|
+
query += ' AND event = ?';
|
|
1724
|
+
params.push(eventFilter);
|
|
1725
|
+
}
|
|
1726
|
+
if (since) {
|
|
1727
|
+
query += ' AND timestamp > ?';
|
|
1728
|
+
params.push(since);
|
|
1729
|
+
}
|
|
1730
|
+
query += ' ORDER BY timestamp ASC';
|
|
1731
|
+
const rows = this.sql.exec(query, ...params).toArray();
|
|
1732
|
+
const events = rows.map((row) => this.deserializeEventRow(row));
|
|
1733
|
+
return Response.json({
|
|
1734
|
+
source: source ?? 'local',
|
|
1735
|
+
eventsReplayed: events.length,
|
|
1736
|
+
events,
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Handle POST /events/rebuild - rebuild entity from events
|
|
1741
|
+
*/
|
|
1742
|
+
async handleRebuildEntity(request) {
|
|
1743
|
+
let body;
|
|
1744
|
+
try {
|
|
1745
|
+
body = (await request.json());
|
|
1746
|
+
}
|
|
1747
|
+
catch {
|
|
1748
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
1749
|
+
}
|
|
1750
|
+
const { object } = body;
|
|
1751
|
+
if (!object || typeof object !== 'string') {
|
|
1752
|
+
return Response.json({ error: 'object field is required' }, { status: 400 });
|
|
1753
|
+
}
|
|
1754
|
+
// Parse object format: Type/id
|
|
1755
|
+
const [type, id] = object.split('/');
|
|
1756
|
+
if (!type || !id) {
|
|
1757
|
+
return Response.json({ error: 'Invalid object format, expected Type/id' }, { status: 400 });
|
|
1758
|
+
}
|
|
1759
|
+
// Get all events for this object in chronological order
|
|
1760
|
+
const rows = this.sql
|
|
1761
|
+
.exec('SELECT * FROM _events WHERE object = ? ORDER BY timestamp ASC', object)
|
|
1762
|
+
.toArray();
|
|
1763
|
+
if (rows.length === 0) {
|
|
1764
|
+
return Response.json({ error: 'No events found for this object' }, { status: 404 });
|
|
1765
|
+
}
|
|
1766
|
+
// Rebuild the entity by applying events (excluding delete events)
|
|
1767
|
+
// This allows us to restore deleted entities to their state before deletion
|
|
1768
|
+
let entityData = {};
|
|
1769
|
+
let hasData = false;
|
|
1770
|
+
for (const row of rows) {
|
|
1771
|
+
const event = this.deserializeEventRow(row);
|
|
1772
|
+
const eventType = event['event'];
|
|
1773
|
+
if (eventType.endsWith('.created')) {
|
|
1774
|
+
entityData = event['data'] ?? {};
|
|
1775
|
+
hasData = true;
|
|
1776
|
+
}
|
|
1777
|
+
else if (eventType.endsWith('.updated')) {
|
|
1778
|
+
entityData = { ...entityData, ...(event['data'] ?? {}) };
|
|
1779
|
+
hasData = true;
|
|
1780
|
+
}
|
|
1781
|
+
// Skip .deleted events - we want to restore to the state before deletion
|
|
1782
|
+
}
|
|
1783
|
+
if (!hasData) {
|
|
1784
|
+
return Response.json({ error: 'No data events found for this object' }, { status: 400 });
|
|
1785
|
+
}
|
|
1786
|
+
// Recreate the entity
|
|
1787
|
+
const now = new Date().toISOString();
|
|
1788
|
+
const dataJson = JSON.stringify(entityData);
|
|
1789
|
+
// Check if entity already exists
|
|
1790
|
+
const existing = this.sql.exec('SELECT id FROM _data WHERE id = ?', id).toArray();
|
|
1791
|
+
if (existing.length > 0) {
|
|
1792
|
+
// Update existing
|
|
1793
|
+
this.sql.exec('UPDATE _data SET data = ?, updated_at = ? WHERE id = ?', dataJson, now, id);
|
|
1794
|
+
}
|
|
1795
|
+
else {
|
|
1796
|
+
// Insert new
|
|
1797
|
+
this.sql.exec('INSERT INTO _data (id, type, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', id, type, dataJson, now, now);
|
|
1798
|
+
}
|
|
1799
|
+
return Response.json({
|
|
1800
|
+
id,
|
|
1801
|
+
type,
|
|
1802
|
+
data: entityData,
|
|
1803
|
+
rebuilt: true,
|
|
1804
|
+
eventsApplied: rows.length,
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Handle POST /events/subscribe - create subscription
|
|
1809
|
+
*/
|
|
1810
|
+
async handleSubscribe(request) {
|
|
1811
|
+
let body;
|
|
1812
|
+
try {
|
|
1813
|
+
body = (await request.json());
|
|
1814
|
+
}
|
|
1815
|
+
catch {
|
|
1816
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
1817
|
+
}
|
|
1818
|
+
const { pattern, webhook } = body;
|
|
1819
|
+
if (!pattern || !webhook) {
|
|
1820
|
+
return Response.json({ error: 'pattern and webhook are required' }, { status: 400 });
|
|
1821
|
+
}
|
|
1822
|
+
const id = crypto.randomUUID();
|
|
1823
|
+
const now = new Date().toISOString();
|
|
1824
|
+
this.sql.exec('INSERT INTO _subscriptions (id, pattern, webhook, created_at) VALUES (?, ?, ?, ?)', id, pattern, webhook, now);
|
|
1825
|
+
return Response.json({
|
|
1826
|
+
id,
|
|
1827
|
+
pattern,
|
|
1828
|
+
webhook,
|
|
1829
|
+
created_at: now,
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Handle GET /events/subscriptions - list subscriptions
|
|
1834
|
+
*/
|
|
1835
|
+
handleListSubscriptions() {
|
|
1836
|
+
const rows = this.sql.exec('SELECT * FROM _subscriptions ORDER BY created_at ASC').toArray();
|
|
1837
|
+
return Response.json(rows);
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Handle DELETE /events/subscriptions/:id - unsubscribe
|
|
1841
|
+
*/
|
|
1842
|
+
handleUnsubscribe(id) {
|
|
1843
|
+
const existing = this.sql.exec('SELECT id FROM _subscriptions WHERE id = ?', id).toArray();
|
|
1844
|
+
if (existing.length === 0) {
|
|
1845
|
+
return Response.json({ error: 'Subscription not found' }, { status: 404 });
|
|
1846
|
+
}
|
|
1847
|
+
this.sql.exec('DELETE FROM _subscriptions WHERE id = ?', id);
|
|
1848
|
+
return Response.json({ deleted: true });
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Handle GET /events/subscriptions/:id/deliveries - list deliveries
|
|
1852
|
+
*/
|
|
1853
|
+
handleListDeliveries(subId) {
|
|
1854
|
+
// Check if subscription exists
|
|
1855
|
+
const existing = this.sql.exec('SELECT id FROM _subscriptions WHERE id = ?', subId).toArray();
|
|
1856
|
+
if (existing.length === 0) {
|
|
1857
|
+
return Response.json({ error: 'Subscription not found' }, { status: 404 });
|
|
1858
|
+
}
|
|
1859
|
+
// In a real implementation, this would return delivery logs
|
|
1860
|
+
// For now, return empty array as deliveries are not persisted
|
|
1861
|
+
return Response.json([]);
|
|
1862
|
+
}
|
|
1863
|
+
// ===========================================================================
|
|
1864
|
+
// Pipeline handlers
|
|
1865
|
+
// ===========================================================================
|
|
1866
|
+
/**
|
|
1867
|
+
* Handle GET /pipeline/status - get pipeline status
|
|
1868
|
+
*/
|
|
1869
|
+
handlePipelineStatus() {
|
|
1870
|
+
return Response.json({
|
|
1871
|
+
eventsProcessed: this.pipelineStats.eventsProcessed,
|
|
1872
|
+
batchesSent: this.pipelineStats.batchesSent,
|
|
1873
|
+
bufferSize: this.pipelineBuffer.length,
|
|
1874
|
+
retriesEnabled: this.pipelineConfig.retryEnabled,
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* Handle POST /pipeline/flush - flush pipeline buffer to R2
|
|
1879
|
+
*/
|
|
1880
|
+
handlePipelineFlush() {
|
|
1881
|
+
this.flushPipeline();
|
|
1882
|
+
return Response.json({ flushed: true, batchesSent: this.pipelineStats.batchesSent });
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Flush the pipeline buffer to R2 storage
|
|
1886
|
+
*/
|
|
1887
|
+
flushPipeline() {
|
|
1888
|
+
if (this.pipelineBuffer.length === 0)
|
|
1889
|
+
return;
|
|
1890
|
+
// Store events in _pipeline_r2 for simulating R2 storage
|
|
1891
|
+
const now = new Date();
|
|
1892
|
+
const year = now.getUTCFullYear();
|
|
1893
|
+
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
1894
|
+
const day = String(now.getUTCDate()).padStart(2, '0');
|
|
1895
|
+
const batchId = crypto.randomUUID();
|
|
1896
|
+
const key = `events/${year}/${month}/${day}/${batchId}.json`;
|
|
1897
|
+
// Create R2 storage table if not exists
|
|
1898
|
+
this.sql.exec(`
|
|
1899
|
+
CREATE TABLE IF NOT EXISTS _pipeline_r2 (
|
|
1900
|
+
key TEXT PRIMARY KEY,
|
|
1901
|
+
data TEXT NOT NULL,
|
|
1902
|
+
created_at TEXT NOT NULL
|
|
1903
|
+
)
|
|
1904
|
+
`);
|
|
1905
|
+
// Store the batch
|
|
1906
|
+
this.sql.exec('INSERT INTO _pipeline_r2 (key, data, created_at) VALUES (?, ?, ?)', key, JSON.stringify(this.pipelineBuffer), now.toISOString());
|
|
1907
|
+
this.pipelineStats.batchesSent++;
|
|
1908
|
+
this.pipelineBuffer = [];
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* Handle GET /pipeline/r2/list - list R2 objects
|
|
1912
|
+
*/
|
|
1913
|
+
handlePipelineR2List() {
|
|
1914
|
+
// Ensure table exists
|
|
1915
|
+
this.sql.exec(`
|
|
1916
|
+
CREATE TABLE IF NOT EXISTS _pipeline_r2 (
|
|
1917
|
+
key TEXT PRIMARY KEY,
|
|
1918
|
+
data TEXT NOT NULL,
|
|
1919
|
+
created_at TEXT NOT NULL
|
|
1920
|
+
)
|
|
1921
|
+
`);
|
|
1922
|
+
const rows = this.sql
|
|
1923
|
+
.exec('SELECT key, created_at FROM _pipeline_r2 ORDER BY created_at ASC')
|
|
1924
|
+
.toArray();
|
|
1925
|
+
return Response.json(rows);
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* Handle POST /pipeline/config - configure pipeline
|
|
1929
|
+
*/
|
|
1930
|
+
async handlePipelineConfig(request) {
|
|
1931
|
+
let body;
|
|
1932
|
+
try {
|
|
1933
|
+
body = (await request.json());
|
|
1934
|
+
}
|
|
1935
|
+
catch {
|
|
1936
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
1937
|
+
}
|
|
1938
|
+
if (typeof body['retryEnabled'] === 'boolean') {
|
|
1939
|
+
this.pipelineConfig.retryEnabled = body['retryEnabled'];
|
|
1940
|
+
}
|
|
1941
|
+
if (typeof body['batchSize'] === 'number') {
|
|
1942
|
+
this.pipelineConfig.batchSize = body['batchSize'];
|
|
1943
|
+
}
|
|
1944
|
+
return Response.json(this.pipelineConfig);
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* Handle POST /pipeline/test-error - test error handling
|
|
1948
|
+
*/
|
|
1949
|
+
async handlePipelineTestError(request) {
|
|
1950
|
+
let body;
|
|
1951
|
+
try {
|
|
1952
|
+
body = (await request.json());
|
|
1953
|
+
}
|
|
1954
|
+
catch {
|
|
1955
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
1956
|
+
}
|
|
1957
|
+
if (body['simulateError']) {
|
|
1958
|
+
// Return error but don't crash
|
|
1959
|
+
return Response.json({ error: 'Simulated pipeline error' }, { status: 503 });
|
|
1960
|
+
}
|
|
1961
|
+
return Response.json({ ok: true });
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Deserialize an event row from the database
|
|
1965
|
+
*/
|
|
1966
|
+
deserializeEventRow(row) {
|
|
1967
|
+
const r = row;
|
|
1968
|
+
return {
|
|
1969
|
+
id: r.id,
|
|
1970
|
+
event: r.event,
|
|
1971
|
+
actor: r.actor,
|
|
1972
|
+
object: r.object,
|
|
1973
|
+
data: r.data ? (typeof r.data === 'string' ? JSON.parse(r.data) : r.data) : null,
|
|
1974
|
+
result: r.result,
|
|
1975
|
+
previousData: r.previous_data
|
|
1976
|
+
? typeof r.previous_data === 'string'
|
|
1977
|
+
? JSON.parse(r.previous_data)
|
|
1978
|
+
: r.previous_data
|
|
1979
|
+
: null,
|
|
1980
|
+
timestamp: r.timestamp,
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
// ===========================================================================
|
|
1984
|
+
// Helpers
|
|
1985
|
+
// ===========================================================================
|
|
1986
|
+
deserializeDataRow(row) {
|
|
1987
|
+
const r = row;
|
|
1988
|
+
return {
|
|
1989
|
+
id: r.id,
|
|
1990
|
+
type: r.type,
|
|
1991
|
+
data: typeof r.data === 'string' ? JSON.parse(r.data) : r.data,
|
|
1992
|
+
created_at: r.created_at,
|
|
1993
|
+
updated_at: r.updated_at,
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
deserializeRelRow(row) {
|
|
1997
|
+
const r = row;
|
|
1998
|
+
return {
|
|
1999
|
+
from_id: r.from_id,
|
|
2000
|
+
relation: r.relation,
|
|
2001
|
+
to_id: r.to_id,
|
|
2002
|
+
metadata: r.metadata
|
|
2003
|
+
? typeof r.metadata === 'string'
|
|
2004
|
+
? JSON.parse(r.metadata)
|
|
2005
|
+
: r.metadata
|
|
2006
|
+
: null,
|
|
2007
|
+
created_at: r.created_at,
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
// ===========================================================================
|
|
2011
|
+
// Semantic Search Handlers
|
|
2012
|
+
// ===========================================================================
|
|
2013
|
+
/**
|
|
2014
|
+
* Configure embeddings model
|
|
2015
|
+
*/
|
|
2016
|
+
async handleConfigureEmbeddings(request) {
|
|
2017
|
+
let body;
|
|
2018
|
+
try {
|
|
2019
|
+
body = (await request.json());
|
|
2020
|
+
}
|
|
2021
|
+
catch {
|
|
2022
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
2023
|
+
}
|
|
2024
|
+
if (body['model']) {
|
|
2025
|
+
this.embeddingsConfig.model = body['model'];
|
|
2026
|
+
}
|
|
2027
|
+
return Response.json(this.embeddingsConfig);
|
|
2028
|
+
}
|
|
2029
|
+
/**
|
|
2030
|
+
* List all embeddings with optional type filter
|
|
2031
|
+
* Generates embeddings lazily for entities that don't have them yet
|
|
2032
|
+
*/
|
|
2033
|
+
async handleListEmbeddings(url) {
|
|
2034
|
+
const entityType = url.searchParams.get('entity_type');
|
|
2035
|
+
// If type is specified, generate embeddings for entities without them (lazy generation)
|
|
2036
|
+
if (entityType) {
|
|
2037
|
+
const entities = this.sql.exec('SELECT * FROM _data WHERE type = ?', entityType).toArray();
|
|
2038
|
+
for (const row of entities) {
|
|
2039
|
+
const entity = this.deserializeDataRow(row);
|
|
2040
|
+
const existing = this.sql
|
|
2041
|
+
.exec('SELECT id FROM _embeddings WHERE entity_type = ? AND entity_id = ?', entityType, entity['id'])
|
|
2042
|
+
.toArray();
|
|
2043
|
+
if (existing.length === 0) {
|
|
2044
|
+
await this.generateEmbeddingForEntity(entityType, entity['id'], entity['data']);
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
let query = 'SELECT * FROM _embeddings';
|
|
2049
|
+
const params = [];
|
|
2050
|
+
if (entityType) {
|
|
2051
|
+
query += ' WHERE entity_type = ?';
|
|
2052
|
+
params.push(entityType);
|
|
2053
|
+
}
|
|
2054
|
+
query += ' ORDER BY created_at ASC';
|
|
2055
|
+
const rows = this.sql.exec(query, ...params).toArray();
|
|
2056
|
+
const results = rows.map((row) => {
|
|
2057
|
+
const embRow = row;
|
|
2058
|
+
return {
|
|
2059
|
+
entity_type: embRow.entity_type,
|
|
2060
|
+
entity_id: embRow.entity_id,
|
|
2061
|
+
model: embRow.model,
|
|
2062
|
+
vector: typeof embRow.vector === 'string' ? JSON.parse(embRow.vector) : embRow.vector,
|
|
2063
|
+
content_hash: embRow.content_hash,
|
|
2064
|
+
created_at: embRow.created_at,
|
|
2065
|
+
updated_at: embRow.updated_at,
|
|
2066
|
+
};
|
|
2067
|
+
});
|
|
2068
|
+
return Response.json(results);
|
|
2069
|
+
}
|
|
2070
|
+
/**
|
|
2071
|
+
* Get embedding cache stats
|
|
2072
|
+
*/
|
|
2073
|
+
handleEmbeddingsStats() {
|
|
2074
|
+
return Response.json(this.embeddingsCacheStats);
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Warm up embedding cache for a type
|
|
2078
|
+
*/
|
|
2079
|
+
async handleEmbeddingsWarmup(request) {
|
|
2080
|
+
let body;
|
|
2081
|
+
try {
|
|
2082
|
+
body = (await request.json());
|
|
2083
|
+
}
|
|
2084
|
+
catch {
|
|
2085
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
2086
|
+
}
|
|
2087
|
+
const { type: rawType } = body;
|
|
2088
|
+
if (!rawType || typeof rawType !== 'string') {
|
|
2089
|
+
return Response.json({ error: 'type field is required' }, { status: 400 });
|
|
2090
|
+
}
|
|
2091
|
+
const type = rawType;
|
|
2092
|
+
// Get all entities of this type
|
|
2093
|
+
const entities = this.sql.exec('SELECT * FROM _data WHERE type = ?', type).toArray();
|
|
2094
|
+
let warmed = 0;
|
|
2095
|
+
for (const row of entities) {
|
|
2096
|
+
const entity = this.deserializeDataRow(row);
|
|
2097
|
+
// Check if embedding already exists
|
|
2098
|
+
const existing = this.sql
|
|
2099
|
+
.exec('SELECT id FROM _embeddings WHERE entity_type = ? AND entity_id = ?', type, entity['id'])
|
|
2100
|
+
.toArray();
|
|
2101
|
+
if (existing.length === 0) {
|
|
2102
|
+
await this.generateEmbeddingForEntity(type, entity['id'], entity['data']);
|
|
2103
|
+
warmed++;
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
return Response.json({ warmed });
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Generate embeddings for all entities of a type
|
|
2110
|
+
*/
|
|
2111
|
+
async handleEmbeddingsGenerate(request) {
|
|
2112
|
+
let body;
|
|
2113
|
+
try {
|
|
2114
|
+
body = (await request.json());
|
|
2115
|
+
}
|
|
2116
|
+
catch {
|
|
2117
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
2118
|
+
}
|
|
2119
|
+
const { type: rawType } = body;
|
|
2120
|
+
if (!rawType || typeof rawType !== 'string') {
|
|
2121
|
+
return Response.json({ error: 'type field is required' }, { status: 400 });
|
|
2122
|
+
}
|
|
2123
|
+
const type = rawType;
|
|
2124
|
+
// Get all entities of this type
|
|
2125
|
+
const entities = this.sql.exec('SELECT * FROM _data WHERE type = ?', type).toArray();
|
|
2126
|
+
let generated = 0;
|
|
2127
|
+
for (const row of entities) {
|
|
2128
|
+
const entity = this.deserializeDataRow(row);
|
|
2129
|
+
await this.generateEmbeddingForEntity(type, entity['id'], entity['data']);
|
|
2130
|
+
generated++;
|
|
2131
|
+
}
|
|
2132
|
+
return Response.json({ generated });
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Batch process embeddings for specific entities
|
|
2136
|
+
*/
|
|
2137
|
+
async handleEmbeddingsBatch(request) {
|
|
2138
|
+
let body;
|
|
2139
|
+
try {
|
|
2140
|
+
body = (await request.json());
|
|
2141
|
+
}
|
|
2142
|
+
catch {
|
|
2143
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
2144
|
+
}
|
|
2145
|
+
const { type: rawType, ids, skipExisting } = body;
|
|
2146
|
+
if (!rawType || typeof rawType !== 'string' || !Array.isArray(ids)) {
|
|
2147
|
+
return Response.json({ error: 'type and ids array are required' }, { status: 400 });
|
|
2148
|
+
}
|
|
2149
|
+
const type = rawType;
|
|
2150
|
+
let processed = 0;
|
|
2151
|
+
let skipped = 0;
|
|
2152
|
+
let errors = 0;
|
|
2153
|
+
for (const id of ids) {
|
|
2154
|
+
// Check if entity exists
|
|
2155
|
+
const entityRows = this.sql
|
|
2156
|
+
.exec('SELECT * FROM _data WHERE id = ? AND type = ?', id, type)
|
|
2157
|
+
.toArray();
|
|
2158
|
+
if (entityRows.length === 0) {
|
|
2159
|
+
errors++;
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
2162
|
+
// Check if should skip existing
|
|
2163
|
+
if (skipExisting) {
|
|
2164
|
+
const existing = this.sql
|
|
2165
|
+
.exec('SELECT id FROM _embeddings WHERE entity_type = ? AND entity_id = ?', type, id)
|
|
2166
|
+
.toArray();
|
|
2167
|
+
if (existing.length > 0) {
|
|
2168
|
+
skipped++;
|
|
2169
|
+
continue;
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
const entity = this.deserializeDataRow(entityRows[0]);
|
|
2173
|
+
await this.generateEmbeddingForEntity(type, id, entity['data']);
|
|
2174
|
+
processed++;
|
|
2175
|
+
}
|
|
2176
|
+
return Response.json({ processed, skipped, errors, success: true });
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Start a batch embedding job
|
|
2180
|
+
*/
|
|
2181
|
+
async handleEmbeddingsBatchStart(request) {
|
|
2182
|
+
let body;
|
|
2183
|
+
try {
|
|
2184
|
+
body = (await request.json());
|
|
2185
|
+
}
|
|
2186
|
+
catch {
|
|
2187
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
2188
|
+
}
|
|
2189
|
+
const { type: rawType, batchSize = 10 } = body;
|
|
2190
|
+
if (!rawType || typeof rawType !== 'string') {
|
|
2191
|
+
return Response.json({ error: 'type field is required' }, { status: 400 });
|
|
2192
|
+
}
|
|
2193
|
+
const type = rawType;
|
|
2194
|
+
// Count entities of this type
|
|
2195
|
+
const countResult = this.sql
|
|
2196
|
+
.exec('SELECT COUNT(*) as count FROM _data WHERE type = ?', type)
|
|
2197
|
+
.toArray();
|
|
2198
|
+
const countRow = countResult[0];
|
|
2199
|
+
const total = countRow?.count ?? 0;
|
|
2200
|
+
const jobId = crypto.randomUUID();
|
|
2201
|
+
// Store job status
|
|
2202
|
+
this.batchJobs.set(jobId, {
|
|
2203
|
+
status: 'pending',
|
|
2204
|
+
total,
|
|
2205
|
+
processed: 0,
|
|
2206
|
+
errors: 0,
|
|
2207
|
+
});
|
|
2208
|
+
// Start processing in the background
|
|
2209
|
+
this.processBatchJob(jobId, type, batchSize).catch(() => {
|
|
2210
|
+
const job = this.batchJobs.get(jobId);
|
|
2211
|
+
if (job) {
|
|
2212
|
+
job.status = 'failed';
|
|
2213
|
+
}
|
|
2214
|
+
});
|
|
2215
|
+
return Response.json({ jobId, total });
|
|
2216
|
+
}
|
|
2217
|
+
/**
|
|
2218
|
+
* Process a batch job asynchronously
|
|
2219
|
+
*/
|
|
2220
|
+
async processBatchJob(jobId, type, batchSize) {
|
|
2221
|
+
const job = this.batchJobs.get(jobId);
|
|
2222
|
+
if (!job)
|
|
2223
|
+
return;
|
|
2224
|
+
job.status = 'processing';
|
|
2225
|
+
// Get all entities of this type
|
|
2226
|
+
const entities = this.sql.exec('SELECT * FROM _data WHERE type = ?', type).toArray();
|
|
2227
|
+
for (let i = 0; i < entities.length; i += batchSize) {
|
|
2228
|
+
const batch = entities.slice(i, i + batchSize);
|
|
2229
|
+
for (const row of batch) {
|
|
2230
|
+
try {
|
|
2231
|
+
const entity = this.deserializeDataRow(row);
|
|
2232
|
+
await this.generateEmbeddingForEntity(type, entity['id'], entity['data']);
|
|
2233
|
+
job.processed++;
|
|
2234
|
+
}
|
|
2235
|
+
catch {
|
|
2236
|
+
job.errors++;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
job.status = 'completed';
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Get batch job status
|
|
2244
|
+
*/
|
|
2245
|
+
handleEmbeddingsBatchStatus(jobId) {
|
|
2246
|
+
const job = this.batchJobs.get(jobId);
|
|
2247
|
+
if (!job) {
|
|
2248
|
+
return Response.json({ error: 'Job not found' }, { status: 404 });
|
|
2249
|
+
}
|
|
2250
|
+
return Response.json(job);
|
|
2251
|
+
}
|
|
2252
|
+
/**
|
|
2253
|
+
* Get embedding for a specific entity
|
|
2254
|
+
*/
|
|
2255
|
+
async handleGetEmbedding(entityType, entityId) {
|
|
2256
|
+
// Check if entity exists
|
|
2257
|
+
const entityRows = this.sql
|
|
2258
|
+
.exec('SELECT * FROM _data WHERE id = ? AND type = ?', entityId, entityType)
|
|
2259
|
+
.toArray();
|
|
2260
|
+
if (entityRows.length === 0) {
|
|
2261
|
+
return Response.json({ error: 'Entity not found' }, { status: 404 });
|
|
2262
|
+
}
|
|
2263
|
+
// Check if embedding exists
|
|
2264
|
+
const embeddingRows = this.sql
|
|
2265
|
+
.exec('SELECT * FROM _embeddings WHERE entity_type = ? AND entity_id = ?', entityType, entityId)
|
|
2266
|
+
.toArray();
|
|
2267
|
+
if (embeddingRows.length > 0) {
|
|
2268
|
+
// Cache hit
|
|
2269
|
+
this.embeddingsCacheStats.cacheHits++;
|
|
2270
|
+
const embRow = embeddingRows[0];
|
|
2271
|
+
return Response.json({
|
|
2272
|
+
entity_type: embRow.entity_type,
|
|
2273
|
+
entity_id: embRow.entity_id,
|
|
2274
|
+
model: embRow.model,
|
|
2275
|
+
vector: typeof embRow.vector === 'string' ? JSON.parse(embRow.vector) : embRow.vector,
|
|
2276
|
+
content_hash: embRow.content_hash,
|
|
2277
|
+
created_at: embRow.created_at,
|
|
2278
|
+
updated_at: embRow.updated_at,
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
// Cache miss - generate embedding
|
|
2282
|
+
this.embeddingsCacheStats.cacheMisses++;
|
|
2283
|
+
const entity = this.deserializeDataRow(entityRows[0]);
|
|
2284
|
+
const embedding = await this.generateEmbeddingForEntity(entityType, entityId, entity.data);
|
|
2285
|
+
if (!embedding) {
|
|
2286
|
+
// Could not generate embedding (e.g., no text content)
|
|
2287
|
+
return Response.json({ error: 'Could not generate embedding' }, { status: 400 });
|
|
2288
|
+
}
|
|
2289
|
+
return Response.json(embedding);
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* Semantic search using vector similarity
|
|
2293
|
+
*/
|
|
2294
|
+
async handleSemanticSearch(request) {
|
|
2295
|
+
let body;
|
|
2296
|
+
try {
|
|
2297
|
+
body = (await request.json());
|
|
2298
|
+
}
|
|
2299
|
+
catch {
|
|
2300
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
2301
|
+
}
|
|
2302
|
+
const { type: rawType, query: rawQuery, minScore: rawMinScore = 0, limit: rawLimit = 10, where, since, until, } = body;
|
|
2303
|
+
if (!rawType || typeof rawType !== 'string') {
|
|
2304
|
+
return Response.json({ error: 'type field is required' }, { status: 400 });
|
|
2305
|
+
}
|
|
2306
|
+
if (!rawQuery || typeof rawQuery !== 'string' || rawQuery.trim() === '') {
|
|
2307
|
+
return Response.json({ error: 'query field is required' }, { status: 400 });
|
|
2308
|
+
}
|
|
2309
|
+
const type = rawType;
|
|
2310
|
+
const query = rawQuery;
|
|
2311
|
+
const minScore = rawMinScore;
|
|
2312
|
+
const limit = rawLimit;
|
|
2313
|
+
// Generate embedding for query
|
|
2314
|
+
const queryEmbedding = await this.generateEmbedding(query);
|
|
2315
|
+
// Get all entities of this type and ensure they have embeddings
|
|
2316
|
+
const entities = this.sql.exec('SELECT * FROM _data WHERE type = ?', type).toArray();
|
|
2317
|
+
// Generate embeddings for entities that don't have them yet (lazy generation)
|
|
2318
|
+
for (const row of entities) {
|
|
2319
|
+
const entity = this.deserializeDataRow(row);
|
|
2320
|
+
const existingEmbedding = this.sql
|
|
2321
|
+
.exec('SELECT id FROM _embeddings WHERE entity_type = ? AND entity_id = ?', type, entity['id'])
|
|
2322
|
+
.toArray();
|
|
2323
|
+
if (existingEmbedding.length === 0) {
|
|
2324
|
+
await this.generateEmbeddingForEntity(type, entity['id'], entity['data']);
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
// Get all embeddings for this type
|
|
2328
|
+
const embeddingRows = this.sql
|
|
2329
|
+
.exec('SELECT * FROM _embeddings WHERE entity_type = ?', type)
|
|
2330
|
+
.toArray();
|
|
2331
|
+
// Calculate similarity scores
|
|
2332
|
+
const results = [];
|
|
2333
|
+
for (const row of embeddingRows) {
|
|
2334
|
+
const embeddingRow = row;
|
|
2335
|
+
const vector = typeof embeddingRow.vector === 'string'
|
|
2336
|
+
? JSON.parse(embeddingRow.vector)
|
|
2337
|
+
: embeddingRow.vector;
|
|
2338
|
+
const score = this.cosineSimilarity(queryEmbedding, vector);
|
|
2339
|
+
if (score >= minScore) {
|
|
2340
|
+
results.push({ entityId: embeddingRow.entity_id, score });
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
// Sort by score descending
|
|
2344
|
+
results.sort((a, b) => b.score - a.score);
|
|
2345
|
+
// Get entity data for top results
|
|
2346
|
+
const finalResults = [];
|
|
2347
|
+
for (const result of results.slice(0, limit)) {
|
|
2348
|
+
const entityRows = this.sql
|
|
2349
|
+
.exec('SELECT * FROM _data WHERE id = ?', result.entityId)
|
|
2350
|
+
.toArray();
|
|
2351
|
+
if (entityRows.length === 0)
|
|
2352
|
+
continue;
|
|
2353
|
+
const entity = this.deserializeDataRow(entityRows[0]);
|
|
2354
|
+
// Apply where filters if specified
|
|
2355
|
+
if (where) {
|
|
2356
|
+
const data = entity['data'];
|
|
2357
|
+
let matches = true;
|
|
2358
|
+
for (const [field, value] of Object.entries(where)) {
|
|
2359
|
+
if (data[field] !== value) {
|
|
2360
|
+
matches = false;
|
|
2361
|
+
break;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
if (!matches)
|
|
2365
|
+
continue;
|
|
2366
|
+
}
|
|
2367
|
+
// Apply time filters
|
|
2368
|
+
if (since && entity['created_at'] < since)
|
|
2369
|
+
continue;
|
|
2370
|
+
if (until && entity['created_at'] > until)
|
|
2371
|
+
continue;
|
|
2372
|
+
finalResults.push({
|
|
2373
|
+
id: entity['id'],
|
|
2374
|
+
type: entity['type'],
|
|
2375
|
+
data: entity['data'],
|
|
2376
|
+
created_at: entity['created_at'],
|
|
2377
|
+
updated_at: entity['updated_at'],
|
|
2378
|
+
$score: result.score,
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
return Response.json(finalResults);
|
|
2382
|
+
}
|
|
2383
|
+
/**
|
|
2384
|
+
* Hybrid search combining FTS and semantic
|
|
2385
|
+
*/
|
|
2386
|
+
async handleHybridSearch(request) {
|
|
2387
|
+
let body;
|
|
2388
|
+
try {
|
|
2389
|
+
body = (await request.json());
|
|
2390
|
+
}
|
|
2391
|
+
catch {
|
|
2392
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
2393
|
+
}
|
|
2394
|
+
const { type: rawType, query: rawQuery, limit: rawLimit = 10, ftsWeight: rawFtsWeight = 0.5, semanticWeight: rawSemanticWeight = 0.5, rrfK: rawRrfK = 60, } = body;
|
|
2395
|
+
if (!rawType) {
|
|
2396
|
+
return Response.json({ error: 'type field is required' }, { status: 400 });
|
|
2397
|
+
}
|
|
2398
|
+
if (!rawQuery || rawQuery.trim() === '') {
|
|
2399
|
+
return Response.json({ error: 'query field is required' }, { status: 400 });
|
|
2400
|
+
}
|
|
2401
|
+
const type = rawType;
|
|
2402
|
+
const query = rawQuery;
|
|
2403
|
+
const limit = rawLimit;
|
|
2404
|
+
const ftsWeight = rawFtsWeight;
|
|
2405
|
+
const semanticWeight = rawSemanticWeight;
|
|
2406
|
+
const rrfK = rawRrfK;
|
|
2407
|
+
// Get FTS results
|
|
2408
|
+
const ftsRanks = new Map();
|
|
2409
|
+
const entities = this.sql.exec('SELECT * FROM _data WHERE type = ?', type).toArray();
|
|
2410
|
+
let ftsRank = 1;
|
|
2411
|
+
const queryLower = query.toLowerCase();
|
|
2412
|
+
const queryTerms = queryLower.split(/\s+/);
|
|
2413
|
+
for (const row of entities) {
|
|
2414
|
+
const entity = this.deserializeDataRow(row);
|
|
2415
|
+
const data = entity['data'];
|
|
2416
|
+
// Simple FTS: check if any text field contains query terms
|
|
2417
|
+
let hasMatch = false;
|
|
2418
|
+
for (const value of Object.values(data)) {
|
|
2419
|
+
if (typeof value === 'string') {
|
|
2420
|
+
const valueLower = value.toLowerCase();
|
|
2421
|
+
if (queryTerms.some((term) => valueLower.includes(term))) {
|
|
2422
|
+
hasMatch = true;
|
|
2423
|
+
break;
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
if (hasMatch) {
|
|
2428
|
+
ftsRanks.set(entity['id'], ftsRank++);
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
// Get semantic results
|
|
2432
|
+
const semanticRanks = new Map();
|
|
2433
|
+
const semanticScores = new Map();
|
|
2434
|
+
// Ensure embeddings exist for all entities (lazy generation)
|
|
2435
|
+
for (const row of entities) {
|
|
2436
|
+
const entity = this.deserializeDataRow(row);
|
|
2437
|
+
const existingEmbedding = this.sql
|
|
2438
|
+
.exec('SELECT id FROM _embeddings WHERE entity_type = ? AND entity_id = ?', type, entity['id'])
|
|
2439
|
+
.toArray();
|
|
2440
|
+
if (existingEmbedding.length === 0) {
|
|
2441
|
+
await this.generateEmbeddingForEntity(type, entity['id'], entity['data']);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
const queryEmbedding = await this.generateEmbedding(query);
|
|
2445
|
+
const embeddingRows = this.sql
|
|
2446
|
+
.exec('SELECT * FROM _embeddings WHERE entity_type = ?', type)
|
|
2447
|
+
.toArray();
|
|
2448
|
+
const semanticResults = [];
|
|
2449
|
+
for (const row of embeddingRows) {
|
|
2450
|
+
const embeddingRow = row;
|
|
2451
|
+
const vector = typeof embeddingRow.vector === 'string'
|
|
2452
|
+
? JSON.parse(embeddingRow.vector)
|
|
2453
|
+
: embeddingRow.vector;
|
|
2454
|
+
const score = this.cosineSimilarity(queryEmbedding, vector);
|
|
2455
|
+
semanticResults.push({ entityId: embeddingRow.entity_id, score });
|
|
2456
|
+
semanticScores.set(embeddingRow.entity_id, score);
|
|
2457
|
+
}
|
|
2458
|
+
// Sort by score and assign ranks
|
|
2459
|
+
semanticResults.sort((a, b) => b.score - a.score);
|
|
2460
|
+
let semanticRank = 1;
|
|
2461
|
+
for (const result of semanticResults) {
|
|
2462
|
+
semanticRanks.set(result.entityId, semanticRank++);
|
|
2463
|
+
}
|
|
2464
|
+
// Compute RRF scores
|
|
2465
|
+
const allIds = new Set([...ftsRanks.keys(), ...semanticRanks.keys()]);
|
|
2466
|
+
const rrfResults = [];
|
|
2467
|
+
for (const id of allIds) {
|
|
2468
|
+
const fRank = ftsRanks.get(id) ?? Infinity;
|
|
2469
|
+
const sRank = semanticRanks.get(id) ?? Infinity;
|
|
2470
|
+
const sScore = semanticScores.get(id) ?? 0;
|
|
2471
|
+
const ftsComponent = fRank < Infinity ? ftsWeight / (rrfK + fRank) : 0;
|
|
2472
|
+
const semanticComponent = sRank < Infinity ? semanticWeight / (rrfK + sRank) : 0;
|
|
2473
|
+
const rrfScore = ftsComponent + semanticComponent;
|
|
2474
|
+
rrfResults.push({
|
|
2475
|
+
entityId: id,
|
|
2476
|
+
rrfScore,
|
|
2477
|
+
ftsRank: fRank,
|
|
2478
|
+
semanticRank: sRank,
|
|
2479
|
+
semanticScore: sScore,
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
// Sort by RRF score
|
|
2483
|
+
rrfResults.sort((a, b) => b.rrfScore - a.rrfScore);
|
|
2484
|
+
// Get entity data for top results
|
|
2485
|
+
const finalResults = [];
|
|
2486
|
+
for (const result of rrfResults.slice(0, limit)) {
|
|
2487
|
+
const entityRows = this.sql
|
|
2488
|
+
.exec('SELECT * FROM _data WHERE id = ?', result.entityId)
|
|
2489
|
+
.toArray();
|
|
2490
|
+
if (entityRows.length === 0)
|
|
2491
|
+
continue;
|
|
2492
|
+
const entity = this.deserializeDataRow(entityRows[0]);
|
|
2493
|
+
finalResults.push({
|
|
2494
|
+
id: entity['id'],
|
|
2495
|
+
type: entity['type'],
|
|
2496
|
+
data: entity['data'],
|
|
2497
|
+
created_at: entity['created_at'],
|
|
2498
|
+
updated_at: entity['updated_at'],
|
|
2499
|
+
$score: result.semanticScore,
|
|
2500
|
+
$rrfScore: result.rrfScore,
|
|
2501
|
+
$ftsRank: result.ftsRank,
|
|
2502
|
+
$semanticRank: result.semanticRank,
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
return Response.json(finalResults);
|
|
2506
|
+
}
|
|
2507
|
+
// ===========================================================================
|
|
2508
|
+
// Embedding Generation Helpers
|
|
2509
|
+
// ===========================================================================
|
|
2510
|
+
/**
|
|
2511
|
+
* Generate embedding for an entity and store it
|
|
2512
|
+
*/
|
|
2513
|
+
async generateEmbeddingForEntity(entityType, entityId, data) {
|
|
2514
|
+
// Extract text content from data
|
|
2515
|
+
const text = this.extractEmbeddableText(data);
|
|
2516
|
+
if (!text || text.trim() === '') {
|
|
2517
|
+
// No text content to embed
|
|
2518
|
+
return null;
|
|
2519
|
+
}
|
|
2520
|
+
const contentHash = this.hashContent(text);
|
|
2521
|
+
const now = new Date().toISOString();
|
|
2522
|
+
// Generate embedding using AI binding
|
|
2523
|
+
const vector = await this.generateEmbedding(text);
|
|
2524
|
+
// Upsert into _embeddings table
|
|
2525
|
+
const existing = this.sql
|
|
2526
|
+
.exec('SELECT id, created_at FROM _embeddings WHERE entity_type = ? AND entity_id = ?', entityType, entityId)
|
|
2527
|
+
.toArray();
|
|
2528
|
+
const existingEmb = existing.length > 0 ? existing[0] : null;
|
|
2529
|
+
const id = existingEmb?.id ?? crypto.randomUUID();
|
|
2530
|
+
const createdAt = existingEmb?.created_at ?? now;
|
|
2531
|
+
const vectorJson = JSON.stringify(vector);
|
|
2532
|
+
if (existing.length > 0) {
|
|
2533
|
+
this.sql.exec(`UPDATE _embeddings SET model = ?, vector = ?, content_hash = ?, updated_at = ?
|
|
2534
|
+
WHERE entity_type = ? AND entity_id = ?`, this.embeddingsConfig.model, vectorJson, contentHash, now, entityType, entityId);
|
|
2535
|
+
}
|
|
2536
|
+
else {
|
|
2537
|
+
this.sql.exec(`INSERT INTO _embeddings (id, entity_type, entity_id, model, vector, content_hash, created_at, updated_at)
|
|
2538
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, id, entityType, entityId, this.embeddingsConfig.model, vectorJson, contentHash, now, now);
|
|
2539
|
+
}
|
|
2540
|
+
return {
|
|
2541
|
+
entity_type: entityType,
|
|
2542
|
+
entity_id: entityId,
|
|
2543
|
+
model: this.embeddingsConfig.model,
|
|
2544
|
+
vector,
|
|
2545
|
+
content_hash: contentHash,
|
|
2546
|
+
created_at: createdAt,
|
|
2547
|
+
updated_at: now,
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* Generate embedding vector for text using AI binding or fallback
|
|
2552
|
+
*/
|
|
2553
|
+
async generateEmbedding(text) {
|
|
2554
|
+
// Try to use AI binding if available
|
|
2555
|
+
const envWithAI = this.env;
|
|
2556
|
+
const ai = envWithAI?.AI;
|
|
2557
|
+
if (ai && typeof ai.run === 'function') {
|
|
2558
|
+
try {
|
|
2559
|
+
const result = await ai.run(this.embeddingsConfig.model, { text: [text] });
|
|
2560
|
+
if (result?.data?.[0]) {
|
|
2561
|
+
return result.data[0];
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
catch {
|
|
2565
|
+
// Fall back to deterministic embedding
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
// Fallback: generate deterministic embedding based on text content
|
|
2569
|
+
return this.generateDeterministicEmbedding(text);
|
|
2570
|
+
}
|
|
2571
|
+
/**
|
|
2572
|
+
* Generate a deterministic embedding based on text content
|
|
2573
|
+
* This is used as a fallback when no AI binding is available
|
|
2574
|
+
*/
|
|
2575
|
+
generateDeterministicEmbedding(text) {
|
|
2576
|
+
// Use semantic word vectors for meaningful similarity
|
|
2577
|
+
const SEMANTIC_VECTORS = {
|
|
2578
|
+
// AI/ML domain
|
|
2579
|
+
machine: [0.9, 0.1, 0.05, 0.02],
|
|
2580
|
+
learning: [0.85, 0.15, 0.08, 0.03],
|
|
2581
|
+
artificial: [0.88, 0.12, 0.06, 0.04],
|
|
2582
|
+
intelligence: [0.87, 0.13, 0.07, 0.05],
|
|
2583
|
+
neural: [0.82, 0.18, 0.09, 0.06],
|
|
2584
|
+
network: [0.75, 0.2, 0.15, 0.1],
|
|
2585
|
+
deep: [0.8, 0.17, 0.1, 0.08],
|
|
2586
|
+
ai: [0.92, 0.08, 0.04, 0.02],
|
|
2587
|
+
ml: [0.88, 0.12, 0.06, 0.03],
|
|
2588
|
+
algorithm: [0.83, 0.17, 0.08, 0.04],
|
|
2589
|
+
algorithms: [0.83, 0.17, 0.08, 0.04],
|
|
2590
|
+
// Programming domain
|
|
2591
|
+
programming: [0.15, 0.85, 0.1, 0.05],
|
|
2592
|
+
code: [0.12, 0.88, 0.12, 0.06],
|
|
2593
|
+
software: [0.18, 0.82, 0.15, 0.08],
|
|
2594
|
+
development: [0.2, 0.8, 0.18, 0.1],
|
|
2595
|
+
typescript: [0.1, 0.9, 0.08, 0.04],
|
|
2596
|
+
javascript: [0.12, 0.88, 0.1, 0.05],
|
|
2597
|
+
python: [0.25, 0.75, 0.12, 0.06],
|
|
2598
|
+
react: [0.08, 0.85, 0.2, 0.1],
|
|
2599
|
+
vue: [0.06, 0.84, 0.18, 0.08],
|
|
2600
|
+
frontend: [0.05, 0.8, 0.25, 0.12],
|
|
2601
|
+
// Database domain
|
|
2602
|
+
database: [0.1, 0.7, 0.08, 0.6],
|
|
2603
|
+
query: [0.12, 0.65, 0.1, 0.7],
|
|
2604
|
+
sql: [0.08, 0.6, 0.05, 0.75],
|
|
2605
|
+
optimization: [0.15, 0.55, 0.12, 0.68],
|
|
2606
|
+
performance: [0.18, 0.5, 0.15, 0.65],
|
|
2607
|
+
// Food domain (very different from tech)
|
|
2608
|
+
cooking: [0.02, 0.05, 0.03, 0.02],
|
|
2609
|
+
recipe: [0.03, 0.04, 0.02, 0.03],
|
|
2610
|
+
recipes: [0.03, 0.04, 0.02, 0.03],
|
|
2611
|
+
food: [0.02, 0.03, 0.02, 0.02],
|
|
2612
|
+
pasta: [0.01, 0.02, 0.01, 0.01],
|
|
2613
|
+
pizza: [0.01, 0.03, 0.02, 0.01],
|
|
2614
|
+
italian: [0.02, 0.04, 0.02, 0.02],
|
|
2615
|
+
traditional: [0.02, 0.03, 0.02, 0.02],
|
|
2616
|
+
// State management - hooks is strongly related to state
|
|
2617
|
+
state: [0.3, 0.5, 0.6, 0.4],
|
|
2618
|
+
management: [0.35, 0.45, 0.55, 0.38],
|
|
2619
|
+
hooks: [0.25, 0.55, 0.65, 0.35],
|
|
2620
|
+
usestate: [0.28, 0.5, 0.62, 0.36],
|
|
2621
|
+
useeffect: [0.24, 0.52, 0.6, 0.34],
|
|
2622
|
+
patterns: [0.3, 0.48, 0.58, 0.37],
|
|
2623
|
+
different: [0.2, 0.4, 0.5, 0.3],
|
|
2624
|
+
// General
|
|
2625
|
+
guide: [0.5, 0.5, 0.5, 0.5],
|
|
2626
|
+
comprehensive: [0.5, 0.5, 0.5, 0.5],
|
|
2627
|
+
introduction: [0.5, 0.5, 0.5, 0.5],
|
|
2628
|
+
overview: [0.5, 0.5, 0.5, 0.5],
|
|
2629
|
+
tutorial: [0.5, 0.5, 0.5, 0.5],
|
|
2630
|
+
tips: [0.5, 0.5, 0.5, 0.5],
|
|
2631
|
+
systems: [0.5, 0.5, 0.5, 0.5],
|
|
2632
|
+
applications: [0.5, 0.5, 0.5, 0.5],
|
|
2633
|
+
// Quantum physics (very different)
|
|
2634
|
+
quantum: [0.01, 0.01, 0.01, 0.99],
|
|
2635
|
+
physics: [0.02, 0.02, 0.01, 0.98],
|
|
2636
|
+
simulation: [0.03, 0.05, 0.02, 0.95],
|
|
2637
|
+
};
|
|
2638
|
+
const DEFAULT_VECTOR = [0.1, 0.1, 0.1, 0.1];
|
|
2639
|
+
// Tokenize
|
|
2640
|
+
const words = text
|
|
2641
|
+
.toLowerCase()
|
|
2642
|
+
.replace(/[^\w\s]/g, ' ')
|
|
2643
|
+
.split(/\s+/)
|
|
2644
|
+
.filter((w) => w.length > 0);
|
|
2645
|
+
if (words.length === 0) {
|
|
2646
|
+
// Return zeros with small noise
|
|
2647
|
+
return Array.from({ length: 768 }, (_, i) => Math.sin(i) * 0.01);
|
|
2648
|
+
}
|
|
2649
|
+
// Aggregate word vectors
|
|
2650
|
+
const aggregated = [0, 0, 0, 0];
|
|
2651
|
+
for (const word of words) {
|
|
2652
|
+
const vec = SEMANTIC_VECTORS[word] ?? DEFAULT_VECTOR;
|
|
2653
|
+
for (let i = 0; i < 4; i++) {
|
|
2654
|
+
aggregated[i] += vec[i];
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
// Normalize
|
|
2658
|
+
const norm = Math.sqrt(aggregated.reduce((sum, v) => sum + v * v, 0));
|
|
2659
|
+
const normalized = aggregated.map((v) => v / (norm || 1));
|
|
2660
|
+
// Expand to 768 dimensions
|
|
2661
|
+
const textHash = this.simpleHash(text);
|
|
2662
|
+
const embedding = new Array(768);
|
|
2663
|
+
for (let i = 0; i < 768; i++) {
|
|
2664
|
+
const baseIndex = i % 4;
|
|
2665
|
+
const base = normalized[baseIndex];
|
|
2666
|
+
const noise = this.seededRandom(textHash, i) * 0.1 - 0.05;
|
|
2667
|
+
embedding[i] = base + noise;
|
|
2668
|
+
}
|
|
2669
|
+
// Final normalization
|
|
2670
|
+
const finalNorm = Math.sqrt(embedding.reduce((sum, v) => sum + v * v, 0));
|
|
2671
|
+
return embedding.map((v) => v / (finalNorm || 1));
|
|
2672
|
+
}
|
|
2673
|
+
/**
|
|
2674
|
+
* Extract text content from entity data for embedding
|
|
2675
|
+
*/
|
|
2676
|
+
extractEmbeddableText(data) {
|
|
2677
|
+
const textParts = [];
|
|
2678
|
+
for (const [key, value] of Object.entries(data)) {
|
|
2679
|
+
// Skip internal fields
|
|
2680
|
+
if (key.startsWith('$') || key.startsWith('_'))
|
|
2681
|
+
continue;
|
|
2682
|
+
// Skip timestamps
|
|
2683
|
+
if (key.endsWith('At') || key.endsWith('_at'))
|
|
2684
|
+
continue;
|
|
2685
|
+
if (typeof value === 'string' && value.trim()) {
|
|
2686
|
+
textParts.push(value);
|
|
2687
|
+
}
|
|
2688
|
+
else if (typeof value === 'number') {
|
|
2689
|
+
// Include numbers as text for embedding
|
|
2690
|
+
textParts.push(String(value));
|
|
2691
|
+
}
|
|
2692
|
+
else if (Array.isArray(value)) {
|
|
2693
|
+
const stringValues = value.filter((v) => typeof v === 'string');
|
|
2694
|
+
if (stringValues.length > 0) {
|
|
2695
|
+
textParts.push(stringValues.join(' '));
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
return textParts.join('\n\n');
|
|
2700
|
+
}
|
|
2701
|
+
/**
|
|
2702
|
+
* Calculate cosine similarity between two vectors
|
|
2703
|
+
*/
|
|
2704
|
+
cosineSimilarity(a, b) {
|
|
2705
|
+
if (a.length !== b.length) {
|
|
2706
|
+
throw new Error(`Vector dimensions must match: ${a.length} vs ${b.length}`);
|
|
2707
|
+
}
|
|
2708
|
+
let dotProduct = 0;
|
|
2709
|
+
let normA = 0;
|
|
2710
|
+
let normB = 0;
|
|
2711
|
+
for (let i = 0; i < a.length; i++) {
|
|
2712
|
+
dotProduct += a[i] * b[i];
|
|
2713
|
+
normA += a[i] * a[i];
|
|
2714
|
+
normB += b[i] * b[i];
|
|
2715
|
+
}
|
|
2716
|
+
const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
|
|
2717
|
+
if (magnitude === 0)
|
|
2718
|
+
return 0;
|
|
2719
|
+
// Return normalized similarity (0-1 range)
|
|
2720
|
+
return Math.max(0, Math.min(1, (dotProduct / magnitude + 1) / 2));
|
|
2721
|
+
}
|
|
2722
|
+
/**
|
|
2723
|
+
* Simple hash function for deterministic randomness
|
|
2724
|
+
*/
|
|
2725
|
+
simpleHash(str) {
|
|
2726
|
+
let hash = 0;
|
|
2727
|
+
for (let i = 0; i < str.length; i++) {
|
|
2728
|
+
const char = str.charCodeAt(i);
|
|
2729
|
+
hash = (hash << 5) - hash + char;
|
|
2730
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
2731
|
+
}
|
|
2732
|
+
return Math.abs(hash);
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* Generate deterministic pseudo-random number from seed
|
|
2736
|
+
*/
|
|
2737
|
+
seededRandom(seed, index) {
|
|
2738
|
+
const x = Math.sin(seed + index) * 10000;
|
|
2739
|
+
return x - Math.floor(x);
|
|
2740
|
+
}
|
|
2741
|
+
/**
|
|
2742
|
+
* Hash content for change detection
|
|
2743
|
+
*/
|
|
2744
|
+
hashContent(text) {
|
|
2745
|
+
const hash = this.simpleHash(text);
|
|
2746
|
+
return hash.toString(16).padStart(8, '0');
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
/**
|
|
2750
|
+
* DatabaseService - WorkerEntrypoint for RPC access
|
|
2751
|
+
*
|
|
2752
|
+
* Provides `connect(namespace)` method that returns an RpcTarget service
|
|
2753
|
+
* with all database operations.
|
|
2754
|
+
*
|
|
2755
|
+
* When used standalone (in tests), uses an in-memory provider with namespace isolation.
|
|
2756
|
+
*/
|
|
2757
|
+
export class DatabaseService extends WorkerEntrypoint {
|
|
2758
|
+
/**
|
|
2759
|
+
* Connect to a namespace and get an RPC-enabled service
|
|
2760
|
+
*
|
|
2761
|
+
* @param namespace - The namespace to connect to (defaults to 'default')
|
|
2762
|
+
* @param options - Optional provider configuration
|
|
2763
|
+
* @returns DatabaseServiceCore instance for RPC calls
|
|
2764
|
+
*/
|
|
2765
|
+
connect(namespace, options) {
|
|
2766
|
+
return new DatabaseServiceCore(namespace ?? 'default', options);
|
|
2767
|
+
}
|
|
2768
|
+
/**
|
|
2769
|
+
* Handle fetch requests - required by vitest-pool-workers for service binding tests
|
|
2770
|
+
* Returns a simple JSON response for health checks or routes to the appropriate service
|
|
2771
|
+
*/
|
|
2772
|
+
async fetch(request) {
|
|
2773
|
+
const url = new URL(request.url);
|
|
2774
|
+
// Health check endpoint
|
|
2775
|
+
if (url.pathname === '/health' || url.pathname === '/') {
|
|
2776
|
+
return Response.json({ status: 'ok', service: 'ai-database' });
|
|
2777
|
+
}
|
|
2778
|
+
// For other requests, return 404 - RPC should be used instead
|
|
2779
|
+
return Response.json({ error: 'Not found. Use RPC via service binding instead.' }, { status: 404 });
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
// WorkerEntrypoint IS the default export
|
|
2783
|
+
export default DatabaseService;
|
|
2784
|
+
// Export aliases
|
|
2785
|
+
export { DatabaseService as DatabaseWorker };
|
|
2786
|
+
//# sourceMappingURL=worker.js.map
|