@sanoma-ai/workflows 0.2.0 → 0.4.0
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 +121 -62
- package/dist/approvals.d.ts +11 -6
- package/dist/approvals.js +38 -10
- package/dist/approvals.js.map +1 -1
- package/dist/call-workflow.d.ts +16 -0
- package/dist/call-workflow.js +35 -0
- package/dist/call-workflow.js.map +1 -0
- package/dist/call.d.ts +33 -4
- package/dist/call.js +107 -42
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +87 -0
- package/dist/cedar.js +365 -0
- package/dist/cedar.js.map +1 -0
- package/dist/client.d.ts +85 -7
- package/dist/client.js +289 -25
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +107 -0
- package/dist/collection.js +306 -0
- package/dist/collection.js.map +1 -0
- package/dist/config.d.ts +42 -14
- package/dist/config.js +82 -18
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +2 -0
- package/dist/constructors.js +5 -0
- package/dist/constructors.js.map +1 -0
- package/dist/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +28 -2
- package/dist/datafile.js +118 -19
- package/dist/datafile.js.map +1 -1
- package/dist/db.d.ts +5 -3
- package/dist/db.js +88 -21
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +10 -4
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +45 -5
- package/dist/describe.js +72 -7
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +9 -0
- package/dist/direct.js +11 -0
- package/dist/direct.js.map +1 -0
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +3 -3
- package/dist/expectations.js +16 -7
- package/dist/expectations.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -1
- package/dist/ledger.js +18 -11
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +5 -4
- package/dist/lint.js +24 -7
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +6 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +2 -0
- package/dist/outline.js +1 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +17 -3
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +61 -0
- package/dist/person.js +69 -0
- package/dist/person.js.map +1 -0
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +51 -55
- package/dist/policy.js +14 -36
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +21 -5
- package/dist/resource.js +12 -3
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +58 -6
- package/dist/resources.js +119 -26
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +17 -1
- package/dist/run.js.map +1 -1
- package/dist/scenario.d.ts +16 -9
- package/dist/scenario.js +93 -20
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -6
- package/dist/scenarios.js +8 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/sdk.d.ts +34 -0
- package/dist/sdk.js +168 -0
- package/dist/sdk.js.map +1 -0
- package/dist/shared.d.ts +181 -5
- package/dist/shared.js +160 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +52 -0
- package/dist/sites.js +174 -0
- package/dist/sites.js.map +1 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +3 -2
- package/dist/worker.js +24 -4
- package/dist/worker.js.map +1 -1
- package/oxlint.json +5 -16
- package/package.json +8 -3
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +157 -45
- package/src/cedar.ts +493 -0
- package/src/client.ts +368 -37
- package/src/collection.ts +431 -0
- package/src/config.ts +131 -34
- package/src/constructors.ts +4 -0
- package/src/data.ts +10 -0
- package/src/datafile.ts +141 -21
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +113 -10
- package/src/direct.ts +15 -0
- package/src/errors.ts +36 -1
- package/src/expectations.ts +17 -8
- package/src/index.ts +33 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/op.ts +6 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +1 -1
- package/src/policy.ts +59 -63
- package/src/resource.ts +35 -8
- package/src/resources.ts +165 -24
- package/src/run.ts +18 -1
- package/src/scenario.ts +128 -33
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/shared.ts +271 -8
- package/src/sites.ts +220 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +29 -6
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DATA_TABLE, type Db } from "./db.ts";
|
|
3
|
+
import { defineFake, type Fake } from "./fake.ts";
|
|
4
|
+
import { isObject } from "./resource.ts";
|
|
5
|
+
import {
|
|
6
|
+
type CallContext,
|
|
7
|
+
type Connector,
|
|
8
|
+
defineConnector,
|
|
9
|
+
defineDriver,
|
|
10
|
+
type Driver,
|
|
11
|
+
type DriverImpl,
|
|
12
|
+
type Op,
|
|
13
|
+
type OpSpec,
|
|
14
|
+
} from "./op.ts";
|
|
15
|
+
import { checkCollection, DATA_VENDOR, type FIELD_TYPES, LIST_LIMIT, type ListQuery, type Saved } from "./shared.ts";
|
|
16
|
+
|
|
17
|
+
// A company's own records: a collection is declared with plain values (a name, a key and typed
|
|
18
|
+
// fields), and its `get`, `list`, `save` and `delete` are operations of the vendor `data`, under
|
|
19
|
+
// the policy and in the ledger like any connector's. The records are JSON rows of one table the
|
|
20
|
+
// runtime owns (`DATA_TABLE`), in the config's database; a sandbox run keeps them in memory.
|
|
21
|
+
|
|
22
|
+
export type FieldType = (typeof FIELD_TYPES)[number];
|
|
23
|
+
|
|
24
|
+
/** A field of a collection: its type, the type marked optional, or one of a list of strings. */
|
|
25
|
+
export type Field =
|
|
26
|
+
| FieldType
|
|
27
|
+
| { type: FieldType; optional?: true }
|
|
28
|
+
| { enum: readonly [string, ...string[]]; optional?: true };
|
|
29
|
+
|
|
30
|
+
type ValueOfType<T> = T extends "number" ? number : T extends "boolean" ? boolean : string;
|
|
31
|
+
type ValueOf<F> = F extends { enum: readonly (infer E)[] }
|
|
32
|
+
? E
|
|
33
|
+
: F extends { type: infer T }
|
|
34
|
+
? ValueOfType<T>
|
|
35
|
+
: ValueOfType<F>;
|
|
36
|
+
type OptionalKeys<F> = { [K in keyof F]: F[K] extends { optional: true } ? K : never }[keyof F];
|
|
37
|
+
type Flat<T> = { [K in keyof T]: T[K] };
|
|
38
|
+
|
|
39
|
+
/** A record of a collection with these fields: text, a person and a date are strings, an enum one of its strings. */
|
|
40
|
+
export type RecordOf<F extends Record<string, Field>> = Flat<
|
|
41
|
+
{ -readonly [K in Exclude<keyof F & string, OptionalKeys<F>>]: ValueOf<F[K]> } & {
|
|
42
|
+
-readonly [K in OptionalKeys<F> & string]?: ValueOf<F[K]>;
|
|
43
|
+
}
|
|
44
|
+
>;
|
|
45
|
+
|
|
46
|
+
/** The fields that may be a collection's key: required text. */
|
|
47
|
+
export type RequiredTextKeys<F extends Record<string, Field>> = {
|
|
48
|
+
[K in keyof F & string]: F[K] extends "text" | { type: "text"; optional?: undefined } ? K : never;
|
|
49
|
+
}[keyof F & string];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where a collection keeps its brand: a registered symbol, so a copy of this package reads a
|
|
53
|
+
* collection another copy made, as `VENDOR` and `RESOURCE`.
|
|
54
|
+
*/
|
|
55
|
+
export const COLLECTION: unique symbol = Symbol.for("sanoma.collection");
|
|
56
|
+
|
|
57
|
+
/** A collection, as `collection()` declares it, with its four operations. */
|
|
58
|
+
export interface Collection<
|
|
59
|
+
N extends string = string,
|
|
60
|
+
F extends Record<string, Field> = Record<string, Field>,
|
|
61
|
+
K extends string = string,
|
|
62
|
+
> {
|
|
63
|
+
readonly [COLLECTION]: true;
|
|
64
|
+
readonly kind: "collection";
|
|
65
|
+
readonly name: N;
|
|
66
|
+
readonly key: K;
|
|
67
|
+
readonly fields: F;
|
|
68
|
+
readonly title?: string;
|
|
69
|
+
readonly get: Op<typeof DATA_VENDOR, N, "get", { key: string }, Saved<RecordOf<F>> | null>;
|
|
70
|
+
readonly list: Op<typeof DATA_VENDOR, N, "list", ListQuery<RecordOf<F>>, Saved<RecordOf<F>>[]>;
|
|
71
|
+
readonly save: Op<typeof DATA_VENDOR, N, "save", { record: RecordOf<F> }, Saved<RecordOf<F>>>;
|
|
72
|
+
readonly delete: Op<typeof DATA_VENDOR, N, "delete", { key: string }, { deleted: boolean }>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Declares a collection of records, in `resources/data.ts`:
|
|
77
|
+
* `collection({ name: "outbound", key: "company", fields: { company: "text", stage: { enum: ["new", "won"] } } })`.
|
|
78
|
+
* Its operations are `data.<name>.get`, `.list`, `.save` and `.delete`, which a workflow names in
|
|
79
|
+
* `uses` and calls on `ctx.data.<name>`. The name is a lowercase letter then letters and digits;
|
|
80
|
+
* the key is a required text field, each record's own and unique.
|
|
81
|
+
*/
|
|
82
|
+
export function collection<
|
|
83
|
+
const N extends string,
|
|
84
|
+
const F extends Record<string, Field>,
|
|
85
|
+
const K extends RequiredTextKeys<F>,
|
|
86
|
+
>(declaration: { name: N; key: K; fields: F; title?: string }): Collection<N, F, K> {
|
|
87
|
+
checkCollection(declaration);
|
|
88
|
+
const { name, key, fields, title } = declaration;
|
|
89
|
+
const record = z.strictObject(
|
|
90
|
+
Object.fromEntries(
|
|
91
|
+
Object.entries(fields).map(([field, kind]) => {
|
|
92
|
+
const value = field === key ? z.string().min(1) : valueSchema(kind);
|
|
93
|
+
return [field, isObject(kind) && kind.optional ? value.optional() : value];
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
const ops = defineConnector(DATA_VENDOR, { [name]: specsOf(name, key, fields, record) })[name]!;
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
[COLLECTION]: true,
|
|
100
|
+
kind: "collection",
|
|
101
|
+
name,
|
|
102
|
+
key,
|
|
103
|
+
fields,
|
|
104
|
+
...(title !== undefined && { title }),
|
|
105
|
+
...ops,
|
|
106
|
+
}) as unknown as Collection<N, F, K>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A date or a date and time with its offset, as ISO 8601 writes them, marked `format: "date"` in
|
|
111
|
+
* its JSON Schema (beside the union's own), which a form reads to draw a date's input.
|
|
112
|
+
*/
|
|
113
|
+
const DATE = z.union([z.iso.date(), z.iso.datetime({ offset: true })]).meta({ format: "date" });
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A person's id: text that is not empty, marked `format: "person"` in its JSON Schema, which a form
|
|
117
|
+
* reads to draw a person's input.
|
|
118
|
+
*/
|
|
119
|
+
const PERSON = z.string().min(1).meta({ format: "person" });
|
|
120
|
+
|
|
121
|
+
/** The schema of a field's value, required. */
|
|
122
|
+
function valueSchema(kind: Field): z.ZodType {
|
|
123
|
+
if (typeof kind === "object" && "enum" in kind) return z.enum(kind.enum);
|
|
124
|
+
const type = typeof kind === "string" ? kind : kind.type;
|
|
125
|
+
return { text: z.string(), person: PERSON, date: DATE, number: z.number(), boolean: z.boolean() }[type];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What `get`, `list` and `save` answer: the record as it is stored, which an older declaration
|
|
130
|
+
* may have accepted, so it is not checked here; `issues` say where the fields refuse it.
|
|
131
|
+
*/
|
|
132
|
+
const SAVED = z.object({
|
|
133
|
+
key: z.string(),
|
|
134
|
+
record: z.record(z.string(), z.unknown()),
|
|
135
|
+
updatedAt: z.string(),
|
|
136
|
+
updatedBy: z.string(),
|
|
137
|
+
issues: z.array(z.object({ path: z.array(z.union([z.string(), z.number()])), message: z.string() })).optional(),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/** A collection's four operations, for `defineConnector`. */
|
|
141
|
+
function specsOf(name: string, key: string, fields: Record<string, Field>, record: z.ZodType): Record<string, OpSpec> {
|
|
142
|
+
const at = (k: string) => `${DATA_VENDOR}:${name}/${k}`;
|
|
143
|
+
const byKey = z.object({ key: z.string().min(1) });
|
|
144
|
+
const names = Object.keys(fields);
|
|
145
|
+
const where = z.strictObject(
|
|
146
|
+
Object.fromEntries(
|
|
147
|
+
Object.entries(fields).map(([field, kind]) => {
|
|
148
|
+
const value = valueSchema(kind);
|
|
149
|
+
const range = z.strictObject({ gt: value, gte: value, lt: value, lte: value }).partial();
|
|
150
|
+
return [field, z.union([value, range]).optional()];
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
return {
|
|
155
|
+
get: {
|
|
156
|
+
effect: "read",
|
|
157
|
+
idempotent: true,
|
|
158
|
+
description: `Get a record of ${name} by its ${key}`,
|
|
159
|
+
input: byKey,
|
|
160
|
+
output: SAVED.nullable(),
|
|
161
|
+
target: ({ key: k }: { key: string }) => at(k),
|
|
162
|
+
},
|
|
163
|
+
list: {
|
|
164
|
+
effect: "read",
|
|
165
|
+
idempotent: true,
|
|
166
|
+
description: `List records of ${name}: fields equal to a value or within a range, sorted, at most \`limit\``,
|
|
167
|
+
input: z.object({
|
|
168
|
+
where: where.optional(),
|
|
169
|
+
order: z.enum([...names, ...names.map((n) => `-${n}`)] as [string, ...string[]]).optional(),
|
|
170
|
+
limit: z.number().int().min(1).max(LIST_LIMIT.max).default(LIST_LIMIT.default),
|
|
171
|
+
}),
|
|
172
|
+
output: z.array(SAVED),
|
|
173
|
+
target: () => `${DATA_VENDOR}:${name}`,
|
|
174
|
+
},
|
|
175
|
+
save: {
|
|
176
|
+
effect: "write",
|
|
177
|
+
idempotent: true,
|
|
178
|
+
description: `Save a record of ${name}: added, or replacing the one with its ${key}`,
|
|
179
|
+
input: z.object({ record }),
|
|
180
|
+
output: SAVED,
|
|
181
|
+
target: ({ record: r }: { record: Record<string, string> }) => at(r[key]!),
|
|
182
|
+
},
|
|
183
|
+
delete: {
|
|
184
|
+
effect: "write",
|
|
185
|
+
idempotent: true,
|
|
186
|
+
description: `Delete the record of ${name} with this ${key}`,
|
|
187
|
+
input: byKey,
|
|
188
|
+
output: z.object({ deleted: z.boolean() }),
|
|
189
|
+
target: ({ key: k }: { key: string }) => at(k),
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** True for a collection `collection()` made, in this copy of the package or another: read by its brand. */
|
|
195
|
+
export const isCollection = (x: unknown): x is Collection =>
|
|
196
|
+
isObject(x) && (x as Partial<Collection>)[COLLECTION] === true && x.kind === "collection";
|
|
197
|
+
|
|
198
|
+
/** The schema a collection's records are saved under: its fields, and nothing else. */
|
|
199
|
+
export const recordSchemaOf = (c: Collection): z.ZodType =>
|
|
200
|
+
(c.save.input as unknown as z.ZodObject<{ record: z.ZodType }>).shape.record;
|
|
201
|
+
|
|
202
|
+
/** Where the record no longer fits the collection's fields, as `Saved.issues` says it; none when it fits. */
|
|
203
|
+
export function recordIssues(c: Collection, record: unknown): NonNullable<Saved["issues"]> {
|
|
204
|
+
const parsed = recordSchemaOf(c).safeParse(record);
|
|
205
|
+
if (parsed.success) return [];
|
|
206
|
+
return parsed.error.issues.map(({ path, message }) => ({
|
|
207
|
+
path: path.filter((p): p is string | number => typeof p !== "symbol"),
|
|
208
|
+
message,
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
type Stored = Omit<Saved, "issues">;
|
|
213
|
+
type DataOps = Record<string, Record<string, OpSpec>>;
|
|
214
|
+
|
|
215
|
+
/** The record as `get`, `list` and `save` answer it: with its issues, when it has any. */
|
|
216
|
+
function answer(c: Collection, stored: Stored): Saved {
|
|
217
|
+
const issues = recordIssues(c, stored.record);
|
|
218
|
+
return issues.length ? { ...stored, issues } : stored;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Who a save is by: the person a direct call is made as, else the run. */
|
|
222
|
+
const updatedBy = (call: CallContext) => call.by ?? `run:${call.runId}`;
|
|
223
|
+
|
|
224
|
+
/** The vendor `data` as a connector of the collections' operations, the very ones each `collection()` made. */
|
|
225
|
+
export function collectionsConnector(list: readonly Collection[]): Connector<typeof DATA_VENDOR, DataOps> {
|
|
226
|
+
const ops = list.map((c) => [c.name, { get: c.get, list: c.list, save: c.save, delete: c.delete }]);
|
|
227
|
+
return { ...defineConnector(DATA_VENDOR, {}), ...Object.fromEntries(ops) } as Connector<typeof DATA_VENDOR, DataOps>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const COLUMNS = "key, record, updated_at, updated_by";
|
|
231
|
+
const RANGE = { gt: ">", gte: ">=", lt: "<", lte: "<=" } as const;
|
|
232
|
+
|
|
233
|
+
interface Row {
|
|
234
|
+
key: string;
|
|
235
|
+
record: Record<string, unknown>;
|
|
236
|
+
updated_at: Date;
|
|
237
|
+
updated_by: string;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** A `list` input as its schema parses it. */
|
|
241
|
+
interface Listing {
|
|
242
|
+
where?: Record<string, unknown>;
|
|
243
|
+
order?: string;
|
|
244
|
+
limit: number;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The collections' driver: their records in `DATA_TABLE` of the database `store` is on, through
|
|
249
|
+
* the pool a run's calls share, or one connection in a transaction (a direct write, with its
|
|
250
|
+
* ledger record). Every value reaches Postgres as a parameter, field names included; the field
|
|
251
|
+
* names and operators are the ones the input's schema accepts.
|
|
252
|
+
*/
|
|
253
|
+
export function collectionsDriver(list: readonly Collection[], store: { db(): Promise<Db> }): Driver {
|
|
254
|
+
const query = async (sql: string, params: unknown[]) => (await store.db()).query<Row>(sql, params);
|
|
255
|
+
const impl = list.map((c) => {
|
|
256
|
+
const saved = (row: Row) =>
|
|
257
|
+
answer(c, {
|
|
258
|
+
key: row.key,
|
|
259
|
+
record: row.record,
|
|
260
|
+
updatedAt: row.updated_at.toISOString(),
|
|
261
|
+
updatedBy: row.updated_by,
|
|
262
|
+
});
|
|
263
|
+
const ops: Record<string, (input: any, call: CallContext) => Promise<unknown>> = {
|
|
264
|
+
get: async ({ key }: { key: string }) => {
|
|
265
|
+
const sql = `SELECT ${COLUMNS} FROM ${DATA_TABLE} WHERE collection = $1 AND key = $2`;
|
|
266
|
+
const [row] = (await query(sql, [c.name, key])).rows;
|
|
267
|
+
return row ? saved(row) : null;
|
|
268
|
+
},
|
|
269
|
+
list: async (listing: Listing) => (await query(...listSql(c, listing))).rows.map(saved),
|
|
270
|
+
save: async ({ record }: { record: Record<string, unknown> }, call) => {
|
|
271
|
+
const sql =
|
|
272
|
+
`INSERT INTO ${DATA_TABLE} (collection, key, record, updated_by) VALUES ($1, $2, $3, $4) ` +
|
|
273
|
+
"ON CONFLICT (collection, key) DO UPDATE SET record = EXCLUDED.record, updated_at = now(), " +
|
|
274
|
+
`updated_by = EXCLUDED.updated_by RETURNING ${COLUMNS}`;
|
|
275
|
+
const params = [c.name, record[c.key], JSON.stringify(record), updatedBy(call)];
|
|
276
|
+
return saved((await query(sql, params)).rows[0]!);
|
|
277
|
+
},
|
|
278
|
+
delete: async ({ key }: { key: string }) => {
|
|
279
|
+
const sql = `DELETE FROM ${DATA_TABLE} WHERE collection = $1 AND key = $2`;
|
|
280
|
+
return { deleted: ((await query(sql, [c.name, key])).rowCount ?? 0) > 0 };
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
return [c.name, ops];
|
|
284
|
+
});
|
|
285
|
+
return defineDriver(collectionsConnector(list), Object.fromEntries(impl) as DriverImpl<DataOps>);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** What a field's values are in JSON: `number`, `boolean`, or a `string` (text, a date, a person, an enum value). */
|
|
289
|
+
type JsonType = "string" | "number" | "boolean";
|
|
290
|
+
|
|
291
|
+
/** Each field's `JsonType`, by name: what `list` compares its values as. */
|
|
292
|
+
const jsonTypesOf = (c: Collection): Record<string, JsonType> =>
|
|
293
|
+
Object.fromEntries(
|
|
294
|
+
Object.entries(c.fields).map(([name, kind]) => {
|
|
295
|
+
const type = typeof kind === "string" ? kind : "type" in kind ? kind.type : "text";
|
|
296
|
+
return [name, type === "number" || type === "boolean" ? type : "string"];
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const SQL_TYPE = { string: "text", number: "numeric", boolean: "boolean" } as const;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The `list` query on Postgres, and its parameters. A field's value is compared as its declared
|
|
304
|
+
* type, text byte by byte (`COLLATE "C"`, whatever the database's collation, as the fake
|
|
305
|
+
* compares it); a missing value, or one of another type, is SQL's null: it matches no filter and
|
|
306
|
+
* sorts last.
|
|
307
|
+
*/
|
|
308
|
+
function listSql(c: Collection, { where = {}, order, limit }: Listing): [string, unknown[]] {
|
|
309
|
+
const types = jsonTypesOf(c);
|
|
310
|
+
const params: unknown[] = [c.name];
|
|
311
|
+
const param = (value: unknown) => `$${params.push(value)}`;
|
|
312
|
+
const field = (name: string) => {
|
|
313
|
+
const type = types[name]!;
|
|
314
|
+
const json = `record -> ${param(name)}::text`;
|
|
315
|
+
const typed = `CASE WHEN jsonb_typeof(${json}) = '${type}' THEN (${json} #>> '{}')::${SQL_TYPE[type]} END`;
|
|
316
|
+
return type === "string" ? `(${typed}) COLLATE "C"` : typed;
|
|
317
|
+
};
|
|
318
|
+
const value = (name: string, v: unknown) => `${param(v)}::${SQL_TYPE[types[name]!]}`;
|
|
319
|
+
const conditions = ["collection = $1"];
|
|
320
|
+
for (const [name, condition] of Object.entries(where)) {
|
|
321
|
+
if (condition === undefined) continue;
|
|
322
|
+
if (!isObject(condition)) conditions.push(`${field(name)} = ${value(name, condition)}`);
|
|
323
|
+
else {
|
|
324
|
+
for (const [op, v] of Object.entries(condition)) {
|
|
325
|
+
const sign = RANGE[op as keyof typeof RANGE];
|
|
326
|
+
if (v !== undefined) conditions.push(`${field(name)} ${sign} ${value(name, v)}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const byKey = `key COLLATE "C"`;
|
|
331
|
+
const by =
|
|
332
|
+
order === undefined
|
|
333
|
+
? byKey
|
|
334
|
+
: `${field(order.replace(/^-/, ""))} ${order.startsWith("-") ? "DESC" : "ASC"} NULLS LAST, ${byKey}`;
|
|
335
|
+
const sql = `SELECT ${COLUMNS} FROM ${DATA_TABLE} WHERE ${conditions.join(" AND ")} ORDER BY ${by} LIMIT ${param(limit)}`;
|
|
336
|
+
return [sql, params];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Orders two values of one type as Postgres does under `COLLATE "C"`: strings by their UTF-8 bytes. */
|
|
340
|
+
const compare = (a: unknown, b: unknown): number =>
|
|
341
|
+
typeof a === "string" && typeof b === "string"
|
|
342
|
+
? Buffer.compare(Buffer.from(a), Buffer.from(b))
|
|
343
|
+
: (a as number) < (b as number)
|
|
344
|
+
? -1
|
|
345
|
+
: (a as number) > (b as number)
|
|
346
|
+
? 1
|
|
347
|
+
: 0;
|
|
348
|
+
|
|
349
|
+
/** The record's value of a field when it is of the field's type; none otherwise, as for a missing one. */
|
|
350
|
+
const valueIn = (record: Record<string, unknown>, name: string, type: JsonType | undefined) =>
|
|
351
|
+
typeof record[name] === type ? record[name] : undefined;
|
|
352
|
+
|
|
353
|
+
/** Whether the record meets each condition of `where`, as the driver's SQL decides it. */
|
|
354
|
+
function matches(record: Record<string, unknown>, where: Record<string, unknown>, types: Record<string, JsonType>) {
|
|
355
|
+
return Object.entries(where).every(([name, condition]) => {
|
|
356
|
+
if (condition === undefined) return true;
|
|
357
|
+
const value = valueIn(record, name, types[name]);
|
|
358
|
+
if (value === undefined) return false;
|
|
359
|
+
if (!isObject(condition)) return compare(value, condition) === 0;
|
|
360
|
+
const { gt, gte, lt, lte } = condition;
|
|
361
|
+
return (
|
|
362
|
+
(gt === undefined || compare(value, gt) > 0) &&
|
|
363
|
+
(gte === undefined || compare(value, gte) >= 0) &&
|
|
364
|
+
(lt === undefined || compare(value, lt) < 0) &&
|
|
365
|
+
(lte === undefined || compare(value, lte) <= 0)
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const byKey = (a: Stored, b: Stored) => compare(a.key, b.key);
|
|
371
|
+
|
|
372
|
+
/** Sorts as the driver's SQL does: by the field (missing last), then by key; by key alone without one. */
|
|
373
|
+
function byOrder(order: string | undefined, types: Record<string, JsonType>): (a: Stored, b: Stored) => number {
|
|
374
|
+
if (order === undefined) return byKey;
|
|
375
|
+
const name = order.replace(/^-/, "");
|
|
376
|
+
const sign = order.startsWith("-") ? -1 : 1;
|
|
377
|
+
return (a, b) => {
|
|
378
|
+
const x = valueIn(a.record, name, types[name]);
|
|
379
|
+
const y = valueIn(b.record, name, types[name]);
|
|
380
|
+
if ((x === undefined) !== (y === undefined)) return x === undefined ? 1 : -1;
|
|
381
|
+
return (x === undefined ? 0 : sign * compare(x, y)) || byKey(a, b);
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** The collections' records in memory, by collection and key: what a sandbox run's fake holds. */
|
|
386
|
+
export type CollectionsState = Record<string, Record<string, Stored>>;
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* An in-memory fake of the collections' driver, which starts empty: a scenario seeds it
|
|
390
|
+
* (`Given data.<name> "<key>" has`), and a test may through `update`.
|
|
391
|
+
*/
|
|
392
|
+
export function collectionsFake(list: readonly Collection[]): Fake<CollectionsState> {
|
|
393
|
+
return defineFake(collectionsConnector(list), {
|
|
394
|
+
// Null-prototype records, so any key is a record's own, `__proto__` too, as it is in Postgres.
|
|
395
|
+
initial: (): CollectionsState => Object.fromEntries(list.map((c) => [c.name, Object.create(null)])),
|
|
396
|
+
ops: (state) =>
|
|
397
|
+
Object.fromEntries(
|
|
398
|
+
list.map((c) => {
|
|
399
|
+
const records = () => state[c.name]!;
|
|
400
|
+
const types = jsonTypesOf(c);
|
|
401
|
+
const saved = (stored: Stored) => answer(c, structuredClone(stored));
|
|
402
|
+
const ops: Record<string, (input: any, call: CallContext) => Promise<unknown>> = {
|
|
403
|
+
get: async ({ key }: { key: string }) => (Object.hasOwn(records(), key) ? saved(records()[key]!) : null),
|
|
404
|
+
list: async ({ where = {}, order, limit }: Listing) =>
|
|
405
|
+
Object.values(records())
|
|
406
|
+
.filter((s) => matches(s.record, where, types))
|
|
407
|
+
.toSorted(byOrder(order, types))
|
|
408
|
+
.slice(0, limit)
|
|
409
|
+
.map(saved),
|
|
410
|
+
save: async ({ record }: { record: Record<string, unknown> }, call) => {
|
|
411
|
+
const key = record[c.key] as string;
|
|
412
|
+
const stored = {
|
|
413
|
+
key,
|
|
414
|
+
record: structuredClone(record),
|
|
415
|
+
updatedAt: new Date().toISOString(),
|
|
416
|
+
updatedBy: updatedBy(call),
|
|
417
|
+
};
|
|
418
|
+
records()[key] = stored;
|
|
419
|
+
return saved(stored);
|
|
420
|
+
},
|
|
421
|
+
delete: async ({ key }: { key: string }) => {
|
|
422
|
+
const had = Object.hasOwn(records(), key);
|
|
423
|
+
delete records()[key];
|
|
424
|
+
return { deleted: had };
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
return [c.name, ops];
|
|
428
|
+
}),
|
|
429
|
+
) as DriverImpl<DataOps>,
|
|
430
|
+
});
|
|
431
|
+
}
|