@sanoma-ai/workflows 0.11.16 → 0.11.18

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.
Files changed (108) hide show
  1. package/README.md +16 -8
  2. package/dist/call.d.ts +35 -4
  3. package/dist/call.js +128 -40
  4. package/dist/call.js.map +1 -1
  5. package/dist/client.d.ts +17 -8
  6. package/dist/client.js +90 -51
  7. package/dist/client.js.map +1 -1
  8. package/dist/collection.d.ts +25 -7
  9. package/dist/collection.js +236 -91
  10. package/dist/collection.js.map +1 -1
  11. package/dist/config.d.ts +24 -1
  12. package/dist/config.js +48 -10
  13. package/dist/config.js.map +1 -1
  14. package/dist/db/company.d.ts +20 -0
  15. package/dist/db/company.js +9 -1
  16. package/dist/db/company.js.map +1 -1
  17. package/dist/describe.js +1 -1
  18. package/dist/describe.js.map +1 -1
  19. package/dist/description.d.ts +6 -3
  20. package/dist/description.js +5 -4
  21. package/dist/description.js.map +1 -1
  22. package/dist/errors.d.ts +2 -1
  23. package/dist/errors.js +3 -2
  24. package/dist/errors.js.map +1 -1
  25. package/dist/grid.js +3 -2
  26. package/dist/grid.js.map +1 -1
  27. package/dist/host-derive.d.ts +13 -8
  28. package/dist/host-derive.js +26 -13
  29. package/dist/host-derive.js.map +1 -1
  30. package/dist/index.d.ts +7 -3
  31. package/dist/index.js +7 -3
  32. package/dist/index.js.map +1 -1
  33. package/dist/ledger.d.ts +7 -0
  34. package/dist/ledger.js +32 -2
  35. package/dist/ledger.js.map +1 -1
  36. package/dist/local-sandbox.d.ts +17 -0
  37. package/dist/local-sandbox.js +131 -0
  38. package/dist/local-sandbox.js.map +1 -0
  39. package/dist/op.d.ts +10 -2
  40. package/dist/op.js.map +1 -1
  41. package/dist/paths.d.ts +9 -0
  42. package/dist/paths.js +20 -4
  43. package/dist/paths.js.map +1 -1
  44. package/dist/resource.d.ts +7 -1
  45. package/dist/resource.js +5 -1
  46. package/dist/resource.js.map +1 -1
  47. package/dist/resources.d.ts +1 -1
  48. package/dist/resources.js +9 -6
  49. package/dist/resources.js.map +1 -1
  50. package/dist/runner-box.d.ts +23 -0
  51. package/dist/runner-box.js +181 -0
  52. package/dist/runner-box.js.map +1 -0
  53. package/dist/runner-protocol.d.ts +73 -0
  54. package/dist/runner-protocol.js +34 -0
  55. package/dist/runner-protocol.js.map +1 -0
  56. package/dist/sandbox-contract.d.ts +109 -0
  57. package/dist/sandbox-contract.js +26 -0
  58. package/dist/sandbox-contract.js.map +1 -0
  59. package/dist/sandbox-runner.cjs +7 -0
  60. package/dist/scenario.js +16 -7
  61. package/dist/scenario.js.map +1 -1
  62. package/dist/schedules.d.ts +11 -3
  63. package/dist/schedules.js +15 -3
  64. package/dist/schedules.js.map +1 -1
  65. package/dist/schema.js +6 -3
  66. package/dist/schema.js.map +1 -1
  67. package/dist/secret.d.ts +2 -1
  68. package/dist/secret.js +3 -2
  69. package/dist/secret.js.map +1 -1
  70. package/dist/shared.d.ts +39 -5
  71. package/dist/shared.js +13 -1
  72. package/dist/shared.js.map +1 -1
  73. package/dist/sites.js +10 -8
  74. package/dist/sites.js.map +1 -1
  75. package/dist/source.d.ts +21 -0
  76. package/dist/source.js.map +1 -1
  77. package/drizzle/company/0003_ledger_target.sql +2 -0
  78. package/drizzle/company/meta/0003_snapshot.json +845 -0
  79. package/drizzle/company/meta/_journal.json +7 -0
  80. package/package.json +5 -2
  81. package/src/call.ts +181 -60
  82. package/src/client.ts +118 -67
  83. package/src/collection.ts +265 -97
  84. package/src/config.ts +65 -16
  85. package/src/db/company.ts +9 -1
  86. package/src/describe.ts +1 -1
  87. package/src/description.ts +5 -4
  88. package/src/errors.ts +3 -2
  89. package/src/grid.ts +3 -1
  90. package/src/host-derive.ts +38 -18
  91. package/src/index.ts +15 -2
  92. package/src/ledger.ts +42 -2
  93. package/src/local-sandbox.ts +133 -0
  94. package/src/op.ts +10 -2
  95. package/src/paths.ts +22 -4
  96. package/src/resource.ts +16 -2
  97. package/src/resources.ts +8 -6
  98. package/src/runner-box.ts +218 -0
  99. package/src/runner-protocol.ts +61 -0
  100. package/src/sandbox-contract.ts +121 -0
  101. package/src/sandbox-runner.ts +187 -0
  102. package/src/scenario.ts +16 -6
  103. package/src/schedules.ts +19 -4
  104. package/src/schema.ts +14 -3
  105. package/src/secret.ts +6 -2
  106. package/src/shared.ts +40 -4
  107. package/src/sites.ts +11 -7
  108. package/src/source.ts +14 -0
package/src/schedules.ts CHANGED
@@ -140,23 +140,38 @@ interface TimeMatcher {
140
140
  /** The first instant strictly after `date` that matches; one that does not at the end of its search horizon. */
141
141
  nextWakeupTime(date: Date | number): Date;
142
142
  }
143
- type TimeMatcherClass = new (pattern: string, timezone?: string) => TimeMatcher;
143
+ export type TimeMatcherClass = new (pattern: string, timezone?: string) => TimeMatcher;
144
144
 
145
145
  let matcherClass: TimeMatcherClass | undefined;
146
146
 
147
147
  /**
148
- * DBOS's `TimeMatcher`, from `dist/src/scheduler/crontab.js` beside its main module: the package's
149
- * exports leave it out (dbos-sdk 5.2.11), so it is loaded by path, and a test holds it there.
148
+ * The file DBOS's `TimeMatcher` is in, `dist/src/scheduler/crontab.js` beside its main module, as
149
+ * `require` resolves the package: its exports leave it out (dbos-sdk 5.2.11), so it is loaded by
150
+ * path, and a test holds it there.
150
151
  */
152
+ export const timeMatcherFile = (require: NodeJS.Require): string =>
153
+ join(dirname(require.resolve("@dbos-inc/dbos-sdk")), "scheduler", "crontab.js");
154
+
155
+ /** DBOS's `TimeMatcher`, required from its file (`timeMatcherFile`), or the one `useTimeMatcher` gave. */
151
156
  export function timeMatcher(): TimeMatcherClass {
152
157
  if (matcherClass) return matcherClass;
153
158
  const require = createRequire(import.meta.url);
154
- const file = join(dirname(require.resolve("@dbos-inc/dbos-sdk")), "scheduler", "crontab.js");
159
+ const file = timeMatcherFile(require);
155
160
  const loaded = (require(file) as { TimeMatcher?: unknown }).TimeMatcher;
156
161
  if (typeof loaded !== "function") throw new Error(`DBOS's cron parser is not in ${file} any more`);
157
162
  return (matcherClass = loaded as TimeMatcherClass);
158
163
  }
159
164
 
165
+ /**
166
+ * From now on, DBOS's cron parser is `matcher`, where it cannot be required by path: workerd,
167
+ * which bundles the runtime, and imports it by its file.
168
+ */
169
+ export function useTimeMatcher(matcher: TimeMatcherClass): void {
170
+ if (typeof matcher !== "function")
171
+ throw new Error("DBOS's cron parser (TimeMatcher) is not in its scheduler's crontab.js any more");
172
+ matcherClass = matcher;
173
+ }
174
+
160
175
  /**
161
176
  * When `schedule` (a cron expression read in `timezone`, UTC without one) fires next, strictly
162
177
  * after `from`: as DBOS's scheduler finds it. Throws for a schedule that never fires.
package/src/schema.ts CHANGED
@@ -13,7 +13,15 @@ import { recordSchemaOf } from "./collection.ts";
13
13
  import type { ResolvedConfig } from "./config.ts";
14
14
  import { jsonSchemaOf } from "./define.ts";
15
15
  import { isObject } from "./resource.ts";
16
- import { CHANGE_MERGE, DATA_VENDOR, EFFECTS, groupNamesOf, type PeopleResource, WORKFLOW_ACTION } from "./shared.ts";
16
+ import {
17
+ CHANGE_MERGE,
18
+ DATA_VENDOR,
19
+ EFFECTS,
20
+ groupNamesOf,
21
+ isBatch,
22
+ type PeopleResource,
23
+ WORKFLOW_ACTION,
24
+ } from "./shared.ts";
17
25
 
18
26
  // The Cedar schema a company's policies are validated and decided against: made from the
19
27
  // resolved config and the people, in memory for the evaluator, and written as text to
@@ -90,7 +98,8 @@ const WRITES = new Set(["save", "delete"]);
90
98
  * - `entity Group enum [...]`, the declared groups, `agents` and `visitors`, so a policy naming another fails
91
99
  * validation; `entity Principal in [Group] { onBehalfOf?: Principal }`; `Collection`;
92
100
  * `Target in [Collection]`; `Workflow`;
93
- * - an action per operation id, in its effect's group (`effect:money`), on a `Target`, with
101
+ * - an action per operation id (a collection's `saveMany` excepted: the policy decides each of
102
+ * its entries as a `save` or `delete`), in its effect's group (`effect:money`), on a `Target`, with
94
103
  * `context` `{ input, before? }`: `input` from the operation's input schema, `before` the record
95
104
  * as it was for a collection's `save` and `delete`; `sanoma.change.merge` on a `Change`, whose
96
105
  * attributes are a change's facts (`ChangeFacts`), so a rule reads `resource.touches`;
@@ -112,7 +121,9 @@ function buildSchema(
112
121
  });
113
122
  const actions: CedarSchemaJson[string]["actions"] = { [START]: {} };
114
123
  for (const effect of EFFECTS) actions[`effect:${effect}`] = {};
115
- for (const op of [...resolved.ops.values()].toSorted((a, b) => a.id.localeCompare(b.id))) {
124
+ // A collection's batch is no action: the policy decides each of its entries as a save or a delete.
125
+ const ops = [...resolved.ops.values()].filter((op) => !isBatch(op));
126
+ for (const op of ops.toSorted((a, b) => a.id.localeCompare(b.id))) {
116
127
  const collection =
117
128
  op.vendor === DATA_VENDOR && WRITES.has(op.name) ? resolved.collections.get(op.resource) : undefined;
118
129
  const before = collection && typeOf(recordSchemaOf(collection));
package/src/secret.ts CHANGED
@@ -101,14 +101,18 @@ export type Resolved<T> = T extends Secret
101
101
 
102
102
  /**
103
103
  * `value` with each secret marker in it replaced by the stored value: generated and stored first
104
- * for one with `generate` and none stored. A marker naming nothing stored fails the call, for good.
104
+ * for one with `generate` and none stored. A marker naming nothing stored fails the call, for good,
105
+ * before the driver changes anything at the vendor (`secret_not_set`, which the ledger tells).
105
106
  */
106
107
  export async function resolveSecrets<T>(value: T, secrets: Secrets): Promise<Resolved<T>> {
107
108
  const resolve = async (v: unknown): Promise<unknown> => {
108
109
  if (isSecret(v)) {
109
110
  const stored = v.generate ? await secrets.ensure(v.$secret, generateSecret) : await secrets.get(v.$secret);
110
111
  if (stored === undefined) {
111
- throw new DriverError(`The secret ${v.$secret} is not set: store it before applying`, { retryable: false });
112
+ throw new DriverError(
113
+ `The secret ${v.$secret} has no value: store it (or, where sanoma.config.ts binds it, set what the binding reads), then try again`,
114
+ { retryable: false, vendorCode: "secret_not_set" },
115
+ );
112
116
  }
113
117
  return stored;
114
118
  }
package/src/shared.ts CHANGED
@@ -55,6 +55,14 @@ export const APPLY_WORKFLOW = "apply";
55
55
  */
56
56
  export const DATA_VENDOR = "data";
57
57
 
58
+ /** The one operation of a collection that stands for others: `saveMany`, each entry a `save` or `delete`. */
59
+ export const BATCH = "saveMany";
60
+ export const isBatch = (op: Pick<OpEntry, "vendor" | "name">): boolean =>
61
+ op.vendor === DATA_VENDOR && op.name === BATCH;
62
+ /** The ids of the operations a batch's entries are: its collection's `save` and `delete`. */
63
+ export const entryOpsOf = (op: Pick<OpEntry, "resource">): string[] =>
64
+ ["save", "delete"].map((name) => `${DATA_VENDOR}.${op.resource}.${name}`);
65
+
58
66
  /** Every effect an operation may have, each an action group (`effect:<effect>`) of the Cedar schema. */
59
67
  export const EFFECTS = ["read", "write", "publish", "send", "money", "access"] as const satisfies readonly Effect[];
60
68
 
@@ -68,11 +76,16 @@ export const WORKFLOW_ACTION = "workflow:";
68
76
  */
69
77
  export function previewRequests(
70
78
  workflow: Pick<WorkflowEntry, "name" | "ops">,
71
- ops: ReadonlyMap<string, Pick<OpEntry, "effect">>,
79
+ ops: ReadonlyMap<string, Pick<OpEntry, "effect" | "vendor" | "resource" | "name">>,
72
80
  ): Omit<PreviewRequest, "principal">[] {
81
+ // A batch is asked as its entries, the save and the delete the policy decides: no rule names it.
82
+ const asked = workflow.ops.flatMap((id) => {
83
+ const op = ops.get(id);
84
+ return op && isBatch(op) ? entryOpsOf(op) : [id];
85
+ });
73
86
  return [
74
87
  { action: { id: `${WORKFLOW_ACTION}${workflow.name}` } },
75
- ...workflow.ops.map((id) => {
88
+ ...[...new Set(asked)].map((id) => {
76
89
  const effect = ops.get(id)?.effect;
77
90
  return { action: effect === undefined ? { id } : { id, effect }, inRun: true };
78
91
  }),
@@ -203,6 +216,12 @@ type Range<V> = { gt?: V; gte?: V; lt?: V; lte?: V };
203
216
  /** How many records a collection's `list` answers: `default` when the query names no `limit`, `max` at most. */
204
217
  export const LIST_LIMIT = { default: 100, max: 1000 } as const;
205
218
 
219
+ /** How many entries (records and deletes together) a collection's `saveMany` takes at most. */
220
+ export const BATCH_LIMIT = 1000;
221
+
222
+ /** What a collection's `delete` answers, and `saveMany` for each of its deletes: whether there was a record. */
223
+ export type Deleted = { key: string; deleted: boolean };
224
+
206
225
  /** The kinds of value a field holds: text, a number, true or false, a date (ISO 8601) or a person's id. */
207
226
  export const FIELD_TYPES = ["text", "number", "boolean", "date", "person"] as const;
208
227
 
@@ -673,10 +692,27 @@ export interface WorkflowFn<I, O> {
673
692
  /** An operation a site may call directly (a read, or a collection's write), as the person on the page. */
674
693
  export type OpFn<I, O> = (input: I) => Promise<O>;
675
694
 
676
- /** A collection as a site's `api.data.<name>` has it: its four operations (`Saved`, `ListQuery`). */
695
+ /** A collection as a site's `api.data.<name>` has it: its six operations (`Saved`, `ListQuery`, `Deleted`). */
677
696
  export interface CollectionApi<R> {
678
697
  get: OpFn<{ key: string }, Saved<R> | null>;
679
698
  list(query?: ListQuery<R>): Promise<Saved<R>[]>;
699
+ /** How many records there are, or match `where` (as `list` takes it). */
700
+ count(query?: Pick<ListQuery<R>, "where">): Promise<{ count: number }>;
680
701
  save: OpFn<{ record: R; ifAbsent?: boolean; ifRecord?: Record<string, unknown> }, Saved<R>>;
681
- delete: OpFn<{ key: string }, { deleted: boolean }>;
702
+ /**
703
+ * Saves and deletes together, all or none, at most `BATCH_LIMIT` entries, each key once: the
704
+ * records' receipts then the deletes' answers, in the order sent. The policy decides each entry
705
+ * as its own `save` or `delete`; a deny refuses the batch (`policy_denied`, with the `denied`
706
+ * keys), a hold holds it whole, and a record an entry's `ifRecord` or `ifAbsent` refuses refuses
707
+ * it (status 409, `record_changed`) with every such entry in `conflicts: { key, current }[]`.
708
+ */
709
+ saveMany: OpFn<
710
+ {
711
+ records?: { record: R; ifAbsent?: boolean; ifRecord?: Record<string, unknown> }[];
712
+ deletes?: { key: string; ifRecord?: Record<string, unknown> }[];
713
+ },
714
+ (Saved<R> | Deleted)[]
715
+ >;
716
+ /** Deletes the record, or with `ifRecord` only while it still matches (status 409, `record_changed`, otherwise). */
717
+ delete: OpFn<{ key: string; ifRecord?: Record<string, unknown> }, Deleted>;
682
718
  }
package/src/sites.ts CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  BUILT_IN_WORKFLOWS,
17
17
  DATA_VENDOR,
18
18
  DOMAIN_NAME,
19
+ entryOpsOf,
20
+ isBatch,
19
21
  LOCALE,
20
22
  SITE_NAME,
21
23
  siteKey,
@@ -80,7 +82,7 @@ export function readSites(
80
82
  */
81
83
  const refused = (call: SiteCall, login: string | undefined): string | undefined => {
82
84
  const why = `a visitor's call to ${KIND_NAMES[call.kind]} "${call.target}" is refused`;
83
- let action: string;
85
+ let actions: string[];
84
86
  let group: string;
85
87
  if (call.kind === "workflow") {
86
88
  if (BUILT_IN_WORKFLOWS.has(call.target)) return `${why}: a visitor may not start the runtime's own workflows`;
@@ -88,22 +90,24 @@ export function readSites(
88
90
  if (call.method !== undefined && call.method !== "start") {
89
91
  return `${why}: a visitor reads only the result of a run it started`;
90
92
  }
91
- [action, group] = [`${WORKFLOW_ACTION}${call.target}`, START];
93
+ [actions, group] = [[`${WORKFLOW_ACTION}${call.target}`], START];
92
94
  } else {
93
- action = call.kind === "op" ? call.target : `${DATA_VENDOR}.${call.target}.${call.method}`;
95
+ const action = call.kind === "op" ? call.target : `${DATA_VENDOR}.${call.target}.${call.method}`;
94
96
  const op = ops.get(action);
95
97
  if (!op) return undefined;
96
- group = `effect:${op.effect}`;
98
+ // A batch is its entries to the policy: the save and the delete each need a permit.
99
+ [actions, group] = [isBatch(op) ? entryOpsOf(op) : [action], `effect:${op.effect}`];
97
100
  }
98
101
  if (!permits) return undefined;
99
102
  const [scopes, principal] =
100
103
  login === undefined
101
104
  ? [[ANONYMOUS], `== Sanoma::Principal::"${ANONYMOUS}"`]
102
105
  : [[ANONYMOUS, VISITORS], `in Sanoma::Group::"${VISITORS}"`];
103
- if (permits(new Set(scopes), action, group)) return undefined;
106
+ const missing = actions.filter((action) => !permits(new Set(scopes), action, group));
107
+ if (!missing.length) return undefined;
104
108
  return (
105
- `${why}: no permit in policies/ names ${scopes.join(" or ")} for ${action}. ` +
106
- `Add: permit (principal ${principal}, action == Sanoma::Action::"${action}", resource);`
109
+ `${why}: no permit in policies/ names ${scopes.join(" or ")} for ${missing.join(" or ")}. ` +
110
+ `Add: ${missing.map((a) => `permit (principal ${principal}, action == Sanoma::Action::"${a}", resource);`).join(" ")}`
107
111
  );
108
112
  };
109
113
 
package/src/source.ts CHANGED
@@ -40,6 +40,20 @@ export interface Source {
40
40
  diff?(id: string): Promise<DraftDiff>;
41
41
  /** Closes the draft `id`: its checkout removed and its branch deleted, whatever it held; nothing when it is gone. */
42
42
  closeDraft?(id: string): Promise<void>;
43
+ /**
44
+ * The draft `id`'s branch as a git bundle (`git bundle create`), for a clone elsewhere (an agent
45
+ * box's) to fetch: its commits since `since`, a commit that clone has, or the whole branch when
46
+ * `since` is absent or a commit this repository lacks; no bytes when the clone has them all.
47
+ * The branch's head beside it. A bundle holds commits and the branch's name alone: no remote,
48
+ * no config, no credential.
49
+ */
50
+ bundle?(id: string, opts?: { since?: string }): Promise<{ bytes: Uint8Array; head: string }>;
51
+ /**
52
+ * Takes a bundle of the draft `id`'s branch made elsewhere (an agent box's new commits on it):
53
+ * its commits fetched, then the draft's checkout fast-forwarded to them. Refused unless that is
54
+ * a fast-forward that leaves what the checkout holds uncommitted in place. The branch's head after.
55
+ */
56
+ unbundle?(id: string, bytes: Uint8Array): Promise<{ head: string }>;
43
57
  /**
44
58
  * `sanoma check` on the draft `id`, as a child in its checkout, one at a time in the process. A
45
59
  * check that gives no result (a config that does not load, no install, a timeout) is a result