on-zero 0.16.16 → 0.16.17
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/dist/cjs/createZeroClient.httpAuth.test.cjs +5 -3
- package/dist/cjs/createZeroClient.httpAuth.test.native.js +3 -3
- package/dist/cjs/createZeroClient.httpAuth.test.native.js.map +1 -1
- package/dist/cjs/generate-helpers.cjs +21 -14
- package/dist/cjs/generate-helpers.native.js +50 -27
- package/dist/cjs/generate-helpers.native.js.map +1 -1
- package/dist/cjs/generate-helpers.test.cjs +146 -0
- package/dist/cjs/generate-helpers.test.native.js +149 -0
- package/dist/cjs/generate-helpers.test.native.js.map +1 -1
- package/dist/cjs/generate-lite.cjs +3 -3
- package/dist/cjs/generate-lite.native.js +5 -3
- package/dist/cjs/generate-lite.native.js.map +1 -1
- package/dist/cjs/generate-lite.test.cjs +56 -0
- package/dist/cjs/generate-lite.test.native.js +60 -0
- package/dist/cjs/generate-lite.test.native.js.map +1 -1
- package/dist/cjs/generate.cjs +37 -26
- package/dist/cjs/generate.native.js +58 -26
- package/dist/cjs/generate.native.js.map +1 -1
- package/dist/cjs/generate.test.cjs +109 -0
- package/dist/cjs/generate.test.native.js +115 -0
- package/dist/cjs/generate.test.native.js.map +1 -1
- package/dist/cjs/mutations.cjs +22 -11
- package/dist/cjs/mutations.native.js +42 -10
- package/dist/cjs/mutations.native.js.map +1 -1
- package/dist/cjs/mutations.test.cjs +228 -0
- package/dist/cjs/mutations.test.native.js +259 -0
- package/dist/cjs/mutations.test.native.js.map +1 -1
- package/dist/esm/createZeroClient.httpAuth.test.mjs +5 -3
- package/dist/esm/createZeroClient.httpAuth.test.mjs.map +1 -1
- package/dist/esm/createZeroClient.httpAuth.test.native.js +3 -3
- package/dist/esm/createZeroClient.httpAuth.test.native.js.map +1 -1
- package/dist/esm/generate-helpers.mjs +21 -15
- package/dist/esm/generate-helpers.mjs.map +1 -1
- package/dist/esm/generate-helpers.native.js +50 -28
- package/dist/esm/generate-helpers.native.js.map +1 -1
- package/dist/esm/generate-helpers.test.mjs +122 -0
- package/dist/esm/generate-helpers.test.mjs.map +1 -1
- package/dist/esm/generate-helpers.test.native.js +125 -0
- package/dist/esm/generate-helpers.test.native.js.map +1 -1
- package/dist/esm/generate-lite.mjs +4 -4
- package/dist/esm/generate-lite.mjs.map +1 -1
- package/dist/esm/generate-lite.native.js +6 -4
- package/dist/esm/generate-lite.native.js.map +1 -1
- package/dist/esm/generate-lite.test.mjs +56 -0
- package/dist/esm/generate-lite.test.mjs.map +1 -1
- package/dist/esm/generate-lite.test.native.js +60 -0
- package/dist/esm/generate-lite.test.native.js.map +1 -1
- package/dist/esm/generate.mjs +38 -27
- package/dist/esm/generate.mjs.map +1 -1
- package/dist/esm/generate.native.js +59 -27
- package/dist/esm/generate.native.js.map +1 -1
- package/dist/esm/generate.test.mjs +86 -1
- package/dist/esm/generate.test.mjs.map +1 -1
- package/dist/esm/generate.test.native.js +92 -1
- package/dist/esm/generate.test.native.js.map +1 -1
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/mutations.mjs +22 -11
- package/dist/esm/mutations.mjs.map +1 -1
- package/dist/esm/mutations.native.js +42 -10
- package/dist/esm/mutations.native.js.map +1 -1
- package/dist/esm/mutations.test.mjs +229 -1
- package/dist/esm/mutations.test.mjs.map +1 -1
- package/dist/esm/mutations.test.native.js +260 -1
- package/dist/esm/mutations.test.native.js.map +1 -1
- package/package.json +2 -2
- package/src/createZeroClient.httpAuth.test.ts +4 -5
- package/src/generate-helpers.test.ts +124 -0
- package/src/generate-helpers.ts +43 -27
- package/src/generate-lite.test.ts +53 -0
- package/src/generate-lite.ts +16 -9
- package/src/generate.test.ts +92 -0
- package/src/generate.ts +60 -33
- package/src/mutations.test.ts +216 -1
- package/src/mutations.ts +39 -39
- package/types/generate-helpers.d.ts +3 -1
- package/types/generate-helpers.d.ts.map +1 -1
- package/types/generate-lite.d.ts +1 -0
- package/types/generate-lite.d.ts.map +1 -1
- package/types/generate.d.ts.map +1 -1
- package/types/mutations.d.ts +5 -2
- package/types/mutations.d.ts.map +1 -1
package/src/generate-lite.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// contexts without pulling in ~10mb of typescript.
|
|
16
16
|
|
|
17
17
|
import {
|
|
18
|
+
CRUD_MUTATION_NAMES,
|
|
18
19
|
generateAggregatesFile,
|
|
19
20
|
generateGroupedQueriesFile,
|
|
20
21
|
generateInstancesFile,
|
|
@@ -38,7 +39,7 @@ export type LiteMutationExport = {
|
|
|
38
39
|
// this targets membership at the named table while the model file basename
|
|
39
40
|
// remains the top-level key in syncedMutations.
|
|
40
41
|
modelName: string
|
|
41
|
-
// handlers = keys of the
|
|
42
|
+
// handlers = keys of the handler argument, before any options argument
|
|
42
43
|
handlers: Array<{
|
|
43
44
|
// handler property name, e.g. 'toggleActive'
|
|
44
45
|
name: string
|
|
@@ -54,6 +55,12 @@ export type LiteMutationExport = {
|
|
|
54
55
|
// model doesn't declare a schema inline (most templates use drizzle-zero,
|
|
55
56
|
// so null is common).
|
|
56
57
|
schema: LiteSchemaInfo | null
|
|
58
|
+
// the `crud` field of the fourth options argument, i.e. false for
|
|
59
|
+
// `mutations(table, permissions, handlers, { crud: false })`. omitted or
|
|
60
|
+
// undefined means the default, which generates the full crud slot set.
|
|
61
|
+
// permissions stay registered either way, so this only controls the
|
|
62
|
+
// generated insert/update/delete/upsert validators.
|
|
63
|
+
crud?: boolean
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
export type LiteSchemaInfo = {
|
|
@@ -514,15 +521,15 @@ export function generateLite(opts: LiteGenerateOptions): LiteGenerateResult {
|
|
|
514
521
|
}
|
|
515
522
|
|
|
516
523
|
// a model participates in crud only when it has a schema AND its mutate
|
|
517
|
-
// call
|
|
518
|
-
//
|
|
519
|
-
//
|
|
524
|
+
// call did not opt out with `{ crud: false }`. in the lite ast, we don't
|
|
525
|
+
// know the call arity directly, so we use the presence of a schema as the
|
|
526
|
+
// signal for a table registration. this matches the real generator's
|
|
520
527
|
// behavior for schemas-with-mutate: hasCRUD is true whenever both exist.
|
|
521
528
|
//
|
|
522
529
|
// models without `export const mutate` still appear in the output as
|
|
523
530
|
// empty entries — see the test "treats models without export const mutate
|
|
524
531
|
// as empty mutations".
|
|
525
|
-
const hasCRUD = hasSchema && mutationExport !== null
|
|
532
|
+
const hasCRUD = hasSchema && mutationExport !== null && mutationExport.crud !== false
|
|
526
533
|
|
|
527
534
|
const custom = (mutationExport?.handlers ?? []).map((h) => {
|
|
528
535
|
// null or empty annotation → void (no second param) → v.void_()
|
|
@@ -821,13 +828,13 @@ export function generateLite(opts: LiteGenerateOptions): LiteGenerateResult {
|
|
|
821
828
|
)
|
|
822
829
|
}
|
|
823
830
|
|
|
824
|
-
// count mutations the same way `generate()` does:
|
|
825
|
-
// non-crud custom mutations (the crud-ops set is excluded when hasCRUD).
|
|
831
|
+
// count mutations the same way `generate()` does: one per generated crud slot
|
|
832
|
+
// plus non-crud custom mutations (the crud-ops set is excluded when hasCRUD).
|
|
826
833
|
let mutationCount = 0
|
|
827
834
|
for (const m of allModelMutations) {
|
|
828
|
-
if (m.hasCRUD) mutationCount +=
|
|
835
|
+
if (m.hasCRUD) mutationCount += CRUD_MUTATION_NAMES.length
|
|
829
836
|
mutationCount += m.custom.filter(
|
|
830
|
-
(mut) => !m.hasCRUD || !
|
|
837
|
+
(mut) => !m.hasCRUD || !CRUD_MUTATION_NAMES.some((name) => name === mut.name)
|
|
831
838
|
).length
|
|
832
839
|
}
|
|
833
840
|
|
package/src/generate.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { dirname, join } from 'node:path'
|
|
|
11
11
|
import { runInNewContext } from 'node:vm'
|
|
12
12
|
|
|
13
13
|
import * as zero from '@rocicorp/zero'
|
|
14
|
+
import * as v from 'valibot'
|
|
14
15
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
15
16
|
|
|
16
17
|
import {
|
|
@@ -85,6 +86,97 @@ describe('generate', () => {
|
|
|
85
86
|
|
|
86
87
|
expect(existsSync(syncedQueriesPath)).toBe(true)
|
|
87
88
|
})
|
|
89
|
+
|
|
90
|
+
test('generates every crud slot for a default table registration', async () => {
|
|
91
|
+
writeFileSync(
|
|
92
|
+
join(testDir, 'post/mutations.ts'),
|
|
93
|
+
`
|
|
94
|
+
export const schema = table('post').columns({ id: string(), title: string() }).primaryKey('id')
|
|
95
|
+
export const mutate = mutations(schema, permission, {
|
|
96
|
+
publish: async (ctx, args: { id: string }) => {},
|
|
97
|
+
})
|
|
98
|
+
`
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
await generate({ dir: testDir, silent: true })
|
|
102
|
+
const output = readFileSync(join(testDir, 'generated/syncedMutations.ts'), 'utf-8')
|
|
103
|
+
|
|
104
|
+
for (const slot of ['insert', 'update', 'delete', 'upsert']) {
|
|
105
|
+
expect(output).toContain(` ${slot}: v.object({`)
|
|
106
|
+
}
|
|
107
|
+
expect(output).toContain(' publish: v.object({')
|
|
108
|
+
|
|
109
|
+
// the emitted module has to evaluate and actually validate payloads
|
|
110
|
+
const context = { v, exports: {} as { mutationValidators?: any } }
|
|
111
|
+
runInNewContext(
|
|
112
|
+
output
|
|
113
|
+
.replace(/^import .*$/m, '')
|
|
114
|
+
.replace('export const', 'exports.mutationValidators ='),
|
|
115
|
+
context
|
|
116
|
+
)
|
|
117
|
+
const validators = context.exports.mutationValidators!.post
|
|
118
|
+
|
|
119
|
+
expect(v.parse(validators.upsert, { id: 'a', title: 'hello' })).toEqual({
|
|
120
|
+
id: 'a',
|
|
121
|
+
title: 'hello',
|
|
122
|
+
})
|
|
123
|
+
// upsert takes the insert shape, so a missing non-optional column is rejected
|
|
124
|
+
expect(() => v.parse(validators.upsert, { id: 'a' })).toThrow()
|
|
125
|
+
// update keeps the primary key required and every other column optional
|
|
126
|
+
expect(v.parse(validators.update, { id: 'a' })).toEqual({ id: 'a' })
|
|
127
|
+
expect(() => v.parse(validators.update, { title: 'hello' })).toThrow()
|
|
128
|
+
// delete is keyed by the primary key alone
|
|
129
|
+
expect(v.parse(validators.delete, { id: 'a' })).toEqual({ id: 'a' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('emits only authored handlers when a registration opts out with crud false', async () => {
|
|
133
|
+
writeFileSync(
|
|
134
|
+
join(testDir, 'post/mutations.ts'),
|
|
135
|
+
`
|
|
136
|
+
export const schema = table('post').columns({ id: string(), title: string() }).primaryKey('id')
|
|
137
|
+
export const mutate = mutations(
|
|
138
|
+
schema,
|
|
139
|
+
permission,
|
|
140
|
+
{
|
|
141
|
+
publish: async (ctx, args: { id: string }) => {},
|
|
142
|
+
},
|
|
143
|
+
{ crud: false }
|
|
144
|
+
)
|
|
145
|
+
`
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
await generate({ dir: testDir, silent: true })
|
|
149
|
+
const output = readFileSync(join(testDir, 'generated/syncedMutations.ts'), 'utf-8')
|
|
150
|
+
|
|
151
|
+
expect(output).toContain(' publish: v.object({')
|
|
152
|
+
for (const slot of ['insert', 'update', 'delete', 'upsert']) {
|
|
153
|
+
expect(output).not.toContain(` ${slot}:`)
|
|
154
|
+
}
|
|
155
|
+
// the options object must never be read as the handlers object
|
|
156
|
+
expect(output).not.toContain('crud:')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('reads handlers from the third argument, never the options or permissions argument', async () => {
|
|
160
|
+
writeFileSync(
|
|
161
|
+
join(testDir, 'post/mutations.ts'),
|
|
162
|
+
`
|
|
163
|
+
export const schema = table('post').columns({ id: string() }).primaryKey('id')
|
|
164
|
+
export const mutate = mutations(schema, permission, { publish: async (ctx, args: { id: string }) => {} }, { crud: true })
|
|
165
|
+
`
|
|
166
|
+
)
|
|
167
|
+
writeFileSync(
|
|
168
|
+
join(testDir, 'note/mutations.ts'),
|
|
169
|
+
`export const mutate = mutations('note', { shouldNotBeAHandler: async (ctx, args: { id: string }) => {} })`
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
await generate({ dir: testDir, silent: true })
|
|
173
|
+
const output = readFileSync(join(testDir, 'generated/syncedMutations.ts'), 'utf-8')
|
|
174
|
+
|
|
175
|
+
expect(output).toContain(' publish: v.object({')
|
|
176
|
+
expect(output).not.toContain('crud:')
|
|
177
|
+
// arg 1 is permissions at runtime, so a two-arg named call authors no handlers
|
|
178
|
+
expect(output).not.toContain('shouldNotBeAHandler')
|
|
179
|
+
})
|
|
88
180
|
})
|
|
89
181
|
|
|
90
182
|
describe('instance layout', () => {
|
package/src/generate.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
|
|
|
3
3
|
import { basename, dirname, resolve } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
CRUD_MUTATION_NAMES,
|
|
6
7
|
formatObjectKey,
|
|
7
8
|
generateAggregatesFile,
|
|
8
9
|
generateGroupedQueriesFile,
|
|
@@ -221,6 +222,57 @@ function resolveParamType(
|
|
|
221
222
|
return result
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
// positional shape of a `mutations(...)` call, matching the runtime overloads:
|
|
226
|
+
// mutations(handlers)
|
|
227
|
+
// mutations(table, permissions)
|
|
228
|
+
// mutations(table, permissions, handlers)
|
|
229
|
+
// mutations(table, permissions, handlers, { crud: false })
|
|
230
|
+
// arg 1 is always permissions and arg 3 is always options, so neither may ever
|
|
231
|
+
// be read as the handlers object.
|
|
232
|
+
function readMutationsCall(
|
|
233
|
+
ts: typeof import('typescript'),
|
|
234
|
+
call: import('typescript').CallExpression
|
|
235
|
+
): {
|
|
236
|
+
hasTable: boolean
|
|
237
|
+
crud: boolean
|
|
238
|
+
handlersArg: import('typescript').ObjectLiteralExpression | null
|
|
239
|
+
} {
|
|
240
|
+
const args = call.arguments
|
|
241
|
+
|
|
242
|
+
if (args.length < 2) {
|
|
243
|
+
const only = args[0]
|
|
244
|
+
return {
|
|
245
|
+
hasTable: false,
|
|
246
|
+
crud: false,
|
|
247
|
+
handlersArg: only && ts.isObjectLiteralExpression(only) ? only : null,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const handlers = args[2]
|
|
252
|
+
const options = args[3]
|
|
253
|
+
|
|
254
|
+
let crud = true
|
|
255
|
+
if (options && ts.isObjectLiteralExpression(options)) {
|
|
256
|
+
for (const prop of options.properties) {
|
|
257
|
+
if (!ts.isPropertyAssignment(prop)) continue
|
|
258
|
+
if (
|
|
259
|
+
!(ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) ||
|
|
260
|
+
prop.name.text !== 'crud'
|
|
261
|
+
)
|
|
262
|
+
continue
|
|
263
|
+
// only a literal opt-out removes the generated slots. a computed value
|
|
264
|
+
// keeps them, since extra validators are inert but missing ones are not.
|
|
265
|
+
if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) crud = false
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
hasTable: true,
|
|
271
|
+
crud,
|
|
272
|
+
handlersArg: handlers && ts.isObjectLiteralExpression(handlers) ? handlers : null,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
224
276
|
// find mutation handler param types in a resolver-owned source file
|
|
225
277
|
// walks `export const mutate = mutations(..., { handlerName: async (ctx, param: Type) => ... })`
|
|
226
278
|
function resolveMutationParamTypes(
|
|
@@ -239,16 +291,8 @@ function resolveMutationParamTypes(
|
|
|
239
291
|
if (decl.name.getText(sourceFile) !== 'mutate') return
|
|
240
292
|
|
|
241
293
|
if (!decl.initializer || !ts.isCallExpression(decl.initializer)) return
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// find the handlers object (last arg that is an object literal)
|
|
245
|
-
let handlersArg: import('typescript').ObjectLiteralExpression | null = null
|
|
246
|
-
for (let i = args.length - 1; i >= 0; i--) {
|
|
247
|
-
if (ts.isObjectLiteralExpression(args[i]!)) {
|
|
248
|
-
handlersArg = args[i] as import('typescript').ObjectLiteralExpression
|
|
249
|
-
break
|
|
250
|
-
}
|
|
251
|
-
}
|
|
294
|
+
|
|
295
|
+
const { handlersArg } = readMutationsCall(ts, decl.initializer)
|
|
252
296
|
if (!handlersArg) return
|
|
253
297
|
|
|
254
298
|
for (const prop of handlersArg.properties) {
|
|
@@ -317,28 +361,11 @@ function extractMutationsFromModel(
|
|
|
317
361
|
}
|
|
318
362
|
|
|
319
363
|
const call = mutateNode as import('typescript').CallExpression
|
|
320
|
-
const args = call.arguments
|
|
321
364
|
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const firstArg = args[0]
|
|
327
|
-
const hasNamedModel = Boolean(firstArg && ts.isStringLiteralLike(firstArg))
|
|
328
|
-
const hasCRUD = args.length >= 2 && !hasNamedModel
|
|
329
|
-
let handlersArg: import('typescript').ObjectLiteralExpression | null = null
|
|
330
|
-
|
|
331
|
-
if (args.length === 1 && ts.isObjectLiteralExpression(args[0]!)) {
|
|
332
|
-
handlersArg = args[0] as import('typescript').ObjectLiteralExpression
|
|
333
|
-
} else if (hasNamedModel) {
|
|
334
|
-
if (args.length >= 3 && ts.isObjectLiteralExpression(args[2]!)) {
|
|
335
|
-
handlersArg = args[2] as import('typescript').ObjectLiteralExpression
|
|
336
|
-
} else if (args.length >= 2 && ts.isObjectLiteralExpression(args[1]!)) {
|
|
337
|
-
handlersArg = args[1] as import('typescript').ObjectLiteralExpression
|
|
338
|
-
}
|
|
339
|
-
} else if (args.length === 3 && ts.isObjectLiteralExpression(args[2]!)) {
|
|
340
|
-
handlersArg = args[2] as import('typescript').ObjectLiteralExpression
|
|
341
|
-
}
|
|
365
|
+
// string-named and table-builder registrations behave identically at runtime:
|
|
366
|
+
// any 2+ arg call registers permissions and generates crud unless it opted out
|
|
367
|
+
const { hasTable, crud, handlersArg } = readMutationsCall(ts, call)
|
|
368
|
+
const hasCRUD = hasTable && crud
|
|
342
369
|
|
|
343
370
|
// extract schema columns for CRUD generation
|
|
344
371
|
const columns: Record<string, SchemaColumn> = {}
|
|
@@ -1327,9 +1354,9 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult
|
|
|
1327
1354
|
|
|
1328
1355
|
// count total mutations (CRUD + custom)
|
|
1329
1356
|
for (const model of allModelMutations) {
|
|
1330
|
-
if (model.hasCRUD) mutationCount +=
|
|
1357
|
+
if (model.hasCRUD) mutationCount += CRUD_MUTATION_NAMES.length
|
|
1331
1358
|
mutationCount += model.custom.filter(
|
|
1332
|
-
(m) => !model.hasCRUD || !
|
|
1359
|
+
(m) => !model.hasCRUD || !CRUD_MUTATION_NAMES.some((name) => name === m.name)
|
|
1333
1360
|
).length
|
|
1334
1361
|
}
|
|
1335
1362
|
|
package/src/mutations.test.ts
CHANGED
|
@@ -1,9 +1,68 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { DatabaseSync, type SQLInputValue } from 'node:sqlite'
|
|
2
3
|
|
|
4
|
+
import { createSchema, string, table } from '@rocicorp/zero'
|
|
5
|
+
import {
|
|
6
|
+
createSQLiteApplicationDatabase,
|
|
7
|
+
createSyncExecutor,
|
|
8
|
+
type ApplicationTransaction,
|
|
9
|
+
} from 'orez-lite'
|
|
10
|
+
import { createQueryCompiler } from 'orez-lite/cloudflare/query-compiler'
|
|
11
|
+
import { executeTransactionQueryPlan } from 'orez-lite/cloudflare/transaction-query'
|
|
12
|
+
import { afterAll, describe, expect, test, vi } from 'vitest'
|
|
13
|
+
|
|
14
|
+
import { createZeroServerBindings, setupAsyncLocalStorage } from './server'
|
|
15
|
+
|
|
16
|
+
const previousEnvironment = vi.hoisted(() => {
|
|
17
|
+
const previous = process.env.VITE_ENVIRONMENT
|
|
18
|
+
process.env.VITE_ENVIRONMENT = 'client'
|
|
19
|
+
return previous
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterAll(() => {
|
|
23
|
+
if (previousEnvironment === undefined) delete process.env.VITE_ENVIRONMENT
|
|
24
|
+
else process.env.VITE_ENVIRONMENT = previousEnvironment
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
import { getMutationsPermissions } from './modelRegistry'
|
|
3
28
|
import { mutations } from './mutations'
|
|
4
29
|
import { serverWhere } from './serverWhere'
|
|
5
30
|
|
|
6
31
|
describe('mutations registry', () => {
|
|
32
|
+
test('explicit opt-out removes generated CRUD from existing proxies and retains permissions', async () => {
|
|
33
|
+
const record = table('closedRecord').columns({ id: string() }).primaryKey('id')
|
|
34
|
+
const permission = serverWhere('closedRecord', () => true)
|
|
35
|
+
const first = mutations(record, permission)
|
|
36
|
+
expect(Object.keys(first).sort()).toEqual(['delete', 'insert', 'update', 'upsert'])
|
|
37
|
+
const custom = vi.fn(async () => {})
|
|
38
|
+
const closed = mutations(record, permission, { custom }, { crud: false })
|
|
39
|
+
expect(closed).toBe(first)
|
|
40
|
+
expect(Object.keys(closed)).toEqual(['custom'])
|
|
41
|
+
expect('insert' in closed).toBe(false)
|
|
42
|
+
expect(Reflect.get(closed, 'upsert')).toBeUndefined()
|
|
43
|
+
expect(getMutationsPermissions('closedRecord')).toBe(permission)
|
|
44
|
+
await closed.custom()
|
|
45
|
+
expect(custom).toHaveBeenCalledOnce()
|
|
46
|
+
const reopened = mutations(record, permission, {}, { crud: true })
|
|
47
|
+
expect(Object.keys(reopened).sort()).toEqual([
|
|
48
|
+
'custom',
|
|
49
|
+
'delete',
|
|
50
|
+
'insert',
|
|
51
|
+
'update',
|
|
52
|
+
'upsert',
|
|
53
|
+
])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('opt-out preserves explicitly declared CRUD handlers', async () => {
|
|
57
|
+
const record = table('customRecord').columns({ id: string() }).primaryKey('id')
|
|
58
|
+
const permission = serverWhere('customRecord', () => true)
|
|
59
|
+
const insert = vi.fn(async () => {})
|
|
60
|
+
const custom = mutations(record, permission, { insert }, { crud: false })
|
|
61
|
+
expect(Object.keys(custom)).toEqual(['insert'])
|
|
62
|
+
await custom.insert()
|
|
63
|
+
expect(insert).toHaveBeenCalledOnce()
|
|
64
|
+
})
|
|
65
|
+
|
|
7
66
|
test('re-registering a handler replaces it per key (HMR)', () => {
|
|
8
67
|
const permissions = serverWhere('post', () => true)
|
|
9
68
|
const v1 = async () => {}
|
|
@@ -15,3 +74,159 @@ describe('mutations registry', () => {
|
|
|
15
74
|
expect(proxy.custom).toBe(v2)
|
|
16
75
|
})
|
|
17
76
|
})
|
|
77
|
+
|
|
78
|
+
describe('generated CRUD authorization', () => {
|
|
79
|
+
test('authorizes both sides of composite-key writes through the server executor', async () => {
|
|
80
|
+
setupAsyncLocalStorage(AsyncLocalStorage)
|
|
81
|
+
const document = table('document')
|
|
82
|
+
.columns({
|
|
83
|
+
workspace: string(),
|
|
84
|
+
id: string(),
|
|
85
|
+
ownerId: string(),
|
|
86
|
+
title: string(),
|
|
87
|
+
})
|
|
88
|
+
.primaryKey('workspace', 'id')
|
|
89
|
+
const schema = createSchema({
|
|
90
|
+
tables: [document],
|
|
91
|
+
relationships: [],
|
|
92
|
+
enableLegacyQueries: true,
|
|
93
|
+
})
|
|
94
|
+
const sqlite = new DatabaseSync(':memory:')
|
|
95
|
+
sqlite.exec(
|
|
96
|
+
'CREATE TABLE document (workspace TEXT, id TEXT, ownerId TEXT, title TEXT, PRIMARY KEY (workspace, id))'
|
|
97
|
+
)
|
|
98
|
+
const compile = createQueryCompiler(schema)
|
|
99
|
+
const query: ApplicationTransaction['query'] = async (sql, params = []) =>
|
|
100
|
+
sqlite.prepare(sql).all(...params.map((value) => value as SQLInputValue)) as any
|
|
101
|
+
const tx: ApplicationTransaction = {
|
|
102
|
+
query,
|
|
103
|
+
exec: async (sql, params = []) => ({
|
|
104
|
+
changes: Number(
|
|
105
|
+
sqlite.prepare(sql).run(...params.map((value) => value as SQLInputValue))
|
|
106
|
+
.changes
|
|
107
|
+
),
|
|
108
|
+
}),
|
|
109
|
+
queryAst: async (ast, format, queryName) =>
|
|
110
|
+
executeTransactionQueryPlan(
|
|
111
|
+
compile(ast, format),
|
|
112
|
+
(sql, params) =>
|
|
113
|
+
sqlite.prepare(sql).all(...params.map((value) => value as SQLInputValue)),
|
|
114
|
+
{ queryName }
|
|
115
|
+
),
|
|
116
|
+
}
|
|
117
|
+
const database = createSQLiteApplicationDatabase({
|
|
118
|
+
query,
|
|
119
|
+
transaction: async (work) => {
|
|
120
|
+
sqlite.exec('BEGIN')
|
|
121
|
+
try {
|
|
122
|
+
const result = await work(tx)
|
|
123
|
+
sqlite.exec('COMMIT')
|
|
124
|
+
return result
|
|
125
|
+
} catch (error) {
|
|
126
|
+
sqlite.exec('ROLLBACK')
|
|
127
|
+
throw error
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
const permission = serverWhere('document', (eb, auth) =>
|
|
132
|
+
eb.cmp(
|
|
133
|
+
'ownerId',
|
|
134
|
+
auth && 'id' in auth && typeof auth.id === 'string' ? auth.id : ''
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
const bindings = createZeroServerBindings({
|
|
138
|
+
schema,
|
|
139
|
+
models: { document: { mutate: mutations(document, permission) } },
|
|
140
|
+
createServerActions: () => ({}),
|
|
141
|
+
})
|
|
142
|
+
const executor = createSyncExecutor({
|
|
143
|
+
database,
|
|
144
|
+
schema,
|
|
145
|
+
mutators: bindings.mutators,
|
|
146
|
+
effects: {
|
|
147
|
+
runBackground: (promise) => promise,
|
|
148
|
+
report: (error) => {
|
|
149
|
+
throw error
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
const server = bindings.server(executor)
|
|
154
|
+
const auth = { authData: { id: 'alice' } }
|
|
155
|
+
const own = { workspace: 'a', id: 'same', ownerId: 'alice', title: 'own' }
|
|
156
|
+
const foreign = { workspace: 'b', id: 'same', ownerId: 'bob', title: 'foreign' }
|
|
157
|
+
try {
|
|
158
|
+
await server.mutate.document.insert(own, auth)
|
|
159
|
+
await server.mutate.document.insert(foreign, { authData: { id: 'bob' } })
|
|
160
|
+
await expect(
|
|
161
|
+
server.mutate.document.insert({ ...foreign, id: 'forbidden' }, auth)
|
|
162
|
+
).rejects.toThrow()
|
|
163
|
+
await expect(
|
|
164
|
+
server.mutate.document.upsert({ ...foreign, ownerId: 'alice' }, auth)
|
|
165
|
+
).rejects.toThrow()
|
|
166
|
+
await expect(
|
|
167
|
+
server.mutate.document.update({ ...own, ownerId: 'bob' }, auth)
|
|
168
|
+
).rejects.toThrow()
|
|
169
|
+
await expect(
|
|
170
|
+
server.mutate.document.upsert({ ...own, ownerId: 'bob' }, auth)
|
|
171
|
+
).rejects.toThrow()
|
|
172
|
+
await expect(
|
|
173
|
+
server.mutate.document.upsert({ ...foreign, id: 'missing' }, auth)
|
|
174
|
+
).rejects.toThrow()
|
|
175
|
+
await server.mutate.document.upsert({ ...own, title: 'updated' }, auth)
|
|
176
|
+
await server.mutate.document.upsert({ ...own, title: 'updated' }, auth)
|
|
177
|
+
await server.mutate.document.upsert({ ...own, id: 'new' }, auth)
|
|
178
|
+
expect(
|
|
179
|
+
sqlite.prepare('SELECT * FROM document ORDER BY workspace, id').all()
|
|
180
|
+
).toEqual([{ ...own, id: 'new' }, { ...own, title: 'updated' }, foreign])
|
|
181
|
+
const closedBindings = createZeroServerBindings({
|
|
182
|
+
schema,
|
|
183
|
+
models: {
|
|
184
|
+
document: {
|
|
185
|
+
mutate: mutations(
|
|
186
|
+
document,
|
|
187
|
+
permission,
|
|
188
|
+
{
|
|
189
|
+
rename: async (
|
|
190
|
+
ctx,
|
|
191
|
+
input: { workspace: string; id: string; title: string }
|
|
192
|
+
) => {
|
|
193
|
+
await ctx.can(permission, input)
|
|
194
|
+
await ctx.tx.mutate.document.update(input)
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{ crud: false }
|
|
198
|
+
),
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
createServerActions: () => ({}),
|
|
202
|
+
})
|
|
203
|
+
expect(Object.keys(closedBindings.mutators)).toEqual(['document|rename'])
|
|
204
|
+
const closedExecutor = createSyncExecutor({
|
|
205
|
+
database,
|
|
206
|
+
schema,
|
|
207
|
+
mutators: closedBindings.mutators,
|
|
208
|
+
effects: {
|
|
209
|
+
runBackground: (promise) => promise,
|
|
210
|
+
report: (error) => {
|
|
211
|
+
throw error
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
for (const action of ['insert', 'update', 'delete', 'upsert']) {
|
|
216
|
+
await expect(
|
|
217
|
+
closedExecutor.execute(`document|${action}`, own, { userID: 'alice' })
|
|
218
|
+
).rejects.toThrow(`unknown mutator: document|${action}`)
|
|
219
|
+
}
|
|
220
|
+
await closedBindings
|
|
221
|
+
.server(closedExecutor)
|
|
222
|
+
.mutate.document.rename({ workspace: 'a', id: 'same', title: 'renamed' }, auth)
|
|
223
|
+
expect(
|
|
224
|
+
sqlite
|
|
225
|
+
.prepare('SELECT title FROM document WHERE workspace = ? AND id = ?')
|
|
226
|
+
.get('a', 'same')
|
|
227
|
+
).toEqual({ title: 'renamed' })
|
|
228
|
+
} finally {
|
|
229
|
+
sqlite.close()
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
})
|