ai-database 2.1.3 → 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 +35 -1
- package/README.md +880 -669
- 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 +49 -23
- package/dist/ai-promise-db.d.ts.map +1 -1
- package/dist/ai-promise-db.js +91 -63
- 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 +35 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +106 -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 +128 -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 +48 -17
- package/dist/schema/cascade.d.ts.map +1 -1
- package/dist/schema/cascade.js +477 -278
- 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 +21 -109
- package/dist/schema/dependency-graph.d.ts.map +1 -1
- package/dist/schema/dependency-graph.js +25 -333
- package/dist/schema/dependency-graph.js.map +1 -1
- 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/index.d.ts +28 -34
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +454 -521
- 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 +144 -89
- package/dist/schema/parse.js.map +1 -1
- package/dist/schema/provider.d.ts +37 -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 +237 -95
- 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 +10 -0
- package/dist/schema/semantic.d.ts.map +1 -1
- package/dist/schema/semantic.js +192 -86
- 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.map +1 -1
- package/dist/schema/union-fallback.js +21 -15
- package/dist/schema/union-fallback.js.map +1 -1
- 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/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 -24
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2852 -40
- 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 +50 -5
- package/dist/type-guards.d.ts.map +1 -1
- package/dist/type-guards.js +87 -16
- package/dist/type-guards.js.map +1 -1
- 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 +2 -5
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +65 -93
- package/dist/validation.js.map +1 -1
- 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 +46 -16
- package/src/docs-rels/migrations/0001-init.sql +125 -0
- package/LICENSE +0 -21
package/dist/schema.js
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Schema-first Database Definition
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* The actual implementation is split into smaller modules:
|
|
8
|
-
* - schema/types.ts - TypeScript types/interfaces
|
|
9
|
-
* - schema/parse.ts - Schema parsing logic
|
|
10
|
-
* - schema/provider.ts - Database provider interface and resolution
|
|
11
|
-
* - schema/resolve.ts - Resolution functions for entity hydration
|
|
12
|
-
* - schema/cascade.ts - Cascade generation and context-aware value generation
|
|
13
|
-
* - schema/semantic.ts - Fuzzy/semantic resolution functions
|
|
14
|
-
* - schema/index.ts - Main factory and entity operations
|
|
4
|
+
* Declarative schema with automatic bi-directional relationships.
|
|
5
|
+
* Uses mdxld conventions for entity structure.
|
|
15
6
|
*
|
|
16
7
|
* @example
|
|
17
8
|
* ```ts
|
|
@@ -36,51 +27,2872 @@
|
|
|
36
27
|
* post.author // Author (single)
|
|
37
28
|
* post.tags // Tag[] (array)
|
|
38
29
|
* ```
|
|
39
|
-
*
|
|
40
|
-
* @packageDocumentation
|
|
41
30
|
*/
|
|
31
|
+
import { DBPromise, wrapEntityOperations, setSchemaRelationInfo, } from './ai-promise-db.js';
|
|
32
|
+
import { cosineSimilarity, computeRRF, extractEmbeddableText, generateContentHash, } from './semantic.js';
|
|
33
|
+
import { isDraft, extractRefs, hasFactoryFunction, isPlainObject, getSchemaMetadata, } from './type-guards.js';
|
|
34
|
+
import { createEventBridge } from './eventbridge.js';
|
|
35
|
+
import { loadEntity } from './dataloader.js';
|
|
36
|
+
import { logWarn } from './logger.js';
|
|
42
37
|
export { toExpanded, toFlat, Verbs, resolveUrl, resolveShortUrl, parseUrl } from './types.js';
|
|
38
|
+
// Re-export from schema/ modules (only items not defined locally in this file)
|
|
39
|
+
export {
|
|
40
|
+
// AI Generation configuration
|
|
41
|
+
configureAIGeneration, getAIGenerationConfig,
|
|
42
|
+
// Cascade functions
|
|
43
|
+
setValueGenerator, getValueGenerator,
|
|
44
|
+
// Entity operations
|
|
45
|
+
createEntityOperations, createEdgeEntityOperations,
|
|
46
|
+
// Verb derivation
|
|
47
|
+
FORWARD_TO_REVERSE, BIDIRECTIONAL_PAIRS, deriveReverseVerb, fieldNameToVerb, isPassiveVerb, registerVerbPair, registerBidirectionalPair, registerFieldVerb,
|
|
48
|
+
// Provider type guards
|
|
49
|
+
hasSemanticSearch, hasHybridSearch, hasEventsAPI, hasActionsAPI, hasArtifactsAPI, hasEmbeddingsConfig,
|
|
50
|
+
// Schema versioning
|
|
51
|
+
computeSchemaHash, getSchemaVersion, setSchemaVersion, hasSchemaChanged,
|
|
52
|
+
// Schema diff
|
|
53
|
+
diffSchemas, describeDiff,
|
|
54
|
+
// Migrations
|
|
55
|
+
defineMigration, runMigrations, getPendingMigrations, rollbackLastMigration,
|
|
56
|
+
// Parse functions - delegated to schema/parse.ts
|
|
57
|
+
parseOperator, parseField, parseSchema, isPrimitiveType, } from './schema/index.js';
|
|
58
|
+
// Import generateAIFields and generateEntity directly from cascade.js to avoid potential circular dependency
|
|
59
|
+
import { generateAIFields, generateEntity as cascadeGenerateEntity, generateContextAwareValue, generateNaturalLanguageContent, DEFAULT_MAX_DEPTH, } from './schema/cascade.js';
|
|
60
|
+
// Import entity operation factories for internal use
|
|
61
|
+
import { createEdgeEntityOperations, createNounEntityOperations, createVerbEntityOperations, } from './schema/entity-operations.js';
|
|
62
|
+
// Import type guards for internal use
|
|
63
|
+
import { hasSemanticSearch, hasHybridSearch, hasEventsAPI, hasActionsAPI, hasArtifactsAPI, hasEmbeddingsConfig, isPromptField, } from './schema/index.js';
|
|
64
|
+
// Import parse functions for internal use (DB() factory needs parseSchema)
|
|
65
|
+
import { parseSchema, parseOperator, parseField, isPrimitiveType } from './schema/parse.js';
|
|
66
|
+
// Import extracted DB() helper modules
|
|
67
|
+
import { addSystemEntities, SYSTEM_ENTITY_NAMES } from './schema/system-entities.js';
|
|
68
|
+
import { buildDefinitionCaches } from './schema/definition-caches.js';
|
|
69
|
+
import { createEventsAPI, createActionsPublicAPI, createArtifactsAPI, createNounsAPI, createVerbsAPI, } from './schema/sub-apis.js';
|
|
70
|
+
// Import module-level state setters for bridging state between schema.ts and schema/ modules
|
|
71
|
+
import { setProvider as setModuleProvider } from './schema/provider.js';
|
|
72
|
+
import { setNLQueryGenerator as setModuleNLQueryGenerator } from './schema/nl-query.js';
|
|
73
|
+
// Import error handling utilities
|
|
74
|
+
import { isNotFoundError, isEntityExistsError, wrapDatabaseError, DatabaseError, CapabilityNotSupportedError, } from './errors.js';
|
|
75
|
+
// Re-export linguistic utilities from linguistic.ts
|
|
76
|
+
export { conjugate, pluralize, singularize, inferNoun, createTypeMeta, getTypeMeta, Type, getVerbFields, } from './linguistic.js';
|
|
77
|
+
import { Verbs } from './types.js';
|
|
78
|
+
import { inferNoun, getTypeMeta, conjugate } from './linguistic.js';
|
|
79
|
+
// Note: EntityOperationsMap type removed - using inline type with explanation below
|
|
80
|
+
/**
|
|
81
|
+
* Create a Noun definition with type inference
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* const Post = defineNoun({
|
|
86
|
+
* singular: 'post',
|
|
87
|
+
* plural: 'posts',
|
|
88
|
+
* description: 'A blog post',
|
|
89
|
+
* properties: {
|
|
90
|
+
* title: { type: 'string', description: 'Post title' },
|
|
91
|
+
* content: { type: 'markdown' },
|
|
92
|
+
* },
|
|
93
|
+
* relationships: {
|
|
94
|
+
* author: { type: 'Author', backref: 'posts' },
|
|
95
|
+
* },
|
|
96
|
+
* })
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export function defineNoun(noun) {
|
|
100
|
+
return noun;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Create a Verb definition with type inference
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* const publish = defineVerb({
|
|
108
|
+
* action: 'publish',
|
|
109
|
+
* actor: 'publisher',
|
|
110
|
+
* act: 'publishes',
|
|
111
|
+
* activity: 'publishing',
|
|
112
|
+
* result: 'publication',
|
|
113
|
+
* reverse: { at: 'publishedAt', by: 'publishedBy' },
|
|
114
|
+
* inverse: 'unpublish',
|
|
115
|
+
* })
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export function defineVerb(verb) {
|
|
119
|
+
return verb;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Convert a Noun to an EntitySchema for use with DB()
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```ts
|
|
126
|
+
* const postNoun = defineNoun({
|
|
127
|
+
* singular: 'post',
|
|
128
|
+
* plural: 'posts',
|
|
129
|
+
* properties: { title: { type: 'string' } },
|
|
130
|
+
* relationships: { author: { type: 'Author', backref: 'posts' } },
|
|
131
|
+
* })
|
|
132
|
+
*
|
|
133
|
+
* const db = DB({
|
|
134
|
+
* Post: nounToSchema(postNoun),
|
|
135
|
+
* })
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export function nounToSchema(noun) {
|
|
139
|
+
const schema = {};
|
|
140
|
+
// Add properties
|
|
141
|
+
if (noun.properties) {
|
|
142
|
+
for (const [name, prop] of Object.entries(noun.properties)) {
|
|
143
|
+
let type = prop.type;
|
|
144
|
+
if (prop.array)
|
|
145
|
+
type = `${type}[]`;
|
|
146
|
+
if (prop.optional)
|
|
147
|
+
type = `${type}?`;
|
|
148
|
+
schema[name] = type;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Add relationships
|
|
152
|
+
if (noun.relationships) {
|
|
153
|
+
for (const [name, rel] of Object.entries(noun.relationships)) {
|
|
154
|
+
const baseType = rel.type.replace('[]', '');
|
|
155
|
+
const isArray = rel.type.endsWith('[]');
|
|
156
|
+
if (rel.backref) {
|
|
157
|
+
schema[name] = isArray ? [`${baseType}.${rel.backref}`] : `${baseType}.${rel.backref}`;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
schema[name] = rel.type;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return schema;
|
|
165
|
+
}
|
|
43
166
|
// =============================================================================
|
|
44
|
-
//
|
|
167
|
+
// Built-in Schema Types - Self-Describing Database
|
|
45
168
|
// =============================================================================
|
|
46
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Built-in Thing schema - base type for all entities
|
|
171
|
+
*
|
|
172
|
+
* Every entity instance is a Thing with a relationship to its Noun.
|
|
173
|
+
* This creates a complete graph: Thing.type -> Noun.things
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```ts
|
|
177
|
+
* // Every post instance:
|
|
178
|
+
* post.$type // 'Post' (string)
|
|
179
|
+
* post.type // -> Noun('Post') (relationship)
|
|
180
|
+
*
|
|
181
|
+
* // From Noun, get all instances:
|
|
182
|
+
* const postNoun = await db.Noun.get('Post')
|
|
183
|
+
* const allPosts = await postNoun.things // -> Post[]
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
export const ThingSchema = {
|
|
187
|
+
// Every Thing has a type that links to its Noun
|
|
188
|
+
type: 'Noun.things', // Thing.type -> Noun, Noun.things -> Thing[]
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* Built-in Noun schema for storing type definitions
|
|
192
|
+
*
|
|
193
|
+
* Every Type/Collection automatically gets a Noun record stored in the database.
|
|
194
|
+
* This enables introspection and self-describing schemas.
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```ts
|
|
198
|
+
* // When you define:
|
|
199
|
+
* const db = DB({ Post: { title: 'string' } })
|
|
200
|
+
*
|
|
201
|
+
* // The database auto-creates:
|
|
202
|
+
* // db.Noun.get('Post') => { singular: 'post', plural: 'posts', ... }
|
|
203
|
+
*
|
|
204
|
+
* // Query all types:
|
|
205
|
+
* const types = await db.Noun.list()
|
|
206
|
+
*
|
|
207
|
+
* // Get all instances of a type:
|
|
208
|
+
* const postNoun = await db.Noun.get('Post')
|
|
209
|
+
* const allPosts = await postNoun.things
|
|
210
|
+
*
|
|
211
|
+
* // Listen for new types:
|
|
212
|
+
* on.Noun.created(noun => console.log(`New type: ${noun.name}`))
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
export const NounSchema = {
|
|
216
|
+
// Identity
|
|
217
|
+
name: 'string', // 'Post', 'BlogPost'
|
|
218
|
+
singular: 'string', // 'post', 'blog post'
|
|
219
|
+
plural: 'string', // 'posts', 'blog posts'
|
|
220
|
+
slug: 'string', // 'post', 'blog-post'
|
|
221
|
+
slugPlural: 'string', // 'posts', 'blog-posts'
|
|
222
|
+
description: 'string?', // Human description
|
|
223
|
+
// Schema
|
|
224
|
+
properties: 'json?', // Property definitions
|
|
225
|
+
relationships: 'json?', // Relationship definitions
|
|
226
|
+
// Behavior
|
|
227
|
+
actions: 'json?', // Available actions (verbs)
|
|
228
|
+
events: 'json?', // Event types
|
|
229
|
+
// Metadata
|
|
230
|
+
metadata: 'json?', // Additional metadata
|
|
231
|
+
// Relationships - auto-created by bi-directional system
|
|
232
|
+
// things: Thing[] // All instances of this type (backref from Thing.type)
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* Built-in Verb schema for storing action definitions
|
|
236
|
+
*/
|
|
237
|
+
export const VerbSchema = {
|
|
238
|
+
action: 'string', // 'create', 'publish'
|
|
239
|
+
actor: 'string?', // 'creator', 'publisher'
|
|
240
|
+
act: 'string?', // 'creates', 'publishes'
|
|
241
|
+
activity: 'string?', // 'creating', 'publishing'
|
|
242
|
+
result: 'string?', // 'creation', 'publication'
|
|
243
|
+
reverse: 'json?', // { at, by, in, for }
|
|
244
|
+
inverse: 'string?', // 'delete', 'unpublish'
|
|
245
|
+
description: 'string?',
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* Built-in Edge schema for relationships between types
|
|
249
|
+
*
|
|
250
|
+
* Every relationship in a schema creates an Edge record.
|
|
251
|
+
* This enables graph queries across the type system.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```ts
|
|
255
|
+
* // Post.author -> Author creates:
|
|
256
|
+
* // Edge { from: 'Post', name: 'author', to: 'Author', backref: 'posts', cardinality: 'many-to-one' }
|
|
257
|
+
*
|
|
258
|
+
* // Query the graph:
|
|
259
|
+
* const edges = await db.Edge.find({ to: 'Author' })
|
|
260
|
+
* // => [{ from: 'Post', name: 'author' }, { from: 'Comment', name: 'author' }]
|
|
261
|
+
*
|
|
262
|
+
* // What types reference Author?
|
|
263
|
+
* const referencing = edges.map(e => e.from) // ['Post', 'Comment']
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
export const EdgeSchema = {
|
|
267
|
+
from: 'string', // Source type: 'Post'
|
|
268
|
+
name: 'string', // Field name: 'author'
|
|
269
|
+
to: 'string', // Target type: 'Author'
|
|
270
|
+
backref: 'string?', // Inverse field: 'posts'
|
|
271
|
+
cardinality: 'string', // 'one-to-one', 'one-to-many', 'many-to-one', 'many-to-many'
|
|
272
|
+
direction: 'string', // 'forward' | 'backward'
|
|
273
|
+
matchMode: 'string?', // 'exact' | 'fuzzy'
|
|
274
|
+
required: 'boolean?', // Is this relationship required?
|
|
275
|
+
description: 'string?', // Human description
|
|
276
|
+
};
|
|
277
|
+
/**
|
|
278
|
+
* System types that are auto-created in every database
|
|
279
|
+
*
|
|
280
|
+
* The graph structure:
|
|
281
|
+
* - Thing.type -> Noun (every instance links to its type)
|
|
282
|
+
* - Noun.things -> Thing[] (every type has its instances)
|
|
283
|
+
* - Edge connects Nouns (relationships between types)
|
|
284
|
+
* - Verb describes actions on Nouns
|
|
285
|
+
*/
|
|
286
|
+
export const SystemSchema = {
|
|
287
|
+
Thing: ThingSchema,
|
|
288
|
+
Noun: NounSchema,
|
|
289
|
+
Verb: VerbSchema,
|
|
290
|
+
Edge: EdgeSchema,
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* Create Edge records from schema relationships
|
|
294
|
+
*
|
|
295
|
+
* @internal Used by DB() to auto-populate Edge records
|
|
296
|
+
*
|
|
297
|
+
* For backward edges (direction === 'backward'), the from/to are inverted:
|
|
298
|
+
* - Forward: from = typeName, to = relatedType
|
|
299
|
+
* - Backward: from = relatedType, to = typeName
|
|
300
|
+
*
|
|
301
|
+
* This enables proper graph traversal where backward edges represent
|
|
302
|
+
* "pointing to" relationships (e.g., Post.comments -> Comments that point TO Post)
|
|
303
|
+
*/
|
|
304
|
+
export function createEdgeRecords(typeName, schema, parsedEntity) {
|
|
305
|
+
const edges = [];
|
|
306
|
+
for (const [fieldName, field] of parsedEntity.fields) {
|
|
307
|
+
if (field.isRelation && field.relatedType) {
|
|
308
|
+
const direction = field.direction ?? 'forward';
|
|
309
|
+
const matchMode = field.matchMode ?? 'exact';
|
|
310
|
+
// For backward edges, invert from/to and adjust cardinality
|
|
311
|
+
const isBackward = direction === 'backward';
|
|
312
|
+
const from = isBackward ? field.relatedType : typeName;
|
|
313
|
+
const to = isBackward ? typeName : field.relatedType;
|
|
314
|
+
// Cardinality from the perspective of the field definition
|
|
315
|
+
// - Array with backref = many-to-many (Post.tags <-> Tag.posts)
|
|
316
|
+
// - Array without backref = one-to-many (one source points to many targets)
|
|
317
|
+
// - Single = many-to-one (many sources point to one target)
|
|
318
|
+
// The 'one-to-one' case is rare and typically requires explicit constraint
|
|
319
|
+
let cardinality;
|
|
320
|
+
if (field.isArray) {
|
|
321
|
+
cardinality = field.backref ? 'many-to-many' : 'one-to-many';
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
// Single reference: by default many-to-one (many posts -> one author)
|
|
325
|
+
cardinality = 'many-to-one';
|
|
326
|
+
}
|
|
327
|
+
edges.push({
|
|
328
|
+
from,
|
|
329
|
+
name: fieldName,
|
|
330
|
+
to,
|
|
331
|
+
backref: field.backref,
|
|
332
|
+
cardinality,
|
|
333
|
+
direction,
|
|
334
|
+
matchMode,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return edges;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Create a Noun record from a type name and optional schema
|
|
342
|
+
*
|
|
343
|
+
* @internal Used by DB() to auto-populate Noun records
|
|
344
|
+
*/
|
|
345
|
+
export function createNounRecord(typeName, schema, nounDef) {
|
|
346
|
+
const meta = getTypeMeta(typeName);
|
|
347
|
+
const inferred = inferNoun(typeName);
|
|
348
|
+
return {
|
|
349
|
+
name: typeName,
|
|
350
|
+
singular: nounDef?.singular ?? meta.singular,
|
|
351
|
+
plural: nounDef?.plural ?? meta.plural,
|
|
352
|
+
slug: meta.slug,
|
|
353
|
+
slugPlural: meta.slugPlural,
|
|
354
|
+
description: nounDef?.description,
|
|
355
|
+
properties: nounDef?.properties ?? (schema ? schemaToProperties(schema) : undefined),
|
|
356
|
+
relationships: nounDef?.relationships,
|
|
357
|
+
actions: nounDef?.actions ?? inferred.actions,
|
|
358
|
+
events: nounDef?.events ?? inferred.events,
|
|
359
|
+
metadata: nounDef?.metadata,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Convert EntitySchema to NounProperty format
|
|
364
|
+
*/
|
|
365
|
+
function schemaToProperties(schema) {
|
|
366
|
+
const properties = {};
|
|
367
|
+
for (const [name, def] of Object.entries(schema)) {
|
|
368
|
+
// Skip metadata fields (prefixed with $) like $context, $instructions
|
|
369
|
+
if (name.startsWith('$'))
|
|
370
|
+
continue;
|
|
371
|
+
// Skip if definition is invalid (null, undefined)
|
|
372
|
+
if (!def)
|
|
373
|
+
continue;
|
|
374
|
+
const defStr = Array.isArray(def) ? def[0] : def;
|
|
375
|
+
const isOptional = defStr.endsWith('?');
|
|
376
|
+
const isArray = defStr.endsWith('[]') || Array.isArray(def);
|
|
377
|
+
const baseType = defStr.replace(/[\?\[\]]/g, '').split('.')[0];
|
|
378
|
+
properties[name] = {
|
|
379
|
+
type: baseType,
|
|
380
|
+
optional: isOptional,
|
|
381
|
+
array: isArray,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return properties;
|
|
385
|
+
}
|
|
47
386
|
// =============================================================================
|
|
48
|
-
//
|
|
387
|
+
// Natural Language Query Implementation
|
|
49
388
|
// =============================================================================
|
|
50
|
-
|
|
51
|
-
|
|
389
|
+
// NOTE: NLQueryGenerator, NLQueryContext, NLQueryPlan types are imported from ./schema/types.js
|
|
390
|
+
// and re-exported via ./schema/index.js for the public API
|
|
391
|
+
// The functions below maintain local state and bridge to the module state in schema/nl-query.ts
|
|
392
|
+
let nlQueryGenerator = null;
|
|
393
|
+
/**
|
|
394
|
+
* Set the AI generator for natural language queries
|
|
395
|
+
*
|
|
396
|
+
* @example
|
|
397
|
+
* ```ts
|
|
398
|
+
* import { generate } from 'ai-functions'
|
|
399
|
+
*
|
|
400
|
+
* setNLQueryGenerator(async (prompt, context) => {
|
|
401
|
+
* return generate({
|
|
402
|
+
* prompt: `Given this schema: ${JSON.stringify(context.types)}
|
|
403
|
+
* Answer this question: ${prompt}
|
|
404
|
+
* Return a query plan as JSON.`,
|
|
405
|
+
* schema: NLQueryPlanSchema
|
|
406
|
+
* })
|
|
407
|
+
* })
|
|
408
|
+
* ```
|
|
409
|
+
*/
|
|
410
|
+
export function setNLQueryGenerator(generator) {
|
|
411
|
+
nlQueryGenerator = generator;
|
|
412
|
+
// Bridge to schema/nl-query.ts module state so extracted modules share the same generator
|
|
413
|
+
setModuleNLQueryGenerator(generator);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Get the currently configured NL query generator
|
|
417
|
+
*/
|
|
418
|
+
export function getNLQueryGenerator() {
|
|
419
|
+
return nlQueryGenerator;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Build schema context for NL queries
|
|
423
|
+
*/
|
|
424
|
+
export function buildNLQueryContext(schema, targetType) {
|
|
425
|
+
const types = [];
|
|
426
|
+
for (const [name, entity] of schema.entities) {
|
|
427
|
+
const fields = [];
|
|
428
|
+
const relationships = [];
|
|
429
|
+
for (const [fieldName, field] of entity.fields) {
|
|
430
|
+
if (field.isRelation && field.relatedType) {
|
|
431
|
+
relationships.push({
|
|
432
|
+
name: fieldName,
|
|
433
|
+
to: field.relatedType,
|
|
434
|
+
cardinality: field.isArray ? 'many' : 'one',
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
fields.push(fieldName);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const meta = getTypeMeta(name);
|
|
442
|
+
types.push({
|
|
443
|
+
name,
|
|
444
|
+
singular: meta.singular,
|
|
445
|
+
plural: meta.plural,
|
|
446
|
+
fields,
|
|
447
|
+
relationships,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return { types, ...(targetType !== undefined && { targetType }) };
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Execute a natural language query
|
|
454
|
+
*/
|
|
455
|
+
export async function executeNLQuery(question, schema, targetType) {
|
|
456
|
+
// Import applyFilters for MongoDB-style filter support
|
|
457
|
+
const { applyFilters } = await import('./schema/nl-query-generator.js');
|
|
458
|
+
// If no AI generator configured, fall back to search
|
|
459
|
+
if (!nlQueryGenerator) {
|
|
460
|
+
const provider = await resolveProvider();
|
|
461
|
+
const results = [];
|
|
462
|
+
// Simple heuristic for common "list all" patterns in fallback mode
|
|
463
|
+
const lowerQuestion = question.toLowerCase().trim();
|
|
464
|
+
const isListAllQuery = /^(show|list|get|find|display)\s+(all|every|the)?\s*/i.test(lowerQuestion) ||
|
|
465
|
+
lowerQuestion === '' ||
|
|
466
|
+
/\ball\b/i.test(lowerQuestion);
|
|
467
|
+
if (targetType) {
|
|
468
|
+
if (isListAllQuery) {
|
|
469
|
+
const listResults = await provider.list(targetType);
|
|
470
|
+
results.push(...listResults);
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
const searchResults = await provider.search(targetType, question);
|
|
474
|
+
results.push(...searchResults);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
for (const [typeName] of schema.entities) {
|
|
479
|
+
if (isListAllQuery) {
|
|
480
|
+
const listResults = await provider.list(typeName);
|
|
481
|
+
results.push(...listResults);
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
const searchResults = await provider.search(typeName, question);
|
|
485
|
+
results.push(...searchResults);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
interpretation: `Search for "${question}"`,
|
|
491
|
+
confidence: 0.5,
|
|
492
|
+
results,
|
|
493
|
+
explanation: 'Fallback to keyword search (no AI generator configured)',
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
// Build context and get AI-generated query plan
|
|
497
|
+
const context = buildNLQueryContext(schema, targetType);
|
|
498
|
+
const plan = await nlQueryGenerator(question, context);
|
|
499
|
+
// Execute the plan
|
|
500
|
+
const provider = await resolveProvider();
|
|
501
|
+
let results = [];
|
|
502
|
+
for (const typeName of plan.types) {
|
|
503
|
+
let typeResults;
|
|
504
|
+
if (plan.search) {
|
|
505
|
+
typeResults = await provider.search(typeName, plan.search);
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
typeResults = await provider.list(typeName);
|
|
509
|
+
}
|
|
510
|
+
// Apply MongoDB-style filters in memory
|
|
511
|
+
if (plan.filters && Object.keys(plan.filters).length > 0) {
|
|
512
|
+
typeResults = applyFilters(typeResults, plan.filters);
|
|
513
|
+
}
|
|
514
|
+
results.push(...typeResults);
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
interpretation: plan.interpretation,
|
|
518
|
+
confidence: plan.confidence,
|
|
519
|
+
results,
|
|
520
|
+
query: JSON.stringify({ types: plan.types, filters: plan.filters, search: plan.search }),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Create a natural language query function for a specific type
|
|
525
|
+
*/
|
|
526
|
+
export function createNLQueryFn(schema, typeName) {
|
|
527
|
+
return async (strings, ...values) => {
|
|
528
|
+
// Interpolate the template
|
|
529
|
+
const question = strings.reduce((acc, str, i) => {
|
|
530
|
+
return acc + str + (values[i] !== undefined ? String(values[i]) : '');
|
|
531
|
+
}, '');
|
|
532
|
+
return executeNLQuery(question, schema, typeName);
|
|
533
|
+
};
|
|
534
|
+
}
|
|
52
535
|
// =============================================================================
|
|
53
|
-
//
|
|
536
|
+
// Provider Interface - Delegated to schema/provider.ts
|
|
54
537
|
// =============================================================================
|
|
55
|
-
|
|
538
|
+
// NOTE: DBProvider interface is imported from ./schema/provider.js for internal use
|
|
539
|
+
// and re-exported via ./schema/index.js for the public API
|
|
56
540
|
// =============================================================================
|
|
57
|
-
//
|
|
541
|
+
// Provider Resolution
|
|
58
542
|
// =============================================================================
|
|
59
|
-
|
|
543
|
+
let globalProvider = null;
|
|
544
|
+
let providerPromise = null;
|
|
545
|
+
/** File count threshold for suggesting ClickHouse upgrade */
|
|
546
|
+
const FILE_COUNT_THRESHOLD = 10_000;
|
|
547
|
+
/**
|
|
548
|
+
* Set the global database provider
|
|
549
|
+
*/
|
|
550
|
+
export function setProvider(provider) {
|
|
551
|
+
globalProvider = provider;
|
|
552
|
+
providerPromise = null;
|
|
553
|
+
// Bridge to schema/provider.ts module state so extracted modules share the same provider
|
|
554
|
+
setModuleProvider(provider);
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Parse DATABASE_URL into provider type and paths
|
|
558
|
+
*
|
|
559
|
+
* Local storage (all use .db/ folder):
|
|
560
|
+
* - `./content` → fs (default)
|
|
561
|
+
* - `sqlite://./content` → sqlite stored in ./content/.db/index.sqlite
|
|
562
|
+
* - `chdb://./content` → clickhouse stored in ./content/.db/clickhouse/
|
|
563
|
+
*
|
|
564
|
+
* Remote:
|
|
565
|
+
* - `libsql://your-db.turso.io` → Turso SQLite
|
|
566
|
+
* - `clickhouse://host:8123` → ClickHouse HTTP
|
|
567
|
+
* - `:memory:` → in-memory
|
|
568
|
+
*/
|
|
569
|
+
function parseDatabaseUrl(url) {
|
|
570
|
+
if (!url)
|
|
571
|
+
return { provider: 'fs', root: './content' };
|
|
572
|
+
// In-memory
|
|
573
|
+
if (url === ':memory:') {
|
|
574
|
+
return { provider: 'memory', root: '' };
|
|
575
|
+
}
|
|
576
|
+
// Remote Turso
|
|
577
|
+
if (url.startsWith('libsql://') || url.includes('.turso.io')) {
|
|
578
|
+
return { provider: 'sqlite', root: '', remoteUrl: url };
|
|
579
|
+
}
|
|
580
|
+
// Remote ClickHouse
|
|
581
|
+
if (url.startsWith('clickhouse://') && url.includes(':')) {
|
|
582
|
+
// clickhouse://host:port/db
|
|
583
|
+
return { provider: 'clickhouse', root: '', remoteUrl: url.replace('clickhouse://', 'https://') };
|
|
584
|
+
}
|
|
585
|
+
// Local SQLite: sqlite://./content → ./content/.db/index.sqlite
|
|
586
|
+
if (url.startsWith('sqlite://')) {
|
|
587
|
+
const root = url.replace('sqlite://', '') || './content';
|
|
588
|
+
return { provider: 'sqlite', root };
|
|
589
|
+
}
|
|
590
|
+
// Local ClickHouse (chDB): chdb://./content → ./content/.db/clickhouse/
|
|
591
|
+
if (url.startsWith('chdb://')) {
|
|
592
|
+
const root = url.replace('chdb://', '') || './content';
|
|
593
|
+
return { provider: 'clickhouse', root };
|
|
594
|
+
}
|
|
595
|
+
// Default: filesystem
|
|
596
|
+
return { provider: 'fs', root: url };
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Resolve provider from DATABASE_URL environment variable
|
|
600
|
+
*
|
|
601
|
+
* @example
|
|
602
|
+
* ```bash
|
|
603
|
+
* # Filesystem (default) - stores in ./content with .db/ metadata
|
|
604
|
+
* DATABASE_URL=./content
|
|
605
|
+
*
|
|
606
|
+
* # Local SQLite - stores in ./content/.db/index.sqlite
|
|
607
|
+
* DATABASE_URL=sqlite://./content
|
|
608
|
+
*
|
|
609
|
+
* # Remote Turso
|
|
610
|
+
* DATABASE_URL=libsql://your-db.turso.io
|
|
611
|
+
*
|
|
612
|
+
* # Local ClickHouse (chDB) - stores in ./content/.db/clickhouse/
|
|
613
|
+
* DATABASE_URL=chdb://./content
|
|
614
|
+
*
|
|
615
|
+
* # Remote ClickHouse
|
|
616
|
+
* DATABASE_URL=clickhouse://localhost:8123
|
|
617
|
+
*
|
|
618
|
+
* # In-memory (testing)
|
|
619
|
+
* DATABASE_URL=:memory:
|
|
620
|
+
* ```
|
|
621
|
+
*/
|
|
622
|
+
async function resolveProvider() {
|
|
623
|
+
if (globalProvider)
|
|
624
|
+
return globalProvider;
|
|
625
|
+
if (providerPromise)
|
|
626
|
+
return providerPromise;
|
|
627
|
+
providerPromise = (async () => {
|
|
628
|
+
const databaseUrl = (typeof process !== 'undefined' && process.env?.['DATABASE_URL']) || './content';
|
|
629
|
+
const parsed = parseDatabaseUrl(databaseUrl);
|
|
630
|
+
switch (parsed.provider) {
|
|
631
|
+
case 'memory': {
|
|
632
|
+
const { createMemoryProvider } = await import('./memory-provider.js');
|
|
633
|
+
globalProvider = createMemoryProvider();
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
case 'fs': {
|
|
637
|
+
try {
|
|
638
|
+
// Dynamic import with runtime validation using type guard
|
|
639
|
+
const fsModule = await import('@mdxdb/fs');
|
|
640
|
+
if (!hasFactoryFunction(fsModule, 'createFsProvider')) {
|
|
641
|
+
throw new Error('@mdxdb/fs does not export createFsProvider');
|
|
642
|
+
}
|
|
643
|
+
globalProvider = fsModule.createFsProvider({ root: parsed.root });
|
|
644
|
+
// Check file count and warn if approaching threshold
|
|
645
|
+
checkFileCountThreshold(parsed.root);
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
console.warn('@mdxdb/fs not available, falling back to memory provider');
|
|
649
|
+
const { createMemoryProvider } = await import('./memory-provider.js');
|
|
650
|
+
globalProvider = createMemoryProvider();
|
|
651
|
+
}
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
case 'sqlite': {
|
|
655
|
+
try {
|
|
656
|
+
// Dynamic import with runtime validation using type guard
|
|
657
|
+
const sqliteModule = await import('@mdxdb/sqlite');
|
|
658
|
+
if (!hasFactoryFunction(sqliteModule, 'createSqliteProvider')) {
|
|
659
|
+
throw new Error('@mdxdb/sqlite does not export createSqliteProvider');
|
|
660
|
+
}
|
|
661
|
+
if (parsed.remoteUrl) {
|
|
662
|
+
// Remote Turso
|
|
663
|
+
globalProvider = (await sqliteModule.createSqliteProvider({
|
|
664
|
+
url: parsed.remoteUrl,
|
|
665
|
+
}));
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
// Local SQLite in .db folder
|
|
669
|
+
const dbPath = `${parsed.root}/.db/index.sqlite`;
|
|
670
|
+
globalProvider = (await sqliteModule.createSqliteProvider({
|
|
671
|
+
url: `file:${dbPath}`,
|
|
672
|
+
}));
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
console.warn('@mdxdb/sqlite not available, falling back to memory provider');
|
|
677
|
+
const { createMemoryProvider } = await import('./memory-provider.js');
|
|
678
|
+
globalProvider = createMemoryProvider();
|
|
679
|
+
}
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
case 'clickhouse': {
|
|
683
|
+
try {
|
|
684
|
+
// Dynamic import with runtime validation using type guard
|
|
685
|
+
const chModule = await import('@mdxdb/clickhouse');
|
|
686
|
+
if (!hasFactoryFunction(chModule, 'createClickhouseProvider')) {
|
|
687
|
+
throw new Error('@mdxdb/clickhouse does not export createClickhouseProvider');
|
|
688
|
+
}
|
|
689
|
+
if (parsed.remoteUrl) {
|
|
690
|
+
// Remote ClickHouse
|
|
691
|
+
globalProvider = (await chModule.createClickhouseProvider({
|
|
692
|
+
mode: 'http',
|
|
693
|
+
url: parsed.remoteUrl,
|
|
694
|
+
}));
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
// Local chDB in .db folder
|
|
698
|
+
const dbPath = `${parsed.root}/.db/clickhouse`;
|
|
699
|
+
globalProvider = (await chModule.createClickhouseProvider({
|
|
700
|
+
mode: 'chdb',
|
|
701
|
+
url: dbPath,
|
|
702
|
+
}));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
console.warn('@mdxdb/clickhouse not available, falling back to memory provider');
|
|
707
|
+
const { createMemoryProvider } = await import('./memory-provider.js');
|
|
708
|
+
globalProvider = createMemoryProvider();
|
|
709
|
+
}
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
default: {
|
|
713
|
+
const { createMemoryProvider } = await import('./memory-provider.js');
|
|
714
|
+
globalProvider = createMemoryProvider();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return globalProvider;
|
|
718
|
+
})();
|
|
719
|
+
return providerPromise;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Check file count and warn if approaching threshold
|
|
723
|
+
*/
|
|
724
|
+
async function checkFileCountThreshold(root) {
|
|
725
|
+
try {
|
|
726
|
+
const fs = await import('node:fs/promises');
|
|
727
|
+
const path = await import('node:path');
|
|
728
|
+
async function countFiles(dir) {
|
|
729
|
+
let count = 0;
|
|
730
|
+
try {
|
|
731
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
732
|
+
for (const entry of entries) {
|
|
733
|
+
if (entry.name.startsWith('.'))
|
|
734
|
+
continue;
|
|
735
|
+
if (entry.isDirectory()) {
|
|
736
|
+
count += await countFiles(path.join(dir, entry.name));
|
|
737
|
+
}
|
|
738
|
+
else if (entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) {
|
|
739
|
+
count++;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
// Directory doesn't exist yet
|
|
745
|
+
}
|
|
746
|
+
return count;
|
|
747
|
+
}
|
|
748
|
+
const count = await countFiles(root);
|
|
749
|
+
if (count > FILE_COUNT_THRESHOLD) {
|
|
750
|
+
console.warn(`\n⚠️ You have ${count.toLocaleString()} MDX files. ` +
|
|
751
|
+
`Consider upgrading to ClickHouse for better performance:\n` +
|
|
752
|
+
` DATABASE_URL=chdb://./data/clickhouse\n`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
// Ignore errors in file counting
|
|
757
|
+
}
|
|
758
|
+
}
|
|
60
759
|
// =============================================================================
|
|
61
|
-
//
|
|
760
|
+
// DB Factory
|
|
62
761
|
// =============================================================================
|
|
63
|
-
|
|
762
|
+
/**
|
|
763
|
+
* Create a typed database from a schema definition
|
|
764
|
+
*
|
|
765
|
+
* Supports both direct usage and destructuring for flexibility:
|
|
766
|
+
*
|
|
767
|
+
* @example Direct usage - everything on one object
|
|
768
|
+
* ```ts
|
|
769
|
+
* const db = DB({
|
|
770
|
+
* Post: { title: 'string', author: 'Author.posts' },
|
|
771
|
+
* Author: { name: 'string' },
|
|
772
|
+
* })
|
|
773
|
+
*
|
|
774
|
+
* // Entity operations
|
|
775
|
+
* const post = await db.Post.create({ title: 'Hello' })
|
|
776
|
+
*
|
|
777
|
+
* // Events, actions, etc. are also available directly
|
|
778
|
+
* db.events.on('Post.created', (event) => console.log(event))
|
|
779
|
+
* db.actions.create({ type: 'generate', data: {} })
|
|
780
|
+
* ```
|
|
781
|
+
*
|
|
782
|
+
* @example Destructured usage - cleaner separation
|
|
783
|
+
* ```ts
|
|
784
|
+
* const { db, events, actions, artifacts, nouns, verbs } = DB({
|
|
785
|
+
* Post: { title: 'string', author: 'Author.posts' },
|
|
786
|
+
* Author: { name: 'string' },
|
|
787
|
+
* })
|
|
788
|
+
*
|
|
789
|
+
* // CRUD operations on db
|
|
790
|
+
* const post = await db.Post.create({ title: 'Hello' })
|
|
791
|
+
* await db.Post.update(post.$id, { title: 'Updated' })
|
|
792
|
+
*
|
|
793
|
+
* // Separate events API
|
|
794
|
+
* events.on('Post.created', (event) => console.log(event))
|
|
795
|
+
*
|
|
796
|
+
* // Separate actions API
|
|
797
|
+
* const action = await actions.create({ type: 'generate', data: {} })
|
|
798
|
+
* ```
|
|
799
|
+
*/
|
|
800
|
+
export function DB(schema, options) {
|
|
801
|
+
const parsedSchema = parseSchema(schema);
|
|
802
|
+
// Validate union types - ensure all union type references point to existing types
|
|
803
|
+
// This is done here rather than in parseSchema() to allow parseSchema() to be used
|
|
804
|
+
// for pure parsing tests without requiring all union types to be defined
|
|
805
|
+
for (const [entityName, entity] of parsedSchema.entities) {
|
|
806
|
+
for (const [fieldName, field] of entity.fields) {
|
|
807
|
+
if (field.isRelation && field.operator && field.unionTypes && field.unionTypes.length > 0) {
|
|
808
|
+
for (const unionType of field.unionTypes) {
|
|
809
|
+
if (unionType === entityName)
|
|
810
|
+
continue; // Skip self-references
|
|
811
|
+
if (!parsedSchema.entities.has(unionType)) {
|
|
812
|
+
throw new Error(`Invalid schema: ${entityName}.${fieldName} references non-existent type '${unionType}'`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
// Build and set schema relation info for batch loading
|
|
819
|
+
// Maps entityType -> fieldName -> relatedType
|
|
820
|
+
const relationInfo = new Map();
|
|
821
|
+
for (const [entityName, entity] of parsedSchema.entities) {
|
|
822
|
+
const fieldRelations = new Map();
|
|
823
|
+
for (const [fieldName, field] of entity.fields) {
|
|
824
|
+
if (field.isRelation && field.relatedType) {
|
|
825
|
+
fieldRelations.set(fieldName, field.relatedType);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (fieldRelations.size > 0) {
|
|
829
|
+
relationInfo.set(entityName, fieldRelations);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
setSchemaRelationInfo(relationInfo);
|
|
833
|
+
// Add system entities to the parsed schema (Noun, Verb, Edge)
|
|
834
|
+
addSystemEntities(parsedSchema);
|
|
835
|
+
// Create local getProvider function for dependency injection
|
|
836
|
+
// If options.provider is provided, use it; otherwise fall back to global resolveProvider()
|
|
837
|
+
let cachedProvider = null;
|
|
838
|
+
let localProviderPromise = null;
|
|
839
|
+
async function getProvider() {
|
|
840
|
+
// Return cached provider if available
|
|
841
|
+
if (cachedProvider)
|
|
842
|
+
return cachedProvider;
|
|
843
|
+
// Return pending promise if resolution is in progress
|
|
844
|
+
if (localProviderPromise)
|
|
845
|
+
return localProviderPromise;
|
|
846
|
+
// Check if options.provider is provided (dependency injection)
|
|
847
|
+
if (options?.provider) {
|
|
848
|
+
localProviderPromise = (async () => {
|
|
849
|
+
const providerOption = options.provider;
|
|
850
|
+
if (typeof providerOption === 'function') {
|
|
851
|
+
// It's a factory function - call it
|
|
852
|
+
const result = providerOption();
|
|
853
|
+
// Handle both sync and async factory functions
|
|
854
|
+
cachedProvider = result instanceof Promise ? await result : result;
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
// It's a direct provider instance
|
|
858
|
+
cachedProvider = providerOption;
|
|
859
|
+
}
|
|
860
|
+
return cachedProvider;
|
|
861
|
+
})();
|
|
862
|
+
return localProviderPromise;
|
|
863
|
+
}
|
|
864
|
+
// Fall back to global resolveProvider()
|
|
865
|
+
localProviderPromise = resolveProvider().then((p) => {
|
|
866
|
+
cachedProvider = p;
|
|
867
|
+
return p;
|
|
868
|
+
});
|
|
869
|
+
return localProviderPromise;
|
|
870
|
+
}
|
|
871
|
+
// Configure provider with embeddings settings if provided
|
|
872
|
+
if (options?.embeddings) {
|
|
873
|
+
const embeddingsConfig = options.embeddings;
|
|
874
|
+
getProvider()
|
|
875
|
+
.then((provider) => {
|
|
876
|
+
if (hasEmbeddingsConfig(provider)) {
|
|
877
|
+
provider.setEmbeddingsConfig(embeddingsConfig);
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
// Warn that embeddings configuration was provided but provider doesn't support it
|
|
881
|
+
logWarn('Embeddings configuration provided but current provider does not support semantic search. ' +
|
|
882
|
+
'Embeddings will not be generated. Use a provider with embedding support (e.g., createMemoryProvider()).');
|
|
883
|
+
}
|
|
884
|
+
})
|
|
885
|
+
.catch((error) => {
|
|
886
|
+
console.error('Failed to configure embeddings on provider:', error);
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
// Collect all edge records from the schema (user-defined entities only)
|
|
890
|
+
const allEdgeRecords = [];
|
|
891
|
+
for (const [entityName, entity] of parsedSchema.entities) {
|
|
892
|
+
if (!SYSTEM_ENTITY_NAMES.has(entityName)) {
|
|
893
|
+
const edgeRecords = createEdgeRecords(entityName, schema[entityName] ?? {}, entity);
|
|
894
|
+
allEdgeRecords.push(...edgeRecords);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// Collect all noun records from the schema (user-defined entities only)
|
|
898
|
+
const allNounRecords = [];
|
|
899
|
+
for (const [entityName] of parsedSchema.entities) {
|
|
900
|
+
if (!SYSTEM_ENTITY_NAMES.has(entityName)) {
|
|
901
|
+
const nounRecord = createNounRecord(entityName, schema[entityName]);
|
|
902
|
+
allNounRecords.push(nounRecord);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
// Collect all verb records from the standard verbs
|
|
906
|
+
const allVerbRecords = Object.values(Verbs).map((verb) => ({
|
|
907
|
+
...verb,
|
|
908
|
+
$id: verb.action,
|
|
909
|
+
$type: 'Verb',
|
|
910
|
+
}));
|
|
911
|
+
// Create Actions API early so it can be injected into entity operations
|
|
912
|
+
const actionsAPI = {
|
|
913
|
+
async create(options) {
|
|
914
|
+
const provider = await getProvider();
|
|
915
|
+
if (hasActionsAPI(provider)) {
|
|
916
|
+
return provider.createAction(options);
|
|
917
|
+
}
|
|
918
|
+
throw new Error('Provider does not support actions');
|
|
919
|
+
},
|
|
920
|
+
async get(id) {
|
|
921
|
+
const provider = await getProvider();
|
|
922
|
+
if (hasActionsAPI(provider)) {
|
|
923
|
+
return provider.getAction(id);
|
|
924
|
+
}
|
|
925
|
+
return null;
|
|
926
|
+
},
|
|
927
|
+
async update(id, updates) {
|
|
928
|
+
const provider = await getProvider();
|
|
929
|
+
if (hasActionsAPI(provider)) {
|
|
930
|
+
return provider.updateAction(id, updates);
|
|
931
|
+
}
|
|
932
|
+
throw new Error('Provider does not support actions');
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
// Create ForEachActionsAPI adapter for wrapEntityOperations
|
|
936
|
+
const forEachActionsAPI = {
|
|
937
|
+
async create(data) {
|
|
938
|
+
const result = await actionsAPI.create(data);
|
|
939
|
+
return { id: result.id };
|
|
940
|
+
},
|
|
941
|
+
async get(id) {
|
|
942
|
+
return actionsAPI.get(id);
|
|
943
|
+
},
|
|
944
|
+
async update(id, updates) {
|
|
945
|
+
// Filter to only the properties that actionsAPI.update accepts
|
|
946
|
+
const filteredUpdates = {};
|
|
947
|
+
if (updates.status !== undefined)
|
|
948
|
+
filteredUpdates.status = updates.status;
|
|
949
|
+
if (updates.progress !== undefined)
|
|
950
|
+
filteredUpdates.progress = updates.progress;
|
|
951
|
+
return actionsAPI.update(id, filteredUpdates);
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
// Create entity operations for each type with promise pipelining
|
|
955
|
+
// NOTE: Using Record<string, unknown> here because entity operations types vary by schema.
|
|
956
|
+
// The actual types are determined at runtime and enforced via wrapEntityOperations.
|
|
957
|
+
// Attempts to use stricter types conflict with wrapEntityOperations return type.
|
|
958
|
+
const entityOperations = {};
|
|
959
|
+
// Internal event emitter for draft/resolve events (defined early for use in entity operations)
|
|
960
|
+
const eventHandlersForOps = new Map();
|
|
961
|
+
function emitInternalEventForOps(eventType, data) {
|
|
962
|
+
const handlers = eventHandlersForOps.get(eventType);
|
|
963
|
+
if (handlers) {
|
|
964
|
+
const snapshot = [...handlers];
|
|
965
|
+
for (const handler of snapshot) {
|
|
966
|
+
try {
|
|
967
|
+
handler(data);
|
|
968
|
+
}
|
|
969
|
+
catch (e) {
|
|
970
|
+
console.error(`Error in event handler for ${eventType}:`, e);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Make entity operations callable as a tagged template literal.
|
|
977
|
+
* This allows both: db.Lead.get('id') and db.Lead`natural language query`
|
|
978
|
+
*/
|
|
979
|
+
function makeCallableEntityOps(ops, entityName) {
|
|
980
|
+
const nlQueryFn = createNLQueryFn(parsedSchema, entityName);
|
|
981
|
+
const callableOps = function (strings, ...values) {
|
|
982
|
+
return nlQueryFn(strings, ...values);
|
|
983
|
+
};
|
|
984
|
+
Object.assign(callableOps, ops);
|
|
985
|
+
return callableOps;
|
|
986
|
+
}
|
|
987
|
+
for (const [entityName, entity] of parsedSchema.entities) {
|
|
988
|
+
if (entityName === 'Edge') {
|
|
989
|
+
// Special handling for Edge entity - query from in-memory edge records + runtime edges
|
|
990
|
+
const edgeOps = createEdgeEntityOperations(allEdgeRecords, resolveProvider);
|
|
991
|
+
entityOperations[entityName] = makeCallableEntityOps(wrapEntityOperations(entityName, edgeOps, forEachActionsAPI), entityName);
|
|
992
|
+
}
|
|
993
|
+
else if (entityName === 'Noun') {
|
|
994
|
+
// Noun entity - auto-generated from schema entity types
|
|
995
|
+
const nounOps = createNounEntityOperations(allNounRecords);
|
|
996
|
+
entityOperations[entityName] = makeCallableEntityOps(wrapEntityOperations(entityName, nounOps, forEachActionsAPI), entityName);
|
|
997
|
+
}
|
|
998
|
+
else if (entityName === 'Verb') {
|
|
999
|
+
// Verb entity - standard verbs with conjugation forms
|
|
1000
|
+
const verbOps = createVerbEntityOperations(allVerbRecords);
|
|
1001
|
+
entityOperations[entityName] = makeCallableEntityOps(wrapEntityOperations(entityName, verbOps, forEachActionsAPI), entityName);
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
const baseOps = createEntityOperations(entityName, entity, parsedSchema);
|
|
1005
|
+
// Wrap with DBPromise for chainable queries, inject actions for forEach persistence
|
|
1006
|
+
const wrappedOps = wrapEntityOperations(entityName, baseOps, forEachActionsAPI);
|
|
1007
|
+
// Add draft and resolve with event emission
|
|
1008
|
+
const draftFn = async (data, options) => {
|
|
1009
|
+
// baseOps.draft is defined in createEntityOperations
|
|
1010
|
+
if (!baseOps.draft) {
|
|
1011
|
+
throw new Error(`Draft method not available for ${entityName}`);
|
|
1012
|
+
}
|
|
1013
|
+
const draft = await baseOps.draft(data, options);
|
|
1014
|
+
// Draft objects are always Record<string, unknown> - safe to set $type
|
|
1015
|
+
if (draft && typeof draft === 'object' && !Array.isArray(draft)) {
|
|
1016
|
+
const draftRecord = draft;
|
|
1017
|
+
draftRecord['$type'] = entityName;
|
|
1018
|
+
}
|
|
1019
|
+
emitInternalEventForOps('draft', draft);
|
|
1020
|
+
return draft;
|
|
1021
|
+
};
|
|
1022
|
+
wrappedOps.draft = draftFn;
|
|
1023
|
+
const resolveFn = async (draft, options) => {
|
|
1024
|
+
// baseOps.resolve is defined in createEntityOperations
|
|
1025
|
+
if (!baseOps.resolve) {
|
|
1026
|
+
throw new Error(`Resolve method not available for ${entityName}`);
|
|
1027
|
+
}
|
|
1028
|
+
// Draft<T> is always a Record with $phase - this cast is safe after validation
|
|
1029
|
+
const resolved = await baseOps.resolve(draft, options);
|
|
1030
|
+
if (resolved && typeof resolved === 'object' && !Array.isArray(resolved)) {
|
|
1031
|
+
const resolvedRecord = resolved;
|
|
1032
|
+
resolvedRecord['$type'] = entityName;
|
|
1033
|
+
}
|
|
1034
|
+
emitInternalEventForOps('resolve', resolved);
|
|
1035
|
+
return resolved;
|
|
1036
|
+
};
|
|
1037
|
+
wrappedOps.resolve = resolveFn;
|
|
1038
|
+
// Update create to support draftOnly option and wire through draft/resolve
|
|
1039
|
+
const originalCreate = wrappedOps.create;
|
|
1040
|
+
wrappedOps.create = async (...args) => {
|
|
1041
|
+
// Parse arguments - can be (data, options?) or (id, data, options?)
|
|
1042
|
+
// Type assertions here are necessary because variadic args are typed as unknown[]
|
|
1043
|
+
let data;
|
|
1044
|
+
let options;
|
|
1045
|
+
if (typeof args[0] === 'string') {
|
|
1046
|
+
// (id, data, options?) - args[1] is the data object
|
|
1047
|
+
data = isPlainObject(args[1]) ? args[1] : {};
|
|
1048
|
+
options = isPlainObject(args[2]) ? args[2] : undefined;
|
|
1049
|
+
}
|
|
1050
|
+
else {
|
|
1051
|
+
// (data, options?) - args[0] is the data object
|
|
1052
|
+
data = isPlainObject(args[0]) ? args[0] : {};
|
|
1053
|
+
// Check if second arg is options (has option-like properties)
|
|
1054
|
+
options = isPlainObject(args[1]) ? args[1] : undefined;
|
|
1055
|
+
}
|
|
1056
|
+
if (options?.draftOnly) {
|
|
1057
|
+
const draft = await draftFn(data);
|
|
1058
|
+
return draft;
|
|
1059
|
+
}
|
|
1060
|
+
// Pre-generate entity ID so it's available during resolve phase
|
|
1061
|
+
// This allows generated child entities to reference the parent via $generatedBy
|
|
1062
|
+
const preGeneratedId = typeof args[0] === 'string' ? args[0] : crypto.randomUUID();
|
|
1063
|
+
// Run draft phase first - draftFn returns an object with draft properties and emits 'draft' event
|
|
1064
|
+
const draftResult = await draftFn(data);
|
|
1065
|
+
const draft = isPlainObject(draftResult) ? draftResult : {};
|
|
1066
|
+
// Inject $id into draft so resolve can pass it as context to resolveReferenceSpec
|
|
1067
|
+
draft['$id'] = preGeneratedId;
|
|
1068
|
+
// Always strip array forward relation refs from the draft phase.
|
|
1069
|
+
// When cascade is enabled, cascadeGenerate handles array relations with proper
|
|
1070
|
+
// depth control and cascadeTypes filtering. When cascade is not enabled,
|
|
1071
|
+
// array relations should not be auto-generated.
|
|
1072
|
+
const draftRefs = draft['$refs'];
|
|
1073
|
+
if (draftRefs) {
|
|
1074
|
+
for (const [refFieldName, refSpec] of Object.entries(draftRefs)) {
|
|
1075
|
+
if (Array.isArray(refSpec)) {
|
|
1076
|
+
delete draftRefs[refFieldName];
|
|
1077
|
+
draft[refFieldName] = undefined;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// Then resolve - resolveFn emits 'resolve' event
|
|
1082
|
+
const resolveResult = await resolveFn(draft);
|
|
1083
|
+
const resolved = isPlainObject(resolveResult) ? resolveResult : {};
|
|
1084
|
+
// Create the final entity (without phase markers)
|
|
1085
|
+
const finalData = { ...resolved };
|
|
1086
|
+
delete finalData['$phase'];
|
|
1087
|
+
delete finalData['$refs'];
|
|
1088
|
+
delete finalData['$errors'];
|
|
1089
|
+
delete finalData['$type'];
|
|
1090
|
+
// Call originalCreate with the resolved data using the pre-generated ID
|
|
1091
|
+
// Pass _preResolved flag to skip internal draft/resolve in entity-operations.create()
|
|
1092
|
+
// Type assertion to Function is necessary for dynamic method call with spread args
|
|
1093
|
+
return originalCreate.call(wrappedOps, preGeneratedId, finalData, { ...options, _preResolved: true });
|
|
1094
|
+
};
|
|
1095
|
+
// Add seed method if entity has seed configuration
|
|
1096
|
+
if (entity.seedConfig) {
|
|
1097
|
+
const seedConfig = entity.seedConfig;
|
|
1098
|
+
wrappedOps['seed'] = async () => {
|
|
1099
|
+
const { loadSeedData } = await import('./schema/seed.js');
|
|
1100
|
+
const records = await loadSeedData(seedConfig);
|
|
1101
|
+
const provider = await getProvider();
|
|
1102
|
+
for (const record of records) {
|
|
1103
|
+
const { $id, ...data } = record;
|
|
1104
|
+
// Upsert: check if exists, then update or create
|
|
1105
|
+
const existing = await provider.get(entityName, $id);
|
|
1106
|
+
if (existing) {
|
|
1107
|
+
await provider.update(entityName, $id, data);
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
await provider.create(entityName, $id, data);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return { count: records.length };
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
entityOperations[entityName] = makeCallableEntityOps(wrappedOps, entityName);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
// Build noun and verb definition caches
|
|
1120
|
+
const { nounDefinitions, verbDefinitions } = buildDefinitionCaches(parsedSchema);
|
|
1121
|
+
// Use the event handlers defined earlier for entity operations
|
|
1122
|
+
function onInternal(eventType, handler) {
|
|
1123
|
+
if (!eventHandlersForOps.has(eventType)) {
|
|
1124
|
+
eventHandlersForOps.set(eventType, new Set());
|
|
1125
|
+
}
|
|
1126
|
+
eventHandlersForOps.get(eventType).add(handler);
|
|
1127
|
+
return () => {
|
|
1128
|
+
eventHandlersForOps.get(eventType)?.delete(handler);
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
// Create the typed DB object
|
|
1132
|
+
const db = {
|
|
1133
|
+
$schema: parsedSchema,
|
|
1134
|
+
async get(url) {
|
|
1135
|
+
const provider = await getProvider();
|
|
1136
|
+
const parsed = parseUrl(url);
|
|
1137
|
+
return provider.get(parsed.type, parsed.id);
|
|
1138
|
+
},
|
|
1139
|
+
async search(query, options) {
|
|
1140
|
+
const provider = await getProvider();
|
|
1141
|
+
const results = [];
|
|
1142
|
+
for (const [typeName] of parsedSchema.entities) {
|
|
1143
|
+
const typeResults = await provider.search(typeName, query, options);
|
|
1144
|
+
results.push(...typeResults);
|
|
1145
|
+
}
|
|
1146
|
+
return results;
|
|
1147
|
+
},
|
|
1148
|
+
async semanticSearch(query, options) {
|
|
1149
|
+
const provider = await getProvider();
|
|
1150
|
+
if (!hasSemanticSearch(provider)) {
|
|
1151
|
+
throw new CapabilityNotSupportedError('hasSemanticSearch', `Semantic search is not supported by the current provider. ` +
|
|
1152
|
+
`The provider does not implement the semanticSearch method required for vector similarity search.`, `Use the regular search() method instead, which performs basic text matching.`);
|
|
1153
|
+
}
|
|
1154
|
+
const results = [];
|
|
1155
|
+
for (const [typeName] of parsedSchema.entities) {
|
|
1156
|
+
const typeResults = await provider.semanticSearch(typeName, query, options);
|
|
1157
|
+
results.push(...typeResults);
|
|
1158
|
+
}
|
|
1159
|
+
// Sort by score across all types
|
|
1160
|
+
results.sort((a, b) => b.$score - a.$score);
|
|
1161
|
+
// Apply limit if specified
|
|
1162
|
+
const limit = options?.limit ?? results.length;
|
|
1163
|
+
return results.slice(0, limit);
|
|
1164
|
+
},
|
|
1165
|
+
async count(type, where) {
|
|
1166
|
+
const provider = await getProvider();
|
|
1167
|
+
const results = await provider.list(type, where ? { where } : undefined);
|
|
1168
|
+
return results.length;
|
|
1169
|
+
},
|
|
1170
|
+
async forEach(options, callback) {
|
|
1171
|
+
const provider = await getProvider();
|
|
1172
|
+
const results = await provider.list(options.type, options.where ? { where: options.where } : undefined);
|
|
1173
|
+
const concurrency = options.concurrency ?? 1;
|
|
1174
|
+
if (concurrency === 1) {
|
|
1175
|
+
for (const entity of results) {
|
|
1176
|
+
await callback(entity);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
else {
|
|
1180
|
+
// Process in batches with concurrency
|
|
1181
|
+
const { Semaphore } = await import('./memory-provider.js');
|
|
1182
|
+
const semaphore = new Semaphore(concurrency);
|
|
1183
|
+
await semaphore.map(results, callback);
|
|
1184
|
+
}
|
|
1185
|
+
},
|
|
1186
|
+
async set(type, id, data) {
|
|
1187
|
+
const provider = await getProvider();
|
|
1188
|
+
const existing = await provider.get(type, id);
|
|
1189
|
+
if (existing) {
|
|
1190
|
+
// Replace entirely (not merge)
|
|
1191
|
+
return provider.update(type, id, data);
|
|
1192
|
+
}
|
|
1193
|
+
return provider.create(type, id, data);
|
|
1194
|
+
},
|
|
1195
|
+
async generate(options) {
|
|
1196
|
+
// Placeholder - actual AI generation would be implemented here
|
|
1197
|
+
// For now, just create with provided data
|
|
1198
|
+
const provider = await getProvider();
|
|
1199
|
+
if (options.mode === 'background') {
|
|
1200
|
+
// Return action ID for tracking
|
|
1201
|
+
const { createMemoryProvider } = await import('./memory-provider.js');
|
|
1202
|
+
const memProvider = provider;
|
|
1203
|
+
if ('createAction' in memProvider) {
|
|
1204
|
+
return memProvider.createAction({
|
|
1205
|
+
type: 'generate',
|
|
1206
|
+
data: options,
|
|
1207
|
+
total: options.count ?? 1,
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
// Sync mode - create single entity
|
|
1212
|
+
return provider.create(options.type, undefined, options.data ?? {});
|
|
1213
|
+
},
|
|
1214
|
+
ask: createNLQueryFn(parsedSchema),
|
|
1215
|
+
on: onInternal,
|
|
1216
|
+
...entityOperations,
|
|
1217
|
+
};
|
|
1218
|
+
// Create sub-APIs using extracted factory functions
|
|
1219
|
+
const events = createEventsAPI(getProvider);
|
|
1220
|
+
const actions = createActionsPublicAPI(getProvider);
|
|
1221
|
+
const artifacts = createArtifactsAPI(getProvider);
|
|
1222
|
+
const nouns = createNounsAPI(nounDefinitions);
|
|
1223
|
+
const verbs = createVerbsAPI(verbDefinitions);
|
|
1224
|
+
const eventBridge = createEventBridge();
|
|
1225
|
+
// Return combined object that supports both direct usage and destructuring
|
|
1226
|
+
// db.User.create() works, db.events.on() works
|
|
1227
|
+
// const { db, events } = DB(...) also works
|
|
1228
|
+
return Object.assign(db, {
|
|
1229
|
+
db, // self-reference for destructuring
|
|
1230
|
+
events,
|
|
1231
|
+
actions,
|
|
1232
|
+
artifacts,
|
|
1233
|
+
nouns,
|
|
1234
|
+
verbs,
|
|
1235
|
+
eventBridge,
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Parse a URL into type and id
|
|
1240
|
+
*/
|
|
1241
|
+
function parseUrl(url) {
|
|
1242
|
+
// Handle full URLs
|
|
1243
|
+
if (url.includes('://')) {
|
|
1244
|
+
const parsed = new URL(url);
|
|
1245
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
1246
|
+
return {
|
|
1247
|
+
type: parts[0] || '',
|
|
1248
|
+
id: parts.slice(1).join('/'),
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
// Handle type/id format
|
|
1252
|
+
if (url.includes('/')) {
|
|
1253
|
+
const parts = url.split('/');
|
|
1254
|
+
return {
|
|
1255
|
+
type: parts[0],
|
|
1256
|
+
id: parts.slice(1).join('/'),
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
// Just id
|
|
1260
|
+
return { type: '', id: url };
|
|
1261
|
+
}
|
|
64
1262
|
// =============================================================================
|
|
65
|
-
//
|
|
1263
|
+
// Forward Exact Resolution - Auto-generate related entities
|
|
66
1264
|
// =============================================================================
|
|
67
|
-
|
|
1265
|
+
/**
|
|
1266
|
+
* Generate an entity based on its type and context
|
|
1267
|
+
*
|
|
1268
|
+
* For testing, generates deterministic content based on the prompt and type.
|
|
1269
|
+
* In production, this would integrate with AI generation.
|
|
1270
|
+
*
|
|
1271
|
+
* @param type - The type of entity to generate
|
|
1272
|
+
* @param prompt - Optional prompt for generation context
|
|
1273
|
+
* @param context - Parent context information (parent type name, parentData, and optional parentId)
|
|
1274
|
+
* @param schema - The parsed schema
|
|
1275
|
+
*/
|
|
1276
|
+
async function generateEntity(type, prompt, context, schema) {
|
|
1277
|
+
const entity = schema.entities.get(type);
|
|
1278
|
+
if (!entity)
|
|
1279
|
+
throw new Error(`Unknown type: ${type}`);
|
|
1280
|
+
// Gather context for generation
|
|
1281
|
+
const parentEntity = schema.entities.get(context.parent);
|
|
1282
|
+
// EntitySchema is Record<string, FieldDefinition | unknown> - safe to treat as Record
|
|
1283
|
+
const parentSchema = parentEntity?.schema ?? {};
|
|
1284
|
+
// Use type guard helper to safely extract schema metadata
|
|
1285
|
+
const instructions = getSchemaMetadata(parentSchema, '$instructions');
|
|
1286
|
+
const schemaContext = getSchemaMetadata(parentSchema, '$context');
|
|
1287
|
+
// Extract relevant parent data for context (excluding metadata fields)
|
|
1288
|
+
const parentContextFields = [];
|
|
1289
|
+
for (const [key, value] of Object.entries(context.parentData)) {
|
|
1290
|
+
if (!key.startsWith('$') && !key.startsWith('_') && typeof value === 'string' && value) {
|
|
1291
|
+
parentContextFields.push(`${key}: ${value}`);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
// Build context string for generation
|
|
1295
|
+
const contextParts = [];
|
|
1296
|
+
if (prompt && prompt.trim()) {
|
|
1297
|
+
contextParts.push(prompt);
|
|
1298
|
+
}
|
|
1299
|
+
if (instructions) {
|
|
1300
|
+
contextParts.push(instructions);
|
|
1301
|
+
}
|
|
1302
|
+
if (schemaContext) {
|
|
1303
|
+
contextParts.push(schemaContext);
|
|
1304
|
+
}
|
|
1305
|
+
if (parentContextFields.length > 0) {
|
|
1306
|
+
contextParts.push(parentContextFields.join(', '));
|
|
1307
|
+
}
|
|
1308
|
+
const fullContext = contextParts.join(' | ');
|
|
1309
|
+
const data = {};
|
|
1310
|
+
for (const [fieldName, field] of entity.fields) {
|
|
1311
|
+
if (!field.isRelation) {
|
|
1312
|
+
if (field.type === 'string') {
|
|
1313
|
+
// Generate context-aware content
|
|
1314
|
+
data[fieldName] = generateContextAwareValue(fieldName, type, fullContext, prompt);
|
|
1315
|
+
}
|
|
1316
|
+
else if (field.isArray && field.type === 'string') {
|
|
1317
|
+
// Generate array of strings
|
|
1318
|
+
data[fieldName] = [generateContextAwareValue(fieldName, type, fullContext, prompt)];
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
else if (field.operator === '<-' && field.direction === 'backward') {
|
|
1322
|
+
// Backward relation to parent - set the parent's ID if this entity's
|
|
1323
|
+
// related type matches the parent type
|
|
1324
|
+
if (field.relatedType === context.parent && context.parentId) {
|
|
1325
|
+
// Store the parent ID directly - this is a reference back to the parent
|
|
1326
|
+
data[fieldName] = context.parentId;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
else if (field.operator === '->' && field.direction === 'forward') {
|
|
1330
|
+
// Recursively generate nested forward exact relations
|
|
1331
|
+
// This handles cases like Person.bio -> Bio (single relations, not arrays)
|
|
1332
|
+
// Array relations are handled by resolveForwardExact or cascadeGenerate
|
|
1333
|
+
if (!field.isOptional && !field.isArray) {
|
|
1334
|
+
const nestedGenerated = await generateEntity(field.relatedType, field.prompt, { parent: type, parentData: data }, schema);
|
|
1335
|
+
// We need to create the nested entity too, but we can't do that here
|
|
1336
|
+
// because we don't have access to the provider yet.
|
|
1337
|
+
// This will be handled by resolveForwardExact when it calls us
|
|
1338
|
+
data[`_pending_${fieldName}`] = { type: field.relatedType, data: nestedGenerated };
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
return data;
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Resolve forward exact (->) fields by auto-generating related entities
|
|
1346
|
+
*
|
|
1347
|
+
* When creating an entity with a -> field, if no value is provided,
|
|
1348
|
+
* we auto-generate the related entity and link it.
|
|
1349
|
+
*
|
|
1350
|
+
* Returns resolved data and pending relationships that need to be created
|
|
1351
|
+
* after the parent entity is created (for array fields).
|
|
1352
|
+
*
|
|
1353
|
+
* @param parentId - Pre-generated ID of the parent entity, so generated children
|
|
1354
|
+
* can set backward references to it
|
|
1355
|
+
*/
|
|
1356
|
+
async function resolveForwardExact(typeName, data, entity, schema, provider, parentId, resolveOptions) {
|
|
1357
|
+
const resolved = { ...data };
|
|
1358
|
+
const pendingRelations = [];
|
|
1359
|
+
/**
|
|
1360
|
+
* For union types, find which type an entity ID belongs to
|
|
1361
|
+
*/
|
|
1362
|
+
async function findEntityType(id, types) {
|
|
1363
|
+
for (const type of types) {
|
|
1364
|
+
const entity = await provider.get(type, id);
|
|
1365
|
+
if (entity)
|
|
1366
|
+
return type;
|
|
1367
|
+
}
|
|
1368
|
+
return null;
|
|
1369
|
+
}
|
|
1370
|
+
for (const [fieldName, field] of entity.fields) {
|
|
1371
|
+
if (field.operator === '->' && field.direction === 'forward') {
|
|
1372
|
+
// Get all possible types (union types or just the single related type)
|
|
1373
|
+
const possibleTypes = field.unionTypes || [field.relatedType];
|
|
1374
|
+
// Skip if value already provided
|
|
1375
|
+
if (resolved[fieldName] !== undefined && resolved[fieldName] !== null) {
|
|
1376
|
+
// If value is provided for array field, we still need to create relationships
|
|
1377
|
+
if (field.isArray && Array.isArray(resolved[fieldName])) {
|
|
1378
|
+
const ids = resolved[fieldName];
|
|
1379
|
+
const matchedTypes = [];
|
|
1380
|
+
for (const targetId of ids) {
|
|
1381
|
+
// For union types, determine the actual type of each ID
|
|
1382
|
+
const actualType = field.unionTypes
|
|
1383
|
+
? (await findEntityType(targetId, possibleTypes)) || field.relatedType
|
|
1384
|
+
: field.relatedType;
|
|
1385
|
+
pendingRelations.push({ fieldName, targetType: actualType, targetId });
|
|
1386
|
+
matchedTypes.push(actualType);
|
|
1387
|
+
}
|
|
1388
|
+
// Store matched types for union type arrays
|
|
1389
|
+
if (field.unionTypes && matchedTypes.length > 0) {
|
|
1390
|
+
resolved[`${fieldName}$matchedTypes`] = matchedTypes;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
else if (!field.isArray) {
|
|
1394
|
+
// Single value provided - for union types, determine the actual type
|
|
1395
|
+
const providedId = resolved[fieldName];
|
|
1396
|
+
if (field.unionTypes) {
|
|
1397
|
+
const actualType = await findEntityType(providedId, possibleTypes);
|
|
1398
|
+
if (actualType) {
|
|
1399
|
+
resolved[`${fieldName}$matchedType`] = actualType;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
// Skip optional fields - they shouldn't auto-generate
|
|
1406
|
+
if (field.isOptional)
|
|
1407
|
+
continue;
|
|
1408
|
+
if (field.isArray) {
|
|
1409
|
+
// When cascade is enabled, skip array generation - cascadeGenerate will handle it
|
|
1410
|
+
// with proper depth control
|
|
1411
|
+
if (resolveOptions?.skipArrayGeneration)
|
|
1412
|
+
continue;
|
|
1413
|
+
// Forward array relation - check if we should auto-generate
|
|
1414
|
+
const relatedEntity = schema.entities.get(field.relatedType);
|
|
1415
|
+
if (!relatedEntity)
|
|
1416
|
+
continue;
|
|
1417
|
+
// Check if related entity has a backward ref to this type (symmetric relationship)
|
|
1418
|
+
let hasBackwardRef = false;
|
|
1419
|
+
for (const [, relField] of relatedEntity.fields) {
|
|
1420
|
+
if (relField.isRelation &&
|
|
1421
|
+
relField.relatedType === typeName &&
|
|
1422
|
+
relField.direction === 'backward') {
|
|
1423
|
+
hasBackwardRef = true;
|
|
1424
|
+
break;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
// Check if related entity has required non-relation fields
|
|
1428
|
+
let hasRequiredScalarFields = false;
|
|
1429
|
+
for (const [, relField] of relatedEntity.fields) {
|
|
1430
|
+
if (!relField.isRelation && !relField.isOptional) {
|
|
1431
|
+
hasRequiredScalarFields = true;
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
// Decide whether to auto-generate:
|
|
1436
|
+
// - If there's a symmetric backward ref AND required scalars, skip (prevents duplicates)
|
|
1437
|
+
// - Otherwise, generate if the related entity can be meaningfully generated
|
|
1438
|
+
// - For union types, be more lenient to allow polymorphic generation
|
|
1439
|
+
const shouldSkip = hasBackwardRef && hasRequiredScalarFields;
|
|
1440
|
+
const canGenerate = !shouldSkip &&
|
|
1441
|
+
(hasBackwardRef || // Symmetric ref without required scalars
|
|
1442
|
+
field.prompt || // Has a generation prompt
|
|
1443
|
+
field.unionTypes || // Union types should generate from first type
|
|
1444
|
+
!hasRequiredScalarFields); // No required fields to worry about
|
|
1445
|
+
if (!canGenerate)
|
|
1446
|
+
continue;
|
|
1447
|
+
// For union types, use first type for generation
|
|
1448
|
+
const generateType = field.relatedType;
|
|
1449
|
+
const generated = await generateEntity(generateType, field.prompt, { parent: typeName, parentData: data, parentId }, schema);
|
|
1450
|
+
// Resolve any pending nested relations in the generated data
|
|
1451
|
+
const resolvedGenerated = await resolveNestedPending(generated, relatedEntity, schema, provider);
|
|
1452
|
+
const created = await provider.create(generateType, undefined, resolvedGenerated);
|
|
1453
|
+
resolved[fieldName] = [created['$id']];
|
|
1454
|
+
// Store matched type for union types
|
|
1455
|
+
if (field.unionTypes) {
|
|
1456
|
+
resolved[`${fieldName}$matchedTypes`] = [generateType];
|
|
1457
|
+
}
|
|
1458
|
+
// Queue relationship creation for after parent entity is created
|
|
1459
|
+
pendingRelations.push({
|
|
1460
|
+
fieldName,
|
|
1461
|
+
targetType: generateType,
|
|
1462
|
+
targetId: created['$id'],
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
else {
|
|
1466
|
+
// Single non-optional forward relation - generate the related entity
|
|
1467
|
+
// For union types, use first type for generation
|
|
1468
|
+
const generateType = field.relatedType;
|
|
1469
|
+
const generated = await generateEntity(generateType, field.prompt, { parent: typeName, parentData: data, parentId }, schema);
|
|
1470
|
+
// Resolve any pending nested relations in the generated data
|
|
1471
|
+
const relatedEntity = schema.entities.get(generateType);
|
|
1472
|
+
if (relatedEntity) {
|
|
1473
|
+
const resolvedGenerated = await resolveNestedPending(generated, relatedEntity, schema, provider);
|
|
1474
|
+
const created = await provider.create(generateType, undefined, resolvedGenerated);
|
|
1475
|
+
resolved[fieldName] = created['$id'];
|
|
1476
|
+
// Mark this forward ref as auto-generated so hydrateEntity knows to create a proxy
|
|
1477
|
+
resolved[`${fieldName}$autoGenerated`] = true;
|
|
1478
|
+
// Store matched type for union types
|
|
1479
|
+
if (field.unionTypes) {
|
|
1480
|
+
resolved[`${fieldName}$matchedType`] = generateType;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
return { data: resolved, pendingRelations };
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Generate a simple entity with only scalar fields populated
|
|
1490
|
+
*
|
|
1491
|
+
* This is used by cascade generation to avoid infinite recursion.
|
|
1492
|
+
* Unlike generateEntity, this does NOT recursively generate nested relations.
|
|
1493
|
+
*
|
|
1494
|
+
* @param type - The type of entity to generate
|
|
1495
|
+
* @param prompt - Optional prompt for generation context
|
|
1496
|
+
* @param context - Parent context information
|
|
1497
|
+
* @param entityDef - The parsed entity definition
|
|
1498
|
+
*/
|
|
1499
|
+
function generateSimpleEntity(type, prompt, context, entityDef, schema) {
|
|
1500
|
+
const data = {};
|
|
1501
|
+
// Build context from parent $instructions and child $instructions
|
|
1502
|
+
const contextParts = [];
|
|
1503
|
+
if (schema) {
|
|
1504
|
+
const parentEntity = schema.entities.get(context.parent);
|
|
1505
|
+
const parentInstructions = parentEntity?.schema?.['$instructions'];
|
|
1506
|
+
const childInstructions = entityDef.schema?.['$instructions'];
|
|
1507
|
+
if (parentInstructions)
|
|
1508
|
+
contextParts.push(parentInstructions);
|
|
1509
|
+
if (childInstructions)
|
|
1510
|
+
contextParts.push(childInstructions);
|
|
1511
|
+
}
|
|
1512
|
+
if (prompt)
|
|
1513
|
+
contextParts.push(prompt);
|
|
1514
|
+
for (const [key, value] of Object.entries(context.parentData)) {
|
|
1515
|
+
if (!key.startsWith('$') && !key.startsWith('_') && typeof value === 'string' && value) {
|
|
1516
|
+
contextParts.push(`${key}: ${value}`);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
const fullContext = contextParts.filter(Boolean).join(' | ');
|
|
1520
|
+
for (const [fieldName, field] of entityDef.fields) {
|
|
1521
|
+
if (!field.isRelation) {
|
|
1522
|
+
const isPrompt = isPromptField(field);
|
|
1523
|
+
if (field.type === 'string' || isPrompt) {
|
|
1524
|
+
const fieldHint = isPrompt ? field.type : prompt;
|
|
1525
|
+
data[fieldName] = generateContextAwareValue(fieldName, type, fullContext, fieldHint);
|
|
1526
|
+
}
|
|
1527
|
+
else if (field.isArray && (field.type === 'string' || isPrompt)) {
|
|
1528
|
+
const fieldHint = isPrompt ? field.type : prompt;
|
|
1529
|
+
data[fieldName] = [generateContextAwareValue(fieldName, type, fullContext, fieldHint)];
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
else if (field.operator === '<-' && field.direction === 'backward') {
|
|
1533
|
+
// Backward relation to parent
|
|
1534
|
+
if (field.relatedType === context.parent && context.parentId) {
|
|
1535
|
+
data[fieldName] = context.parentId;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
// Skip forward relations - cascade will handle them
|
|
1539
|
+
}
|
|
1540
|
+
return data;
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Recursively generate related entities through cascade relationships
|
|
1544
|
+
*
|
|
1545
|
+
* This function traverses -> and ~> array relationships and generates
|
|
1546
|
+
* child entities at each level, respecting depth limits and type filters.
|
|
1547
|
+
*
|
|
1548
|
+
* @param entity - The parent entity data
|
|
1549
|
+
* @param entityDef - The parsed entity definition
|
|
1550
|
+
* @param schema - The parsed schema
|
|
1551
|
+
* @param provider - The database provider
|
|
1552
|
+
* @param options - Cascade options including maxDepth and type filters
|
|
1553
|
+
* @param depth - Current recursion depth
|
|
1554
|
+
* @param progress - Progress tracking object (mutated)
|
|
1555
|
+
*/
|
|
1556
|
+
async function cascadeGenerate(entity, entityDef, schema, provider, options, depth, progress) {
|
|
1557
|
+
const maxDepth = options.maxDepth ?? 3;
|
|
1558
|
+
// Hard safety cap to prevent infinite recursion with circular schemas
|
|
1559
|
+
const effectiveMax = Math.min(maxDepth, DEFAULT_MAX_DEPTH);
|
|
1560
|
+
// Stop if we've reached max depth
|
|
1561
|
+
if (depth >= effectiveMax)
|
|
1562
|
+
return;
|
|
1563
|
+
// Report progress at this depth (even if no relations to process)
|
|
1564
|
+
progress.currentDepth = depth;
|
|
1565
|
+
progress.depth = depth;
|
|
1566
|
+
options.onProgress?.({ ...progress, phase: 'generating' });
|
|
1567
|
+
const entityId = (entity['$id'] || entity['id']);
|
|
1568
|
+
for (const [fieldName, field] of entityDef.fields) {
|
|
1569
|
+
// Only cascade through forward relationships (-> or ~>) that are arrays
|
|
1570
|
+
// or single references that haven't been populated yet
|
|
1571
|
+
const isForwardRelation = field.operator === '->' || field.operator === '~>';
|
|
1572
|
+
const isGeneratableRelation = isForwardRelation && field.relatedType;
|
|
1573
|
+
if (!isGeneratableRelation)
|
|
1574
|
+
continue;
|
|
1575
|
+
// Check if this type should be cascaded (if cascadeTypes filter is set)
|
|
1576
|
+
if (options.cascadeTypes && !options.cascadeTypes.includes(field.relatedType)) {
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
// Report progress for this type
|
|
1580
|
+
progress.currentDepth = depth;
|
|
1581
|
+
progress.depth = depth;
|
|
1582
|
+
if (field.relatedType !== undefined) {
|
|
1583
|
+
progress.currentType = field.relatedType;
|
|
1584
|
+
}
|
|
1585
|
+
options.onProgress?.({ ...progress, phase: 'generating' });
|
|
1586
|
+
try {
|
|
1587
|
+
const relatedEntityDef = schema.entities.get(field.relatedType);
|
|
1588
|
+
if (!relatedEntityDef)
|
|
1589
|
+
continue;
|
|
1590
|
+
// Check if field already has values (from existing resolution)
|
|
1591
|
+
const existingValue = entity[fieldName];
|
|
1592
|
+
if (existingValue && Array.isArray(existingValue) && existingValue.length > 0) {
|
|
1593
|
+
// Already has values, cascade into each child
|
|
1594
|
+
for (const childId of existingValue) {
|
|
1595
|
+
const childData = await provider.get(field.relatedType, childId);
|
|
1596
|
+
if (childData) {
|
|
1597
|
+
await cascadeGenerate(childData, relatedEntityDef, schema, provider, options, depth + 1, progress);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
else if (existingValue && typeof existingValue === 'string') {
|
|
1603
|
+
// Single reference already populated, cascade into it
|
|
1604
|
+
const childData = await provider.get(field.relatedType, existingValue);
|
|
1605
|
+
if (childData) {
|
|
1606
|
+
await cascadeGenerate(childData, relatedEntityDef, schema, provider, options, depth + 1, progress);
|
|
1607
|
+
}
|
|
1608
|
+
continue;
|
|
1609
|
+
}
|
|
1610
|
+
// Generate new related entities
|
|
1611
|
+
if (field.isArray) {
|
|
1612
|
+
// Generate array of related entities using AI-enabled generation
|
|
1613
|
+
const generated = await cascadeGenerateEntity(field.relatedType, field.prompt, { parent: entityDef.name, parentData: entity, parentId: entityId }, schema);
|
|
1614
|
+
const created = await provider.create(field.relatedType, undefined, generated);
|
|
1615
|
+
// Update the parent entity with the new relation
|
|
1616
|
+
const existingIds = entity[fieldName] || [];
|
|
1617
|
+
const newIds = [...existingIds, created['$id']];
|
|
1618
|
+
await provider.update(entityDef.name, entityId, { [fieldName]: newIds });
|
|
1619
|
+
entity[fieldName] = newIds;
|
|
1620
|
+
// Create relationship
|
|
1621
|
+
await provider.relate(entityDef.name, entityId, fieldName, field.relatedType, created['$id']);
|
|
1622
|
+
progress.totalEntitiesCreated++;
|
|
1623
|
+
if (!progress.typesGenerated.includes(field.relatedType)) {
|
|
1624
|
+
progress.typesGenerated.push(field.relatedType);
|
|
1625
|
+
}
|
|
1626
|
+
// Recursively cascade into the new child
|
|
1627
|
+
await cascadeGenerate(created, relatedEntityDef, schema, provider, options, depth + 1, progress);
|
|
1628
|
+
}
|
|
1629
|
+
else {
|
|
1630
|
+
// Generate single related entity using AI-enabled generation
|
|
1631
|
+
const generated = await cascadeGenerateEntity(field.relatedType, field.prompt, { parent: entityDef.name, parentData: entity, parentId: entityId }, schema);
|
|
1632
|
+
const created = await provider.create(field.relatedType, undefined, generated);
|
|
1633
|
+
// Update the parent entity with the new relation
|
|
1634
|
+
await provider.update(entityDef.name, entityId, { [fieldName]: created['$id'] });
|
|
1635
|
+
entity[fieldName] = created['$id'];
|
|
1636
|
+
progress.totalEntitiesCreated++;
|
|
1637
|
+
if (!progress.typesGenerated.includes(field.relatedType)) {
|
|
1638
|
+
progress.typesGenerated.push(field.relatedType);
|
|
1639
|
+
}
|
|
1640
|
+
// Recursively cascade into the new child
|
|
1641
|
+
await cascadeGenerate(created, relatedEntityDef, schema, provider, options, depth + 1, progress);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
catch (error) {
|
|
1645
|
+
options.onError?.(error, { type: field.relatedType, depth });
|
|
1646
|
+
if (options.stopOnError)
|
|
1647
|
+
throw error;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Resolve backward fuzzy (<~) fields by using semantic search to find existing entities
|
|
1653
|
+
*
|
|
1654
|
+
* The <~ operator differs from <- in that it uses semantic/fuzzy matching:
|
|
1655
|
+
* - Uses AI/embedding-based similarity to find the best match from existing entities
|
|
1656
|
+
* - Does NOT generate new entities - only grounds to existing reference data
|
|
1657
|
+
* - Uses hint fields (e.g., categoryHint for category field) to guide matching
|
|
1658
|
+
*
|
|
1659
|
+
* @param typeName - The type of entity being created
|
|
1660
|
+
* @param data - The input data including hint fields
|
|
1661
|
+
* @param entity - The parsed entity definition
|
|
1662
|
+
* @param schema - The parsed schema
|
|
1663
|
+
* @param provider - The database provider (must support semanticSearch)
|
|
1664
|
+
* @returns The resolved data with backward fuzzy fields populated with matched entity IDs
|
|
1665
|
+
*/
|
|
1666
|
+
async function resolveBackwardFuzzy(typeName, data, entity, schema, provider) {
|
|
1667
|
+
const resolved = { ...data };
|
|
1668
|
+
// Type-safe access to schema metadata with default fallback
|
|
1669
|
+
const schemaWithMeta = entity.schema;
|
|
1670
|
+
const threshold = schemaWithMeta?.$fuzzyThreshold ?? 0.75;
|
|
1671
|
+
/**
|
|
1672
|
+
* Search all union types in parallel and return matches
|
|
1673
|
+
*/
|
|
1674
|
+
async function searchUnionTypes(types, searchQuery, threshold, limit) {
|
|
1675
|
+
if (!hasSemanticSearch(provider))
|
|
1676
|
+
return [];
|
|
1677
|
+
// Search all types in parallel
|
|
1678
|
+
const allMatches = await Promise.all(types.map(async (type) => {
|
|
1679
|
+
const matches = await provider.semanticSearch(type, searchQuery, {
|
|
1680
|
+
minScore: threshold,
|
|
1681
|
+
limit,
|
|
1682
|
+
});
|
|
1683
|
+
return matches.map((m) => ({ id: m.$id, type, score: m.$score }));
|
|
1684
|
+
}));
|
|
1685
|
+
// Flatten and sort by score (best first)
|
|
1686
|
+
return allMatches.flat().sort((a, b) => b.score - a.score);
|
|
1687
|
+
}
|
|
1688
|
+
for (const [fieldName, field] of entity.fields) {
|
|
1689
|
+
if (field.operator === '<~' && field.direction === 'backward') {
|
|
1690
|
+
// Skip if value already provided
|
|
1691
|
+
if (resolved[fieldName] !== undefined && resolved[fieldName] !== null) {
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
// Get the hint field value - uses fieldNameHint convention
|
|
1695
|
+
const hintKey = `${fieldName}Hint`;
|
|
1696
|
+
const searchQuery = data[hintKey] || field.prompt || '';
|
|
1697
|
+
// Skip if no search query available (optional fields without hint)
|
|
1698
|
+
if (!searchQuery) {
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
// Get all types to search (union types or just the single related type)
|
|
1702
|
+
const typesToSearch = field.unionTypes || [field.relatedType];
|
|
1703
|
+
// Check if provider supports semantic search
|
|
1704
|
+
if (hasSemanticSearch(provider)) {
|
|
1705
|
+
if (field.unionTypes && field.unionTypes.length > 0) {
|
|
1706
|
+
// Union type - search all types
|
|
1707
|
+
const matches = await searchUnionTypes(typesToSearch, searchQuery, threshold, field.isArray ? 10 : 1);
|
|
1708
|
+
if (matches.length > 0) {
|
|
1709
|
+
if (field.isArray) {
|
|
1710
|
+
// For array fields, return all matches above threshold
|
|
1711
|
+
const validMatches = matches.filter((m) => m.score >= threshold);
|
|
1712
|
+
resolved[fieldName] = validMatches.map((m) => m.id);
|
|
1713
|
+
resolved[`${fieldName}$matchedTypes`] = validMatches.map((m) => m.type);
|
|
1714
|
+
resolved[`${fieldName}$scores`] = validMatches.map((m) => m.score);
|
|
1715
|
+
resolved[`${fieldName}$searchOrder`] = typesToSearch;
|
|
1716
|
+
// Check if any match came from a non-first type (fallback)
|
|
1717
|
+
const firstType = typesToSearch[0];
|
|
1718
|
+
const fallbackUsed = validMatches.some((m) => m.type !== firstType);
|
|
1719
|
+
if (fallbackUsed) {
|
|
1720
|
+
resolved[`${fieldName}$fallbackUsed`] = true;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
else {
|
|
1724
|
+
// For single fields, return the best match
|
|
1725
|
+
const firstMatch = matches[0];
|
|
1726
|
+
if (firstMatch) {
|
|
1727
|
+
resolved[fieldName] = firstMatch.id;
|
|
1728
|
+
resolved[`${fieldName}$matchedType`] = firstMatch.type;
|
|
1729
|
+
resolved[`${fieldName}$score`] = firstMatch.score;
|
|
1730
|
+
resolved[`${fieldName}$searchOrder`] = typesToSearch;
|
|
1731
|
+
// Fallback is triggered if the matched type is not the first type in the union
|
|
1732
|
+
const firstType = typesToSearch[0];
|
|
1733
|
+
if (firstMatch.type !== firstType) {
|
|
1734
|
+
resolved[`${fieldName}$fallbackUsed`] = true;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
else {
|
|
1741
|
+
// Non-union type - use standard search
|
|
1742
|
+
const matches = await provider.semanticSearch(field.relatedType, searchQuery, {
|
|
1743
|
+
minScore: threshold,
|
|
1744
|
+
limit: field.isArray ? 10 : 1,
|
|
1745
|
+
});
|
|
1746
|
+
if (matches.length > 0) {
|
|
1747
|
+
if (field.isArray) {
|
|
1748
|
+
// For array fields, return all matches above threshold
|
|
1749
|
+
// SemanticSearchResult has $id and $score properties
|
|
1750
|
+
resolved[fieldName] = matches.filter((m) => m.$score >= threshold).map((m) => m.$id);
|
|
1751
|
+
}
|
|
1752
|
+
else {
|
|
1753
|
+
// For single fields, return the best match
|
|
1754
|
+
const firstMatch = matches[0];
|
|
1755
|
+
if (firstMatch) {
|
|
1756
|
+
resolved[fieldName] = firstMatch.$id;
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
// Note: <~ typically doesn't generate - it grounds to existing data
|
|
1763
|
+
// If no match found and field is optional, leave as undefined
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
return resolved;
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Resolve forward fuzzy (~>) fields via semantic search then generation
|
|
1770
|
+
*
|
|
1771
|
+
* The ~> operator differs from -> in that it first attempts semantic search:
|
|
1772
|
+
* - Searches existing entities via embedding similarity
|
|
1773
|
+
* - If a match is found above threshold, reuses the existing entity
|
|
1774
|
+
* - If no match is found, generates a new entity
|
|
1775
|
+
* - Respects configurable similarity threshold ($fuzzyThreshold or field-level)
|
|
1776
|
+
*
|
|
1777
|
+
* @param typeName - The type of entity being created
|
|
1778
|
+
* @param data - The input data including hint fields
|
|
1779
|
+
* @param entity - The parsed entity definition
|
|
1780
|
+
* @param schema - The parsed schema
|
|
1781
|
+
* @param provider - The database provider (must support semanticSearch)
|
|
1782
|
+
* @param parentId - Pre-generated ID of the parent entity for backward refs
|
|
1783
|
+
* @returns Object with resolved data and pending relations for array fields
|
|
1784
|
+
*/
|
|
1785
|
+
async function resolveForwardFuzzy(typeName, data, entity, schema, provider, parentId) {
|
|
1786
|
+
const resolved = { ...data };
|
|
1787
|
+
const pendingRelations = [];
|
|
1788
|
+
// Type-safe access to schema metadata with default fallback
|
|
1789
|
+
const schemaWithMeta = entity.schema;
|
|
1790
|
+
const defaultThreshold = schemaWithMeta?.$fuzzyThreshold ?? 0.75;
|
|
1791
|
+
/**
|
|
1792
|
+
* Search all union types in parallel and return the best match
|
|
1793
|
+
*/
|
|
1794
|
+
async function searchUnionTypes(types, searchQuery, threshold) {
|
|
1795
|
+
if (!hasSemanticSearch(provider))
|
|
1796
|
+
return null;
|
|
1797
|
+
const allMatches = await Promise.all(types.map(async (type) => {
|
|
1798
|
+
const matches = await provider.semanticSearch(type, searchQuery, {
|
|
1799
|
+
minScore: threshold,
|
|
1800
|
+
limit: 3,
|
|
1801
|
+
});
|
|
1802
|
+
return matches.map((m) => ({ ...m, $matchedType: type }));
|
|
1803
|
+
}));
|
|
1804
|
+
// Flatten and find the best match
|
|
1805
|
+
const flat = allMatches.flat();
|
|
1806
|
+
if (flat.length === 0)
|
|
1807
|
+
return null;
|
|
1808
|
+
const best = flat.reduce((a, b) => (a.$score > b.$score ? a : b));
|
|
1809
|
+
return best.$score >= threshold
|
|
1810
|
+
? { id: best.$id, type: best.$matchedType, score: best.$score }
|
|
1811
|
+
: null;
|
|
1812
|
+
}
|
|
1813
|
+
for (const [fieldName, field] of entity.fields) {
|
|
1814
|
+
if (field.operator === '~>' && field.direction === 'forward') {
|
|
1815
|
+
// Skip if value already provided (e.g., resolved by draft/resolve pipeline)
|
|
1816
|
+
if (resolved[fieldName] !== undefined && resolved[fieldName] !== null) {
|
|
1817
|
+
if (field.isArray && Array.isArray(resolved[fieldName])) {
|
|
1818
|
+
// Array field - create relationships with matched type metadata
|
|
1819
|
+
const ids = resolved[fieldName];
|
|
1820
|
+
const matchedTypes = resolved[`${fieldName}$matchedTypes`];
|
|
1821
|
+
for (let i = 0; i < ids.length; i++) {
|
|
1822
|
+
const targetType = matchedTypes?.[i] || field.relatedType;
|
|
1823
|
+
pendingRelations.push({
|
|
1824
|
+
fieldName,
|
|
1825
|
+
targetType,
|
|
1826
|
+
targetId: ids[i],
|
|
1827
|
+
matchedType: targetType,
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
else if (typeof resolved[fieldName] === 'string') {
|
|
1832
|
+
// Single field - create relationship with matched type metadata
|
|
1833
|
+
const targetId = resolved[fieldName];
|
|
1834
|
+
const matchedType = resolved[`${fieldName}$matchedType`] || field.relatedType;
|
|
1835
|
+
const score = resolved[`${fieldName}$score`];
|
|
1836
|
+
pendingRelations.push({
|
|
1837
|
+
fieldName,
|
|
1838
|
+
targetType: matchedType,
|
|
1839
|
+
targetId,
|
|
1840
|
+
...(score !== undefined ? { similarity: score } : {}),
|
|
1841
|
+
matchedType,
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1846
|
+
// Get the hint field value - uses fieldNameHint convention
|
|
1847
|
+
const hintKey = `${fieldName}Hint`;
|
|
1848
|
+
const hintValue = data[hintKey];
|
|
1849
|
+
const searchQuery = (typeof hintValue === 'string' ? hintValue : undefined) || field.prompt || fieldName;
|
|
1850
|
+
// Get threshold - field-level overrides entity-level
|
|
1851
|
+
const threshold = field.threshold ?? defaultThreshold;
|
|
1852
|
+
// Get all types to search (union types or just the single related type)
|
|
1853
|
+
const typesToSearch = field.unionTypes || [field.relatedType];
|
|
1854
|
+
if (field.isArray) {
|
|
1855
|
+
// Array fuzzy field - can contain both matched and generated
|
|
1856
|
+
const hints = Array.isArray(hintValue) ? hintValue : [hintValue].filter(Boolean);
|
|
1857
|
+
const resultIds = [];
|
|
1858
|
+
const matchedTypes = [];
|
|
1859
|
+
for (const hint of hints) {
|
|
1860
|
+
const hintStr = String(hint || fieldName);
|
|
1861
|
+
let matched = false;
|
|
1862
|
+
// Try semantic search across all union types
|
|
1863
|
+
const match = await searchUnionTypes(typesToSearch, hintStr, threshold);
|
|
1864
|
+
if (match) {
|
|
1865
|
+
resultIds.push(match.id);
|
|
1866
|
+
matchedTypes.push(match.type);
|
|
1867
|
+
pendingRelations.push({
|
|
1868
|
+
fieldName,
|
|
1869
|
+
targetType: match.type,
|
|
1870
|
+
targetId: match.id,
|
|
1871
|
+
similarity: match.score,
|
|
1872
|
+
matchedType: match.type,
|
|
1873
|
+
});
|
|
1874
|
+
matched = true;
|
|
1875
|
+
}
|
|
1876
|
+
// Generate if no match found - use first type in union
|
|
1877
|
+
if (!matched) {
|
|
1878
|
+
const generateType = typesToSearch[0];
|
|
1879
|
+
const generated = await generateEntity(generateType, hintStr, { parent: typeName, parentData: data, parentId }, schema);
|
|
1880
|
+
// Resolve any pending nested relations
|
|
1881
|
+
const relatedEntity = schema.entities.get(generateType);
|
|
1882
|
+
if (relatedEntity) {
|
|
1883
|
+
const resolvedGenerated = await resolveNestedPending(generated, relatedEntity, schema, provider);
|
|
1884
|
+
const created = await provider.create(generateType, undefined, {
|
|
1885
|
+
...resolvedGenerated,
|
|
1886
|
+
$generated: true,
|
|
1887
|
+
$generatedBy: parentId,
|
|
1888
|
+
$sourceField: fieldName,
|
|
1889
|
+
});
|
|
1890
|
+
resultIds.push(created['$id']);
|
|
1891
|
+
matchedTypes.push(generateType);
|
|
1892
|
+
pendingRelations.push({
|
|
1893
|
+
fieldName,
|
|
1894
|
+
targetType: generateType,
|
|
1895
|
+
targetId: created['$id'],
|
|
1896
|
+
matchedType: generateType,
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
resolved[fieldName] = resultIds;
|
|
1902
|
+
// Store matched types for array fields
|
|
1903
|
+
if (matchedTypes.length > 0) {
|
|
1904
|
+
resolved[`${fieldName}$matchedTypes`] = matchedTypes;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
else {
|
|
1908
|
+
// Single fuzzy field
|
|
1909
|
+
let matched = false;
|
|
1910
|
+
// Try semantic search across all union types
|
|
1911
|
+
const match = await searchUnionTypes(typesToSearch, searchQuery, threshold);
|
|
1912
|
+
if (match) {
|
|
1913
|
+
resolved[fieldName] = match.id;
|
|
1914
|
+
resolved[`${fieldName}$matched`] = true;
|
|
1915
|
+
resolved[`${fieldName}$score`] = match.score;
|
|
1916
|
+
resolved[`${fieldName}$matchedType`] = match.type;
|
|
1917
|
+
pendingRelations.push({
|
|
1918
|
+
fieldName,
|
|
1919
|
+
targetType: match.type,
|
|
1920
|
+
targetId: match.id,
|
|
1921
|
+
similarity: match.score,
|
|
1922
|
+
matchedType: match.type,
|
|
1923
|
+
});
|
|
1924
|
+
matched = true;
|
|
1925
|
+
}
|
|
1926
|
+
// Generate if no match found - use first type in union
|
|
1927
|
+
if (!matched) {
|
|
1928
|
+
const generateType = typesToSearch[0];
|
|
1929
|
+
const generated = await generateEntity(generateType, searchQuery, // Use searchQuery which prioritizes hint over field.prompt
|
|
1930
|
+
{ parent: typeName, parentData: data, parentId }, schema);
|
|
1931
|
+
// Resolve any pending nested relations
|
|
1932
|
+
const relatedEntity = schema.entities.get(generateType);
|
|
1933
|
+
if (relatedEntity) {
|
|
1934
|
+
const resolvedGenerated = await resolveNestedPending(generated, relatedEntity, schema, provider);
|
|
1935
|
+
const created = await provider.create(generateType, undefined, {
|
|
1936
|
+
...resolvedGenerated,
|
|
1937
|
+
$generated: true,
|
|
1938
|
+
$generatedBy: parentId,
|
|
1939
|
+
$sourceField: fieldName,
|
|
1940
|
+
});
|
|
1941
|
+
resolved[fieldName] = created['$id'];
|
|
1942
|
+
resolved[`${fieldName}$matchedType`] = generateType;
|
|
1943
|
+
pendingRelations.push({
|
|
1944
|
+
fieldName,
|
|
1945
|
+
targetType: generateType,
|
|
1946
|
+
targetId: created['$id'],
|
|
1947
|
+
matchedType: generateType,
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
return { data: resolved, pendingRelations };
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Resolve pending nested relations in generated data
|
|
1958
|
+
*
|
|
1959
|
+
* When generateEntity encounters nested -> relations, it stores them as
|
|
1960
|
+
* _pending_fieldName entries. This function creates those entities and
|
|
1961
|
+
* replaces the pending entries with actual IDs.
|
|
1962
|
+
*/
|
|
1963
|
+
async function resolveNestedPending(data, entity, schema, provider) {
|
|
1964
|
+
const resolved = { ...data };
|
|
1965
|
+
for (const key of Object.keys(resolved)) {
|
|
1966
|
+
if (key.startsWith('_pending_')) {
|
|
1967
|
+
const fieldName = key.replace('_pending_', '');
|
|
1968
|
+
const pending = resolved[key];
|
|
1969
|
+
delete resolved[key];
|
|
1970
|
+
// Get the field definition to check if it's an array
|
|
1971
|
+
const field = entity.fields.get(fieldName);
|
|
1972
|
+
// Get the related entity to resolve its nested pending relations too
|
|
1973
|
+
const relatedEntity = schema.entities.get(pending.type);
|
|
1974
|
+
if (relatedEntity) {
|
|
1975
|
+
const resolvedNested = await resolveNestedPending(pending.data, relatedEntity, schema, provider);
|
|
1976
|
+
const created = await provider.create(pending.type, undefined, resolvedNested);
|
|
1977
|
+
// Set as array or single value based on field definition
|
|
1978
|
+
resolved[fieldName] = field?.isArray ? [created['$id']] : created['$id'];
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
return resolved;
|
|
1983
|
+
}
|
|
68
1984
|
// =============================================================================
|
|
69
|
-
//
|
|
1985
|
+
// Two-Phase Draft/Resolve Helper Functions
|
|
70
1986
|
// =============================================================================
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
1987
|
+
/**
|
|
1988
|
+
* Resolve a single reference specification to an entity ID
|
|
1989
|
+
*
|
|
1990
|
+
* For exact matches (-> and <-), creates new entities.
|
|
1991
|
+
* For fuzzy matches (~> and <~), searches for existing entities first.
|
|
1992
|
+
*/
|
|
1993
|
+
async function resolveReferenceSpec(spec, contextData, schema, provider) {
|
|
1994
|
+
const targetEntity = schema.entities.get(spec.type);
|
|
1995
|
+
if (!targetEntity) {
|
|
1996
|
+
throw new Error(`Unknown target type: ${spec.type}`);
|
|
1997
|
+
}
|
|
1998
|
+
if (spec.matchMode === 'fuzzy') {
|
|
1999
|
+
// For fuzzy references, try to find an existing entity first
|
|
2000
|
+
if (hasSemanticSearch(provider)) {
|
|
2001
|
+
const searchQuery = spec.generatedText || spec.prompt || spec.field;
|
|
2002
|
+
const threshold = spec.threshold ?? 0.75;
|
|
2003
|
+
// Search across all union types (or just the single type)
|
|
2004
|
+
const typesToSearch = spec.unionTypes || [spec.type];
|
|
2005
|
+
let bestMatch = null;
|
|
2006
|
+
for (const searchType of typesToSearch) {
|
|
2007
|
+
const matches = await provider.semanticSearch(searchType, searchQuery, {
|
|
2008
|
+
minScore: threshold,
|
|
2009
|
+
limit: 1,
|
|
2010
|
+
});
|
|
2011
|
+
if (matches.length > 0 && matches[0]) {
|
|
2012
|
+
if (!bestMatch || matches[0].$score > bestMatch.$score) {
|
|
2013
|
+
bestMatch = {
|
|
2014
|
+
$id: matches[0].$id,
|
|
2015
|
+
$score: matches[0].$score,
|
|
2016
|
+
$matchedType: searchType,
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
if (bestMatch) {
|
|
2022
|
+
// Set metadata on contextData so it's available in the resolved entity
|
|
2023
|
+
contextData[`${spec.field}$matched`] = true;
|
|
2024
|
+
contextData[`${spec.field}$score`] = bestMatch.$score;
|
|
2025
|
+
contextData[`${spec.field}$matchedType`] = bestMatch.$matchedType;
|
|
2026
|
+
return bestMatch.$id;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
// If no match found for fuzzy, fall through to create
|
|
2030
|
+
}
|
|
2031
|
+
// Create a new entity
|
|
2032
|
+
const generatedData = {};
|
|
2033
|
+
// Build context for generation from contextData
|
|
2034
|
+
const genCtxParts = [];
|
|
2035
|
+
// Include $instructions from source entity schema (injected during resolve)
|
|
2036
|
+
if (typeof contextData['$instructions'] === 'string') {
|
|
2037
|
+
genCtxParts.push(contextData['$instructions']);
|
|
2038
|
+
}
|
|
2039
|
+
// Include target entity's $instructions
|
|
2040
|
+
const tgtInstructions = targetEntity.schema?.['$instructions'];
|
|
2041
|
+
if (typeof tgtInstructions === 'string') {
|
|
2042
|
+
genCtxParts.push(tgtInstructions);
|
|
2043
|
+
}
|
|
2044
|
+
// Include spec prompt
|
|
2045
|
+
if (spec.prompt) {
|
|
2046
|
+
genCtxParts.push(spec.prompt);
|
|
2047
|
+
}
|
|
2048
|
+
for (const [key, value] of Object.entries(contextData)) {
|
|
2049
|
+
if (!key.startsWith('$') && !key.startsWith('_') && typeof value === 'string' && value) {
|
|
2050
|
+
genCtxParts.push(`${key}: ${value}`);
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
const genCtx = genCtxParts.filter(Boolean).join(' | ');
|
|
2054
|
+
// Generate default values for the target entity's fields
|
|
2055
|
+
for (const [fieldName, field] of targetEntity.fields) {
|
|
2056
|
+
if (!field.isRelation) {
|
|
2057
|
+
const isPrompt = isPromptField(field);
|
|
2058
|
+
// Generate both required fields and optional prompt fields
|
|
2059
|
+
if (!field.isOptional || isPrompt) {
|
|
2060
|
+
if (field.type === 'string' || isPrompt) {
|
|
2061
|
+
const fldHint = isPrompt ? field.type : undefined;
|
|
2062
|
+
generatedData[fieldName] = generateContextAwareValue(fieldName, spec.type, genCtx, fldHint);
|
|
2063
|
+
}
|
|
2064
|
+
else if (field.type === 'number') {
|
|
2065
|
+
generatedData[fieldName] = 0;
|
|
2066
|
+
}
|
|
2067
|
+
else if (field.type === 'boolean') {
|
|
2068
|
+
generatedData[fieldName] = false;
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
else if (field.isRelation && field.operator === '->' && !field.isArray && !field.isOptional) {
|
|
2073
|
+
// Recursively resolve nested forward exact relations
|
|
2074
|
+
const nestedSpec = {
|
|
2075
|
+
field: fieldName,
|
|
2076
|
+
operator: '->',
|
|
2077
|
+
type: field.relatedType,
|
|
2078
|
+
matchMode: 'exact',
|
|
2079
|
+
resolved: false,
|
|
2080
|
+
...(field.prompt !== undefined && { prompt: field.prompt }),
|
|
2081
|
+
};
|
|
2082
|
+
const nestedId = await resolveReferenceSpec(nestedSpec, generatedData, schema, provider);
|
|
2083
|
+
if (nestedId) {
|
|
2084
|
+
generatedData[fieldName] = nestedId;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
const created = await provider.create(spec.type, undefined, {
|
|
2089
|
+
...generatedData,
|
|
2090
|
+
$generated: true,
|
|
2091
|
+
$generatedBy: contextData['$id'] ||
|
|
2092
|
+
(spec.matchMode === 'fuzzy' ? 'fuzzy-resolution' : 'reference-resolution'),
|
|
2093
|
+
$sourceField: spec.field,
|
|
2094
|
+
});
|
|
2095
|
+
return created['$id'];
|
|
2096
|
+
}
|
|
2097
|
+
/**
|
|
2098
|
+
* Create operations for a single entity type
|
|
2099
|
+
*/
|
|
2100
|
+
function createEntityOperations(typeName, entity, schema) {
|
|
2101
|
+
return {
|
|
2102
|
+
async get(id) {
|
|
2103
|
+
const provider = await resolveProvider();
|
|
2104
|
+
const result = await provider.get(typeName, id);
|
|
2105
|
+
if (!result)
|
|
2106
|
+
return null;
|
|
2107
|
+
return hydrateEntity(result, entity, schema);
|
|
2108
|
+
},
|
|
2109
|
+
async list(options) {
|
|
2110
|
+
try {
|
|
2111
|
+
const provider = await resolveProvider();
|
|
2112
|
+
const results = await provider.list(typeName, options);
|
|
2113
|
+
return Promise.all(results.map((r) => hydrateEntity(r, entity, schema)));
|
|
2114
|
+
}
|
|
2115
|
+
catch (error) {
|
|
2116
|
+
// Handle error with callback if provided
|
|
2117
|
+
if (options?.onError) {
|
|
2118
|
+
const wrappedError = error instanceof Error ? error : new Error(String(error));
|
|
2119
|
+
const fallback = options.onError(wrappedError);
|
|
2120
|
+
return (fallback ?? []);
|
|
2121
|
+
}
|
|
2122
|
+
// Suppress errors if requested
|
|
2123
|
+
if (options?.suppressErrors) {
|
|
2124
|
+
return [];
|
|
2125
|
+
}
|
|
2126
|
+
throw error;
|
|
2127
|
+
}
|
|
2128
|
+
},
|
|
2129
|
+
async find(where) {
|
|
2130
|
+
const provider = await resolveProvider();
|
|
2131
|
+
// Partial<T> is a subset of Record<string, unknown> - safe cast for provider API
|
|
2132
|
+
const results = await provider.list(typeName, {
|
|
2133
|
+
where: where,
|
|
2134
|
+
});
|
|
2135
|
+
// hydrateEntity returns Record<string, unknown>, cast to T for type-safe API
|
|
2136
|
+
return Promise.all(results.map((r) => hydrateEntity(r, entity, schema)));
|
|
2137
|
+
},
|
|
2138
|
+
async search(query, options) {
|
|
2139
|
+
const provider = await resolveProvider();
|
|
2140
|
+
const results = await provider.search(typeName, query, options);
|
|
2141
|
+
return Promise.all(results.map((r) => hydrateEntity(r, entity, schema)));
|
|
2142
|
+
},
|
|
2143
|
+
async create(idOrData, maybeDataOrOptions, maybeOptions) {
|
|
2144
|
+
const provider = await resolveProvider();
|
|
2145
|
+
// Parse arguments: support both (data, options) and (id, data, options) signatures
|
|
2146
|
+
// Type assertions are necessary here because T extends Record<string, unknown>
|
|
2147
|
+
// but TypeScript can't infer this from the Omit<T, ...> type
|
|
2148
|
+
let providedId;
|
|
2149
|
+
let data;
|
|
2150
|
+
let options;
|
|
2151
|
+
if (typeof idOrData === 'string') {
|
|
2152
|
+
// First arg is ID - maybeDataOrOptions must be the data object
|
|
2153
|
+
providedId = idOrData;
|
|
2154
|
+
// Safe cast: maybeDataOrOptions is Omit<T, '$id' | '$type'> which extends Record
|
|
2155
|
+
data = (maybeDataOrOptions ?? {});
|
|
2156
|
+
options = maybeOptions;
|
|
2157
|
+
}
|
|
2158
|
+
else {
|
|
2159
|
+
// First arg is data - safe cast since idOrData is Omit<T, '$id' | '$type'>
|
|
2160
|
+
providedId = undefined;
|
|
2161
|
+
data = idOrData;
|
|
2162
|
+
// Check if second arg is options by detecting option-specific properties
|
|
2163
|
+
if (maybeDataOrOptions &&
|
|
2164
|
+
typeof maybeDataOrOptions === 'object' &&
|
|
2165
|
+
('cascade' in maybeDataOrOptions ||
|
|
2166
|
+
'maxDepth' in maybeDataOrOptions ||
|
|
2167
|
+
'onProgress' in maybeDataOrOptions ||
|
|
2168
|
+
'onError' in maybeDataOrOptions ||
|
|
2169
|
+
'draftOnly' in maybeDataOrOptions ||
|
|
2170
|
+
'cascadeTypes' in maybeDataOrOptions ||
|
|
2171
|
+
'stopOnError' in maybeDataOrOptions)) {
|
|
2172
|
+
options = maybeDataOrOptions;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
// Pre-generate entity ID so child entities can reference us
|
|
2176
|
+
const entityId = providedId || crypto.randomUUID();
|
|
2177
|
+
// Resolve forward exact (->) fields by auto-generating related entities
|
|
2178
|
+
// Pass the entityId so generated children can set backward references
|
|
2179
|
+
// When cascade is enabled, skip auto-generation for array fields - cascadeGenerate will handle them
|
|
2180
|
+
const { data: resolvedData, pendingRelations } = await resolveForwardExact(typeName, data, entity, schema, provider, entityId, { skipArrayGeneration: true });
|
|
2181
|
+
// Resolve forward fuzzy (~>) fields by semantic search then generation
|
|
2182
|
+
const { data: fuzzyResolvedData, pendingRelations: fuzzyPendingRelations } = await resolveForwardFuzzy(typeName, resolvedData, entity, schema, provider, entityId);
|
|
2183
|
+
// Resolve backward fuzzy (<~) fields by semantic search against existing entities
|
|
2184
|
+
const backwardResolvedData = await resolveBackwardFuzzy(typeName, fuzzyResolvedData, entity, schema, provider);
|
|
2185
|
+
// Generate AI fields for entities with $context dependencies
|
|
2186
|
+
// This handles prompt fields like 'string (compelling title)' that need context
|
|
2187
|
+
const finalData = await generateAIFields(backwardResolvedData, typeName, entity, schema, provider);
|
|
2188
|
+
let result;
|
|
2189
|
+
try {
|
|
2190
|
+
result = await provider.create(typeName, entityId, finalData);
|
|
2191
|
+
}
|
|
2192
|
+
catch (error) {
|
|
2193
|
+
if (error instanceof DatabaseError)
|
|
2194
|
+
throw error;
|
|
2195
|
+
throw wrapDatabaseError(error, 'create', typeName, entityId);
|
|
2196
|
+
}
|
|
2197
|
+
// Create relationships for array fields (exact)
|
|
2198
|
+
for (const rel of pendingRelations) {
|
|
2199
|
+
await provider.relate(typeName, entityId, rel.fieldName, rel.targetType, rel.targetId);
|
|
2200
|
+
}
|
|
2201
|
+
// Create relationships for fuzzy fields with metadata
|
|
2202
|
+
// Track created Edge IDs to avoid duplicates
|
|
2203
|
+
const createdEdgeIds = new Set();
|
|
2204
|
+
for (const rel of fuzzyPendingRelations) {
|
|
2205
|
+
await provider.relate(typeName, entityId, rel.fieldName, rel.targetType, rel.targetId, {
|
|
2206
|
+
matchMode: 'fuzzy',
|
|
2207
|
+
...(rel.similarity !== undefined && { similarity: rel.similarity }),
|
|
2208
|
+
});
|
|
2209
|
+
// Also create an Edge entity to store the fuzzy match metadata
|
|
2210
|
+
// Use a unique ID based on the relationship
|
|
2211
|
+
const edgeId = `${typeName}-${rel.fieldName}-${entityId}-${rel.targetId}`;
|
|
2212
|
+
if (!createdEdgeIds.has(edgeId)) {
|
|
2213
|
+
createdEdgeIds.add(edgeId);
|
|
2214
|
+
try {
|
|
2215
|
+
await provider.create('Edge', edgeId, {
|
|
2216
|
+
from: typeName,
|
|
2217
|
+
name: rel.fieldName,
|
|
2218
|
+
to: rel.targetType,
|
|
2219
|
+
direction: 'forward',
|
|
2220
|
+
matchMode: 'fuzzy',
|
|
2221
|
+
similarity: rel.similarity,
|
|
2222
|
+
matchedType: rel.matchedType,
|
|
2223
|
+
fromId: entityId,
|
|
2224
|
+
toId: rel.targetId,
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
catch (error) {
|
|
2228
|
+
// Only ignore actual duplicate key errors, propagate other errors
|
|
2229
|
+
if (!isEntityExistsError(error)) {
|
|
2230
|
+
throw wrapDatabaseError(error, 'create', 'Edge', edgeId);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
// If cascade is enabled, recursively generate related entities
|
|
2236
|
+
if (options?.cascade) {
|
|
2237
|
+
const progress = {
|
|
2238
|
+
phase: 'generating',
|
|
2239
|
+
currentDepth: 0,
|
|
2240
|
+
depth: 0,
|
|
2241
|
+
currentType: typeName,
|
|
2242
|
+
totalEntitiesCreated: 1, // Count the root entity
|
|
2243
|
+
typesGenerated: [typeName],
|
|
2244
|
+
};
|
|
2245
|
+
// Report initial progress
|
|
2246
|
+
options.onProgress?.({ ...progress });
|
|
2247
|
+
try {
|
|
2248
|
+
await cascadeGenerate(result, entity, schema, provider, options, 0, progress);
|
|
2249
|
+
// Report completion
|
|
2250
|
+
progress.phase = 'complete';
|
|
2251
|
+
options.onProgress?.({ ...progress });
|
|
2252
|
+
}
|
|
2253
|
+
catch (error) {
|
|
2254
|
+
progress.phase = 'error';
|
|
2255
|
+
options.onProgress?.({ ...progress });
|
|
2256
|
+
if (options.stopOnError)
|
|
2257
|
+
throw error;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
return hydrateEntity(result, entity, schema);
|
|
2261
|
+
},
|
|
2262
|
+
async update(id, data) {
|
|
2263
|
+
try {
|
|
2264
|
+
const provider = await resolveProvider();
|
|
2265
|
+
// Partial<Omit<T, ...>> is a subset of Record<string, unknown> - safe cast for provider API
|
|
2266
|
+
const result = await provider.update(typeName, id, data);
|
|
2267
|
+
return hydrateEntity(result, entity, schema);
|
|
2268
|
+
}
|
|
2269
|
+
catch (error) {
|
|
2270
|
+
if (error instanceof DatabaseError)
|
|
2271
|
+
throw error;
|
|
2272
|
+
throw wrapDatabaseError(error, 'update', typeName, id);
|
|
2273
|
+
}
|
|
2274
|
+
},
|
|
2275
|
+
async upsert(id, data) {
|
|
2276
|
+
try {
|
|
2277
|
+
const provider = await resolveProvider();
|
|
2278
|
+
const existing = await provider.get(typeName, id);
|
|
2279
|
+
// Omit<T, ...> is a subset of Record<string, unknown> - safe cast for provider API
|
|
2280
|
+
if (existing) {
|
|
2281
|
+
const result = await provider.update(typeName, id, data);
|
|
2282
|
+
return hydrateEntity(result, entity, schema);
|
|
2283
|
+
}
|
|
2284
|
+
const result = await provider.create(typeName, id, data);
|
|
2285
|
+
return hydrateEntity(result, entity, schema);
|
|
2286
|
+
}
|
|
2287
|
+
catch (error) {
|
|
2288
|
+
if (error instanceof DatabaseError)
|
|
2289
|
+
throw error;
|
|
2290
|
+
throw wrapDatabaseError(error, 'upsert', typeName, id);
|
|
2291
|
+
}
|
|
2292
|
+
},
|
|
2293
|
+
async delete(id) {
|
|
2294
|
+
try {
|
|
2295
|
+
const provider = await resolveProvider();
|
|
2296
|
+
const result = await provider.delete(typeName, id);
|
|
2297
|
+
return result;
|
|
2298
|
+
}
|
|
2299
|
+
catch (error) {
|
|
2300
|
+
if (error instanceof DatabaseError)
|
|
2301
|
+
throw error;
|
|
2302
|
+
throw wrapDatabaseError(error, 'delete', typeName, id);
|
|
2303
|
+
}
|
|
2304
|
+
},
|
|
2305
|
+
async forEach(optionsOrCallback, maybeCallback) {
|
|
2306
|
+
const options = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback;
|
|
2307
|
+
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback;
|
|
2308
|
+
const items = await this.list(options);
|
|
2309
|
+
for (const item of items) {
|
|
2310
|
+
await callback(item);
|
|
2311
|
+
}
|
|
2312
|
+
},
|
|
2313
|
+
async semanticSearch(query, options) {
|
|
2314
|
+
const provider = await resolveProvider();
|
|
2315
|
+
if (!hasSemanticSearch(provider)) {
|
|
2316
|
+
throw new CapabilityNotSupportedError('hasSemanticSearch', `Semantic search is not supported by the current provider. ` +
|
|
2317
|
+
`The provider does not implement the semanticSearch method required for vector similarity search.`, `Use the regular search() method instead, which performs basic text matching.`);
|
|
2318
|
+
}
|
|
2319
|
+
const results = await provider.semanticSearch(typeName, query, options);
|
|
2320
|
+
return Promise.all(results.map((r) => ({
|
|
2321
|
+
...hydrateEntity(r, entity, schema),
|
|
2322
|
+
$score: r.$score,
|
|
2323
|
+
})));
|
|
2324
|
+
},
|
|
2325
|
+
async hybridSearch(query, options) {
|
|
2326
|
+
const provider = await resolveProvider();
|
|
2327
|
+
if (!hasHybridSearch(provider)) {
|
|
2328
|
+
throw new CapabilityNotSupportedError('hasHybridSearch', `Hybrid search is not supported by the current provider. ` +
|
|
2329
|
+
`The provider does not implement the hybridSearch method required for combined FTS and vector search.`, `Use the regular search() method instead, which performs basic text matching.`);
|
|
2330
|
+
}
|
|
2331
|
+
const results = await provider.hybridSearch(typeName, query, options);
|
|
2332
|
+
return Promise.all(results.map((r) => ({
|
|
2333
|
+
...hydrateEntity(r, entity, schema),
|
|
2334
|
+
$rrfScore: r.$rrfScore,
|
|
2335
|
+
$ftsRank: r.$ftsRank,
|
|
2336
|
+
$semanticRank: r.$semanticRank,
|
|
2337
|
+
$score: r.$score,
|
|
2338
|
+
})));
|
|
2339
|
+
},
|
|
2340
|
+
async draft(data, options) {
|
|
2341
|
+
const draftData = { ...data, $phase: 'draft' };
|
|
2342
|
+
const refs = {};
|
|
2343
|
+
// Process each field that has a relationship operator
|
|
2344
|
+
for (const [fieldName, field] of entity.fields) {
|
|
2345
|
+
// Skip if value already provided
|
|
2346
|
+
if (draftData[fieldName] !== undefined && draftData[fieldName] !== null) {
|
|
2347
|
+
continue;
|
|
2348
|
+
}
|
|
2349
|
+
// Handle non-relation prompt fields (like 'Write a detailed article')
|
|
2350
|
+
if (!field.isRelation) {
|
|
2351
|
+
const isPrompt = isPromptField(field);
|
|
2352
|
+
if (isPrompt) {
|
|
2353
|
+
// Build context for generation
|
|
2354
|
+
const ctxParts = [];
|
|
2355
|
+
const entitySchemaForCtx = entity.schema;
|
|
2356
|
+
if (entitySchemaForCtx?.['$instructions']) {
|
|
2357
|
+
ctxParts.push(entitySchemaForCtx['$instructions']);
|
|
2358
|
+
}
|
|
2359
|
+
for (const [k, v] of Object.entries(data)) {
|
|
2360
|
+
if (!k.startsWith('$') && !k.startsWith('_') && typeof v === 'string' && v) {
|
|
2361
|
+
ctxParts.push(`${k}: ${v}`);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
const draftCtx = ctxParts.filter(Boolean).join(' | ');
|
|
2365
|
+
const generatedText = generateContextAwareValue(fieldName, typeName, draftCtx, field.type);
|
|
2366
|
+
draftData[fieldName] = generatedText;
|
|
2367
|
+
if (options?.stream && options.onChunk) {
|
|
2368
|
+
options.onChunk(generatedText);
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
continue;
|
|
2372
|
+
}
|
|
2373
|
+
// Only process fields with relationship operators
|
|
2374
|
+
if (field.operator && field.relatedType) {
|
|
2375
|
+
// Skip optional relation fields - they shouldn't auto-generate
|
|
2376
|
+
if (field.isOptional)
|
|
2377
|
+
continue;
|
|
2378
|
+
// Skip backward references - they are resolved lazily via hydrateEntity
|
|
2379
|
+
if (field.operator === '<-' || field.operator === '<~')
|
|
2380
|
+
continue;
|
|
2381
|
+
const matchMode = field.matchMode ?? (field.operator.includes('~') ? 'fuzzy' : 'exact');
|
|
2382
|
+
// data is Partial<Omit<T, ...>> which extends Record<string, unknown>
|
|
2383
|
+
// The cast is safe because we're accessing arbitrary properties
|
|
2384
|
+
const dataRecord = data;
|
|
2385
|
+
// Get fuzzy threshold: field-level overrides entity-level
|
|
2386
|
+
const entitySchemaRaw = entity.schema;
|
|
2387
|
+
const entityThreshold = entitySchemaRaw && '$fuzzyThreshold' in entitySchemaRaw
|
|
2388
|
+
? entitySchemaRaw['$fuzzyThreshold']
|
|
2389
|
+
: undefined;
|
|
2390
|
+
const threshold = field.threshold ?? entityThreshold;
|
|
2391
|
+
if (field.isArray) {
|
|
2392
|
+
// Array relationship - check for hint values
|
|
2393
|
+
const hintKey = `${fieldName}Hint`;
|
|
2394
|
+
const hintValue = dataRecord[hintKey];
|
|
2395
|
+
const hints = Array.isArray(hintValue)
|
|
2396
|
+
? hintValue
|
|
2397
|
+
: hintValue
|
|
2398
|
+
? [hintValue]
|
|
2399
|
+
: [
|
|
2400
|
+
generateNaturalLanguageContent(fieldName, field.prompt, field.relatedType, dataRecord),
|
|
2401
|
+
];
|
|
2402
|
+
const refSpecs = hints.map((hint) => {
|
|
2403
|
+
const generatedText = String(hint);
|
|
2404
|
+
return {
|
|
2405
|
+
field: fieldName,
|
|
2406
|
+
operator: field.operator,
|
|
2407
|
+
type: field.relatedType,
|
|
2408
|
+
matchMode,
|
|
2409
|
+
resolved: false,
|
|
2410
|
+
...(field.unionTypes !== undefined && { unionTypes: field.unionTypes }),
|
|
2411
|
+
...(field.prompt !== undefined && { prompt: field.prompt }),
|
|
2412
|
+
...(generatedText !== undefined && { generatedText }),
|
|
2413
|
+
...(threshold !== undefined && { threshold }),
|
|
2414
|
+
};
|
|
2415
|
+
});
|
|
2416
|
+
draftData[fieldName] = hints.map(String).join(', ');
|
|
2417
|
+
refs[fieldName] = refSpecs;
|
|
2418
|
+
if (options?.stream && options.onChunk) {
|
|
2419
|
+
for (const spec of refSpecs) {
|
|
2420
|
+
if (spec.generatedText) {
|
|
2421
|
+
options.onChunk(spec.generatedText);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
else {
|
|
2427
|
+
// Single relationship - check for hint values
|
|
2428
|
+
const hintKey = `${fieldName}Hint`;
|
|
2429
|
+
const hintValue = dataRecord[hintKey];
|
|
2430
|
+
const generatedText = hintValue ||
|
|
2431
|
+
generateNaturalLanguageContent(fieldName, field.prompt, field.relatedType, dataRecord);
|
|
2432
|
+
draftData[fieldName] = generatedText;
|
|
2433
|
+
refs[fieldName] = {
|
|
2434
|
+
field: fieldName,
|
|
2435
|
+
operator: field.operator,
|
|
2436
|
+
type: field.relatedType,
|
|
2437
|
+
matchMode,
|
|
2438
|
+
resolved: false,
|
|
2439
|
+
...(field.unionTypes !== undefined && { unionTypes: field.unionTypes }),
|
|
2440
|
+
...(field.prompt !== undefined && { prompt: field.prompt }),
|
|
2441
|
+
...(generatedText !== undefined && { generatedText }),
|
|
2442
|
+
...(threshold !== undefined && { threshold }),
|
|
2443
|
+
};
|
|
2444
|
+
if (options?.stream && options.onChunk) {
|
|
2445
|
+
options.onChunk(generatedText);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
draftData['$refs'] = refs;
|
|
2451
|
+
// draftData has all Draft<T> properties - safe to return as Draft<T>
|
|
2452
|
+
return draftData;
|
|
2453
|
+
},
|
|
2454
|
+
async resolve(draft, options) {
|
|
2455
|
+
// Validate that this is actually a draft - use type guard for safe access
|
|
2456
|
+
if (!isDraft(draft)) {
|
|
2457
|
+
throw new Error('Cannot resolve entity: not a draft (missing $phase: "draft")');
|
|
2458
|
+
}
|
|
2459
|
+
const provider = await resolveProvider();
|
|
2460
|
+
const resolved = { ...draft };
|
|
2461
|
+
const errors = [];
|
|
2462
|
+
// Inject $instructions from entity schema into resolved context for reference resolution
|
|
2463
|
+
const entityInstructions = entity.schema?.['$instructions'];
|
|
2464
|
+
if (entityInstructions && typeof entityInstructions === 'string') {
|
|
2465
|
+
resolved['$instructions'] = entityInstructions;
|
|
2466
|
+
}
|
|
2467
|
+
// Remove draft markers
|
|
2468
|
+
delete resolved['$refs'];
|
|
2469
|
+
resolved['$phase'] = 'resolved';
|
|
2470
|
+
// Extract refs using type guard - safe access since isDraft validated
|
|
2471
|
+
const refs = (extractRefs(draft) || {});
|
|
2472
|
+
// Resolve each reference
|
|
2473
|
+
for (const [fieldName, refSpec] of Object.entries(refs)) {
|
|
2474
|
+
try {
|
|
2475
|
+
if (Array.isArray(refSpec)) {
|
|
2476
|
+
// Array of references
|
|
2477
|
+
const resolvedIds = [];
|
|
2478
|
+
for (const spec of refSpec) {
|
|
2479
|
+
const resolvedId = await resolveReferenceSpec(spec, resolved, schema, provider);
|
|
2480
|
+
if (resolvedId) {
|
|
2481
|
+
resolvedIds.push(resolvedId);
|
|
2482
|
+
options?.onResolved?.(fieldName, resolvedId);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
resolved[fieldName] = resolvedIds;
|
|
2486
|
+
}
|
|
2487
|
+
else {
|
|
2488
|
+
// Single reference
|
|
2489
|
+
const resolvedId = await resolveReferenceSpec(refSpec, resolved, schema, provider);
|
|
2490
|
+
if (resolvedId) {
|
|
2491
|
+
resolved[fieldName] = resolvedId;
|
|
2492
|
+
// Mark as auto-generated so hydrateEntity creates a thenable proxy
|
|
2493
|
+
resolved[`${fieldName}$autoGenerated`] = true;
|
|
2494
|
+
options?.onResolved?.(fieldName, resolvedId);
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
catch (err) {
|
|
2499
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
2500
|
+
if (options?.onError === 'skip') {
|
|
2501
|
+
errors.push({ field: fieldName, error: errorMsg });
|
|
2502
|
+
}
|
|
2503
|
+
else {
|
|
2504
|
+
throw err;
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
// Add $errors if onError mode is 'skip' (even if empty, to indicate skip mode was used)
|
|
2509
|
+
// or if there are actual errors
|
|
2510
|
+
if (errors.length > 0 || options?.onError === 'skip') {
|
|
2511
|
+
resolved['$errors'] = errors;
|
|
2512
|
+
}
|
|
2513
|
+
return resolved;
|
|
2514
|
+
},
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
/**
|
|
2518
|
+
* Hydrate an entity with lazy-loaded relations
|
|
2519
|
+
*
|
|
2520
|
+
* For backward edges (direction === 'backward'), we query for entities
|
|
2521
|
+
* of the related type that have a reference pointing TO this entity.
|
|
2522
|
+
* This enables reverse lookups like "get all comments for a post".
|
|
2523
|
+
*
|
|
2524
|
+
* Backward reference resolution:
|
|
2525
|
+
* - Single backward ref with stored ID: resolve directly (e.g., member.team = teamId -> get Team by ID)
|
|
2526
|
+
* - Single backward ref without stored ID: find related entity that points to us via relations
|
|
2527
|
+
* - Array backward ref: find all entities of related type where their forward ref points to us
|
|
2528
|
+
*/
|
|
2529
|
+
function hydrateEntity(data, entity, schema) {
|
|
2530
|
+
const hydrated = { ...data };
|
|
2531
|
+
const id = (data['$id'] || data['id']);
|
|
2532
|
+
const typeName = entity.name;
|
|
2533
|
+
// Add lazy getters for relations
|
|
2534
|
+
for (const [fieldName, field] of entity.fields) {
|
|
2535
|
+
if (field.isRelation && field.relatedType) {
|
|
2536
|
+
const relatedEntity = schema.entities.get(field.relatedType);
|
|
2537
|
+
if (!relatedEntity)
|
|
2538
|
+
continue;
|
|
2539
|
+
// Check if this is a backward edge
|
|
2540
|
+
const isBackward = field.direction === 'backward';
|
|
2541
|
+
// For backward single relations with stored IDs (user-provided backward refs),
|
|
2542
|
+
// create a thenable proxy so `await entity.parent` resolves to the related entity.
|
|
2543
|
+
if (isBackward && !field.isArray && data[fieldName] && typeof data[fieldName] === 'string') {
|
|
2544
|
+
const storedId = data[fieldName];
|
|
2545
|
+
const proxyTarget = {};
|
|
2546
|
+
const thenableProxy = new Proxy(proxyTarget, {
|
|
2547
|
+
get(target, prop) {
|
|
2548
|
+
if (prop === 'then') {
|
|
2549
|
+
return (resolve, reject) => {
|
|
2550
|
+
return (async () => {
|
|
2551
|
+
const provider = await resolveProvider();
|
|
2552
|
+
const result = await provider.get(field.relatedType, storedId);
|
|
2553
|
+
if (!result)
|
|
2554
|
+
return null;
|
|
2555
|
+
return hydrateEntity(result, relatedEntity, schema);
|
|
2556
|
+
})().then(resolve, reject);
|
|
2557
|
+
};
|
|
2558
|
+
}
|
|
2559
|
+
if (prop === Symbol.toPrimitive || prop === 'valueOf') {
|
|
2560
|
+
return () => storedId;
|
|
2561
|
+
}
|
|
2562
|
+
if (prop === 'toString') {
|
|
2563
|
+
return () => storedId;
|
|
2564
|
+
}
|
|
2565
|
+
if (prop === 'match') {
|
|
2566
|
+
return (regex) => storedId.match(regex);
|
|
2567
|
+
}
|
|
2568
|
+
if (prop === '$id') {
|
|
2569
|
+
return storedId;
|
|
2570
|
+
}
|
|
2571
|
+
return undefined;
|
|
2572
|
+
},
|
|
2573
|
+
});
|
|
2574
|
+
hydrated[fieldName] = thenableProxy;
|
|
2575
|
+
continue;
|
|
2576
|
+
}
|
|
2577
|
+
// For forward single relations with stored IDs, create a thenable proxy that:
|
|
2578
|
+
// - Acts like a string (for .toMatch(), String(), etc.)
|
|
2579
|
+
// - Can be awaited to get the related entity (thenable)
|
|
2580
|
+
if (!isBackward && !field.isArray && data[fieldName]) {
|
|
2581
|
+
const storedId = data[fieldName];
|
|
2582
|
+
// For union types, get the actual matched type from stored metadata
|
|
2583
|
+
const matchedType = data[`${fieldName}$matchedType`] || field.relatedType;
|
|
2584
|
+
const actualRelatedEntity = schema.entities.get(matchedType) || relatedEntity;
|
|
2585
|
+
// Create a thenable proxy - the empty object target is just a placeholder for the Proxy
|
|
2586
|
+
const proxyTarget = {};
|
|
2587
|
+
const thenableProxy = new Proxy(proxyTarget, {
|
|
2588
|
+
get(target, prop) {
|
|
2589
|
+
if (prop === 'then') {
|
|
2590
|
+
return (resolve, reject) => {
|
|
2591
|
+
return (async () => {
|
|
2592
|
+
const provider = await resolveProvider();
|
|
2593
|
+
const result = await loadEntity(provider, matchedType, storedId);
|
|
2594
|
+
if (!result)
|
|
2595
|
+
return null;
|
|
2596
|
+
const hydratedResult = hydrateEntity(result, actualRelatedEntity, schema);
|
|
2597
|
+
// Add $matchedType to the result for union type tracking
|
|
2598
|
+
if (field.unionTypes && field.unionTypes.length > 0) {
|
|
2599
|
+
hydratedResult.$matchedType = matchedType;
|
|
2600
|
+
}
|
|
2601
|
+
return hydratedResult;
|
|
2602
|
+
})().then(resolve, reject);
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
if (prop === Symbol.toPrimitive || prop === 'valueOf') {
|
|
2606
|
+
return () => storedId;
|
|
2607
|
+
}
|
|
2608
|
+
if (prop === 'toString') {
|
|
2609
|
+
return () => storedId;
|
|
2610
|
+
}
|
|
2611
|
+
if (prop === 'match') {
|
|
2612
|
+
return (regex) => storedId.match(regex);
|
|
2613
|
+
}
|
|
2614
|
+
if (prop === '$type') {
|
|
2615
|
+
return matchedType;
|
|
2616
|
+
}
|
|
2617
|
+
return undefined;
|
|
2618
|
+
},
|
|
2619
|
+
});
|
|
2620
|
+
hydrated[fieldName] = thenableProxy;
|
|
2621
|
+
continue;
|
|
2622
|
+
}
|
|
2623
|
+
// For forward array relations with stored IDs, create a real array proxy
|
|
2624
|
+
// that passes Array.isArray() and supports synchronous .map()/.length
|
|
2625
|
+
// while also being thenable for async hydration
|
|
2626
|
+
if (!isBackward && field.isArray && data[fieldName]) {
|
|
2627
|
+
const storedIds = data[fieldName];
|
|
2628
|
+
if (Array.isArray(storedIds)) {
|
|
2629
|
+
const matchedTypes = data[`${fieldName}$matchedTypes`];
|
|
2630
|
+
// Create a real array so Array.isArray() returns true
|
|
2631
|
+
// Each element is a thenable proxy for the related entity
|
|
2632
|
+
const arrayResult = storedIds.map((targetId, index) => {
|
|
2633
|
+
const targetType = (field.unionTypes && matchedTypes?.[index]) || field.relatedType;
|
|
2634
|
+
const targetEntity = schema.entities.get(targetType) || relatedEntity;
|
|
2635
|
+
const proxyTarget = {};
|
|
2636
|
+
return new Proxy(proxyTarget, {
|
|
2637
|
+
get(_target, prop) {
|
|
2638
|
+
if (prop === 'then') {
|
|
2639
|
+
return (resolve, reject) => {
|
|
2640
|
+
return (async () => {
|
|
2641
|
+
const prov = await resolveProvider();
|
|
2642
|
+
const result = await loadEntity(prov, targetType, targetId);
|
|
2643
|
+
if (!result)
|
|
2644
|
+
return null;
|
|
2645
|
+
const hydratedResult = hydrateEntity(result, targetEntity, schema);
|
|
2646
|
+
if (field.unionTypes && field.unionTypes.length > 0) {
|
|
2647
|
+
hydratedResult.$matchedType = targetType;
|
|
2648
|
+
}
|
|
2649
|
+
return hydratedResult;
|
|
2650
|
+
})().then(resolve, reject);
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
if (prop === Symbol.toPrimitive || prop === 'valueOf') {
|
|
2654
|
+
return () => targetId;
|
|
2655
|
+
}
|
|
2656
|
+
if (prop === 'toString') {
|
|
2657
|
+
return () => targetId;
|
|
2658
|
+
}
|
|
2659
|
+
if (prop === '$type') {
|
|
2660
|
+
return targetType;
|
|
2661
|
+
}
|
|
2662
|
+
if (prop === '$id') {
|
|
2663
|
+
return targetId;
|
|
2664
|
+
}
|
|
2665
|
+
return undefined;
|
|
2666
|
+
},
|
|
2667
|
+
});
|
|
2668
|
+
});
|
|
2669
|
+
arrayResult['$type'] = field.relatedType;
|
|
2670
|
+
arrayResult['$isArrayRelation'] = true;
|
|
2671
|
+
arrayResult['then'] = (resolve, reject) => {
|
|
2672
|
+
return (async () => {
|
|
2673
|
+
const prov = await resolveProvider();
|
|
2674
|
+
const results = await Promise.all(storedIds.map(async (targetId, index) => {
|
|
2675
|
+
const targetType = (field.unionTypes && matchedTypes?.[index]) || field.relatedType;
|
|
2676
|
+
const targetEntity = schema.entities.get(targetType) || relatedEntity;
|
|
2677
|
+
const result = await loadEntity(prov, targetType, targetId);
|
|
2678
|
+
if (!result)
|
|
2679
|
+
return null;
|
|
2680
|
+
const hydratedResult = hydrateEntity(result, targetEntity, schema);
|
|
2681
|
+
if (field.unionTypes && field.unionTypes.length > 0) {
|
|
2682
|
+
hydratedResult.$matchedType = targetType;
|
|
2683
|
+
}
|
|
2684
|
+
return hydratedResult;
|
|
2685
|
+
}));
|
|
2686
|
+
return results.filter((r) => r !== null);
|
|
2687
|
+
})().then(resolve, reject);
|
|
2688
|
+
};
|
|
2689
|
+
hydrated[fieldName] = arrayResult;
|
|
2690
|
+
continue;
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
// For forward array relations with explicitly empty array data, set to empty array
|
|
2694
|
+
if (!isBackward &&
|
|
2695
|
+
field.isArray &&
|
|
2696
|
+
Array.isArray(data[fieldName]) &&
|
|
2697
|
+
data[fieldName].length === 0) {
|
|
2698
|
+
const emptyArray = [];
|
|
2699
|
+
emptyArray['$type'] = field.relatedType;
|
|
2700
|
+
emptyArray['$isArrayRelation'] = true;
|
|
2701
|
+
emptyArray['then'] = (resolve, _reject) => {
|
|
2702
|
+
return Promise.resolve([]).then(resolve);
|
|
2703
|
+
};
|
|
2704
|
+
hydrated[fieldName] = emptyArray;
|
|
2705
|
+
continue;
|
|
2706
|
+
}
|
|
2707
|
+
// Define lazy getter
|
|
2708
|
+
Object.defineProperty(hydrated, fieldName, {
|
|
2709
|
+
get: () => {
|
|
2710
|
+
// Check if this is a backward edge
|
|
2711
|
+
if (isBackward && !field.isArray) {
|
|
2712
|
+
// Case 1: Single backward ref
|
|
2713
|
+
// Returns a Promise that resolves to the related entity
|
|
2714
|
+
const storedId = data[fieldName];
|
|
2715
|
+
// For backward fuzzy with union types (single field, not array)
|
|
2716
|
+
// storedId is a single ID and matchedType is the type that matched
|
|
2717
|
+
if (field.unionTypes && field.operator === '<~') {
|
|
2718
|
+
return (async () => {
|
|
2719
|
+
const singleStoredId = data[fieldName];
|
|
2720
|
+
const matchedType = data[`${fieldName}$matchedType`];
|
|
2721
|
+
if (singleStoredId) {
|
|
2722
|
+
const provider = await resolveProvider();
|
|
2723
|
+
const targetType = matchedType || field.relatedType;
|
|
2724
|
+
const targetEntity = schema.entities.get(targetType);
|
|
2725
|
+
const result = await provider.get(targetType, singleStoredId);
|
|
2726
|
+
if (!result)
|
|
2727
|
+
return null;
|
|
2728
|
+
const hydratedEntity = targetEntity
|
|
2729
|
+
? hydrateEntity(result, targetEntity, schema)
|
|
2730
|
+
: result;
|
|
2731
|
+
if (hydratedEntity) {
|
|
2732
|
+
;
|
|
2733
|
+
hydratedEntity.$matchedType = targetType;
|
|
2734
|
+
}
|
|
2735
|
+
return hydratedEntity;
|
|
2736
|
+
}
|
|
2737
|
+
// No match found - return null for single fields (not empty array)
|
|
2738
|
+
return null;
|
|
2739
|
+
})();
|
|
2740
|
+
}
|
|
2741
|
+
return (async () => {
|
|
2742
|
+
const provider = await resolveProvider();
|
|
2743
|
+
if (storedId) {
|
|
2744
|
+
// Has stored ID - directly fetch the related entity
|
|
2745
|
+
const result = await provider.get(field.relatedType, storedId);
|
|
2746
|
+
return result ? hydrateEntity(result, relatedEntity, schema) : null;
|
|
2747
|
+
}
|
|
2748
|
+
// No stored ID - find via inverse relation lookup
|
|
2749
|
+
// Find entities of relatedType that have this entity in their relations
|
|
2750
|
+
for (const [relFieldName, relField] of relatedEntity.fields) {
|
|
2751
|
+
if (relField.isRelation &&
|
|
2752
|
+
relField.relatedType === typeName &&
|
|
2753
|
+
relField.direction !== 'backward' &&
|
|
2754
|
+
relField.isArray) {
|
|
2755
|
+
// Found a forward array relation on related entity pointing to us
|
|
2756
|
+
// Check if any entity of relatedType has this entity in that relation
|
|
2757
|
+
const allRelated = await provider.list(field.relatedType);
|
|
2758
|
+
for (const candidate of allRelated) {
|
|
2759
|
+
const candidateId = (candidate['$id'] || candidate['id']);
|
|
2760
|
+
const related = await provider.related(field.relatedType, candidateId, relFieldName);
|
|
2761
|
+
if (related.some((r) => (r['$id'] || r['id']) === id)) {
|
|
2762
|
+
return hydrateEntity(candidate, relatedEntity, schema);
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
return null;
|
|
2768
|
+
})();
|
|
2769
|
+
}
|
|
2770
|
+
// For forward relations and backward arrays, return async resolver
|
|
2771
|
+
return (async () => {
|
|
2772
|
+
const provider = await resolveProvider();
|
|
2773
|
+
if (isBackward) {
|
|
2774
|
+
// Case 2: Array backward ref
|
|
2775
|
+
// Check if we have stored IDs (e.g., from backward fuzzy resolution)
|
|
2776
|
+
const storedIds = data[fieldName];
|
|
2777
|
+
const matchedTypes = data[`${fieldName}$matchedTypes`];
|
|
2778
|
+
if (Array.isArray(storedIds) && storedIds.length > 0) {
|
|
2779
|
+
// Use stored IDs directly - this handles backward fuzzy (<~) array fields
|
|
2780
|
+
// For union types, fetch from the correct type
|
|
2781
|
+
if (field.unionTypes && matchedTypes) {
|
|
2782
|
+
const results = await Promise.all(storedIds.map(async (targetId, index) => {
|
|
2783
|
+
const targetType = matchedTypes[index] || field.relatedType;
|
|
2784
|
+
const targetEntity = schema.entities.get(targetType);
|
|
2785
|
+
const result = await provider.get(targetType, targetId);
|
|
2786
|
+
if (!result)
|
|
2787
|
+
return null;
|
|
2788
|
+
const hydratedEntity = targetEntity
|
|
2789
|
+
? hydrateEntity(result, targetEntity, schema)
|
|
2790
|
+
: result;
|
|
2791
|
+
if (hydratedEntity) {
|
|
2792
|
+
;
|
|
2793
|
+
hydratedEntity.$matchedType = targetType;
|
|
2794
|
+
}
|
|
2795
|
+
return hydratedEntity;
|
|
2796
|
+
}));
|
|
2797
|
+
return results.filter((r) => r !== null);
|
|
2798
|
+
}
|
|
2799
|
+
else {
|
|
2800
|
+
const results = await Promise.all(storedIds.map((targetId) => provider.get(field.relatedType, targetId)));
|
|
2801
|
+
return Promise.all(results
|
|
2802
|
+
.filter((r) => r !== null)
|
|
2803
|
+
.map((r) => hydrateEntity(r, relatedEntity, schema)));
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
// For backward fuzzy union types without stored IDs, return empty array
|
|
2807
|
+
// (the test expects at least an empty array, not null)
|
|
2808
|
+
if (field.unionTypes && field.operator === '<~') {
|
|
2809
|
+
return [];
|
|
2810
|
+
}
|
|
2811
|
+
// No stored IDs - use backref lookup
|
|
2812
|
+
// e.g., Blog.posts: ['<-Post'] - find Posts where post.blog === blog.$id
|
|
2813
|
+
// The backref tells us which field on the related type stores our ID
|
|
2814
|
+
// If no explicit backref, infer from schema relationships
|
|
2815
|
+
let backrefField = field.backref;
|
|
2816
|
+
if (!backrefField) {
|
|
2817
|
+
// Infer backref: look for a field on related entity that points to us
|
|
2818
|
+
for (const [relFieldName, relField] of relatedEntity.fields) {
|
|
2819
|
+
if (relField.isRelation &&
|
|
2820
|
+
relField.relatedType === typeName &&
|
|
2821
|
+
relField.direction !== 'backward' &&
|
|
2822
|
+
!relField.isArray) {
|
|
2823
|
+
// Found a forward single relation pointing to us - use its name
|
|
2824
|
+
backrefField = relFieldName;
|
|
2825
|
+
break;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
// Fallback to entity name lowercase if no explicit relation found
|
|
2829
|
+
if (!backrefField) {
|
|
2830
|
+
backrefField = typeName.toLowerCase();
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
// Query the related type for entities that reference this entity
|
|
2834
|
+
const results = await provider.list(field.relatedType, {
|
|
2835
|
+
where: { [backrefField]: id },
|
|
2836
|
+
});
|
|
2837
|
+
return Promise.all(results.map((r) => hydrateEntity(r, relatedEntity, schema)));
|
|
2838
|
+
}
|
|
2839
|
+
else if (field.isArray) {
|
|
2840
|
+
// Forward array relation - get related entities
|
|
2841
|
+
// For union types, we need to look up each entity from its matched type
|
|
2842
|
+
const storedIds = data[fieldName];
|
|
2843
|
+
const matchedTypes = data[`${fieldName}$matchedTypes`];
|
|
2844
|
+
if (storedIds && storedIds.length > 0) {
|
|
2845
|
+
if (field.unionTypes && matchedTypes) {
|
|
2846
|
+
// Union type array - fetch each entity from its specific type
|
|
2847
|
+
const results = await Promise.all(storedIds.map(async (targetId, index) => {
|
|
2848
|
+
const targetType = matchedTypes[index] || field.relatedType;
|
|
2849
|
+
const targetEntity = schema.entities.get(targetType);
|
|
2850
|
+
const result = await provider.get(targetType, targetId);
|
|
2851
|
+
if (!result)
|
|
2852
|
+
return null;
|
|
2853
|
+
const hydratedEntity = targetEntity
|
|
2854
|
+
? hydrateEntity(result, targetEntity, schema)
|
|
2855
|
+
: result;
|
|
2856
|
+
// Add $matchedType for union type tracking
|
|
2857
|
+
if (hydratedEntity) {
|
|
2858
|
+
;
|
|
2859
|
+
hydratedEntity.$matchedType = targetType;
|
|
2860
|
+
}
|
|
2861
|
+
return hydratedEntity;
|
|
2862
|
+
}));
|
|
2863
|
+
return results.filter((r) => r !== null);
|
|
2864
|
+
}
|
|
2865
|
+
else {
|
|
2866
|
+
// Non-union type array with stored IDs - fetch directly by ID
|
|
2867
|
+
const results = await Promise.all(storedIds.map((targetId) => provider.get(field.relatedType, targetId)));
|
|
2868
|
+
return Promise.all(results
|
|
2869
|
+
.filter((r) => r !== null)
|
|
2870
|
+
.map((r) => hydrateEntity(r, relatedEntity, schema)));
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
// No stored IDs - use standard relation lookup
|
|
2874
|
+
const results = await provider.related(entity.name, id, fieldName);
|
|
2875
|
+
return Promise.all(results.map((r) => hydrateEntity(r, relatedEntity, schema)));
|
|
2876
|
+
}
|
|
2877
|
+
else {
|
|
2878
|
+
// Forward single relation - get the stored ID and fetch
|
|
2879
|
+
const relatedId = data[fieldName];
|
|
2880
|
+
if (!relatedId)
|
|
2881
|
+
return null;
|
|
2882
|
+
const result = await provider.get(field.relatedType, relatedId);
|
|
2883
|
+
return result ? hydrateEntity(result, relatedEntity, schema) : null;
|
|
2884
|
+
}
|
|
2885
|
+
})();
|
|
2886
|
+
},
|
|
2887
|
+
enumerable: true,
|
|
2888
|
+
configurable: true,
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
return hydrated;
|
|
2893
|
+
}
|
|
82
2894
|
// =============================================================================
|
|
83
|
-
//
|
|
2895
|
+
// Re-export for convenience
|
|
84
2896
|
// =============================================================================
|
|
85
|
-
export { parseSchema as parse }
|
|
2897
|
+
export { parseSchema as parse };
|
|
86
2898
|
//# sourceMappingURL=schema.js.map
|