@utaba/deep-memory-storage-sqlserver 0.16.0 → 0.18.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 CHANGED
@@ -175,7 +175,7 @@
175
175
 
176
176
  END OF TERMS AND CONDITIONS
177
177
 
178
- Copyright 2024 Utaba AI Ltd
178
+ Copyright 2024 Tim Wheeler
179
179
 
180
180
  Licensed under the Apache License, Version 2.0 (the "License");
181
181
  you may not use this file except in compliance with the License.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utaba/deep-memory-storage-sqlserver
2
2
 
3
- SQL Server storage provider for [@utaba/deep-memory](../core/README.md).
3
+ SQL Server storage provider for [`@utaba/deep-memory`](https://www.npmjs.com/package/@utaba/deep-memory). Provides persistent, multi-tenant graph storage backed by SQL Server (2016+).
4
4
 
5
5
  ## Installation
6
6
 
@@ -8,7 +8,9 @@ SQL Server storage provider for [@utaba/deep-memory](../core/README.md).
8
8
  pnpm add @utaba/deep-memory @utaba/deep-memory-storage-sqlserver
9
9
  ```
10
10
 
11
- ## Usage
11
+ **Runtime dependency:** [`mssql`](https://www.npmjs.com/package/mssql) (the Node.js SQL Server driver).
12
+
13
+ ## Quick Start
12
14
 
13
15
  ```typescript
14
16
  import { DeepMemory } from '@utaba/deep-memory';
@@ -17,13 +19,12 @@ import { SqlServerStorageProvider } from '@utaba/deep-memory-storage-sqlserver';
17
19
  const provider = new SqlServerStorageProvider({
18
20
  connection: {
19
21
  server: 'localhost',
20
- port: 1434,
22
+ port: 1435,
21
23
  database: 'deep-memory',
22
24
  user: 'sa',
23
25
  password: 'YourPassword',
24
26
  options: { trustServerCertificate: true },
25
27
  },
26
- schema: 'dbo', // optional, default 'dbo'
27
28
  });
28
29
 
29
30
  const dm = new DeepMemory({ storage: provider });
@@ -32,17 +33,44 @@ const dm = new DeepMemory({ storage: provider });
32
33
  await dm.ensureSchema();
33
34
  ```
34
35
 
35
- You can also pass a connection string:
36
+ ## Configuration
37
+
38
+ ### `SqlServerStorageProviderConfig`
39
+
40
+ | Option | Type | Default | Description |
41
+ |--------|------|---------|-------------|
42
+ | `connection` | `sql.config \| sql.ConnectionPool` | *required* | Either an `mssql` config object, a connection-string config, or an existing `ConnectionPool` instance. |
43
+ | `schema` | `string` | `'dbo'` | SQL Server schema name. Must already exist in the database. |
44
+
45
+ ### Connection options
46
+
47
+ **Config object:**
36
48
 
37
49
  ```typescript
38
50
  const provider = new SqlServerStorageProvider({
39
51
  connection: {
40
- connectionString: 'Server=localhost,1434;Database=deep-memory;User Id=sa;Password=YourPassword;TrustServerCertificate=true',
52
+ server: 'localhost',
53
+ port: 1435,
54
+ database: 'deep-memory',
55
+ user: 'sa',
56
+ password: 'YourPassword',
57
+ options: { trustServerCertificate: true },
41
58
  },
42
59
  });
43
60
  ```
44
61
 
45
- Or an existing `mssql.ConnectionPool`:
62
+ **Connection string:**
63
+
64
+ ```typescript
65
+ const provider = new SqlServerStorageProvider({
66
+ connection: {
67
+ connectionString:
68
+ 'Server=localhost,1435;Database=deep-memory;User Id=sa;Password=YourPassword;TrustServerCertificate=true',
69
+ },
70
+ });
71
+ ```
72
+
73
+ **Existing connection pool** (shared with your application):
46
74
 
47
75
  ```typescript
48
76
  import sql from 'mssql';
@@ -53,73 +81,220 @@ await pool.connect();
53
81
  const provider = new SqlServerStorageProvider({ connection: pool });
54
82
  ```
55
83
 
84
+ When you pass an existing pool, the provider will not close it on `dispose()` — your application retains ownership.
85
+
86
+ ## Lifecycle
87
+
88
+ ```typescript
89
+ // 1. Create the provider
90
+ const provider = new SqlServerStorageProvider({ connection: config });
91
+
92
+ // 2. Initialise — connects to SQL Server
93
+ await provider.initialise();
94
+
95
+ // 3. Use via DeepMemory
96
+ const dm = new DeepMemory({ storage: provider });
97
+ const repo = await dm.createRepository({ ... });
98
+
99
+ // 4. Dispose — closes the connection pool (if provider owns it)
100
+ await provider.dispose();
101
+ ```
102
+
56
103
  ## Database Schema
57
104
 
58
- ### Automatic creation
105
+ ### Table overview
106
+
107
+ All tables use the `dm_` prefix to avoid collisions when sharing a database with other applications.
108
+
109
+ | Table | Purpose |
110
+ |-------|---------|
111
+ | `dm_meta` | Schema version tracking (single row) |
112
+ | `dm_repositories` | Repository definitions and governance config |
113
+ | `dm_vocabularies` | One vocabulary JSON document per repository |
114
+ | `dm_vocabulary_change_log` | Audit trail for vocabulary changes |
115
+ | `dm_entities` | Graph nodes with typed properties, optional data/embeddings, and provenance |
116
+ | `dm_relationships` | Graph edges with typed properties, directionality, and provenance |
117
+
118
+ ### Entity-relationship diagram
119
+
120
+ ```
121
+ dm_repositories
122
+ PK: repository_id
123
+
124
+ ├──< dm_vocabularies (1:1)
125
+ │ PK/FK: repository_id
126
+
127
+ ├──< dm_vocabulary_change_log (1:N)
128
+ │ PK: (repository_id, change_id)
129
+ │ FK: repository_id → dm_repositories
130
+
131
+ ├──< dm_entities (1:N)
132
+ │ PK: (repository_id, entity_id)
133
+ │ FK: repository_id → dm_repositories
134
+ │ IX: (repository_id, entity_type)
135
+ │ IX: (repository_id, label)
136
+ │ IX: (repository_id, modified_at DESC)
137
+
138
+ └──< dm_relationships (1:N)
139
+ PK: (repository_id, relationship_id)
140
+ FK: repository_id → dm_repositories (CASCADE DELETE)
141
+ FK: (repository_id, source_entity_id) → dm_entities
142
+ FK: (repository_id, target_entity_id) → dm_entities
143
+ IX: (repository_id, source_entity_id) INCLUDE (relationship_type, target_entity_id, bidirectional)
144
+ IX: (repository_id, target_entity_id) INCLUDE (relationship_type, source_entity_id, bidirectional)
145
+ IX: (repository_id, relationship_type)
146
+ ```
147
+
148
+ ### Naming conventions
149
+
150
+ | Element | Convention | Example |
151
+ |---------|-----------|---------|
152
+ | Tables | `dm_` prefix + plural snake_case | `dm_entities` |
153
+ | Columns | snake_case | `entity_type`, `created_at` |
154
+ | Primary keys | `pk_{table}` | `pk_dm_entities` |
155
+ | Foreign keys | `fk_{table}_{referenced_table}` | `fk_dm_relationships_entities` |
156
+ | Indexes | `ix_{table}_{columns}` | `ix_dm_entities_type` |
157
+
158
+ ### Multi-tenancy
59
159
 
60
- Call `ensureSchema()` once at startup or deployment to create tables if they don't exist. This is not called automatically the consuming application decides when to run it.
160
+ All data is scoped by `repository_id`. Each repository is an isolated knowledge graph entities, relationships, vocabulary, and change log are all partitioned by this key. This supports multi-tenant deployments where each agent or domain has its own repository within a shared database.
61
161
 
62
- ### Manual creation
162
+ ### Key size limits
63
163
 
64
- If you manage your own migrations, export the DDL and run it yourself.
164
+ Composite primary keys are sized to stay within SQL Server's 900-byte clustered index limit:
65
165
 
66
- **From code:**
166
+ | Column | Max Length | Bytes (NVARCHAR) |
167
+ |--------|-----------|-----------------|
168
+ | `repository_id` | 128 chars | 256 bytes |
169
+ | `entity_id` | 300 chars | 600 bytes |
170
+ | `relationship_id` | 300 chars | 600 bytes |
171
+ | `change_id` | 128 chars | 256 bytes |
172
+
173
+ Largest PK: `(repository_id, entity_id)` = 856 bytes (under 900 limit).
174
+
175
+ ### Cascade deletes
176
+
177
+ Deleting a repository cascades to vocabularies, vocabulary change log, entities, and relationships. Entity deletion explicitly removes related relationships before removing the entity.
178
+
179
+ ## Schema Management
180
+
181
+ ### Automatic (default)
182
+
183
+ Call `dm.ensureSchema()` once at startup or deployment. The provider checks for existing tables and creates them if missing. Schema version is tracked in `dm_meta`. This is **not** called automatically — the consuming application decides when to run it.
184
+
185
+ ### Manual
186
+
187
+ For production environments with managed migrations, export the DDL and run it yourself:
67
188
 
68
189
  ```typescript
69
- import { getSchemaSQL } from '@utaba/deep-memory-storage-sqlserver';
190
+ import { getSchemaSQL, SCHEMA_VERSION } from '@utaba/deep-memory-storage-sqlserver';
70
191
 
71
- // Default schema (dbo)
192
+ // Get DDL for default schema (dbo)
72
193
  const ddl = getSchemaSQL();
73
194
 
74
- // Custom schema
195
+ // Get DDL for a custom schema
75
196
  const ddl = getSchemaSQL('my_schema');
76
197
  ```
77
198
 
78
- **From the command line (after building):**
199
+ From the command line:
79
200
 
80
201
  ```bash
81
- cd packages/storage-sqlserver
82
- node -e "import('./dist/index.js').then(m => console.log(m.getSchemaSQL()))"
202
+ node -e "import('@utaba/deep-memory-storage-sqlserver').then(m => console.log(m.getSchemaSQL()))" > schema.sql
83
203
  ```
84
204
 
85
- **Pipe to a file for SSMS:**
205
+ ### Static schema file
86
206
 
87
- ```bash
88
- node -e "import('./dist/index.js').then(m => console.log(m.getSchemaSQL()))" > schema.sql
89
- ```
207
+ A pre-generated copy of the full DDL (schema + search procedure) lives at `schemas/deep-memory-schema-v1.0.sql` inside the package. It is generated from runtime code and must never be hand-edited.
90
208
 
91
- ### Schema versioning
209
+ ### Version checking
92
210
 
93
- A `dm_meta` table tracks the schema version. On `initialise()`, the provider checks this version and warns if the database is newer than the provider. The provider does not auto-migrate — update the schema manually using the DDL output from `getSchemaSQL()`.
211
+ On startup, the provider reads `schema_version` from `dm_meta`:
94
212
 
95
- ### Tables
213
+ - **Same version** — no action needed
214
+ - **Database newer than provider** — throws `ProviderError` (update the package)
215
+ - **Database older than provider** — future migrations will run here; currently creates from scratch
96
216
 
97
- All tables are prefixed with `dm_` to avoid collisions when co-located with other schemas.
217
+ ## Query Capabilities
98
218
 
99
- | Table | Description |
100
- |-------|-------------|
101
- | `dm_meta` | Schema version tracking |
102
- | `dm_repositories` | Repository definitions and governance config |
103
- | `dm_vocabularies` | Vocabulary JSON document per repository |
104
- | `dm_vocabulary_change_log` | Vocabulary change audit trail |
105
- | `dm_entities` | Graph nodes with provenance and optional embeddings |
106
- | `dm_relationships` | Graph edges with provenance |
219
+ ### Entity search
107
220
 
108
- ### Naming conventions
221
+ `findEntities()` supports:
222
+
223
+ - **Type filter** — restrict to specific entity types
224
+ - **Text search** — case-insensitive `LIKE` on label and summary columns
225
+ - **Property filter** — exact match via `JSON_VALUE()` on the JSON properties column
226
+ - **Pagination** — `OFFSET` / `FETCH NEXT` with total count
227
+
228
+ ### Relationship queries
229
+
230
+ `getEntityRelationships()` supports:
231
+
232
+ - **Direction** — `outbound`, `inbound`, or `both` (default)
233
+ - **Relationship type filter** — restrict to specific types
234
+ - **Bidirectional handling** — bidirectional relationships appear in both directions
235
+ - **Pagination** — same `OFFSET` / `FETCH NEXT` pattern
236
+
237
+ ### Graph traversal
238
+
239
+ - **`exploreNeighbourhood()`** — multi-hop BFS exploration from a centre entity, with depth, direction, entity type, and relationship type filters. Results are grouped by relationship type per layer.
240
+ - **`findPaths()`** — BFS path finding between two entities, with max depth and relationship type filters. Returns all paths up to the configured limit.
241
+
242
+ ### Timeline
109
243
 
110
- - Tables: plural snake_case (`dm_entities`, `dm_relationships`)
111
- - Columns: snake_case (`entity_id`, `created_at`)
112
- - Primary keys: `pk_{table}`
113
- - Foreign keys: `fk_{table}_{referenced_table}`
114
- - Indexes: `ix_{table}_{columns}`
244
+ `getTimeline()` returns creation and modification events for an entity plus its relationship creation events, with optional time range and event type filters.
245
+
246
+ ## Bulk Operations
247
+
248
+ ### Export
249
+
250
+ `exportAll()` returns an async iterable of chunks (batches of 100), first entities then relationships. Suitable for streaming large repositories without loading everything into memory.
251
+
252
+ ```typescript
253
+ for await (const chunk of provider.exportAll(repositoryId)) {
254
+ // chunk.type: 'entities' | 'relationships'
255
+ // chunk.data: StoredEntity[] | StoredRelationship[]
256
+ // chunk.isLast: boolean
257
+ }
258
+ ```
259
+
260
+ ### Import
261
+
262
+ `importBulk()` uses SQL Server `MERGE` statements for upsert semantics — existing records are updated, new records are inserted. Returns a count of imported entities/relationships and any errors.
263
+
264
+ ## Error Handling
265
+
266
+ All errors are typed using the `@utaba/deep-memory` error hierarchy:
267
+
268
+ | Error | When |
269
+ |-------|------|
270
+ | `ProviderError` | Connection failure, schema issues, SQL errors |
271
+ | `RepositoryNotFoundError` | Repository ID doesn't exist |
272
+ | `DuplicateRepositoryError` | Repository ID already exists |
273
+ | `EntityNotFoundError` | Entity ID doesn't exist in repository |
274
+ | `DuplicateEntityError` | Entity ID already exists in repository |
275
+ | `RelationshipNotFoundError` | Relationship ID doesn't exist |
276
+ | `DuplicateRelationshipError` | Relationship ID already exists |
115
277
 
116
278
  ## Testing
117
279
 
118
- The conformance test suite requires a running SQL Server instance:
280
+ The conformance test suite requires a running SQL Server instance. Set the connection string via environment variable:
119
281
 
120
282
  ```bash
121
- MSSQL_CONNECTION_STRING="Server=localhost,1434;Database=deep-memory;User Id=sa;Password=YourPassword;TrustServerCertificate=true" \
283
+ MSSQL_CONNECTION_STRING="Server=localhost,1435;Database=deep-memory;User Id=sa;Password=YourPassword;TrustServerCertificate=true" \
122
284
  pnpm --filter @utaba/deep-memory-storage-sqlserver test
123
285
  ```
124
286
 
125
287
  Without `MSSQL_CONNECTION_STRING`, tests are skipped.
288
+
289
+ ## Exports
290
+
291
+ ```typescript
292
+ // Provider class
293
+ import { SqlServerStorageProvider } from '@utaba/deep-memory-storage-sqlserver';
294
+
295
+ // Config type
296
+ import type { SqlServerStorageProviderConfig } from '@utaba/deep-memory-storage-sqlserver';
297
+
298
+ // Schema utilities
299
+ import { getSchemaSQL, SCHEMA_VERSION } from '@utaba/deep-memory-storage-sqlserver';
300
+ ```
package/dist/index.cjs CHANGED
@@ -917,33 +917,36 @@ var SqlServerStorageProvider = class {
917
917
  `);
918
918
  return entity;
919
919
  }
920
- async getEntity(repositoryId, entityId) {
920
+ async getEntity(repositoryId, entityId, options) {
921
921
  const pool = this.getPool();
922
+ const cols = options?.loadEmbeddings ? ENTITY_COLS_FULL : ENTITY_COLS_LIGHT;
922
923
  const result = await pool.request().input("repoId", import_mssql.default.UniqueIdentifier, repositoryId).input("entityId", import_mssql.default.NVarChar, entityId).query(
923
- `SELECT ${ENTITY_COLS_LIGHT} FROM ${this.t("dm_entities")}
924
+ `SELECT ${cols} FROM ${this.t("dm_entities")}
924
925
  WHERE [repository_id] = @repoId AND [entity_id] = @entityId`
925
926
  );
926
927
  const row = result.recordset[0];
927
928
  if (!row) return null;
928
929
  return entityFromRow(row);
929
930
  }
930
- async getEntityBySlug(repositoryId, slug) {
931
+ async getEntityBySlug(repositoryId, slug, options) {
931
932
  const pool = this.getPool();
933
+ const cols = options?.loadEmbeddings ? ENTITY_COLS_FULL : ENTITY_COLS_LIGHT;
932
934
  const result = await pool.request().input("repoId", import_mssql.default.UniqueIdentifier, repositoryId).input("slug", import_mssql.default.NVarChar, slug).query(
933
- `SELECT ${ENTITY_COLS_LIGHT} FROM ${this.t("dm_entities")}
935
+ `SELECT ${cols} FROM ${this.t("dm_entities")}
934
936
  WHERE [repository_id] = @repoId AND [slug] = @slug`
935
937
  );
936
938
  const row = result.recordset[0];
937
939
  if (!row) return null;
938
940
  return entityFromRow(row);
939
941
  }
940
- async getEntities(repositoryId, entityIds) {
942
+ async getEntities(repositoryId, entityIds, options) {
941
943
  const pool = this.getPool();
942
944
  const result = /* @__PURE__ */ new Map();
943
945
  if (entityIds.length === 0) return result;
946
+ const cols = options?.loadEmbeddings ? ENTITY_COLS_FULL : ENTITY_COLS_LIGHT;
944
947
  const tvp = this.createIdListTvp(entityIds);
945
948
  const rows = await pool.request().input("repoId", import_mssql.default.UniqueIdentifier, repositoryId).input("entityIds", tvp).query(
946
- `SELECT ${ENTITY_COLS_LIGHT} FROM ${this.t("dm_entities")}
949
+ `SELECT ${cols} FROM ${this.t("dm_entities")}
947
950
  WHERE [repository_id] = @repoId
948
951
  AND [entity_id] IN (SELECT [id] FROM @entityIds)`
949
952
  );
@@ -1055,7 +1058,7 @@ var SqlServerStorageProvider = class {
1055
1058
  const deletedEntities = entResult.rowsAffected[0] ?? 0;
1056
1059
  return { deletedEntities, deletedRelationships };
1057
1060
  }
1058
- async findEntities(repositoryId, query) {
1061
+ async findEntities(repositoryId, query, options) {
1059
1062
  const pool = this.getPool();
1060
1063
  const req = pool.request().input("repoId", import_mssql.default.UniqueIdentifier, repositoryId);
1061
1064
  const conditions = ["[repository_id] = @repoId"];
@@ -1115,8 +1118,9 @@ var SqlServerStorageProvider = class {
1115
1118
  const total = totalResult.recordset[0]?.cnt ?? 0;
1116
1119
  req.input("limit", import_mssql.default.Int, query.limit);
1117
1120
  req.input("offset", import_mssql.default.Int, query.offset);
1121
+ const cols = options?.loadEmbeddings ? ENTITY_COLS_FULL : ENTITY_COLS_LIGHT;
1118
1122
  const result = await req.query(
1119
- `SELECT ${ENTITY_COLS_LIGHT} FROM ${this.t("dm_entities")}
1123
+ `SELECT ${cols} FROM ${this.t("dm_entities")}
1120
1124
  WHERE ${where}
1121
1125
  ORDER BY [entity_id]
1122
1126
  OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`
@@ -1236,9 +1240,9 @@ var SqlServerStorageProvider = class {
1236
1240
  const tgtBidi = `SELECT * FROM ${tbl} WHERE [repository_id] = @repoId AND [target_entity_id] = @entityId AND [bidirectional] = 1${rtFilter}`;
1237
1241
  const srcBidi = `SELECT * FROM ${tbl} WHERE [repository_id] = @repoId AND [source_entity_id] = @entityId AND [bidirectional] = 1${rtFilter}`;
1238
1242
  switch (direction) {
1239
- case "outbound":
1243
+ case "out":
1240
1244
  return `${srcBase} UNION ALL ${tgtBidi} AND [source_entity_id] <> @entityId`;
1241
- case "inbound":
1245
+ case "in":
1242
1246
  return `${tgtBase} UNION ALL ${srcBidi} AND [target_entity_id] <> @entityId`;
1243
1247
  case "both":
1244
1248
  default:
@@ -1671,10 +1675,10 @@ var SqlServerStorageProvider = class {
1671
1675
  const srcBidi = `SELECT * FROM ${tbl} WHERE [repository_id] = @repoId AND [source_entity_id] IN (SELECT [id] FROM @entityIds) AND [bidirectional] = 1${rtFilter}`;
1672
1676
  let unionQuery;
1673
1677
  switch (direction) {
1674
- case "outbound":
1678
+ case "out":
1675
1679
  unionQuery = `${srcBase} UNION ALL ${tgtBidi}`;
1676
1680
  break;
1677
- case "inbound":
1681
+ case "in":
1678
1682
  unionQuery = `${tgtBase} UNION ALL ${srcBidi}`;
1679
1683
  break;
1680
1684
  case "both":