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.
Files changed (81) hide show
  1. package/dist/cjs/createZeroClient.httpAuth.test.cjs +5 -3
  2. package/dist/cjs/createZeroClient.httpAuth.test.native.js +3 -3
  3. package/dist/cjs/createZeroClient.httpAuth.test.native.js.map +1 -1
  4. package/dist/cjs/generate-helpers.cjs +21 -14
  5. package/dist/cjs/generate-helpers.native.js +50 -27
  6. package/dist/cjs/generate-helpers.native.js.map +1 -1
  7. package/dist/cjs/generate-helpers.test.cjs +146 -0
  8. package/dist/cjs/generate-helpers.test.native.js +149 -0
  9. package/dist/cjs/generate-helpers.test.native.js.map +1 -1
  10. package/dist/cjs/generate-lite.cjs +3 -3
  11. package/dist/cjs/generate-lite.native.js +5 -3
  12. package/dist/cjs/generate-lite.native.js.map +1 -1
  13. package/dist/cjs/generate-lite.test.cjs +56 -0
  14. package/dist/cjs/generate-lite.test.native.js +60 -0
  15. package/dist/cjs/generate-lite.test.native.js.map +1 -1
  16. package/dist/cjs/generate.cjs +37 -26
  17. package/dist/cjs/generate.native.js +58 -26
  18. package/dist/cjs/generate.native.js.map +1 -1
  19. package/dist/cjs/generate.test.cjs +109 -0
  20. package/dist/cjs/generate.test.native.js +115 -0
  21. package/dist/cjs/generate.test.native.js.map +1 -1
  22. package/dist/cjs/mutations.cjs +22 -11
  23. package/dist/cjs/mutations.native.js +42 -10
  24. package/dist/cjs/mutations.native.js.map +1 -1
  25. package/dist/cjs/mutations.test.cjs +228 -0
  26. package/dist/cjs/mutations.test.native.js +259 -0
  27. package/dist/cjs/mutations.test.native.js.map +1 -1
  28. package/dist/esm/createZeroClient.httpAuth.test.mjs +5 -3
  29. package/dist/esm/createZeroClient.httpAuth.test.mjs.map +1 -1
  30. package/dist/esm/createZeroClient.httpAuth.test.native.js +3 -3
  31. package/dist/esm/createZeroClient.httpAuth.test.native.js.map +1 -1
  32. package/dist/esm/generate-helpers.mjs +21 -15
  33. package/dist/esm/generate-helpers.mjs.map +1 -1
  34. package/dist/esm/generate-helpers.native.js +50 -28
  35. package/dist/esm/generate-helpers.native.js.map +1 -1
  36. package/dist/esm/generate-helpers.test.mjs +122 -0
  37. package/dist/esm/generate-helpers.test.mjs.map +1 -1
  38. package/dist/esm/generate-helpers.test.native.js +125 -0
  39. package/dist/esm/generate-helpers.test.native.js.map +1 -1
  40. package/dist/esm/generate-lite.mjs +4 -4
  41. package/dist/esm/generate-lite.mjs.map +1 -1
  42. package/dist/esm/generate-lite.native.js +6 -4
  43. package/dist/esm/generate-lite.native.js.map +1 -1
  44. package/dist/esm/generate-lite.test.mjs +56 -0
  45. package/dist/esm/generate-lite.test.mjs.map +1 -1
  46. package/dist/esm/generate-lite.test.native.js +60 -0
  47. package/dist/esm/generate-lite.test.native.js.map +1 -1
  48. package/dist/esm/generate.mjs +38 -27
  49. package/dist/esm/generate.mjs.map +1 -1
  50. package/dist/esm/generate.native.js +59 -27
  51. package/dist/esm/generate.native.js.map +1 -1
  52. package/dist/esm/generate.test.mjs +86 -1
  53. package/dist/esm/generate.test.mjs.map +1 -1
  54. package/dist/esm/generate.test.native.js +92 -1
  55. package/dist/esm/generate.test.native.js.map +1 -1
  56. package/dist/esm/index.native.js.map +1 -1
  57. package/dist/esm/mutations.mjs +22 -11
  58. package/dist/esm/mutations.mjs.map +1 -1
  59. package/dist/esm/mutations.native.js +42 -10
  60. package/dist/esm/mutations.native.js.map +1 -1
  61. package/dist/esm/mutations.test.mjs +229 -1
  62. package/dist/esm/mutations.test.mjs.map +1 -1
  63. package/dist/esm/mutations.test.native.js +260 -1
  64. package/dist/esm/mutations.test.native.js.map +1 -1
  65. package/package.json +2 -2
  66. package/src/createZeroClient.httpAuth.test.ts +4 -5
  67. package/src/generate-helpers.test.ts +124 -0
  68. package/src/generate-helpers.ts +43 -27
  69. package/src/generate-lite.test.ts +53 -0
  70. package/src/generate-lite.ts +16 -9
  71. package/src/generate.test.ts +92 -0
  72. package/src/generate.ts +60 -33
  73. package/src/mutations.test.ts +216 -1
  74. package/src/mutations.ts +39 -39
  75. package/types/generate-helpers.d.ts +3 -1
  76. package/types/generate-helpers.d.ts.map +1 -1
  77. package/types/generate-lite.d.ts +1 -0
  78. package/types/generate-lite.d.ts.map +1 -1
  79. package/types/generate.d.ts.map +1 -1
  80. package/types/mutations.d.ts +5 -2
  81. package/types/mutations.d.ts.map +1 -1
@@ -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 last object literal arg to `mutations(...)`
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 is `mutations(schema, perm)` / `mutations(schema, perm, { ... })`.
518
- // in the lite ast, we don't know the call arity directly, so we use the
519
- // presence of a schema as the signal. this matches the real generator's
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: 3 per crud model plus
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 += 3
835
+ if (m.hasCRUD) mutationCount += CRUD_MUTATION_NAMES.length
829
836
  mutationCount += m.custom.filter(
830
- (mut) => !m.hasCRUD || !['insert', 'update', 'delete', 'upsert'].includes(mut.name)
837
+ (mut) => !m.hasCRUD || !CRUD_MUTATION_NAMES.some((name) => name === mut.name)
831
838
  ).length
832
839
  }
833
840
 
@@ -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
- const args = decl.initializer.arguments
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
- // determine overload
323
- // 1-arg: mutations({ ... })
324
- // schema model: mutations(schema, perm) / mutations(schema, perm, { ... })
325
- // named model: mutations('name', handlers) / mutations('name', perm, { ... })
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 += 3 // insert, update, delete
1357
+ if (model.hasCRUD) mutationCount += CRUD_MUTATION_NAMES.length
1331
1358
  mutationCount += model.custom.filter(
1332
- (m) => !model.hasCRUD || !['insert', 'update', 'delete', 'upsert'].includes(m.name)
1359
+ (m) => !model.hasCRUD || !CRUD_MUTATION_NAMES.some((name) => name === m.name)
1333
1360
  ).length
1334
1361
  }
1335
1362
 
@@ -1,9 +1,68 @@
1
- import { describe, expect, test } from 'vitest'
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
+ })