@reboot-dev/reboot 0.27.1 → 0.29.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,5 @@
1
- import { errors_pb, IdempotencyOptions, protobuf_es, ScheduleOptions } from "@reboot-dev/reboot-api";
1
+ import { errors_pb, IdempotencyOptions, protobuf_es, ScheduleOptions, tasks_pb } from "@reboot-dev/reboot-api";
2
+ import { z } from "zod/v4";
2
3
  import * as reboot_native from "./reboot_native.cjs";
3
4
  import { Application as ExpressApplication, NextFunction as ExpressNextFunction, Request as ExpressRequest, Response as ExpressResponse } from "express";
4
5
  export { reboot_native };
@@ -160,8 +161,8 @@ export declare abstract class AuthorizerRule<StateType, RequestType> {
160
161
  request?: RequestType;
161
162
  }): Promise<AuthorizerDecision>;
162
163
  }
163
- export declare function deny(): AuthorizerRule<protobuf_es.Message, protobuf_es.Message>;
164
- export declare function allow(): AuthorizerRule<protobuf_es.Message, protobuf_es.Message>;
164
+ export declare function deny(): AuthorizerRule<any, any>;
165
+ export declare function allow(): AuthorizerRule<any, any>;
165
166
  export declare function allowIf<StateType, RequestType>(args: {
166
167
  all: AuthorizerCallable<StateType, RequestType>[];
167
168
  any?: never;
@@ -172,13 +173,13 @@ export declare function allowIf<StateType, RequestType>(args: {
172
173
  }): AuthorizerRule<StateType, RequestType>;
173
174
  export declare function hasVerifiedToken({ context, }: {
174
175
  context: ReaderContext;
175
- state?: protobuf_es.Message;
176
- request?: protobuf_es.Message;
176
+ state?: any;
177
+ request?: any;
177
178
  }): errors_pb.Unauthenticated | errors_pb.Ok;
178
179
  export declare function isAppInternal({ context, }: {
179
180
  context: ReaderContext;
180
- state?: protobuf_es.Message;
181
- request?: protobuf_es.Message;
181
+ state?: any;
182
+ request?: any;
182
183
  }): errors_pb.PermissionDenied | errors_pb.Ok;
183
184
  export declare class Application {
184
185
  #private;
@@ -213,10 +214,6 @@ export declare namespace Application {
213
214
  post(path: Http.Path, ...handlers: Http.Handler[]): void;
214
215
  }
215
216
  }
216
- /**
217
- * @deprecated testWithReboot is deprecated in favor of the manual start of Reboot in the test setup.
218
- */
219
- export declare function testWithReboot(name: string, body: (t: any, rbt: Reboot) => Promise<void>): void;
220
217
  export declare class Loop {
221
218
  when: Date;
222
219
  constructor(options?: ScheduleOptions);
@@ -265,3 +262,9 @@ export declare function until<T>(idempotencyAlias: string, context: WorkflowCont
265
262
  parse?: undefined;
266
263
  validate: (result: T) => boolean;
267
264
  }): Promise<Exclude<T, boolean>>;
265
+ export declare const zod: {
266
+ tasks: {
267
+ TaskId: z.ZodCustom<protobuf_es.PartialMessage<tasks_pb.TaskId>, protobuf_es.PartialMessage<tasks_pb.TaskId>>;
268
+ };
269
+ bytes: z.ZodCustom<Uint8Array, Uint8Array>;
270
+ };
package/index.js CHANGED
@@ -10,12 +10,12 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
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;
13
- import { auth_pb, errors_pb, protobuf_es, } from "@reboot-dev/reboot-api";
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";
16
16
  import { fork } from "node:child_process";
17
17
  import { createRequire } from "node:module";
18
- import { test } from "node:test";
18
+ import { z } from "zod/v4";
19
19
  import * as reboot_native from "./reboot_native.cjs";
20
20
  import { ensureError } from "./utils/errors.js";
21
21
  import { ensurePythonVenv } from "./venv.js";
@@ -613,22 +613,6 @@ _Application_servicers = new WeakMap(), _Application_tokenVerifier = new WeakMap
613
613
  _Http_express = new WeakMap(), _Http_createExternalContext = new WeakMap();
614
614
  Application.Http = Http;
615
615
  })(Application || (Application = {}));
616
- /**
617
- * @deprecated testWithReboot is deprecated in favor of the manual start of Reboot in the test setup.
618
- */
619
- export function testWithReboot(name, body) {
620
- console.warn("testWithReboot is deprecated and will be removed in the future.");
621
- test(name, async (t) => {
622
- const rbt = new Reboot();
623
- await rbt.start();
624
- try {
625
- await body(t, rbt);
626
- }
627
- finally {
628
- await rbt.stop();
629
- }
630
- });
631
- }
632
616
  export class Loop {
633
617
  constructor(options) {
634
618
  this.when = options?.when || new Date();
@@ -658,13 +642,20 @@ export async function retry_reactively_until(context, condition) {
658
642
  });
659
643
  return t;
660
644
  }
661
- async function atLeastOrMostOnce(idempotencyAlias, context, callable, { stringify = JSON.stringify, parse = JSON.parse, validate, atMostOnce, }) {
645
+ async function memoize(idempotencyAlias, context, callable, { stringify = JSON.stringify, parse = JSON.parse, validate, atMostOnce, until = false, }) {
662
646
  assert(stringify !== undefined);
663
647
  assert(parse !== undefined);
664
648
  assert(atMostOnce !== undefined);
665
- const result = await reboot_native.atLeastOrMostOnce(context.__external, idempotencyAlias, async () => {
649
+ const result = await reboot_native.memoize(context.__external, idempotencyAlias, async () => {
666
650
  const t = await callable();
667
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`);
655
+ }
656
+ else if (!validate(t)) {
657
+ throw new Error(`Calling 'validate' on result of function passed to '${atMostOnce ? "atMostOnce" : until ? "until" : "atLeastOnce"}' returned 'false'`);
658
+ }
668
659
  // NOTE: to differentiate `callable` returning `void` (or
669
660
  // explicitly `undefined`) from `stringify` returning an empty
670
661
  // string we use `{ value: stringify(t) }`.
@@ -680,25 +671,20 @@ async function atLeastOrMostOnce(idempotencyAlias, context, callable, { stringif
680
671
  // returning `void` (or explicitly `undefined`).
681
672
  return "";
682
673
  }, atMostOnce);
683
- // NOTE: we parse and validate `value` every time, even the first
684
- // time, so as to catch bugs where the `value` returned from
685
- // `callable` might not parse or be valid. We will have already
686
- // persisted `result`, so in the event of a bug the developer will
687
- // have to change the idempotency alias so that `callable` is
688
- // re-executed. These semantics are the same as Python (although
689
- // Python uses the `type` keyword argument instead of the
690
- // `parse` and `validate` properties we use here).
674
+ // NOTE: we parse and validate `value` every time (even the first
675
+ // time, even though we validate above). These semantics are the
676
+ // same as Python (although Python uses the `type` keyword argument
677
+ // instead of the `parse` and `validate` properties we use here).
691
678
  assert(result !== undefined);
692
679
  if (result !== "") {
693
680
  const { value } = JSON.parse(result);
694
681
  const t = parse(value);
695
682
  if (parse !== JSON.parse) {
696
683
  if (validate === undefined) {
697
- // TODO: link to docs about why this is required, when those docs exist.
698
- throw new Error("Missing `validate` property");
684
+ throw new Error(`Result of function passed to '${atMostOnce ? "atMostOnce" : until ? "until" : "atLeastOnce"}' is not 'boolean' but 'validate' option is undefined`);
699
685
  }
700
686
  else if (!validate(t)) {
701
- throw new Error("Failed to validate memoized result");
687
+ throw new Error(`Calling 'validate' on result of function passed to '${atMostOnce ? "atMostOnce" : until ? "until" : "atLeastOnce"}' returned 'false'`);
702
688
  }
703
689
  }
704
690
  return t;
@@ -708,7 +694,7 @@ async function atLeastOrMostOnce(idempotencyAlias, context, callable, { stringif
708
694
  }
709
695
  export async function atMostOnce(idempotencyAlias, context, callable, options = { validate: undefined }) {
710
696
  try {
711
- return await atLeastOrMostOnce(idempotencyAlias, context, callable, {
697
+ return await memoize(idempotencyAlias, context, callable, {
712
698
  ...options,
713
699
  atMostOnce: true,
714
700
  });
@@ -721,7 +707,7 @@ export async function atMostOnce(idempotencyAlias, context, callable, options =
721
707
  }
722
708
  }
723
709
  export async function atLeastOnce(idempotencyAlias, context, callable, options = { validate: undefined }) {
724
- return atLeastOrMostOnce(idempotencyAlias, context, callable, {
710
+ return memoize(idempotencyAlias, context, callable, {
725
711
  ...options,
726
712
  atMostOnce: false,
727
713
  });
@@ -733,7 +719,11 @@ export async function until(idempotencyAlias, context, callable, options = { val
733
719
  const converge = () => {
734
720
  return retry_reactively_until(context, callable);
735
721
  };
736
- return atLeastOnce(idempotencyAlias, context, converge, options);
722
+ return memoize(idempotencyAlias, context, converge, {
723
+ ...options,
724
+ atMostOnce: false,
725
+ until: true,
726
+ });
737
727
  }
738
728
  const launchSubprocessConsensus = (base64_args) => {
739
729
  // Create a child process via `fork` (which does not mean `fork` as
@@ -756,3 +746,43 @@ const callback = (...args) => {
756
746
  };
757
747
  ensurePythonVenv();
758
748
  reboot_native.initialize(callback, Context.fromNativeExternal, launchSubprocessConsensus);
749
+ // TODO: move these into @reboot-dev/reboot-api and also generate them
750
+ // via plugin, perhaps via a new plugin which emits the Zod schemas
751
+ // for all protobuf messages, which might be easier after we've moved
752
+ // to protobuf-es v2 which has more natural TypeScript types.
753
+ function protobufToZodSchema(type) {
754
+ return z
755
+ .custom((t) => {
756
+ // TODO: replace this with `protobuf_es.isMessage` after upgrading
757
+ // '@bufbuild/protobuf'.
758
+ if (t instanceof type) {
759
+ return true;
760
+ }
761
+ if (typeof t === "object" && "getType" in t) {
762
+ if (typeof t.getType === "function") {
763
+ const actualType = t.getType();
764
+ if (typeof actualType === "function" && "typeName" in actualType) {
765
+ return actualType.typeName === type.typeName;
766
+ }
767
+ }
768
+ }
769
+ try {
770
+ type.fromJson(t);
771
+ return true;
772
+ }
773
+ catch (e) {
774
+ return false;
775
+ }
776
+ })
777
+ .meta({ protobuf: type.typeName });
778
+ }
779
+ export const zod = {
780
+ tasks: {
781
+ TaskId: protobufToZodSchema(tasks_pb.TaskId),
782
+ },
783
+ bytes: z
784
+ .custom((array) => {
785
+ return array instanceof Uint8Array;
786
+ })
787
+ .meta({ protobuf: "bytes" }),
788
+ };
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.27.1",
6
+ "@reboot-dev/reboot-api": "0.29.0",
7
7
  "chalk": "^4.1.2",
8
8
  "node-addon-api": "^7.0.0",
9
9
  "node-gyp": ">=10.2.0",
@@ -12,11 +12,13 @@
12
12
  "extensionless": "^1.9.9",
13
13
  "esbuild": "^0.24.0",
14
14
  "express": "^5.1.0",
15
- "@scarf/scarf": "1.4.0"
15
+ "@scarf/scarf": "1.4.0",
16
+ "tsx": "^4.19.2",
17
+ "zod": "^3.25.51"
16
18
  },
17
19
  "type": "module",
18
20
  "name": "@reboot-dev/reboot",
19
- "version": "0.27.1",
21
+ "version": "0.29.0",
20
22
  "description": "npm package for Reboot",
21
23
  "scripts": {
22
24
  "preinstall": "node preinstall.cjs",
@@ -26,7 +28,7 @@
26
28
  "prepack": "tsc"
27
29
  },
28
30
  "devDependencies": {
29
- "typescript": ">=4.9.5",
31
+ "typescript": "5.4.5",
30
32
  "@types/express": "^5.0.1",
31
33
  "@types/node": "20.11.5",
32
34
  "@types/uuid": "^9.0.4",
@@ -35,6 +37,7 @@
35
37
  "bin": {
36
38
  "rbt": "./rbt.js",
37
39
  "rbt-esbuild": "./rbt-esbuild.js",
40
+ "rbt-schema-to-proto": "./rbt-schema-to-proto.js",
38
41
  "protoc-gen-es": "./protoc-gen-es.js"
39
42
  },
40
43
  "engines": {
@@ -54,6 +57,8 @@
54
57
  "rbt.d.ts",
55
58
  "rbt-esbuild.d.ts",
56
59
  "rbt-esbuild.js",
60
+ "rbt-schema-to-proto.d.ts",
61
+ "rbt-schema-to-proto.js",
57
62
  "rbt.js",
58
63
  "index.d.ts",
59
64
  "index.js",
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env tsx
2
+ export {};
@@ -0,0 +1,594 @@
1
+ #!/usr/bin/env tsx
2
+ import { toPascalCase, toSnakeCase, ZOD_ERROR_NAMES, } from "@reboot-dev/reboot-api";
3
+ import { strict as assert } from "assert";
4
+ import chalk from "chalk";
5
+ import { mkdtemp } from "fs/promises";
6
+ import * as fs from "node:fs";
7
+ import * as os from "os";
8
+ import * as path from "path";
9
+ import { z } from "zod/v4";
10
+ // Expected usage: rbt-schema-to-protoc filesDirectory path/relative/to/filesDirectory/to/file.ts ...
11
+ const args = process.argv.slice(2);
12
+ const filesDirectory = args[0];
13
+ const files = args.slice(1);
14
+ const requestSchema = z.union([
15
+ z.instanceof(z.ZodObject),
16
+ z.record(z.string(), z.instanceof(z.ZodType)),
17
+ ]);
18
+ const responseSchema = z.union([
19
+ z.instanceof(z.ZodObject),
20
+ z.record(z.string(), z.instanceof(z.ZodType)),
21
+ z.instanceof(z.ZodVoid),
22
+ ]);
23
+ const errorsSchema = z.union([
24
+ z.tuple([z.instanceof(z.ZodObject)], z.instanceof(z.ZodObject)),
25
+ z.instanceof(z.ZodDiscriminatedUnion),
26
+ ]);
27
+ const typeSchema = z.object({
28
+ state: z.union([
29
+ z.instanceof(z.ZodObject),
30
+ z.record(z.string(), z.instanceof(z.ZodType)),
31
+ ]),
32
+ methods: z.record(z.string(), z.discriminatedUnion("kind", [
33
+ z.object({
34
+ kind: z.literal(["reader", "workflow"]),
35
+ request: requestSchema,
36
+ response: responseSchema,
37
+ errors: errorsSchema.optional(),
38
+ }),
39
+ z.object({
40
+ kind: z.literal(["writer", "transaction"]),
41
+ factory: z.object({}).optional(),
42
+ request: requestSchema,
43
+ response: responseSchema,
44
+ errors: errorsSchema.optional(),
45
+ }),
46
+ ])),
47
+ });
48
+ // TODO:
49
+ //
50
+ // z.date (use well-known proto)
51
+ // z.enum (only support number enums)
52
+ // "recursive types"
53
+ // z.map (use protobuf map and only allow the mapped types that protobuf supports)
54
+ // z.set (use protobuf repeated but the fact that we must take a `Set` will ensure it's a set, and we could add annotations to do proto validations for other languages)
55
+ // z.tuple
56
+ //
57
+ // UNREPRESENTABLE:
58
+ //
59
+ // z.union is unlikely because if it is not "flattened" we won't know
60
+ // which fields to set when there are multiple possible "correct" fields
61
+ //
62
+ // z.intersection (suggest destructuring as that's what Zod suggests)
63
+ const Z_JSON_GETTER_TO_STRING = z.json()._zod.def.getter.toString();
64
+ const iszjson = (schema) => {
65
+ return (schema instanceof z.ZodLazy &&
66
+ schema._zod.def.getter?.toString() === Z_JSON_GETTER_TO_STRING);
67
+ };
68
+ const generate = (proto, { schema, path, name, state = false, }) => {
69
+ if (schema instanceof z.ZodObject) {
70
+ assert(name !== undefined);
71
+ proto.write(`message ${name} {`);
72
+ if (state) {
73
+ proto.write(`option (rbt.v1alpha1.state) = {};`);
74
+ }
75
+ const tags = new Map();
76
+ const shape = schema._zod.def.shape;
77
+ for (const key in shape) {
78
+ // TODO: ensure `key` is PascalCase.
79
+ let value = shape[key];
80
+ if (value === undefined || value._zod.def === undefined) {
81
+ // TODO: is this expected or possible or should we raise here
82
+ // instead?
83
+ continue;
84
+ }
85
+ const meta = value.meta();
86
+ if (meta === undefined || !("tag" in meta)) {
87
+ console.error(chalk.stderr.bold.red(`Missing tag for property '${key}'; all properties of your object schemas must be tagged for backwards compatibility`));
88
+ process.exit(-1);
89
+ }
90
+ const { tag } = meta;
91
+ if (tags.has(tag)) {
92
+ // TODO: give "path" to this property.
93
+ console.error(chalk.stderr.bold.red(`Trying to use tag ${tag} with property '${key}' already used by '${tags.get(tag)}'`));
94
+ process.exit(-1);
95
+ }
96
+ tags.set(tag, key);
97
+ const field = toSnakeCase(key);
98
+ if (value instanceof z.ZodOptional) {
99
+ value = value._zod.def.innerType;
100
+ }
101
+ else if (value instanceof z.ZodDefault) {
102
+ const firstDefaultValue = value._zod.def.defaultValue;
103
+ const secondDefaultValue = value._zod.def.defaultValue;
104
+ switch (typeof firstDefaultValue) {
105
+ case "object":
106
+ if (firstDefaultValue === secondDefaultValue) {
107
+ console.error(chalk.stderr.bold.red(`'${path}.${key}' has a default value of type ` +
108
+ `${Array.isArray(firstDefaultValue) ? "array" : "object"} which ` +
109
+ `is dangerous and not supported, please use a ` +
110
+ `primitive type (e.g., string, number, boolean) ` +
111
+ `which is immutable or pass a function that creates ` +
112
+ `a new ${Array.isArray(firstDefaultValue) ? "array" : "object"} ` +
113
+ `each time\n`));
114
+ process.exit(-1);
115
+ }
116
+ // Otherwise, if objects are different, that means that
117
+ // the default value is a function that returns a new
118
+ // object each time, which is fine.
119
+ break;
120
+ default:
121
+ break;
122
+ }
123
+ value = value._zod.def.innerType;
124
+ }
125
+ if (value._zod.def.type === "string") {
126
+ proto.write(`optional string ${field} = ${tag};`);
127
+ }
128
+ else if (value._zod.def.type === "number") {
129
+ proto.write(`optional double ${field} = ${tag};`);
130
+ }
131
+ else if (value._zod.def.type === "bigint") {
132
+ proto.write(`optional int64 ${field} = ${tag};`);
133
+ }
134
+ else if (value._zod.def.type === "boolean") {
135
+ proto.write(`optional bool ${field} = ${tag};`);
136
+ }
137
+ else if (value._zod.def.type === "literal") {
138
+ // Make the name of this nested type be the PascalCase property
139
+ // TODO: ensure `key` is already camelCase.
140
+ const typeName = key.charAt(0).toUpperCase() + key.slice(1);
141
+ proto.write(`enum ${typeName} {`);
142
+ let i = 0;
143
+ for (const literal of value._zod.def.values) {
144
+ if (typeof literal !== "string") {
145
+ console.error(chalk.stderr.bold.red(`Unexpected literal '${literal}' for property '${key}'; only 'string' literals are currently supported`));
146
+ process.exit(-1);
147
+ }
148
+ proto.write(`${literal} = ${i++};`);
149
+ }
150
+ proto.write(`}`);
151
+ proto.write(`optional ${typeName} ${field} = ${tag};`);
152
+ }
153
+ else if (value._zod.def.type === "array") {
154
+ // Make the name of this nested type be the PascalCase property
155
+ // TODO: ensure `key` is already camelCase.
156
+ const typeName = key.charAt(0).toUpperCase() + key.slice(1) + "Array";
157
+ proto.write(`message ${typeName} {`);
158
+ generate(proto, { schema: value, path: `${path}.${key}` });
159
+ proto.write(`}`);
160
+ proto.write(`optional ${typeName} ${field} = ${tag};`);
161
+ }
162
+ else if (value._zod.def.type === "record") {
163
+ // Make the name of this nested type be the PascalCase property
164
+ // TODO: ensure `key` is already camelCase.
165
+ const typeName = key.charAt(0).toUpperCase() + key.slice(1) + "Record";
166
+ proto.write(`message ${typeName} {`);
167
+ generate(proto, { schema: value, path: `${path}.${key}` });
168
+ proto.write(`}`);
169
+ proto.write(`optional ${typeName} ${field} = ${tag};`);
170
+ }
171
+ else if (value instanceof z.ZodDiscriminatedUnion) {
172
+ // `instanceof` b.c. type = "union".
173
+ // Make the name of this nested type be the PascalCase property
174
+ // TODO: ensure `key` is already camelCase.
175
+ const typeName = key.charAt(0).toUpperCase() + key.slice(1);
176
+ generate(proto, {
177
+ schema: value,
178
+ path: `${path}.${key}`,
179
+ name: typeName,
180
+ });
181
+ proto.write(`optional ${typeName} ${field} = ${tag};`);
182
+ }
183
+ else if (value._zod.def.type === "object") {
184
+ // Make the name of this nested type be the PascalCase property.
185
+ const typeName = key.charAt(0).toUpperCase() + key.slice(1);
186
+ generate(proto, {
187
+ schema: value,
188
+ path: `${path}.${key}`,
189
+ name: typeName,
190
+ });
191
+ proto.write(`optional ${typeName} ${field} = ${tag};`);
192
+ }
193
+ else if (value._zod.def.type === "custom" && "protobuf" in meta) {
194
+ const typeName = meta.protobuf;
195
+ proto.write(`optional ${typeName} ${field} = ${tag};`);
196
+ }
197
+ else if (iszjson(value)) {
198
+ proto.write(`optional google.protobuf.Value ${field} = ${tag};`);
199
+ }
200
+ else {
201
+ console.error(chalk.stderr.bold.red(`'${path}.${key}' has type '${value._zod.def.type}' which is not (yet) supported, please reach out to the maintainers!`));
202
+ process.exit(-1);
203
+ }
204
+ }
205
+ proto.write(`}\n`);
206
+ }
207
+ else if (schema instanceof z.ZodRecord) {
208
+ const keyType = schema.keyType;
209
+ // To account for all possible "string" types in Zod, e.g.,
210
+ // `z.uuid()`, `z.email()`, etc, we can't use `instanceof`.
211
+ if (keyType._zod.def.type !== "string") {
212
+ console.error(chalk.stderr.bold.red(`Unexpected record key type '${keyType._zod.def.type}' at '${path}'; only 'string' key types are currently supported for records`));
213
+ process.exit(-1);
214
+ }
215
+ const valueType = schema.valueType;
216
+ if (valueType instanceof z.ZodOptional) {
217
+ console.error(chalk.stderr.bold.red(`Record at '${path}' has _optional_ value type which is not supported`));
218
+ process.exit(-1);
219
+ }
220
+ else if (valueType instanceof z.ZodDefault) {
221
+ console.error(chalk.stderr.bold.red(`Record at '${path}' has _default_ value type which is not supported`));
222
+ process.exit(-1);
223
+ }
224
+ const typeName = (() => {
225
+ // To account for all possible "string" types in Zod, e.g.,
226
+ // `z.uuid()`, `z.email()`, etc, we can't use `instanceof`.
227
+ if (valueType._zod.def.type === "string") {
228
+ return "string";
229
+ }
230
+ else if (valueType instanceof z.ZodNumber) {
231
+ return "double";
232
+ }
233
+ else if (valueType instanceof z.ZodBigInt) {
234
+ return "int64";
235
+ }
236
+ else if (valueType instanceof z.ZodBoolean) {
237
+ return "bool";
238
+ }
239
+ else if (valueType instanceof z.ZodObject) {
240
+ return "Value";
241
+ }
242
+ else if (valueType instanceof z.ZodRecord) {
243
+ return "Value";
244
+ }
245
+ else if (valueType instanceof z.ZodArray) {
246
+ return "Value";
247
+ }
248
+ else if (valueType instanceof z.ZodDiscriminatedUnion) {
249
+ return "Value";
250
+ }
251
+ else if (valueType instanceof z.ZodCustom) {
252
+ const meta = valueType.meta();
253
+ if (valueType instanceof z.ZodCustom && "protobuf" in meta) {
254
+ return meta.protobuf;
255
+ }
256
+ }
257
+ else if (iszjson(valueType)) {
258
+ return "google.protobuf.Value";
259
+ }
260
+ console.error(chalk.stderr.bold.red(`Record at '${path}' has value type '${valueType._zod.def.type}' which is not (yet) supported`));
261
+ process.exit(-1);
262
+ })();
263
+ if (valueType instanceof z.ZodObject) {
264
+ generate(proto, {
265
+ schema: valueType,
266
+ path: `${path}.[value]`,
267
+ name: typeName,
268
+ });
269
+ }
270
+ else if (valueType instanceof z.ZodRecord) {
271
+ proto.write(`message ${typeName} {`);
272
+ generate(proto, {
273
+ schema: valueType,
274
+ path: `${path}.[value]`,
275
+ });
276
+ proto.write(`}`);
277
+ }
278
+ else if (valueType instanceof z.ZodArray) {
279
+ proto.write(`message ${typeName} {`);
280
+ generate(proto, {
281
+ schema: valueType,
282
+ path: `${path}.[value]`,
283
+ });
284
+ proto.write(`}`);
285
+ }
286
+ else if (valueType instanceof z.ZodDiscriminatedUnion) {
287
+ generate(proto, {
288
+ schema: valueType,
289
+ path: `${path}.[value]`,
290
+ name: typeName,
291
+ });
292
+ }
293
+ proto.write(`map <string, ${typeName}> record = 1;`);
294
+ }
295
+ else if (schema instanceof z.ZodArray) {
296
+ const element = schema.element;
297
+ if (element instanceof z.ZodOptional) {
298
+ console.error(chalk.stderr.bold.red(`Array at '${path}' has _optional_ element type which is not supported`));
299
+ process.exit(-1);
300
+ }
301
+ else if (element instanceof z.ZodDefault) {
302
+ console.error(chalk.stderr.bold.red(`Array at '${path}' has _default_ element type which is not supported`));
303
+ process.exit(-1);
304
+ }
305
+ const typeName = (() => {
306
+ // To account for all possible "string" types in Zod, e.g.,
307
+ // `z.uuid()`, `z.email()`, etc, we can't use `instanceof`.
308
+ if (element._zod.def.type === "string") {
309
+ return "string";
310
+ }
311
+ else if (element instanceof z.ZodNumber) {
312
+ return "double";
313
+ }
314
+ else if (element instanceof z.ZodBigInt) {
315
+ return "int64";
316
+ }
317
+ else if (element instanceof z.ZodBoolean) {
318
+ return "bool";
319
+ }
320
+ else if (element instanceof z.ZodObject) {
321
+ return "Element";
322
+ }
323
+ else if (element instanceof z.ZodRecord) {
324
+ return "Element";
325
+ }
326
+ else if (element instanceof z.ZodArray) {
327
+ return "Element";
328
+ }
329
+ else if (element instanceof z.ZodDiscriminatedUnion) {
330
+ return "Element";
331
+ }
332
+ else if (element instanceof z.ZodCustom) {
333
+ const meta = element.meta();
334
+ if (element instanceof z.ZodCustom && "protobuf" in meta) {
335
+ return meta.protobuf;
336
+ }
337
+ }
338
+ else if (iszjson(element)) {
339
+ return "google.protobuf.Value";
340
+ }
341
+ console.error(chalk.stderr.bold.red(`Array at '${path}' has element type '${element._zod.def.type}' which is not (yet) supported`));
342
+ process.exit(-1);
343
+ })();
344
+ if (element instanceof z.ZodObject) {
345
+ generate(proto, {
346
+ schema: element,
347
+ path: `${path}.[element]`,
348
+ name: typeName,
349
+ });
350
+ }
351
+ else if (element instanceof z.ZodRecord) {
352
+ proto.write(`message ${typeName} {`);
353
+ generate(proto, {
354
+ schema: element,
355
+ path: `${path}.[element]`,
356
+ });
357
+ proto.write(`}`);
358
+ }
359
+ else if (element instanceof z.ZodArray) {
360
+ proto.write(`message ${typeName} {`);
361
+ generate(proto, {
362
+ schema: element,
363
+ path: `${path}.[element]`,
364
+ });
365
+ proto.write(`}`);
366
+ }
367
+ else if (element instanceof z.ZodDiscriminatedUnion) {
368
+ generate(proto, {
369
+ schema: element,
370
+ path: `${path}.[element]`,
371
+ name: typeName,
372
+ });
373
+ }
374
+ proto.write(`repeated ${typeName} elements = 1;`);
375
+ }
376
+ else if (schema instanceof z.ZodDiscriminatedUnion) {
377
+ assert(name !== undefined);
378
+ proto.write(`message ${name} {`);
379
+ const discriminator = schema._zod.def.discriminator;
380
+ const literals = new Set();
381
+ const tags = new Map();
382
+ for (const option of schema.options) {
383
+ if (!(option instanceof z.ZodObject)) {
384
+ console.error(chalk.stderr.bold.red(`Discriminated union at '${path}' has option of type '${option._zod.def.type}', should be 'object'`));
385
+ process.exit(-1);
386
+ }
387
+ if (!(discriminator in option.shape)) {
388
+ console.error(chalk.stderr.bold.red(`Discriminated union at '${path}' has option missing discriminator '${discriminator}'`));
389
+ process.exit(-1);
390
+ }
391
+ if (!(option.shape[discriminator] instanceof z.ZodLiteral)) {
392
+ console.error(chalk.stderr.bold.red(`Discriminated union at '${path}' has option with unexpected discriminator '${discriminator}'; only 'string' literals are currently supported`));
393
+ process.exit(-1);
394
+ }
395
+ for (const literal of option.shape[discriminator]._zod.def.values) {
396
+ if (typeof literal !== "string") {
397
+ console.error(chalk.stderr.bold.red(`Discriminated union at '${path}' has option with unexpected discriminator '${discriminator}'; only 'string' literals are currently supported`));
398
+ process.exit(-1);
399
+ }
400
+ }
401
+ // Can only support one value because of how we name
402
+ // things. Could consider using the `tag` to name things instead
403
+ // so that the literal values could change, but it makes the
404
+ // proto less readable in places like the inspect page.
405
+ if (option.shape[discriminator]._zod.def.values.length !== 1) {
406
+ console.error(chalk.stderr.bold.red(`Discriminated union at '${path}' has option with more than one discriminator '${discriminator}' literals, only one is currently supported`));
407
+ process.exit(-1);
408
+ }
409
+ const literal = option.shape[discriminator]._zod.def.values[0];
410
+ if (literals.has(literal)) {
411
+ console.error(chalk.stderr.bold.red(`Discriminated union at '${path}' has an option that is reusing the literal '${literal}' for discrimniator '${discriminator}'`));
412
+ process.exit(-1);
413
+ }
414
+ literals.add(literal);
415
+ // Make the name of this nested type be the PascalCase property.
416
+ const typeName = literal.charAt(0).toUpperCase() + literal.slice(1);
417
+ const meta = option.meta();
418
+ if (meta === undefined || !("tag" in meta)) {
419
+ console.error(chalk.stderr.bold.red(`Missing tag for discriminated union option at '${path}'; all discriminated union options must be tagged for backwards compatibility`));
420
+ process.exit(-1);
421
+ }
422
+ const { tag } = meta;
423
+ if (tags.has(tag)) {
424
+ console.error(chalk.stderr.bold.red(`Trying to use already used tag ${tag} in discriminated union`));
425
+ process.exit(-1);
426
+ }
427
+ tags.set(tag, [toSnakeCase(literal), typeName]);
428
+ const omit = {};
429
+ omit[discriminator] = true;
430
+ generate(proto, {
431
+ schema: option.omit(omit),
432
+ path: `${path}.{ ${discriminator}: "${literal}", ... }`,
433
+ name: typeName,
434
+ });
435
+ }
436
+ proto.write(`oneof ${discriminator} {`);
437
+ for (const [tag, [literal, typeName]] of tags) {
438
+ proto.write(`${typeName} ${literal} = ${tag};`);
439
+ }
440
+ proto.write(`}`);
441
+ proto.write(`}`);
442
+ }
443
+ else {
444
+ throw new Error(`Unexpected type '${schema._zod.def.type}'`);
445
+ }
446
+ };
447
+ const main = async () => {
448
+ // We generate the `.proto` files in a temporary directory.
449
+ const generatedProtosDirectory = await mkdtemp(path.join(os.tmpdir(), "protos-"));
450
+ const cwd = process.cwd();
451
+ let protoFileGenerated = false;
452
+ for (const file of files) {
453
+ const module = await import(`${path.join(cwd, filesDirectory, file)}`);
454
+ const api = module.api;
455
+ if (api === undefined) {
456
+ // Skipping the file if it does not export `api`.
457
+ // We will error out from 'protoc' if none of the files
458
+ // exported `api`.
459
+ continue;
460
+ }
461
+ // Get '.proto' file name, i.e., by changing '.ts' to '.proto'.
462
+ //
463
+ // NOTE: there is an prerequisite invariant here that the paths
464
+ // are already in the correct form, hence why we have to join with
465
+ // all of `cwd`, `filesDirectory`, and `file` above.
466
+ const parsed = path.parse(file);
467
+ parsed.ext = ".proto";
468
+ parsed.base = parsed.name + parsed.ext;
469
+ const name = `${path.format(parsed)}`;
470
+ fs.mkdirSync(path.dirname(path.join(generatedProtosDirectory, name)), {
471
+ recursive: true,
472
+ });
473
+ const proto = fs.createWriteStream(path.join(generatedProtosDirectory, name));
474
+ proto.write(`syntax = "proto3";\n`);
475
+ proto.write(`package ${parsed.dir.replace(/\//g, ".")};\n`);
476
+ proto.write(`import "google/protobuf/empty.proto";\n`);
477
+ proto.write(`import "google/protobuf/struct.proto";\n`);
478
+ proto.write(`import "rbt/v1alpha1/options.proto";\n`);
479
+ // Including 'rbt/v1alpha1/tasks.proto' preemptively to support
480
+ // protobuf messages like `TaskId`.
481
+ proto.write(`import "rbt/v1alpha1/tasks.proto";\n`);
482
+ proto.write(`option (rbt.v1alpha1.file).zod = "${path.join(cwd, filesDirectory, file)}";\n`);
483
+ for (const typeName in api) {
484
+ // TODO: ensure `typeName` is PascalCase.
485
+ const result = typeSchema.safeParse(api[typeName]);
486
+ if (!result.success) {
487
+ console.error(chalk.stderr.bold.red(`'api.${typeName}' in '${path.join(filesDirectory, file)}' could not be validated! Please try again after correcting the following errors: \n\n${z.prettifyError(result.error)}\n`));
488
+ // Check if we have any 'not instance of ZodType' errors,
489
+ // possibly indicating that 'zod/v4' is not being
490
+ // used. Unfortunately Zod does not provide a way to check
491
+ // what version of the library is being used, so this is just
492
+ // best effort.
493
+ for (const issue of result.error.issues) {
494
+ if (JSON.stringify(issue).includes("not instance of ZodType")) {
495
+ console.error(chalk.stderr.bold.red(`NOTE: Reboot requires 'zod/v4', are you using it?\n`));
496
+ break;
497
+ }
498
+ }
499
+ process.exit(-1);
500
+ }
501
+ const type = result.data;
502
+ generate(proto, {
503
+ schema: type.state instanceof z.ZodObject ? type.state : z.object(type.state),
504
+ path: `api.${typeName}.state`,
505
+ name: typeName,
506
+ state: true,
507
+ });
508
+ const errorsToGenerate = [];
509
+ for (const methodName in type.methods) {
510
+ // TODO: ensure `methodName` is PascalCase.
511
+ const { request, response } = type.methods[methodName];
512
+ const requestTypeName = `${typeName}${toPascalCase(methodName)}Request`;
513
+ generate(proto, {
514
+ schema: request instanceof z.ZodObject ? request : z.object(request),
515
+ path: `api.${typeName}.methods.${methodName}.request`,
516
+ name: requestTypeName,
517
+ });
518
+ if (response instanceof z.ZodVoid) {
519
+ continue;
520
+ }
521
+ const responseTypeName = `${typeName}${toPascalCase(methodName)}Response`;
522
+ generate(proto, {
523
+ schema: response instanceof z.ZodObject ? response : z.object(response),
524
+ path: `api.${typeName}.methods.${methodName}.response`,
525
+ name: responseTypeName,
526
+ });
527
+ }
528
+ proto.write(`service ${typeName}Methods {\n`);
529
+ for (const methodName in type.methods) {
530
+ // TODO: ensure `methodName` is PascalCase.
531
+ const method = type.methods[methodName];
532
+ const requestTypeName = `${typeName}${toPascalCase(methodName)}Request`;
533
+ const responseTypeName = method.response instanceof z.ZodVoid
534
+ ? `google.protobuf.Empty`
535
+ : `${typeName}${toPascalCase(methodName)}Response`;
536
+ proto.write([
537
+ ` rpc ${toPascalCase(methodName)}(${requestTypeName})`,
538
+ ` returns (${responseTypeName}) {`,
539
+ ` option (rbt.v1alpha1.method) = {`,
540
+ ` ${method.kind}: {`,
541
+ ].join(`\n`));
542
+ if (method.kind === "writer" || method.kind === "transaction") {
543
+ if (method.factory !== undefined) {
544
+ proto.write(` constructor: {},\n`);
545
+ }
546
+ }
547
+ proto.write(` },\n`);
548
+ if (method.errors !== undefined) {
549
+ const errors = method.errors instanceof z.ZodDiscriminatedUnion
550
+ ? method.errors
551
+ : z.discriminatedUnion("type", method.errors);
552
+ const path = `api.${typeName}.methods.${methodName}.errors`;
553
+ const name = `${typeName}${toPascalCase(methodName)}Errors`;
554
+ errorsToGenerate.push(() => {
555
+ generate(proto, { schema: errors, path, name });
556
+ });
557
+ // In addition to all the checks we get above when calling
558
+ // `generate()`, we also want to make sure that none of the
559
+ // gRPC or Reboot error types conflict.
560
+ for (const option of errors.options) {
561
+ const literal = option.shape.type._zod.def.values[0];
562
+ if (ZOD_ERROR_NAMES.includes(literal)) {
563
+ console.error(chalk.stderr.bold.red(`'${path}' uses '${literal}' as an error 'type' that conflicts with system errors, please use a different literal!\n`));
564
+ process.exit(-1);
565
+ }
566
+ }
567
+ // And finally, we need to add this protobuf message to the
568
+ // `errors` option for this method in the `.proto` file.
569
+ proto.write(` errors: ["${name}"],\n`);
570
+ }
571
+ proto.write([` };`, ` }`].join(`\n`));
572
+ }
573
+ proto.write(`}\n`);
574
+ // Generate all the errors we need at the top-level.
575
+ errorsToGenerate.forEach((generate) => generate());
576
+ }
577
+ proto.end();
578
+ // Need to wait for the file to be written (TODO: wait for all of
579
+ // them via `Promise.all`).
580
+ await new Promise((resolve, reject) => {
581
+ proto.on("finish", () => resolve());
582
+ proto.on("error", (err) => reject(err));
583
+ });
584
+ protoFileGenerated = true;
585
+ }
586
+ if (protoFileGenerated) {
587
+ // If at least one proto file was generated, we write the
588
+ // directory to the stdout, so Python 'generate' script can
589
+ // glob inside it and adjust the 'protoc' generation command.
590
+ console.log(`${generatedProtosDirectory}`);
591
+ }
592
+ process.exit(0);
593
+ };
594
+ main();
package/reboot_native.cc CHANGED
@@ -2597,7 +2597,7 @@ Napi::Value retry_reactively_until(const Napi::CallbackInfo& info) {
2597
2597
  }
2598
2598
 
2599
2599
 
2600
- Napi::Value atLeastOrMostOnce(const Napi::CallbackInfo& info) {
2600
+ Napi::Value memoize(const Napi::CallbackInfo& info) {
2601
2601
  auto js_external_context = NapiSafeReference(
2602
2602
  info[0].As<Napi::External<py::object>>());
2603
2603
 
@@ -2840,8 +2840,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
2840
2840
  Napi::Function::New<retry_reactively_until>(env));
2841
2841
 
2842
2842
  exports.Set(
2843
- Napi::String::New(env, "atLeastOrMostOnce"),
2844
- Napi::Function::New<atLeastOrMostOnce>(env));
2843
+ Napi::String::New(env, "memoize"),
2844
+ Napi::Function::New<memoize>(env));
2845
2845
 
2846
2846
  exports.Set(
2847
2847
  Napi::String::New(env, "Task_await"),
package/reboot_native.cjs CHANGED
@@ -85,7 +85,7 @@ exports.Context_generateIdempotentStateId =
85
85
  reboot_native.exports.Context_generateIdempotentStateId;
86
86
  exports.WriterContext_set_sync = reboot_native.exports.WriterContext_set_sync;
87
87
  exports.retry_reactively_until = reboot_native.exports.retry_reactively_until;
88
- exports.atLeastOrMostOnce = reboot_native.exports.atLeastOrMostOnce;
88
+ exports.memoize = reboot_native.exports.memoize;
89
89
  exports.Servicer_read = reboot_native.exports.Servicer_read;
90
90
  exports.Servicer_write = reboot_native.exports.Servicer_write;
91
91
  exports.importPy = reboot_native.exports.importPy;
@@ -89,7 +89,7 @@ export namespace rbt_native {
89
89
  external: NapiExternal,
90
90
  condition: () => Promise<boolean>
91
91
  ): Promise<void>;
92
- function atLeastOrMostOnce(
92
+ function memoize(
93
93
  external: NapiExternal,
94
94
  idempotencyAlias: string,
95
95
  callable: () => Promise<string>,
@@ -1,3 +1,4 @@
1
+ /// <reference types="node" resolution-mode="require"/>
1
2
  export declare const ENVVAR_RBT_SECRETS_DIRECTORY = "RBT_SECRETS_DIRECTORY";
2
3
  declare abstract class SecretSource {
3
4
  abstract get(secretName: string): Promise<Buffer>;
package/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const REBOOT_VERSION = "0.27.1";
1
+ export declare const REBOOT_VERSION = "0.29.0";
package/version.js CHANGED
@@ -1 +1 @@
1
- export const REBOOT_VERSION = "0.27.1";
1
+ export const REBOOT_VERSION = "0.29.0";