@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.
- package/README.md +16 -8
- package/dist/call.d.ts +35 -4
- package/dist/call.js +128 -40
- package/dist/call.js.map +1 -1
- package/dist/client.d.ts +17 -8
- package/dist/client.js +90 -51
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +25 -7
- package/dist/collection.js +236 -91
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +24 -1
- package/dist/config.js +48 -10
- package/dist/config.js.map +1 -1
- package/dist/db/company.d.ts +20 -0
- package/dist/db/company.js +9 -1
- package/dist/db/company.js.map +1 -1
- package/dist/describe.js +1 -1
- package/dist/describe.js.map +1 -1
- package/dist/description.d.ts +6 -3
- package/dist/description.js +5 -4
- package/dist/description.js.map +1 -1
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +3 -2
- package/dist/errors.js.map +1 -1
- package/dist/grid.js +3 -2
- package/dist/grid.js.map +1 -1
- package/dist/host-derive.d.ts +13 -8
- package/dist/host-derive.js +26 -13
- package/dist/host-derive.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +7 -0
- package/dist/ledger.js +32 -2
- package/dist/ledger.js.map +1 -1
- package/dist/local-sandbox.d.ts +17 -0
- package/dist/local-sandbox.js +131 -0
- package/dist/local-sandbox.js.map +1 -0
- package/dist/op.d.ts +10 -2
- package/dist/op.js.map +1 -1
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +20 -4
- package/dist/paths.js.map +1 -1
- package/dist/resource.d.ts +7 -1
- package/dist/resource.js +5 -1
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +1 -1
- package/dist/resources.js +9 -6
- package/dist/resources.js.map +1 -1
- package/dist/runner-box.d.ts +23 -0
- package/dist/runner-box.js +181 -0
- package/dist/runner-box.js.map +1 -0
- package/dist/runner-protocol.d.ts +73 -0
- package/dist/runner-protocol.js +34 -0
- package/dist/runner-protocol.js.map +1 -0
- package/dist/sandbox-contract.d.ts +109 -0
- package/dist/sandbox-contract.js +26 -0
- package/dist/sandbox-contract.js.map +1 -0
- package/dist/sandbox-runner.cjs +7 -0
- package/dist/scenario.js +16 -7
- package/dist/scenario.js.map +1 -1
- package/dist/schedules.d.ts +11 -3
- package/dist/schedules.js +15 -3
- package/dist/schedules.js.map +1 -1
- package/dist/schema.js +6 -3
- package/dist/schema.js.map +1 -1
- package/dist/secret.d.ts +2 -1
- package/dist/secret.js +3 -2
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +39 -5
- package/dist/shared.js +13 -1
- package/dist/shared.js.map +1 -1
- package/dist/sites.js +10 -8
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +21 -0
- package/dist/source.js.map +1 -1
- package/drizzle/company/0003_ledger_target.sql +2 -0
- package/drizzle/company/meta/0003_snapshot.json +845 -0
- package/drizzle/company/meta/_journal.json +7 -0
- package/package.json +5 -2
- package/src/call.ts +181 -60
- package/src/client.ts +118 -67
- package/src/collection.ts +265 -97
- package/src/config.ts +65 -16
- package/src/db/company.ts +9 -1
- package/src/describe.ts +1 -1
- package/src/description.ts +5 -4
- package/src/errors.ts +3 -2
- package/src/grid.ts +3 -1
- package/src/host-derive.ts +38 -18
- package/src/index.ts +15 -2
- package/src/ledger.ts +42 -2
- package/src/local-sandbox.ts +133 -0
- package/src/op.ts +10 -2
- package/src/paths.ts +22 -4
- package/src/resource.ts +16 -2
- package/src/resources.ts +8 -6
- package/src/runner-box.ts +218 -0
- package/src/runner-protocol.ts +61 -0
- package/src/sandbox-contract.ts +121 -0
- package/src/sandbox-runner.ts +187 -0
- package/src/scenario.ts +16 -6
- package/src/schedules.ts +19 -4
- package/src/schema.ts +14 -3
- package/src/secret.ts +6 -2
- package/src/shared.ts +40 -4
- package/src/sites.ts +11 -7
- 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
|
|
34
|
-
// the policy and in the ledger like any connector's. The records are JSON
|
|
35
|
-
// runtime owns (`data`), in the config's database; a sandbox run keeps
|
|
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
|
|
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
|
|
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`,
|
|
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
|
-
/**
|
|
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
|
|
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:
|
|
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: () =>
|
|
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:
|
|
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:
|
|
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) => [
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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(
|
|
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 }) =>
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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" &&
|
|
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
|
-
|
|
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
|
|
449
|
-
const
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => [
|
|
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. */
|