@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.
Files changed (156) hide show
  1. package/.env.example +57 -0
  2. package/README.md +374 -0
  3. package/dist/index.js +189 -0
  4. package/dist/mcp/index.js +1132 -0
  5. package/docker-compose.prod.yml +91 -0
  6. package/docker-compose.yml +358 -0
  7. package/drizzle/0000_dapper_the_professor.sql +159 -0
  8. package/drizzle/0001_api_keys.sql +51 -0
  9. package/drizzle/meta/0000_snapshot.json +1532 -0
  10. package/drizzle/meta/_journal.json +13 -0
  11. package/drizzle.config.ts +20 -0
  12. package/package.json +114 -0
  13. package/scripts/add-extraction-job.ts +122 -0
  14. package/scripts/benchmark-pgvector.ts +122 -0
  15. package/scripts/bootstrap.sh +209 -0
  16. package/scripts/check-runtime-pack.ts +111 -0
  17. package/scripts/claude-mcp-config.ts +336 -0
  18. package/scripts/docker-entrypoint.sh +183 -0
  19. package/scripts/doctor.ts +377 -0
  20. package/scripts/init-db.sql +33 -0
  21. package/scripts/install.sh +1110 -0
  22. package/scripts/mcp-setup.ts +271 -0
  23. package/scripts/migrations/001_create_pgvector_extension.sql +31 -0
  24. package/scripts/migrations/002_create_memory_embeddings_table.sql +75 -0
  25. package/scripts/migrations/003_create_hnsw_index.sql +94 -0
  26. package/scripts/migrations/004_create_memory_embeddings_standalone.sql +70 -0
  27. package/scripts/migrations/005_create_chunks_table.sql +95 -0
  28. package/scripts/migrations/006_create_processing_queue.sql +45 -0
  29. package/scripts/migrations/generate_test_data.sql +42 -0
  30. package/scripts/migrations/phase1_comprehensive_test.sql +204 -0
  31. package/scripts/migrations/run_migrations.sh +286 -0
  32. package/scripts/migrations/test_hnsw_index.sql +255 -0
  33. package/scripts/pre-commit-secrets +282 -0
  34. package/scripts/run-extraction-worker.ts +46 -0
  35. package/scripts/run-phase1-tests.sh +291 -0
  36. package/scripts/setup.ts +222 -0
  37. package/scripts/smoke-install.sh +12 -0
  38. package/scripts/test-health-endpoint.sh +328 -0
  39. package/src/api/index.ts +2 -0
  40. package/src/api/middleware/auth.ts +80 -0
  41. package/src/api/middleware/csrf.ts +308 -0
  42. package/src/api/middleware/errorHandler.ts +166 -0
  43. package/src/api/middleware/rateLimit.ts +360 -0
  44. package/src/api/middleware/validation.ts +514 -0
  45. package/src/api/routes/documents.ts +286 -0
  46. package/src/api/routes/profiles.ts +237 -0
  47. package/src/api/routes/search.ts +71 -0
  48. package/src/api/stores/index.ts +58 -0
  49. package/src/config/bootstrap-env.ts +3 -0
  50. package/src/config/env.ts +71 -0
  51. package/src/config/feature-flags.ts +25 -0
  52. package/src/config/index.ts +140 -0
  53. package/src/config/secrets.config.ts +291 -0
  54. package/src/db/client.ts +92 -0
  55. package/src/db/index.ts +73 -0
  56. package/src/db/postgres.ts +72 -0
  57. package/src/db/schema/chunks.schema.ts +31 -0
  58. package/src/db/schema/containers.schema.ts +46 -0
  59. package/src/db/schema/documents.schema.ts +49 -0
  60. package/src/db/schema/embeddings.schema.ts +32 -0
  61. package/src/db/schema/index.ts +11 -0
  62. package/src/db/schema/memories.schema.ts +72 -0
  63. package/src/db/schema/profiles.schema.ts +34 -0
  64. package/src/db/schema/queue.schema.ts +59 -0
  65. package/src/db/schema/relationships.schema.ts +42 -0
  66. package/src/db/schema.ts +223 -0
  67. package/src/db/worker-connection.ts +47 -0
  68. package/src/index.ts +235 -0
  69. package/src/mcp/CLAUDE.md +1 -0
  70. package/src/mcp/index.ts +1380 -0
  71. package/src/mcp/legacyState.ts +22 -0
  72. package/src/mcp/rateLimit.ts +358 -0
  73. package/src/mcp/resources.ts +309 -0
  74. package/src/mcp/results.ts +104 -0
  75. package/src/mcp/tools.ts +401 -0
  76. package/src/queues/config.ts +119 -0
  77. package/src/queues/index.ts +289 -0
  78. package/src/sdk/client.ts +225 -0
  79. package/src/sdk/errors.ts +266 -0
  80. package/src/sdk/http.ts +560 -0
  81. package/src/sdk/index.ts +244 -0
  82. package/src/sdk/resources/base.ts +65 -0
  83. package/src/sdk/resources/connections.ts +204 -0
  84. package/src/sdk/resources/documents.ts +163 -0
  85. package/src/sdk/resources/index.ts +10 -0
  86. package/src/sdk/resources/memories.ts +150 -0
  87. package/src/sdk/resources/search.ts +60 -0
  88. package/src/sdk/resources/settings.ts +36 -0
  89. package/src/sdk/types.ts +674 -0
  90. package/src/services/chunking/index.ts +451 -0
  91. package/src/services/chunking.service.ts +650 -0
  92. package/src/services/csrf.service.ts +252 -0
  93. package/src/services/documents.repository.ts +219 -0
  94. package/src/services/documents.service.ts +191 -0
  95. package/src/services/embedding.service.ts +404 -0
  96. package/src/services/extraction.service.ts +300 -0
  97. package/src/services/extractors/code.extractor.ts +451 -0
  98. package/src/services/extractors/index.ts +9 -0
  99. package/src/services/extractors/markdown.extractor.ts +461 -0
  100. package/src/services/extractors/pdf.extractor.ts +315 -0
  101. package/src/services/extractors/text.extractor.ts +118 -0
  102. package/src/services/extractors/url.extractor.ts +243 -0
  103. package/src/services/index.ts +235 -0
  104. package/src/services/ingestion.service.ts +177 -0
  105. package/src/services/llm/anthropic.ts +400 -0
  106. package/src/services/llm/base.ts +460 -0
  107. package/src/services/llm/contradiction-detector.service.ts +526 -0
  108. package/src/services/llm/heuristics.ts +148 -0
  109. package/src/services/llm/index.ts +309 -0
  110. package/src/services/llm/memory-classifier.service.ts +383 -0
  111. package/src/services/llm/memory-extension-detector.service.ts +523 -0
  112. package/src/services/llm/mock.ts +470 -0
  113. package/src/services/llm/openai.ts +398 -0
  114. package/src/services/llm/prompts.ts +438 -0
  115. package/src/services/llm/types.ts +373 -0
  116. package/src/services/memory.repository.ts +1769 -0
  117. package/src/services/memory.service.ts +1338 -0
  118. package/src/services/memory.types.ts +234 -0
  119. package/src/services/persistence/index.ts +295 -0
  120. package/src/services/pipeline.service.ts +509 -0
  121. package/src/services/profile.repository.ts +436 -0
  122. package/src/services/profile.service.ts +560 -0
  123. package/src/services/profile.types.ts +270 -0
  124. package/src/services/relationships/detector.ts +1128 -0
  125. package/src/services/relationships/index.ts +268 -0
  126. package/src/services/relationships/memory-integration.ts +459 -0
  127. package/src/services/relationships/strategies.ts +132 -0
  128. package/src/services/relationships/types.ts +370 -0
  129. package/src/services/search.service.ts +761 -0
  130. package/src/services/search.types.ts +220 -0
  131. package/src/services/secrets.service.ts +384 -0
  132. package/src/services/vectorstore/base.ts +327 -0
  133. package/src/services/vectorstore/index.ts +444 -0
  134. package/src/services/vectorstore/memory.ts +286 -0
  135. package/src/services/vectorstore/migration.ts +295 -0
  136. package/src/services/vectorstore/mock.ts +403 -0
  137. package/src/services/vectorstore/pgvector.ts +695 -0
  138. package/src/services/vectorstore/types.ts +247 -0
  139. package/src/startup.ts +389 -0
  140. package/src/types/api.types.ts +193 -0
  141. package/src/types/document.types.ts +103 -0
  142. package/src/types/index.ts +241 -0
  143. package/src/types/profile.base.ts +133 -0
  144. package/src/utils/errors.ts +447 -0
  145. package/src/utils/id.ts +15 -0
  146. package/src/utils/index.ts +101 -0
  147. package/src/utils/logger.ts +313 -0
  148. package/src/utils/sanitization.ts +501 -0
  149. package/src/utils/secret-validation.ts +273 -0
  150. package/src/utils/synonyms.ts +188 -0
  151. package/src/utils/validation.ts +581 -0
  152. package/src/workers/chunking.worker.ts +242 -0
  153. package/src/workers/embedding.worker.ts +358 -0
  154. package/src/workers/extraction.worker.ts +346 -0
  155. package/src/workers/indexing.worker.ts +505 -0
  156. 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
+ })