@reboot-dev/reboot 0.29.5 → 0.31.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.
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { errors_pb, IdempotencyOptions, protobuf_es, ScheduleOptions, tasks_pb } from "@reboot-dev/reboot-api";
1
+ import { errors_pb, IdempotencyOptions, protobuf_es, tasks_pb } from "@reboot-dev/reboot-api";
2
2
  import { z } from "zod/v4";
3
3
  import * as reboot_native from "./reboot_native.cjs";
4
4
  import { Application as ExpressApplication, NextFunction as ExpressNextFunction, Request as ExpressRequest, Response as ExpressResponse } from "express";
@@ -42,6 +42,9 @@ export declare class ExternalContext {
42
42
  generateIdempotentStateId(stateType: string, serviceName: string, method: string, idempotency: IdempotencyOptions): Promise<string>;
43
43
  }
44
44
  export declare function getContext(): Context;
45
+ export declare function isWithinUntil(): boolean;
46
+ export declare function isWithinLoop(): boolean;
47
+ export declare function getLoopIteration(): number;
45
48
  export declare function runWithContext<T>(context: Context, callback: () => T): Promise<T>;
46
49
  export declare class Context {
47
50
  #private;
@@ -52,7 +55,6 @@ export declare class Context {
52
55
  get auth(): Auth | null;
53
56
  get stateId(): string;
54
57
  get callerBearerToken(): string | null;
55
- get iteration(): number;
56
58
  get cookie(): string;
57
59
  get appInternal(): boolean;
58
60
  generateIdempotentStateId(stateType: string, serviceName: string, method: string, idempotency: IdempotencyOptions): Promise<any>;
@@ -70,9 +72,19 @@ export declare class TransactionContext extends Context {
70
72
  #private;
71
73
  constructor(external: any, cancelled: any);
72
74
  }
75
+ export type Interval = {
76
+ ms?: number;
77
+ secs?: number;
78
+ mins?: number;
79
+ hours?: number;
80
+ days?: number;
81
+ };
73
82
  export declare class WorkflowContext extends Context {
74
83
  #private;
75
84
  constructor(external: any, cancelled: any);
85
+ loop(alias: string, { interval }?: {
86
+ interval?: Interval;
87
+ }): AsyncGenerator<number, void, unknown>;
76
88
  }
77
89
  export declare function clearField(field: any, target: any): void;
78
90
  export declare function clearFields(target: any): void;
@@ -214,18 +226,50 @@ export declare namespace Application {
214
226
  post(path: Http.Path, ...handlers: Http.Handler[]): void;
215
227
  }
216
228
  }
217
- export declare class Loop {
218
- when: Date;
219
- constructor(options?: ScheduleOptions);
220
- }
221
- export declare function retry_reactively_until(context: WorkflowContext, condition: () => Promise<boolean>): Promise<void>;
222
- export declare function retry_reactively_until<T>(context: WorkflowContext, condition: () => Promise<false | Exclude<T, boolean>>): Promise<Exclude<T, boolean>>;
223
- export declare function atMostOnce(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<void>, options?: {
229
+ export declare function retryReactivelyUntil(context: WorkflowContext, condition: () => Promise<boolean>): Promise<void>;
230
+ export declare function retryReactivelyUntil<T>(context: WorkflowContext, condition: () => Promise<false | Exclude<T, boolean>>): Promise<Exclude<T, boolean>>;
231
+ export declare const ALWAYS: "ALWAYS";
232
+ export declare const PER_WORKFLOW: "PER_WORKFLOW";
233
+ export declare const PER_ITERATION: "PER_ITERATION";
234
+ export type How = "ALWAYS" | "PER_WORKFLOW" | "PER_ITERATION";
235
+ export type AtMostLeastOnceTupleType = [
236
+ string,
237
+ "PER_WORKFLOW" | "PER_ITERATION"
238
+ ];
239
+ export declare function atMostOnce(idempotencyAliasOrTuple: string | AtMostLeastOnceTupleType, context: WorkflowContext, callable: () => Promise<void>, options?: {
240
+ stringify?: undefined;
241
+ parse?: undefined;
242
+ validate?: undefined;
243
+ }): Promise<void>;
244
+ export declare function atMostOnce<T>(idempotencyAliasOrTuple: string | AtMostLeastOnceTupleType, context: WorkflowContext, callable: () => Promise<T>, options: {
245
+ stringify?: (result: T) => string;
246
+ parse: (value: string) => T;
247
+ validate?: undefined;
248
+ } | {
249
+ stringify?: (result: T) => string;
250
+ parse?: undefined;
251
+ validate: (result: T) => boolean;
252
+ }): Promise<T>;
253
+ export declare function atMostOncePerWorkflow(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<void>, options?: {
254
+ stringify?: undefined;
255
+ parse?: undefined;
256
+ validate?: undefined;
257
+ }): Promise<void>;
258
+ export declare function atMostOncePerWorkflow<T>(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<T>, options: {
259
+ stringify?: (result: T) => string;
260
+ parse: (value: string) => T;
261
+ validate?: undefined;
262
+ } | {
263
+ stringify?: (result: T) => string;
264
+ parse?: undefined;
265
+ validate: (result: T) => boolean;
266
+ }): Promise<T>;
267
+ export declare function atLeastOnce(idempotencyAliasOrTuple: string | AtMostLeastOnceTupleType, context: WorkflowContext, callable: () => Promise<void>, options?: {
224
268
  stringify?: undefined;
225
269
  parse?: undefined;
226
270
  validate?: undefined;
227
271
  }): Promise<void>;
228
- export declare function atMostOnce<T>(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<T>, options: {
272
+ export declare function atLeastOnce<T>(idempotencyAliasOrTuple: string | AtMostLeastOnceTupleType, context: WorkflowContext, callable: () => Promise<T>, options: {
229
273
  stringify?: (result: T) => string;
230
274
  parse: (value: string) => T;
231
275
  validate?: undefined;
@@ -234,12 +278,12 @@ export declare function atMostOnce<T>(idempotencyAlias: string, context: Workflo
234
278
  parse?: undefined;
235
279
  validate: (result: T) => boolean;
236
280
  }): Promise<T>;
237
- export declare function atLeastOnce(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<void>, options?: {
281
+ export declare function atLeastOncePerWorkflow(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<void>, options?: {
238
282
  stringify?: undefined;
239
283
  parse?: undefined;
240
284
  validate?: undefined;
241
285
  }): Promise<void>;
242
- export declare function atLeastOnce<T>(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<T>, options: {
286
+ export declare function atLeastOncePerWorkflow<T>(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<T>, options: {
243
287
  stringify?: (result: T) => string;
244
288
  parse: (value: string) => T;
245
289
  validate?: undefined;
@@ -248,12 +292,30 @@ export declare function atLeastOnce<T>(idempotencyAlias: string, context: Workfl
248
292
  parse?: undefined;
249
293
  validate: (result: T) => boolean;
250
294
  }): Promise<T>;
251
- export declare function until(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<boolean>, options?: {
295
+ export type UntilTupleType = [
296
+ string,
297
+ "ALWAYS" | "PER_WORKFLOW" | "PER_ITERATION"
298
+ ];
299
+ export declare function until(idempotencyAliasOrTuple: string | UntilTupleType, context: WorkflowContext, callable: () => Promise<boolean>, options?: {
300
+ stringify?: undefined;
301
+ parse?: undefined;
302
+ validate?: undefined;
303
+ }): Promise<void>;
304
+ export declare function until<T>(idempotencyAliasOrTuple: string | UntilTupleType, context: WorkflowContext, callable: () => Promise<false | Exclude<T, boolean>>, options: {
305
+ stringify?: (result: T) => string;
306
+ parse: (value: string) => T;
307
+ validate?: undefined;
308
+ } | {
309
+ stringify?: (result: T) => string;
310
+ parse?: undefined;
311
+ validate: (result: T) => boolean;
312
+ }): Promise<Exclude<T, boolean>>;
313
+ export declare function untilPerWorkflow(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<boolean>, options?: {
252
314
  stringify?: undefined;
253
315
  parse?: undefined;
254
316
  validate?: undefined;
255
317
  }): Promise<void>;
256
- export declare function until<T>(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<false | Exclude<T, boolean>>, options: {
318
+ export declare function untilPerWorkflow<T>(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<false | Exclude<T, boolean>>, options: {
257
319
  stringify?: (result: T) => string;
258
320
  parse: (value: string) => T;
259
321
  validate?: undefined;
@@ -262,6 +324,12 @@ export declare function until<T>(idempotencyAlias: string, context: WorkflowCont
262
324
  parse?: undefined;
263
325
  validate: (result: T) => boolean;
264
326
  }): Promise<Exclude<T, boolean>>;
327
+ export declare function untilChanges<T>(idempotencyAlias: string, context: WorkflowContext, callable: () => Promise<T>, options: {
328
+ equals: (previous: T, current: T) => boolean;
329
+ stringify?: (result: T) => string;
330
+ parse?: (value: string) => T;
331
+ validate?: (result: T) => boolean;
332
+ }): Promise<T>;
265
333
  export declare const zod: {
266
334
  tasks: {
267
335
  TaskId: z.ZodCustom<protobuf_es.PartialMessage<tasks_pb.TaskId>, protobuf_es.PartialMessage<tasks_pb.TaskId>>;
package/index.js CHANGED
@@ -9,7 +9,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
- var _Reboot_external, _ExternalContext_external, _a, _Context_external, _Context_isInternalConstructing, _ReaderContext_kind, _WriterContext_kind, _TransactionContext_kind, _WorkflowContext_kind, _Application_servicers, _Application_tokenVerifier, _Application_express, _Application_http, _Application_servers, _Application_createExternalContext, _Application_external;
12
+ var _Reboot_external, _ExternalContext_external, _a, _Context_external, _Context_isInternalConstructing, _Context_stateId, _ReaderContext_kind, _WriterContext_kind, _TransactionContext_kind, _WorkflowContext_kind, _Application_servicers, _Application_tokenVerifier, _Application_express, _Application_http, _Application_servers, _Application_createExternalContext, _Application_external;
13
13
  import { auth_pb, errors_pb, protobuf_es, tasks_pb, } from "@reboot-dev/reboot-api";
14
14
  import { strict as assert } from "assert";
15
15
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -128,18 +128,44 @@ export class ExternalContext {
128
128
  _ExternalContext_external = new WeakMap();
129
129
  const contextStorage = new AsyncLocalStorage();
130
130
  export function getContext() {
131
- const context = contextStorage.getStore();
132
- if (!context) {
131
+ const store = contextStorage.getStore();
132
+ if (!store) {
133
133
  throw new Error("`getContext` may only be called within a `Servicer` method.");
134
134
  }
135
- return context;
135
+ return store.context;
136
+ }
137
+ export function isWithinUntil() {
138
+ const store = contextStorage.getStore();
139
+ if (!store) {
140
+ throw new Error("`isWithinUntil` may only be called within a `Servicer` method.");
141
+ }
142
+ return store.withinUntil;
143
+ }
144
+ export function isWithinLoop() {
145
+ const store = contextStorage.getStore();
146
+ if (!store) {
147
+ throw new Error("`isWithinLoop` may only be called within a `Servicer` method.");
148
+ }
149
+ return store.withinLoop;
150
+ }
151
+ export function getLoopIteration() {
152
+ const store = contextStorage.getStore();
153
+ if (!store) {
154
+ throw new Error("`getLoopIteration` may only be called within a `Servicer` method.");
155
+ }
156
+ return store.loopIteration;
136
157
  }
137
158
  export async function runWithContext(context, callback) {
138
- return await contextStorage.run(context, callback);
159
+ return await contextStorage.run({
160
+ context,
161
+ withinLoop: false,
162
+ withinUntil: false,
163
+ }, callback);
139
164
  }
140
165
  export class Context {
141
166
  constructor(external, cancelled) {
142
167
  _Context_external.set(this, void 0);
168
+ _Context_stateId.set(this, void 0);
143
169
  if (!__classPrivateFieldGet(_a, _a, "f", _Context_isInternalConstructing)) {
144
170
  throw new TypeError("Context is not publicly constructable");
145
171
  }
@@ -174,14 +200,14 @@ export class Context {
174
200
  return null;
175
201
  }
176
202
  get stateId() {
177
- return reboot_native.Context_stateId(__classPrivateFieldGet(this, _Context_external, "f"));
203
+ if (__classPrivateFieldGet(this, _Context_stateId, "f") === undefined) {
204
+ __classPrivateFieldSet(this, _Context_stateId, reboot_native.Context_stateId(__classPrivateFieldGet(this, _Context_external, "f")), "f");
205
+ }
206
+ return __classPrivateFieldGet(this, _Context_stateId, "f");
178
207
  }
179
208
  get callerBearerToken() {
180
209
  return reboot_native.Context_callerBearerToken(__classPrivateFieldGet(this, _Context_external, "f"));
181
210
  }
182
- get iteration() {
183
- return reboot_native.Context_iteration(__classPrivateFieldGet(this, _Context_external, "f"));
184
- }
185
211
  get cookie() {
186
212
  return reboot_native.Context_cookie(__classPrivateFieldGet(this, _Context_external, "f"));
187
213
  }
@@ -192,7 +218,7 @@ export class Context {
192
218
  return reboot_native.Context_generateIdempotentStateId(__classPrivateFieldGet(this, _Context_external, "f"), stateType, serviceName, method, idempotency);
193
219
  }
194
220
  }
195
- _a = Context, _Context_external = new WeakMap();
221
+ _a = Context, _Context_external = new WeakMap(), _Context_stateId = new WeakMap();
196
222
  _Context_isInternalConstructing = { value: false };
197
223
  export class ReaderContext extends Context {
198
224
  constructor(external, cancelled) {
@@ -243,6 +269,41 @@ export class WorkflowContext extends Context {
243
269
  // context type that structurally looks equivalent.
244
270
  _WorkflowContext_kind.set(this, "workflow");
245
271
  }
272
+ // TODO: implement workflow specific properties/methods.
273
+ async *loop(alias, { interval } = {}) {
274
+ const iterate = await reboot_native.WorkflowContext_loop(this.__external, alias);
275
+ const ms = (interval &&
276
+ (interval?.ms || 0) +
277
+ (interval?.secs * 1000 || 0) +
278
+ (interval?.mins * 60 * 1000 || 0) +
279
+ (interval?.hours * 60 * 60 * 1000 || 0) +
280
+ (interval?.days * 24 * 60 * 60 * 1000 || 0)) ||
281
+ 0;
282
+ const store = contextStorage.getStore();
283
+ assert(store !== undefined);
284
+ store.withinLoop = true;
285
+ let iteration = null;
286
+ try {
287
+ while (true) {
288
+ iteration = await iterate(true);
289
+ if (iteration === null) {
290
+ return;
291
+ }
292
+ store.loopIteration = iteration;
293
+ yield iteration;
294
+ if (ms > 0) {
295
+ await new Promise((resolve) => setTimeout(resolve, ms));
296
+ }
297
+ }
298
+ }
299
+ finally {
300
+ if (iteration !== null) {
301
+ await iterate(false);
302
+ }
303
+ store.withinLoop = false;
304
+ delete store.loopIteration;
305
+ }
306
+ }
246
307
  }
247
308
  _WorkflowContext_kind = new WeakMap();
248
309
  // Helper for clearing a specific field of a protobuf-es message.
@@ -613,14 +674,12 @@ _Application_servicers = new WeakMap(), _Application_tokenVerifier = new WeakMap
613
674
  _Http_express = new WeakMap(), _Http_createExternalContext = new WeakMap();
614
675
  Application.Http = Http;
615
676
  })(Application || (Application = {}));
616
- export class Loop {
617
- constructor(options) {
618
- this.when = options?.when || new Date();
619
- }
620
- }
621
- export async function retry_reactively_until(context, condition) {
677
+ export async function retryReactivelyUntil(context, condition) {
622
678
  let t = undefined;
623
- await reboot_native.retry_reactively_until(context.__external, async () => {
679
+ await reboot_native.retry_reactively_until(context.__external, AsyncLocalStorage.bind(async () => {
680
+ const store = contextStorage.getStore();
681
+ assert(store !== undefined);
682
+ store.withinUntil = true;
624
683
  try {
625
684
  const result = await condition();
626
685
  if (typeof result === "boolean") {
@@ -632,45 +691,71 @@ export async function retry_reactively_until(context, condition) {
632
691
  }
633
692
  }
634
693
  catch (e) {
635
- if (e instanceof Error) {
636
- throw e;
637
- }
638
- else {
639
- throw new Error(`${e}`);
640
- }
694
+ const error = ensureError(e);
695
+ console.error(error);
696
+ throw error;
641
697
  }
642
- });
698
+ finally {
699
+ store.withinUntil = false;
700
+ }
701
+ }));
643
702
  return t;
644
703
  }
645
- async function memoize(idempotencyAlias, context, callable, { stringify = JSON.stringify, parse = JSON.parse, validate, atMostOnce, until = false, }) {
704
+ // NOTE: we're not using an enum because the values that can be used in
705
+ // `atMostOnce` and `atLeastOnce` are different than `until`.
706
+ export const ALWAYS = "ALWAYS";
707
+ export const PER_WORKFLOW = "PER_WORKFLOW";
708
+ export const PER_ITERATION = "PER_ITERATION";
709
+ async function memoize(idempotencyAliasOrTuple, context, callable, { stringify = JSON.stringify, parse = JSON.parse, validate, atMostOnce, until = false, }) {
646
710
  assert(stringify !== undefined);
647
711
  assert(parse !== undefined);
648
712
  assert(atMostOnce !== undefined);
649
- const result = await reboot_native.memoize(context.__external, idempotencyAlias, async () => {
650
- const t = await callable();
651
- if (t !== undefined) {
652
- if (validate === undefined) {
653
- // TODO: link to docs about why this is required, when those docs exist.
654
- throw new Error(`Result of function passed to '${atMostOnce ? "atMostOnce" : until ? "until" : "atLeastOnce"}' is not 'boolean' but 'validate' option is undefined`);
713
+ if (!(typeof idempotencyAliasOrTuple === "string" ||
714
+ (Array.isArray(idempotencyAliasOrTuple) &&
715
+ idempotencyAliasOrTuple.length === 2))) {
716
+ throw new TypeError(`Expecting either a 'string' or a tuple for first argument passed to '${atMostOnce ? "atMostOnce" : until ? "until" : "atLeastOnce"}'`);
717
+ }
718
+ const result = await reboot_native.memoize(context.__external, typeof idempotencyAliasOrTuple === "string"
719
+ ? [idempotencyAliasOrTuple, PER_ITERATION]
720
+ : idempotencyAliasOrTuple,
721
+ // Bind with async local storage so we can check things like
722
+ // `isWithinLoop`, etc.
723
+ AsyncLocalStorage.bind(async () => {
724
+ try {
725
+ const t = await callable();
726
+ if (t !== undefined) {
727
+ if (validate === undefined) {
728
+ // TODO: link to docs about why this is required, when those docs exist.
729
+ throw new Error(`Result of function passed to '${atMostOnce ? "atMostOnce" : until ? "until" : "atLeastOnce"}' is not 'boolean' but 'validate' option is undefined`);
730
+ }
731
+ else if (!validate(t)) {
732
+ throw new Error(`Calling 'validate' on result of function passed to '${atMostOnce ? "atMostOnce" : until ? "until" : "atLeastOnce"}' returned 'false'`);
733
+ }
734
+ // NOTE: to differentiate `callable` returning `void` (or
735
+ // explicitly `undefined`) from `stringify` returning an empty
736
+ // string we use `{ value: stringify(t) }`.
737
+ const result = { value: stringify(t) };
738
+ return JSON.stringify(result);
655
739
  }
656
- else if (!validate(t)) {
657
- throw new Error(`Calling 'validate' on result of function passed to '${atMostOnce ? "atMostOnce" : until ? "until" : "atLeastOnce"}' returned 'false'`);
740
+ // Fail early if the developer thinks that they have some value
741
+ // that they want to validate but we got `undefined`.
742
+ if (validate !== undefined) {
743
+ throw new Error("Not expecting `validate` as you are returning `void` (or explicitly `undefined`); did you mean to return a value (or if you want to explicitly return the absence of a value use `null`)");
658
744
  }
659
- // NOTE: to differentiate `callable` returning `void` (or
660
- // explicitly `undefined`) from `stringify` returning an empty
661
- // string we use `{ value: stringify(t) }`.
662
- const result = { value: stringify(t) };
663
- return JSON.stringify(result);
664
- }
665
- // Fail early if the developer thinks that they have some value
666
- // that they want to validate but we got `undefined`.
667
- if (validate !== undefined) {
668
- throw new Error("Not expecting `validate` as you are returning `void` (or explicitly `undefined`); did you mean to return a value (or if you want to explicitly return the absence of a value use `null`)");
669
- }
670
- // NOTE: using the empty string to represent a `callable`
671
- // returning `void` (or explicitly `undefined`).
672
- return "";
673
- }, atMostOnce);
745
+ // NOTE: using the empty string to represent a `callable`
746
+ // returning `void` (or explicitly `undefined`).
747
+ return "";
748
+ }
749
+ catch (e) {
750
+ const error = ensureError(e);
751
+ // We handle printing the exception for `until` in
752
+ // `retryReactivelyUntil`.
753
+ if (!until) {
754
+ console.error(error);
755
+ }
756
+ throw error;
757
+ }
758
+ }), atMostOnce, until);
674
759
  // NOTE: we parse and validate `value` every time (even the first
675
760
  // time, even though we validate above). These semantics are the
676
761
  // same as Python (although Python uses the `type` keyword argument
@@ -692,9 +777,9 @@ async function memoize(idempotencyAlias, context, callable, { stringify = JSON.s
692
777
  // Otherwise `callable` must have returned void (or explicitly
693
778
  // `undefined`), fall through.
694
779
  }
695
- export async function atMostOnce(idempotencyAlias, context, callable, options = { validate: undefined }) {
780
+ export async function atMostOnce(idempotencyAliasOrTuple, context, callable, options = { validate: undefined }) {
696
781
  try {
697
- return await memoize(idempotencyAlias, context, callable, {
782
+ return await memoize(idempotencyAliasOrTuple, context, callable, {
698
783
  ...options,
699
784
  atMostOnce: true,
700
785
  });
@@ -706,25 +791,67 @@ export async function atMostOnce(idempotencyAlias, context, callable, options =
706
791
  throw e;
707
792
  }
708
793
  }
709
- export async function atLeastOnce(idempotencyAlias, context, callable, options = { validate: undefined }) {
710
- return memoize(idempotencyAlias, context, callable, {
794
+ export async function atMostOncePerWorkflow(idempotencyAlias, context, callable, options = { validate: undefined }) {
795
+ return atMostOnce([idempotencyAlias, PER_WORKFLOW], context, callable, options);
796
+ }
797
+ export async function atLeastOnce(idempotencyAliasOrTuple, context, callable, options = { validate: undefined }) {
798
+ return memoize(idempotencyAliasOrTuple, context, callable, {
711
799
  ...options,
712
800
  atMostOnce: false,
713
801
  });
714
802
  }
715
- export async function until(idempotencyAlias, context, callable, options = { validate: undefined }) {
803
+ export async function atLeastOncePerWorkflow(idempotencyAlias, context, callable, options = { validate: undefined }) {
804
+ return await atLeastOnce([idempotencyAlias, PER_WORKFLOW], context, callable, options);
805
+ }
806
+ export async function until(idempotencyAliasOrTuple, context, callable, options = { validate: undefined }) {
716
807
  // TODO(benh): figure out how to not use `as` type assertions here
717
808
  // to appease the TypeScript compiler which otherwise isn't happy
718
809
  // with passing on these types.
719
810
  const converge = () => {
720
- return retry_reactively_until(context, callable);
811
+ return retryReactivelyUntil(context, callable);
721
812
  };
722
- return memoize(idempotencyAlias, context, converge, {
813
+ // TODO: should we not memoize if passed `ALWAYS`? There still might
814
+ // be value in having a "paper trail" of what happened ...
815
+ return memoize(idempotencyAliasOrTuple, context, converge, {
723
816
  ...options,
724
817
  atMostOnce: false,
725
818
  until: true,
726
819
  });
727
820
  }
821
+ export async function untilPerWorkflow(idempotencyAlias, context, callable, options = { validate: undefined }) {
822
+ return await until([idempotencyAlias, PER_WORKFLOW], context, callable, options);
823
+ }
824
+ export async function untilChanges(idempotencyAlias, context, callable, options) {
825
+ const iteration = getLoopIteration();
826
+ if (iteration === undefined) {
827
+ throw new Error("Waiting for changes must be done _within_ a control loop");
828
+ }
829
+ if (options.equals === undefined) {
830
+ // TODO: don't make `equals` required, instead use one of the
831
+ // various libraries that does deep equality.
832
+ throw new Error("Missing 'equals' option");
833
+ }
834
+ const { equals, ...optionsWithoutEquals } = options;
835
+ let previous = null;
836
+ if (iteration > 0) {
837
+ // Get the previous memoized result!
838
+ previous = (await untilPerWorkflow(`${idempotencyAlias} #${iteration - 1}`, context, (async () => {
839
+ throw new Error(`Missing memoized value for '${idempotencyAlias}'`);
840
+ }), optionsWithoutEquals));
841
+ }
842
+ // Wait until previous result does not equal current result.
843
+ return (await untilPerWorkflow(`${idempotencyAlias} #${iteration}`, context, (async () => {
844
+ const current = await callable();
845
+ if (iteration === 0) {
846
+ return current;
847
+ }
848
+ assert(previous !== null);
849
+ if (!equals(previous, current)) {
850
+ return current;
851
+ }
852
+ return false;
853
+ }), optionsWithoutEquals));
854
+ }
728
855
  const launchSubprocessConsensus = (base64_args) => {
729
856
  // Create a child process via `fork` (which does not mean `fork` as
730
857
  // in POSIX fork/clone) that uses the exact same module that was
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "@bufbuild/protobuf": "1.3.2",
4
4
  "@bufbuild/protoplugin": "1.3.2",
5
5
  "@bufbuild/protoc-gen-es": "1.3.2",
6
- "@reboot-dev/reboot-api": "0.29.5",
6
+ "@reboot-dev/reboot-api": "0.31.0",
7
7
  "chalk": "^4.1.2",
8
8
  "node-addon-api": "^7.0.0",
9
9
  "node-gyp": ">=10.2.0",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "type": "module",
20
20
  "name": "@reboot-dev/reboot",
21
- "version": "0.29.5",
21
+ "version": "0.31.0",
22
22
  "description": "npm package for Reboot",
23
23
  "scripts": {
24
24
  "preinstall": "node preinstall.cjs",
package/reboot_native.cc CHANGED
@@ -1533,7 +1533,7 @@ py::list make_py_servicers(
1533
1533
 
1534
1534
  // Include memoize servicers by default!
1535
1535
  py_servicers.attr("extend")(
1536
- py::module::import("reboot.aio.memoize").attr("servicers")());
1536
+ py::module::import("rebootdev.aio.memoize").attr("servicers")());
1537
1537
 
1538
1538
  return py_servicers;
1539
1539
  }
@@ -2401,33 +2401,6 @@ Napi::Value Context_callerBearerToken(
2401
2401
  }
2402
2402
 
2403
2403
 
2404
- Napi::Value Context_iteration(const Napi::CallbackInfo& info) {
2405
- Napi::External<py::object> js_external_context =
2406
- info[0].As<Napi::External<py::object>>();
2407
-
2408
- // CHECK(...CheckTypeTag(...));
2409
-
2410
- py::object* py_context = js_external_context.Data();
2411
-
2412
- std::optional<int> iteration = RunCallbackOnPythonEventLoop(
2413
- [py_context]() -> std::optional<int> {
2414
- py::object iteration = py_context->attr("iteration");
2415
-
2416
- if (iteration.is_none()) {
2417
- return std::nullopt;
2418
- } else {
2419
- return iteration.cast<int>();
2420
- }
2421
- });
2422
-
2423
- if (iteration.has_value()) {
2424
- return Napi::Number::New(info.Env(), *iteration);
2425
- } else {
2426
- return info.Env().Undefined();
2427
- }
2428
- }
2429
-
2430
-
2431
2404
  Napi::Value Context_cookie(const Napi::CallbackInfo& info) {
2432
2405
  Napi::External<py::object> js_external_context =
2433
2406
  info[0].As<Napi::External<py::object>>();
@@ -2493,6 +2466,11 @@ Napi::Value Context_generateIdempotentStateId(const Napi::CallbackInfo& info) {
2493
2466
  if (js_alias.IsString()) {
2494
2467
  alias = js_alias.As<Napi::String>().Utf8Value();
2495
2468
  }
2469
+ auto js_each_iteration = idempotency_options.Get("eachIteration");
2470
+ std::optional<bool> each_iteration;
2471
+ if (js_each_iteration.IsBoolean()) {
2472
+ each_iteration = js_each_iteration.As<Napi::Boolean>();
2473
+ }
2496
2474
 
2497
2475
  return NodePromiseFromPythonCallback(
2498
2476
  info.Env(),
@@ -2502,21 +2480,34 @@ Napi::Value Context_generateIdempotentStateId(const Napi::CallbackInfo& info) {
2502
2480
  service_name = std::move(service_name),
2503
2481
  method = std::move(method),
2504
2482
  key = std::move(key),
2505
- alias = std::move(alias)]() {
2506
- py::object py_key = py::none();
2507
- if (key.has_value()) {
2508
- py_key = py::cast(*key);
2509
- }
2510
- py::object py_alias = py::none();
2511
- if (alias.has_value()) {
2512
- py_alias = py::cast(*alias);
2513
- }
2514
- py::object py_idempotency_module = py::module::import(
2515
- "rebootdev.aio.idempotency");
2516
- py::object py_idempotency = py::object(
2517
- py_idempotency_module.attr("Idempotency")(
2518
- "key"_a = py_key,
2519
- "alias"_a = py_alias));
2483
+ alias = std::move(alias),
2484
+ each_iteration = std::move(each_iteration)]() {
2485
+ // Need to use `call_with_context` to ensure that we have
2486
+ // `py_context` as a valid asyncio context variable.
2487
+ py::object py_idempotency =
2488
+ py::module::import("rebootdev.nodejs.python")
2489
+ .attr("call_with_context")(
2490
+ py::cpp_function([&]() {
2491
+ py::object py_key = py::none();
2492
+ if (key.has_value()) {
2493
+ py_key = py::cast(*key);
2494
+ }
2495
+ py::object py_alias = py::none();
2496
+ if (alias.has_value()) {
2497
+ py_alias = py::cast(*alias);
2498
+ }
2499
+ py::object py_each_iteration = py::none();
2500
+ if (each_iteration.has_value()) {
2501
+ py_each_iteration = py::cast(*each_iteration);
2502
+ }
2503
+ return py::module::import("rebootdev.aio.contexts")
2504
+ .attr("Context")
2505
+ .attr("idempotency")(
2506
+ "key"_a = py_key,
2507
+ "alias"_a = py_alias,
2508
+ "each_iteration"_a = py_each_iteration);
2509
+ }),
2510
+ py_context);
2520
2511
 
2521
2512
  py::object py_generate_idempotent_state_id =
2522
2513
  py_context->attr("generate_idempotent_state_id");
@@ -2555,6 +2546,71 @@ Napi::Value WriterContext_set_sync(const Napi::CallbackInfo& info) {
2555
2546
  }
2556
2547
 
2557
2548
 
2549
+ Napi::Value WorkflowContext_loop(const Napi::CallbackInfo& info) {
2550
+ auto js_external_context = NapiSafeReference(
2551
+ info[0].As<Napi::External<py::object>>());
2552
+
2553
+ // CHECK(...CheckTypeTag(...));
2554
+
2555
+ py::object* py_context = js_external_context.Value(info.Env()).Data();
2556
+
2557
+ std::string alias = info[1].As<Napi::String>().Utf8Value();
2558
+
2559
+ return NodePromiseFromPythonTaskWithContext(
2560
+ info.Env(),
2561
+ "context.loop(...) in nodejs",
2562
+ js_external_context,
2563
+ [js_external_context, // Ensures `py_context` remains valid.
2564
+ py_context,
2565
+ alias = std::move(alias)]() {
2566
+ return py::module::import("rebootdev.nodejs.python")
2567
+ .attr("loop")(py_context, alias);
2568
+ },
2569
+ [](py::object py_iterate) {
2570
+ return new py::object(py_iterate);
2571
+ },
2572
+ [js_external_context](Napi::Env env, py::object* py_iterate) mutable {
2573
+ Napi::External<py::object> js_iterate_external =
2574
+ make_napi_external(env, py_iterate);
2575
+
2576
+ Napi::Function js_iterate =
2577
+ Napi::Function::New(
2578
+ env,
2579
+ [js_external_context,
2580
+ js_iterate_external = // Ensures `py_iterate` remains valid.
2581
+ NapiSafeReference(js_iterate_external),
2582
+ py_iterate](
2583
+ const Napi::CallbackInfo& info) mutable {
2584
+ bool more = info[0].As<Napi::Boolean>();
2585
+ return NodePromiseFromPythonTaskWithContext(
2586
+ info.Env(),
2587
+ "iterate(...) in nodejs",
2588
+ js_external_context,
2589
+ [js_iterate_external, py_iterate, more]() {
2590
+ return (*py_iterate)(more);
2591
+ },
2592
+ [](py::object py_iteration) -> std::optional<int> {
2593
+ if (!py_iteration.is_none()) {
2594
+ return py_iteration.cast<int>();
2595
+ } else {
2596
+ return std::nullopt;
2597
+ }
2598
+ },
2599
+ [](Napi::Env env,
2600
+ std::optional<int>&& iteration) -> Napi::Value {
2601
+ if (iteration.has_value()) {
2602
+ return Napi::Number::New(env, *iteration);
2603
+ } else {
2604
+ return env.Null();
2605
+ }
2606
+ });
2607
+ });
2608
+
2609
+ return js_iterate;
2610
+ });
2611
+ }
2612
+
2613
+
2558
2614
  Napi::Value retry_reactively_until(const Napi::CallbackInfo& info) {
2559
2615
  auto js_external_context = NapiSafeReference(
2560
2616
  info[0].As<Napi::External<py::object>>());
@@ -2605,22 +2661,30 @@ Napi::Value memoize(const Napi::CallbackInfo& info) {
2605
2661
 
2606
2662
  py::object* py_context = js_external_context.Value(info.Env()).Data();
2607
2663
 
2608
- std::string idempotency_alias = info[1].As<Napi::String>().Utf8Value();
2664
+ Napi::Object idempotency_tuple = info[1].As<Napi::Array>();
2665
+ auto js_alias = idempotency_tuple.Get((uint32_t) 0);
2666
+ std::string alias = js_alias.As<Napi::String>().Utf8Value();
2667
+ auto js_how = idempotency_tuple.Get((uint32_t) 1);
2668
+ std::string how = js_how.As<Napi::String>().Utf8Value();
2609
2669
 
2610
2670
  auto js_callable = NapiSafeFunctionReference(
2611
2671
  info[2].As<Napi::Function>());
2612
2672
 
2613
2673
  bool at_most_once = info[3].As<Napi::Boolean>();
2614
2674
 
2675
+ bool until = info[4].As<Napi::Boolean>();
2676
+
2615
2677
  return NodePromiseFromPythonTaskWithContext(
2616
2678
  info.Env(),
2617
2679
  "memoize(...) in nodejs",
2618
2680
  js_external_context,
2619
2681
  [js_external_context, // Ensures `py_context` remains valid.
2620
2682
  py_context,
2683
+ alias = std::move(alias),
2684
+ how = std::move(how),
2621
2685
  js_callable = std::move(js_callable),
2622
- idempotency_alias = std::move(idempotency_alias),
2623
- at_most_once]() {
2686
+ at_most_once,
2687
+ until]() {
2624
2688
  py::object py_callable = py::cpp_function(
2625
2689
  [js_callable = std::move(js_callable)]() mutable {
2626
2690
  return PythonFutureFromNodePromise(
@@ -2637,13 +2701,14 @@ Napi::Value memoize(const Napi::CallbackInfo& info) {
2637
2701
  });
2638
2702
  });
2639
2703
 
2640
- return py::module::import("reboot.aio.memoize")
2704
+ return py::module::import("rebootdev.aio.memoize")
2641
2705
  .attr("memoize")(
2642
- py::str(idempotency_alias),
2706
+ py::make_tuple(alias, how),
2643
2707
  py_context,
2644
2708
  py_callable,
2645
2709
  "type_t"_a = py::eval("str"),
2646
- "at_most_once"_a = at_most_once);
2710
+ "at_most_once"_a = at_most_once,
2711
+ "until"_a = until);
2647
2712
  },
2648
2713
  [](py::object py_json) {
2649
2714
  return py_json.cast<std::string>();
@@ -2667,6 +2732,8 @@ Napi::Value Servicer_read(const Napi::CallbackInfo& info) {
2667
2732
 
2668
2733
  // CHECK(...CheckTypeTag(...));
2669
2734
 
2735
+ std::string json_options = info[2].As<Napi::String>().Utf8Value();
2736
+
2670
2737
  py::object* py_context = js_external_context.Value(info.Env()).Data();
2671
2738
 
2672
2739
  return NodePromiseFromPythonTaskWithContext(
@@ -2676,8 +2743,9 @@ Napi::Value Servicer_read(const Napi::CallbackInfo& info) {
2676
2743
  [js_external_servicer, // Ensures `py_servicer` remains valid.
2677
2744
  py_servicer,
2678
2745
  js_external_context, // Ensures `py_context` remains valid.
2679
- py_context]() {
2680
- return py_servicer->attr("_read")(py_context);
2746
+ py_context,
2747
+ json_options = std::move(json_options)]() {
2748
+ return py_servicer->attr("_read")(py_context, json_options);
2681
2749
  },
2682
2750
  [](py::object py_json) {
2683
2751
  return py_json.cast<std::string>();
@@ -2815,10 +2883,6 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
2815
2883
  Napi::String::New(env, "Context_callerBearerToken"),
2816
2884
  Napi::Function::New<Context_callerBearerToken>(env));
2817
2885
 
2818
- exports.Set(
2819
- Napi::String::New(env, "Context_iteration"),
2820
- Napi::Function::New<Context_iteration>(env));
2821
-
2822
2886
  exports.Set(
2823
2887
  Napi::String::New(env, "Context_cookie"),
2824
2888
  Napi::Function::New<Context_cookie>(env));
@@ -2835,6 +2899,10 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
2835
2899
  Napi::String::New(env, "WriterContext_set_sync"),
2836
2900
  Napi::Function::New<WriterContext_set_sync>(env));
2837
2901
 
2902
+ exports.Set(
2903
+ Napi::String::New(env, "WorkflowContext_loop"),
2904
+ Napi::Function::New<WorkflowContext_loop>(env));
2905
+
2838
2906
  exports.Set(
2839
2907
  Napi::String::New(env, "retry_reactively_until"),
2840
2908
  Napi::Function::New<retry_reactively_until>(env));
package/reboot_native.cjs CHANGED
@@ -80,10 +80,10 @@ exports.Context_callerBearerToken =
80
80
  reboot_native.exports.Context_callerBearerToken;
81
81
  exports.Context_cookie = reboot_native.exports.Context_cookie;
82
82
  exports.Context_appInternal = reboot_native.exports.Context_appInternal;
83
- exports.Context_iteration = reboot_native.exports.Context_iteration;
84
83
  exports.Context_generateIdempotentStateId =
85
84
  reboot_native.exports.Context_generateIdempotentStateId;
86
85
  exports.WriterContext_set_sync = reboot_native.exports.WriterContext_set_sync;
86
+ exports.WorkflowContext_loop = reboot_native.exports.WorkflowContext_loop;
87
87
  exports.retry_reactively_until = reboot_native.exports.retry_reactively_until;
88
88
  exports.memoize = reboot_native.exports.memoize;
89
89
  exports.Servicer_read = reboot_native.exports.Servicer_read;
package/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const REBOOT_VERSION = "0.29.5";
1
+ export declare const REBOOT_VERSION = "0.31.0";
package/version.js CHANGED
@@ -1 +1 @@
1
- export const REBOOT_VERSION = "0.29.5";
1
+ export const REBOOT_VERSION = "0.31.0";