@zenstackhq/client-helpers 3.1.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.
@@ -0,0 +1,359 @@
1
+ import { enumerate } from '@zenstackhq/common-helpers';
2
+ import type { FieldDef, SchemaDef } from '@zenstackhq/schema';
3
+ import { ORMWriteActions, type MaybePromise, type ORMWriteActionType } from './types';
4
+
5
+ type NestingPathItem = { field?: FieldDef; model: string; where: any; unique: boolean };
6
+
7
+ /**
8
+ * Context for visiting
9
+ */
10
+ export type NestedWriteVisitorContext = {
11
+ /**
12
+ * Parent data, can be used to replace fields
13
+ */
14
+ parent: any;
15
+
16
+ /**
17
+ * Current field, undefined if toplevel
18
+ */
19
+ field?: FieldDef;
20
+
21
+ /**
22
+ * A top-down path of all nested update conditions and corresponding field till now
23
+ */
24
+ nestingPath: NestingPathItem[];
25
+ };
26
+
27
+ /**
28
+ * NestedWriteVisitor's callback actions. A call back function should return true or void to indicate
29
+ * that the visitor should continue traversing its children, or false to stop. It can also return an object
30
+ * to let the visitor traverse it instead of its original children.
31
+ */
32
+ export type NestedWriteVisitorCallback = {
33
+ create?: (model: string, data: any, context: NestedWriteVisitorContext) => MaybePromise<boolean | object | void>;
34
+
35
+ createMany?: (
36
+ model: string,
37
+ args: { data: any; skipDuplicates?: boolean },
38
+ context: NestedWriteVisitorContext,
39
+ ) => MaybePromise<boolean | object | void>;
40
+
41
+ connectOrCreate?: (
42
+ model: string,
43
+ args: { where: object; create: any },
44
+ context: NestedWriteVisitorContext,
45
+ ) => MaybePromise<boolean | object | void>;
46
+
47
+ connect?: (
48
+ model: string,
49
+ args: object,
50
+ context: NestedWriteVisitorContext,
51
+ ) => MaybePromise<boolean | object | void>;
52
+
53
+ disconnect?: (
54
+ model: string,
55
+ args: object,
56
+ context: NestedWriteVisitorContext,
57
+ ) => MaybePromise<boolean | object | void>;
58
+
59
+ set?: (model: string, args: object, context: NestedWriteVisitorContext) => MaybePromise<boolean | object | void>;
60
+
61
+ update?: (model: string, args: object, context: NestedWriteVisitorContext) => MaybePromise<boolean | object | void>;
62
+
63
+ updateMany?: (
64
+ model: string,
65
+ args: { where?: object; data: any },
66
+ context: NestedWriteVisitorContext,
67
+ ) => MaybePromise<boolean | object | void>;
68
+
69
+ upsert?: (
70
+ model: string,
71
+ args: { where: object; create: any; update: any },
72
+ context: NestedWriteVisitorContext,
73
+ ) => MaybePromise<boolean | object | void>;
74
+
75
+ delete?: (
76
+ model: string,
77
+ args: object | boolean,
78
+ context: NestedWriteVisitorContext,
79
+ ) => MaybePromise<boolean | object | void>;
80
+
81
+ deleteMany?: (
82
+ model: string,
83
+ args: any | object,
84
+ context: NestedWriteVisitorContext,
85
+ ) => MaybePromise<boolean | object | void>;
86
+
87
+ field?: (
88
+ field: FieldDef,
89
+ action: ORMWriteActionType,
90
+ data: any,
91
+ context: NestedWriteVisitorContext,
92
+ ) => MaybePromise<void>;
93
+ };
94
+
95
+ /**
96
+ * Recursive visitor for nested write (create/update) payload.
97
+ */
98
+ export class NestedWriteVisitor {
99
+ constructor(
100
+ private readonly schema: SchemaDef,
101
+ private readonly callback: NestedWriteVisitorCallback,
102
+ ) {}
103
+
104
+ private isWriteAction(value: string): value is ORMWriteActionType {
105
+ return ORMWriteActions.includes(value as ORMWriteActionType);
106
+ }
107
+
108
+ /**
109
+ * Start visiting
110
+ *
111
+ * @see NestedWriteVisitorCallback
112
+ */
113
+ async visit(model: string, action: ORMWriteActionType, args: any): Promise<void> {
114
+ if (!args) {
115
+ return;
116
+ }
117
+
118
+ let topData = args;
119
+
120
+ switch (action) {
121
+ // create has its data wrapped in 'data' field
122
+ case 'create':
123
+ topData = topData.data;
124
+ break;
125
+
126
+ case 'delete':
127
+ case 'deleteMany':
128
+ topData = topData.where;
129
+ break;
130
+ }
131
+
132
+ await this.doVisit(model, action, topData, undefined, undefined, []);
133
+ }
134
+
135
+ private async doVisit(
136
+ model: string,
137
+ action: ORMWriteActionType,
138
+ data: any,
139
+ parent: any,
140
+ field: FieldDef | undefined,
141
+ nestingPath: NestingPathItem[],
142
+ ): Promise<void> {
143
+ if (!data) {
144
+ return;
145
+ }
146
+
147
+ const toplevel = field == undefined;
148
+
149
+ const context = { parent, field, nestingPath: [...nestingPath] };
150
+ const pushNewContext = (field: FieldDef | undefined, model: string, where: any, unique = false) => {
151
+ return { ...context, nestingPath: [...context.nestingPath, { field, model, where, unique }] };
152
+ };
153
+
154
+ // visit payload
155
+ switch (action) {
156
+ case 'create':
157
+ for (const item of this.enumerateReverse(data)) {
158
+ const newContext = pushNewContext(field, model, {});
159
+ let callbackResult: any;
160
+ if (this.callback.create) {
161
+ callbackResult = await this.callback.create(model, item, newContext);
162
+ }
163
+ if (callbackResult !== false) {
164
+ const subPayload = typeof callbackResult === 'object' ? callbackResult : item;
165
+ await this.visitSubPayload(model, action, subPayload, newContext.nestingPath);
166
+ }
167
+ }
168
+ break;
169
+
170
+ case 'createMany':
171
+ case 'createManyAndReturn':
172
+ {
173
+ const newContext = pushNewContext(field, model, {});
174
+ let callbackResult: any;
175
+ if (this.callback.createMany) {
176
+ callbackResult = await this.callback.createMany(model, data, newContext);
177
+ }
178
+ if (callbackResult !== false) {
179
+ const subPayload = typeof callbackResult === 'object' ? callbackResult : data.data;
180
+ await this.visitSubPayload(model, action, subPayload, newContext.nestingPath);
181
+ }
182
+ }
183
+ break;
184
+
185
+ case 'connectOrCreate':
186
+ for (const item of this.enumerateReverse(data)) {
187
+ const newContext = pushNewContext(field, model, item.where);
188
+ let callbackResult: any;
189
+ if (this.callback.connectOrCreate) {
190
+ callbackResult = await this.callback.connectOrCreate(model, item, newContext);
191
+ }
192
+ if (callbackResult !== false) {
193
+ const subPayload = typeof callbackResult === 'object' ? callbackResult : item.create;
194
+ await this.visitSubPayload(model, action, subPayload, newContext.nestingPath);
195
+ }
196
+ }
197
+ break;
198
+
199
+ case 'connect':
200
+ if (this.callback.connect) {
201
+ for (const item of this.enumerateReverse(data)) {
202
+ const newContext = pushNewContext(field, model, item, true);
203
+ await this.callback.connect(model, item, newContext);
204
+ }
205
+ }
206
+ break;
207
+
208
+ case 'disconnect':
209
+ // disconnect has two forms:
210
+ // if relation is to-many, the payload is a unique filter object
211
+ // if relation is to-one, the payload can only be boolean `true`
212
+ if (this.callback.disconnect) {
213
+ for (const item of this.enumerateReverse(data)) {
214
+ const newContext = pushNewContext(field, model, item, typeof item === 'object');
215
+ await this.callback.disconnect(model, item, newContext);
216
+ }
217
+ }
218
+ break;
219
+
220
+ case 'set':
221
+ if (this.callback.set) {
222
+ for (const item of this.enumerateReverse(data)) {
223
+ const newContext = pushNewContext(field, model, item, true);
224
+ await this.callback.set(model, item, newContext);
225
+ }
226
+ }
227
+ break;
228
+
229
+ case 'update':
230
+ for (const item of this.enumerateReverse(data)) {
231
+ const newContext = pushNewContext(field, model, item.where);
232
+ let callbackResult: any;
233
+ if (this.callback.update) {
234
+ callbackResult = await this.callback.update(model, item, newContext);
235
+ }
236
+ if (callbackResult !== false) {
237
+ const subPayload =
238
+ typeof callbackResult === 'object'
239
+ ? callbackResult
240
+ : typeof item.data === 'object'
241
+ ? item.data
242
+ : item;
243
+ await this.visitSubPayload(model, action, subPayload, newContext.nestingPath);
244
+ }
245
+ }
246
+ break;
247
+
248
+ case 'updateMany':
249
+ case 'updateManyAndReturn':
250
+ for (const item of this.enumerateReverse(data)) {
251
+ const newContext = pushNewContext(field, model, item.where);
252
+ let callbackResult: any;
253
+ if (this.callback.updateMany) {
254
+ callbackResult = await this.callback.updateMany(model, item, newContext);
255
+ }
256
+ if (callbackResult !== false) {
257
+ const subPayload = typeof callbackResult === 'object' ? callbackResult : item;
258
+ await this.visitSubPayload(model, action, subPayload, newContext.nestingPath);
259
+ }
260
+ }
261
+ break;
262
+
263
+ case 'upsert': {
264
+ for (const item of this.enumerateReverse(data)) {
265
+ const newContext = pushNewContext(field, model, item.where);
266
+ let callbackResult: any;
267
+ if (this.callback.upsert) {
268
+ callbackResult = await this.callback.upsert(model, item, newContext);
269
+ }
270
+ if (callbackResult !== false) {
271
+ if (typeof callbackResult === 'object') {
272
+ await this.visitSubPayload(model, action, callbackResult, newContext.nestingPath);
273
+ } else {
274
+ await this.visitSubPayload(model, action, item.create, newContext.nestingPath);
275
+ await this.visitSubPayload(model, action, item.update, newContext.nestingPath);
276
+ }
277
+ }
278
+ }
279
+ break;
280
+ }
281
+
282
+ case 'delete': {
283
+ if (this.callback.delete) {
284
+ for (const item of this.enumerateReverse(data)) {
285
+ const newContext = pushNewContext(field, model, toplevel ? item.where : item);
286
+ await this.callback.delete(model, item, newContext);
287
+ }
288
+ }
289
+ break;
290
+ }
291
+
292
+ case 'deleteMany':
293
+ if (this.callback.deleteMany) {
294
+ for (const item of this.enumerateReverse(data)) {
295
+ const newContext = pushNewContext(field, model, toplevel ? item.where : item);
296
+ await this.callback.deleteMany(model, item, newContext);
297
+ }
298
+ }
299
+ break;
300
+
301
+ default: {
302
+ throw new Error(`unhandled action type ${action}`);
303
+ }
304
+ }
305
+ }
306
+
307
+ private async visitSubPayload(
308
+ model: string,
309
+ action: ORMWriteActionType,
310
+ payload: any,
311
+ nestingPath: NestingPathItem[],
312
+ ) {
313
+ for (const item of enumerate(payload)) {
314
+ if (!item || typeof item !== 'object') {
315
+ continue;
316
+ }
317
+ for (const field of Object.keys(item)) {
318
+ const fieldDef = this.schema.models[model]?.fields[field];
319
+ if (!fieldDef) {
320
+ continue;
321
+ }
322
+
323
+ if (fieldDef.relation) {
324
+ if (item[field]) {
325
+ // recurse into nested payloads
326
+ for (const [subAction, subData] of Object.entries<any>(item[field])) {
327
+ if (this.isWriteAction(subAction) && subData) {
328
+ await this.doVisit(fieldDef.type, subAction, subData, item[field], fieldDef, [
329
+ ...nestingPath,
330
+ ]);
331
+ }
332
+ }
333
+ }
334
+ } else {
335
+ // visit plain field
336
+ if (this.callback.field) {
337
+ await this.callback.field(fieldDef, action, item[field], {
338
+ parent: item,
339
+ nestingPath,
340
+ field: fieldDef,
341
+ });
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ // enumerate a (possible) array in reverse order, so that the enumeration
349
+ // callback can safely delete the current item
350
+ private *enumerateReverse(data: any) {
351
+ if (Array.isArray(data)) {
352
+ for (let i = data.length - 1; i >= 0; i--) {
353
+ yield data[i];
354
+ }
355
+ } else {
356
+ yield data;
357
+ }
358
+ }
359
+ }
@@ -0,0 +1,139 @@
1
+ import type { SchemaDef } from '@zenstackhq/schema';
2
+ import { log, type Logger } from './logging';
3
+ import { applyMutation } from './mutator';
4
+ import type { ORMWriteActionType, QueryInfo } from './types';
5
+
6
+ /**
7
+ * Custom optimistic data provider. It takes query information (usually fetched from query cache)
8
+ * and returns a verdict on how to optimistically update the query data.
9
+ *
10
+ * @param args Arguments.
11
+ * @param args.queryModel The model of the query.
12
+ * @param args.queryOperation The operation of the query, `findMany`, `count`, etc.
13
+ * @param args.queryArgs The arguments of the query.
14
+ * @param args.currentData The current cache data for the query.
15
+ * @param args.mutationArgs The arguments of the mutation.
16
+ */
17
+ export type OptimisticDataProvider = (args: {
18
+ queryModel: string;
19
+ queryOperation: string;
20
+ queryArgs: any;
21
+ currentData: any;
22
+ mutationArgs: any;
23
+ }) => OptimisticDataProviderResult | Promise<OptimisticDataProviderResult>;
24
+
25
+ /**
26
+ * Result of optimistic data provider.
27
+ */
28
+ export type OptimisticDataProviderResult = {
29
+ /**
30
+ * Kind of the result.
31
+ * - Update: use the `data` field to update the query cache.
32
+ * - Skip: skip the optimistic update for this query.
33
+ * - ProceedDefault: proceed with the default optimistic update.
34
+ */
35
+ kind: 'Update' | 'Skip' | 'ProceedDefault';
36
+
37
+ /**
38
+ * Data to update the query cache. Only applicable if `kind` is 'Update'.
39
+ *
40
+ * If the data is an object with fields updated, it should have a `$optimistic`
41
+ * field set to `true`. If it's an array and an element object is created or updated,
42
+ * the element should have a `$optimistic` field set to `true`.
43
+ */
44
+ data?: any;
45
+ };
46
+
47
+ /**
48
+ * Options for optimistic update.
49
+ */
50
+ export type OptimisticUpdateOptions = {
51
+ /**
52
+ * A custom optimistic data provider.
53
+ */
54
+ optimisticDataProvider?: OptimisticDataProvider;
55
+ };
56
+
57
+ /**
58
+ * Creates a function that performs optimistic updates for queries potentially
59
+ * affected by the given mutation operation.
60
+ *
61
+ * @param model Model under mutation.
62
+ * @param operation Mutation operation (e.g, `update`).
63
+ * @param schema The schema.
64
+ * @param options Optimistic update options.
65
+ * @param getAllQueries Callback to get all cached queries.
66
+ * @param logging Logging option.
67
+ */
68
+ export function createOptimisticUpdater(
69
+ model: string,
70
+ operation: string,
71
+ schema: SchemaDef,
72
+ options: OptimisticUpdateOptions,
73
+ getAllQueries: () => readonly QueryInfo[],
74
+ logging: Logger | undefined,
75
+ ) {
76
+ return async (...args: unknown[]) => {
77
+ const [mutationArgs] = args;
78
+
79
+ for (const queryInfo of getAllQueries()) {
80
+ const logInfo = JSON.stringify({
81
+ model: queryInfo.model,
82
+ operation: queryInfo.operation,
83
+ args: queryInfo.args,
84
+ });
85
+
86
+ if (!queryInfo.optimisticUpdate) {
87
+ if (logging) {
88
+ log(logging, `Skipping optimistic update for ${logInfo} due to opt-out`);
89
+ }
90
+ continue;
91
+ }
92
+
93
+ if (options.optimisticDataProvider) {
94
+ const providerResult = await options.optimisticDataProvider({
95
+ queryModel: queryInfo.model,
96
+ queryOperation: queryInfo.operation,
97
+ queryArgs: queryInfo.args,
98
+ currentData: queryInfo.data,
99
+ mutationArgs,
100
+ });
101
+
102
+ if (providerResult?.kind === 'Skip') {
103
+ // skip
104
+ if (logging) {
105
+ log(logging, `Skipping optimistic updating due to provider result: ${logInfo}`);
106
+ }
107
+ continue;
108
+ } else if (providerResult?.kind === 'Update') {
109
+ // update cache
110
+ if (logging) {
111
+ log(logging, `Optimistically updating due to provider result: ${logInfo}`);
112
+ }
113
+ queryInfo.updateData(providerResult.data, true);
114
+ continue;
115
+ }
116
+ }
117
+
118
+ // proceed with default optimistic update
119
+ const mutatedData = await applyMutation(
120
+ queryInfo.model,
121
+ queryInfo.operation,
122
+ queryInfo.data,
123
+ model,
124
+ operation as ORMWriteActionType,
125
+ mutationArgs,
126
+ schema,
127
+ logging,
128
+ );
129
+
130
+ if (mutatedData !== undefined) {
131
+ // mutation applicable to this query, update cache
132
+ if (logging) {
133
+ log(logging, `Optimistically updating due to mutation "${model}.${operation}": ${logInfo}`);
134
+ }
135
+ queryInfo.updateData(mutatedData, true);
136
+ }
137
+ }
138
+ };
139
+ }
@@ -0,0 +1,111 @@
1
+ import type { SchemaDef } from '@zenstackhq/schema';
2
+ import { NestedReadVisitor } from './nested-read-visitor';
3
+ import { NestedWriteVisitor } from './nested-write-visitor';
4
+ import type { ORMWriteActionType } from './types';
5
+
6
+ /**
7
+ * Gets models read (including nested ones) given a query args.
8
+ */
9
+ export function getReadModels(model: string, schema: SchemaDef, args: any) {
10
+ const result = new Set<string>();
11
+ result.add(model);
12
+ const visitor = new NestedReadVisitor(schema, {
13
+ field: (model) => {
14
+ result.add(model);
15
+ return true;
16
+ },
17
+ });
18
+ visitor.visit(model, args);
19
+ return [...result];
20
+ }
21
+
22
+ /**
23
+ * Gets mutated models (including nested ones) given a mutation args.
24
+ */
25
+ export async function getMutatedModels(
26
+ model: string,
27
+ operation: ORMWriteActionType,
28
+ mutationArgs: any,
29
+ schema: SchemaDef,
30
+ ) {
31
+ const result = new Set<string>();
32
+ result.add(model);
33
+
34
+ if (mutationArgs) {
35
+ const addModel = (model: string) => void result.add(model);
36
+
37
+ // add models that are cascaded deleted recursively
38
+ const addCascades = (model: string) => {
39
+ const cascades = new Set<string>();
40
+ const visited = new Set<string>();
41
+ collectDeleteCascades(model, schema, cascades, visited);
42
+ cascades.forEach((m) => addModel(m));
43
+ };
44
+
45
+ const visitor = new NestedWriteVisitor(schema, {
46
+ create: addModel,
47
+ createMany: addModel,
48
+ connectOrCreate: addModel,
49
+ connect: addModel,
50
+ disconnect: addModel,
51
+ set: addModel,
52
+ update: addModel,
53
+ updateMany: addModel,
54
+ upsert: addModel,
55
+ delete: (model) => {
56
+ addModel(model);
57
+ addCascades(model);
58
+ },
59
+ deleteMany: (model) => {
60
+ addModel(model);
61
+ addCascades(model);
62
+ },
63
+ });
64
+ await visitor.visit(model, operation, mutationArgs);
65
+ }
66
+
67
+ // include delegate base models recursively
68
+ result.forEach((m) => {
69
+ getBaseRecursively(m, schema, result);
70
+ });
71
+
72
+ return [...result];
73
+ }
74
+
75
+ function collectDeleteCascades(model: string, schema: SchemaDef, result: Set<string>, visited: Set<string>) {
76
+ if (visited.has(model)) {
77
+ // break circle
78
+ return;
79
+ }
80
+ visited.add(model);
81
+
82
+ const modelDef = schema.models[model];
83
+ if (!modelDef) {
84
+ return;
85
+ }
86
+
87
+ for (const [modelName, modelDef] of Object.entries(schema.models)) {
88
+ if (!modelDef) {
89
+ continue;
90
+ }
91
+ for (const fieldDef of Object.values(modelDef.fields)) {
92
+ if (fieldDef.relation?.onDelete === 'Cascade' && fieldDef.type === model) {
93
+ if (!result.has(modelName)) {
94
+ result.add(modelName);
95
+ }
96
+ collectDeleteCascades(modelName, schema, result, visited);
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ function getBaseRecursively(model: string, schema: SchemaDef, result: Set<string>) {
103
+ const modelDef = schema.models[model];
104
+ if (!modelDef) {
105
+ return;
106
+ }
107
+ if (modelDef.baseModel) {
108
+ result.add(modelDef.baseModel);
109
+ getBaseRecursively(modelDef.baseModel, schema, result);
110
+ }
111
+ }
package/src/types.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A type that represents either a value of type T or a Promise that resolves to type T.
3
+ */
4
+ export type MaybePromise<T> = T | Promise<T> | PromiseLike<T>;
5
+
6
+ /**
7
+ * List of ORM write actions.
8
+ */
9
+ export const ORMWriteActions = [
10
+ 'create',
11
+ 'createMany',
12
+ 'createManyAndReturn',
13
+ 'connectOrCreate',
14
+ 'update',
15
+ 'updateMany',
16
+ 'updateManyAndReturn',
17
+ 'upsert',
18
+ 'connect',
19
+ 'disconnect',
20
+ 'set',
21
+ 'delete',
22
+ 'deleteMany',
23
+ ] as const;
24
+
25
+ /**
26
+ * Type representing ORM write action types.
27
+ */
28
+ export type ORMWriteActionType = (typeof ORMWriteActions)[number];
29
+
30
+ /**
31
+ * Type for query and mutation errors.
32
+ */
33
+ export type QueryError = Error & {
34
+ /**
35
+ * Additional error information.
36
+ */
37
+ info?: unknown;
38
+
39
+ /**
40
+ * HTTP status code.
41
+ */
42
+ status?: number;
43
+ };
44
+
45
+ /**
46
+ * Information about a cached query.
47
+ */
48
+ export type QueryInfo = {
49
+ /**
50
+ * Model of the query.
51
+ */
52
+ model: string;
53
+
54
+ /**
55
+ * Query operation, e.g., `findUnique`
56
+ */
57
+ operation: string;
58
+
59
+ /**
60
+ * Query arguments.
61
+ */
62
+ args: unknown;
63
+
64
+ /**
65
+ * Current data cached for this query.
66
+ */
67
+ data: unknown;
68
+
69
+ /**
70
+ * Whether optimistic update is enabled for this query.
71
+ */
72
+ optimisticUpdate: boolean;
73
+
74
+ /**
75
+ * Function to update the cached data.
76
+ *
77
+ * @param data New data to set.
78
+ * @param cancelOnTheFlyQueries Whether to cancel on-the-fly queries to avoid accidentally
79
+ * overwriting the optimistic update.
80
+ */
81
+ updateData: (data: unknown, cancelOnTheFlyQueries: boolean) => void;
82
+ };