@twelvehart/supermemory-runtime 1.0.0-next.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/.env.example +57 -0
- package/README.md +374 -0
- package/dist/index.js +189 -0
- package/dist/mcp/index.js +1132 -0
- package/docker-compose.prod.yml +91 -0
- package/docker-compose.yml +358 -0
- package/drizzle/0000_dapper_the_professor.sql +159 -0
- package/drizzle/0001_api_keys.sql +51 -0
- package/drizzle/meta/0000_snapshot.json +1532 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +20 -0
- package/package.json +114 -0
- package/scripts/add-extraction-job.ts +122 -0
- package/scripts/benchmark-pgvector.ts +122 -0
- package/scripts/bootstrap.sh +209 -0
- package/scripts/check-runtime-pack.ts +111 -0
- package/scripts/claude-mcp-config.ts +336 -0
- package/scripts/docker-entrypoint.sh +183 -0
- package/scripts/doctor.ts +377 -0
- package/scripts/init-db.sql +33 -0
- package/scripts/install.sh +1110 -0
- package/scripts/mcp-setup.ts +271 -0
- package/scripts/migrations/001_create_pgvector_extension.sql +31 -0
- package/scripts/migrations/002_create_memory_embeddings_table.sql +75 -0
- package/scripts/migrations/003_create_hnsw_index.sql +94 -0
- package/scripts/migrations/004_create_memory_embeddings_standalone.sql +70 -0
- package/scripts/migrations/005_create_chunks_table.sql +95 -0
- package/scripts/migrations/006_create_processing_queue.sql +45 -0
- package/scripts/migrations/generate_test_data.sql +42 -0
- package/scripts/migrations/phase1_comprehensive_test.sql +204 -0
- package/scripts/migrations/run_migrations.sh +286 -0
- package/scripts/migrations/test_hnsw_index.sql +255 -0
- package/scripts/pre-commit-secrets +282 -0
- package/scripts/run-extraction-worker.ts +46 -0
- package/scripts/run-phase1-tests.sh +291 -0
- package/scripts/setup.ts +222 -0
- package/scripts/smoke-install.sh +12 -0
- package/scripts/test-health-endpoint.sh +328 -0
- package/src/api/index.ts +2 -0
- package/src/api/middleware/auth.ts +80 -0
- package/src/api/middleware/csrf.ts +308 -0
- package/src/api/middleware/errorHandler.ts +166 -0
- package/src/api/middleware/rateLimit.ts +360 -0
- package/src/api/middleware/validation.ts +514 -0
- package/src/api/routes/documents.ts +286 -0
- package/src/api/routes/profiles.ts +237 -0
- package/src/api/routes/search.ts +71 -0
- package/src/api/stores/index.ts +58 -0
- package/src/config/bootstrap-env.ts +3 -0
- package/src/config/env.ts +71 -0
- package/src/config/feature-flags.ts +25 -0
- package/src/config/index.ts +140 -0
- package/src/config/secrets.config.ts +291 -0
- package/src/db/client.ts +92 -0
- package/src/db/index.ts +73 -0
- package/src/db/postgres.ts +72 -0
- package/src/db/schema/chunks.schema.ts +31 -0
- package/src/db/schema/containers.schema.ts +46 -0
- package/src/db/schema/documents.schema.ts +49 -0
- package/src/db/schema/embeddings.schema.ts +32 -0
- package/src/db/schema/index.ts +11 -0
- package/src/db/schema/memories.schema.ts +72 -0
- package/src/db/schema/profiles.schema.ts +34 -0
- package/src/db/schema/queue.schema.ts +59 -0
- package/src/db/schema/relationships.schema.ts +42 -0
- package/src/db/schema.ts +223 -0
- package/src/db/worker-connection.ts +47 -0
- package/src/index.ts +235 -0
- package/src/mcp/CLAUDE.md +1 -0
- package/src/mcp/index.ts +1380 -0
- package/src/mcp/legacyState.ts +22 -0
- package/src/mcp/rateLimit.ts +358 -0
- package/src/mcp/resources.ts +309 -0
- package/src/mcp/results.ts +104 -0
- package/src/mcp/tools.ts +401 -0
- package/src/queues/config.ts +119 -0
- package/src/queues/index.ts +289 -0
- package/src/sdk/client.ts +225 -0
- package/src/sdk/errors.ts +266 -0
- package/src/sdk/http.ts +560 -0
- package/src/sdk/index.ts +244 -0
- package/src/sdk/resources/base.ts +65 -0
- package/src/sdk/resources/connections.ts +204 -0
- package/src/sdk/resources/documents.ts +163 -0
- package/src/sdk/resources/index.ts +10 -0
- package/src/sdk/resources/memories.ts +150 -0
- package/src/sdk/resources/search.ts +60 -0
- package/src/sdk/resources/settings.ts +36 -0
- package/src/sdk/types.ts +674 -0
- package/src/services/chunking/index.ts +451 -0
- package/src/services/chunking.service.ts +650 -0
- package/src/services/csrf.service.ts +252 -0
- package/src/services/documents.repository.ts +219 -0
- package/src/services/documents.service.ts +191 -0
- package/src/services/embedding.service.ts +404 -0
- package/src/services/extraction.service.ts +300 -0
- package/src/services/extractors/code.extractor.ts +451 -0
- package/src/services/extractors/index.ts +9 -0
- package/src/services/extractors/markdown.extractor.ts +461 -0
- package/src/services/extractors/pdf.extractor.ts +315 -0
- package/src/services/extractors/text.extractor.ts +118 -0
- package/src/services/extractors/url.extractor.ts +243 -0
- package/src/services/index.ts +235 -0
- package/src/services/ingestion.service.ts +177 -0
- package/src/services/llm/anthropic.ts +400 -0
- package/src/services/llm/base.ts +460 -0
- package/src/services/llm/contradiction-detector.service.ts +526 -0
- package/src/services/llm/heuristics.ts +148 -0
- package/src/services/llm/index.ts +309 -0
- package/src/services/llm/memory-classifier.service.ts +383 -0
- package/src/services/llm/memory-extension-detector.service.ts +523 -0
- package/src/services/llm/mock.ts +470 -0
- package/src/services/llm/openai.ts +398 -0
- package/src/services/llm/prompts.ts +438 -0
- package/src/services/llm/types.ts +373 -0
- package/src/services/memory.repository.ts +1769 -0
- package/src/services/memory.service.ts +1338 -0
- package/src/services/memory.types.ts +234 -0
- package/src/services/persistence/index.ts +295 -0
- package/src/services/pipeline.service.ts +509 -0
- package/src/services/profile.repository.ts +436 -0
- package/src/services/profile.service.ts +560 -0
- package/src/services/profile.types.ts +270 -0
- package/src/services/relationships/detector.ts +1128 -0
- package/src/services/relationships/index.ts +268 -0
- package/src/services/relationships/memory-integration.ts +459 -0
- package/src/services/relationships/strategies.ts +132 -0
- package/src/services/relationships/types.ts +370 -0
- package/src/services/search.service.ts +761 -0
- package/src/services/search.types.ts +220 -0
- package/src/services/secrets.service.ts +384 -0
- package/src/services/vectorstore/base.ts +327 -0
- package/src/services/vectorstore/index.ts +444 -0
- package/src/services/vectorstore/memory.ts +286 -0
- package/src/services/vectorstore/migration.ts +295 -0
- package/src/services/vectorstore/mock.ts +403 -0
- package/src/services/vectorstore/pgvector.ts +695 -0
- package/src/services/vectorstore/types.ts +247 -0
- package/src/startup.ts +389 -0
- package/src/types/api.types.ts +193 -0
- package/src/types/document.types.ts +103 -0
- package/src/types/index.ts +241 -0
- package/src/types/profile.base.ts +133 -0
- package/src/utils/errors.ts +447 -0
- package/src/utils/id.ts +15 -0
- package/src/utils/index.ts +101 -0
- package/src/utils/logger.ts +313 -0
- package/src/utils/sanitization.ts +501 -0
- package/src/utils/secret-validation.ts +273 -0
- package/src/utils/synonyms.ts +188 -0
- package/src/utils/validation.ts +581 -0
- package/src/workers/chunking.worker.ts +242 -0
- package/src/workers/embedding.worker.ts +358 -0
- package/src/workers/extraction.worker.ts +346 -0
- package/src/workers/indexing.worker.ts +505 -0
- package/tsconfig.json +38 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto'
|
|
2
|
+
import { getLogger } from '../utils/logger.js'
|
|
3
|
+
|
|
4
|
+
const logger = getLogger('csrf-service')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CSRF Token Service
|
|
8
|
+
*
|
|
9
|
+
* Provides cryptographically secure CSRF token generation, signing, and validation
|
|
10
|
+
* using HMAC-SHA256 for token integrity.
|
|
11
|
+
*
|
|
12
|
+
* Security features:
|
|
13
|
+
* - Crypto.randomBytes(32) for token generation
|
|
14
|
+
* - HMAC-SHA256 for token signing
|
|
15
|
+
* - Constant-time comparison to prevent timing attacks
|
|
16
|
+
* - Token rotation support
|
|
17
|
+
* - Session association
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface CsrfToken {
|
|
21
|
+
token: string
|
|
22
|
+
signature: string
|
|
23
|
+
expiresAt: number
|
|
24
|
+
sessionId?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CsrfConfig {
|
|
28
|
+
secret: string
|
|
29
|
+
tokenLength: number
|
|
30
|
+
expirationMs: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Maximum number of tokens to store before evicting oldest */
|
|
34
|
+
const MAX_TOKENS = 10000
|
|
35
|
+
|
|
36
|
+
export class CsrfService {
|
|
37
|
+
private readonly secret: Buffer
|
|
38
|
+
private readonly tokenLength: number
|
|
39
|
+
private readonly expirationMs: number
|
|
40
|
+
private readonly tokenStore: Map<string, CsrfToken>
|
|
41
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null
|
|
42
|
+
|
|
43
|
+
constructor(config: CsrfConfig) {
|
|
44
|
+
// Ensure secret is at least 32 bytes for security
|
|
45
|
+
if (config.secret.length < 32) {
|
|
46
|
+
throw new Error('CSRF secret must be at least 32 characters')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.secret = Buffer.from(config.secret, 'utf8')
|
|
50
|
+
this.tokenLength = config.tokenLength
|
|
51
|
+
this.expirationMs = config.expirationMs
|
|
52
|
+
this.tokenStore = new Map()
|
|
53
|
+
|
|
54
|
+
// Cleanup expired tokens periodically
|
|
55
|
+
this.cleanupTimer = setInterval(() => this.cleanupExpiredTokens(), 60000) // Every minute
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generate a cryptographically secure CSRF token.
|
|
60
|
+
*
|
|
61
|
+
* @param sessionId - Optional session identifier for token association
|
|
62
|
+
* @returns CSRF token with signature and expiration
|
|
63
|
+
*/
|
|
64
|
+
generateToken(sessionId?: string): CsrfToken {
|
|
65
|
+
// Generate random token using crypto.randomBytes
|
|
66
|
+
const tokenBytes = crypto.randomBytes(this.tokenLength)
|
|
67
|
+
const token = tokenBytes.toString('base64url')
|
|
68
|
+
|
|
69
|
+
// Create expiration timestamp
|
|
70
|
+
const expiresAt = Date.now() + this.expirationMs
|
|
71
|
+
|
|
72
|
+
// Sign the token using HMAC-SHA256
|
|
73
|
+
const signature = this.signToken(token, expiresAt, sessionId)
|
|
74
|
+
|
|
75
|
+
const csrfToken: CsrfToken = {
|
|
76
|
+
token,
|
|
77
|
+
signature,
|
|
78
|
+
expiresAt,
|
|
79
|
+
sessionId,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Enforce token store limit to prevent DoS
|
|
83
|
+
if (this.tokenStore.size >= MAX_TOKENS) {
|
|
84
|
+
// LRU eviction: remove oldest token (first entry in Map)
|
|
85
|
+
const firstKey = this.tokenStore.keys().next().value
|
|
86
|
+
if (firstKey) {
|
|
87
|
+
this.tokenStore.delete(firstKey)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Store token for validation
|
|
92
|
+
this.tokenStore.set(token, csrfToken)
|
|
93
|
+
|
|
94
|
+
return csrfToken
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate a CSRF token using constant-time comparison.
|
|
99
|
+
*
|
|
100
|
+
* @param token - Token to validate
|
|
101
|
+
* @param signature - Expected signature
|
|
102
|
+
* @param sessionId - Optional session identifier for validation
|
|
103
|
+
* @returns True if token is valid and not expired
|
|
104
|
+
*/
|
|
105
|
+
validateToken(token: string, signature: string, sessionId?: string): boolean {
|
|
106
|
+
// Retrieve stored token
|
|
107
|
+
const storedToken = this.tokenStore.get(token)
|
|
108
|
+
|
|
109
|
+
if (!storedToken) {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Check expiration
|
|
114
|
+
if (Date.now() > storedToken.expiresAt) {
|
|
115
|
+
this.tokenStore.delete(token)
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Verify session association if provided
|
|
120
|
+
if (sessionId && storedToken.sessionId !== sessionId) {
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Verify signature using constant-time comparison
|
|
125
|
+
const expectedSignature = this.signToken(token, storedToken.expiresAt, storedToken.sessionId)
|
|
126
|
+
|
|
127
|
+
return this.constantTimeCompare(signature, expectedSignature)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Rotate a token (invalidate old, generate new).
|
|
132
|
+
*
|
|
133
|
+
* @param oldToken - Token to invalidate
|
|
134
|
+
* @param sessionId - Optional session identifier
|
|
135
|
+
* @returns New CSRF token
|
|
136
|
+
*/
|
|
137
|
+
rotateToken(oldToken: string, sessionId?: string): CsrfToken {
|
|
138
|
+
// Invalidate old token
|
|
139
|
+
this.tokenStore.delete(oldToken)
|
|
140
|
+
|
|
141
|
+
// Generate new token
|
|
142
|
+
return this.generateToken(sessionId)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Sign a token using HMAC-SHA256.
|
|
147
|
+
*
|
|
148
|
+
* @param token - Token to sign
|
|
149
|
+
* @param expiresAt - Expiration timestamp
|
|
150
|
+
* @param sessionId - Optional session identifier
|
|
151
|
+
* @returns HMAC signature
|
|
152
|
+
*/
|
|
153
|
+
private signToken(token: string, expiresAt: number, sessionId?: string): string {
|
|
154
|
+
const hmac = crypto.createHmac('sha256', this.secret)
|
|
155
|
+
|
|
156
|
+
// Include token, expiration, and optional session in signature
|
|
157
|
+
hmac.update(token)
|
|
158
|
+
hmac.update(String(expiresAt))
|
|
159
|
+
|
|
160
|
+
if (sessionId) {
|
|
161
|
+
hmac.update(sessionId)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return hmac.digest('base64url')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Constant-time string comparison to prevent timing attacks.
|
|
169
|
+
*
|
|
170
|
+
* @param a - First string
|
|
171
|
+
* @param b - Second string
|
|
172
|
+
* @returns True if strings are equal
|
|
173
|
+
*/
|
|
174
|
+
private constantTimeCompare(a: string, b: string): boolean {
|
|
175
|
+
if (a.length !== b.length) return false
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
return crypto.timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'))
|
|
179
|
+
} catch {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private cleanupExpiredTokens(): void {
|
|
185
|
+
const now = Date.now()
|
|
186
|
+
let cleanedCount = 0
|
|
187
|
+
|
|
188
|
+
for (const [token, csrfToken] of this.tokenStore) {
|
|
189
|
+
if (now > csrfToken.expiresAt) {
|
|
190
|
+
this.tokenStore.delete(token)
|
|
191
|
+
cleanedCount++
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (cleanedCount > 0) {
|
|
196
|
+
logger.debug('Cleaned up expired tokens', { cleanedCount })
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Get token count (for monitoring) */
|
|
201
|
+
getTokenCount(): number {
|
|
202
|
+
return this.tokenStore.size
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Clear all tokens (for testing) */
|
|
206
|
+
clearTokens(): void {
|
|
207
|
+
this.tokenStore.clear()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Release resources */
|
|
211
|
+
destroy(): void {
|
|
212
|
+
if (this.cleanupTimer) {
|
|
213
|
+
clearInterval(this.cleanupTimer)
|
|
214
|
+
this.cleanupTimer = null
|
|
215
|
+
}
|
|
216
|
+
this.tokenStore.clear()
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Create a CSRF service instance with default or custom configuration */
|
|
221
|
+
export function createCsrfService(config?: Partial<CsrfConfig>): CsrfService {
|
|
222
|
+
const secret = config?.secret || process.env.CSRF_SECRET || generateDefaultSecret()
|
|
223
|
+
|
|
224
|
+
const defaultConfig: CsrfConfig = {
|
|
225
|
+
secret,
|
|
226
|
+
tokenLength: 32, // 32 bytes = 256 bits
|
|
227
|
+
expirationMs: 60 * 60 * 1000, // 1 hour
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return new CsrfService({ ...defaultConfig, ...config })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Generate a default secret for development. WARNING: Never use in production. */
|
|
234
|
+
function generateDefaultSecret(): string {
|
|
235
|
+
const allowGeneratedLocalSecrets = process.env.SUPERMEMORY_ALLOW_GENERATED_LOCAL_SECRETS === 'true'
|
|
236
|
+
|
|
237
|
+
if (process.env.NODE_ENV === 'production' && !allowGeneratedLocalSecrets) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
'CSRF_SECRET environment variable must be set in production. Generate a secure secret using: openssl rand -base64 48'
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (process.env.NODE_ENV === 'production') {
|
|
244
|
+
logger.warn(
|
|
245
|
+
'CSRF_SECRET not set; generating an ephemeral local secret because SUPERMEMORY_ALLOW_GENERATED_LOCAL_SECRETS=true'
|
|
246
|
+
)
|
|
247
|
+
} else {
|
|
248
|
+
logger.warn('Using generated CSRF secret for development - set CSRF_SECRET in production')
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return crypto.randomBytes(48).toString('base64')
|
|
252
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document Repository - Database operations for documents (PostgreSQL)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { desc, eq, inArray, or, sql } from 'drizzle-orm'
|
|
6
|
+
import { getPostgresDatabase } from '../db/postgres.js'
|
|
7
|
+
import { getDatabaseUrl, isPostgresUrl } from '../db/client.js'
|
|
8
|
+
import { documents, type Document, type NewDocument } from '../db/schema/documents.schema.js'
|
|
9
|
+
import { DatabaseError } from '../utils/errors.js'
|
|
10
|
+
import { getLogger } from '../utils/logger.js'
|
|
11
|
+
|
|
12
|
+
const logger = getLogger('DocumentRepository')
|
|
13
|
+
|
|
14
|
+
let _db: ReturnType<typeof getPostgresDatabase> | null = null
|
|
15
|
+
|
|
16
|
+
function getDb(): ReturnType<typeof getPostgresDatabase> {
|
|
17
|
+
if (_db) return _db
|
|
18
|
+
const databaseUrl = getDatabaseUrl()
|
|
19
|
+
if (!isPostgresUrl(databaseUrl)) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
'DocumentRepository requires a PostgreSQL DATABASE_URL. SQLite is only supported in tests and is not compatible with document persistence.'
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
_db = getPostgresDatabase(databaseUrl)
|
|
25
|
+
return _db
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const db = new Proxy({} as ReturnType<typeof getPostgresDatabase>, {
|
|
29
|
+
get(_target, prop) {
|
|
30
|
+
return getDb()[prop as keyof ReturnType<typeof getPostgresDatabase>]
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export interface DocumentListOptions {
|
|
35
|
+
containerTag?: string
|
|
36
|
+
limit?: number
|
|
37
|
+
offset?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class DocumentRepository {
|
|
41
|
+
private readonly database: typeof db
|
|
42
|
+
|
|
43
|
+
constructor(database: typeof db = db) {
|
|
44
|
+
this.database = database
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async create(input: {
|
|
48
|
+
id?: string
|
|
49
|
+
content: string
|
|
50
|
+
containerTag: string
|
|
51
|
+
metadata?: Record<string, unknown> | null
|
|
52
|
+
customId?: string | null
|
|
53
|
+
contentType?: string | null
|
|
54
|
+
status?: string | null
|
|
55
|
+
}): Promise<Document> {
|
|
56
|
+
try {
|
|
57
|
+
const [record] = await this.database
|
|
58
|
+
.insert(documents)
|
|
59
|
+
.values({
|
|
60
|
+
id: input.id,
|
|
61
|
+
content: input.content,
|
|
62
|
+
containerTag: input.containerTag,
|
|
63
|
+
metadata: input.metadata ?? null,
|
|
64
|
+
customId: input.customId ?? null,
|
|
65
|
+
contentType: input.contentType ?? 'text/plain',
|
|
66
|
+
status: input.status ?? 'pending',
|
|
67
|
+
} as NewDocument)
|
|
68
|
+
.returning()
|
|
69
|
+
|
|
70
|
+
if (!record) {
|
|
71
|
+
throw new DatabaseError('Failed to create document', 'insert', {
|
|
72
|
+
containerTag: input.containerTag,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return record
|
|
77
|
+
} catch (error) {
|
|
78
|
+
logger.errorWithException('Failed to create document', error, {
|
|
79
|
+
containerTag: input.containerTag,
|
|
80
|
+
})
|
|
81
|
+
if (error instanceof DatabaseError) {
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
throw new DatabaseError('Failed to create document', 'insert', { originalError: error })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async findById(id: string): Promise<Document | null> {
|
|
89
|
+
const [record] = await this.database.select().from(documents).where(eq(documents.id, id)).limit(1)
|
|
90
|
+
return record ?? null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async findByCustomId(customId: string): Promise<Document | null> {
|
|
94
|
+
const [record] = await this.database.select().from(documents).where(eq(documents.customId, customId)).limit(1)
|
|
95
|
+
return record ?? null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async findByIdOrCustomId(idOrCustomId: string): Promise<Document | null> {
|
|
99
|
+
const byId = await this.findById(idOrCustomId)
|
|
100
|
+
if (byId) return byId
|
|
101
|
+
return this.findByCustomId(idOrCustomId)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async list(options: DocumentListOptions = {}): Promise<{ documents: Document[]; total: number }> {
|
|
105
|
+
const limit = options.limit ?? 20
|
|
106
|
+
const offset = options.offset ?? 0
|
|
107
|
+
const whereClause = options.containerTag ? eq(documents.containerTag, options.containerTag) : undefined
|
|
108
|
+
|
|
109
|
+
const countResult = await this.database
|
|
110
|
+
.select({ count: sql<number>`count(*)` })
|
|
111
|
+
.from(documents)
|
|
112
|
+
.where(whereClause)
|
|
113
|
+
|
|
114
|
+
const records = await this.database
|
|
115
|
+
.select()
|
|
116
|
+
.from(documents)
|
|
117
|
+
.where(whereClause)
|
|
118
|
+
.orderBy(desc(documents.createdAt))
|
|
119
|
+
.limit(limit)
|
|
120
|
+
.offset(offset)
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
documents: records,
|
|
124
|
+
total: Number(countResult[0]?.count ?? 0),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async update(
|
|
129
|
+
id: string,
|
|
130
|
+
updates: {
|
|
131
|
+
content?: string
|
|
132
|
+
containerTag?: string
|
|
133
|
+
metadata?: Record<string, unknown> | null
|
|
134
|
+
}
|
|
135
|
+
): Promise<Document | null> {
|
|
136
|
+
try {
|
|
137
|
+
const updatePayload: Partial<NewDocument> = {
|
|
138
|
+
updatedAt: new Date(),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (updates.content !== undefined) {
|
|
142
|
+
updatePayload.content = updates.content
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (updates.containerTag !== undefined) {
|
|
146
|
+
updatePayload.containerTag = updates.containerTag
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (updates.metadata !== undefined) {
|
|
150
|
+
updatePayload.metadata = updates.metadata ?? null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const [record] = await this.database.update(documents).set(updatePayload).where(eq(documents.id, id)).returning()
|
|
154
|
+
|
|
155
|
+
return record ?? null
|
|
156
|
+
} catch (error) {
|
|
157
|
+
logger.errorWithException('Failed to update document', error, { id })
|
|
158
|
+
throw new DatabaseError('Failed to update document', 'update', { originalError: error, id })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async deleteById(id: string): Promise<boolean> {
|
|
163
|
+
const [deleted] = await this.database.delete(documents).where(eq(documents.id, id)).returning({ id: documents.id })
|
|
164
|
+
return Boolean(deleted)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async findByIdsOrCustomIds(ids: string[]): Promise<Document[]> {
|
|
168
|
+
if (ids.length === 0) return []
|
|
169
|
+
return this.database
|
|
170
|
+
.select()
|
|
171
|
+
.from(documents)
|
|
172
|
+
.where(or(inArray(documents.id, ids), inArray(documents.customId, ids)))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async deleteByIds(ids: string[]): Promise<string[]> {
|
|
176
|
+
if (ids.length === 0) return []
|
|
177
|
+
const deleted = await this.database
|
|
178
|
+
.delete(documents)
|
|
179
|
+
.where(inArray(documents.id, ids))
|
|
180
|
+
.returning({ id: documents.id })
|
|
181
|
+
return deleted.map((row) => row.id)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async deleteByContainerTags(containerTags: string[]): Promise<string[]> {
|
|
185
|
+
if (containerTags.length === 0) return []
|
|
186
|
+
const deleted = await this.database
|
|
187
|
+
.delete(documents)
|
|
188
|
+
.where(inArray(documents.containerTag, containerTags))
|
|
189
|
+
.returning({ id: documents.id })
|
|
190
|
+
return deleted.map((row) => row.id)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ==========================================================================
|
|
195
|
+
// Singleton Factory (lazy)
|
|
196
|
+
// ==========================================================================
|
|
197
|
+
|
|
198
|
+
let _repositoryInstance: DocumentRepository | null = null
|
|
199
|
+
|
|
200
|
+
export function getDocumentRepository(): DocumentRepository {
|
|
201
|
+
if (!_repositoryInstance) {
|
|
202
|
+
_repositoryInstance = new DocumentRepository()
|
|
203
|
+
}
|
|
204
|
+
return _repositoryInstance
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function resetDocumentRepository(): void {
|
|
208
|
+
_repositoryInstance = null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function createDocumentRepository(database?: typeof db): DocumentRepository {
|
|
212
|
+
return new DocumentRepository(database ?? db)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const documentRepository = new Proxy({} as DocumentRepository, {
|
|
216
|
+
get(_, prop) {
|
|
217
|
+
return getDocumentRepository()[prop as keyof DocumentRepository]
|
|
218
|
+
},
|
|
219
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document Service - API-facing document operations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ApiDocument } from '../types/api.types.js'
|
|
6
|
+
import type { Document } from '../db/schema/documents.schema.js'
|
|
7
|
+
import { DocumentRepository, getDocumentRepository, type DocumentListOptions } from './documents.repository.js'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONTAINER_TAG = 'default'
|
|
10
|
+
|
|
11
|
+
export interface CreateDocumentInput {
|
|
12
|
+
id: string
|
|
13
|
+
content: string
|
|
14
|
+
containerTag?: string
|
|
15
|
+
metadata?: Record<string, unknown>
|
|
16
|
+
customId?: string
|
|
17
|
+
contentType?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UpdateDocumentInput {
|
|
21
|
+
content?: string
|
|
22
|
+
containerTag?: string
|
|
23
|
+
metadata?: Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class DocumentService {
|
|
27
|
+
private repository: DocumentRepository
|
|
28
|
+
|
|
29
|
+
constructor(repository?: DocumentRepository) {
|
|
30
|
+
this.repository = repository ?? getDocumentRepository()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async createDocument(input: CreateDocumentInput): Promise<ApiDocument> {
|
|
34
|
+
const record = await this.repository.create({
|
|
35
|
+
id: input.id,
|
|
36
|
+
content: input.content,
|
|
37
|
+
containerTag: this.normalizeContainerTag(input.containerTag),
|
|
38
|
+
metadata: input.metadata ?? null,
|
|
39
|
+
customId: input.customId ?? null,
|
|
40
|
+
contentType: input.contentType ?? 'text/plain',
|
|
41
|
+
status: 'pending',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return this.toApiDocument(record)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getDocument(idOrCustomId: string): Promise<ApiDocument | null> {
|
|
48
|
+
const record = await this.repository.findByIdOrCustomId(idOrCustomId)
|
|
49
|
+
return record ? this.toApiDocument(record) : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async getDocumentByCustomId(customId: string): Promise<ApiDocument | null> {
|
|
53
|
+
const record = await this.repository.findByCustomId(customId)
|
|
54
|
+
return record ? this.toApiDocument(record) : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async updateDocument(idOrCustomId: string, updates: UpdateDocumentInput): Promise<ApiDocument | null> {
|
|
58
|
+
const existing = await this.repository.findByIdOrCustomId(idOrCustomId)
|
|
59
|
+
if (!existing) return null
|
|
60
|
+
|
|
61
|
+
const record = await this.repository.update(existing.id, {
|
|
62
|
+
content: updates.content,
|
|
63
|
+
containerTag: updates.containerTag,
|
|
64
|
+
metadata: updates.metadata,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return record ? this.toApiDocument(record) : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async deleteDocument(idOrCustomId: string): Promise<string | null> {
|
|
71
|
+
const existing = await this.repository.findByIdOrCustomId(idOrCustomId)
|
|
72
|
+
if (!existing) return null
|
|
73
|
+
|
|
74
|
+
const deleted = await this.repository.deleteById(existing.id)
|
|
75
|
+
return deleted ? existing.id : null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async listDocuments(options: DocumentListOptions = {}): Promise<{
|
|
79
|
+
documents: ApiDocument[]
|
|
80
|
+
total: number
|
|
81
|
+
limit: number
|
|
82
|
+
offset: number
|
|
83
|
+
}> {
|
|
84
|
+
const limit = options.limit ?? 20
|
|
85
|
+
const offset = options.offset ?? 0
|
|
86
|
+
|
|
87
|
+
const { documents, total } = await this.repository.list({
|
|
88
|
+
containerTag: options.containerTag,
|
|
89
|
+
limit,
|
|
90
|
+
offset,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
documents: documents.map((doc) => this.toApiDocument(doc)),
|
|
95
|
+
total,
|
|
96
|
+
limit,
|
|
97
|
+
offset,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async bulkDelete(input: {
|
|
102
|
+
ids?: string[]
|
|
103
|
+
containerTags?: string[]
|
|
104
|
+
}): Promise<{ deletedIds: string[]; notFoundIds: string[] }> {
|
|
105
|
+
const deleted = new Set<string>()
|
|
106
|
+
const notFound = new Set<string>()
|
|
107
|
+
|
|
108
|
+
if (input.ids?.length) {
|
|
109
|
+
const matches = await this.repository.findByIdsOrCustomIds(input.ids)
|
|
110
|
+
const byId = new Map(matches.map((doc) => [doc.id, doc] as const))
|
|
111
|
+
const byCustomId = new Map(
|
|
112
|
+
matches.filter((doc) => doc.customId).map((doc) => [doc.customId as string, doc] as const)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const idsToDelete = new Set<string>()
|
|
116
|
+
for (const id of input.ids) {
|
|
117
|
+
const match = byId.get(id) ?? byCustomId.get(id)
|
|
118
|
+
if (match) {
|
|
119
|
+
idsToDelete.add(match.id)
|
|
120
|
+
} else {
|
|
121
|
+
notFound.add(id)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (idsToDelete.size > 0) {
|
|
126
|
+
const deletedIds = await this.repository.deleteByIds([...idsToDelete])
|
|
127
|
+
for (const id of deletedIds) {
|
|
128
|
+
deleted.add(id)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (input.containerTags?.length) {
|
|
134
|
+
const deletedIds = await this.repository.deleteByContainerTags(input.containerTags)
|
|
135
|
+
for (const id of deletedIds) {
|
|
136
|
+
deleted.add(id)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
deletedIds: [...deleted],
|
|
142
|
+
notFoundIds: [...notFound],
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private normalizeContainerTag(containerTag?: string): string {
|
|
147
|
+
if (!containerTag || !containerTag.trim()) {
|
|
148
|
+
return DEFAULT_CONTAINER_TAG
|
|
149
|
+
}
|
|
150
|
+
return containerTag
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private toApiDocument(document: Document): ApiDocument {
|
|
154
|
+
return {
|
|
155
|
+
id: document.id,
|
|
156
|
+
content: document.content,
|
|
157
|
+
containerTag: document.containerTag || undefined,
|
|
158
|
+
metadata: (document.metadata as Record<string, unknown> | null) ?? undefined,
|
|
159
|
+
customId: document.customId ?? undefined,
|
|
160
|
+
createdAt: document.createdAt.toISOString(),
|
|
161
|
+
updatedAt: document.updatedAt.toISOString(),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ==========================================================================
|
|
167
|
+
// Singleton Factory (lazy)
|
|
168
|
+
// ==========================================================================
|
|
169
|
+
|
|
170
|
+
let _serviceInstance: DocumentService | null = null
|
|
171
|
+
|
|
172
|
+
export function getDocumentService(): DocumentService {
|
|
173
|
+
if (!_serviceInstance) {
|
|
174
|
+
_serviceInstance = new DocumentService()
|
|
175
|
+
}
|
|
176
|
+
return _serviceInstance
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function resetDocumentService(): void {
|
|
180
|
+
_serviceInstance = null
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function createDocumentService(repository?: DocumentRepository): DocumentService {
|
|
184
|
+
return new DocumentService(repository ?? getDocumentRepository())
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export const documentService = new Proxy({} as DocumentService, {
|
|
188
|
+
get(_, prop) {
|
|
189
|
+
return getDocumentService()[prop as keyof DocumentService]
|
|
190
|
+
},
|
|
191
|
+
})
|