duroxide 0.1.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.
@@ -0,0 +1,727 @@
1
+ /**
2
+ * duroxide - Node.js SDK for the Duroxide durable execution runtime.
3
+ *
4
+ * Generator-based orchestrations: users write function*(ctx, input) { ... }
5
+ * and yield ScheduledTask descriptors. The Rust runtime handles DurableFutures.
6
+ */
7
+
8
+ // Load native bindings from the auto-generated napi-rs loader
9
+ const { JsSqliteProvider, JsPostgresProvider, JsClient, JsRuntime, activityTraceLog, orchestrationTraceLog, activityIsCancelled } = require('../index.js');
10
+
11
+ // ─── Generator Driver ────────────────────────────────────────────
12
+
13
+ /** @type {Map<number, Generator>} */
14
+ const generators = new Map();
15
+ let nextGeneratorId = 1;
16
+
17
+ /**
18
+ * Registered orchestration functions keyed by name.
19
+ * @type {Map<string, GeneratorFunction>}
20
+ */
21
+ const orchestrationFunctions = new Map();
22
+
23
+ /**
24
+ * Called from Rust via ThreadsafeFunction when starting a new orchestration.
25
+ * Creates a generator, drives it to the first yield, and returns the result.
26
+ *
27
+ * @param {string} payloadJson - JSON: { ctxInfo, input }
28
+ * @returns {string} JSON: GeneratorStepResult
29
+ */
30
+ function createGenerator(payloadJson) {
31
+ try {
32
+ const payload = JSON.parse(payloadJson);
33
+ const { ctxInfo, input } = payload;
34
+
35
+ // Find the orchestration function by name@version, falling back to name
36
+ const orchName = ctxInfo.orchestrationName;
37
+ const orchVersion = ctxInfo.orchestrationVersion;
38
+ const versionedKey = orchVersion ? `${orchName}@${orchVersion}` : null;
39
+ const fn = (versionedKey && orchestrationFunctions.get(versionedKey)) || orchestrationFunctions.get(orchName);
40
+ if (!fn) {
41
+ const err = JSON.stringify({
42
+ status: 'error',
43
+ message: `Orchestration '${orchName}' not registered on JS side`,
44
+ });
45
+ return err;
46
+ }
47
+
48
+ // Create the orchestration context wrapper
49
+ const ctx = new OrchestrationContext(ctxInfo);
50
+
51
+ // Create the generator
52
+ const gen = fn(ctx, input ? JSON.parse(input) : undefined);
53
+
54
+ // Assign an ID and store
55
+ const id = nextGeneratorId++;
56
+ generators.set(id, gen);
57
+
58
+ // Drive to first yield
59
+ return driveStep(id, gen, undefined);
60
+ } catch (e) {
61
+ return JSON.stringify({
62
+ status: 'error',
63
+ message: String(e.stack || e),
64
+ });
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Called from Rust via ThreadsafeFunction to feed a result and get the next step.
70
+ *
71
+ * @param {string} payloadJson - JSON: { generatorId, result, isError }
72
+ * @returns {string} JSON: GeneratorStepResult
73
+ */
74
+ function nextStep(payloadJson) {
75
+ try {
76
+ const { generatorId, result, isError } = JSON.parse(payloadJson);
77
+
78
+ const gen = generators.get(generatorId);
79
+ if (!gen) {
80
+ return JSON.stringify({
81
+ status: 'error',
82
+ message: `Generator ${generatorId} not found`,
83
+ });
84
+ }
85
+
86
+ // Parse the result from Rust
87
+ let value;
88
+ try {
89
+ value = JSON.parse(result);
90
+ } catch {
91
+ value = result;
92
+ }
93
+
94
+ if (isError) {
95
+ return driveStepWithError(generatorId, gen, value);
96
+ }
97
+
98
+ return driveStep(generatorId, gen, value);
99
+ } catch (e) {
100
+ return JSON.stringify({
101
+ status: 'error',
102
+ message: String(e.stack || e),
103
+ });
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Called from Rust to clean up a generator.
109
+ *
110
+ * @param {string} idStr - Generator ID as string
111
+ * @returns {string} "ok"
112
+ */
113
+ function disposeGenerator(idStr) {
114
+ const id = parseInt(idStr, 10);
115
+ generators.delete(id);
116
+ return 'ok';
117
+ }
118
+
119
+ /**
120
+ * Drive a generator one step forward with a value.
121
+ * @returns {string} JSON GeneratorStepResult
122
+ */
123
+ function driveStep(generatorId, gen, value) {
124
+ try {
125
+ const { value: task, done } = gen.next(value);
126
+
127
+ if (done) {
128
+ generators.delete(generatorId);
129
+ return JSON.stringify({
130
+ status: 'completed',
131
+ output: JSON.stringify(task === undefined ? null : task),
132
+ });
133
+ }
134
+
135
+ // task should be a ScheduledTask descriptor
136
+ return JSON.stringify({
137
+ status: 'yielded',
138
+ generatorId,
139
+ task,
140
+ });
141
+ } catch (e) {
142
+ generators.delete(generatorId);
143
+ return JSON.stringify({
144
+ status: 'error',
145
+ message: String(e.stack || e),
146
+ });
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Drive a generator by throwing an error into it.
152
+ * @returns {string} JSON GeneratorStepResult
153
+ */
154
+ function driveStepWithError(generatorId, gen, error) {
155
+ try {
156
+ const { value: task, done } = gen.throw(
157
+ new Error(typeof error === 'string' ? error : JSON.stringify(error))
158
+ );
159
+
160
+ if (done) {
161
+ generators.delete(generatorId);
162
+ return JSON.stringify({
163
+ status: 'completed',
164
+ output: JSON.stringify(task === undefined ? null : task),
165
+ });
166
+ }
167
+
168
+ return JSON.stringify({
169
+ status: 'yielded',
170
+ generatorId,
171
+ task,
172
+ });
173
+ } catch (e) {
174
+ generators.delete(generatorId);
175
+ return JSON.stringify({
176
+ status: 'error',
177
+ message: String(e.stack || e),
178
+ });
179
+ }
180
+ }
181
+
182
+ // ─── OrchestrationContext ────────────────────────────────────────
183
+
184
+ /**
185
+ * Context object passed to orchestration generator functions.
186
+ * Methods that schedule work return ScheduledTask descriptors to be yielded.
187
+ * Logging methods are fire-and-forget (no yield needed).
188
+ */
189
+ class OrchestrationContext {
190
+ constructor(ctxInfo) {
191
+ this.instanceId = ctxInfo.instanceId;
192
+ this.executionId = ctxInfo.executionId;
193
+ this.orchestrationName = ctxInfo.orchestrationName;
194
+ this.orchestrationVersion = ctxInfo.orchestrationVersion;
195
+ }
196
+
197
+ // ─── Scheduling (yield these) ──────────────────────────
198
+
199
+ /**
200
+ * Schedule an activity. Yield the return value.
201
+ * @param {string} name - Activity name
202
+ * @param {*} input - Activity input (will be JSON-serialized)
203
+ * @returns {ScheduledTask}
204
+ */
205
+ scheduleActivity(name, input) {
206
+ return {
207
+ type: 'activity',
208
+ name,
209
+ input: JSON.stringify(input === undefined ? null : input),
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Schedule an activity with retry policy. Yield the return value.
215
+ */
216
+ scheduleActivityWithRetry(name, input, retry) {
217
+ return {
218
+ type: 'activityWithRetry',
219
+ name,
220
+ input: JSON.stringify(input === undefined ? null : input),
221
+ retry: {
222
+ maxAttempts: retry.maxAttempts,
223
+ timeoutMs: retry.timeoutMs,
224
+ totalTimeoutMs: retry.totalTimeoutMs,
225
+ backoff: retry.backoff,
226
+ },
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Schedule a timer (delay in milliseconds). Yield the return value.
232
+ * @param {number} delayMs
233
+ * @returns {ScheduledTask}
234
+ */
235
+ scheduleTimer(delayMs) {
236
+ return { type: 'timer', delayMs };
237
+ }
238
+
239
+ /**
240
+ * Wait for an external event. Yield the return value.
241
+ * @param {string} name - Event name
242
+ * @returns {ScheduledTask}
243
+ */
244
+ waitForEvent(name) {
245
+ return { type: 'waitEvent', name };
246
+ }
247
+
248
+ /**
249
+ * Schedule a sub-orchestration. Yield the return value.
250
+ * @param {string} name - Orchestration name
251
+ * @param {*} input
252
+ * @returns {ScheduledTask}
253
+ */
254
+ scheduleSubOrchestration(name, input) {
255
+ return {
256
+ type: 'subOrchestration',
257
+ name,
258
+ input: JSON.stringify(input === undefined ? null : input),
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Schedule a sub-orchestration with a specific instance ID.
264
+ */
265
+ scheduleSubOrchestrationWithId(name, instanceId, input) {
266
+ return {
267
+ type: 'subOrchestrationWithId',
268
+ name,
269
+ instanceId,
270
+ input: JSON.stringify(input === undefined ? null : input),
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Schedule a versioned sub-orchestration. Yield the return value.
276
+ */
277
+ scheduleSubOrchestrationVersioned(name, version, input) {
278
+ return {
279
+ type: 'subOrchestrationVersioned',
280
+ name,
281
+ version: version || null,
282
+ input: JSON.stringify(input === undefined ? null : input),
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Schedule a versioned sub-orchestration with a specific instance ID. Yield the return value.
288
+ */
289
+ scheduleSubOrchestrationVersionedWithId(name, version, instanceId, input) {
290
+ return {
291
+ type: 'subOrchestrationVersionedWithId',
292
+ name,
293
+ version: version || null,
294
+ instanceId,
295
+ input: JSON.stringify(input === undefined ? null : input),
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Start a detached orchestration (fire-and-forget). Yield the return value.
301
+ * Unlike scheduleSubOrchestration, this does NOT wait for completion.
302
+ * @param {string} name - Orchestration name
303
+ * @param {string} instanceId - Instance ID for the new orchestration
304
+ * @param {*} input
305
+ * @returns {ScheduledTask}
306
+ */
307
+ startOrchestration(name, instanceId, input) {
308
+ return {
309
+ type: 'orchestration',
310
+ name,
311
+ instanceId,
312
+ input: JSON.stringify(input === undefined ? null : input),
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Start a versioned detached orchestration (fire-and-forget). Yield the return value.
318
+ */
319
+ startOrchestrationVersioned(name, version, instanceId, input) {
320
+ return {
321
+ type: 'orchestrationVersioned',
322
+ name,
323
+ version: version || null,
324
+ instanceId,
325
+ input: JSON.stringify(input === undefined ? null : input),
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Get a deterministic GUID. Yield the return value.
331
+ * @returns {ScheduledTask}
332
+ */
333
+ newGuid() {
334
+ return { type: 'newGuid' };
335
+ }
336
+
337
+ /**
338
+ * Get the current deterministic UTC time. Yield the return value.
339
+ * Returns a timestamp in milliseconds.
340
+ * @returns {ScheduledTask}
341
+ */
342
+ utcNow() {
343
+ return { type: 'utcNow' };
344
+ }
345
+
346
+ /**
347
+ * Continue the orchestration as a new instance with new input.
348
+ * @param {*} input
349
+ * @returns {ScheduledTask}
350
+ */
351
+ continueAsNew(input) {
352
+ return {
353
+ type: 'continueAsNew',
354
+ input: JSON.stringify(input === undefined ? null : input),
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Continue the orchestration as a new instance with new input and a specific version.
360
+ */
361
+ continueAsNewVersioned(input, version) {
362
+ return {
363
+ type: 'continueAsNewVersioned',
364
+ input: JSON.stringify(input === undefined ? null : input),
365
+ version: version || null,
366
+ };
367
+ }
368
+
369
+ // ─── Composition helpers ───────────────────────────────
370
+
371
+ /**
372
+ * Join multiple tasks (wait for all). Yield the return value.
373
+ * @param {ScheduledTask[]} tasks - Array of scheduled task descriptors
374
+ * @returns {ScheduledTask}
375
+ */
376
+ all(tasks) {
377
+ return { type: 'join', tasks };
378
+ }
379
+
380
+ /**
381
+ * Select/race multiple tasks (wait for first). Yield the return value.
382
+ * @param {...ScheduledTask} tasks
383
+ * @returns {ScheduledTask}
384
+ */
385
+ race(...tasks) {
386
+ return { type: 'select', tasks };
387
+ }
388
+
389
+ // ─── Logging (fire-and-forget, delegates to Rust ctx.trace()) ───
390
+
391
+ traceInfo(message) {
392
+ orchestrationTraceLog(this.instanceId, 'info', String(message));
393
+ }
394
+
395
+ traceWarn(message) {
396
+ orchestrationTraceLog(this.instanceId, 'warn', String(message));
397
+ }
398
+
399
+ traceError(message) {
400
+ orchestrationTraceLog(this.instanceId, 'error', String(message));
401
+ }
402
+
403
+ traceDebug(message) {
404
+ orchestrationTraceLog(this.instanceId, 'debug', String(message));
405
+ }
406
+ }
407
+
408
+ // ─── Public API ──────────────────────────────────────────────────
409
+
410
+ /**
411
+ * SQLite provider for duroxide.
412
+ */
413
+ class SqliteProvider {
414
+ /** @param {JsSqliteProvider} native */
415
+ constructor(native) {
416
+ this._native = native;
417
+ }
418
+
419
+ /**
420
+ * Open a SQLite database file.
421
+ * @param {string} path
422
+ * @returns {Promise<SqliteProvider>}
423
+ */
424
+ static async open(path) {
425
+ const n = await JsSqliteProvider.open(path);
426
+ return new SqliteProvider(n);
427
+ }
428
+
429
+ /**
430
+ * Create an in-memory SQLite database.
431
+ * @returns {Promise<SqliteProvider>}
432
+ */
433
+ static async inMemory() {
434
+ const n = await JsSqliteProvider.inMemory();
435
+ return new SqliteProvider(n);
436
+ }
437
+ }
438
+
439
+ /**
440
+ * PostgreSQL provider for duroxide.
441
+ */
442
+ class PostgresProvider {
443
+ /** @param {JsPostgresProvider} native */
444
+ constructor(native) {
445
+ this._native = native;
446
+ this._type = 'postgres';
447
+ }
448
+
449
+ /**
450
+ * Connect to a PostgreSQL database (uses "public" schema).
451
+ * @param {string} databaseUrl - e.g. "postgresql://user:pass@host:5432/db"
452
+ * @returns {Promise<PostgresProvider>}
453
+ */
454
+ static async connect(databaseUrl) {
455
+ const n = await JsPostgresProvider.connect(databaseUrl);
456
+ return new PostgresProvider(n);
457
+ }
458
+
459
+ /**
460
+ * Connect to a PostgreSQL database with a custom schema.
461
+ * @param {string} databaseUrl
462
+ * @param {string} schema - Schema name (created if it doesn't exist)
463
+ * @returns {Promise<PostgresProvider>}
464
+ */
465
+ static async connectWithSchema(databaseUrl, schema) {
466
+ const n = await JsPostgresProvider.connectWithSchema(databaseUrl, schema);
467
+ return new PostgresProvider(n);
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Client for starting and managing orchestration instances.
473
+ */
474
+ class Client {
475
+ /**
476
+ * @param {SqliteProvider|PostgresProvider} provider
477
+ */
478
+ constructor(provider) {
479
+ if (provider._type === 'postgres') {
480
+ this._native = JsClient.fromPostgres(provider._native);
481
+ } else {
482
+ this._native = new JsClient(provider._native);
483
+ }
484
+ }
485
+
486
+ async startOrchestration(instanceId, orchestrationName, input) {
487
+ await this._native.startOrchestration(
488
+ instanceId,
489
+ orchestrationName,
490
+ JSON.stringify(input === undefined ? null : input)
491
+ );
492
+ }
493
+
494
+ async startOrchestrationVersioned(instanceId, orchestrationName, input, version) {
495
+ await this._native.startOrchestrationVersioned(
496
+ instanceId,
497
+ orchestrationName,
498
+ JSON.stringify(input === undefined ? null : input),
499
+ version
500
+ );
501
+ }
502
+
503
+ async getStatus(instanceId) {
504
+ return await this._native.getStatus(instanceId);
505
+ }
506
+
507
+ async waitForOrchestration(instanceId, timeoutMs = 30000) {
508
+ const result = await this._native.waitForOrchestration(instanceId, timeoutMs);
509
+ if (result.output) {
510
+ try {
511
+ result.output = JSON.parse(result.output);
512
+ } catch {}
513
+ }
514
+ return result;
515
+ }
516
+
517
+ async cancelInstance(instanceId, reason) {
518
+ await this._native.cancelInstance(instanceId, reason);
519
+ }
520
+
521
+ async raiseEvent(instanceId, eventName, data) {
522
+ await this._native.raiseEvent(
523
+ instanceId,
524
+ eventName,
525
+ JSON.stringify(data === undefined ? null : data)
526
+ );
527
+ }
528
+
529
+ async getSystemMetrics() {
530
+ return await this._native.getSystemMetrics();
531
+ }
532
+
533
+ async getQueueDepths() {
534
+ return await this._native.getQueueDepths();
535
+ }
536
+
537
+ // ─── Management / Admin API ─────────────────────────────
538
+
539
+ async listAllInstances() {
540
+ return await this._native.listAllInstances();
541
+ }
542
+
543
+ async listInstancesByStatus(status) {
544
+ return await this._native.listInstancesByStatus(status);
545
+ }
546
+
547
+ async getInstanceInfo(instanceId) {
548
+ const info = await this._native.getInstanceInfo(instanceId);
549
+ if (info.output) {
550
+ try { info.output = JSON.parse(info.output); } catch {}
551
+ }
552
+ return info;
553
+ }
554
+
555
+ async getExecutionInfo(instanceId, executionId) {
556
+ const info = await this._native.getExecutionInfo(instanceId, executionId);
557
+ if (info.output) {
558
+ try { info.output = JSON.parse(info.output); } catch {}
559
+ }
560
+ return info;
561
+ }
562
+
563
+ async listExecutions(instanceId) {
564
+ return await this._native.listExecutions(instanceId);
565
+ }
566
+
567
+ async readExecutionHistory(instanceId, executionId) {
568
+ return await this._native.readExecutionHistory(instanceId, executionId);
569
+ }
570
+
571
+ async getInstanceTree(instanceId) {
572
+ return await this._native.getInstanceTree(instanceId);
573
+ }
574
+
575
+ async deleteInstance(instanceId, force = false) {
576
+ return await this._native.deleteInstance(instanceId, force);
577
+ }
578
+
579
+ async deleteInstanceBulk(filter = {}) {
580
+ return await this._native.deleteInstanceBulk(filter);
581
+ }
582
+
583
+ async pruneExecutions(instanceId, options = {}) {
584
+ return await this._native.pruneExecutions(instanceId, options);
585
+ }
586
+
587
+ async pruneExecutionsBulk(filter = {}, options = {}) {
588
+ return await this._native.pruneExecutionsBulk(filter, options);
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Durable execution runtime.
594
+ */
595
+ class Runtime {
596
+ /**
597
+ * @param {SqliteProvider|PostgresProvider} provider
598
+ * @param {object} [options]
599
+ */
600
+ constructor(provider, options) {
601
+ if (provider._type === 'postgres') {
602
+ this._native = JsRuntime.fromPostgres(provider._native, options);
603
+ } else {
604
+ this._native = new JsRuntime(provider._native, options);
605
+ }
606
+ // Wire up the generator driver functions
607
+ this._native.setGeneratorDriver(createGenerator, nextStep, disposeGenerator);
608
+ }
609
+
610
+ /**
611
+ * Register an activity function.
612
+ * @param {string} name
613
+ * @param {(ctx: ActivityContext, input: any) => Promise<any>} fn
614
+ */
615
+ registerActivity(name, fn) {
616
+ // Wrap the JS function to match the native callback signature
617
+ const wrappedFn = async (payload) => {
618
+ const newlineIdx = payload.indexOf('\n');
619
+ const ctxInfoStr = payload.substring(0, newlineIdx);
620
+ const inputStr = payload.substring(newlineIdx + 1);
621
+ const ctxInfo = JSON.parse(ctxInfoStr);
622
+ const ctx = new ActivityContext(ctxInfo);
623
+
624
+ let input;
625
+ try {
626
+ input = JSON.parse(inputStr);
627
+ } catch {
628
+ input = inputStr;
629
+ }
630
+
631
+ const result = await fn(ctx, input);
632
+ return JSON.stringify(result === undefined ? null : result);
633
+ };
634
+
635
+ this._native.registerActivity(name, wrappedFn);
636
+ }
637
+
638
+ /**
639
+ * Register an orchestration generator function.
640
+ * @param {string} name
641
+ * @param {GeneratorFunction} fn - function*(ctx, input) { ... }
642
+ */
643
+ registerOrchestration(name, fn) {
644
+ orchestrationFunctions.set(name, fn);
645
+ this._native.registerOrchestration(name);
646
+ }
647
+
648
+ /**
649
+ * Register a versioned orchestration generator function.
650
+ * @param {string} name
651
+ * @param {string} version
652
+ * @param {GeneratorFunction} fn
653
+ */
654
+ registerOrchestrationVersioned(name, version, fn) {
655
+ const key = `${name}@${version}`;
656
+ orchestrationFunctions.set(key, fn);
657
+ // Don't overwrite the plain name key — it serves as the fallback
658
+ // for versions registered via registerOrchestration() (unversioned).
659
+ this._native.registerOrchestrationVersioned(name, version);
660
+ }
661
+
662
+ /**
663
+ * Start the runtime. Blocks until shutdown is called.
664
+ */
665
+ async start() {
666
+ await this._native.start();
667
+ }
668
+
669
+ /**
670
+ * Shutdown the runtime gracefully.
671
+ * @param {number} [timeoutMs]
672
+ */
673
+ async shutdown(timeoutMs) {
674
+ await this._native.shutdown(timeoutMs);
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Context for activity execution.
680
+ */
681
+ class ActivityContext {
682
+ constructor(ctxInfo) {
683
+ this.instanceId = ctxInfo.instanceId;
684
+ this.executionId = ctxInfo.executionId;
685
+ this.orchestrationName = ctxInfo.orchestrationName;
686
+ this.orchestrationVersion = ctxInfo.orchestrationVersion;
687
+ this.activityName = ctxInfo.activityName;
688
+ this.workerId = ctxInfo.workerId;
689
+ this._traceToken = ctxInfo._traceToken;
690
+ }
691
+
692
+ traceInfo(message) {
693
+ activityTraceLog(this._traceToken, 'info', String(message));
694
+ }
695
+
696
+ traceWarn(message) {
697
+ activityTraceLog(this._traceToken, 'warn', String(message));
698
+ }
699
+
700
+ traceError(message) {
701
+ activityTraceLog(this._traceToken, 'error', String(message));
702
+ }
703
+
704
+ traceDebug(message) {
705
+ activityTraceLog(this._traceToken, 'debug', String(message));
706
+ }
707
+
708
+ /**
709
+ * Check if this activity has been cancelled (e.g., lost a race/select).
710
+ * Use this for cooperative cancellation in long-running activities.
711
+ * @returns {boolean}
712
+ */
713
+ isCancelled() {
714
+ return activityIsCancelled(this._traceToken);
715
+ }
716
+ }
717
+
718
+ // ─── Exports ─────────────────────────────────────────────────────
719
+
720
+ module.exports = {
721
+ SqliteProvider,
722
+ PostgresProvider,
723
+ Client,
724
+ Runtime,
725
+ OrchestrationContext,
726
+ ActivityContext,
727
+ };