@pikku/mongodb 0.12.20 → 0.12.22

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.
@@ -1,235 +0,0 @@
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 { MongoDBAgentStorageService } from './mongodb-agent-storage-service.js'
13
- import { MongoDBAgentRunService } from './mongodb-agent-run-service.js'
14
- import { MongoDBSecretService } from './mongodb-secret-service.js'
15
- import { MongoDBSessionStore } from './mongodb-session-store.js'
16
-
17
- function registerTests(name: string, getDb: () => Db) {
18
- defineServiceTests({
19
- name,
20
- services: {
21
- channelStore: async () => {
22
- const s = new MongoDBChannelStore(getDb())
23
- await s.init()
24
- return s
25
- },
26
- eventHubStore: async () => {
27
- const s = new MongoDBEventHubStore(getDb())
28
- await s.init()
29
- return s
30
- },
31
- workflowService: async () => {
32
- const s = new MongoDBWorkflowService(getDb())
33
- await s.init()
34
- return s
35
- },
36
- workflowRunService: async () => new MongoDBWorkflowRunService(getDb()),
37
- deploymentService: async () => {
38
- const s = new MongoDBDeploymentService(
39
- { heartbeatInterval: 60000, heartbeatTtl: 120000 },
40
- getDb()
41
- )
42
- await s.init()
43
- return s
44
- },
45
- agentStorageService: async () => {
46
- const s = new MongoDBAgentStorageService(getDb())
47
- await s.init()
48
- return s
49
- },
50
- agentRunService: async () => new MongoDBAgentRunService(getDb()),
51
- secretService: async (config) => {
52
- const s = new MongoDBSecretService(getDb(), config)
53
- await s.init()
54
- return s
55
- },
56
- sessionStore: async () => {
57
- const s = new MongoDBSessionStore(getDb())
58
- await s.init()
59
- return s
60
- },
61
- },
62
- })
63
-
64
- describe(`MongoDBSecretService audit [${name}]`, () => {
65
- const kek = 'test-key-encryption-key-32chars!'
66
-
67
- test('audit logs writes, reads, and deletes', async () => {
68
- const service = new MongoDBSecretService(getDb(), {
69
- key: kek,
70
- audit: true,
71
- auditReads: true,
72
- })
73
- await service.init()
74
- await service.setSecret('audit-test', 'value')
75
- await service.getSecret('audit-test')
76
- await service.deleteSecret('audit-test')
77
-
78
- const logs = await getDb()
79
- .collection('secrets_audit')
80
- .find({ secretKey: 'audit-test' })
81
- .sort({ performedAt: 1 })
82
- .toArray()
83
-
84
- assert.equal(logs.length, 3)
85
- assert.equal(logs[0]!.action, 'write')
86
- assert.equal(logs[1]!.action, 'read')
87
- assert.equal(logs[2]!.action, 'delete')
88
- })
89
-
90
- test('audit skips reads when auditReads is false', async () => {
91
- const service = new MongoDBSecretService(getDb(), {
92
- key: kek,
93
- audit: true,
94
- auditReads: false,
95
- })
96
- await service.init()
97
- await service.setSecret('no-read-audit', 'value')
98
- await service.getSecret('no-read-audit')
99
-
100
- const logs = await getDb()
101
- .collection('secrets_audit')
102
- .find({ secretKey: 'no-read-audit' })
103
- .toArray()
104
-
105
- assert.equal(logs.length, 1)
106
- assert.equal(logs[0]!.action, 'write')
107
- })
108
- })
109
-
110
- describe(`MongoDBSecretService decryption failures [${name}]`, () => {
111
- const kek = 'test-key-encryption-key-32chars!'
112
- const otherKek = 'a-totally-different-kek-32chars!'
113
-
114
- test('getSecrets throws naming a secret it cannot decrypt', async () => {
115
- const writer = new MongoDBSecretService(getDb(), { key: kek })
116
- await writer.init()
117
- await writer.setSecret('undecryptable', { token: 'abc' })
118
-
119
- const reader = new MongoDBSecretService(getDb(), { key: otherKek })
120
- await reader.init()
121
-
122
- await assert.rejects(
123
- () => reader.getSecrets(['undecryptable']),
124
- (error: Error) => {
125
- assert.equal(
126
- error.message,
127
- 'Failed to decrypt secret "undecryptable" (key_version 1): ' +
128
- 'the configured KEK does not match the key it was wrapped under'
129
- )
130
- assert.ok(error.cause, 'the underlying crypto failure is preserved')
131
- return true
132
- }
133
- )
134
- })
135
-
136
- test('getSecrets throws when no KEK is available for a stored key_version', async () => {
137
- const writer = new MongoDBSecretService(getDb(), {
138
- key: kek,
139
- keyVersion: 7,
140
- })
141
- await writer.init()
142
- await writer.setSecret('old-version', 'value')
143
-
144
- const reader = new MongoDBSecretService(getDb(), {
145
- key: kek,
146
- keyVersion: 8,
147
- })
148
- await reader.init()
149
-
150
- await assert.rejects(
151
- () => reader.getSecrets(['old-version']),
152
- (error: Error) => {
153
- assert.equal(
154
- error.message,
155
- 'Failed to decrypt secret "old-version" (key_version 7): ' +
156
- 'the configured KEK does not match the key it was wrapped under'
157
- )
158
- return true
159
- }
160
- )
161
- })
162
-
163
- test('getSecrets returns every secret when they all decrypt', async () => {
164
- const service = new MongoDBSecretService(getDb(), { key: kek })
165
- await service.init()
166
- await service.setSecret('batch-a', { v: 1 })
167
- await service.setSecret('batch-b', 'two')
168
-
169
- const out = await service.getSecrets<{
170
- 'batch-a': { v: number }
171
- 'batch-b': string
172
- }>(['batch-a', 'batch-b'])
173
-
174
- assert.deepEqual(out['batch-a']!.reveal(), { v: 1 })
175
- assert.equal(out['batch-b']!.reveal(), 'two')
176
- })
177
-
178
- test('getSecrets omits keys that were never stored', async () => {
179
- const service = new MongoDBSecretService(getDb(), { key: kek })
180
- await service.init()
181
- await service.setSecret('present', 'here')
182
-
183
- const out = await service.getSecrets(['present', 'never-stored'])
184
-
185
- assert.deepEqual(Object.keys(out), ['present'])
186
- assert.equal(out.present!.reveal(), 'here')
187
- })
188
- })
189
- }
190
-
191
- describe('MongoDB Services - In-Memory', () => {
192
- let mongod: MongoMemoryServer
193
- let client: MongoClient
194
- let db: Db
195
-
196
- before(async () => {
197
- mongod = await MongoMemoryServer.create()
198
- const uri = mongod.getUri()
199
- client = new MongoClient(uri)
200
- await client.connect()
201
- db = client.db('pikku_test')
202
- })
203
-
204
- after(async () => {
205
- await client.close()
206
- await mongod.stop()
207
- })
208
-
209
- registerTests('MongoMemory', () => db)
210
- })
211
-
212
- describe(
213
- 'MongoDB Services - Real',
214
- {
215
- skip: !process.env.MONGODB_URL ? 'MONGODB_URL not set' : undefined,
216
- },
217
- () => {
218
- let client: MongoClient
219
- let db: Db
220
-
221
- before(async () => {
222
- client = new MongoClient(process.env.MONGODB_URL!)
223
- await client.connect()
224
- db = client.db('pikku_test')
225
- await db.dropDatabase()
226
- })
227
-
228
- after(async () => {
229
- if (db) await db.dropDatabase()
230
- if (client) await client.close()
231
- })
232
-
233
- registerTests('Real MongoDB', () => db)
234
- }
235
- )
@@ -1,50 +0,0 @@
1
- import type { SessionStore } from '@pikku/core/services'
2
- import type { CoreUserSession } from '@pikku/core/types'
3
- import type { Db, Collection } from 'mongodb'
4
-
5
- interface SessionDoc {
6
- _id: string
7
- session: any
8
- createdAt: Date
9
- updatedAt: Date
10
- }
11
-
12
- export class MongoDBSessionStore implements SessionStore {
13
- private initialized = false
14
- private sessions!: Collection<SessionDoc>
15
-
16
- constructor(private db: Db) {}
17
-
18
- public async init(): Promise<void> {
19
- if (this.initialized) return
20
-
21
- this.sessions = this.db.collection<SessionDoc>('pikkuUserSessions')
22
- this.initialized = true
23
- }
24
-
25
- async get(pikkuUserId: string): Promise<CoreUserSession | undefined> {
26
- const doc = await this.sessions.findOne({ _id: pikkuUserId })
27
- if (!doc) {
28
- return undefined
29
- }
30
- return doc.session as CoreUserSession
31
- }
32
-
33
- async set(pikkuUserId: string, session: CoreUserSession): Promise<void> {
34
- const now = new Date()
35
- await this.sessions.updateOne(
36
- { _id: pikkuUserId },
37
- {
38
- $set: { session, updatedAt: now },
39
- $setOnInsert: { createdAt: now },
40
- },
41
- { upsert: true }
42
- )
43
- }
44
-
45
- async clear(pikkuUserId: string): Promise<void> {
46
- await this.sessions.deleteOne({ _id: pikkuUserId })
47
- }
48
-
49
- public async close(): Promise<void> {}
50
- }
@@ -1,235 +0,0 @@
1
- import type {
2
- WorkflowPlannedStep,
3
- WorkflowRun,
4
- StepState,
5
- WorkflowStatus,
6
- WorkflowRunService,
7
- } from '@pikku/core/workflow'
8
- import type { Db, Collection } from 'mongodb'
9
-
10
- interface WorkflowRunDoc {
11
- _id: string
12
- workflow: string
13
- status: string
14
- input: any
15
- output: any | null
16
- error: any | null
17
- state: Record<string, unknown>
18
- inline: boolean
19
- graphHash: string | null
20
- deterministic?: boolean
21
- plannedSteps?: WorkflowPlannedStep[]
22
- wire: any | null
23
- createdAt: Date
24
- updatedAt: Date
25
- }
26
-
27
- interface WorkflowStepDoc {
28
- _id: string
29
- workflowRunId: string
30
- stepName: string
31
- rpcName: string | null
32
- data: any | null
33
- status: string
34
- result: any | null
35
- error: any | null
36
- branchTaken: string | null
37
- retries: number | null
38
- retryDelay: string | null
39
- createdAt: Date
40
- updatedAt: Date
41
- }
42
-
43
- interface WorkflowStepHistoryDoc {
44
- _id: string
45
- workflowStepId: string
46
- status: string
47
- result: any | null
48
- error: any | null
49
- createdAt: Date
50
- runningAt: Date | null
51
- scheduledAt: Date | null
52
- succeededAt: Date | null
53
- failedAt: Date | null
54
- }
55
-
56
- interface WorkflowVersionDoc {
57
- workflowName: string
58
- graphHash: string
59
- graph: any
60
- source: string
61
- createdAt: Date
62
- }
63
-
64
- export class MongoDBWorkflowRunService implements WorkflowRunService {
65
- private runs: Collection<WorkflowRunDoc>
66
- private steps: Collection<WorkflowStepDoc>
67
- private stepHistory: Collection<WorkflowStepHistoryDoc>
68
- private versions: Collection<WorkflowVersionDoc>
69
-
70
- constructor(db: Db) {
71
- this.runs = db.collection<WorkflowRunDoc>('workflow_runs')
72
- this.steps = db.collection<WorkflowStepDoc>('workflow_step')
73
- this.stepHistory = db.collection<WorkflowStepHistoryDoc>(
74
- 'workflow_step_history'
75
- )
76
- this.versions = db.collection<WorkflowVersionDoc>('workflow_versions')
77
- }
78
-
79
- async listRuns(options?: {
80
- workflowName?: string
81
- status?: string
82
- limit?: number
83
- offset?: number
84
- }): Promise<WorkflowRun[]> {
85
- const { workflowName, status, limit = 50, offset = 0 } = options ?? {}
86
-
87
- const filter: Record<string, any> = {}
88
- if (workflowName) filter.workflow = workflowName
89
- if (status) filter.status = status
90
-
91
- const result = await this.runs
92
- .find(filter)
93
- .sort({ createdAt: -1 })
94
- .skip(offset)
95
- .limit(limit)
96
- .toArray()
97
-
98
- return result.map((row) => this.mapRunRow(row))
99
- }
100
-
101
- async getRun(id: string): Promise<WorkflowRun | null> {
102
- const row = await this.runs.findOne({ _id: id })
103
- if (!row) return null
104
- return this.mapRunRow(row)
105
- }
106
-
107
- async getRunSteps(
108
- runId: string
109
- ): Promise<
110
- Array<StepState & { stepName: string; rpcName?: string; data?: any }>
111
- > {
112
- const result = await this.steps
113
- .find({ workflowRunId: runId })
114
- .sort({ createdAt: 1 })
115
- .toArray()
116
-
117
- const stepIds = result.map((r) => r._id)
118
- const historyCounts = await this.stepHistory
119
- .aggregate<{
120
- _id: string
121
- count: number
122
- }>([
123
- { $match: { workflowStepId: { $in: stepIds } } },
124
- { $group: { _id: '$workflowStepId', count: { $sum: 1 } } },
125
- ])
126
- .toArray()
127
-
128
- const countMap = new Map(historyCounts.map((h) => [h._id, h.count]))
129
-
130
- return result.map((row) => ({
131
- stepId: row._id,
132
- stepName: row.stepName,
133
- rpcName: row.rpcName ?? undefined,
134
- data: row.data ?? undefined,
135
- status: row.status as StepState['status'],
136
- result: row.result ?? undefined,
137
- error: row.error ?? undefined,
138
- attemptCount: countMap.get(row._id) ?? 1,
139
- retries: row.retries != null ? Number(row.retries) : undefined,
140
- retryDelay: row.retryDelay ?? undefined,
141
- createdAt: new Date(row.createdAt),
142
- updatedAt: new Date(row.updatedAt),
143
- }))
144
- }
145
-
146
- async getRunHistory(
147
- runId: string
148
- ): Promise<Array<StepState & { stepName: string }>> {
149
- const steps = await this.steps.find({ workflowRunId: runId }).toArray()
150
-
151
- const stepMap = new Map(steps.map((s) => [s._id, s]))
152
- const stepIds = steps.map((s) => s._id)
153
-
154
- const history = await this.stepHistory
155
- .find({ workflowStepId: { $in: stepIds } })
156
- .sort({ createdAt: 1 })
157
- .toArray()
158
-
159
- let attemptCounters: Record<string, number> = {}
160
- return history.map((row) => {
161
- const stepId = row.workflowStepId
162
- const step = stepMap.get(stepId)!
163
- attemptCounters[stepId] = (attemptCounters[stepId] ?? 0) + 1
164
-
165
- return {
166
- stepId,
167
- stepName: step.stepName,
168
- status: row.status as StepState['status'],
169
- result: row.result ?? undefined,
170
- error: row.error ?? undefined,
171
- attemptCount: attemptCounters[stepId]!,
172
- retries: step.retries != null ? Number(step.retries) : undefined,
173
- retryDelay: step.retryDelay ?? undefined,
174
- createdAt: new Date(row.createdAt),
175
- updatedAt: new Date(row.createdAt),
176
- runningAt: row.runningAt ? new Date(row.runningAt) : undefined,
177
- scheduledAt: row.scheduledAt ? new Date(row.scheduledAt) : undefined,
178
- succeededAt: row.succeededAt ? new Date(row.succeededAt) : undefined,
179
- failedAt: row.failedAt ? new Date(row.failedAt) : undefined,
180
- }
181
- })
182
- }
183
-
184
- async getDistinctWorkflowNames(): Promise<string[]> {
185
- const result = await this.runs.distinct('workflow')
186
- return result.sort()
187
- }
188
-
189
- async getWorkflowVersion(
190
- name: string,
191
- graphHash: string
192
- ): Promise<{ graph: any; source: string } | null> {
193
- const row = await this.versions.findOne({
194
- workflowName: name,
195
- graphHash,
196
- })
197
-
198
- if (!row) return null
199
- return {
200
- graph: row.graph,
201
- source: row.source,
202
- }
203
- }
204
-
205
- async deleteRun(id: string): Promise<boolean> {
206
- const steps = await this.steps.find({ workflowRunId: id }).toArray()
207
- const stepIds = steps.map((s) => s._id)
208
-
209
- if (stepIds.length > 0) {
210
- await this.stepHistory.deleteMany({ workflowStepId: { $in: stepIds } })
211
- await this.steps.deleteMany({ workflowRunId: id })
212
- }
213
-
214
- const result = await this.runs.deleteOne({ _id: id })
215
- return result.deletedCount > 0
216
- }
217
-
218
- private mapRunRow(row: WorkflowRunDoc): WorkflowRun {
219
- return {
220
- id: row._id,
221
- workflow: row.workflow,
222
- status: row.status as WorkflowStatus,
223
- input: row.input,
224
- output: row.output ?? undefined,
225
- error: row.error ?? undefined,
226
- inline: row.inline,
227
- graphHash: row.graphHash ?? undefined,
228
- deterministic: row.deterministic ?? undefined,
229
- plannedSteps: row.plannedSteps ?? undefined,
230
- wire: row.wire ?? { type: 'unknown' },
231
- createdAt: new Date(row.createdAt),
232
- updatedAt: new Date(row.updatedAt),
233
- }
234
- }
235
- }