@pikku/mongodb 0.12.21 → 0.12.23

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,687 +0,0 @@
1
- import type { SerializedError } from '@pikku/core/errors'
2
- import {
3
- PikkuWorkflowService,
4
- WorkflowStepFunctionMismatchError,
5
- } from '@pikku/core/workflow'
6
- import type {
7
- WorkflowPlannedStep,
8
- WorkflowQueueOptions,
9
- WorkflowRun,
10
- WorkflowRunWire,
11
- StepState,
12
- StepStatus,
13
- WorkflowStatus,
14
- WorkflowVersionStatus,
15
- } from '@pikku/core/workflow'
16
- import type { Db, Collection } from 'mongodb'
17
- import { MongoDBWorkflowRunService } from './mongodb-workflow-run-service.js'
18
-
19
- interface WorkflowRunDoc {
20
- _id: string
21
- workflow: string
22
- status: string
23
- input: any
24
- output: any | null
25
- error: any | null
26
- state: Record<string, unknown>
27
- inline: boolean
28
- graphHash: string | null
29
- deterministic?: boolean
30
- plannedSteps?: WorkflowPlannedStep[]
31
- wire: any | null
32
- createdAt: Date
33
- updatedAt: Date
34
- }
35
-
36
- interface WorkflowStepDoc {
37
- _id: string
38
- workflowRunId: string
39
- stepName: string
40
- rpcName: string | null
41
- data: any | null
42
- status: string
43
- result: any | null
44
- error: any | null
45
- childRunId?: string
46
- branchTaken: string | null
47
- retries: number | null
48
- retryDelay: string | null
49
- fromStepName?: string | null
50
- createdAt: Date
51
- updatedAt: Date
52
- }
53
-
54
- interface WorkflowStepHistoryDoc {
55
- _id: string
56
- workflowStepId: string
57
- status: string
58
- result: any | null
59
- error: any | null
60
- createdAt: Date
61
- runningAt: Date | null
62
- scheduledAt: Date | null
63
- succeededAt: Date | null
64
- failedAt: Date | null
65
- }
66
-
67
- interface WorkflowVersionDoc {
68
- workflowName: string
69
- graphHash: string
70
- graph: any
71
- source: string
72
- status: string
73
- createdAt: Date
74
- }
75
-
76
- export class MongoDBWorkflowService extends PikkuWorkflowService {
77
- private initialized = false
78
- private runService: MongoDBWorkflowRunService
79
- private runs: Collection<WorkflowRunDoc>
80
- private steps: Collection<WorkflowStepDoc>
81
- private stepHistory: Collection<WorkflowStepHistoryDoc>
82
- private versions: Collection<WorkflowVersionDoc>
83
-
84
- constructor(db: Db, options: WorkflowQueueOptions = {}) {
85
- super(options)
86
- this.runService = new MongoDBWorkflowRunService(db)
87
- this.runs = db.collection<WorkflowRunDoc>('workflow_runs')
88
- this.steps = db.collection<WorkflowStepDoc>('workflow_step')
89
- this.stepHistory = db.collection<WorkflowStepHistoryDoc>(
90
- 'workflow_step_history'
91
- )
92
- this.versions = db.collection<WorkflowVersionDoc>('workflow_versions')
93
- }
94
-
95
- public async init(): Promise<void> {
96
- if (this.initialized) return
97
-
98
- await this.runs.createIndex({ workflow: 1 })
99
- await this.runs.createIndex({ status: 1 })
100
- await this.runs.createIndex({ createdAt: -1 })
101
-
102
- await this.steps.createIndex(
103
- { workflowRunId: 1, stepName: 1 },
104
- { unique: true }
105
- )
106
- await this.steps.createIndex({ workflowRunId: 1 })
107
-
108
- await this.stepHistory.createIndex({ workflowStepId: 1 })
109
- await this.stepHistory.createIndex({ createdAt: 1 })
110
-
111
- await this.versions.createIndex(
112
- { workflowName: 1, graphHash: 1 },
113
- { unique: true }
114
- )
115
-
116
- this.initialized = true
117
- }
118
-
119
- protected async createRunImpl(
120
- workflowName: string,
121
- input: any,
122
- inline: boolean,
123
- graphHash: string,
124
- wire: WorkflowRunWire,
125
- options?: {
126
- deterministic?: boolean
127
- plannedSteps?: WorkflowPlannedStep[]
128
- }
129
- ): Promise<string> {
130
- const id = crypto.randomUUID()
131
- const now = new Date()
132
-
133
- await this.runs.insertOne({
134
- _id: id,
135
- workflow: workflowName,
136
- status: 'running',
137
- input,
138
- output: null,
139
- error: null,
140
- state: {},
141
- inline,
142
- graphHash,
143
- deterministic: options?.deterministic ?? false,
144
- plannedSteps: options?.plannedSteps ?? [],
145
- wire,
146
- createdAt: now,
147
- updatedAt: now,
148
- })
149
-
150
- return id
151
- }
152
-
153
- async getRun(id: string): Promise<WorkflowRun | null> {
154
- return this.runService.getRun(id)
155
- }
156
-
157
- protected async updateRunStatusImpl(
158
- id: string,
159
- status: WorkflowStatus,
160
- output?: any,
161
- error?: SerializedError
162
- ): Promise<void> {
163
- await this.runs.updateOne(
164
- { _id: id },
165
- {
166
- $set: {
167
- status,
168
- output: output ?? null,
169
- error: error ?? null,
170
- updatedAt: new Date(),
171
- },
172
- }
173
- )
174
- }
175
-
176
- protected async insertStepStateImpl(
177
- runId: string,
178
- stepName: string,
179
- rpcName: string | null,
180
- data: any,
181
- stepOptions?: { retries?: number; retryDelay?: string | number },
182
- fromStepName?: string
183
- ): Promise<StepState> {
184
- const stepId = crypto.randomUUID()
185
- const now = new Date()
186
-
187
- await this.steps.insertOne({
188
- _id: stepId,
189
- workflowRunId: runId,
190
- stepName,
191
- rpcName,
192
- data: data ?? null,
193
- status: 'pending',
194
- result: null,
195
- error: null,
196
- branchTaken: null,
197
- retries: stepOptions?.retries ?? null,
198
- retryDelay: stepOptions?.retryDelay?.toString() ?? null,
199
- fromStepName: fromStepName ?? null,
200
- createdAt: now,
201
- updatedAt: now,
202
- })
203
-
204
- await this.insertHistoryRecord(stepId, 'pending')
205
-
206
- return {
207
- stepId,
208
- status: 'pending',
209
- rpcName,
210
- result: undefined,
211
- error: undefined,
212
- attemptCount: 1,
213
- retries: stepOptions?.retries,
214
- retryDelay: stepOptions?.retryDelay?.toString(),
215
- fromStepName,
216
- createdAt: now,
217
- updatedAt: now,
218
- }
219
- }
220
-
221
- async getStepState(runId: string, stepName: string): Promise<StepState> {
222
- const row = await this.steps.findOne({
223
- workflowRunId: runId,
224
- stepName,
225
- })
226
-
227
- if (!row) {
228
- throw new Error(
229
- `Step not found: runId=${runId}, stepName=${stepName}. Use insertStepState to create it.`
230
- )
231
- }
232
-
233
- const attemptCount = await this.stepHistory.countDocuments({
234
- workflowStepId: row._id,
235
- })
236
-
237
- return {
238
- stepId: row._id,
239
- status: row.status as StepState['status'],
240
- rpcName: row.rpcName ?? null,
241
- result: row.result ?? undefined,
242
- error: row.error ?? undefined,
243
- attemptCount,
244
- retries: row.retries != null ? Number(row.retries) : undefined,
245
- retryDelay: row.retryDelay ?? undefined,
246
- fromStepName: row.fromStepName ?? undefined,
247
- createdAt: new Date(row.createdAt),
248
- updatedAt: new Date(row.updatedAt),
249
- }
250
- }
251
-
252
- async getRunHistory(
253
- runId: string
254
- ): Promise<Array<StepState & { stepName: string }>> {
255
- return this.runService.getRunHistory(runId)
256
- }
257
-
258
- protected async setStepRunningImpl(stepId: string): Promise<void> {
259
- await this.steps.updateOne(
260
- { _id: stepId },
261
- { $set: { status: 'running', updatedAt: new Date() } }
262
- )
263
- await this.writeLatestHistory(stepId, 'running')
264
- }
265
-
266
- protected async setStepScheduledImpl(stepId: string): Promise<void> {
267
- await this.steps.updateOne(
268
- { _id: stepId },
269
- { $set: { status: 'scheduled', updatedAt: new Date() } }
270
- )
271
- await this.writeLatestHistory(stepId, 'scheduled')
272
- }
273
-
274
- /**
275
- * Move the current attempt of a step's history to a status.
276
- *
277
- * Finding the newest attempt and then updating it by `_id` was two round
278
- * trips with a window in between; `findOneAndUpdate` applies the same sort
279
- * server-side and writes the row it selected, so nothing can move underneath
280
- * it. The attempt is still identified by `createdAt` because Mongo keeps no
281
- * attempt number — see `getStepState`, which counts history documents.
282
- */
283
- private async writeLatestHistory(
284
- stepId: string,
285
- status: string,
286
- values: Record<string, unknown> = {}
287
- ): Promise<void> {
288
- const now = new Date()
289
- const update: Record<string, unknown> = { status, ...values }
290
- const timestampField = this.getTimestampFieldForStatus(status)
291
- if (timestampField !== 'createdAt') {
292
- update[timestampField] = now
293
- }
294
-
295
- await this.stepHistory.findOneAndUpdate(
296
- { workflowStepId: stepId },
297
- { $set: update as any },
298
- { sort: { createdAt: -1 } }
299
- )
300
- }
301
-
302
- private async insertHistoryRecord(
303
- stepId: string,
304
- status: string,
305
- result?: any,
306
- error?: SerializedError
307
- ): Promise<void> {
308
- const now = new Date()
309
- const doc: any = {
310
- _id: crypto.randomUUID(),
311
- workflowStepId: stepId,
312
- status,
313
- result: result ?? null,
314
- error: error ?? null,
315
- createdAt: now,
316
- runningAt: null,
317
- scheduledAt: null,
318
- succeededAt: null,
319
- failedAt: null,
320
- }
321
-
322
- const timestampField = this.getTimestampFieldForStatus(status)
323
- if (timestampField !== 'createdAt') {
324
- doc[timestampField] = now
325
- }
326
-
327
- await this.stepHistory.insertOne(doc)
328
- }
329
-
330
- private getTimestampFieldForStatus(status: string): string {
331
- switch (status) {
332
- case 'running':
333
- return 'runningAt'
334
- case 'scheduled':
335
- return 'scheduledAt'
336
- case 'succeeded':
337
- return 'succeededAt'
338
- case 'failed':
339
- return 'failedAt'
340
- default:
341
- return 'createdAt'
342
- }
343
- }
344
-
345
- protected async setStepChildRunIdImpl(
346
- stepId: string,
347
- childRunId: string
348
- ): Promise<void> {
349
- await this.steps.updateOne(
350
- { _id: stepId },
351
- {
352
- $set: {
353
- childRunId,
354
- updatedAt: new Date(),
355
- },
356
- }
357
- )
358
- }
359
-
360
- protected async setStepResultImpl(
361
- stepId: string,
362
- result: any
363
- ): Promise<void> {
364
- await this.steps.updateOne(
365
- { _id: stepId },
366
- {
367
- $set: {
368
- status: 'succeeded',
369
- result,
370
- error: null,
371
- updatedAt: new Date(),
372
- },
373
- }
374
- )
375
-
376
- await this.writeLatestHistory(stepId, 'succeeded', { result })
377
- }
378
-
379
- protected async setStepErrorImpl(
380
- stepId: string,
381
- error: Error
382
- ): Promise<void> {
383
- const serializedError: SerializedError = {
384
- message: error.message,
385
- stack: error.stack,
386
- code: (error as any).code,
387
- }
388
-
389
- await this.steps.updateOne(
390
- { _id: stepId },
391
- {
392
- $set: {
393
- status: 'failed',
394
- error: serializedError,
395
- result: null,
396
- updatedAt: new Date(),
397
- },
398
- }
399
- )
400
-
401
- await this.writeLatestHistory(stepId, 'failed', {
402
- error: serializedError,
403
- })
404
- }
405
-
406
- protected async createRetryAttemptImpl(
407
- stepId: string,
408
- status: 'pending' | 'running'
409
- ): Promise<StepState> {
410
- await this.steps.updateOne(
411
- { _id: stepId },
412
- {
413
- $set: {
414
- status,
415
- result: null,
416
- error: null,
417
- updatedAt: new Date(),
418
- },
419
- }
420
- )
421
-
422
- await this.insertHistoryRecord(stepId, status)
423
-
424
- const row = await this.steps.findOne({ _id: stepId })
425
- if (!row) throw new Error(`Step not found: ${stepId}`)
426
-
427
- const attemptCount = await this.stepHistory.countDocuments({
428
- workflowStepId: stepId,
429
- })
430
-
431
- return {
432
- stepId: row._id,
433
- status: row.status as StepState['status'],
434
- rpcName: row.rpcName ?? null,
435
- result: row.result ?? undefined,
436
- error: row.error ?? undefined,
437
- attemptCount,
438
- retries: row.retries != null ? Number(row.retries) : undefined,
439
- retryDelay: row.retryDelay ?? undefined,
440
- fromStepName: row.fromStepName ?? undefined,
441
- createdAt: new Date(row.createdAt),
442
- updatedAt: new Date(row.updatedAt),
443
- }
444
- }
445
-
446
- async withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
447
- return fn()
448
- }
449
-
450
- /**
451
- * A pass-through: MongoDB has no advisory-lock primitive to build one on.
452
- * The one decision that must exclude — claiming a step to execute it — is
453
- * made by `claimStepForExecution` below as a single conditional write, which
454
- * needs no lock to be exclusive.
455
- */
456
- async withStepLock<T>(
457
- _runId: string,
458
- _stepName: string,
459
- fn: () => Promise<T>
460
- ): Promise<T> {
461
- return fn()
462
- }
463
-
464
- /**
465
- * Claim the step with a status-guarded update and read the modified count:
466
- * a single-document update is atomic in MongoDB, so two dispatches racing for
467
- * the same step cannot both proceed.
468
- *
469
- * This replaces the read-then-write the base engine does under
470
- * `withStepLock`, which excludes nothing here — the lock above is a
471
- * pass-through. The winner then goes through the ordinary transition methods,
472
- * so history rows see exactly what they saw before.
473
- */
474
- protected override async claimStepForExecution(
475
- runId: string,
476
- stepName: string,
477
- rpcName: string
478
- ): Promise<StepState | null> {
479
- const stepState = await this.getStepState(runId, stepName)
480
-
481
- // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
482
- if (
483
- stepState.rpcName !== undefined &&
484
- stepState.rpcName !== (rpcName ?? null)
485
- ) {
486
- throw new WorkflowStepFunctionMismatchError(runId, stepName)
487
- }
488
-
489
- if (stepState.status === 'succeeded' || stepState.status === 'running') {
490
- return null
491
- }
492
-
493
- if (stepState.status === 'failed') {
494
- if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
495
- return null
496
- }
497
- return this.createRetryAttempt(stepState.stepId, 'running')
498
- }
499
-
500
- if (stepState.status === 'pending' || stepState.status === 'scheduled') {
501
- if (
502
- !(await this.claimStepStatus(stepState.stepId, [
503
- 'pending',
504
- 'scheduled',
505
- ]))
506
- ) {
507
- return null
508
- }
509
- await this.setStepRunning(stepState.stepId)
510
- return { ...stepState, status: 'running' }
511
- }
512
-
513
- return stepState
514
- }
515
-
516
- /**
517
- * Move a step to `running` only if it is still in one of `from`, reporting
518
- * whether this caller is the one that moved it.
519
- */
520
- private async claimStepStatus(
521
- stepId: string,
522
- from: StepStatus[]
523
- ): Promise<boolean> {
524
- const claimed = await this.steps.updateOne(
525
- { _id: stepId, status: { $in: from } },
526
- { $set: { status: 'running', updatedAt: new Date() } }
527
- )
528
-
529
- return claimed.modifiedCount > 0
530
- }
531
-
532
- async getCompletedGraphState(runId: string): Promise<{
533
- completedNodeIds: string[]
534
- failedNodeIds: string[]
535
- branchKeys: Record<string, string>
536
- }> {
537
- const results = await this.steps
538
- .find({
539
- workflowRunId: runId,
540
- status: { $in: ['succeeded', 'failed'] },
541
- })
542
- .toArray()
543
-
544
- const completedNodeIds: string[] = []
545
- const failedNodeIds: string[] = []
546
- const branchKeys: Record<string, string> = {}
547
-
548
- for (const row of results) {
549
- const nodeId = row.stepName
550
-
551
- if (row.status === 'succeeded') {
552
- completedNodeIds.push(nodeId)
553
- if (row.branchTaken) {
554
- branchKeys[nodeId] = row.branchTaken
555
- }
556
- } else if (row.status === 'failed') {
557
- const maxAttempts = (row.retries ?? 0) + 1
558
- const attemptCount = await this.stepHistory.countDocuments({
559
- workflowStepId: row._id,
560
- })
561
- if (attemptCount >= maxAttempts) {
562
- failedNodeIds.push(nodeId)
563
- }
564
- }
565
- }
566
-
567
- return { completedNodeIds, failedNodeIds, branchKeys }
568
- }
569
-
570
- async getStepInstances(
571
- runId: string
572
- ): Promise<
573
- Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
574
- > {
575
- const rows = await this.steps
576
- .find({ workflowRunId: runId })
577
- .project({ stepName: 1, status: 1, fromStepName: 1 })
578
- .toArray()
579
- return rows.map((r: any) => ({
580
- stepName: r.stepName,
581
- status: r.status as StepStatus,
582
- fromStepName: r.fromStepName ?? undefined,
583
- }))
584
- }
585
-
586
- async getNodeResults(
587
- runId: string,
588
- nodeIds: string[]
589
- ): Promise<Record<string, any>> {
590
- if (nodeIds.length === 0) return {}
591
-
592
- const result = await this.steps
593
- .find({
594
- workflowRunId: runId,
595
- stepName: { $in: nodeIds },
596
- status: 'succeeded',
597
- })
598
- .toArray()
599
-
600
- const results: Record<string, any> = {}
601
- for (const row of result) {
602
- results[row.stepName] = row.result
603
- }
604
- return results
605
- }
606
-
607
- protected async setBranchTakenImpl(
608
- stepId: string,
609
- branchKey: string
610
- ): Promise<void> {
611
- await this.steps.updateOne(
612
- { _id: stepId },
613
- { $set: { branchTaken: branchKey, updatedAt: new Date() } }
614
- )
615
- }
616
-
617
- protected async updateRunStateImpl(
618
- runId: string,
619
- name: string,
620
- value: unknown
621
- ): Promise<void> {
622
- if (!/^[a-zA-Z0-9_]+$/.test(name)) {
623
- throw new Error('Invalid state key name')
624
- }
625
- await this.runs.updateOne(
626
- { _id: runId },
627
- {
628
- $set: {
629
- [`state.${name}`]: value,
630
- updatedAt: new Date(),
631
- },
632
- }
633
- )
634
- }
635
-
636
- async getRunState(runId: string): Promise<Record<string, unknown>> {
637
- const row = await this.runs.findOne(
638
- { _id: runId },
639
- { projection: { state: 1 } }
640
- )
641
- if (!row) return {}
642
- return row.state ?? {}
643
- }
644
-
645
- protected async upsertWorkflowVersionImpl(
646
- name: string,
647
- graphHash: string,
648
- graph: any,
649
- source: string,
650
- status?: WorkflowVersionStatus
651
- ): Promise<void> {
652
- await this.versions.updateOne(
653
- { workflowName: name, graphHash },
654
- {
655
- $setOnInsert: {
656
- workflowName: name,
657
- graphHash,
658
- graph,
659
- source,
660
- status: status ?? 'active',
661
- createdAt: new Date(),
662
- },
663
- },
664
- { upsert: true }
665
- )
666
- }
667
-
668
- protected async updateWorkflowVersionStatusImpl(
669
- name: string,
670
- graphHash: string,
671
- status: WorkflowVersionStatus
672
- ): Promise<void> {
673
- await this.versions.updateOne(
674
- { workflowName: name, graphHash },
675
- { $set: { status } }
676
- )
677
- }
678
-
679
- async getWorkflowVersion(
680
- name: string,
681
- graphHash: string
682
- ): Promise<{ graph: any; source: string } | null> {
683
- return this.runService.getWorkflowVersion(name, graphHash)
684
- }
685
-
686
- async close(): Promise<void> {}
687
- }
@@ -1,41 +0,0 @@
1
- import type { Logger } from '@pikku/core/services'
2
- import { MongoClient, type MongoClientOptions, type Db } from 'mongodb'
3
-
4
- export class PikkuMongoDB {
5
- public db: Db
6
- private client: MongoClient
7
- private ownsConnection: boolean
8
-
9
- constructor(
10
- private logger: Logger,
11
- clientOrUri: MongoClient | string,
12
- private dbName: string,
13
- options?: MongoClientOptions
14
- ) {
15
- if (typeof clientOrUri === 'string') {
16
- this.client = new MongoClient(clientOrUri, options)
17
- this.ownsConnection = true
18
- } else {
19
- this.client = clientOrUri
20
- this.ownsConnection = false
21
- }
22
- this.db = this.client.db(dbName)
23
- }
24
-
25
- public async init() {
26
- this.logger.info(`Connecting to MongoDB database: ${this.dbName}`)
27
- try {
28
- await this.client.db('admin').command({ ping: 1 })
29
- this.logger.info('MongoDB connection successful')
30
- } catch (error) {
31
- this.logger.error('Error connecting to MongoDB', error)
32
- throw error
33
- }
34
- }
35
-
36
- public async close() {
37
- if (this.ownsConnection) {
38
- await this.client.close()
39
- }
40
- }
41
- }