@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.
- package/README.md +13 -9
- 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 +8 -5
- package/dist/config.js +10 -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.js +1 -1
- 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 +2 -0
- package/dist/index.js +2 -0
- 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 +8 -1
- package/dist/local-sandbox.js +3 -2
- package/dist/local-sandbox.js.map +1 -1
- package/dist/op.d.ts +3 -2
- package/dist/op.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 +14 -5
- package/dist/sandbox-contract.js.map +1 -1
- package/dist/sandbox-runner.cjs +7 -0
- package/dist/scenario.js +16 -7
- package/dist/scenario.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 +20 -20
- package/src/db/company.ts +9 -1
- package/src/describe.ts +1 -1
- package/src/description.ts +1 -1
- package/src/errors.ts +3 -2
- package/src/grid.ts +3 -1
- package/src/host-derive.ts +38 -18
- package/src/index.ts +2 -0
- package/src/ledger.ts +42 -2
- package/src/local-sandbox.ts +3 -2
- package/src/op.ts +3 -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 +11 -4
- package/src/sandbox-runner.ts +187 -0
- package/src/scenario.ts +16 -6
- 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
|
/**
|
|
@@ -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
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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<
|
|
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" &&
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => [
|
|
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));
|
package/src/description.ts
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
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
|
}
|