@utaba/deep-memory-storage-sqlserver 0.1.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/LICENSE +190 -0
- package/README.md +125 -0
- package/dist/index.cjs +1684 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +140 -0
- package/dist/index.d.ts +140 -0
- package/dist/index.js +1652 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1652 @@
|
|
|
1
|
+
// src/SqlServerStorageProvider.ts
|
|
2
|
+
import sql from "mssql";
|
|
3
|
+
import {
|
|
4
|
+
RepositoryNotFoundError,
|
|
5
|
+
DuplicateRepositoryError,
|
|
6
|
+
EntityNotFoundError,
|
|
7
|
+
DuplicateEntityError,
|
|
8
|
+
RelationshipNotFoundError,
|
|
9
|
+
DuplicateRelationshipError,
|
|
10
|
+
ProviderError,
|
|
11
|
+
matchesPropertyFilters
|
|
12
|
+
} from "@utaba/deep-memory";
|
|
13
|
+
|
|
14
|
+
// src/schema.ts
|
|
15
|
+
var SCHEMA_VERSION = 1;
|
|
16
|
+
function getSchemaSQL(schema = "dbo") {
|
|
17
|
+
const s = schema;
|
|
18
|
+
return `
|
|
19
|
+
-- ============================================================
|
|
20
|
+
-- @utaba/deep-memory SQL Server schema v${SCHEMA_VERSION}
|
|
21
|
+
-- ============================================================
|
|
22
|
+
|
|
23
|
+
-- Schema version tracking
|
|
24
|
+
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'dm_meta' AND schema_id = SCHEMA_ID('${s}'))
|
|
25
|
+
BEGIN
|
|
26
|
+
CREATE TABLE [${s}].[dm_meta] (
|
|
27
|
+
[key] NVARCHAR(100) NOT NULL,
|
|
28
|
+
[value] NVARCHAR(MAX) NOT NULL,
|
|
29
|
+
CONSTRAINT [pk_dm_meta] PRIMARY KEY ([key])
|
|
30
|
+
);
|
|
31
|
+
INSERT INTO [${s}].[dm_meta] ([key], [value])
|
|
32
|
+
VALUES ('schema_version', '${SCHEMA_VERSION}');
|
|
33
|
+
END;
|
|
34
|
+
|
|
35
|
+
-- Repositories
|
|
36
|
+
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'dm_repositories' AND schema_id = SCHEMA_ID('${s}'))
|
|
37
|
+
CREATE TABLE [${s}].[dm_repositories] (
|
|
38
|
+
[repository_id] UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID(),
|
|
39
|
+
[type] NVARCHAR(128) NULL,
|
|
40
|
+
[label] NVARCHAR(500) NOT NULL,
|
|
41
|
+
[description] NVARCHAR(MAX) NULL,
|
|
42
|
+
[legal] NVARCHAR(MAX) NULL,
|
|
43
|
+
[owner] NVARCHAR(500) NULL,
|
|
44
|
+
[governance_config] NVARCHAR(MAX) NOT NULL, -- JSON
|
|
45
|
+
[metadata] NVARCHAR(MAX) NULL, -- JSON
|
|
46
|
+
[created_at] NVARCHAR(50) NOT NULL, -- ISO 8601
|
|
47
|
+
[created_by] NVARCHAR(255) NOT NULL,
|
|
48
|
+
CONSTRAINT [pk_dm_repositories] PRIMARY KEY ([repository_id])
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
-- Vocabularies (one JSON document per repository)
|
|
52
|
+
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'dm_vocabularies' AND schema_id = SCHEMA_ID('${s}'))
|
|
53
|
+
CREATE TABLE [${s}].[dm_vocabularies] (
|
|
54
|
+
[repository_id] UNIQUEIDENTIFIER NOT NULL,
|
|
55
|
+
[vocabulary] NVARCHAR(MAX) NOT NULL, -- JSON (MemoryVocabulary)
|
|
56
|
+
CONSTRAINT [pk_dm_vocabularies] PRIMARY KEY ([repository_id]),
|
|
57
|
+
CONSTRAINT [fk_dm_vocabularies_repositories]
|
|
58
|
+
FOREIGN KEY ([repository_id]) REFERENCES [${s}].[dm_repositories]([repository_id])
|
|
59
|
+
ON DELETE CASCADE
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
-- Vocabulary change log
|
|
63
|
+
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'dm_vocabulary_change_log' AND schema_id = SCHEMA_ID('${s}'))
|
|
64
|
+
CREATE TABLE [${s}].[dm_vocabulary_change_log] (
|
|
65
|
+
[change_id] NVARCHAR(128) NOT NULL,
|
|
66
|
+
[repository_id] UNIQUEIDENTIFIER NOT NULL,
|
|
67
|
+
[change_type] NVARCHAR(50) NOT NULL,
|
|
68
|
+
[type_name] NVARCHAR(255) NOT NULL,
|
|
69
|
+
[previous_version] NVARCHAR(50) NULL,
|
|
70
|
+
[new_version] NVARCHAR(50) NOT NULL,
|
|
71
|
+
[proposed_by] NVARCHAR(255) NOT NULL,
|
|
72
|
+
[proposed_at] NVARCHAR(50) NOT NULL, -- ISO 8601
|
|
73
|
+
[approved_by] NVARCHAR(255) NULL,
|
|
74
|
+
[approved_at] NVARCHAR(50) NULL, -- ISO 8601
|
|
75
|
+
[reason] NVARCHAR(MAX) NOT NULL,
|
|
76
|
+
CONSTRAINT [pk_dm_vocabulary_change_log] PRIMARY KEY ([repository_id], [change_id]),
|
|
77
|
+
CONSTRAINT [fk_dm_vocabulary_change_log_repositories]
|
|
78
|
+
FOREIGN KEY ([repository_id]) REFERENCES [${s}].[dm_repositories]([repository_id])
|
|
79
|
+
ON DELETE CASCADE
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE NONCLUSTERED INDEX [ix_dm_vocabulary_change_log_proposed_at]
|
|
83
|
+
ON [${s}].[dm_vocabulary_change_log] ([repository_id], [proposed_at] DESC);
|
|
84
|
+
|
|
85
|
+
-- Entities
|
|
86
|
+
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'dm_entities' AND schema_id = SCHEMA_ID('${s}'))
|
|
87
|
+
CREATE TABLE [${s}].[dm_entities] (
|
|
88
|
+
[ft_key] INT NOT NULL IDENTITY(1,1), -- surrogate for FT index
|
|
89
|
+
[entity_id] NVARCHAR(300) NOT NULL,
|
|
90
|
+
[slug] NVARCHAR(300) NOT NULL,
|
|
91
|
+
[repository_id] UNIQUEIDENTIFIER NOT NULL,
|
|
92
|
+
[entity_type] NVARCHAR(255) NOT NULL,
|
|
93
|
+
[label] NVARCHAR(500) NOT NULL,
|
|
94
|
+
[summary] NVARCHAR(MAX) NULL,
|
|
95
|
+
[properties] NVARCHAR(MAX) NOT NULL, -- JSON
|
|
96
|
+
[data] NVARCHAR(MAX) NULL,
|
|
97
|
+
[data_format] NVARCHAR(100) NULL,
|
|
98
|
+
[embedding] NVARCHAR(MAX) NULL, -- JSON array of numbers
|
|
99
|
+
-- Provenance
|
|
100
|
+
[created_by] NVARCHAR(255) NOT NULL,
|
|
101
|
+
[created_by_type] NVARCHAR(10) NOT NULL, -- 'user' | 'agent'
|
|
102
|
+
[created_at] NVARCHAR(50) NOT NULL, -- ISO 8601
|
|
103
|
+
[created_in_conversation] NVARCHAR(255) NULL,
|
|
104
|
+
[created_from_message] NVARCHAR(255) NULL,
|
|
105
|
+
[modified_by] NVARCHAR(255) NOT NULL,
|
|
106
|
+
[modified_by_type] NVARCHAR(10) NOT NULL,
|
|
107
|
+
[modified_at] NVARCHAR(50) NOT NULL, -- ISO 8601
|
|
108
|
+
[modified_in_conversation] NVARCHAR(255) NULL,
|
|
109
|
+
[modified_from_message] NVARCHAR(255) NULL,
|
|
110
|
+
CONSTRAINT [pk_dm_entities] PRIMARY KEY ([repository_id], [entity_id]),
|
|
111
|
+
CONSTRAINT [uq_dm_entities_ft_key] UNIQUE ([ft_key]),
|
|
112
|
+
CONSTRAINT [fk_dm_entities_repositories]
|
|
113
|
+
FOREIGN KEY ([repository_id]) REFERENCES [${s}].[dm_repositories]([repository_id])
|
|
114
|
+
ON DELETE CASCADE
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
CREATE NONCLUSTERED INDEX [ix_dm_entities_type]
|
|
118
|
+
ON [${s}].[dm_entities] ([repository_id], [entity_type]);
|
|
119
|
+
CREATE NONCLUSTERED INDEX [ix_dm_entities_label]
|
|
120
|
+
ON [${s}].[dm_entities] ([repository_id], [label]);
|
|
121
|
+
|
|
122
|
+
CREATE UNIQUE NONCLUSTERED INDEX [ix_dm_entities_slug]
|
|
123
|
+
ON [${s}].[dm_entities] ([repository_id], [slug]);
|
|
124
|
+
|
|
125
|
+
-- Provenance indexes for conversation/actor/temporal queries
|
|
126
|
+
CREATE NONCLUSTERED INDEX [ix_dm_entities_created_conversation]
|
|
127
|
+
ON [${s}].[dm_entities] ([repository_id], [created_in_conversation])
|
|
128
|
+
INCLUDE ([entity_id], [entity_type], [label], [summary]);
|
|
129
|
+
|
|
130
|
+
CREATE NONCLUSTERED INDEX [ix_dm_entities_modified_conversation]
|
|
131
|
+
ON [${s}].[dm_entities] ([repository_id], [modified_in_conversation])
|
|
132
|
+
INCLUDE ([entity_id], [entity_type], [label], [summary]);
|
|
133
|
+
|
|
134
|
+
CREATE NONCLUSTERED INDEX [ix_dm_entities_created_by]
|
|
135
|
+
ON [${s}].[dm_entities] ([repository_id], [created_by])
|
|
136
|
+
INCLUDE ([entity_id], [entity_type], [label], [summary]);
|
|
137
|
+
|
|
138
|
+
CREATE NONCLUSTERED INDEX [ix_dm_entities_modified_at]
|
|
139
|
+
ON [${s}].[dm_entities] ([repository_id], [modified_at] DESC)
|
|
140
|
+
INCLUDE ([entity_id], [entity_type], [label], [summary]);
|
|
141
|
+
|
|
142
|
+
-- Full-text catalog and index on entities
|
|
143
|
+
IF NOT EXISTS (SELECT 1 FROM sys.fulltext_catalogs WHERE name = 'dm_fulltext_catalog')
|
|
144
|
+
CREATE FULLTEXT CATALOG [dm_fulltext_catalog] AS DEFAULT;
|
|
145
|
+
|
|
146
|
+
IF NOT EXISTS (
|
|
147
|
+
SELECT 1 FROM sys.fulltext_indexes fi
|
|
148
|
+
JOIN sys.tables t ON fi.object_id = t.object_id
|
|
149
|
+
WHERE t.name = 'dm_entities' AND t.schema_id = SCHEMA_ID('${s}')
|
|
150
|
+
)
|
|
151
|
+
CREATE FULLTEXT INDEX ON [${s}].[dm_entities] (
|
|
152
|
+
[label] LANGUAGE 1033,
|
|
153
|
+
[summary] LANGUAGE 1033,
|
|
154
|
+
[data] LANGUAGE 1033,
|
|
155
|
+
[properties] LANGUAGE 1033
|
|
156
|
+
)
|
|
157
|
+
KEY INDEX [uq_dm_entities_ft_key]
|
|
158
|
+
ON [dm_fulltext_catalog]
|
|
159
|
+
WITH CHANGE_TRACKING AUTO;
|
|
160
|
+
|
|
161
|
+
-- Relationships
|
|
162
|
+
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'dm_relationships' AND schema_id = SCHEMA_ID('${s}'))
|
|
163
|
+
CREATE TABLE [${s}].[dm_relationships] (
|
|
164
|
+
[relationship_id] NVARCHAR(300) NOT NULL,
|
|
165
|
+
[repository_id] UNIQUEIDENTIFIER NOT NULL,
|
|
166
|
+
[relationship_type] NVARCHAR(255) NOT NULL,
|
|
167
|
+
[source_entity_id] NVARCHAR(300) NOT NULL,
|
|
168
|
+
[target_entity_id] NVARCHAR(300) NOT NULL,
|
|
169
|
+
[properties] NVARCHAR(MAX) NOT NULL, -- JSON
|
|
170
|
+
[bidirectional] BIT NOT NULL DEFAULT 0,
|
|
171
|
+
-- Provenance
|
|
172
|
+
[created_by] NVARCHAR(255) NOT NULL,
|
|
173
|
+
[created_by_type] NVARCHAR(10) NOT NULL,
|
|
174
|
+
[created_at] NVARCHAR(50) NOT NULL,
|
|
175
|
+
[created_in_conversation] NVARCHAR(255) NULL,
|
|
176
|
+
[created_from_message] NVARCHAR(255) NULL,
|
|
177
|
+
[modified_by] NVARCHAR(255) NOT NULL,
|
|
178
|
+
[modified_by_type] NVARCHAR(10) NOT NULL,
|
|
179
|
+
[modified_at] NVARCHAR(50) NOT NULL,
|
|
180
|
+
[modified_in_conversation] NVARCHAR(255) NULL,
|
|
181
|
+
[modified_from_message] NVARCHAR(255) NULL,
|
|
182
|
+
CONSTRAINT [pk_dm_relationships] PRIMARY KEY ([repository_id], [relationship_id]),
|
|
183
|
+
CONSTRAINT [fk_dm_relationships_repositories]
|
|
184
|
+
FOREIGN KEY ([repository_id]) REFERENCES [${s}].[dm_repositories]([repository_id])
|
|
185
|
+
ON DELETE CASCADE,
|
|
186
|
+
CONSTRAINT [fk_dm_relationships_source]
|
|
187
|
+
FOREIGN KEY ([repository_id], [source_entity_id]) REFERENCES [${s}].[dm_entities]([repository_id], [entity_id]),
|
|
188
|
+
CONSTRAINT [fk_dm_relationships_target]
|
|
189
|
+
FOREIGN KEY ([repository_id], [target_entity_id]) REFERENCES [${s}].[dm_entities]([repository_id], [entity_id])
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
CREATE NONCLUSTERED INDEX [ix_dm_relationships_source]
|
|
193
|
+
ON [${s}].[dm_relationships] ([repository_id], [source_entity_id], [relationship_type])
|
|
194
|
+
INCLUDE ([target_entity_id], [bidirectional]);
|
|
195
|
+
CREATE NONCLUSTERED INDEX [ix_dm_relationships_target]
|
|
196
|
+
ON [${s}].[dm_relationships] ([repository_id], [target_entity_id], [relationship_type])
|
|
197
|
+
INCLUDE ([source_entity_id], [bidirectional]);
|
|
198
|
+
CREATE NONCLUSTERED INDEX [ix_dm_relationships_type]
|
|
199
|
+
ON [${s}].[dm_relationships] ([repository_id], [relationship_type]);
|
|
200
|
+
|
|
201
|
+
-- Relationship provenance indexes
|
|
202
|
+
CREATE NONCLUSTERED INDEX [ix_dm_relationships_created_conversation]
|
|
203
|
+
ON [${s}].[dm_relationships] ([repository_id], [created_in_conversation]);
|
|
204
|
+
|
|
205
|
+
CREATE NONCLUSTERED INDEX [ix_dm_relationships_modified_at]
|
|
206
|
+
ON [${s}].[dm_relationships] ([repository_id], [modified_at] DESC);
|
|
207
|
+
|
|
208
|
+
-- Table-valued parameter type for batch ID lookups (eliminates plan cache bloat)
|
|
209
|
+
IF NOT EXISTS (SELECT 1 FROM sys.types WHERE name = 'dm_id_list' AND is_table_type = 1)
|
|
210
|
+
CREATE TYPE [${s}].[dm_id_list] AS TABLE ([id] NVARCHAR(300) NOT NULL);
|
|
211
|
+
`.trim();
|
|
212
|
+
}
|
|
213
|
+
function getSearchProcSQL(schema = "dbo") {
|
|
214
|
+
const s = schema;
|
|
215
|
+
return `
|
|
216
|
+
CREATE OR ALTER PROCEDURE [${s}].[dm_search_entities]
|
|
217
|
+
@RepositoryId UNIQUEIDENTIFIER,
|
|
218
|
+
@Query NVARCHAR(4000),
|
|
219
|
+
@EntityTypes NVARCHAR(MAX) = NULL, -- JSON array of strings, e.g. '["person","project"]'
|
|
220
|
+
@Limit INT = 20,
|
|
221
|
+
@Offset INT = 0,
|
|
222
|
+
@UseContains BIT = 0 -- 0 = FREETEXTTABLE (natural language), 1 = CONTAINSTABLE (exact/prefix)
|
|
223
|
+
AS
|
|
224
|
+
BEGIN
|
|
225
|
+
SET NOCOUNT ON;
|
|
226
|
+
|
|
227
|
+
-- Parse entity type filter from JSON array into a temp table
|
|
228
|
+
CREATE TABLE #entity_types ([type_name] NVARCHAR(255));
|
|
229
|
+
IF @EntityTypes IS NOT NULL AND @EntityTypes <> '[]'
|
|
230
|
+
BEGIN
|
|
231
|
+
INSERT INTO #entity_types ([type_name])
|
|
232
|
+
SELECT TRIM(value) FROM OPENJSON(@EntityTypes);
|
|
233
|
+
END;
|
|
234
|
+
|
|
235
|
+
DECLARE @HasTypeFilter BIT = CASE WHEN EXISTS (SELECT 1 FROM #entity_types) THEN 1 ELSE 0 END;
|
|
236
|
+
|
|
237
|
+
-- CTE: ranked full-text matches
|
|
238
|
+
;WITH ft_results AS (
|
|
239
|
+
SELECT
|
|
240
|
+
e.[entity_id],
|
|
241
|
+
e.[slug],
|
|
242
|
+
e.[entity_type],
|
|
243
|
+
e.[label],
|
|
244
|
+
e.[summary],
|
|
245
|
+
e.[data],
|
|
246
|
+
e.[properties],
|
|
247
|
+
ft.[RANK] AS ft_rank
|
|
248
|
+
FROM (
|
|
249
|
+
SELECT [KEY], [RANK]
|
|
250
|
+
FROM FREETEXTTABLE([${s}].[dm_entities], ([label], [summary], [data], [properties]), @Query)
|
|
251
|
+
WHERE @UseContains = 0
|
|
252
|
+
UNION ALL
|
|
253
|
+
SELECT [KEY], [RANK]
|
|
254
|
+
FROM CONTAINSTABLE([${s}].[dm_entities], ([label], [summary], [data], [properties]), @Query)
|
|
255
|
+
WHERE @UseContains = 1
|
|
256
|
+
) AS ft
|
|
257
|
+
INNER JOIN [${s}].[dm_entities] e ON e.[ft_key] = ft.[KEY]
|
|
258
|
+
WHERE e.[repository_id] = @RepositoryId
|
|
259
|
+
AND (@HasTypeFilter = 0 OR e.[entity_type] IN (SELECT [type_name] FROM #entity_types))
|
|
260
|
+
),
|
|
261
|
+
-- Count total matching rows before pagination
|
|
262
|
+
ft_counted AS (
|
|
263
|
+
SELECT *, COUNT(*) OVER () AS total_count FROM ft_results
|
|
264
|
+
)
|
|
265
|
+
SELECT
|
|
266
|
+
[entity_id],
|
|
267
|
+
[slug],
|
|
268
|
+
[entity_type],
|
|
269
|
+
[label],
|
|
270
|
+
[summary],
|
|
271
|
+
[data],
|
|
272
|
+
[properties],
|
|
273
|
+
[ft_rank],
|
|
274
|
+
[total_count],
|
|
275
|
+
-- Highlight snippets: return the first 200 chars of each matched field
|
|
276
|
+
-- (the application layer will do finer-grained highlighting using the query terms)
|
|
277
|
+
CASE WHEN FREETEXT([label], @Query) THEN LEFT([label], 200) ELSE NULL END AS [hl_label],
|
|
278
|
+
CASE WHEN FREETEXT([summary], @Query) THEN LEFT(CAST([summary] AS NVARCHAR(200)), 200) ELSE NULL END AS [hl_summary],
|
|
279
|
+
CASE WHEN FREETEXT([data], @Query) THEN LEFT(CAST([data] AS NVARCHAR(200)), 200) ELSE NULL END AS [hl_data],
|
|
280
|
+
CASE WHEN FREETEXT([properties], @Query) THEN LEFT(CAST([properties] AS NVARCHAR(200)), 200) ELSE NULL END AS [hl_properties]
|
|
281
|
+
FROM ft_counted
|
|
282
|
+
ORDER BY [ft_rank] DESC
|
|
283
|
+
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY;
|
|
284
|
+
|
|
285
|
+
DROP TABLE #entity_types;
|
|
286
|
+
END;
|
|
287
|
+
`.trim();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/SqlServerStorageProvider.ts
|
|
291
|
+
var ENTITY_COLS_LIGHT = [
|
|
292
|
+
"entity_id",
|
|
293
|
+
"slug",
|
|
294
|
+
"repository_id",
|
|
295
|
+
"entity_type",
|
|
296
|
+
"label",
|
|
297
|
+
"summary",
|
|
298
|
+
"properties",
|
|
299
|
+
"data",
|
|
300
|
+
"data_format",
|
|
301
|
+
"created_by",
|
|
302
|
+
"created_by_type",
|
|
303
|
+
"created_at",
|
|
304
|
+
"created_in_conversation",
|
|
305
|
+
"created_from_message",
|
|
306
|
+
"modified_by",
|
|
307
|
+
"modified_by_type",
|
|
308
|
+
"modified_at",
|
|
309
|
+
"modified_in_conversation",
|
|
310
|
+
"modified_from_message"
|
|
311
|
+
].map((c) => `[${c}]`).join(", ");
|
|
312
|
+
var ENTITY_COLS_FULL = `${ENTITY_COLS_LIGHT}, [embedding]`;
|
|
313
|
+
function provenanceFromRow(row) {
|
|
314
|
+
const r = row;
|
|
315
|
+
return {
|
|
316
|
+
createdBy: r["created_by"],
|
|
317
|
+
createdByType: r["created_by_type"],
|
|
318
|
+
createdAt: r["created_at"],
|
|
319
|
+
createdInConversation: r["created_in_conversation"] || void 0,
|
|
320
|
+
createdFromMessage: r["created_from_message"] || void 0,
|
|
321
|
+
modifiedBy: r["modified_by"],
|
|
322
|
+
modifiedByType: r["modified_by_type"],
|
|
323
|
+
modifiedAt: r["modified_at"],
|
|
324
|
+
modifiedInConversation: r["modified_in_conversation"] || void 0,
|
|
325
|
+
modifiedFromMessage: r["modified_from_message"] || void 0
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function addProvenanceConditions(req, prov, conditions, prefix) {
|
|
329
|
+
if (prov.conversationIds && prov.conversationIds.length > 0) {
|
|
330
|
+
const placeholders = prov.conversationIds.map((id, i) => {
|
|
331
|
+
req.input(`${prefix}ConvId${i}`, sql.NVarChar, id);
|
|
332
|
+
return `@${prefix}ConvId${i}`;
|
|
333
|
+
});
|
|
334
|
+
const inClause = placeholders.join(",");
|
|
335
|
+
conditions.push(`([created_in_conversation] IN (${inClause}) OR [modified_in_conversation] IN (${inClause}))`);
|
|
336
|
+
}
|
|
337
|
+
if (prov.actors && prov.actors.length > 0) {
|
|
338
|
+
const placeholders = prov.actors.map((a, i) => {
|
|
339
|
+
req.input(`${prefix}Actor${i}`, sql.NVarChar, a);
|
|
340
|
+
return `@${prefix}Actor${i}`;
|
|
341
|
+
});
|
|
342
|
+
const inClause = placeholders.join(",");
|
|
343
|
+
conditions.push(`([created_by] IN (${inClause}) OR [modified_by] IN (${inClause}))`);
|
|
344
|
+
}
|
|
345
|
+
if (prov.dateRange) {
|
|
346
|
+
req.input(`${prefix}DateFrom`, sql.NVarChar, prov.dateRange.from);
|
|
347
|
+
req.input(`${prefix}DateTo`, sql.NVarChar, prov.dateRange.to);
|
|
348
|
+
conditions.push(`([created_at] >= @${prefix}DateFrom AND [created_at] <= @${prefix}DateTo) OR ([modified_at] >= @${prefix}DateFrom AND [modified_at] <= @${prefix}DateTo)`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function entityFromRow(row) {
|
|
352
|
+
const r = row;
|
|
353
|
+
return {
|
|
354
|
+
id: r["entity_id"],
|
|
355
|
+
slug: r["slug"],
|
|
356
|
+
entityType: r["entity_type"],
|
|
357
|
+
label: r["label"],
|
|
358
|
+
summary: r["summary"] || void 0,
|
|
359
|
+
properties: JSON.parse(r["properties"] || "{}"),
|
|
360
|
+
data: r["data"] || void 0,
|
|
361
|
+
dataFormat: r["data_format"] || void 0,
|
|
362
|
+
provenance: provenanceFromRow(row),
|
|
363
|
+
embedding: "embedding" in r && r["embedding"] ? JSON.parse(r["embedding"]) : void 0
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function relationshipFromRow(row) {
|
|
367
|
+
const r = row;
|
|
368
|
+
return {
|
|
369
|
+
id: r["relationship_id"],
|
|
370
|
+
relationshipType: r["relationship_type"],
|
|
371
|
+
sourceEntityId: r["source_entity_id"],
|
|
372
|
+
targetEntityId: r["target_entity_id"],
|
|
373
|
+
properties: JSON.parse(r["properties"] || "{}"),
|
|
374
|
+
bidirectional: r["bidirectional"] === true || r["bidirectional"] === 1,
|
|
375
|
+
provenance: provenanceFromRow(row)
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function changeRecordFromRow(row) {
|
|
379
|
+
const r = row;
|
|
380
|
+
return {
|
|
381
|
+
changeId: r["change_id"],
|
|
382
|
+
changeType: r["change_type"],
|
|
383
|
+
typeName: r["type_name"],
|
|
384
|
+
previousVersion: r["previous_version"] || void 0,
|
|
385
|
+
newVersion: r["new_version"],
|
|
386
|
+
proposedBy: r["proposed_by"],
|
|
387
|
+
proposedAt: r["proposed_at"],
|
|
388
|
+
approvedBy: r["approved_by"] || void 0,
|
|
389
|
+
approvedAt: r["approved_at"] || void 0,
|
|
390
|
+
reason: r["reason"]
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
var SqlServerStorageProvider = class {
|
|
394
|
+
pool = null;
|
|
395
|
+
ownsPool;
|
|
396
|
+
config;
|
|
397
|
+
schema;
|
|
398
|
+
constructor(config) {
|
|
399
|
+
this.config = config;
|
|
400
|
+
this.schema = config.schema ?? "dbo";
|
|
401
|
+
this.ownsPool = !(config.connection instanceof sql.ConnectionPool);
|
|
402
|
+
}
|
|
403
|
+
t(table) {
|
|
404
|
+
return `[${this.schema}].[${table}]`;
|
|
405
|
+
}
|
|
406
|
+
getPool() {
|
|
407
|
+
if (!this.pool) {
|
|
408
|
+
throw new ProviderError(
|
|
409
|
+
"SqlServerStorageProvider not initialised. Call initialise() first."
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return this.pool;
|
|
413
|
+
}
|
|
414
|
+
// ─── Lifecycle ────────────────────────────────────────────────────
|
|
415
|
+
async initialise() {
|
|
416
|
+
if (this.config.connection instanceof sql.ConnectionPool) {
|
|
417
|
+
this.pool = this.config.connection;
|
|
418
|
+
this.ownsPool = false;
|
|
419
|
+
if (!this.pool.connected) {
|
|
420
|
+
await this.pool.connect();
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
this.pool = new sql.ConnectionPool(this.config.connection);
|
|
424
|
+
this.ownsPool = true;
|
|
425
|
+
await this.pool.connect();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async dispose() {
|
|
429
|
+
if (this.ownsPool && this.pool) {
|
|
430
|
+
await this.pool.close();
|
|
431
|
+
}
|
|
432
|
+
this.pool = null;
|
|
433
|
+
}
|
|
434
|
+
async ensureSchema() {
|
|
435
|
+
const pool = this.getPool();
|
|
436
|
+
const metaCheck = await pool.request().query(
|
|
437
|
+
`SELECT COUNT(*) AS cnt FROM sys.tables WHERE name = 'dm_meta' AND schema_id = SCHEMA_ID('${this.schema}')`
|
|
438
|
+
);
|
|
439
|
+
const metaExists = (metaCheck.recordset[0]?.cnt ?? 0) > 0;
|
|
440
|
+
if (metaExists) {
|
|
441
|
+
const versionResult = await pool.request().query(
|
|
442
|
+
`SELECT [value] FROM ${this.t("dm_meta")} WHERE [key] = 'schema_version'`
|
|
443
|
+
);
|
|
444
|
+
const currentVersion = parseInt(versionResult.recordset[0]?.value ?? "0", 10);
|
|
445
|
+
if (currentVersion > SCHEMA_VERSION) {
|
|
446
|
+
throw new ProviderError(
|
|
447
|
+
`Database schema version ${currentVersion} is newer than provider version ${SCHEMA_VERSION}. Update the provider package.`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
if (currentVersion === SCHEMA_VERSION) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const ddl = getSchemaSQL(this.schema);
|
|
455
|
+
const statements = ddl.split(/\n\n+/).map((s) => s.split("\n").filter((line) => !line.trimStart().startsWith("--")).join("\n").trim()).filter((s) => s.length > 0);
|
|
456
|
+
for (const stmt of statements) {
|
|
457
|
+
try {
|
|
458
|
+
await pool.request().query(stmt);
|
|
459
|
+
} catch (err) {
|
|
460
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
461
|
+
if (!msg.includes("already exists")) {
|
|
462
|
+
throw new ProviderError(`Schema creation failed: ${msg}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// ─── Repository ──────────────────────────────────────────────────
|
|
468
|
+
async createRepository(config) {
|
|
469
|
+
const pool = this.getPool();
|
|
470
|
+
const existing = await pool.request().input("id", sql.UniqueIdentifier, config.repositoryId).query(
|
|
471
|
+
`SELECT [repository_id] FROM ${this.t("dm_repositories")} WHERE [repository_id] = @id`
|
|
472
|
+
);
|
|
473
|
+
if (existing.recordset.length > 0) {
|
|
474
|
+
throw new DuplicateRepositoryError(config.repositoryId);
|
|
475
|
+
}
|
|
476
|
+
await pool.request().input("id", sql.UniqueIdentifier, config.repositoryId).input("type", sql.NVarChar, config.type ?? null).input("label", sql.NVarChar, config.label).input("description", sql.NVarChar, config.description ?? null).input("legal", sql.NVarChar, config.legal ?? null).input("owner", sql.NVarChar, config.owner ?? null).input("governanceConfig", sql.NVarChar, JSON.stringify(config.governanceConfig)).input("metadata", sql.NVarChar, config.metadata ? JSON.stringify(config.metadata) : null).input("createdAt", sql.NVarChar, config.createdAt).input("createdBy", sql.NVarChar, config.createdBy).query(`
|
|
477
|
+
INSERT INTO ${this.t("dm_repositories")}
|
|
478
|
+
([repository_id], [type], [label], [description], [legal], [owner], [governance_config], [metadata], [created_at], [created_by])
|
|
479
|
+
VALUES (@id, @type, @label, @description, @legal, @owner, @governanceConfig, @metadata, @createdAt, @createdBy)
|
|
480
|
+
`);
|
|
481
|
+
const emptyVocabulary = {
|
|
482
|
+
version: "0.0.0",
|
|
483
|
+
lastModified: config.createdAt,
|
|
484
|
+
modifiedBy: config.createdBy,
|
|
485
|
+
entityTypes: [],
|
|
486
|
+
relationshipTypes: []
|
|
487
|
+
};
|
|
488
|
+
await pool.request().input("id", sql.UniqueIdentifier, config.repositoryId).input("vocabulary", sql.NVarChar, JSON.stringify(emptyVocabulary)).query(`
|
|
489
|
+
INSERT INTO ${this.t("dm_vocabularies")} ([repository_id], [vocabulary])
|
|
490
|
+
VALUES (@id, @vocabulary)
|
|
491
|
+
`);
|
|
492
|
+
return {
|
|
493
|
+
repositoryId: config.repositoryId,
|
|
494
|
+
type: config.type,
|
|
495
|
+
label: config.label,
|
|
496
|
+
description: config.description,
|
|
497
|
+
legal: config.legal,
|
|
498
|
+
owner: config.owner,
|
|
499
|
+
governanceConfig: config.governanceConfig,
|
|
500
|
+
metadata: config.metadata,
|
|
501
|
+
createdAt: config.createdAt,
|
|
502
|
+
createdBy: config.createdBy
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
async getRepository(repositoryId) {
|
|
506
|
+
const pool = this.getPool();
|
|
507
|
+
const result = await pool.request().input("id", sql.UniqueIdentifier, repositoryId).query(
|
|
508
|
+
`SELECT * FROM ${this.t("dm_repositories")} WHERE [repository_id] = @id`
|
|
509
|
+
);
|
|
510
|
+
const row = result.recordset[0];
|
|
511
|
+
if (!row) return null;
|
|
512
|
+
const metadataRaw = row["metadata"];
|
|
513
|
+
return {
|
|
514
|
+
repositoryId: row["repository_id"],
|
|
515
|
+
type: row["type"] || void 0,
|
|
516
|
+
label: row["label"],
|
|
517
|
+
description: row["description"] || void 0,
|
|
518
|
+
legal: row["legal"] || void 0,
|
|
519
|
+
owner: row["owner"] || void 0,
|
|
520
|
+
governanceConfig: JSON.parse(row["governance_config"]),
|
|
521
|
+
metadata: metadataRaw ? JSON.parse(metadataRaw) : void 0,
|
|
522
|
+
createdAt: row["created_at"],
|
|
523
|
+
createdBy: row["created_by"]
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
async listRepositories(filter) {
|
|
527
|
+
const pool = this.getPool();
|
|
528
|
+
const offset = filter?.offset ?? 0;
|
|
529
|
+
const limit = filter?.limit ?? 20;
|
|
530
|
+
const countReq = pool.request();
|
|
531
|
+
const dataReq = pool.request();
|
|
532
|
+
let where = "";
|
|
533
|
+
if (filter?.type) {
|
|
534
|
+
countReq.input("type", sql.NVarChar, filter.type);
|
|
535
|
+
dataReq.input("type", sql.NVarChar, filter.type);
|
|
536
|
+
where = "WHERE [type] = @type";
|
|
537
|
+
}
|
|
538
|
+
const countResult = await countReq.query(
|
|
539
|
+
`SELECT COUNT(*) AS [cnt] FROM ${this.t("dm_repositories")} ${where}`
|
|
540
|
+
);
|
|
541
|
+
const total = countResult.recordset[0]?.["cnt"] ?? 0;
|
|
542
|
+
dataReq.input("offset", sql.Int, offset);
|
|
543
|
+
dataReq.input("limit", sql.Int, limit);
|
|
544
|
+
const result = await dataReq.query(
|
|
545
|
+
`SELECT [repository_id], [type], [label], [description], [governance_config]
|
|
546
|
+
FROM ${this.t("dm_repositories")} ${where}
|
|
547
|
+
ORDER BY [label]
|
|
548
|
+
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`
|
|
549
|
+
);
|
|
550
|
+
const items = result.recordset.map((row) => ({
|
|
551
|
+
repositoryId: row["repository_id"],
|
|
552
|
+
type: row["type"] || void 0,
|
|
553
|
+
label: row["label"],
|
|
554
|
+
description: row["description"] || void 0,
|
|
555
|
+
governanceConfig: JSON.parse(row["governance_config"])
|
|
556
|
+
}));
|
|
557
|
+
return {
|
|
558
|
+
items,
|
|
559
|
+
total,
|
|
560
|
+
hasMore: offset + items.length < total,
|
|
561
|
+
limit,
|
|
562
|
+
offset
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
async updateRepository(repositoryId, updates) {
|
|
566
|
+
const pool = this.getPool();
|
|
567
|
+
const setClauses = [];
|
|
568
|
+
const request = pool.request().input("id", sql.UniqueIdentifier, repositoryId);
|
|
569
|
+
if (updates.label !== void 0) {
|
|
570
|
+
setClauses.push("[label] = @label");
|
|
571
|
+
request.input("label", sql.NVarChar, updates.label);
|
|
572
|
+
}
|
|
573
|
+
if (updates.description !== void 0) {
|
|
574
|
+
setClauses.push("[description] = @description");
|
|
575
|
+
request.input("description", sql.NVarChar, updates.description);
|
|
576
|
+
}
|
|
577
|
+
if (updates.type !== void 0) {
|
|
578
|
+
setClauses.push("[type] = @type");
|
|
579
|
+
request.input("type", sql.NVarChar, updates.type);
|
|
580
|
+
}
|
|
581
|
+
if (updates.legal !== void 0) {
|
|
582
|
+
setClauses.push("[legal] = @legal");
|
|
583
|
+
request.input("legal", sql.NVarChar, updates.legal);
|
|
584
|
+
}
|
|
585
|
+
if (updates.owner !== void 0) {
|
|
586
|
+
setClauses.push("[owner] = @owner");
|
|
587
|
+
request.input("owner", sql.NVarChar, updates.owner);
|
|
588
|
+
}
|
|
589
|
+
if (updates.governanceConfig !== void 0) {
|
|
590
|
+
setClauses.push("[governance_config] = @governanceConfig");
|
|
591
|
+
request.input("governanceConfig", sql.NVarChar, JSON.stringify(updates.governanceConfig));
|
|
592
|
+
}
|
|
593
|
+
if (updates.metadata !== void 0) {
|
|
594
|
+
const existing = await this.getRepository(repositoryId);
|
|
595
|
+
if (!existing) throw new RepositoryNotFoundError(repositoryId);
|
|
596
|
+
const merged = { ...existing.metadata, ...updates.metadata };
|
|
597
|
+
setClauses.push("[metadata] = @metadata");
|
|
598
|
+
request.input("metadata", sql.NVarChar, JSON.stringify(merged));
|
|
599
|
+
}
|
|
600
|
+
if (setClauses.length === 0) {
|
|
601
|
+
const existing = await this.getRepository(repositoryId);
|
|
602
|
+
if (!existing) throw new RepositoryNotFoundError(repositoryId);
|
|
603
|
+
return existing;
|
|
604
|
+
}
|
|
605
|
+
const result = await request.query(
|
|
606
|
+
`UPDATE ${this.t("dm_repositories")} SET ${setClauses.join(", ")} WHERE [repository_id] = @id`
|
|
607
|
+
);
|
|
608
|
+
if (result.rowsAffected[0] === 0) {
|
|
609
|
+
throw new RepositoryNotFoundError(repositoryId);
|
|
610
|
+
}
|
|
611
|
+
const updated = await this.getRepository(repositoryId);
|
|
612
|
+
if (!updated) throw new RepositoryNotFoundError(repositoryId);
|
|
613
|
+
return updated;
|
|
614
|
+
}
|
|
615
|
+
async deleteRepository(repositoryId) {
|
|
616
|
+
const pool = this.getPool();
|
|
617
|
+
const result = await pool.request().input("id", sql.UniqueIdentifier, repositoryId).query(`DELETE FROM ${this.t("dm_repositories")} WHERE [repository_id] = @id`);
|
|
618
|
+
if (result.rowsAffected[0] === 0) {
|
|
619
|
+
throw new RepositoryNotFoundError(repositoryId);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async getRepositoryStats(repositoryId) {
|
|
623
|
+
await this.assertRepository(repositoryId);
|
|
624
|
+
const pool = this.getPool();
|
|
625
|
+
const [entityStats, relStats, vocab] = await Promise.all([
|
|
626
|
+
pool.request().input("id", sql.UniqueIdentifier, repositoryId).query(
|
|
627
|
+
`SELECT [entity_type], COUNT(*) AS cnt
|
|
628
|
+
FROM ${this.t("dm_entities")} WHERE [repository_id] = @id
|
|
629
|
+
GROUP BY [entity_type]`
|
|
630
|
+
),
|
|
631
|
+
pool.request().input("id", sql.UniqueIdentifier, repositoryId).query(
|
|
632
|
+
`SELECT [relationship_type], COUNT(*) AS cnt
|
|
633
|
+
FROM ${this.t("dm_relationships")} WHERE [repository_id] = @id
|
|
634
|
+
GROUP BY [relationship_type]`
|
|
635
|
+
),
|
|
636
|
+
this.getVocabulary(repositoryId)
|
|
637
|
+
]);
|
|
638
|
+
const entityTypeBreakdown = {};
|
|
639
|
+
let entityCount = 0;
|
|
640
|
+
for (const row of entityStats.recordset) {
|
|
641
|
+
entityTypeBreakdown[row.entity_type] = row.cnt;
|
|
642
|
+
entityCount += row.cnt;
|
|
643
|
+
}
|
|
644
|
+
const relationshipTypeBreakdown = {};
|
|
645
|
+
let relationshipCount = 0;
|
|
646
|
+
for (const row of relStats.recordset) {
|
|
647
|
+
relationshipTypeBreakdown[row.relationship_type] = row.cnt;
|
|
648
|
+
relationshipCount += row.cnt;
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
entityCount,
|
|
652
|
+
relationshipCount,
|
|
653
|
+
vocabularyVersion: vocab.version,
|
|
654
|
+
entityTypeBreakdown,
|
|
655
|
+
relationshipTypeBreakdown
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
// ─── Vocabulary ──────────────────────────────────────────────────
|
|
659
|
+
async getVocabulary(repositoryId) {
|
|
660
|
+
await this.assertRepository(repositoryId);
|
|
661
|
+
const pool = this.getPool();
|
|
662
|
+
const result = await pool.request().input("id", sql.UniqueIdentifier, repositoryId).query(
|
|
663
|
+
`SELECT [vocabulary] FROM ${this.t("dm_vocabularies")} WHERE [repository_id] = @id`
|
|
664
|
+
);
|
|
665
|
+
const row = result.recordset[0];
|
|
666
|
+
if (!row) {
|
|
667
|
+
return {
|
|
668
|
+
version: "0.0.0",
|
|
669
|
+
lastModified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
670
|
+
modifiedBy: "system",
|
|
671
|
+
entityTypes: [],
|
|
672
|
+
relationshipTypes: []
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
return JSON.parse(row.vocabulary);
|
|
676
|
+
}
|
|
677
|
+
async saveVocabulary(repositoryId, vocabulary) {
|
|
678
|
+
await this.assertRepository(repositoryId);
|
|
679
|
+
const pool = this.getPool();
|
|
680
|
+
await pool.request().input("id", sql.UniqueIdentifier, repositoryId).input("vocabulary", sql.NVarChar, JSON.stringify(vocabulary)).query(`
|
|
681
|
+
UPDATE ${this.t("dm_vocabularies")}
|
|
682
|
+
SET [vocabulary] = @vocabulary
|
|
683
|
+
WHERE [repository_id] = @id
|
|
684
|
+
`);
|
|
685
|
+
}
|
|
686
|
+
async getVocabularyChangeLog(repositoryId, options) {
|
|
687
|
+
await this.assertRepository(repositoryId);
|
|
688
|
+
const pool = this.getPool();
|
|
689
|
+
const limit = options?.limit ?? 10;
|
|
690
|
+
const offset = options?.offset ?? 0;
|
|
691
|
+
const countResult = await pool.request().input("id", sql.UniqueIdentifier, repositoryId).query(
|
|
692
|
+
`SELECT COUNT(*) AS cnt FROM ${this.t("dm_vocabulary_change_log")} WHERE [repository_id] = @id`
|
|
693
|
+
);
|
|
694
|
+
const total = countResult.recordset[0]?.cnt ?? 0;
|
|
695
|
+
const result = await pool.request().input("id", sql.UniqueIdentifier, repositoryId).input("limit", sql.Int, limit).input("offset", sql.Int, offset).query(
|
|
696
|
+
`SELECT * FROM ${this.t("dm_vocabulary_change_log")}
|
|
697
|
+
WHERE [repository_id] = @id
|
|
698
|
+
ORDER BY [proposed_at] DESC
|
|
699
|
+
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`
|
|
700
|
+
);
|
|
701
|
+
return {
|
|
702
|
+
items: result.recordset.map(changeRecordFromRow),
|
|
703
|
+
total,
|
|
704
|
+
hasMore: offset + limit < total,
|
|
705
|
+
limit,
|
|
706
|
+
offset
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
// ─── Entities ────────────────────────────────────────────────────
|
|
710
|
+
async createEntity(repositoryId, entity) {
|
|
711
|
+
await this.assertRepository(repositoryId);
|
|
712
|
+
const pool = this.getPool();
|
|
713
|
+
const existing = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityId", sql.NVarChar, entity.id).query(
|
|
714
|
+
`SELECT [entity_id] FROM ${this.t("dm_entities")}
|
|
715
|
+
WHERE [repository_id] = @repoId AND [entity_id] = @entityId`
|
|
716
|
+
);
|
|
717
|
+
if (existing.recordset.length > 0) {
|
|
718
|
+
throw new DuplicateEntityError(entity.id);
|
|
719
|
+
}
|
|
720
|
+
const req = pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityId", sql.NVarChar, entity.id).input("slug", sql.NVarChar, entity.slug).input("entityType", sql.NVarChar, entity.entityType).input("label", sql.NVarChar, entity.label).input("summary", sql.NVarChar, entity.summary ?? null).input("properties", sql.NVarChar, JSON.stringify(entity.properties)).input("data", sql.NVarChar, entity.data ?? null).input("dataFormat", sql.NVarChar, entity.dataFormat ?? null).input("embedding", sql.NVarChar, entity.embedding ? JSON.stringify(entity.embedding) : null);
|
|
721
|
+
this.addProvenanceInputs(req, entity.provenance);
|
|
722
|
+
await req.query(`
|
|
723
|
+
INSERT INTO ${this.t("dm_entities")} (
|
|
724
|
+
[repository_id], [entity_id], [slug], [entity_type], [label], [summary],
|
|
725
|
+
[properties], [data], [data_format], [embedding],
|
|
726
|
+
[created_by], [created_by_type], [created_at],
|
|
727
|
+
[created_in_conversation], [created_from_message],
|
|
728
|
+
[modified_by], [modified_by_type], [modified_at],
|
|
729
|
+
[modified_in_conversation], [modified_from_message]
|
|
730
|
+
) VALUES (
|
|
731
|
+
@repoId, @entityId, @slug, @entityType, @label, @summary,
|
|
732
|
+
@properties, @data, @dataFormat, @embedding,
|
|
733
|
+
@createdBy, @createdByType, @createdAt,
|
|
734
|
+
@createdInConversation, @createdFromMessage,
|
|
735
|
+
@modifiedBy, @modifiedByType, @modifiedAt,
|
|
736
|
+
@modifiedInConversation, @modifiedFromMessage
|
|
737
|
+
)
|
|
738
|
+
`);
|
|
739
|
+
return entity;
|
|
740
|
+
}
|
|
741
|
+
async getEntity(repositoryId, entityId) {
|
|
742
|
+
const pool = this.getPool();
|
|
743
|
+
const result = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityId", sql.NVarChar, entityId).query(
|
|
744
|
+
`SELECT ${ENTITY_COLS_LIGHT} FROM ${this.t("dm_entities")}
|
|
745
|
+
WHERE [repository_id] = @repoId AND [entity_id] = @entityId`
|
|
746
|
+
);
|
|
747
|
+
const row = result.recordset[0];
|
|
748
|
+
if (!row) return null;
|
|
749
|
+
return entityFromRow(row);
|
|
750
|
+
}
|
|
751
|
+
async getEntityBySlug(repositoryId, slug) {
|
|
752
|
+
const pool = this.getPool();
|
|
753
|
+
const result = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("slug", sql.NVarChar, slug).query(
|
|
754
|
+
`SELECT ${ENTITY_COLS_LIGHT} FROM ${this.t("dm_entities")}
|
|
755
|
+
WHERE [repository_id] = @repoId AND [slug] = @slug`
|
|
756
|
+
);
|
|
757
|
+
const row = result.recordset[0];
|
|
758
|
+
if (!row) return null;
|
|
759
|
+
return entityFromRow(row);
|
|
760
|
+
}
|
|
761
|
+
async getEntities(repositoryId, entityIds) {
|
|
762
|
+
const pool = this.getPool();
|
|
763
|
+
const result = /* @__PURE__ */ new Map();
|
|
764
|
+
if (entityIds.length === 0) return result;
|
|
765
|
+
const tvp = this.createIdListTvp(entityIds);
|
|
766
|
+
const rows = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityIds", tvp).query(
|
|
767
|
+
`SELECT ${ENTITY_COLS_LIGHT} FROM ${this.t("dm_entities")}
|
|
768
|
+
WHERE [repository_id] = @repoId
|
|
769
|
+
AND [entity_id] IN (SELECT [id] FROM @entityIds)`
|
|
770
|
+
);
|
|
771
|
+
for (const row of rows.recordset) {
|
|
772
|
+
const entity = entityFromRow(row);
|
|
773
|
+
result.set(entity.id, entity);
|
|
774
|
+
}
|
|
775
|
+
return result;
|
|
776
|
+
}
|
|
777
|
+
async updateEntity(repositoryId, entityId, updates) {
|
|
778
|
+
const pool = this.getPool();
|
|
779
|
+
const existing = await this.getEntity(repositoryId, entityId);
|
|
780
|
+
if (!existing) {
|
|
781
|
+
throw new EntityNotFoundError(entityId);
|
|
782
|
+
}
|
|
783
|
+
const updated = {
|
|
784
|
+
...existing,
|
|
785
|
+
label: updates.label ?? existing.label,
|
|
786
|
+
slug: updates.slug ?? existing.slug,
|
|
787
|
+
summary: updates.summary !== void 0 ? updates.summary : existing.summary,
|
|
788
|
+
properties: updates.properties ?? existing.properties,
|
|
789
|
+
data: updates.data !== void 0 ? updates.data : existing.data,
|
|
790
|
+
dataFormat: updates.dataFormat !== void 0 ? updates.dataFormat : existing.dataFormat,
|
|
791
|
+
provenance: updates.provenance,
|
|
792
|
+
embedding: updates.embedding ?? existing.embedding
|
|
793
|
+
};
|
|
794
|
+
const req = pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityId", sql.NVarChar, entityId).input("slug", sql.NVarChar, updated.slug).input("label", sql.NVarChar, updated.label).input("summary", sql.NVarChar, updated.summary ?? null).input("properties", sql.NVarChar, JSON.stringify(updated.properties)).input("data", sql.NVarChar, updated.data ?? null).input("dataFormat", sql.NVarChar, updated.dataFormat ?? null).input("embedding", sql.NVarChar, updated.embedding ? JSON.stringify(updated.embedding) : null).input("modifiedBy", sql.NVarChar, updates.provenance.modifiedBy).input("modifiedByType", sql.NVarChar, updates.provenance.modifiedByType).input("modifiedAt", sql.NVarChar, updates.provenance.modifiedAt).input("modifiedInConversation", sql.NVarChar, updates.provenance.modifiedInConversation ?? null).input("modifiedFromMessage", sql.NVarChar, updates.provenance.modifiedFromMessage ?? null);
|
|
795
|
+
await req.query(`
|
|
796
|
+
UPDATE ${this.t("dm_entities")} SET
|
|
797
|
+
[slug] = @slug,
|
|
798
|
+
[label] = @label,
|
|
799
|
+
[summary] = @summary,
|
|
800
|
+
[properties] = @properties,
|
|
801
|
+
[data] = @data,
|
|
802
|
+
[data_format] = @dataFormat,
|
|
803
|
+
[embedding] = @embedding,
|
|
804
|
+
[modified_by] = @modifiedBy,
|
|
805
|
+
[modified_by_type] = @modifiedByType,
|
|
806
|
+
[modified_at] = @modifiedAt,
|
|
807
|
+
[modified_in_conversation] = @modifiedInConversation,
|
|
808
|
+
[modified_from_message] = @modifiedFromMessage
|
|
809
|
+
WHERE [repository_id] = @repoId AND [entity_id] = @entityId
|
|
810
|
+
`);
|
|
811
|
+
return updated;
|
|
812
|
+
}
|
|
813
|
+
async deleteEntity(repositoryId, entityId) {
|
|
814
|
+
const pool = this.getPool();
|
|
815
|
+
const delReq = pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityId", sql.NVarChar, entityId);
|
|
816
|
+
await delReq.query(`
|
|
817
|
+
DELETE FROM ${this.t("dm_relationships")}
|
|
818
|
+
WHERE [repository_id] = @repoId AND [source_entity_id] = @entityId;
|
|
819
|
+
DELETE FROM ${this.t("dm_relationships")}
|
|
820
|
+
WHERE [repository_id] = @repoId AND [target_entity_id] = @entityId;
|
|
821
|
+
`);
|
|
822
|
+
const result = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityId", sql.NVarChar, entityId).query(
|
|
823
|
+
`DELETE FROM ${this.t("dm_entities")}
|
|
824
|
+
WHERE [repository_id] = @repoId AND [entity_id] = @entityId`
|
|
825
|
+
);
|
|
826
|
+
if (result.rowsAffected[0] === 0) {
|
|
827
|
+
throw new EntityNotFoundError(entityId);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
async deleteEntitiesByType(repositoryId, entityType) {
|
|
831
|
+
await this.assertRepository(repositoryId);
|
|
832
|
+
const pool = this.getPool();
|
|
833
|
+
const srcResult = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityType", sql.NVarChar, entityType).query(`
|
|
834
|
+
DELETE r FROM ${this.t("dm_relationships")} r
|
|
835
|
+
INNER JOIN ${this.t("dm_entities")} e
|
|
836
|
+
ON r.[repository_id] = e.[repository_id]
|
|
837
|
+
AND r.[source_entity_id] = e.[entity_id]
|
|
838
|
+
WHERE e.[repository_id] = @repoId AND e.[entity_type] = @entityType
|
|
839
|
+
`);
|
|
840
|
+
const tgtResult = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityType", sql.NVarChar, entityType).query(`
|
|
841
|
+
DELETE r FROM ${this.t("dm_relationships")} r
|
|
842
|
+
INNER JOIN ${this.t("dm_entities")} e
|
|
843
|
+
ON r.[repository_id] = e.[repository_id]
|
|
844
|
+
AND r.[target_entity_id] = e.[entity_id]
|
|
845
|
+
WHERE e.[repository_id] = @repoId AND e.[entity_type] = @entityType
|
|
846
|
+
`);
|
|
847
|
+
const deletedRelationships = (srcResult.rowsAffected[0] ?? 0) + (tgtResult.rowsAffected[0] ?? 0);
|
|
848
|
+
const entResult = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityType", sql.NVarChar, entityType).query(
|
|
849
|
+
`DELETE FROM ${this.t("dm_entities")}
|
|
850
|
+
WHERE [repository_id] = @repoId AND [entity_type] = @entityType`
|
|
851
|
+
);
|
|
852
|
+
const deletedEntities = entResult.rowsAffected[0] ?? 0;
|
|
853
|
+
return { deletedEntities, deletedRelationships };
|
|
854
|
+
}
|
|
855
|
+
async findEntities(repositoryId, query) {
|
|
856
|
+
const pool = this.getPool();
|
|
857
|
+
const req = pool.request().input("repoId", sql.UniqueIdentifier, repositoryId);
|
|
858
|
+
const conditions = ["[repository_id] = @repoId"];
|
|
859
|
+
if (query.entityTypes && query.entityTypes.length > 0) {
|
|
860
|
+
const typePlaceholders = query.entityTypes.map((t, i) => {
|
|
861
|
+
req.input(`et${i}`, sql.NVarChar, t);
|
|
862
|
+
return `@et${i}`;
|
|
863
|
+
});
|
|
864
|
+
conditions.push(`[entity_type] IN (${typePlaceholders.join(",")})`);
|
|
865
|
+
}
|
|
866
|
+
if (query.searchTerm) {
|
|
867
|
+
req.input("searchTerm", sql.NVarChar, `%${query.searchTerm}%`);
|
|
868
|
+
conditions.push(`([label] LIKE @searchTerm OR [summary] LIKE @searchTerm)`);
|
|
869
|
+
}
|
|
870
|
+
if (query.properties) {
|
|
871
|
+
const entries = Object.entries(query.properties);
|
|
872
|
+
for (let i = 0; i < entries.length; i++) {
|
|
873
|
+
const [key, value] = entries[i];
|
|
874
|
+
req.input(`propKey${i}`, sql.NVarChar, `$.${key}`);
|
|
875
|
+
req.input(`propVal${i}`, sql.NVarChar, String(value));
|
|
876
|
+
conditions.push(`JSON_VALUE([properties], @propKey${i}) = @propVal${i}`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (query.provenance) {
|
|
880
|
+
addProvenanceConditions(req, query.provenance, conditions, "data");
|
|
881
|
+
}
|
|
882
|
+
const where = conditions.join(" AND ");
|
|
883
|
+
const countReq = pool.request().input("repoId", sql.UniqueIdentifier, repositoryId);
|
|
884
|
+
const countConditions = ["[repository_id] = @repoId"];
|
|
885
|
+
if (query.entityTypes && query.entityTypes.length > 0) {
|
|
886
|
+
const typePlaceholders = query.entityTypes.map((t, i) => {
|
|
887
|
+
countReq.input(`et${i}`, sql.NVarChar, t);
|
|
888
|
+
return `@et${i}`;
|
|
889
|
+
});
|
|
890
|
+
countConditions.push(`[entity_type] IN (${typePlaceholders.join(",")})`);
|
|
891
|
+
}
|
|
892
|
+
if (query.searchTerm) {
|
|
893
|
+
countReq.input("searchTerm", sql.NVarChar, `%${query.searchTerm}%`);
|
|
894
|
+
countConditions.push(`([label] LIKE @searchTerm OR [summary] LIKE @searchTerm)`);
|
|
895
|
+
}
|
|
896
|
+
if (query.properties) {
|
|
897
|
+
const entries = Object.entries(query.properties);
|
|
898
|
+
for (let i = 0; i < entries.length; i++) {
|
|
899
|
+
const [key, value] = entries[i];
|
|
900
|
+
countReq.input(`propKey${i}`, sql.NVarChar, `$.${key}`);
|
|
901
|
+
countReq.input(`propVal${i}`, sql.NVarChar, String(value));
|
|
902
|
+
countConditions.push(`JSON_VALUE([properties], @propKey${i}) = @propVal${i}`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (query.provenance) {
|
|
906
|
+
addProvenanceConditions(countReq, query.provenance, countConditions, "count");
|
|
907
|
+
}
|
|
908
|
+
const countWhere = countConditions.join(" AND ");
|
|
909
|
+
const totalResult = await countReq.query(
|
|
910
|
+
`SELECT COUNT(*) AS cnt FROM ${this.t("dm_entities")} WHERE ${countWhere}`
|
|
911
|
+
);
|
|
912
|
+
const total = totalResult.recordset[0]?.cnt ?? 0;
|
|
913
|
+
req.input("limit", sql.Int, query.limit);
|
|
914
|
+
req.input("offset", sql.Int, query.offset);
|
|
915
|
+
const result = await req.query(
|
|
916
|
+
`SELECT ${ENTITY_COLS_LIGHT} FROM ${this.t("dm_entities")}
|
|
917
|
+
WHERE ${where}
|
|
918
|
+
ORDER BY [entity_id]
|
|
919
|
+
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`
|
|
920
|
+
);
|
|
921
|
+
return {
|
|
922
|
+
items: result.recordset.map(entityFromRow),
|
|
923
|
+
total,
|
|
924
|
+
hasMore: query.offset + query.limit < total,
|
|
925
|
+
limit: query.limit,
|
|
926
|
+
offset: query.offset
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
// ─── Relationships ──────────────────────────────────────────────
|
|
930
|
+
async createRelationship(repositoryId, relationship) {
|
|
931
|
+
await this.assertRepository(repositoryId);
|
|
932
|
+
const pool = this.getPool();
|
|
933
|
+
const existing = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("relId", sql.NVarChar, relationship.id).query(
|
|
934
|
+
`SELECT [relationship_id] FROM ${this.t("dm_relationships")}
|
|
935
|
+
WHERE [repository_id] = @repoId AND [relationship_id] = @relId`
|
|
936
|
+
);
|
|
937
|
+
if (existing.recordset.length > 0) {
|
|
938
|
+
throw new DuplicateRelationshipError(relationship.id);
|
|
939
|
+
}
|
|
940
|
+
const req = pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("relId", sql.NVarChar, relationship.id).input("relType", sql.NVarChar, relationship.relationshipType).input("sourceId", sql.NVarChar, relationship.sourceEntityId).input("targetId", sql.NVarChar, relationship.targetEntityId).input("properties", sql.NVarChar, JSON.stringify(relationship.properties)).input("bidirectional", sql.Bit, relationship.bidirectional ? 1 : 0);
|
|
941
|
+
this.addProvenanceInputs(req, relationship.provenance);
|
|
942
|
+
await req.query(`
|
|
943
|
+
INSERT INTO ${this.t("dm_relationships")} (
|
|
944
|
+
[repository_id], [relationship_id], [relationship_type],
|
|
945
|
+
[source_entity_id], [target_entity_id], [properties], [bidirectional],
|
|
946
|
+
[created_by], [created_by_type], [created_at],
|
|
947
|
+
[created_in_conversation], [created_from_message],
|
|
948
|
+
[modified_by], [modified_by_type], [modified_at],
|
|
949
|
+
[modified_in_conversation], [modified_from_message]
|
|
950
|
+
) VALUES (
|
|
951
|
+
@repoId, @relId, @relType,
|
|
952
|
+
@sourceId, @targetId, @properties, @bidirectional,
|
|
953
|
+
@createdBy, @createdByType, @createdAt,
|
|
954
|
+
@createdInConversation, @createdFromMessage,
|
|
955
|
+
@modifiedBy, @modifiedByType, @modifiedAt,
|
|
956
|
+
@modifiedInConversation, @modifiedFromMessage
|
|
957
|
+
)
|
|
958
|
+
`);
|
|
959
|
+
return relationship;
|
|
960
|
+
}
|
|
961
|
+
async getRelationship(repositoryId, relationshipId) {
|
|
962
|
+
const pool = this.getPool();
|
|
963
|
+
const result = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("relId", sql.NVarChar, relationshipId).query(
|
|
964
|
+
`SELECT * FROM ${this.t("dm_relationships")}
|
|
965
|
+
WHERE [repository_id] = @repoId AND [relationship_id] = @relId`
|
|
966
|
+
);
|
|
967
|
+
const row = result.recordset[0];
|
|
968
|
+
if (!row) return null;
|
|
969
|
+
return relationshipFromRow(row);
|
|
970
|
+
}
|
|
971
|
+
async getEntityRelationships(repositoryId, entityId, options) {
|
|
972
|
+
const pool = this.getPool();
|
|
973
|
+
const direction = options?.direction ?? "both";
|
|
974
|
+
const limit = options?.limit ?? 10;
|
|
975
|
+
const offset = options?.offset ?? 0;
|
|
976
|
+
const tbl = this.t("dm_relationships");
|
|
977
|
+
let rtFilter = "";
|
|
978
|
+
const rtInputs = [];
|
|
979
|
+
if (options?.relationshipTypes && options.relationshipTypes.length > 0) {
|
|
980
|
+
const placeholders = options.relationshipTypes.map((t, i) => {
|
|
981
|
+
rtInputs.push({ name: `rt${i}`, value: t });
|
|
982
|
+
return `@rt${i}`;
|
|
983
|
+
});
|
|
984
|
+
rtFilter = ` AND [relationship_type] IN (${placeholders.join(",")})`;
|
|
985
|
+
}
|
|
986
|
+
const unionBranches = this.buildRelationshipUnion(tbl, direction, rtFilter);
|
|
987
|
+
const addParams = (req) => {
|
|
988
|
+
req.input("repoId", sql.UniqueIdentifier, repositoryId);
|
|
989
|
+
req.input("entityId", sql.NVarChar, entityId);
|
|
990
|
+
for (const p of rtInputs) req.input(p.name, sql.NVarChar, p.value);
|
|
991
|
+
return req;
|
|
992
|
+
};
|
|
993
|
+
const countReq = addParams(pool.request());
|
|
994
|
+
const totalResult = await countReq.query(
|
|
995
|
+
`SELECT COUNT(*) AS cnt FROM (${unionBranches}) AS _u`
|
|
996
|
+
);
|
|
997
|
+
const total = totalResult.recordset[0]?.cnt ?? 0;
|
|
998
|
+
const fetchReq = addParams(pool.request());
|
|
999
|
+
fetchReq.input("limit", sql.Int, limit);
|
|
1000
|
+
fetchReq.input("offset", sql.Int, offset);
|
|
1001
|
+
const result = await fetchReq.query(
|
|
1002
|
+
`SELECT * FROM (${unionBranches}) AS _u
|
|
1003
|
+
ORDER BY [relationship_id]
|
|
1004
|
+
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`
|
|
1005
|
+
);
|
|
1006
|
+
let items = result.recordset.map(relationshipFromRow);
|
|
1007
|
+
if (options?.propertyFilters && options.propertyFilters.length > 0) {
|
|
1008
|
+
items = items.filter((r) => matchesPropertyFilters(r.properties, options.propertyFilters));
|
|
1009
|
+
const filteredTotal = total;
|
|
1010
|
+
return {
|
|
1011
|
+
items,
|
|
1012
|
+
total: filteredTotal,
|
|
1013
|
+
hasMore: offset + limit < filteredTotal,
|
|
1014
|
+
limit,
|
|
1015
|
+
offset
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
return {
|
|
1019
|
+
items,
|
|
1020
|
+
total,
|
|
1021
|
+
hasMore: offset + limit < total,
|
|
1022
|
+
limit,
|
|
1023
|
+
offset
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Build a UNION ALL query that seeks on source and target indexes separately,
|
|
1028
|
+
* avoiding OR-based index scans. Each branch produces an index seek at scale.
|
|
1029
|
+
*/
|
|
1030
|
+
buildRelationshipUnion(tbl, direction, rtFilter) {
|
|
1031
|
+
const srcBase = `SELECT * FROM ${tbl} WHERE [repository_id] = @repoId AND [source_entity_id] = @entityId${rtFilter}`;
|
|
1032
|
+
const tgtBase = `SELECT * FROM ${tbl} WHERE [repository_id] = @repoId AND [target_entity_id] = @entityId${rtFilter}`;
|
|
1033
|
+
const tgtBidi = `SELECT * FROM ${tbl} WHERE [repository_id] = @repoId AND [target_entity_id] = @entityId AND [bidirectional] = 1${rtFilter}`;
|
|
1034
|
+
const srcBidi = `SELECT * FROM ${tbl} WHERE [repository_id] = @repoId AND [source_entity_id] = @entityId AND [bidirectional] = 1${rtFilter}`;
|
|
1035
|
+
switch (direction) {
|
|
1036
|
+
case "outbound":
|
|
1037
|
+
return `${srcBase} UNION ALL ${tgtBidi} AND [source_entity_id] <> @entityId`;
|
|
1038
|
+
case "inbound":
|
|
1039
|
+
return `${tgtBase} UNION ALL ${srcBidi} AND [target_entity_id] <> @entityId`;
|
|
1040
|
+
case "both":
|
|
1041
|
+
default:
|
|
1042
|
+
return `${srcBase} UNION ALL ${tgtBase} AND [source_entity_id] <> @entityId`;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
async deleteRelationship(repositoryId, relationshipId) {
|
|
1046
|
+
const pool = this.getPool();
|
|
1047
|
+
const result = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("relId", sql.NVarChar, relationshipId).query(
|
|
1048
|
+
`DELETE FROM ${this.t("dm_relationships")}
|
|
1049
|
+
WHERE [repository_id] = @repoId AND [relationship_id] = @relId`
|
|
1050
|
+
);
|
|
1051
|
+
if (result.rowsAffected[0] === 0) {
|
|
1052
|
+
throw new RelationshipNotFoundError(relationshipId);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
async deleteRelationshipsByType(repositoryId, relationshipType) {
|
|
1056
|
+
await this.assertRepository(repositoryId);
|
|
1057
|
+
const pool = this.getPool();
|
|
1058
|
+
const result = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("relType", sql.NVarChar, relationshipType).query(
|
|
1059
|
+
`DELETE FROM ${this.t("dm_relationships")}
|
|
1060
|
+
WHERE [repository_id] = @repoId AND [relationship_type] = @relType`
|
|
1061
|
+
);
|
|
1062
|
+
return { deletedRelationships: result.rowsAffected[0] ?? 0 };
|
|
1063
|
+
}
|
|
1064
|
+
// ─── Graph Traversal ────────────────────────────────────────────
|
|
1065
|
+
async exploreNeighbourhood(repositoryId, entityId, options) {
|
|
1066
|
+
const centre = await this.getEntityLight(repositoryId, entityId);
|
|
1067
|
+
if (!centre) {
|
|
1068
|
+
throw new EntityNotFoundError(entityId);
|
|
1069
|
+
}
|
|
1070
|
+
const layers = [];
|
|
1071
|
+
const visited = /* @__PURE__ */ new Set([entityId]);
|
|
1072
|
+
let currentFrontier = /* @__PURE__ */ new Set([entityId]);
|
|
1073
|
+
for (let depth = 0; depth < options.depth; depth++) {
|
|
1074
|
+
const layer = {};
|
|
1075
|
+
const nextFrontier = /* @__PURE__ */ new Set();
|
|
1076
|
+
const frontierIds = [...currentFrontier];
|
|
1077
|
+
const relsByEntity = await this.getRelationshipsForEntities(
|
|
1078
|
+
repositoryId,
|
|
1079
|
+
frontierIds,
|
|
1080
|
+
options.direction,
|
|
1081
|
+
options.relationshipTypes
|
|
1082
|
+
);
|
|
1083
|
+
const connectedIdsToFetch = /* @__PURE__ */ new Set();
|
|
1084
|
+
const pendingRels = [];
|
|
1085
|
+
for (const frontierEntityId of frontierIds) {
|
|
1086
|
+
const rels = relsByEntity.get(frontierEntityId) ?? [];
|
|
1087
|
+
for (const rel of rels) {
|
|
1088
|
+
let connectedEntityId;
|
|
1089
|
+
if (rel.sourceEntityId === frontierEntityId) {
|
|
1090
|
+
connectedEntityId = rel.targetEntityId;
|
|
1091
|
+
} else if (rel.targetEntityId === frontierEntityId) {
|
|
1092
|
+
connectedEntityId = rel.sourceEntityId;
|
|
1093
|
+
}
|
|
1094
|
+
if (!connectedEntityId || visited.has(connectedEntityId)) continue;
|
|
1095
|
+
if (options.relationshipPropertyFilters && options.relationshipPropertyFilters.length > 0) {
|
|
1096
|
+
if (!matchesPropertyFilters(rel.properties, options.relationshipPropertyFilters)) {
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
connectedIdsToFetch.add(connectedEntityId);
|
|
1101
|
+
pendingRels.push({ frontierEntityId, rel, connectedEntityId });
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
const connectedEntities = await this.getEntitiesLight(repositoryId, [...connectedIdsToFetch]);
|
|
1105
|
+
for (const { rel, connectedEntityId } of pendingRels) {
|
|
1106
|
+
if (visited.has(connectedEntityId)) continue;
|
|
1107
|
+
const connectedEntity = connectedEntities.get(connectedEntityId);
|
|
1108
|
+
if (!connectedEntity) continue;
|
|
1109
|
+
if (options.entityTypes && !options.entityTypes.includes(connectedEntity.entityType)) {
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
const relType = rel.relationshipType;
|
|
1113
|
+
if (!layer[relType]) {
|
|
1114
|
+
layer[relType] = { total: 0, entities: [], relationships: [] };
|
|
1115
|
+
}
|
|
1116
|
+
const group = layer[relType];
|
|
1117
|
+
group.total++;
|
|
1118
|
+
if (group.entities.length < options.limitPerType) {
|
|
1119
|
+
group.entities.push(connectedEntity);
|
|
1120
|
+
group.relationships.push(rel);
|
|
1121
|
+
}
|
|
1122
|
+
nextFrontier.add(connectedEntityId);
|
|
1123
|
+
visited.add(connectedEntityId);
|
|
1124
|
+
}
|
|
1125
|
+
layers.push(layer);
|
|
1126
|
+
currentFrontier = nextFrontier;
|
|
1127
|
+
if (nextFrontier.size === 0) break;
|
|
1128
|
+
}
|
|
1129
|
+
return { centreId: entityId, layers };
|
|
1130
|
+
}
|
|
1131
|
+
async findPaths(repositoryId, sourceId, targetId, options) {
|
|
1132
|
+
const [source, target] = await Promise.all([
|
|
1133
|
+
this.getEntityLight(repositoryId, sourceId),
|
|
1134
|
+
this.getEntityLight(repositoryId, targetId)
|
|
1135
|
+
]);
|
|
1136
|
+
if (!source) throw new EntityNotFoundError(sourceId);
|
|
1137
|
+
if (!target) throw new EntityNotFoundError(targetId);
|
|
1138
|
+
if (sourceId === targetId) {
|
|
1139
|
+
return { paths: [{ entityIds: [sourceId], relationshipIds: [] }], totalPaths: 1 };
|
|
1140
|
+
}
|
|
1141
|
+
const paths = [];
|
|
1142
|
+
let currentLevel = [
|
|
1143
|
+
{ entityId: sourceId, path: [sourceId], relPath: [] }
|
|
1144
|
+
];
|
|
1145
|
+
const visitedAtDepth = /* @__PURE__ */ new Map();
|
|
1146
|
+
visitedAtDepth.set(sourceId, 0);
|
|
1147
|
+
for (let depth = 0; depth <= options.maxDepth && currentLevel.length > 0 && paths.length < options.limit + options.offset; depth++) {
|
|
1148
|
+
const frontierIds = [...new Set(currentLevel.map((e) => e.entityId))];
|
|
1149
|
+
const relsByEntity = await this.getRelationshipsForEntities(
|
|
1150
|
+
repositoryId,
|
|
1151
|
+
frontierIds,
|
|
1152
|
+
"both",
|
|
1153
|
+
options.relationshipTypes
|
|
1154
|
+
);
|
|
1155
|
+
const nextLevel = [];
|
|
1156
|
+
for (const current of currentLevel) {
|
|
1157
|
+
if (paths.length >= options.limit + options.offset) break;
|
|
1158
|
+
const rels = relsByEntity.get(current.entityId) ?? [];
|
|
1159
|
+
for (const rel of rels) {
|
|
1160
|
+
if (options.relationshipPropertyFilters && options.relationshipPropertyFilters.length > 0) {
|
|
1161
|
+
if (!matchesPropertyFilters(rel.properties, options.relationshipPropertyFilters)) {
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
let nextEntityId;
|
|
1166
|
+
if (rel.sourceEntityId === current.entityId) {
|
|
1167
|
+
nextEntityId = rel.targetEntityId;
|
|
1168
|
+
} else if (rel.targetEntityId === current.entityId) {
|
|
1169
|
+
nextEntityId = rel.sourceEntityId;
|
|
1170
|
+
}
|
|
1171
|
+
if (!nextEntityId) continue;
|
|
1172
|
+
if (current.path.includes(nextEntityId) && nextEntityId !== targetId) continue;
|
|
1173
|
+
const newPath = [...current.path, nextEntityId];
|
|
1174
|
+
const newRelPath = [...current.relPath, rel.id];
|
|
1175
|
+
if (nextEntityId === targetId) {
|
|
1176
|
+
paths.push({ entityIds: newPath, relationshipIds: newRelPath });
|
|
1177
|
+
} else if (newPath.length <= options.maxDepth) {
|
|
1178
|
+
const prevDepth = visitedAtDepth.get(nextEntityId);
|
|
1179
|
+
if (prevDepth === void 0 || prevDepth >= newPath.length - 1) {
|
|
1180
|
+
visitedAtDepth.set(nextEntityId, newPath.length - 1);
|
|
1181
|
+
nextLevel.push({ entityId: nextEntityId, path: newPath, relPath: newRelPath });
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
if (options.entityTypes && nextLevel.length > 0) {
|
|
1187
|
+
const nextIds = [...new Set(nextLevel.map((e) => e.entityId))];
|
|
1188
|
+
const entityMap = await this.getEntitiesLight(repositoryId, nextIds);
|
|
1189
|
+
const allowedIds = /* @__PURE__ */ new Set();
|
|
1190
|
+
for (const [id, e] of entityMap) {
|
|
1191
|
+
if (options.entityTypes.includes(e.entityType)) {
|
|
1192
|
+
allowedIds.add(id);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
currentLevel = nextLevel.filter((e) => allowedIds.has(e.entityId));
|
|
1196
|
+
} else {
|
|
1197
|
+
currentLevel = nextLevel;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const paginatedPaths = paths.slice(options.offset, options.offset + options.limit);
|
|
1201
|
+
return { paths: paginatedPaths, totalPaths: paths.length };
|
|
1202
|
+
}
|
|
1203
|
+
// ─── Timeline ───────────────────────────────────────────────────
|
|
1204
|
+
async getTimeline(repositoryId, entityId, options) {
|
|
1205
|
+
const entity = await this.getEntityLight(repositoryId, entityId);
|
|
1206
|
+
if (!entity) {
|
|
1207
|
+
throw new EntityNotFoundError(entityId);
|
|
1208
|
+
}
|
|
1209
|
+
const events = [];
|
|
1210
|
+
events.push({
|
|
1211
|
+
timestamp: entity.provenance.createdAt,
|
|
1212
|
+
eventType: "entity:created",
|
|
1213
|
+
entityId
|
|
1214
|
+
});
|
|
1215
|
+
if (entity.provenance.modifiedAt !== entity.provenance.createdAt) {
|
|
1216
|
+
events.push({
|
|
1217
|
+
timestamp: entity.provenance.modifiedAt,
|
|
1218
|
+
eventType: "entity:updated",
|
|
1219
|
+
entityId
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
const pool = this.getPool();
|
|
1223
|
+
const relReq = pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityId", sql.NVarChar, entityId).input("from", sql.NVarChar, options.timeRange?.from ?? null).input("to", sql.NVarChar, options.timeRange?.to ?? null);
|
|
1224
|
+
const relResult = await relReq.query(
|
|
1225
|
+
`SELECT [relationship_id], [created_at] FROM ${this.t("dm_relationships")}
|
|
1226
|
+
WHERE [repository_id] = @repoId AND [source_entity_id] = @entityId
|
|
1227
|
+
AND (@from IS NULL OR [created_at] >= @from)
|
|
1228
|
+
AND (@to IS NULL OR [created_at] <= @to)
|
|
1229
|
+
UNION ALL
|
|
1230
|
+
SELECT [relationship_id], [created_at] FROM ${this.t("dm_relationships")}
|
|
1231
|
+
WHERE [repository_id] = @repoId AND [target_entity_id] = @entityId
|
|
1232
|
+
AND [source_entity_id] <> @entityId
|
|
1233
|
+
AND (@from IS NULL OR [created_at] >= @from)
|
|
1234
|
+
AND (@to IS NULL OR [created_at] <= @to)`
|
|
1235
|
+
);
|
|
1236
|
+
for (const row of relResult.recordset) {
|
|
1237
|
+
events.push({
|
|
1238
|
+
timestamp: row["created_at"],
|
|
1239
|
+
eventType: "relationship:created",
|
|
1240
|
+
entityId,
|
|
1241
|
+
relationshipId: row["relationship_id"]
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
let filtered = events;
|
|
1245
|
+
if (options.timeRange) {
|
|
1246
|
+
const from = new Date(options.timeRange.from).getTime();
|
|
1247
|
+
const to = new Date(options.timeRange.to).getTime();
|
|
1248
|
+
filtered = filtered.filter((e) => {
|
|
1249
|
+
const t = new Date(e.timestamp).getTime();
|
|
1250
|
+
return t >= from && t <= to;
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
if (options.eventTypes && options.eventTypes.length > 0) {
|
|
1254
|
+
filtered = filtered.filter((e) => options.eventTypes.includes(e.eventType));
|
|
1255
|
+
}
|
|
1256
|
+
filtered.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
1257
|
+
const total = filtered.length;
|
|
1258
|
+
const items = filtered.slice(options.offset, options.offset + options.limit);
|
|
1259
|
+
return { events: items, total };
|
|
1260
|
+
}
|
|
1261
|
+
// ─── Bulk Operations ────────────────────────────────────────────
|
|
1262
|
+
async *exportAll(repositoryId) {
|
|
1263
|
+
await this.assertRepository(repositoryId);
|
|
1264
|
+
const pool = this.getPool();
|
|
1265
|
+
const batchSize = 100;
|
|
1266
|
+
const entityCount = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).query(
|
|
1267
|
+
`SELECT COUNT(*) AS cnt FROM ${this.t("dm_entities")} WHERE [repository_id] = @repoId`
|
|
1268
|
+
);
|
|
1269
|
+
const totalEntities = entityCount.recordset[0]?.cnt ?? 0;
|
|
1270
|
+
if (totalEntities === 0) {
|
|
1271
|
+
yield { type: "entities", data: [], sequence: 0, isLast: true };
|
|
1272
|
+
} else {
|
|
1273
|
+
for (let offset = 0; offset < totalEntities; offset += batchSize) {
|
|
1274
|
+
const batch = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("offset", sql.Int, offset).input("limit", sql.Int, batchSize).query(
|
|
1275
|
+
`SELECT ${ENTITY_COLS_FULL} FROM ${this.t("dm_entities")}
|
|
1276
|
+
WHERE [repository_id] = @repoId
|
|
1277
|
+
ORDER BY [entity_id]
|
|
1278
|
+
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`
|
|
1279
|
+
);
|
|
1280
|
+
yield {
|
|
1281
|
+
type: "entities",
|
|
1282
|
+
data: batch.recordset.map(entityFromRow),
|
|
1283
|
+
sequence: Math.floor(offset / batchSize),
|
|
1284
|
+
isLast: offset + batchSize >= totalEntities
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
const relCount = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).query(
|
|
1289
|
+
`SELECT COUNT(*) AS cnt FROM ${this.t("dm_relationships")} WHERE [repository_id] = @repoId`
|
|
1290
|
+
);
|
|
1291
|
+
const totalRels = relCount.recordset[0]?.cnt ?? 0;
|
|
1292
|
+
if (totalRels === 0) {
|
|
1293
|
+
yield { type: "relationships", data: [], sequence: 0, isLast: true };
|
|
1294
|
+
} else {
|
|
1295
|
+
for (let offset = 0; offset < totalRels; offset += batchSize) {
|
|
1296
|
+
const batch = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("offset", sql.Int, offset).input("limit", sql.Int, batchSize).query(
|
|
1297
|
+
`SELECT * FROM ${this.t("dm_relationships")}
|
|
1298
|
+
WHERE [repository_id] = @repoId
|
|
1299
|
+
ORDER BY [relationship_id]
|
|
1300
|
+
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`
|
|
1301
|
+
);
|
|
1302
|
+
yield {
|
|
1303
|
+
type: "relationships",
|
|
1304
|
+
data: batch.recordset.map(relationshipFromRow),
|
|
1305
|
+
sequence: Math.floor(offset / batchSize),
|
|
1306
|
+
isLast: offset + batchSize >= totalRels
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
async importBulk(repositoryId, data) {
|
|
1312
|
+
await this.assertRepository(repositoryId);
|
|
1313
|
+
const pool = this.getPool();
|
|
1314
|
+
let entitiesImported = 0;
|
|
1315
|
+
let relationshipsImported = 0;
|
|
1316
|
+
const transaction = pool.transaction();
|
|
1317
|
+
await transaction.begin();
|
|
1318
|
+
try {
|
|
1319
|
+
for (const chunk of data) {
|
|
1320
|
+
if (chunk.entities) {
|
|
1321
|
+
for (const entity of chunk.entities) {
|
|
1322
|
+
const req = transaction.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityId", sql.NVarChar, entity.id).input("slug", sql.NVarChar, entity.slug).input("entityType", sql.NVarChar, entity.entityType).input("label", sql.NVarChar, entity.label).input("summary", sql.NVarChar, entity.summary ?? null).input("properties", sql.NVarChar, JSON.stringify(entity.properties)).input("data", sql.NVarChar, entity.data ?? null).input("dataFormat", sql.NVarChar, entity.dataFormat ?? null).input("embedding", sql.NVarChar, entity.embedding ? JSON.stringify(entity.embedding) : null);
|
|
1323
|
+
this.addProvenanceInputs(req, entity.provenance);
|
|
1324
|
+
await req.query(`
|
|
1325
|
+
MERGE ${this.t("dm_entities")} AS target
|
|
1326
|
+
USING (SELECT @repoId AS repository_id, @entityId AS entity_id) AS source
|
|
1327
|
+
ON target.[repository_id] = source.repository_id AND target.[entity_id] = source.entity_id
|
|
1328
|
+
WHEN MATCHED THEN UPDATE SET
|
|
1329
|
+
[entity_type] = @entityType, [slug] = @slug, [label] = @label, [summary] = @summary,
|
|
1330
|
+
[properties] = @properties, [data] = @data, [data_format] = @dataFormat,
|
|
1331
|
+
[embedding] = @embedding,
|
|
1332
|
+
[modified_by] = @modifiedBy, [modified_by_type] = @modifiedByType,
|
|
1333
|
+
[modified_at] = @modifiedAt, [modified_in_conversation] = @modifiedInConversation,
|
|
1334
|
+
[modified_from_message] = @modifiedFromMessage
|
|
1335
|
+
WHEN NOT MATCHED THEN INSERT (
|
|
1336
|
+
[repository_id], [entity_id], [slug], [entity_type], [label], [summary],
|
|
1337
|
+
[properties], [data], [data_format], [embedding],
|
|
1338
|
+
[created_by], [created_by_type], [created_at],
|
|
1339
|
+
[created_in_conversation], [created_from_message],
|
|
1340
|
+
[modified_by], [modified_by_type], [modified_at],
|
|
1341
|
+
[modified_in_conversation], [modified_from_message]
|
|
1342
|
+
) VALUES (
|
|
1343
|
+
@repoId, @entityId, @slug, @entityType, @label, @summary,
|
|
1344
|
+
@properties, @data, @dataFormat, @embedding,
|
|
1345
|
+
@createdBy, @createdByType, @createdAt,
|
|
1346
|
+
@createdInConversation, @createdFromMessage,
|
|
1347
|
+
@modifiedBy, @modifiedByType, @modifiedAt,
|
|
1348
|
+
@modifiedInConversation, @modifiedFromMessage
|
|
1349
|
+
);
|
|
1350
|
+
`);
|
|
1351
|
+
entitiesImported++;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
if (chunk.relationships) {
|
|
1355
|
+
for (const rel of chunk.relationships) {
|
|
1356
|
+
const req = transaction.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("relId", sql.NVarChar, rel.id).input("relType", sql.NVarChar, rel.relationshipType).input("sourceId", sql.NVarChar, rel.sourceEntityId).input("targetId", sql.NVarChar, rel.targetEntityId).input("properties", sql.NVarChar, JSON.stringify(rel.properties)).input("bidirectional", sql.Bit, rel.bidirectional ? 1 : 0);
|
|
1357
|
+
this.addProvenanceInputs(req, rel.provenance);
|
|
1358
|
+
await req.query(`
|
|
1359
|
+
MERGE ${this.t("dm_relationships")} AS target
|
|
1360
|
+
USING (SELECT @repoId AS repository_id, @relId AS relationship_id) AS source
|
|
1361
|
+
ON target.[repository_id] = source.repository_id AND target.[relationship_id] = source.relationship_id
|
|
1362
|
+
WHEN MATCHED THEN UPDATE SET
|
|
1363
|
+
[relationship_type] = @relType, [source_entity_id] = @sourceId,
|
|
1364
|
+
[target_entity_id] = @targetId, [properties] = @properties,
|
|
1365
|
+
[bidirectional] = @bidirectional,
|
|
1366
|
+
[modified_by] = @modifiedBy, [modified_by_type] = @modifiedByType,
|
|
1367
|
+
[modified_at] = @modifiedAt, [modified_in_conversation] = @modifiedInConversation,
|
|
1368
|
+
[modified_from_message] = @modifiedFromMessage
|
|
1369
|
+
WHEN NOT MATCHED THEN INSERT (
|
|
1370
|
+
[repository_id], [relationship_id], [relationship_type],
|
|
1371
|
+
[source_entity_id], [target_entity_id], [properties], [bidirectional],
|
|
1372
|
+
[created_by], [created_by_type], [created_at],
|
|
1373
|
+
[created_in_conversation], [created_from_message],
|
|
1374
|
+
[modified_by], [modified_by_type], [modified_at],
|
|
1375
|
+
[modified_in_conversation], [modified_from_message]
|
|
1376
|
+
) VALUES (
|
|
1377
|
+
@repoId, @relId, @relType,
|
|
1378
|
+
@sourceId, @targetId, @properties, @bidirectional,
|
|
1379
|
+
@createdBy, @createdByType, @createdAt,
|
|
1380
|
+
@createdInConversation, @createdFromMessage,
|
|
1381
|
+
@modifiedBy, @modifiedByType, @modifiedAt,
|
|
1382
|
+
@modifiedInConversation, @modifiedFromMessage
|
|
1383
|
+
);
|
|
1384
|
+
`);
|
|
1385
|
+
relationshipsImported++;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
await transaction.commit();
|
|
1390
|
+
} catch (err) {
|
|
1391
|
+
await transaction.rollback();
|
|
1392
|
+
throw err;
|
|
1393
|
+
}
|
|
1394
|
+
return { entitiesImported, relationshipsImported, errors: [] };
|
|
1395
|
+
}
|
|
1396
|
+
// ─── Private helpers ────────────────────────────────────────────
|
|
1397
|
+
/** Creates a TVP table from an array of string IDs (eliminates plan cache bloat from IN clauses). */
|
|
1398
|
+
createIdListTvp(ids) {
|
|
1399
|
+
const tvp = new sql.Table(`${this.schema}.dm_id_list`);
|
|
1400
|
+
tvp.columns.add("id", sql.NVarChar(300));
|
|
1401
|
+
for (const id of ids) tvp.rows.add(id);
|
|
1402
|
+
return tvp;
|
|
1403
|
+
}
|
|
1404
|
+
/** Returns a StoredEntity without the embedding column (lighter I/O). */
|
|
1405
|
+
async getEntityLight(repositoryId, entityId) {
|
|
1406
|
+
const pool = this.getPool();
|
|
1407
|
+
const result = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityId", sql.NVarChar, entityId).query(
|
|
1408
|
+
`SELECT ${ENTITY_COLS_LIGHT} FROM ${this.t("dm_entities")}
|
|
1409
|
+
WHERE [repository_id] = @repoId AND [entity_id] = @entityId`
|
|
1410
|
+
);
|
|
1411
|
+
const row = result.recordset[0];
|
|
1412
|
+
if (!row) return null;
|
|
1413
|
+
return entityFromRow(row);
|
|
1414
|
+
}
|
|
1415
|
+
/** Returns multiple entities without embedding columns, using TVP for batch lookup. */
|
|
1416
|
+
async getEntitiesLight(repositoryId, entityIds) {
|
|
1417
|
+
const pool = this.getPool();
|
|
1418
|
+
const result = /* @__PURE__ */ new Map();
|
|
1419
|
+
if (entityIds.length === 0) return result;
|
|
1420
|
+
const tvp = this.createIdListTvp(entityIds);
|
|
1421
|
+
const rows = await pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityIds", tvp).query(
|
|
1422
|
+
`SELECT ${ENTITY_COLS_LIGHT} FROM ${this.t("dm_entities")}
|
|
1423
|
+
WHERE [repository_id] = @repoId
|
|
1424
|
+
AND [entity_id] IN (SELECT [id] FROM @entityIds)`
|
|
1425
|
+
);
|
|
1426
|
+
for (const row of rows.recordset) {
|
|
1427
|
+
const entity = entityFromRow(row);
|
|
1428
|
+
result.set(entity.id, entity);
|
|
1429
|
+
}
|
|
1430
|
+
return result;
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Fetches relationships for multiple entity IDs in a single query using TVP.
|
|
1434
|
+
* Returns results grouped by the frontier entity ID.
|
|
1435
|
+
*/
|
|
1436
|
+
async getRelationshipsForEntities(repositoryId, entityIds, direction, relationshipTypes) {
|
|
1437
|
+
const pool = this.getPool();
|
|
1438
|
+
const result = /* @__PURE__ */ new Map();
|
|
1439
|
+
if (entityIds.length === 0) return result;
|
|
1440
|
+
const tbl = this.t("dm_relationships");
|
|
1441
|
+
const tvp = this.createIdListTvp(entityIds);
|
|
1442
|
+
let rtFilter = "";
|
|
1443
|
+
const rtInputs = [];
|
|
1444
|
+
if (relationshipTypes && relationshipTypes.length > 0) {
|
|
1445
|
+
const placeholders = relationshipTypes.map((t, i) => {
|
|
1446
|
+
rtInputs.push({ name: `rt${i}`, value: t });
|
|
1447
|
+
return `@rt${i}`;
|
|
1448
|
+
});
|
|
1449
|
+
rtFilter = ` AND [relationship_type] IN (${placeholders.join(",")})`;
|
|
1450
|
+
}
|
|
1451
|
+
const srcBase = `SELECT * FROM ${tbl} WHERE [repository_id] = @repoId AND [source_entity_id] IN (SELECT [id] FROM @entityIds)${rtFilter}`;
|
|
1452
|
+
const tgtBase = `SELECT * FROM ${tbl} WHERE [repository_id] = @repoId AND [target_entity_id] IN (SELECT [id] FROM @entityIds)${rtFilter}`;
|
|
1453
|
+
const tgtBidi = `SELECT * FROM ${tbl} WHERE [repository_id] = @repoId AND [target_entity_id] IN (SELECT [id] FROM @entityIds) AND [bidirectional] = 1${rtFilter}`;
|
|
1454
|
+
const srcBidi = `SELECT * FROM ${tbl} WHERE [repository_id] = @repoId AND [source_entity_id] IN (SELECT [id] FROM @entityIds) AND [bidirectional] = 1${rtFilter}`;
|
|
1455
|
+
let unionQuery;
|
|
1456
|
+
switch (direction) {
|
|
1457
|
+
case "outbound":
|
|
1458
|
+
unionQuery = `${srcBase} UNION ALL ${tgtBidi}`;
|
|
1459
|
+
break;
|
|
1460
|
+
case "inbound":
|
|
1461
|
+
unionQuery = `${tgtBase} UNION ALL ${srcBidi}`;
|
|
1462
|
+
break;
|
|
1463
|
+
case "both":
|
|
1464
|
+
default:
|
|
1465
|
+
unionQuery = `${srcBase} UNION ALL ${tgtBase}`;
|
|
1466
|
+
break;
|
|
1467
|
+
}
|
|
1468
|
+
const req = pool.request().input("repoId", sql.UniqueIdentifier, repositoryId).input("entityIds", tvp);
|
|
1469
|
+
for (const p of rtInputs) req.input(p.name, sql.NVarChar, p.value);
|
|
1470
|
+
const rows = await req.query(unionQuery);
|
|
1471
|
+
const entityIdSet = new Set(entityIds);
|
|
1472
|
+
for (const row of rows.recordset) {
|
|
1473
|
+
const rel = relationshipFromRow(row);
|
|
1474
|
+
const frontierIds = [];
|
|
1475
|
+
if (entityIdSet.has(rel.sourceEntityId)) frontierIds.push(rel.sourceEntityId);
|
|
1476
|
+
if (entityIdSet.has(rel.targetEntityId)) frontierIds.push(rel.targetEntityId);
|
|
1477
|
+
for (const fid of frontierIds) {
|
|
1478
|
+
let list = result.get(fid);
|
|
1479
|
+
if (!list) {
|
|
1480
|
+
list = [];
|
|
1481
|
+
result.set(fid, list);
|
|
1482
|
+
}
|
|
1483
|
+
list.push(rel);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
return result;
|
|
1487
|
+
}
|
|
1488
|
+
async assertRepository(repositoryId) {
|
|
1489
|
+
const repo = await this.getRepository(repositoryId);
|
|
1490
|
+
if (!repo) {
|
|
1491
|
+
throw new RepositoryNotFoundError(repositoryId);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
addProvenanceInputs(req, provenance) {
|
|
1495
|
+
req.input("createdBy", sql.NVarChar, provenance.createdBy);
|
|
1496
|
+
req.input("createdByType", sql.NVarChar, provenance.createdByType);
|
|
1497
|
+
req.input("createdAt", sql.NVarChar, provenance.createdAt);
|
|
1498
|
+
req.input("createdInConversation", sql.NVarChar, provenance.createdInConversation ?? null);
|
|
1499
|
+
req.input("createdFromMessage", sql.NVarChar, provenance.createdFromMessage ?? null);
|
|
1500
|
+
req.input("modifiedBy", sql.NVarChar, provenance.modifiedBy);
|
|
1501
|
+
req.input("modifiedByType", sql.NVarChar, provenance.modifiedByType);
|
|
1502
|
+
req.input("modifiedAt", sql.NVarChar, provenance.modifiedAt);
|
|
1503
|
+
req.input("modifiedInConversation", sql.NVarChar, provenance.modifiedInConversation ?? null);
|
|
1504
|
+
req.input("modifiedFromMessage", sql.NVarChar, provenance.modifiedFromMessage ?? null);
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
// src/SqlServerSearchProvider.ts
|
|
1509
|
+
import sql2 from "mssql";
|
|
1510
|
+
import { ProviderError as ProviderError2 } from "@utaba/deep-memory";
|
|
1511
|
+
function normaliseRank(rank) {
|
|
1512
|
+
return Math.min(1, Math.max(0, rank / 1e3));
|
|
1513
|
+
}
|
|
1514
|
+
function buildHighlights(query, row) {
|
|
1515
|
+
const highlights = {};
|
|
1516
|
+
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
|
1517
|
+
const fields = [
|
|
1518
|
+
{ key: "label", hlCol: "hl_label" },
|
|
1519
|
+
{ key: "summary", hlCol: "hl_summary" },
|
|
1520
|
+
{ key: "data", hlCol: "hl_data" },
|
|
1521
|
+
{ key: "properties", hlCol: "hl_properties" }
|
|
1522
|
+
];
|
|
1523
|
+
for (const { key, hlCol } of fields) {
|
|
1524
|
+
const snippet = row[hlCol];
|
|
1525
|
+
if (!snippet) continue;
|
|
1526
|
+
const lowerSnippet = snippet.toLowerCase();
|
|
1527
|
+
for (const term of terms) {
|
|
1528
|
+
const idx = lowerSnippet.indexOf(term);
|
|
1529
|
+
if (idx !== -1) {
|
|
1530
|
+
const windowStart = Math.max(0, idx - 40);
|
|
1531
|
+
const windowEnd = Math.min(snippet.length, idx + term.length + 40);
|
|
1532
|
+
const prefix = windowStart > 0 ? "..." : "";
|
|
1533
|
+
const suffix = windowEnd < snippet.length ? "..." : "";
|
|
1534
|
+
const window = prefix + snippet.slice(windowStart, windowEnd) + suffix;
|
|
1535
|
+
if (!highlights[key]) highlights[key] = [];
|
|
1536
|
+
highlights[key].push(window);
|
|
1537
|
+
break;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
return Object.keys(highlights).length > 0 ? highlights : void 0;
|
|
1542
|
+
}
|
|
1543
|
+
var SqlServerSearchProvider = class {
|
|
1544
|
+
pool;
|
|
1545
|
+
schema;
|
|
1546
|
+
procedureCreated = false;
|
|
1547
|
+
constructor(config) {
|
|
1548
|
+
this.pool = config.pool;
|
|
1549
|
+
this.schema = config.schema ?? "dbo";
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Ensure the stored procedure exists. Called lazily on first search.
|
|
1553
|
+
* Idempotent — uses CREATE OR ALTER.
|
|
1554
|
+
*/
|
|
1555
|
+
async ensureProcedure() {
|
|
1556
|
+
if (this.procedureCreated) return;
|
|
1557
|
+
try {
|
|
1558
|
+
const ddl = getSearchProcSQL(this.schema);
|
|
1559
|
+
await this.pool.request().query(ddl);
|
|
1560
|
+
this.procedureCreated = true;
|
|
1561
|
+
} catch (err) {
|
|
1562
|
+
throw new ProviderError2(
|
|
1563
|
+
`Failed to create search stored procedure: ${err instanceof Error ? err.message : String(err)}`
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Index an entity for full-text search.
|
|
1569
|
+
*
|
|
1570
|
+
* No-op: the FT index on dm_entities uses CHANGE_TRACKING AUTO,
|
|
1571
|
+
* so SQL Server automatically indexes rows on insert/update.
|
|
1572
|
+
*/
|
|
1573
|
+
async indexEntity(_repositoryId, _entity) {
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Remove an entity from the search index.
|
|
1577
|
+
*
|
|
1578
|
+
* No-op: the FT index auto-removes when the row is deleted from dm_entities.
|
|
1579
|
+
*/
|
|
1580
|
+
async removeEntity(_repositoryId, _entityId) {
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Full-text search across entity labels, summaries, properties, and data.
|
|
1584
|
+
* Calls the dm_search_entities stored procedure using FREETEXTTABLE
|
|
1585
|
+
* for natural-language queries.
|
|
1586
|
+
*/
|
|
1587
|
+
async search(repositoryId, query, options) {
|
|
1588
|
+
await this.ensureProcedure();
|
|
1589
|
+
const limit = options?.limit ?? 20;
|
|
1590
|
+
const offset = options?.offset ?? 0;
|
|
1591
|
+
const entityTypes = options?.entityTypes;
|
|
1592
|
+
try {
|
|
1593
|
+
const request = this.pool.request();
|
|
1594
|
+
request.input("RepositoryId", sql2.UniqueIdentifier, repositoryId);
|
|
1595
|
+
request.input("Query", sql2.NVarChar(4e3), query);
|
|
1596
|
+
request.input(
|
|
1597
|
+
"EntityTypes",
|
|
1598
|
+
sql2.NVarChar(sql2.MAX),
|
|
1599
|
+
entityTypes && entityTypes.length > 0 ? JSON.stringify(entityTypes) : null
|
|
1600
|
+
);
|
|
1601
|
+
request.input("Limit", sql2.Int, limit);
|
|
1602
|
+
request.input("Offset", sql2.Int, offset);
|
|
1603
|
+
request.input("UseContains", sql2.Bit, 0);
|
|
1604
|
+
const result = await request.execute(
|
|
1605
|
+
`[${this.schema}].[dm_search_entities]`
|
|
1606
|
+
);
|
|
1607
|
+
const rows = result.recordset;
|
|
1608
|
+
const total = rows.length > 0 ? rows[0]["total_count"] : 0;
|
|
1609
|
+
const items = rows.map((row) => ({
|
|
1610
|
+
id: row["entity_id"],
|
|
1611
|
+
score: normaliseRank(row["ft_rank"]),
|
|
1612
|
+
highlights: buildHighlights(query, row)
|
|
1613
|
+
}));
|
|
1614
|
+
return {
|
|
1615
|
+
items,
|
|
1616
|
+
total,
|
|
1617
|
+
hasMore: offset + items.length < total,
|
|
1618
|
+
limit,
|
|
1619
|
+
offset
|
|
1620
|
+
};
|
|
1621
|
+
} catch (err) {
|
|
1622
|
+
throw new ProviderError2(
|
|
1623
|
+
`Full-text search failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Re-index an entire repository.
|
|
1629
|
+
*
|
|
1630
|
+
* Since the FT index is auto-maintained, this triggers a manual
|
|
1631
|
+
* population of the full-text catalog to pick up any pending changes.
|
|
1632
|
+
*/
|
|
1633
|
+
async reindexRepository(_repositoryId, _entities) {
|
|
1634
|
+
try {
|
|
1635
|
+
await this.pool.request().query(
|
|
1636
|
+
`ALTER FULLTEXT CATALOG [dm_fulltext_catalog] REORGANIZE`
|
|
1637
|
+
);
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
throw new ProviderError2(
|
|
1640
|
+
`Full-text reindex failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
export {
|
|
1646
|
+
SCHEMA_VERSION,
|
|
1647
|
+
SqlServerSearchProvider,
|
|
1648
|
+
SqlServerStorageProvider,
|
|
1649
|
+
getSchemaSQL,
|
|
1650
|
+
getSearchProcSQL
|
|
1651
|
+
};
|
|
1652
|
+
//# sourceMappingURL=index.js.map
|