@opensaas/stack-core 0.40.0 → 0.42.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.
Files changed (86) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +65 -0
  3. package/CLAUDE.md +52 -0
  4. package/dist/access/access-filter.d.ts +88 -18
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +343 -36
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +759 -6
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/denied-relation-visibility.test.d.ts +2 -0
  11. package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
  12. package/dist/access/denied-relation-visibility.test.js +114 -0
  13. package/dist/access/denied-relation-visibility.test.js.map +1 -0
  14. package/dist/access/engine.d.ts +11 -0
  15. package/dist/access/engine.d.ts.map +1 -1
  16. package/dist/access/engine.js +26 -0
  17. package/dist/access/engine.js.map +1 -1
  18. package/dist/access/errors.d.ts +34 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +47 -0
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-visibility.d.ts +2 -2
  23. package/dist/access/field-visibility.d.ts.map +1 -1
  24. package/dist/access/field-visibility.js +73 -23
  25. package/dist/access/field-visibility.js.map +1 -1
  26. package/dist/access/index.d.ts +7 -3
  27. package/dist/access/index.d.ts.map +1 -1
  28. package/dist/access/index.js +9 -2
  29. package/dist/access/index.js.map +1 -1
  30. package/dist/access/multi-column-read-write.test.js.map +1 -1
  31. package/dist/access/query-validation.d.ts +31 -0
  32. package/dist/access/query-validation.d.ts.map +1 -1
  33. package/dist/access/query-validation.js +75 -16
  34. package/dist/access/query-validation.js.map +1 -1
  35. package/dist/access/relationship-count.d.ts +41 -3
  36. package/dist/access/relationship-count.d.ts.map +1 -1
  37. package/dist/access/relationship-count.js +44 -7
  38. package/dist/access/relationship-count.js.map +1 -1
  39. package/dist/access/relationship-count.test.js +62 -0
  40. package/dist/access/relationship-count.test.js.map +1 -1
  41. package/dist/access/synthetic-include-read.test.d.ts +2 -0
  42. package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
  43. package/dist/access/synthetic-include-read.test.js +121 -0
  44. package/dist/access/synthetic-include-read.test.js.map +1 -0
  45. package/dist/config/types.d.ts +70 -58
  46. package/dist/config/types.d.ts.map +1 -1
  47. package/dist/context/hook-pipeline.d.ts +2 -2
  48. package/dist/context/hook-pipeline.d.ts.map +1 -1
  49. package/dist/context/hook-pipeline.js.map +1 -1
  50. package/dist/context/index.d.ts +16 -1
  51. package/dist/context/index.d.ts.map +1 -1
  52. package/dist/context/index.js +76 -26
  53. package/dist/context/index.js.map +1 -1
  54. package/dist/context/nested-operations.d.ts +2 -2
  55. package/dist/context/nested-operations.d.ts.map +1 -1
  56. package/dist/context/nested-operations.js +21 -3
  57. package/dist/context/nested-operations.js.map +1 -1
  58. package/dist/context/write-pipeline.d.ts.map +1 -1
  59. package/dist/context/write-pipeline.js +23 -29
  60. package/dist/context/write-pipeline.js.map +1 -1
  61. package/dist/hooks/index.d.ts +16 -15
  62. package/dist/hooks/index.d.ts.map +1 -1
  63. package/dist/hooks/index.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/access/access-filter.test.ts +1403 -159
  66. package/src/access/access-filter.ts +525 -47
  67. package/src/access/denied-relation-visibility.test.ts +173 -0
  68. package/src/access/engine.ts +28 -0
  69. package/src/access/errors.ts +55 -0
  70. package/src/access/field-visibility.ts +105 -24
  71. package/src/access/index.ts +11 -0
  72. package/src/access/multi-column-read-write.test.ts +3 -2
  73. package/src/access/query-validation.ts +136 -9
  74. package/src/access/relationship-count.test.ts +75 -0
  75. package/src/access/relationship-count.ts +53 -10
  76. package/src/access/synthetic-include-read.test.ts +179 -0
  77. package/src/config/types.ts +76 -50
  78. package/src/context/hook-pipeline.ts +4 -2
  79. package/src/context/index.ts +120 -39
  80. package/src/context/nested-operations.ts +35 -11
  81. package/src/context/write-pipeline.ts +52 -32
  82. package/src/hooks/index.ts +22 -15
  83. package/tests/context.test.ts +193 -5
  84. package/tests/hook-context-secured.test.ts +424 -0
  85. package/tests/nested-access-and-hooks.test.ts +324 -0
  86. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,424 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { getContext } from '../src/context/index.js'
3
+ import { config, list } from '../src/config/index.js'
4
+ import { text } from '../src/fields/index.js'
5
+ import { ResolveOutputCycleError } from '../src/access/index.js'
6
+ import type { Plugin } from '../src/config/types.js'
7
+ import type { StackContext } from '../src/context/index.js'
8
+
9
+ /**
10
+ * #1176: a list/field `resolveInput`/`validate`/`beforeOperation`/
11
+ * `afterOperation` hook now receives the FULL secured {@link StackContext} —
12
+ * `sudo()`, `withSession()`, `transaction()`, `serverAction` — bound to the
13
+ * write's OWN transaction client, instead of the bare `AccessContext` object
14
+ * literal `bindContextToTransaction` used to hand-assemble. See ADR-0012's
15
+ * amendment and `packages/core/CLAUDE.md`.
16
+ *
17
+ * `beforeTransaction`/`afterTransaction` are OUT of scope (ADR-0028): they
18
+ * keep a base-client `AccessContext`, unchanged by this issue.
19
+ */
20
+
21
+ /**
22
+ * A transaction-aware mock whose `$transaction` hands the callback a `tx`
23
+ * object that is NOT `client` itself (mirroring real Prisma, and required to
24
+ * prove a hook's context is bound to the TRANSACTION client, not the base
25
+ * one) and — faithfully — has no `$transaction` of its own, so a nested
26
+ * `context.transaction()`/`context.db` write can only join, never nest.
27
+ */
28
+ function createTxPrisma(tableNames: string[]) {
29
+ const tables: Record<string, Map<string, Record<string, unknown>>> = {}
30
+ for (const name of tableNames) tables[name] = new Map()
31
+ let idCounter = 0
32
+ const nextId = () => `id-${++idCounter}`
33
+
34
+ function makeModel(table: string) {
35
+ return {
36
+ findUnique: vi.fn(
37
+ async ({ where }: { where: { id: string } }) => tables[table].get(where.id) ?? null,
38
+ ),
39
+ findFirst: vi.fn(async () => tables[table].values().next().value ?? null),
40
+ findMany: vi.fn(async () => Array.from(tables[table].values())),
41
+ count: vi.fn(async () => tables[table].size),
42
+ create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
43
+ const id = (data.id as string) ?? nextId()
44
+ const record = { ...data, id }
45
+ tables[table].set(id, record)
46
+ return record
47
+ }),
48
+ update: vi.fn(
49
+ async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
50
+ const existing = tables[table].get(where.id) ?? { id: where.id }
51
+ const updated = { ...existing, ...data }
52
+ tables[table].set(where.id, updated)
53
+ return updated
54
+ },
55
+ ),
56
+ delete: vi.fn(async ({ where }: { where: { id: string } }) => {
57
+ const existing = tables[table].get(where.id) ?? { id: where.id }
58
+ tables[table].delete(where.id)
59
+ return existing
60
+ }),
61
+ }
62
+ }
63
+
64
+ const client: Record<string, unknown> = {}
65
+ for (const name of tableNames) client[name] = makeModel(name)
66
+
67
+ const transactionSpy = vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => {
68
+ const snapshot: Record<string, Map<string, Record<string, unknown>>> = {}
69
+ for (const [name, map] of Object.entries(tables)) snapshot[name] = new Map(map)
70
+ // Faithful to real Prisma: the tx client has the model delegates but NOT
71
+ // `$transaction` — a nested write detects this and joins rather than
72
+ // opening a second transaction. Spreading into a NEW object also makes
73
+ // `tx !== client`, which the "bound to tx, not the base client" tests key on.
74
+ const { $transaction: _omit, ...models } = client
75
+ void _omit
76
+ try {
77
+ return await fn(models)
78
+ } catch (err) {
79
+ for (const [name, map] of Object.entries(snapshot)) tables[name] = map
80
+ throw err
81
+ }
82
+ })
83
+ client.$transaction = transactionSpy
84
+
85
+ return { client, tables, transactionSpy }
86
+ }
87
+
88
+ describe('#1176 hook context is a full secured StackContext bound to the transaction', () => {
89
+ let mock: ReturnType<typeof createTxPrisma>
90
+
91
+ beforeEach(() => {
92
+ mock = createTxPrisma(['user'])
93
+ vi.clearAllMocks()
94
+ })
95
+
96
+ it('resolveInput/validate/beforeOperation/afterOperation (list AND field) all receive sudo/withSession/transaction/serverAction, bound to `tx`', async () => {
97
+ const captured: StackContext[] = []
98
+
99
+ const testConfig = await config({
100
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
101
+ lists: {
102
+ User: list({
103
+ fields: {
104
+ name: text({
105
+ hooks: {
106
+ resolveInput: async ({ context, resolvedData, fieldKey }) => {
107
+ captured.push(context)
108
+ return resolvedData[fieldKey]
109
+ },
110
+ validate: async ({ context }) => {
111
+ captured.push(context)
112
+ },
113
+ beforeOperation: async ({ context }) => {
114
+ captured.push(context)
115
+ },
116
+ afterOperation: async ({ context }) => {
117
+ captured.push(context)
118
+ },
119
+ },
120
+ }),
121
+ },
122
+ access: { operation: { query: () => true, create: () => true } },
123
+ hooks: {
124
+ resolveInput: async ({ context, resolvedData }) => {
125
+ captured.push(context)
126
+ return resolvedData
127
+ },
128
+ validate: async ({ context }) => {
129
+ captured.push(context)
130
+ },
131
+ beforeOperation: async ({ context }) => {
132
+ captured.push(context)
133
+ },
134
+ afterOperation: async ({ context }) => {
135
+ captured.push(context)
136
+ },
137
+ },
138
+ }),
139
+ },
140
+ })
141
+
142
+ const context = getContext(testConfig, mock.client, { userId: '1' })
143
+ await context.db.user.create({ data: { name: 'jane' } })
144
+
145
+ // list resolveInput/validate/beforeOperation/afterOperation + the same 4
146
+ // at field level = 8 hook invocations, one create.
147
+ expect(captured).toHaveLength(8)
148
+
149
+ for (const hookContext of captured) {
150
+ expect(typeof hookContext.sudo).toBe('function')
151
+ expect(typeof hookContext.withSession).toBe('function')
152
+ expect(typeof hookContext.transaction).toBe('function')
153
+ expect(typeof hookContext.serverAction).toBe('function')
154
+ // Bound to the transaction client, never the base one.
155
+ expect(hookContext.prisma).not.toBe(mock.client)
156
+ expect(hookContext.sudo().prisma).toBe(hookContext.prisma)
157
+ }
158
+ })
159
+
160
+ it('a context.sudo()/.withSession() write from beforeOperation succeeds against an access-denied-for-the-session list, and rolls back with the outer write', async () => {
161
+ async function run(shouldThrow: boolean) {
162
+ const denyMock = createTxPrisma(['user', 'draft'])
163
+ const testConfig = await config({
164
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
165
+ lists: {
166
+ User: list({
167
+ fields: { name: text() },
168
+ access: { operation: { query: () => true, create: () => true } },
169
+ hooks: {
170
+ beforeOperation: async ({ context, operation }) => {
171
+ if (operation !== 'create') return
172
+ // Denied for this session under normal access — only sudo can write it.
173
+ await context.sudo().db.draft.create({ data: { note: 'draft' } })
174
+ if (shouldThrow) {
175
+ throw new Error('boom')
176
+ }
177
+ },
178
+ },
179
+ }),
180
+ Draft: list({
181
+ fields: { note: text() },
182
+ access: { operation: { query: () => true, create: () => false } },
183
+ }),
184
+ },
185
+ })
186
+ const context = getContext(testConfig, denyMock.client, { userId: '1' })
187
+
188
+ if (shouldThrow) {
189
+ await expect(context.db.user.create({ data: { name: 'jane' } })).rejects.toThrow('boom')
190
+ } else {
191
+ await context.db.user.create({ data: { name: 'jane' } })
192
+ }
193
+ return denyMock
194
+ }
195
+
196
+ const committed = await run(false)
197
+ expect(committed.tables.draft.size).toBe(1)
198
+
199
+ const rolledBack = await run(true)
200
+ expect(rolledBack.tables.draft.size).toBe(0)
201
+ })
202
+
203
+ it('context.withSession() from a hook stays bound to `tx` and preserves the receiver sudo state; sudo()/withSession() compose in either order', async () => {
204
+ const seen: { withSessionPrisma?: unknown; ownPrisma?: unknown; combos?: boolean[] } = {}
205
+
206
+ const testConfig = await config({
207
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
208
+ lists: {
209
+ User: list({
210
+ fields: { name: text() },
211
+ access: { operation: { query: () => true, create: () => true } },
212
+ hooks: {
213
+ beforeOperation: async ({ context }) => {
214
+ seen.ownPrisma = context.prisma
215
+ seen.withSessionPrisma = context.withSession({ userId: '2' }).prisma
216
+ seen.combos = [
217
+ context.sudo().withSession({ userId: '2' })._isSudo,
218
+ context.withSession({ userId: '2' }).sudo()._isSudo,
219
+ context.withSession({ userId: '2' })._isSudo, // non-sudo receiver stays non-sudo
220
+ ]
221
+ },
222
+ },
223
+ }),
224
+ },
225
+ })
226
+
227
+ const context = getContext(testConfig, mock.client, { userId: '1' })
228
+ await context.db.user.create({ data: { name: 'jane' } })
229
+
230
+ expect(seen.withSessionPrisma).toBe(seen.ownPrisma)
231
+ expect(seen.combos).toEqual([true, true, false])
232
+ })
233
+
234
+ it('context.transaction() called from a hook JOINS the write transaction rather than opening a nested one', async () => {
235
+ const joinMock = createTxPrisma(['user', 'note'])
236
+ const testConfig = await config({
237
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
238
+ lists: {
239
+ User: list({
240
+ fields: { name: text() },
241
+ access: { operation: { query: () => true, create: () => true } },
242
+ hooks: {
243
+ beforeOperation: async ({ context }) => {
244
+ await context.transaction(async (tx2) => {
245
+ await tx2.db.note.create({ data: { name: 'joined' } })
246
+ })
247
+ },
248
+ },
249
+ }),
250
+ Note: list({
251
+ fields: { name: text() },
252
+ access: { operation: { query: () => true, create: () => true } },
253
+ }),
254
+ },
255
+ })
256
+
257
+ const context = getContext(testConfig, joinMock.client, { userId: '1' })
258
+ await context.db.user.create({ data: { name: 'jane' } })
259
+
260
+ // Exactly the one outer transaction — the hook's `context.transaction()`
261
+ // joined it instead of calling `$transaction` again.
262
+ expect(joinMock.transactionSpy).toHaveBeenCalledTimes(1)
263
+ expect(joinMock.tables.user.size).toBe(1)
264
+ expect(joinMock.tables.note.size).toBe(1)
265
+ })
266
+
267
+ it('a write issued through a hook context.sudo() defers afterTransaction to the outer settle and reports the outer outcome', async () => {
268
+ async function run(commentThrows: boolean) {
269
+ const m = createTxPrisma(['user', 'comment', 'audit'])
270
+ const auditAfter = vi.fn()
271
+ const testConfig = await config({
272
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
273
+ lists: {
274
+ User: list({
275
+ fields: { name: text() },
276
+ access: { operation: { query: () => true, create: () => true } },
277
+ hooks: {
278
+ afterOperation: async ({ context, operation }) => {
279
+ if (operation === 'create') {
280
+ await context.sudo().db.audit.create({ data: { note: 'audit' } })
281
+ }
282
+ },
283
+ },
284
+ }),
285
+ Comment: list({
286
+ fields: { body: text() },
287
+ access: { operation: { query: () => true, create: () => true } },
288
+ hooks: commentThrows
289
+ ? {
290
+ afterOperation: () => {
291
+ throw new Error('comment boom')
292
+ },
293
+ }
294
+ : undefined,
295
+ }),
296
+ // Denied for the session — only reachable via sudo, proving the
297
+ // deferred afterTransaction reports the join even for a sudo write.
298
+ Audit: list({
299
+ fields: { note: text() },
300
+ access: { operation: { query: () => true, create: () => false } },
301
+ hooks: { afterTransaction: auditAfter },
302
+ }),
303
+ },
304
+ })
305
+ const context = getContext(testConfig, m.client, { userId: '1' })
306
+
307
+ if (commentThrows) {
308
+ await expect(
309
+ context.transaction(async (tx) => {
310
+ await tx.db.user.create({ data: { name: 'jane' } })
311
+ await tx.db.comment.create({ data: { body: 'trigger' } })
312
+ }),
313
+ ).rejects.toThrow('comment boom')
314
+ } else {
315
+ await context.transaction(async (tx) => {
316
+ await tx.db.user.create({ data: { name: 'jane' } })
317
+ })
318
+ }
319
+
320
+ return { m, auditAfter }
321
+ }
322
+
323
+ const committed = await run(false)
324
+ expect(committed.m.tables.audit.size).toBe(1)
325
+ expect(committed.auditAfter).toHaveBeenCalledTimes(1)
326
+ expect(committed.auditAfter.mock.calls[0][0].status).toBe('committed')
327
+
328
+ const rolledBack = await run(true)
329
+ expect(rolledBack.m.tables.audit.size).toBe(0)
330
+ expect(rolledBack.auditAfter).toHaveBeenCalledTimes(1)
331
+ expect(rolledBack.auditAfter.mock.calls[0][0].status).toBe('rolled-back')
332
+ })
333
+
334
+ it('plugin runtime() is invoked exactly once per request, even across a hook-firing write with a nested context.db write', async () => {
335
+ const runtimeSpy = vi.fn(() => ({}))
336
+ const testPlugin: Plugin = {
337
+ name: 'test-plugin',
338
+ init: () => {},
339
+ runtime: runtimeSpy,
340
+ }
341
+
342
+ const nestedMock = createTxPrisma(['user', 'audit'])
343
+ const testConfig = await config({
344
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
345
+ plugins: [testPlugin],
346
+ lists: {
347
+ User: list({
348
+ fields: { name: text() },
349
+ access: { operation: { query: () => true, create: () => true } },
350
+ hooks: {
351
+ afterOperation: async ({ context, operation }) => {
352
+ if (operation === 'create') {
353
+ await context.db.audit.create({ data: { note: 'audit' } })
354
+ }
355
+ },
356
+ },
357
+ }),
358
+ Audit: list({
359
+ fields: { note: text() },
360
+ access: { operation: { query: () => true, create: () => true } },
361
+ }),
362
+ },
363
+ })
364
+
365
+ const context = getContext(testConfig, nestedMock.client, { userId: '1' })
366
+ await context.db.user.create({ data: { name: 'jane' } })
367
+
368
+ expect(runtimeSpy).toHaveBeenCalledTimes(1)
369
+ expect(nestedMock.tables.audit.size).toBe(1)
370
+ })
371
+
372
+ it("a write issued from inside a resolveOutput hook carries that hook's resolve chain into the write's own Field Visibility pass (ADR-0023)", async () => {
373
+ // Ping.label's resolveOutput creates a Pong row, whose own Field
374
+ // Visibility pass (Phase 11 of THAT write) computes Pong.label — which
375
+ // creates a Ping row, whose Field Visibility pass computes Ping.label
376
+ // again. If (and only if) the write's hook context carries the resolve
377
+ // chain forward, that third hop re-enters `Ping.label`, already on the
378
+ // chain, and the cycle guard throws instead of recursing forever.
379
+ const { virtual } = await import('../src/fields/index.js')
380
+
381
+ const cycleMock = createTxPrisma(['ping', 'pong'])
382
+ const testConfig = await config({
383
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
384
+ lists: {
385
+ Ping: list({
386
+ fields: {
387
+ name: text(),
388
+ label: virtual({
389
+ type: 'string',
390
+ hooks: {
391
+ resolveOutput: async ({ context }) => {
392
+ await context.db.pong.create({ data: { name: 'q' } })
393
+ return 'ping'
394
+ },
395
+ },
396
+ }),
397
+ },
398
+ access: { operation: { query: () => true, create: () => true } },
399
+ }),
400
+ Pong: list({
401
+ fields: {
402
+ name: text(),
403
+ label: virtual({
404
+ type: 'string',
405
+ hooks: {
406
+ resolveOutput: async ({ context }) => {
407
+ await context.db.ping.create({ data: { name: 'p' } })
408
+ return 'pong'
409
+ },
410
+ },
411
+ }),
412
+ },
413
+ access: { operation: { query: () => true, create: () => true } },
414
+ }),
415
+ },
416
+ })
417
+
418
+ const context = getContext(testConfig, cycleMock.client, { userId: '1' })
419
+
420
+ await expect(context.db.ping.create({ data: { name: 'p0' } })).rejects.toThrow(
421
+ ResolveOutputCycleError,
422
+ )
423
+ })
424
+ })