@pikku/mongodb 0.12.1

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 (36) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/src/index.d.ts +12 -0
  3. package/dist/src/index.js +9 -0
  4. package/dist/src/mongodb-agent-run-service.d.ts +21 -0
  5. package/dist/src/mongodb-agent-run-service.js +127 -0
  6. package/dist/src/mongodb-ai-storage-service.d.ts +41 -0
  7. package/dist/src/mongodb-ai-storage-service.js +408 -0
  8. package/dist/src/mongodb-channel-store.d.ts +19 -0
  9. package/dist/src/mongodb-channel-store.js +52 -0
  10. package/dist/src/mongodb-deployment-service.d.ts +17 -0
  11. package/dist/src/mongodb-deployment-service.js +75 -0
  12. package/dist/src/mongodb-eventhub-store.d.ts +13 -0
  13. package/dist/src/mongodb-eventhub-store.js +37 -0
  14. package/dist/src/mongodb-secret-service.d.ts +30 -0
  15. package/dist/src/mongodb-secret-service.js +110 -0
  16. package/dist/src/mongodb-workflow-run-service.d.ts +36 -0
  17. package/dist/src/mongodb-workflow-run-service.js +146 -0
  18. package/dist/src/mongodb-workflow-service.d.ts +55 -0
  19. package/dist/src/mongodb-workflow-service.js +351 -0
  20. package/dist/src/pikku-mongodb.d.ts +12 -0
  21. package/dist/src/pikku-mongodb.js +37 -0
  22. package/dist/tsconfig.tsbuildinfo +1 -0
  23. package/package.json +29 -0
  24. package/run-tests.sh +53 -0
  25. package/src/index.ts +14 -0
  26. package/src/mongodb-agent-run-service.ts +206 -0
  27. package/src/mongodb-ai-storage-service.ts +576 -0
  28. package/src/mongodb-channel-store.ts +96 -0
  29. package/src/mongodb-deployment-service.ts +114 -0
  30. package/src/mongodb-eventhub-store.ts +52 -0
  31. package/src/mongodb-secret-service.ts +178 -0
  32. package/src/mongodb-services.test.ts +149 -0
  33. package/src/mongodb-workflow-run-service.ts +246 -0
  34. package/src/mongodb-workflow-service.ts +583 -0
  35. package/src/pikku-mongodb.ts +41 -0
  36. package/tsconfig.json +17 -0
@@ -0,0 +1,114 @@
1
+ import type {
2
+ DeploymentService,
3
+ DeploymentServiceConfig,
4
+ DeploymentConfig,
5
+ DeploymentInfo,
6
+ } from '@pikku/core/services'
7
+ import { getAllFunctionNames } from '@pikku/core/function'
8
+ import type { Db, Collection } from 'mongodb'
9
+
10
+ interface DeploymentDoc {
11
+ _id: string
12
+ endpoint: string
13
+ lastHeartbeat: Date
14
+ createdAt: Date
15
+ functions: string[]
16
+ }
17
+
18
+ export class MongoDBDeploymentService implements DeploymentService {
19
+ private initialized = false
20
+ private heartbeatTimer?: ReturnType<typeof setInterval>
21
+ private deploymentConfig?: DeploymentConfig
22
+ private heartbeatInterval: number
23
+ private heartbeatTtl: number
24
+ private deployments!: Collection<DeploymentDoc>
25
+
26
+ constructor(
27
+ config: DeploymentServiceConfig,
28
+ private db: Db
29
+ ) {
30
+ this.heartbeatInterval = config.heartbeatInterval ?? 10000
31
+ this.heartbeatTtl = config.heartbeatTtl ?? 30000
32
+ }
33
+
34
+ public async init(): Promise<void> {
35
+ if (this.initialized) return
36
+
37
+ this.deployments = this.db.collection<DeploymentDoc>('pikku_deployments')
38
+
39
+ await this.deployments.createIndex({ lastHeartbeat: 1 })
40
+ await this.deployments.createIndex({ functions: 1 })
41
+
42
+ this.initialized = true
43
+ }
44
+
45
+ async start(config: DeploymentConfig): Promise<void> {
46
+ const functions = config.functions ?? getAllFunctionNames()
47
+ this.deploymentConfig = { ...config, functions }
48
+ const now = new Date()
49
+
50
+ await this.deployments.updateOne(
51
+ { _id: config.deploymentId },
52
+ {
53
+ $set: {
54
+ endpoint: config.endpoint,
55
+ lastHeartbeat: now,
56
+ functions,
57
+ },
58
+ $setOnInsert: {
59
+ _id: config.deploymentId,
60
+ createdAt: now,
61
+ },
62
+ },
63
+ { upsert: true }
64
+ )
65
+
66
+ this.heartbeatTimer = setInterval(
67
+ () => this.sendHeartbeat(),
68
+ this.heartbeatInterval
69
+ )
70
+ }
71
+
72
+ async stop(): Promise<void> {
73
+ if (this.heartbeatTimer) {
74
+ clearInterval(this.heartbeatTimer)
75
+ this.heartbeatTimer = undefined
76
+ }
77
+
78
+ if (this.deploymentConfig) {
79
+ await this.deployments.deleteOne({
80
+ _id: this.deploymentConfig.deploymentId,
81
+ })
82
+ }
83
+ }
84
+
85
+ async findFunction(name: string): Promise<DeploymentInfo[]> {
86
+ const cutoff = new Date(Date.now() - this.heartbeatTtl)
87
+
88
+ const result = await this.deployments
89
+ .find({
90
+ functions: name,
91
+ lastHeartbeat: { $gt: cutoff },
92
+ })
93
+ .sort({ lastHeartbeat: -1 })
94
+ .toArray()
95
+
96
+ return result.map((row) => ({
97
+ deploymentId: row._id,
98
+ endpoint: row.endpoint,
99
+ }))
100
+ }
101
+
102
+ private async sendHeartbeat(): Promise<void> {
103
+ if (!this.deploymentConfig) return
104
+
105
+ try {
106
+ await this.deployments.updateOne(
107
+ { _id: this.deploymentConfig.deploymentId },
108
+ { $set: { lastHeartbeat: new Date() } }
109
+ )
110
+ } catch {
111
+ // Heartbeat failed, will retry on next interval
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,52 @@
1
+ import { EventHubStore } from '@pikku/core/channel'
2
+ import type { Db, Collection } from 'mongodb'
3
+
4
+ interface ChannelSubscriptionDoc {
5
+ channelId: string
6
+ topic: string
7
+ }
8
+
9
+ export class MongoDBEventHubStore extends EventHubStore {
10
+ private initialized = false
11
+ private subscriptions!: Collection<ChannelSubscriptionDoc>
12
+
13
+ constructor(private db: Db) {
14
+ super()
15
+ }
16
+
17
+ public async init(): Promise<void> {
18
+ if (this.initialized) return
19
+ this.subscriptions = this.db.collection<ChannelSubscriptionDoc>(
20
+ 'channel_subscriptions'
21
+ )
22
+ this.initialized = true
23
+ }
24
+
25
+ public async getChannelIdsForTopic(topic: string): Promise<string[]> {
26
+ const result = await this.subscriptions
27
+ .find({ topic })
28
+ .project({ channelId: 1 })
29
+ .toArray()
30
+ return result.map((row) => row.channelId)
31
+ }
32
+
33
+ public async subscribe(topic: string, channelId: string): Promise<boolean> {
34
+ try {
35
+ await this.subscriptions.updateOne(
36
+ { channelId, topic },
37
+ { $setOnInsert: { channelId, topic } },
38
+ { upsert: true }
39
+ )
40
+ return true
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ public async unsubscribe(topic: string, channelId: string): Promise<boolean> {
47
+ const result = await this.subscriptions.deleteOne({ channelId, topic })
48
+ return result.deletedCount > 0
49
+ }
50
+
51
+ public async close(): Promise<void> {}
52
+ }
@@ -0,0 +1,178 @@
1
+ import type { SecretService } from '@pikku/core/services'
2
+ import type { Db, Collection } from 'mongodb'
3
+ import {
4
+ envelopeEncrypt,
5
+ envelopeDecrypt,
6
+ envelopeRewrap,
7
+ } from '@pikku/core/crypto-utils'
8
+
9
+ export interface MongoDBSecretServiceConfig {
10
+ key: string
11
+ keyVersion?: number
12
+ previousKey?: string
13
+ audit?: boolean
14
+ auditReads?: boolean
15
+ }
16
+
17
+ interface SecretDoc {
18
+ _id: string
19
+ ciphertext: string
20
+ wrappedDek: string
21
+ keyVersion: number
22
+ createdAt: Date
23
+ updatedAt: Date
24
+ }
25
+
26
+ interface SecretAuditDoc {
27
+ _id: string
28
+ secretKey: string
29
+ action: string
30
+ performedAt: Date
31
+ }
32
+
33
+ export class MongoDBSecretService implements SecretService {
34
+ private initialized = false
35
+ private key: string
36
+ private keyVersion: number
37
+ private previousKey?: string
38
+ private audit: boolean
39
+ private auditReads: boolean
40
+ private secrets!: Collection<SecretDoc>
41
+ private secretsAudit!: Collection<SecretAuditDoc>
42
+
43
+ constructor(
44
+ private db: Db,
45
+ config: MongoDBSecretServiceConfig
46
+ ) {
47
+ this.key = config.key
48
+ this.keyVersion = config.keyVersion ?? 1
49
+ this.previousKey = config.previousKey
50
+ this.audit = config.audit ?? false
51
+ this.auditReads = config.auditReads ?? false
52
+ }
53
+
54
+ public async init(): Promise<void> {
55
+ if (this.initialized) return
56
+
57
+ this.secrets = this.db.collection<SecretDoc>('secrets')
58
+ this.secretsAudit = this.db.collection<SecretAuditDoc>('secrets_audit')
59
+
60
+ await this.secrets.createIndex({ _id: 1 })
61
+
62
+ if (this.audit) {
63
+ await this.secretsAudit.createIndex({ secretKey: 1 })
64
+ }
65
+
66
+ this.initialized = true
67
+ }
68
+
69
+ private async logAudit(
70
+ secretKey: string,
71
+ action: 'read' | 'write' | 'delete' | 'rotate'
72
+ ): Promise<void> {
73
+ if (!this.audit) return
74
+ if (action === 'read' && !this.auditReads) return
75
+
76
+ await this.secretsAudit.insertOne({
77
+ _id: crypto.randomUUID(),
78
+ secretKey,
79
+ action,
80
+ performedAt: new Date(),
81
+ })
82
+ }
83
+
84
+ private getKEK(version: number): string {
85
+ if (version === this.keyVersion) return this.key
86
+ if (this.previousKey) return this.previousKey
87
+ throw new Error(`No KEK available for key_version ${version}`)
88
+ }
89
+
90
+ async getSecret(key: string): Promise<string> {
91
+ const row = await this.secrets.findOne({ _id: key })
92
+ if (!row) throw new Error(`Secret not found: ${key}`)
93
+
94
+ const kek = this.getKEK(row.keyVersion)
95
+ const result = await envelopeDecrypt<string>(
96
+ kek,
97
+ row.ciphertext,
98
+ row.wrappedDek
99
+ )
100
+ await this.logAudit(key, 'read')
101
+ return result
102
+ }
103
+
104
+ async getSecretJSON<R = {}>(key: string): Promise<R> {
105
+ const raw = await this.getSecret(key)
106
+ return JSON.parse(raw) as R
107
+ }
108
+
109
+ async hasSecret(key: string): Promise<boolean> {
110
+ const count = await this.secrets.countDocuments({ _id: key }, { limit: 1 })
111
+ return count > 0
112
+ }
113
+
114
+ async setSecretJSON(key: string, value: unknown): Promise<void> {
115
+ const plaintext = JSON.stringify(value)
116
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(
117
+ this.key,
118
+ plaintext
119
+ )
120
+ const now = new Date()
121
+
122
+ await this.secrets.updateOne(
123
+ { _id: key },
124
+ {
125
+ $set: {
126
+ ciphertext,
127
+ wrappedDek: wrappedDEK,
128
+ keyVersion: this.keyVersion,
129
+ updatedAt: now,
130
+ },
131
+ $setOnInsert: {
132
+ _id: key,
133
+ createdAt: now,
134
+ },
135
+ },
136
+ { upsert: true }
137
+ )
138
+
139
+ await this.logAudit(key, 'write')
140
+ }
141
+
142
+ async deleteSecret(key: string): Promise<void> {
143
+ await this.secrets.deleteOne({ _id: key })
144
+ await this.logAudit(key, 'delete')
145
+ }
146
+
147
+ async rotateKEK(): Promise<number> {
148
+ if (!this.previousKey) {
149
+ throw new Error('No previousKey configured — nothing to rotate from')
150
+ }
151
+
152
+ const rows = await this.secrets
153
+ .find({ keyVersion: { $lt: this.keyVersion } })
154
+ .project({ _id: 1, wrappedDek: 1 })
155
+ .toArray()
156
+
157
+ for (const row of rows) {
158
+ const newWrappedDEK = await envelopeRewrap(
159
+ this.previousKey,
160
+ this.key,
161
+ row.wrappedDek
162
+ )
163
+ await this.secrets.updateOne(
164
+ { _id: row._id },
165
+ {
166
+ $set: {
167
+ wrappedDek: newWrappedDEK,
168
+ keyVersion: this.keyVersion,
169
+ updatedAt: new Date(),
170
+ },
171
+ }
172
+ )
173
+ await this.logAudit(row._id as string, 'rotate')
174
+ }
175
+
176
+ return rows.length
177
+ }
178
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, test, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { MongoClient, type Db } from 'mongodb'
4
+ import { MongoMemoryServer } from 'mongodb-memory-server'
5
+ import { defineServiceTests } from '@pikku/core/testing'
6
+
7
+ import { MongoDBChannelStore } from './mongodb-channel-store.js'
8
+ import { MongoDBEventHubStore } from './mongodb-eventhub-store.js'
9
+ import { MongoDBWorkflowService } from './mongodb-workflow-service.js'
10
+ import { MongoDBWorkflowRunService } from './mongodb-workflow-run-service.js'
11
+ import { MongoDBDeploymentService } from './mongodb-deployment-service.js'
12
+ import { MongoDBAIStorageService } from './mongodb-ai-storage-service.js'
13
+ import { MongoDBAgentRunService } from './mongodb-agent-run-service.js'
14
+ import { MongoDBSecretService } from './mongodb-secret-service.js'
15
+
16
+ function registerTests(name: string, getDb: () => Db) {
17
+ defineServiceTests({
18
+ name,
19
+ services: {
20
+ channelStore: async () => {
21
+ const s = new MongoDBChannelStore(getDb())
22
+ await s.init()
23
+ return s
24
+ },
25
+ eventHubStore: async () => {
26
+ const s = new MongoDBEventHubStore(getDb())
27
+ await s.init()
28
+ return s
29
+ },
30
+ workflowService: async () => {
31
+ const s = new MongoDBWorkflowService(getDb())
32
+ await s.init()
33
+ return s
34
+ },
35
+ workflowRunService: async () => new MongoDBWorkflowRunService(getDb()),
36
+ deploymentService: async () => {
37
+ const s = new MongoDBDeploymentService(
38
+ { heartbeatInterval: 60000, heartbeatTtl: 120000 },
39
+ getDb()
40
+ )
41
+ await s.init()
42
+ return s
43
+ },
44
+ aiStorageService: async () => {
45
+ const s = new MongoDBAIStorageService(getDb())
46
+ await s.init()
47
+ return s
48
+ },
49
+ agentRunService: async () => new MongoDBAgentRunService(getDb()),
50
+ secretService: async (config) => {
51
+ const s = new MongoDBSecretService(getDb(), config)
52
+ await s.init()
53
+ return s
54
+ },
55
+ },
56
+ })
57
+
58
+ describe(`MongoDBSecretService audit [${name}]`, () => {
59
+ const kek = 'test-key-encryption-key-32chars!'
60
+
61
+ test('audit logs writes, reads, and deletes', async () => {
62
+ const service = new MongoDBSecretService(getDb(), {
63
+ key: kek,
64
+ audit: true,
65
+ auditReads: true,
66
+ })
67
+ await service.init()
68
+ await service.setSecretJSON('audit-test', 'value')
69
+ await service.getSecret('audit-test')
70
+ await service.deleteSecret('audit-test')
71
+
72
+ const logs = await getDb()
73
+ .collection('secrets_audit')
74
+ .find({ secretKey: 'audit-test' })
75
+ .sort({ performedAt: 1 })
76
+ .toArray()
77
+
78
+ assert.equal(logs.length, 3)
79
+ assert.equal(logs[0]!.action, 'write')
80
+ assert.equal(logs[1]!.action, 'read')
81
+ assert.equal(logs[2]!.action, 'delete')
82
+ })
83
+
84
+ test('audit skips reads when auditReads is false', async () => {
85
+ const service = new MongoDBSecretService(getDb(), {
86
+ key: kek,
87
+ audit: true,
88
+ auditReads: false,
89
+ })
90
+ await service.init()
91
+ await service.setSecretJSON('no-read-audit', 'value')
92
+ await service.getSecret('no-read-audit')
93
+
94
+ const logs = await getDb()
95
+ .collection('secrets_audit')
96
+ .find({ secretKey: 'no-read-audit' })
97
+ .toArray()
98
+
99
+ assert.equal(logs.length, 1)
100
+ assert.equal(logs[0]!.action, 'write')
101
+ })
102
+ })
103
+ }
104
+
105
+ describe('MongoDB Services - In-Memory', () => {
106
+ let mongod: MongoMemoryServer
107
+ let client: MongoClient
108
+ let db: Db
109
+
110
+ before(async () => {
111
+ mongod = await MongoMemoryServer.create()
112
+ const uri = mongod.getUri()
113
+ client = new MongoClient(uri)
114
+ await client.connect()
115
+ db = client.db('pikku_test')
116
+ })
117
+
118
+ after(async () => {
119
+ await client.close()
120
+ await mongod.stop()
121
+ })
122
+
123
+ registerTests('MongoMemory', () => db)
124
+ })
125
+
126
+ describe(
127
+ 'MongoDB Services - Real',
128
+ {
129
+ skip: !process.env.MONGODB_URL ? 'MONGODB_URL not set' : undefined,
130
+ },
131
+ () => {
132
+ let client: MongoClient
133
+ let db: Db
134
+
135
+ before(async () => {
136
+ client = new MongoClient(process.env.MONGODB_URL!)
137
+ await client.connect()
138
+ db = client.db('pikku_test')
139
+ await db.dropDatabase()
140
+ })
141
+
142
+ after(async () => {
143
+ if (db) await db.dropDatabase()
144
+ if (client) await client.close()
145
+ })
146
+
147
+ registerTests('Real MongoDB', () => db)
148
+ }
149
+ )