@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/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