@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/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
  /**
@@ -231,6 +233,13 @@ export interface ResolvedConfig {
231
233
  * declares a host type.
232
234
  */
233
235
  hosts(): Promise<string[]>;
236
+ /**
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.
241
+ */
242
+ sandboxes(): Promise<DeclaredResource[]>;
234
243
  /**
235
244
  * The objects the data files under `root` declare, read at each call: what a workflow's change
236
245
  * operation may not aim at, since only apply changes a declared object.
@@ -289,12 +298,55 @@ export function defineConfig(config: SanomaConfig): SanomaConfig {
289
298
  return { ...config, file: config.file ?? callerFile() };
290
299
  }
291
300
 
301
+ /**
302
+ * Joins the declaration files' modules (`declarationFiles`, each by its path) to `config`, as
303
+ * `sanoma` loads it and the Worker's bundle composes it: a workflow file's default export is a
304
+ * workflow (`defineWorkflow`), a file without one a helper; each export of a data file is a
305
+ * collection. One the config lists already is kept, once. A `workflows` or `collections` that is
306
+ * no list is left for `resolveConfig` to explain. Throws naming the file for an export that is
307
+ * neither.
308
+ */
309
+ export function joinDeclarations(
310
+ config: SanomaConfig,
311
+ modules: { workflows: [path: string, module: { default?: unknown }][]; data: [path: string, module: object][] },
312
+ ): void {
313
+ if (config.workflows === undefined || Array.isArray(config.workflows)) {
314
+ const workflows = new Set(config.workflows);
315
+ for (const [path, { default: wf }] of modules.workflows) {
316
+ if (wf === undefined) continue;
317
+ if ((wf as { kind?: unknown } | null)?.kind !== "workflow") {
318
+ throw new Error(
319
+ `${path}'s default export is not a workflow: a file of workflows/ exports one, \`export default defineWorkflow({ … })\`, or none (a helper the workflows import)`,
320
+ );
321
+ }
322
+ workflows.add(wf as WorkflowDefinition<any, any>);
323
+ }
324
+ if (workflows.size > 0) config.workflows = [...workflows];
325
+ }
326
+ if (config.collections === undefined || Array.isArray(config.collections)) {
327
+ const collections = new Set(config.collections);
328
+ for (const [path, module] of modules.data) {
329
+ for (const [name, value] of Object.entries(module)) {
330
+ if (!isCollection(value)) {
331
+ throw new Error(
332
+ `${path} exports ${name}, which is not a collection: resources/data.ts holds collections only, each \`export const ${name} = collection({ name, key, fields })\` with collection from "sanoma/data"`,
333
+ );
334
+ }
335
+ collections.add(value);
336
+ }
337
+ }
338
+ if (collections.size > 0) config.collections = [...collections];
339
+ }
340
+ }
341
+
292
342
  export function resolveDatabaseUrl(config: { databaseUrl?: string }, env: NodeJS.ProcessEnv = process.env): string {
293
343
  return config.databaseUrl ?? env.SANOMA_DATABASE_URL ?? DEFAULT_DATABASE_URL;
294
344
  }
295
345
 
296
346
  const isStore = (x: unknown): x is LedgerStore =>
297
- 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";
298
350
 
299
351
  /** The config's `ledger`, checked, when it gives one. */
300
352
  function ledgerOf(config: SanomaConfig): LedgerStore | undefined {
@@ -429,12 +481,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
429
481
  ...options,
430
482
  drivers: driversByVendor,
431
483
  builtInStore: !config.secrets?.store,
432
- signingKeyBound: config.secrets?.bindings?.SANOMA_SIGNING_KEY !== undefined,
433
- hostEnvironment: Object.fromEntries(
434
- Object.entries(config.secrets?.bindings ?? {}).flatMap(([name, binding]) =>
435
- binding.host && binding.env !== undefined ? [[binding.env, name]] : [],
436
- ),
437
- ),
484
+ bindings: config.secrets?.bindings,
438
485
  ...(isOp(config.login?.sender) && { sender: config.login.sender.vendor }),
439
486
  });
440
487
  if (types.size) {
@@ -445,13 +492,14 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
445
492
  names.set(APPLY_WORKFLOW, applyWorkflow(types, ops, drivers, applySteps(appName, root, read)));
446
493
  }
447
494
  // Built-in, in every config: the life of a change, which deploys to the declared hosts after its merge.
448
- // The declared hosts' ids, as the files say them: nothing derived, nothing minted.
449
- const hosts = async () => {
495
+ // The declared hosts (and sandboxes), as the files say them: nothing derived, nothing minted.
496
+ const declaredOf = (contract: "host" | "sandbox") => async () => {
450
497
  const { loadDataFiles } = await import("./resources.ts");
451
- return loadDataFiles(root, connectors, declarable)
452
- .resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.host)
453
- .map((r) => r.id);
498
+ return loadDataFiles(root, connectors, declarable).resources.filter(
499
+ (r) => types.get(`${r.vendor}.${r.type}`)?.[contract],
500
+ );
454
501
  };
502
+ const hosts = async () => (await declaredOf("host")()).map((r) => r.id);
455
503
  names.set(CHANGE_WORKFLOW, changeWorkflow(changeOps, changeSteps({ remotes, hosts })));
456
504
  // Built-in, in every config: what a git host's webhook says happened, which the worker with the checkout follows.
457
505
  names.set(FOLLOW_WORKFLOW, followWorkflow(followSteps({ remotes, hosts })));
@@ -492,8 +540,10 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
492
540
  preparations: makersOf(config.connectors, "prepare"),
493
541
  logins: makersOf(config.connectors, "login"),
494
542
  models: makersOf(config.connectors, "model"),
543
+ sandboxBackends: makersOf(config.connectors, "sandbox"),
495
544
  remotes,
496
545
  hosts,
546
+ sandboxes: declaredOf("sandbox"),
497
547
  declaredObjects,
498
548
  ...(scenarios && { scenarios }),
499
549
  types,
@@ -772,8 +822,7 @@ async function readDeclared(
772
822
  options: ReadOptions & {
773
823
  drivers: ReadonlyMap<string, Driver>;
774
824
  builtInStore: boolean;
775
- signingKeyBound: boolean;
776
- hostEnvironment: Readonly<Record<string, string>>;
825
+ bindings?: SecretConfiguration["bindings"];
777
826
  sender?: string;
778
827
  },
779
828
  ): Promise<Omit<ApplyDeclared, "rebinds">> {
@@ -794,10 +843,10 @@ async function readDeclared(
794
843
  drivers: options.drivers,
795
844
  ...(run && { secrets: run.state.secrets }),
796
845
  builtInStore: options.builtInStore,
797
- signingKeyBound: options.signingKeyBound,
798
- hostEnvironment: options.hostEnvironment,
846
+ bindings: options.bindings,
799
847
  ...(options.sender !== undefined && { sender: options.sender }),
800
848
  mint: !!options.mint && !!run && !run.sandbox,
849
+ sandbox: !!run?.sandbox,
801
850
  });
802
851
  if (facts) problems.push(...deriveHosts(resources, types, bindings, facts, { keep, leftOut }));
803
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. */