Skip to content

Commit 8d416c0

Browse files
committed
Merge branch 'dev' into feat/policy-custom-error-codes
2 parents 3b0e78f + 679f91f commit 8d416c0

34 files changed

Lines changed: 2936 additions & 667 deletions

packages/clients/client-helpers/src/invalidation.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ export function createInvalidator(
3030
invalidator: InvalidateFunc,
3131
logging: Logger | undefined,
3232
) {
33+
const normalizedModel = normalizeModelName(model, schema);
3334
return async (...args: unknown[]) => {
3435
const [_, variables] = args;
3536
const predicate = await getInvalidationPredicate(
36-
model,
37+
normalizedModel,
3738
operation as ORMWriteActionType,
3839
variables,
3940
schema,
@@ -87,3 +88,9 @@ function findNestedRead(visitingModel: string, targetModels: string[], schema: S
8788
const modelsRead = getReadModels(visitingModel, schema, args);
8889
return targetModels.some((m) => modelsRead.includes(m));
8990
}
91+
92+
// resolves a model name to its canonical form as defined in the schema (case-insensitive match)
93+
function normalizeModelName(model: string, schema: SchemaDef) {
94+
const target = model.toLowerCase();
95+
return Object.keys(schema.models).find((k) => k.toLowerCase() === target) ?? model;
96+
}

packages/clients/client-helpers/src/nested-write-visitor.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,10 +297,6 @@ export class NestedWriteVisitor {
297297
}
298298
}
299299
break;
300-
301-
default: {
302-
throw new Error(`unhandled action type ${action}`);
303-
}
304300
}
305301
}
306302

packages/clients/client-helpers/test/nested-write-visitor.test.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,25 +1097,6 @@ describe('NestedWriteVisitor tests', () => {
10971097
}),
10981098
).resolves.not.toThrow();
10991099
});
1100-
1101-
it('throws error for unhandled action type', async () => {
1102-
const schema = createSchema({
1103-
User: {
1104-
name: 'User',
1105-
fields: {
1106-
id: createField('id', 'String'),
1107-
},
1108-
uniqueFields: {},
1109-
idFields: ['id'],
1110-
},
1111-
});
1112-
1113-
const visitor = new NestedWriteVisitor(schema, {});
1114-
1115-
await expect(visitor.visit('User', 'invalidAction' as any, { data: {} })).rejects.toThrow(
1116-
'unhandled action type',
1117-
);
1118-
});
11191100
});
11201101

11211102
describe('complex real-world scenarios', () => {

packages/clients/tanstack-query/src/common/client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import type { QueryClient } from '@tanstack/query-core';
22
import type { InvalidationPredicate, QueryInfo } from '@zenstackhq/client-helpers';
33
import { parseQueryKey } from './query-key.js';
44

5+
/** Strips a trailing slash from an endpoint URL. */
6+
export function normalizeEndpoint(endpoint: string) {
7+
return endpoint.replace(/\/$/, '');
8+
}
9+
510
export function invalidateQueriesMatchingPredicate(queryClient: QueryClient, predicate: InvalidationPredicate) {
611
return queryClient.invalidateQueries({
712
predicate: ({ queryKey }) => {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1+
/** Route segment for custom procedures. */
12
export const CUSTOM_PROC_ROUTE_NAME = '$procs';
3+
4+
/** Route prefix for transaction endpoints. */
5+
export const TRANSACTION_ROUTE_PREFIX = '$transaction';
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { Logger } from '@zenstackhq/client-helpers';
2+
import { createInvalidator, type InvalidateFunc } from '@zenstackhq/client-helpers';
3+
import type { FetchFn } from '@zenstackhq/client-helpers/fetch';
4+
import { fetcher, marshal } from '@zenstackhq/client-helpers/fetch';
5+
import { CoreReadOperations } from '@zenstackhq/orm';
6+
import type { SchemaDef } from '@zenstackhq/schema';
7+
import { TRANSACTION_ROUTE_PREFIX } from './constants.js';
8+
import type { TransactionOperation } from './types.js';
9+
10+
/**
11+
* Builds the mutation function for a sequential transaction request.
12+
*/
13+
export function makeTransactionMutationFn<Schema extends SchemaDef>(endpoint: string, fetch: FetchFn | undefined) {
14+
return (operations: TransactionOperation<Schema>[]) => {
15+
const reqUrl = `${endpoint}/${TRANSACTION_ROUTE_PREFIX}/sequential`;
16+
const fetchInit = {
17+
method: 'POST',
18+
headers: { 'content-type': 'application/json' },
19+
body: marshal(operations),
20+
};
21+
return fetcher<unknown[]>(reqUrl, fetchInit, fetch);
22+
};
23+
}
24+
25+
/**
26+
* Builds the `onSuccess` handler for a sequential transaction mutation that invalidates
27+
* all queries affected by the operations in the transaction.
28+
*
29+
* @param schema The schema definition.
30+
* @param invalidateFunc Function that invalidates queries matching a predicate.
31+
* @param logging Logging option.
32+
* @param origOnSuccess The user-provided `onSuccess` callback to call after invalidation.
33+
*/
34+
export function makeTransactionOnSuccess(
35+
schema: SchemaDef,
36+
invalidateFunc: InvalidateFunc,
37+
logging: Logger | undefined,
38+
origOnSuccess: ((...args: any[]) => any) | undefined,
39+
) {
40+
return async (...args: any[]) => {
41+
const variables = Array.isArray(args[1]) ? args[1] : [];
42+
for (const op of variables) {
43+
if (typeof op?.model !== 'string' || typeof op?.op !== 'string') {
44+
continue;
45+
}
46+
// read-only ops don't mutate state, so they don't trigger invalidation
47+
if (CoreReadOperations.includes(op.op)) {
48+
continue;
49+
}
50+
const invalidator = createInvalidator(op.model, op.op, schema, invalidateFunc, logging);
51+
// pass op.args as mutation variables so the invalidator can analyze nested writes
52+
await invalidator(args[0], op.args, args[2]);
53+
}
54+
await origOnSuccess?.(...args);
55+
};
56+
}

packages/clients/tanstack-query/src/common/types.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
import type { Logger, OptimisticDataProvider } from '@zenstackhq/client-helpers';
22
import type { FetchFn } from '@zenstackhq/client-helpers/fetch';
33
import type {
4+
AggregateArgs,
5+
CountArgs,
6+
CreateArgs,
7+
CreateManyAndReturnArgs,
8+
CreateManyArgs,
9+
DeleteArgs,
10+
DeleteManyArgs,
11+
ExistsArgs,
12+
FindFirstArgs,
13+
FindManyArgs,
14+
FindUniqueArgs,
415
GetProcedureNames,
516
GetSlicedOperations,
17+
GroupByArgs,
618
ModelAllowsCreate,
719
OperationsRequiringCreate,
820
ProcedureFunc,
921
QueryOptions,
22+
UpdateArgs,
23+
UpdateManyAndReturnArgs,
24+
UpdateManyArgs,
25+
UpsertArgs,
1026
} from '@zenstackhq/orm';
1127
import type { GetModels, SchemaDef } from '@zenstackhq/schema';
1228

@@ -100,3 +116,48 @@ export type WithOptimistic<T> = T extends Array<infer U> ? Array<WithOptimisticF
100116
export type ProcedureReturn<Schema extends SchemaDef, Name extends GetProcedureNames<Schema>> = Awaited<
101117
ReturnType<ProcedureFunc<Schema, Name>>
102118
>;
119+
120+
/**
121+
* Maps each core CRUD operation to its argument type for a given model.
122+
*/
123+
type CrudArgsMap<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
124+
findMany: FindManyArgs<Schema, Model>;
125+
findUnique: FindUniqueArgs<Schema, Model>;
126+
findFirst: FindFirstArgs<Schema, Model>;
127+
create: CreateArgs<Schema, Model>;
128+
createMany: CreateManyArgs<Schema, Model>;
129+
createManyAndReturn: CreateManyAndReturnArgs<Schema, Model>;
130+
update: UpdateArgs<Schema, Model>;
131+
updateMany: UpdateManyArgs<Schema, Model>;
132+
updateManyAndReturn: UpdateManyAndReturnArgs<Schema, Model>;
133+
upsert: UpsertArgs<Schema, Model>;
134+
delete: DeleteArgs<Schema, Model>;
135+
deleteMany: DeleteManyArgs<Schema, Model>;
136+
count: CountArgs<Schema, Model>;
137+
aggregate: AggregateArgs<Schema, Model>;
138+
groupBy: GroupByArgs<Schema, Model>;
139+
exists: ExistsArgs<Schema, Model>;
140+
};
141+
142+
/**
143+
* Operations available for a given model, omitting create-style operations
144+
* for models that don't allow them (e.g. delegate models).
145+
*/
146+
type AllowedTransactionOps<Schema extends SchemaDef, Model extends GetModels<Schema>> =
147+
ModelAllowsCreate<Schema, Model> extends true
148+
? keyof CrudArgsMap<Schema, Model>
149+
: Exclude<keyof CrudArgsMap<Schema, Model>, OperationsRequiringCreate>;
150+
151+
/**
152+
* Represents a single operation to execute within a sequential transaction.
153+
*
154+
* The `model`, `op`, and `args` fields are correlated: `op` is constrained to
155+
* the CRUD operations available on `model`, and `args` is typed accordingly.
156+
*/
157+
export type TransactionOperation<Schema extends SchemaDef> = {
158+
[Model in GetModels<Schema>]: {
159+
[Op in AllowedTransactionOps<Schema, Model>]: {} extends CrudArgsMap<Schema, Model>[Op]
160+
? { model: Model; op: Op; args?: CrudArgsMap<Schema, Model>[Op] }
161+
: { model: Model; op: Op; args: CrudArgsMap<Schema, Model>[Op] };
162+
}[AllowedTransactionOps<Schema, Model>];
163+
}[GetModels<Schema>];

packages/clients/tanstack-query/src/react.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,16 @@ import type {
6565
} from '@zenstackhq/orm';
6666
import type { GetModels, SchemaDef } from '@zenstackhq/schema';
6767
import { createContext, useContext } from 'react';
68-
import { getAllQueries, invalidateQueriesMatchingPredicate } from './common/client.js';
68+
import { getAllQueries, invalidateQueriesMatchingPredicate, normalizeEndpoint } from './common/client.js';
6969
import { CUSTOM_PROC_ROUTE_NAME } from './common/constants.js';
7070
import { getQueryKey } from './common/query-key.js';
71+
import { makeTransactionMutationFn, makeTransactionOnSuccess } from './common/transaction.js';
7172
import type {
7273
ExtraMutationOptions,
7374
ExtraQueryOptions,
7475
ProcedureReturn,
7576
QueryContext,
77+
TransactionOperation,
7678
TrimSlicedOperations,
7779
WithOptimistic,
7880
} from './common/types.js';
@@ -165,6 +167,12 @@ export type ModelMutationModelResult<
165167
): Promise<SimplifiedResult<Schema, Model, T, Options, false, Array, ExtResult>>;
166168
};
167169

170+
export type TransactionMutationOptions<Schema extends SchemaDef> = Omit<
171+
UseMutationOptions<unknown[], DefaultError, TransactionOperation<Schema>[]>,
172+
'mutationFn'
173+
> &
174+
Omit<ExtraMutationOptions, 'optimisticUpdate' | 'optimisticDataProvider'>;
175+
168176
export type ClientHooks<
169177
Schema extends SchemaDef,
170178
Options extends QueryOptions<Schema> = QueryOptions<Schema>,
@@ -176,7 +184,13 @@ export type ClientHooks<
176184
Options,
177185
ExtResult
178186
>;
179-
} & ProcedureHooks<Schema, Options>;
187+
} & ProcedureHooks<Schema, Options> & {
188+
$transaction: {
189+
useSequential(
190+
options?: TransactionMutationOptions<Schema>,
191+
): UseMutationResult<unknown[], DefaultError, TransactionOperation<Schema>[]>;
192+
};
193+
};
180194

181195
type ProcedureHookGroup<Schema extends SchemaDef, Options extends QueryOptions<Schema>> = {
182196
[Name in GetSlicedProcedures<Schema, Options>]: GetProcedure<Schema, Name> extends { mutation: true }
@@ -448,6 +462,10 @@ export function useClientQueries<SchemaOrClient extends SchemaDef | ClientContra
448462
(result as any).$procs = buildProcedureHooks();
449463
}
450464

465+
(result as any).$transaction = {
466+
useSequential: (hookOptions?: any) => useInternalTransactionMutation(schema, { ...options, ...hookOptions }),
467+
};
468+
451469
return result;
452470
}
453471

@@ -789,11 +807,35 @@ export function useInternalMutation<TArgs, R = any>(
789807
return useMutation(finalOptions);
790808
}
791809

810+
export function useInternalTransactionMutation<Schema extends SchemaDef>(
811+
schema: Schema,
812+
options?: TransactionMutationOptions<Schema>,
813+
) {
814+
const { endpoint, fetch, logging } = useFetchOptions(options);
815+
const queryClient = useQueryClient();
816+
817+
const mutationFn = makeTransactionMutationFn<Schema>(endpoint, fetch);
818+
819+
const finalOptions = { ...options, mutationFn };
820+
821+
if (options?.invalidateQueries !== false) {
822+
const origOnSuccess = finalOptions.onSuccess;
823+
finalOptions.onSuccess = makeTransactionOnSuccess(
824+
schema,
825+
(predicate) => invalidateQueriesMatchingPredicate(queryClient, predicate),
826+
logging,
827+
origOnSuccess as any,
828+
);
829+
}
830+
831+
return useMutation(finalOptions);
832+
}
833+
792834
function useFetchOptions(options: QueryContext | undefined) {
793835
const { endpoint, fetch, logging } = useHooksContext();
794836
// options take precedence over context
795837
return {
796-
endpoint: options?.endpoint ?? endpoint,
838+
endpoint: normalizeEndpoint(options?.endpoint ?? endpoint),
797839
fetch: options?.fetch ?? fetch,
798840
logging: options?.logging ?? logging,
799841
};

0 commit comments

Comments
 (0)