@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
package/src/collection.ts CHANGED
@@ -4,6 +4,7 @@ import * as drizzle from "drizzle-orm";
4
4
  import { and, eq, or, sql, type SQL } from "drizzle-orm";
5
5
  import type { Db } from "./db.ts";
6
6
  import { data } from "./db/company.ts";
7
+ import { vendorCodeOf } from "./errors.ts";
7
8
  import { defineFake, type Fake } from "./fake.ts";
8
9
  import { isObject } from "./resource.ts";
9
10
  import {
@@ -20,8 +21,11 @@ import {
20
21
  import { callerFile } from "./define.ts";
21
22
  import { appOfFile } from "./paths.ts";
22
23
  import {
24
+ BATCH,
25
+ BATCH_LIMIT,
23
26
  checkCollection,
24
27
  DATA_VENDOR,
28
+ type Deleted,
25
29
  type FIELD_TYPES,
26
30
  LIST_LIMIT,
27
31
  type ListQuery,
@@ -30,9 +34,10 @@ import {
30
34
  } from "./shared.ts";
31
35
 
32
36
  // A company's own records: a collection is declared with plain values (a name, a key and typed
33
- // fields), and its `get`, `list`, `save` and `delete` are operations of the vendor `data`, under
34
- // the policy and in the ledger like any connector's. The records are JSON rows of one table the
35
- // runtime owns (`data`), in the config's database; a sandbox run keeps them in memory.
37
+ // fields), and its `get`, `list`, `count`, `save`, `saveMany` and `delete` are operations of the
38
+ // vendor `data`, under the policy and in the ledger like any connector's. The records are JSON
39
+ // rows of one table the runtime owns (`data`), in the config's database; a sandbox run keeps
40
+ // them in memory.
36
41
 
37
42
  export type FieldType = (typeof FIELD_TYPES)[number];
38
43
 
@@ -69,7 +74,7 @@ export type RequiredTextKeys<F extends Record<string, Field>> = {
69
74
  */
70
75
  export const COLLECTION: unique symbol = Symbol.for("sanoma.collection");
71
76
 
72
- /** A collection, as `collection()` declares it, with its four operations. */
77
+ /** A collection, as `collection()` declares it, with its six operations. */
73
78
  export interface Collection<
74
79
  N extends string = string,
75
80
  F extends Record<string, Field> = Record<string, Field>,
@@ -86,6 +91,7 @@ export interface Collection<
86
91
  readonly title?: string;
87
92
  readonly get: Op<typeof DATA_VENDOR, N, "get", { key: string }, Saved<RecordOf<F>> | null>;
88
93
  readonly list: Op<typeof DATA_VENDOR, N, "list", ListQuery<RecordOf<F>>, Saved<RecordOf<F>>[]>;
94
+ readonly count: Op<typeof DATA_VENDOR, N, "count", Pick<ListQuery<RecordOf<F>>, "where">, { count: number }>;
89
95
  readonly save: Op<
90
96
  typeof DATA_VENDOR,
91
97
  N,
@@ -93,14 +99,24 @@ export interface Collection<
93
99
  { record: RecordOf<F>; ifAbsent?: boolean; ifRecord?: Record<string, unknown> },
94
100
  Saved<RecordOf<F>>
95
101
  >;
96
- readonly delete: Op<typeof DATA_VENDOR, N, "delete", { key: string }, { deleted: boolean }>;
102
+ readonly saveMany: Op<
103
+ typeof DATA_VENDOR,
104
+ N,
105
+ "saveMany",
106
+ {
107
+ records?: { record: RecordOf<F>; ifAbsent?: boolean; ifRecord?: Record<string, unknown> }[];
108
+ deletes?: { key: string; ifRecord?: Record<string, unknown> }[];
109
+ },
110
+ (Saved<RecordOf<F>> | Deleted)[]
111
+ >;
112
+ readonly delete: Op<typeof DATA_VENDOR, N, "delete", { key: string; ifRecord?: Record<string, unknown> }, Deleted>;
97
113
  }
98
114
 
99
115
  /**
100
116
  * Declares a collection of records, in `resources/data.ts`:
101
117
  * `collection({ name: "outbound", key: "company", fields: { company: "text", stage: { enum: ["new", "won"] } } })`.
102
- * Its operations are `data.<name>.get`, `.list`, `.save` and `.delete`, which a workflow names in
103
- * `uses` and calls on `ctx.data.<name>`. The name is a lowercase letter then letters and digits;
118
+ * Its operations are `data.<name>.get`, `.list`, `.count`, `.save`, `.saveMany` and `.delete`,
119
+ * which a workflow names in `uses` and calls on `ctx.data.<name>`. The name is a lowercase letter then letters and digits;
104
120
  * the key is a required text field, each record's own and unique. In an app's folder
105
121
  * (`apps/<app>/resources/data.ts`) the runtime names it `<app>.<name>`: `data.<app>.<name>.get`,
106
122
  * called on `ctx.data.<app>.<name>`.
@@ -175,10 +191,21 @@ const SAVED = z.object({
175
191
  issues: z.array(z.object({ path: z.array(z.union([z.string(), z.number()])), message: z.string() })).optional(),
176
192
  });
177
193
 
178
- /** A collection's four operations, for `defineConnector`. */
194
+ /** What `delete` answers, and a batch for each of its deletes: whether there was a record to delete. */
195
+ const DELETED = z.object({ key: z.string(), deleted: z.boolean() });
196
+
197
+ /** A collection's six operations, for `defineConnector`. */
179
198
  function specsOf(name: string, key: string, fields: Record<string, Field>, record: z.ZodType): Record<string, OpSpec> {
180
199
  const at = (k: string) => `${DATA_VENDOR}:${name}/${k}`;
181
- const byKey = z.object({ key: z.string().min(1) });
200
+ const whole = `${DATA_VENDOR}:${name}`;
201
+ const ifRecord = z.record(z.string(), z.unknown()).optional();
202
+ const byKey = z.object({ key: z.string().min(1), ifRecord });
203
+ /** What `save` takes, and a batch for each of its records. */
204
+ const saving = z
205
+ .object({ record, ifAbsent: z.boolean().optional(), ifRecord })
206
+ .refine((input) => !(input.ifAbsent && input.ifRecord !== undefined), {
207
+ message: "Choose ifAbsent or ifRecord, not both",
208
+ });
182
209
  const names = Object.keys(fields);
183
210
  const where = z.strictObject(
184
211
  Object.fromEntries(
@@ -194,7 +221,7 @@ function specsOf(name: string, key: string, fields: Record<string, Field>, recor
194
221
  effect: "read",
195
222
  idempotent: true,
196
223
  description: `Get a record of ${name} by its ${key}`,
197
- input: byKey,
224
+ input: z.object({ key: z.string().min(1) }),
198
225
  output: SAVED.nullable(),
199
226
  target: ({ key: k }: { key: string }) => at(k),
200
227
  },
@@ -208,26 +235,54 @@ function specsOf(name: string, key: string, fields: Record<string, Field>, recor
208
235
  limit: z.number().int().min(1).max(LIST_LIMIT.max).default(LIST_LIMIT.default),
209
236
  }),
210
237
  output: z.array(SAVED),
211
- target: () => `${DATA_VENDOR}:${name}`,
238
+ target: () => whole,
239
+ },
240
+ count: {
241
+ effect: "read",
242
+ idempotent: true,
243
+ description: `Count the records of ${name}, or those with fields equal to a value or within a range`,
244
+ input: z.object({ where: where.optional() }),
245
+ output: z.object({ count: z.number().int() }),
246
+ target: () => whole,
212
247
  },
213
248
  save: {
214
249
  effect: "write",
215
250
  idempotent: true,
216
251
  description: `Save a record of ${name}: added, or replacing the one with its ${key}; ifAbsent refuses a different existing record`,
217
- input: z
218
- .object({ record, ifAbsent: z.boolean().optional(), ifRecord: z.record(z.string(), z.unknown()).optional() })
219
- .refine((input) => !(input.ifAbsent && input.ifRecord !== undefined), {
220
- message: "Choose ifAbsent or ifRecord, not both",
221
- }),
252
+ input: saving,
222
253
  output: SAVED,
223
254
  target: ({ record: r }: { record: Record<string, string> }) => at(r[key]!),
224
255
  },
256
+ [BATCH]: {
257
+ effect: "write",
258
+ idempotent: true,
259
+ description: `Save and delete records of ${name} together, all or none, each entry decided and recorded as its own save or delete`,
260
+ input: z
261
+ .object({
262
+ records: z.array(saving).default([]),
263
+ deletes: z.array(byKey).default([]),
264
+ })
265
+ .superRefine((batch, ctx) => {
266
+ const keys = [
267
+ ...batch.records.map((e) => (e.record as Record<string, string>)[key]!),
268
+ ...batch.deletes.map((e) => e.key),
269
+ ];
270
+ if (keys.length < 1 || keys.length > BATCH_LIMIT) {
271
+ ctx.addIssue({ code: "custom", message: `A batch has 1 to ${BATCH_LIMIT} entries` });
272
+ }
273
+ if (new Set(keys).size !== keys.length) {
274
+ ctx.addIssue({ code: "custom", message: `A batch names each ${key} once` });
275
+ }
276
+ }),
277
+ output: z.array(z.union([SAVED, DELETED])),
278
+ target: () => whole,
279
+ },
225
280
  delete: {
226
281
  effect: "write",
227
282
  idempotent: true,
228
- description: `Delete the record of ${name} with this ${key}`,
283
+ description: `Delete the record of ${name} with this ${key}; ifRecord refuses a changed or missing one`,
229
284
  input: byKey,
230
- output: z.object({ deleted: z.boolean() }),
285
+ output: DELETED,
231
286
  target: ({ key: k }: { key: string }) => at(k),
232
287
  },
233
288
  };
@@ -265,22 +320,54 @@ const updatedBy = (call: CallContext) => call.by ?? `run:${call.runId}`;
265
320
 
266
321
  /** The vendor `data` as a connector of the collections' operations, the very ones each `collection()` made. */
267
322
  export function collectionsConnector(list: readonly Collection[]): Connector<typeof DATA_VENDOR, DataOps> {
268
- const ops = list.map((c) => [c.name, { get: c.get, list: c.list, save: c.save, delete: c.delete }]);
323
+ const ops = list.map((c) => [
324
+ c.name,
325
+ { get: c.get, list: c.list, count: c.count, save: c.save, [BATCH]: c.saveMany, delete: c.delete },
326
+ ]);
269
327
  return { ...defineConnector(DATA_VENDOR, {}), ...Object.fromEntries(ops) } as Connector<typeof DATA_VENDOR, DataOps>;
270
328
  }
271
329
 
272
330
  const COLUMNS = { key: data.key, record: data.record, updatedAt: data.updatedAt, updatedBy: data.updatedBy };
273
- const recordExists = () =>
274
- new DriverError("Collection record already exists", { retryable: false, status: 409, vendorCode: "record_exists" });
275
- const recordChanged = () =>
276
- new DriverError("Collection record changed or is missing", {
277
- retryable: false,
278
- status: 409,
279
- vendorCode: "record_changed",
280
- });
331
+ /** The refusal of an `ifAbsent` save, carrying the record that is there (`current`). */
332
+ const recordExists = (current: Saved) =>
333
+ Object.assign(
334
+ new DriverError("Collection record already exists", { retryable: false, status: 409, vendorCode: "record_exists" }),
335
+ { current },
336
+ );
337
+ /** The refusal of an `ifRecord` save or delete, carrying the record as it stands (`current`), or `null` when it is gone. */
338
+ const recordChanged = (current: Saved | null) =>
339
+ Object.assign(
340
+ new DriverError(current ? "Collection record changed" : "Collection record is missing", {
341
+ retryable: false,
342
+ status: 409,
343
+ vendorCode: "record_changed",
344
+ }),
345
+ { current },
346
+ );
347
+ /** An entry of a batch its `ifRecord` refused: its key, and the record as it stands, or `null` when it is gone. */
348
+ export type Conflict = { key: string; current: Saved | null };
349
+ /** The refusal of a batch, carrying every entry refused (`conflicts`), with the record as each stands. */
350
+ const recordsChanged = (conflicts: Conflict[]) =>
351
+ Object.assign(
352
+ new DriverError(`${conflicts.length} of the batch's records changed or are missing`, {
353
+ retryable: false,
354
+ status: 409,
355
+ vendorCode: "record_changed",
356
+ }),
357
+ { conflicts },
358
+ );
359
+ /** The refusal of one entry (its `ifRecord` or `ifAbsent`), as a batch collects it; any other failure is the batch's. */
360
+ const conflictOf = (key: string, err: unknown): Conflict => {
361
+ const code = vendorCodeOf(err);
362
+ if (code !== "record_changed" && code !== "record_exists") throw err;
363
+ return { key, current: (err as { current: Saved | null }).current };
364
+ };
281
365
  const sameJson = (a: Record<string, unknown>, b: Record<string, unknown>) =>
282
366
  isDeepStrictEqual(JSON.parse(JSON.stringify(a)), JSON.parse(JSON.stringify(b)));
283
367
  type SaveInput = { record: Record<string, unknown>; ifAbsent?: boolean; ifRecord?: Record<string, unknown> };
368
+ type DeleteInput = { key: string; ifRecord?: Record<string, unknown> };
369
+ /** A `saveMany` input as its schema parses it. */
370
+ type Batch = { records: SaveInput[]; deletes: DeleteInput[] };
284
371
  // By namespace: the fake names a range's bounds `gt`, `gte`, `lt` and `lte`.
285
372
  const RANGE = { gt: drizzle.gt, gte: drizzle.gte, lt: drizzle.lt, lte: drizzle.lte };
286
373
 
@@ -309,11 +396,58 @@ export function collectionsDriver(list: readonly Collection[], store: { db(): Pr
309
396
  updatedBy: row.updatedBy,
310
397
  });
311
398
  const here = (key: string) => and(eq(data.collection, c.name), eq(data.key, key));
399
+ /** The record on `db`, as `get` answers it and a refused save or delete carries it (`current`). */
400
+ const getOn = async (db: Db, key: string) => {
401
+ const [row] = await db.select(COLUMNS).from(data).where(here(key));
402
+ return row ? saved(row) : null;
403
+ };
404
+ /** One save on `db`, as `save` makes it and a batch makes each of its records. */
405
+ const saveOn = async (db: Db, { record, ifAbsent, ifRecord }: SaveInput, by: string) => {
406
+ const key = record[c.key] as string;
407
+ if (ifRecord !== undefined) {
408
+ // PostgreSQL locks and rechecks the predicate on a concurrent update. A lost-response
409
+ // retry may recover its own identical result, but never overwrite another actor's state.
410
+ const retry = and(eq(data.record, record), eq(data.updatedBy, by))!;
411
+ const [row] = await db
412
+ .update(data)
413
+ .set({ record, updatedAt: sql`CASE WHEN ${retry} THEN ${data.updatedAt} ELSE now() END`, updatedBy: by })
414
+ .where(and(here(key), or(eq(data.record, ifRecord), retry)))
415
+ .returning(COLUMNS);
416
+ if (row) return saved(row);
417
+ throw recordChanged(await getOn(db, key));
418
+ }
419
+ // The conflict row is locked by Postgres: never implement an insert-only claim as get + save.
420
+ // An identical retry by the same actor keeps the first receipt and never overwrites later progress.
421
+ const conflict = ifAbsent
422
+ ? { set: { record: sql`${data.record}` }, setWhere: and(eq(data.record, record), eq(data.updatedBy, by)) }
423
+ : { set: { record, updatedAt: sql`now()`, updatedBy: by } };
424
+ const [row] = await db
425
+ .insert(data)
426
+ .values({ collection: c.name, key, record, updatedBy: by })
427
+ .onConflictDoUpdate({ target: [data.collection, data.key], ...conflict })
428
+ .returning(COLUMNS);
429
+ if (row) return saved(row);
430
+ const current = await getOn(db, key);
431
+ // Gone between the refusal and this read: what refused it was a record, so it has changed since.
432
+ throw current ? recordExists(current) : recordChanged(null);
433
+ };
434
+ /**
435
+ * One delete on `db`, as `delete` makes it and a batch makes each of its deletes. A retry
436
+ * (`attempt` past the first) that finds no record under `ifRecord` deleted it the first time,
437
+ * and its answer was lost: nothing is left to recover a receipt from, so it is deleted.
438
+ */
439
+ const deleteOn = async (db: Db, { key, ifRecord }: DeleteInput, retrying: boolean): Promise<Deleted> => {
440
+ const gone = await db
441
+ .delete(data)
442
+ .where(ifRecord === undefined ? here(key) : and(here(key), eq(data.record, ifRecord)))
443
+ .returning({ key: data.key });
444
+ if (gone.length || ifRecord === undefined) return { key, deleted: gone.length > 0 };
445
+ const current = await getOn(db, key);
446
+ if (current === null && retrying) return { key, deleted: true };
447
+ throw recordChanged(current);
448
+ };
312
449
  const ops: Record<string, (input: any, call: CallContext) => Promise<unknown>> = {
313
- get: async ({ key }: { key: string }) => {
314
- const [row] = await (await store.db()).select(COLUMNS).from(data).where(here(key));
315
- return row ? saved(row) : null;
316
- },
450
+ get: async ({ key }: { key: string }) => getOn(await store.db(), key),
317
451
  list: async (listing: Listing) => {
318
452
  const { where, order } = listClauses(c, listing);
319
453
  const rows = await (
@@ -326,39 +460,35 @@ export function collectionsDriver(list: readonly Collection[], store: { db(): Pr
326
460
  .limit(listing.limit);
327
461
  return rows.map(saved);
328
462
  },
329
- save: async ({ record, ifAbsent, ifRecord }: SaveInput, call) => {
330
- const db = await store.db();
331
- const key = record[c.key] as string;
332
- const by = updatedBy(call);
333
- if (ifRecord !== undefined) {
334
- // PostgreSQL locks and rechecks the predicate on a concurrent update. A lost-response
335
- // retry may recover its own identical result, but never overwrite another actor's state.
336
- const retry = and(eq(data.record, record), eq(data.updatedBy, by))!;
337
- const [row] = await db
338
- .update(data)
339
- .set({ record, updatedAt: sql`CASE WHEN ${retry} THEN ${data.updatedAt} ELSE now() END`, updatedBy: by })
340
- .where(and(here(key), or(eq(data.record, ifRecord), retry)))
341
- .returning(COLUMNS);
342
- if (!row) throw recordChanged();
343
- return saved(row);
344
- }
345
- // The conflict row is locked by Postgres: never implement an insert-only claim as get + save.
346
- // An identical retry by the same actor keeps the first receipt and never overwrites later progress.
347
- const conflict = ifAbsent
348
- ? { set: { record: sql`${data.record}` }, setWhere: and(eq(data.record, record), eq(data.updatedBy, by)) }
349
- : { set: { record, updatedAt: sql`now()`, updatedBy: by } };
350
- const [row] = await db
351
- .insert(data)
352
- .values({ collection: c.name, key, record, updatedBy: by })
353
- .onConflictDoUpdate({ target: [data.collection, data.key], ...conflict })
354
- .returning(COLUMNS);
355
- if (!row) throw recordExists();
356
- return saved(row);
357
- },
358
- delete: async ({ key }: { key: string }) => {
359
- const gone = await (await store.db()).delete(data).where(here(key)).returning({ key: data.key });
360
- return { deleted: gone.length > 0 };
463
+ count: async (counting: Pick<Listing, "where">) => {
464
+ const [row] = await (
465
+ await store.db()
466
+ )
467
+ .select({ count: drizzle.count() })
468
+ .from(data)
469
+ .where(listClauses(c, counting).where);
470
+ return { count: row!.count };
361
471
  },
472
+ save: async (input: SaveInput, call) => saveOn(await store.db(), input, updatedBy(call)),
473
+ // One transaction: a conflict on any entry, collected so each is answered, rolls back them all.
474
+ [BATCH]: async ({ records, deletes }: Batch, call) =>
475
+ (await store.db()).transaction(async (tx) => {
476
+ const by = updatedBy(call);
477
+ const results: unknown[] = [];
478
+ const conflicts: Conflict[] = [];
479
+ const made = async (key: string, entry: Promise<unknown>) => {
480
+ try {
481
+ results.push(await entry);
482
+ } catch (err) {
483
+ conflicts.push(conflictOf(key, err));
484
+ }
485
+ };
486
+ for (const e of records) await made(e.record[c.key] as string, saveOn(tx, e, by));
487
+ for (const e of deletes) await made(e.key, deleteOn(tx, e, call.attempt > 1));
488
+ if (conflicts.length) throw recordsChanged(conflicts);
489
+ return results;
490
+ }),
491
+ delete: async (input: DeleteInput, call) => deleteOn(await store.db(), input, call.attempt > 1),
362
492
  };
363
493
  return [c.name, ops];
364
494
  });
@@ -385,7 +515,10 @@ const SQL_TYPE = { string: "text", number: "numeric", boolean: "boolean" } as co
385
515
  * compares it); a missing value, or one of another type, is SQL's null: it matches no filter and
386
516
  * sorts last.
387
517
  */
388
- function listClauses(c: Collection, { where = {}, order }: Listing): { where: SQL | undefined; order: SQL[] } {
518
+ function listClauses(
519
+ c: Collection,
520
+ { where = {}, order }: Pick<Listing, "where" | "order">,
521
+ ): { where: SQL | undefined; order: SQL[] } {
389
522
  const types = jsonTypesOf(c);
390
523
  const field = (name: string) => {
391
524
  const type = types[name]!;
@@ -476,42 +609,77 @@ export function collectionsFake(list: readonly Collection[]): Fake<CollectionsSt
476
609
  const records = () => state[c.name]!;
477
610
  const types = jsonTypesOf(c);
478
611
  const saved = (stored: Stored) => answer(c, structuredClone(stored));
612
+ const stored = (key: string) => (Object.hasOwn(records(), key) ? records()[key] : undefined);
613
+ const matching = (where: Record<string, unknown> = {}) =>
614
+ Object.values(records()).filter((s) => matches(s.record, where, types));
615
+ const save = ({ record, ifAbsent, ifRecord }: SaveInput, by: string) => {
616
+ const key = record[c.key] as string;
617
+ const previous = stored(key);
618
+ if (ifRecord !== undefined) {
619
+ if (!previous) throw recordChanged(null);
620
+ if (previous.updatedBy === by && sameJson(previous.record, record)) return saved(previous);
621
+ if (!sameJson(previous.record, ifRecord)) throw recordChanged(saved(previous));
622
+ }
623
+ if (ifAbsent && previous) {
624
+ // Postgres jsonb ignores property order and omitted undefined fields.
625
+ if (previous.updatedBy !== by || !sameJson(previous.record, record)) throw recordExists(saved(previous));
626
+ return saved(previous);
627
+ }
628
+ const next = { key, record: structuredClone(record), updatedAt: new Date().toISOString(), updatedBy: by };
629
+ records()[key] = next;
630
+ return saved(next);
631
+ };
632
+ const remove = ({ key, ifRecord }: DeleteInput, retrying: boolean): Deleted => {
633
+ const previous = stored(key);
634
+ if (ifRecord !== undefined && !(previous && sameJson(previous.record, ifRecord))) {
635
+ // As the driver: a retry finding no record deleted it the first time.
636
+ if (!previous && retrying) return { key, deleted: true };
637
+ throw recordChanged(previous ? saved(previous) : null);
638
+ }
639
+ delete records()[key];
640
+ return { key, deleted: previous !== undefined };
641
+ };
479
642
  const ops: Record<string, (input: any, call: CallContext) => Promise<unknown>> = {
480
- get: async ({ key }: { key: string }) => (Object.hasOwn(records(), key) ? saved(records()[key]!) : null),
481
- list: async ({ where = {}, order, limit }: Listing) =>
482
- Object.values(records())
483
- .filter((s) => matches(s.record, where, types))
484
- .toSorted(byOrder(order, types))
485
- .slice(0, limit)
486
- .map(saved),
487
- save: async ({ record, ifAbsent, ifRecord }: SaveInput, call) => {
488
- const key = record[c.key] as string;
489
- if (ifRecord !== undefined) {
490
- const previous = Object.hasOwn(records(), key) ? records()[key]! : undefined;
491
- if (!previous) throw recordChanged();
492
- if (previous.updatedBy === updatedBy(call) && sameJson(previous.record, record)) return saved(previous);
493
- if (!sameJson(previous.record, ifRecord)) throw recordChanged();
494
- }
495
- if (ifAbsent && Object.hasOwn(records(), key)) {
496
- const previous = records()[key]!;
497
- // Postgres jsonb ignores property order and omitted undefined fields.
498
- if (previous.updatedBy !== updatedBy(call) || !sameJson(previous.record, record)) throw recordExists();
499
- return saved(previous);
500
- }
501
- const stored = {
502
- key,
503
- record: structuredClone(record),
504
- updatedAt: new Date().toISOString(),
505
- updatedBy: updatedBy(call),
506
- };
507
- records()[key] = stored;
508
- return saved(stored);
643
+ get: async ({ key }: { key: string }) => {
644
+ const s = stored(key);
645
+ return s ? saved(s) : null;
509
646
  },
510
- delete: async ({ key }: { key: string }) => {
511
- const had = Object.hasOwn(records(), key);
512
- delete records()[key];
513
- return { deleted: had };
647
+ list: async ({ where, order, limit }: Listing) =>
648
+ matching(where).toSorted(byOrder(order, types)).slice(0, limit).map(saved),
649
+ count: async ({ where }: Pick<Listing, "where">) => ({ count: matching(where).length }),
650
+ save: async (input: SaveInput, call) => save(input, updatedBy(call)),
651
+ // All or none, as the driver's transaction: a failure puts back every entry made before it.
652
+ [BATCH]: async ({ records: saves, deletes }: Batch, call) => {
653
+ const by = updatedBy(call);
654
+ const results: unknown[] = [];
655
+ const conflicts: Conflict[] = [];
656
+ const before = new Map<string, Stored | undefined>();
657
+ const restore = () => {
658
+ for (const [key, previous] of before) {
659
+ if (previous) records()[key] = previous;
660
+ else delete records()[key];
661
+ }
662
+ };
663
+ const made = (key: string, entry: () => unknown) => {
664
+ before.set(key, stored(key));
665
+ try {
666
+ results.push(entry());
667
+ } catch (err) {
668
+ conflicts.push(conflictOf(key, err));
669
+ }
670
+ };
671
+ try {
672
+ for (const e of saves) made(e.record[c.key] as string, () => save(e, by));
673
+ for (const e of deletes) made(e.key, () => remove(e, call.attempt > 1));
674
+ } catch (err) {
675
+ restore();
676
+ throw err;
677
+ }
678
+ if (!conflicts.length) return results;
679
+ restore();
680
+ throw recordsChanged(conflicts);
514
681
  },
682
+ delete: async (input: DeleteInput, call) => remove(input, call.attempt > 1),
515
683
  };
516
684
  return [c.name, ops];
517
685
  }),
package/src/config.ts CHANGED
@@ -223,6 +223,8 @@ export interface ResolvedConfig {
223
223
  logins: ReadonlyMap<string, NonNullable<ConnectorSetup["login"]>>;
224
224
  /** The makers of the model halves the connectors attach, by vendor id in `connectors` order: the app makes each once for its model registry. */
225
225
  models: ReadonlyMap<string, NonNullable<ConnectorSetup["model"]>>;
226
+ /** The makers of the sandbox halves the connectors attach, by vendor id in `connectors` order: the app makes one for the declared sandbox's vendor (`sandboxes`) when a coding agent's turn opens a box. */
227
+ sandboxBackends: ReadonlyMap<string, NonNullable<ConnectorSetup["sandbox"]>>;
226
228
  /** The remote halves the connectors attach, made once for this resolution, by vendor id in `connectors` order: `remoteFor` finds a URL's. */
227
229
  remotes: ReadonlyMap<string, RemoteHalf>;
228
230
  /**
@@ -232,11 +234,12 @@ export interface ResolvedConfig {
232
234
  */
233
235
  hosts(): Promise<string[]>;
234
236
  /**
235
- * The resources the data files under `root` declare whose type is a sandbox (`<file>#<export>`),
236
- * read at each call: where a coding agent's boxes are opened. None when no connector declares a
237
- * sandbox type.
237
+ * The resources the data files under `root` declare whose type is a sandbox, read at each call:
238
+ * where a coding agent's boxes are opened, its connector's backend (`sandboxBackends`) given its
239
+ * fields. At most one: several are each a problem of the data files, and left out. None when no
240
+ * connector declares a sandbox type.
238
241
  */
239
- sandboxes(): Promise<string[]>;
242
+ sandboxes(): Promise<DeclaredResource[]>;
240
243
  /**
241
244
  * The objects the data files under `root` declare, read at each call: what a workflow's change
242
245
  * operation may not aim at, since only apply changes a declared object.
@@ -341,7 +344,9 @@ export function resolveDatabaseUrl(config: { databaseUrl?: string }, env: NodeJS
341
344
  }
342
345
 
343
346
  const isStore = (x: unknown): x is LedgerStore =>
344
- typeof (x as LedgerStore | null)?.append === "function" && typeof (x as LedgerStore).read === "function";
347
+ typeof (x as LedgerStore | null)?.append === "function" &&
348
+ typeof (x as LedgerStore).read === "function" &&
349
+ typeof (x as LedgerStore).readByTarget === "function";
345
350
 
346
351
  /** The config's `ledger`, checked, when it gives one. */
347
352
  function ledgerOf(config: SanomaConfig): LedgerStore | undefined {
@@ -476,12 +481,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
476
481
  ...options,
477
482
  drivers: driversByVendor,
478
483
  builtInStore: !config.secrets?.store,
479
- signingKeyBound: config.secrets?.bindings?.SANOMA_SIGNING_KEY !== undefined,
480
- hostEnvironment: Object.fromEntries(
481
- Object.entries(config.secrets?.bindings ?? {}).flatMap(([name, binding]) =>
482
- binding.host && binding.env !== undefined ? [[binding.env, name]] : [],
483
- ),
484
- ),
484
+ bindings: config.secrets?.bindings,
485
485
  ...(isOp(config.login?.sender) && { sender: config.login.sender.vendor }),
486
486
  });
487
487
  if (types.size) {
@@ -492,14 +492,14 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
492
492
  names.set(APPLY_WORKFLOW, applyWorkflow(types, ops, drivers, applySteps(appName, root, read)));
493
493
  }
494
494
  // Built-in, in every config: the life of a change, which deploys to the declared hosts after its merge.
495
- // The declared hosts' (and sandboxes') ids, as the files say them: nothing derived, nothing minted.
495
+ // The declared hosts (and sandboxes), as the files say them: nothing derived, nothing minted.
496
496
  const declaredOf = (contract: "host" | "sandbox") => async () => {
497
497
  const { loadDataFiles } = await import("./resources.ts");
498
- return loadDataFiles(root, connectors, declarable)
499
- .resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.[contract])
500
- .map((r) => r.id);
498
+ return loadDataFiles(root, connectors, declarable).resources.filter(
499
+ (r) => types.get(`${r.vendor}.${r.type}`)?.[contract],
500
+ );
501
501
  };
502
- const hosts = declaredOf("host");
502
+ const hosts = async () => (await declaredOf("host")()).map((r) => r.id);
503
503
  names.set(CHANGE_WORKFLOW, changeWorkflow(changeOps, changeSteps({ remotes, hosts })));
504
504
  // Built-in, in every config: what a git host's webhook says happened, which the worker with the checkout follows.
505
505
  names.set(FOLLOW_WORKFLOW, followWorkflow(followSteps({ remotes, hosts })));
@@ -540,6 +540,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
540
540
  preparations: makersOf(config.connectors, "prepare"),
541
541
  logins: makersOf(config.connectors, "login"),
542
542
  models: makersOf(config.connectors, "model"),
543
+ sandboxBackends: makersOf(config.connectors, "sandbox"),
543
544
  remotes,
544
545
  hosts,
545
546
  sandboxes: declaredOf("sandbox"),
@@ -821,8 +822,7 @@ async function readDeclared(
821
822
  options: ReadOptions & {
822
823
  drivers: ReadonlyMap<string, Driver>;
823
824
  builtInStore: boolean;
824
- signingKeyBound: boolean;
825
- hostEnvironment: Readonly<Record<string, string>>;
825
+ bindings?: SecretConfiguration["bindings"];
826
826
  sender?: string;
827
827
  },
828
828
  ): Promise<Omit<ApplyDeclared, "rebinds">> {
@@ -843,10 +843,10 @@ async function readDeclared(
843
843
  drivers: options.drivers,
844
844
  ...(run && { secrets: run.state.secrets }),
845
845
  builtInStore: options.builtInStore,
846
- signingKeyBound: options.signingKeyBound,
847
- hostEnvironment: options.hostEnvironment,
846
+ bindings: options.bindings,
848
847
  ...(options.sender !== undefined && { sender: options.sender }),
849
848
  mint: !!options.mint && !!run && !run.sandbox,
849
+ sandbox: !!run?.sandbox,
850
850
  });
851
851
  if (facts) problems.push(...deriveHosts(resources, types, bindings, facts, { keep, leftOut }));
852
852
  }
package/src/db/company.ts CHANGED
@@ -45,6 +45,7 @@ export const credentials = pgTable(
45
45
  /**
46
46
  * The Postgres ledger's records. `n` is the write order, `id` the record's, which the first write
47
47
  * of takes, and `seq` the record's place in its run: a run reads in `seq` order, then write order.
48
+ * `target` is the JSON's (an `op.called`'s instance), kept by Postgres, for a record's history.
48
49
  */
49
50
  export const ledger = pgTable(
50
51
  "sanoma_ledger",
@@ -54,8 +55,15 @@ export const ledger = pgTable(
54
55
  runId: text().notNull(),
55
56
  seq: integer().notNull(),
56
57
  record: jsonb().$type<LedgerRecord>().notNull(),
58
+ target: text().generatedAlwaysAs(sql`"record" ->> 'target'`),
57
59
  },
58
- (t) => [index("sanoma_ledger_run_seq").on(t.runId, t.seq, t.n)],
60
+ (t) => [
61
+ index("sanoma_ledger_run_seq").on(t.runId, t.seq, t.n),
62
+ // Only the `op.called` records carry a target: the others stay out of the index.
63
+ index("sanoma_ledger_target")
64
+ .on(t.target)
65
+ .where(sql`${t.target} is not null`),
66
+ ],
59
67
  );
60
68
 
61
69
  /** The collections' records (`collection.ts`): one row per collection and key, the record as JSON. */
package/src/describe.ts CHANGED
@@ -205,7 +205,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
205
205
  key: c.key,
206
206
  fields: c.fields,
207
207
  record: { $id: `sanoma:collection/${c.name}`, ...toJsonSchema(recordSchemaOf(c), `${c.name} record`, "input") },
208
- ops: [c.get, c.list, c.save, c.delete].map((op) => op.id),
208
+ ops: [c.get, c.list, c.count, c.save, c.saveMany, c.delete].map((op) => op.id),
209
209
  ...(declared.has(c.name) && { declared: declared.get(c.name) }),
210
210
  }))
211
211
  .toSorted((a, b) => a.name.localeCompare(b.name));
@@ -349,7 +349,7 @@ export const CollectionEntrySchema = z.strictObject({
349
349
  fields: z.record(z.string(), FieldSchema),
350
350
  /** What `save` accepts, as JSON Schema, with `$id` `sanoma:collection/<name>`. */
351
351
  record: JsonSchema,
352
- /** Its operations' ids: `data.<name>.get`, `.list`, `.save` and `.delete`. */
352
+ /** Its operations' ids: `data.<name>.get`, `.list`, `.count`, `.save`, `.saveMany` and `.delete`. */
353
353
  ops: z.array(z.string()),
354
354
  /** Where `resources/data.ts` declares it, read as the data files are; absent for one declared elsewhere. */
355
355
  declared: DeclaredCollectionSchema.optional(),
package/src/errors.ts CHANGED
@@ -79,8 +79,9 @@ export const isRunControlError = (err: unknown): boolean =>
79
79
 
80
80
  /** Thrown into the run when the policy denies an operation call. The run fails with it. */
81
81
  export class PolicyDeniedError extends SanomaError {
82
- constructor(op: string, reason: string) {
83
- super("policy_denied", `${op} was denied by policy: ${reason}`, { op, reason });
82
+ /** `data` adds to the error's: a batch's `denied` keys. */
83
+ constructor(op: string, reason: string, data: Record<string, unknown> = {}) {
84
+ super("policy_denied", `${op} was denied by policy: ${reason}`, { op, reason, ...data });
84
85
  this.name = "PolicyDeniedError";
85
86
  }
86
87
  }