@sanoma-ai/workflows 0.11.17 → 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 (94) hide show
  1. package/README.md +13 -9
  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 +8 -5
  12. package/dist/config.js +10 -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.js +1 -1
  20. package/dist/description.js.map +1 -1
  21. package/dist/errors.d.ts +2 -1
  22. package/dist/errors.js +3 -2
  23. package/dist/errors.js.map +1 -1
  24. package/dist/grid.js +3 -2
  25. package/dist/grid.js.map +1 -1
  26. package/dist/host-derive.d.ts +13 -8
  27. package/dist/host-derive.js +26 -13
  28. package/dist/host-derive.js.map +1 -1
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/ledger.d.ts +7 -0
  33. package/dist/ledger.js +32 -2
  34. package/dist/ledger.js.map +1 -1
  35. package/dist/local-sandbox.d.ts +8 -1
  36. package/dist/local-sandbox.js +3 -2
  37. package/dist/local-sandbox.js.map +1 -1
  38. package/dist/op.d.ts +3 -2
  39. package/dist/op.js.map +1 -1
  40. package/dist/resources.d.ts +1 -1
  41. package/dist/resources.js +9 -6
  42. package/dist/resources.js.map +1 -1
  43. package/dist/runner-box.d.ts +23 -0
  44. package/dist/runner-box.js +181 -0
  45. package/dist/runner-box.js.map +1 -0
  46. package/dist/runner-protocol.d.ts +73 -0
  47. package/dist/runner-protocol.js +34 -0
  48. package/dist/runner-protocol.js.map +1 -0
  49. package/dist/sandbox-contract.d.ts +14 -5
  50. package/dist/sandbox-contract.js.map +1 -1
  51. package/dist/sandbox-runner.cjs +7 -0
  52. package/dist/scenario.js +16 -7
  53. package/dist/scenario.js.map +1 -1
  54. package/dist/schema.js +6 -3
  55. package/dist/schema.js.map +1 -1
  56. package/dist/secret.d.ts +2 -1
  57. package/dist/secret.js +3 -2
  58. package/dist/secret.js.map +1 -1
  59. package/dist/shared.d.ts +39 -5
  60. package/dist/shared.js +13 -1
  61. package/dist/shared.js.map +1 -1
  62. package/dist/sites.js +10 -8
  63. package/dist/sites.js.map +1 -1
  64. package/dist/source.d.ts +21 -0
  65. package/dist/source.js.map +1 -1
  66. package/drizzle/company/0003_ledger_target.sql +2 -0
  67. package/drizzle/company/meta/0003_snapshot.json +845 -0
  68. package/drizzle/company/meta/_journal.json +7 -0
  69. package/package.json +5 -2
  70. package/src/call.ts +181 -60
  71. package/src/client.ts +118 -67
  72. package/src/collection.ts +265 -97
  73. package/src/config.ts +20 -20
  74. package/src/db/company.ts +9 -1
  75. package/src/describe.ts +1 -1
  76. package/src/description.ts +1 -1
  77. package/src/errors.ts +3 -2
  78. package/src/grid.ts +3 -1
  79. package/src/host-derive.ts +38 -18
  80. package/src/index.ts +2 -0
  81. package/src/ledger.ts +42 -2
  82. package/src/local-sandbox.ts +3 -2
  83. package/src/op.ts +3 -2
  84. package/src/resources.ts +8 -6
  85. package/src/runner-box.ts +218 -0
  86. package/src/runner-protocol.ts +61 -0
  87. package/src/sandbox-contract.ts +11 -4
  88. package/src/sandbox-runner.ts +187 -0
  89. package/src/scenario.ts +16 -6
  90. package/src/schema.ts +14 -3
  91. package/src/secret.ts +6 -2
  92. package/src/shared.ts +40 -4
  93. package/src/sites.ts +11 -7
  94. package/src/source.ts +14 -0
@@ -0,0 +1,187 @@
1
+ import { createServer } from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+ import { homedir } from "node:os";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { type WebSocket, WebSocketServer } from "ws";
6
+ import { localBox } from "./local-sandbox.ts";
7
+ import {
8
+ FILE,
9
+ frame,
10
+ RUNNER_HEALTH,
11
+ RUNNER_PORT,
12
+ RUNNER_PROTOCOL,
13
+ type RunnerAnswer,
14
+ type RunnerHealth,
15
+ type RunnerRequest,
16
+ STDERR,
17
+ STDIN,
18
+ STDOUT,
19
+ unframe,
20
+ } from "./runner-protocol.ts";
21
+ import type { Process } from "./sandbox-contract.ts";
22
+ import { errorMessage } from "./shared.ts";
23
+
24
+ // The runner: the server of ours a backend starts inside a remote box, which `runnerBox` talks to
25
+ // over a WebSocket (the protocol is in `runner-protocol.ts`). It serves this machine as the local
26
+ // backend's box does (`localBox`, whose code it bundles): commands are `bash -c` in process groups
27
+ // of their own, a relative path or `cwd` is under the home. Built into one file,
28
+ // `dist/sandbox-runner.cjs` (`ws` bundled in), run as `node sandbox-runner.cjs`.
29
+ //
30
+ // It listens on `SANOMA_RUNNER_HOST:SANOMA_RUNNER_PORT` (default `0.0.0.0:7700`) and asks no
31
+ // credential: the vendor's edge authenticates every request to the port, the upgrade too
32
+ // (Agent37: the API key, on a port never made public). Anything inside the box already runs as
33
+ // the box's user, so a secret of the runner's own would guard nothing.
34
+
35
+ const port = Number(process.env.SANOMA_RUNNER_PORT ?? RUNNER_PORT);
36
+ const host = process.env.SANOMA_RUNNER_HOST ?? "0.0.0.0";
37
+
38
+ /**
39
+ * How long an exited command's output may go quiet before its exit is sent, in ms: `localBox` has
40
+ * waited for its pipes already, unless what it left running holds them. Output held back for a
41
+ * slow connection is not quiet.
42
+ */
43
+ const LAST_OUTPUT = 50;
44
+ /** How much a connection may hold unsent before a command's output waits for it, in bytes. */
45
+ const UNSENT = 1 << 20;
46
+ /** How often a connection is pinged: Agent37's edge closes one silent for 15 minutes. */
47
+ const BEAT = 30_000;
48
+
49
+ // Its commands' environment is its own, but for where it listens.
50
+ const { SANOMA_RUNNER_PORT: _, SANOMA_RUNNER_HOST: __, ...own } = process.env;
51
+ const box = localBox("runner", homedir(), own as Record<string, string>);
52
+
53
+ const server = createServer((req, res) => {
54
+ if (req.url !== RUNNER_HEALTH) return void res.writeHead(404).end();
55
+ res
56
+ .writeHead(200, { "content-type": "application/json" })
57
+ .end(JSON.stringify({ protocol: RUNNER_PROTOCOL, pid: process.pid } satisfies RunnerHealth));
58
+ });
59
+ new WebSocketServer({ server })
60
+ .on("connection", serve)
61
+ .on("error", (error) => console.error(`the runner's WebSocket server: ${error.message}`));
62
+ server.listen(port, host, () => {
63
+ const { address, port: bound } = server.address() as AddressInfo;
64
+ console.log(`sanoma runner listening on ${address}:${bound}`);
65
+ });
66
+ // Asked to end (the box shuts down): what it started ends with it.
67
+ process.on("SIGTERM", () => void box.stop().finally(() => process.exit(0)));
68
+
69
+ /** One client's connection: its commands, by the ids it gave them. */
70
+ function serve(ws: WebSocket) {
71
+ // Promises: a command's kill and its stdin may come before it has started, and wait for it.
72
+ const processes = new Map<number, Promise<Process>>();
73
+ const inputs = new Map<number, Promise<WritableStreamDefaultWriter<Uint8Array>>>();
74
+ /** The writes whose bytes are yet to come. */
75
+ const writes = new Map<number, { path: string; mode?: number }>();
76
+ const send = (data: Uint8Array | string) => ws.readyState === ws.OPEN && ws.send(data);
77
+ const say = (answer: RunnerAnswer) => send(JSON.stringify(answer));
78
+ const beat = setInterval(() => ws.ping(), BEAT);
79
+ // A frame ws refuses (one too large, one malformed) closes this connection, not the runner.
80
+ ws.on("error", (error) => console.error(`a connection: ${error.message}`));
81
+ ws.on("close", () => {
82
+ clearInterval(beat);
83
+ // A dropped connection kills nothing: its commands go on, their stdin ended, their output dropped.
84
+ for (const id of inputs.keys()) endInput(id);
85
+ });
86
+ ws.on("message", (data: Buffer, binary) => {
87
+ // A frame that is not the protocol's is answered, never a crash of the runner.
88
+ if (binary) {
89
+ if (data.length < 5) return say({ op: "error", message: "a binary frame is an id, a stream and bytes" });
90
+ return received(unframe(data));
91
+ }
92
+ let request: RunnerRequest;
93
+ try {
94
+ request = JSON.parse(String(data));
95
+ } catch {
96
+ return say({ op: "error", message: "a text frame is JSON" });
97
+ }
98
+ if (typeof request !== "object" || request === null) return say({ op: "error", message: "a request is an object" });
99
+ handle(request).catch((error: unknown) => say({ op: "error", id: request.id, message: errorMessage(error) }));
100
+ });
101
+
102
+ async function handle(request: RunnerRequest): Promise<unknown> {
103
+ switch (request.op) {
104
+ case "run": {
105
+ const { id, command, cwd, env, stdin } = request;
106
+ const starting = box.run(command, { cwd, env, stdin });
107
+ processes.set(id, starting);
108
+ if (stdin) {
109
+ const input = starting.then((started) => started.stdin.getWriter());
110
+ // A run that fails to start is answered below; its stdin's rejection is not another.
111
+ input.catch(() => {});
112
+ inputs.set(id, input);
113
+ }
114
+ try {
115
+ const started = await starting;
116
+ const moved = { at: Date.now(), done: false };
117
+ const pumped = Promise.all([
118
+ pump(id, STDOUT, started.stdout, moved),
119
+ pump(id, STDERR, started.stderr, moved),
120
+ ]).finally(() => (moved.done = true));
121
+ const { exitCode } = await started.wait();
122
+ // What it printed last goes out first, unless what it left running holds its pipes.
123
+ moved.at = Date.now();
124
+ while (!moved.done && Date.now() - moved.at < LAST_OUTPUT) await Promise.race([pumped, delay(10)]);
125
+ return say({ op: "exit", id, code: exitCode });
126
+ } finally {
127
+ processes.delete(id);
128
+ inputs.delete(id);
129
+ }
130
+ }
131
+ case "stdin-end":
132
+ return endInput(request.id);
133
+ case "kill":
134
+ return processes.get(request.id)?.then((started) => started.kill(request.signal));
135
+ case "read": {
136
+ const file = await box.read(request.path);
137
+ return file ? send(frame(request.id, FILE, file)) : say({ op: "absent", id: request.id });
138
+ }
139
+ case "write":
140
+ return writes.set(request.id, request);
141
+ case "stop":
142
+ await box.stop();
143
+ return say({ op: "ok", id: request.id });
144
+ default:
145
+ throw new Error(`no such request: ${(request as { op?: unknown }).op}`);
146
+ }
147
+ }
148
+
149
+ function received({ id, stream, bytes }: ReturnType<typeof unframe>) {
150
+ if (stream === STDIN)
151
+ return void inputs
152
+ .get(id)
153
+ ?.then((writer) => writer.write(bytes))
154
+ .catch(() => {});
155
+ const write = writes.get(id);
156
+ if (stream !== FILE || !write) return;
157
+ writes.delete(id);
158
+ box.write(write.path, bytes, { mode: write.mode }).then(
159
+ () => say({ op: "ok", id }),
160
+ (error: unknown) => say({ op: "error", id, message: errorMessage(error) }),
161
+ );
162
+ }
163
+
164
+ /** Ends the stdin of the command `id`, once it has started. */
165
+ function endInput(id: number) {
166
+ const input = inputs.get(id);
167
+ inputs.delete(id);
168
+ return input?.then((writer) => writer.close()).catch(() => {});
169
+ }
170
+
171
+ /** Sends what `from` prints, until it ends or fails, keeping in `moved` when it last sent or waited to. */
172
+ async function pump(id: number, stream: number, from: ReadableStream<Uint8Array>, moved: { at: number }) {
173
+ try {
174
+ for await (const chunk of from) {
175
+ // Its output waits for a slow connection, and so the command does, its pipe full.
176
+ while (ws.readyState === ws.OPEN && ws.bufferedAmount > UNSENT) {
177
+ moved.at = Date.now();
178
+ await delay(10);
179
+ }
180
+ send(frame(id, stream, chunk));
181
+ moved.at = Date.now();
182
+ }
183
+ } catch {
184
+ // A pipe that failed has nothing more to say.
185
+ }
186
+ }
187
+ }
package/src/scenario.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type Step as GherkinStep,
13
13
  } from "@cucumber/messages";
14
14
  import type { z } from "zod";
15
+ import { targetOf } from "./call.ts";
15
16
  import type { RunSummary, SanomaClient } from "./client.ts";
16
17
  import type { Approver, Principal } from "./define.ts";
17
18
  import type { ResolvedConfig } from "./config.ts";
@@ -44,10 +45,12 @@ import {
44
45
  DATA_VENDOR,
45
46
  declaresPeople,
46
47
  DRIFT_WORKFLOW,
48
+ entryOpsOf,
47
49
  errorMessage,
48
50
  fitsOp,
49
51
  flatten,
50
52
  groupNamesOf,
53
+ isBatch,
51
54
  isEnded,
52
55
  principalIdsOf,
53
56
  problemLine,
@@ -446,6 +449,12 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
446
449
  { name: "field", regexp: /\S+/, transformer: (s) => s },
447
450
  ];
448
451
  const opOf = (id: unknown) => scope.ops.get(id as string)!;
452
+ /** An operation a `Then` step reads the ledger for: never a batch, which the ledger records as its entries. */
453
+ const recordedOp = (id: unknown): string => {
454
+ const op = opOf(id);
455
+ if (isBatch(op)) throw new Error(`${op.id} is recorded as its entries: name ${entryOpsOf(op).join(" or ")}`);
456
+ return op.id;
457
+ };
449
458
  const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
450
459
  // The people as the root's data files declare them when a step names one (`readPeople`, read
451
460
  // again only once a file changed): while they declare no one, any id and any group go.
@@ -596,33 +605,33 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
596
605
  source: "{op} was called with",
597
606
  takes: TAKES_OBJECT,
598
607
  read: ([op], pickle) =>
599
- expecting({ op: op as string, input: fieldsFrom(pickle, opOf(op).input, inputOf(op), true), called: true }),
608
+ expecting({ op: recordedOp(op), input: fieldsFrom(pickle, opOf(op).input, inputOf(op), true), called: true }),
600
609
  },
601
610
  ...[true, false].map((called): Rule => ({
602
611
  kind: "then",
603
612
  source: `{op} was ${called ? "" : "not "}called`,
604
- read: ([op]) => expecting({ op: op as string, called }),
613
+ read: ([op]) => expecting({ op: recordedOp(op), called }),
605
614
  })),
606
615
  {
607
616
  kind: "then",
608
617
  source: "{op} was denied",
609
- read: ([op]) => expecting({ op: op as string, decision: "denied" }),
618
+ read: ([op]) => expecting({ op: recordedOp(op), decision: "denied" }),
610
619
  },
611
620
  {
612
621
  kind: "then",
613
622
  source: "{op} was denied with {string}",
614
- read: ([op, reason]) => expecting({ op: op as string, decision: "denied", reason: reason as string }),
623
+ read: ([op, reason]) => expecting({ op: recordedOp(op), decision: "denied", reason: reason as string }),
615
624
  },
616
625
  {
617
626
  kind: "then",
618
627
  source: "{op} was held for {who}",
619
- read: ([op, who]) => expecting({ op: op as string, decision: "held", approver: who as string }),
628
+ read: ([op, who]) => expecting({ op: recordedOp(op), decision: "held", approver: who as string }),
620
629
  },
621
630
  {
622
631
  kind: "then",
623
632
  source: "{op} was held for group {string}",
624
633
  read: ([op, group]) =>
625
- expecting({ op: op as string, decision: "held", approver: { group: groupNamed(group as string) } }),
634
+ expecting({ op: recordedOp(op), decision: "held", approver: { group: groupNamed(group as string) } }),
626
635
  },
627
636
  {
628
637
  kind: "then",
@@ -1207,6 +1216,7 @@ function testWorld(built: Built, { driver, fake, live }: FeatureTarget) {
1207
1216
  record({
1208
1217
  type: "op.called",
1209
1218
  op: op.id,
1219
+ ...targetOf(op, input),
1210
1220
  effect: op.effect,
1211
1221
  input,
1212
1222
  decision: { kind: "allow" },
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