Skip to content

Commit d4841a3

Browse files
authored
feat: sequential transcation support (#30)
* feat: sequential transcation support * fix comments
1 parent de8e516 commit d4841a3

File tree

3 files changed

+153
-1
lines changed

3 files changed

+153
-1
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@zenstackhq/proxy",
3-
"version": "0.3.0",
3+
"version": "0.4.0",
44
"description": "A CLI tool to run an Express server that proxies CRUD requests to a ZenStack backend",
55
"main": "index.js",
66
"publishConfig": {
@@ -40,6 +40,7 @@
4040
"express": "^4.19.2",
4141
"mixpanel": "^0.19.1",
4242
"semver": "^7.7.3",
43+
"superjson": "^2.2.6",
4344
"tsx": "^4.20.6",
4445
"uuid": "^13.0.0"
4546
},

pnpm-lock.yaml

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/server.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getNodeModulesFolder, getPrismaVersion, getZenStackVersion } from './ut
77
import { blue, grey, red } from 'colors'
88
import semver from 'semver'
99
import { CliError } from './cli-error'
10+
import SuperJSON from 'superjson'
1011

1112
export interface ServerOptions {
1213
zenstackPath: string | undefined
@@ -20,6 +21,25 @@ type EnhancementKind = 'password' | 'omit' | 'policy' | 'validation' | 'delegate
2021
// enable all enhancements except policy
2122
const Enhancements: EnhancementKind[] = ['password', 'omit', 'validation', 'delegate', 'encryption']
2223

24+
const VALID_OPS = new Set([
25+
'findMany',
26+
'findUnique',
27+
'findFirst',
28+
'create',
29+
'createMany',
30+
'createManyAndReturn',
31+
'update',
32+
'updateMany',
33+
'updateManyAndReturn',
34+
'upsert',
35+
'delete',
36+
'deleteMany',
37+
'count',
38+
'aggregate',
39+
'groupBy',
40+
'exists',
41+
])
42+
2343
/**
2444
* Resolve the absolute path to the Prisma schema directory
2545
*/
@@ -203,6 +223,96 @@ async function loadZenStackModules(
203223
return { PrismaClient, modelMeta, enums, zenstackVersion, enhanceFunc }
204224
}
205225

226+
function makeError(message: string, status = 400) {
227+
return { status, body: { error: message } }
228+
}
229+
230+
function lowerCaseFirst(input: string) {
231+
return input.charAt(0).toLowerCase() + input.slice(1)
232+
}
233+
234+
function isValidModel(modelMeta: any, modelName: string): boolean {
235+
return lowerCaseFirst(modelName) in modelMeta.models
236+
}
237+
238+
function processRequestPayload(args: any) {
239+
if (args === null || args === undefined) {
240+
return args
241+
}
242+
const { meta, ...rest } = args
243+
if (meta?.serialization) {
244+
// superjson deserialization
245+
return SuperJSON.deserialize({ json: rest, meta: meta.serialization })
246+
} else {
247+
return args
248+
}
249+
}
250+
251+
async function handleTransaction(modelMeta: any, client: any, requestBody: unknown) {
252+
const processedOps: Array<{ model: string; op: string; args: unknown }> = []
253+
if (!requestBody || !Array.isArray(requestBody) || requestBody.length === 0) {
254+
return makeError('request body must be a non-empty array of operations')
255+
}
256+
for (let i = 0; i < requestBody.length; i++) {
257+
const item = requestBody[i]
258+
if (!item || typeof item !== 'object') {
259+
return makeError(`operation at index ${i} must be an object`)
260+
}
261+
const { model: itemModel, op: itemOp, args: itemArgs } = item as any
262+
if (!itemModel || typeof itemModel !== 'string') {
263+
return makeError(`operation at index ${i} is missing a valid "model" field`)
264+
}
265+
if (!itemOp || typeof itemOp !== 'string') {
266+
return makeError(`operation at index ${i} is missing a valid "op" field`)
267+
}
268+
if (!VALID_OPS.has(itemOp)) {
269+
return makeError(`operation at index ${i} has invalid op: ${itemOp}`)
270+
}
271+
if (!isValidModel(modelMeta, itemModel)) {
272+
return makeError(`operation at index ${i} has unknown model: ${itemModel}`)
273+
}
274+
if (
275+
itemArgs !== undefined &&
276+
itemArgs !== null &&
277+
(typeof itemArgs !== 'object' || Array.isArray(itemArgs))
278+
) {
279+
return makeError(`operation at index ${i} has invalid "args" field`)
280+
}
281+
282+
processedOps.push({
283+
model: lowerCaseFirst(itemModel),
284+
op: itemOp,
285+
args: processRequestPayload(itemArgs),
286+
})
287+
}
288+
289+
try {
290+
const clientResult = await client.$transaction(async (tx: any) => {
291+
const result: any[] = []
292+
for (const { model, op, args } of processedOps) {
293+
result.push(await (tx as any)[model][op](args))
294+
}
295+
return result
296+
})
297+
298+
const { json, meta } = SuperJSON.serialize(clientResult)
299+
const responseBody: any = { data: json }
300+
if (meta) {
301+
responseBody.meta = { serialization: meta }
302+
}
303+
304+
const response = { status: 200, body: responseBody }
305+
306+
return response
307+
} catch (err) {
308+
console.error('error occurred when handling "$transaction" request:', err)
309+
return makeError(
310+
'Transaction failed: ' + (err instanceof Error ? err.message : String(err)),
311+
500
312+
)
313+
}
314+
}
315+
206316
/**
207317
* Start the Express server with ZenStack proxy
208318
*/
@@ -237,6 +347,22 @@ export async function startServer(options: ServerOptions) {
237347
app.use(express.urlencoded({ extended: true, limit: '5mb' }))
238348

239349
// ZenStack API endpoint
350+
351+
app.post('/api/model/\\$transaction/sequential', async (_req, res) => {
352+
const response = await handleTransaction(
353+
modelMeta,
354+
enhanceFunc(
355+
prisma,
356+
{},
357+
{
358+
kinds: Enhancements,
359+
}
360+
),
361+
_req.body
362+
)
363+
res.status(response.status).json(response.body)
364+
})
365+
240366
app.use(
241367
'/api/model',
242368
ZenStackMiddleware({

0 commit comments

Comments
 (0)