@opensaas/stack-core 0.34.0 → 0.36.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 (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +82 -0
  3. package/CLAUDE.md +48 -0
  4. package/dist/access/declared-dependencies.d.ts +68 -0
  5. package/dist/access/declared-dependencies.d.ts.map +1 -0
  6. package/dist/access/declared-dependencies.js +79 -0
  7. package/dist/access/declared-dependencies.js.map +1 -0
  8. package/dist/access/field-visibility.d.ts +2 -1
  9. package/dist/access/field-visibility.d.ts.map +1 -1
  10. package/dist/access/field-visibility.js +23 -3
  11. package/dist/access/field-visibility.js.map +1 -1
  12. package/dist/access/index.d.ts +2 -0
  13. package/dist/access/index.d.ts.map +1 -1
  14. package/dist/access/index.js +3 -0
  15. package/dist/access/index.js.map +1 -1
  16. package/dist/config/types.d.ts +65 -1
  17. package/dist/config/types.d.ts.map +1 -1
  18. package/dist/context/index.d.ts.map +1 -1
  19. package/dist/context/index.js +65 -72
  20. package/dist/context/index.js.map +1 -1
  21. package/dist/fields/index.d.ts.map +1 -1
  22. package/dist/fields/index.js +12 -12
  23. package/dist/fields/index.js.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/validation/needs-closure.d.ts +49 -0
  29. package/dist/validation/needs-closure.d.ts.map +1 -0
  30. package/dist/validation/needs-closure.js +139 -0
  31. package/dist/validation/needs-closure.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/access/declared-dependencies.ts +140 -0
  34. package/src/access/field-visibility.ts +24 -0
  35. package/src/access/index.ts +8 -0
  36. package/src/config/types.ts +65 -1
  37. package/src/context/index.ts +117 -90
  38. package/src/fields/index.ts +12 -9
  39. package/src/index.ts +8 -0
  40. package/src/validation/needs-closure.ts +188 -0
  41. package/tests/bare-read-scalars.test.ts +147 -0
  42. package/tests/field-types.test.ts +6 -2
  43. package/tests/needs-declared-dependencies.test.ts +500 -0
  44. package/tests/nested-access-and-hooks.test.ts +10 -0
  45. package/tests/resolve-chain.test.ts +40 -23
  46. package/tests/singleton.test.ts +83 -0
  47. package/tsconfig.tsbuildinfo +1 -1
@@ -397,8 +397,12 @@ describe('Nested Operations - Access Control and Hooks', () => {
397
397
 
398
398
  const context = getContext(await testConfig, mockPrisma, null)
399
399
 
400
+ // A caller-supplied `include` is required to fetch `posts` at all — a
401
+ // bare read returns scalars only (ADR-0024) — so name it explicitly to
402
+ // exercise the per-relation access `where` merge.
400
403
  await context.db.user.findUnique({
401
404
  where: { id: '1' },
405
+ include: { posts: true },
402
406
  })
403
407
 
404
408
  // Verify findFirst was called with access filter
@@ -469,8 +473,11 @@ describe('Nested Operations - Access Control and Hooks', () => {
469
473
 
470
474
  const context = getContext(await testConfig, mockPrisma, null)
471
475
 
476
+ // `author` must be named explicitly — a bare read returns scalars only
477
+ // (ADR-0024).
472
478
  const result = await context.db.post.findUnique({
473
479
  where: { id: '1' },
480
+ include: { author: true },
474
481
  })
475
482
 
476
483
  // Email should be filtered out
@@ -522,8 +529,11 @@ describe('Nested Operations - Access Control and Hooks', () => {
522
529
 
523
530
  const context = getContext(await testConfig, mockPrisma, null)
524
531
 
532
+ // `author` must be named explicitly to exercise the access-denied drop —
533
+ // a bare read never requests it at all (ADR-0024).
525
534
  await context.db.post.findUnique({
526
535
  where: { id: '1' },
536
+ include: { author: true },
527
537
  })
528
538
 
529
539
  // Verify include does NOT include author (access denied)
@@ -136,9 +136,12 @@ describe('resolve chain — cycle guard terminates hook-issued reads (#844)', ()
136
136
  )
137
137
  })
138
138
 
139
- it("reproduces the reporter's 3-list cyclic schema (User → Account → Student) within a bounded query budget", async () => {
139
+ it("no longer reproduces the reporter's 3-list cyclic schema (User → Account → Student) on a bare hook-issued read (#848, ADR-0024)", async () => {
140
140
  // Sketch matches the issue's minimal reproduction: User.name reads
141
- // Account, whose Student rows' own virtual field reads Account again.
141
+ // Account, whose Student rows' own virtual field reads Account again. The
142
+ // hook's read is BARE (no `include`), so under ADR-0024 it fetches
143
+ // Account's own columns only — `students` is never fetched, the walk into
144
+ // `Student.label` never happens, and the cycle cannot form.
142
145
  const config: OpenSaasConfig = {
143
146
  db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
144
147
  lists: {
@@ -191,33 +194,42 @@ describe('resolve chain — cycle guard terminates hook-issued reads (#844)', ()
191
194
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
192
195
  const prisma: any = { user: makeModel(), account: makeModel(), student: makeModel() }
193
196
  prisma.user.findMany.mockResolvedValue([{ id: 'u1' }])
194
- // Account rows returned to a hook-issued read embed their (row-scoped,
195
- // one-level) relations directly deliberately WITHOUT `user`, so the
196
- // walk goes through `students` `Student.label`, matching the trace in
197
- // the issue rather than short-circuiting through the `user` back-edge.
198
- prisma.account.findMany.mockResolvedValue([
199
- { id: 'a1', firstName: 'Ann', students: [{ id: 's1', accountId: 'a1' }] },
200
- ])
197
+ // Mirrors real Prisma semantics: a relation key is only present on the
198
+ // returned row when the caller actually asked for it via `include`. The
199
+ // hook's read never does, so `students` (and `user`) never appear here
200
+ // that is the mechanism that breaks the cycle under ADR-0024.
201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
+ prisma.account.findMany.mockImplementation((args: any = {}) => {
203
+ const row: Record<string, unknown> = { id: 'a1', firstName: 'Ann' }
204
+ if (args.include?.students) row.students = [{ id: 's1', accountId: 'a1' }]
205
+ if (args.include?.user) row.user = { id: 'u1' }
206
+ return Promise.resolve([row])
207
+ })
201
208
  // `context.db.<list>.findUnique` is implemented via the Prisma model's
202
209
  // `findFirst` (see `createFindUnique` in `context/index.ts`), not its
203
210
  // `findUnique` — mock the method actually called.
204
- prisma.account.findFirst.mockResolvedValue({
205
- id: 'a1',
206
- firstName: 'Ann',
207
- students: [{ id: 's1', accountId: 'a1' }],
211
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
+ prisma.account.findFirst.mockImplementation((args: any = {}) => {
213
+ const row: Record<string, unknown> = { id: 'a1', firstName: 'Ann' }
214
+ if (args.include?.students) row.students = [{ id: 's1', accountId: 'a1' }]
215
+ if (args.include?.user) row.user = { id: 'u1' }
216
+ return Promise.resolve(row)
208
217
  })
209
218
 
210
219
  const context = await getContext(config, prisma, null)
211
220
 
212
- // Must settle (not hang), and must not have driven an unbounded number of
213
- // queries before doing so this is the actual OOM mechanism from #844.
214
- await expect(context.db.user.findMany({})).rejects.toThrow(ResolveOutputCycleError)
221
+ // Must settle (not hang or throw) the bare hook-issued read never
222
+ // fetches `students`, so `Student.label` never fires and there is no
223
+ // cycle to detect.
224
+ const result = await context.db.user.findMany({})
225
+ expect(result).toEqual([{ id: 'u1', name: 'Ann' }])
215
226
 
227
+ // Only the two reads the hook actually issues — no unbounded recursion.
216
228
  const totalCalls =
217
229
  prisma.user.findMany.mock.calls.length +
218
230
  prisma.account.findMany.mock.calls.length +
219
231
  prisma.account.findFirst.mock.calls.length
220
- expect(totalCalls).toBeLessThan(20)
232
+ expect(totalCalls).toBe(2)
221
233
  })
222
234
  })
223
235
 
@@ -319,7 +331,7 @@ describe('resolve chain — concurrent hook invocations are isolated (#844)', ()
319
331
  expect(observedLengths.sort()).toEqual([1, 1, 1])
320
332
  })
321
333
 
322
- it('an unrelated top-level read in flight alongside a hook still gets its full nested auto-include', async () => {
334
+ it('an unrelated top-level read in flight alongside a hook still gets its full explicit include scoped', async () => {
323
335
  let releaseSlowHook: () => void = () => {}
324
336
 
325
337
  const config: OpenSaasConfig = {
@@ -377,16 +389,21 @@ describe('resolve chain — concurrent hook invocations are isolated (#844)', ()
377
389
  await new Promise((resolve) => setTimeout(resolve, 0))
378
390
 
379
391
  // While the slow hook is still in flight, issue a plain top-level read
380
- // that has nothing to do with it.
381
- const fastPromise = context.db.fast.findMany({})
392
+ // that has nothing to do with it. A bare read fetches scalars only
393
+ // (ADR-0024), so name `child` explicitly — a caller include naming a
394
+ // relation BARE (`true`, no nested include of its own) still picks up the
395
+ // access-controlled include for whatever lies beneath it.
396
+ const fastPromise = context.db.fast.findMany({ include: { child: true } })
382
397
 
383
398
  await fastPromise
384
399
  releaseSlowHook()
385
400
  await slowPromise
386
401
 
387
- // The unrelated read's auto-include must still descend two levels deep
388
- // (child → grandchild), not collapse to a bare `{ child: true }` because
389
- // it happened to run while a totally different read's hook was active.
402
+ // The unrelated read's access-controlled scoping must still descend two
403
+ // levels deep (child → grandchild), not collapse to a bare `{ child: true }`
404
+ // because it happened to run while a totally different read's hook was
405
+ // active — `insideResolveOutput` must stay scoped to the hook's OWN
406
+ // derived context, never leak into a concurrently-running, unrelated one.
390
407
  expect(prisma.fast.findMany.mock.calls[0][0].include).toEqual({
391
408
  child: { include: { grandchild: true } },
392
409
  })
@@ -205,6 +205,89 @@ describe('Singleton Lists', () => {
205
205
  })
206
206
  })
207
207
 
208
+ describe('get() with a caller include (#848, ADR-0024)', () => {
209
+ // A singleton read gains the same caller-`include` handling as findUnique
210
+ // and findMany: bare fetches scalars only, and a caller include is merged
211
+ // with the access-controlled include (row-scoped per relation).
212
+ it("a bare get() sends no include and does not evaluate the related list's query access", async () => {
213
+ const homeAuthorQuerySpy = vi.fn(() => true)
214
+ const relConfig: OpenSaasConfig = {
215
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
216
+ lists: {
217
+ HomePage: {
218
+ fields: {
219
+ title: { type: 'text' },
220
+ featuredAuthor: { type: 'relationship', ref: 'Author' },
221
+ },
222
+ access: { operation: { query: () => true, create: () => true } },
223
+ isSingleton: true,
224
+ },
225
+ Author: {
226
+ fields: { name: { type: 'text' } },
227
+ access: { operation: { query: homeAuthorQuerySpy } },
228
+ },
229
+ },
230
+ }
231
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
232
+ const relPrisma: any = {
233
+ homePage: { findFirst: vi.fn(), create: vi.fn(), count: vi.fn() },
234
+ author: { findFirst: vi.fn(), findMany: vi.fn() },
235
+ }
236
+ relPrisma.homePage.findFirst.mockResolvedValue({
237
+ id: 1,
238
+ title: 'Home',
239
+ featuredAuthorId: 'a1',
240
+ })
241
+
242
+ const context = getContext(relConfig, relPrisma, null)
243
+ const result = await context.db.homePage.get()
244
+
245
+ expect(relPrisma.homePage.findFirst).toHaveBeenCalledWith({ where: {}, include: undefined })
246
+ expect(homeAuthorQuerySpy).not.toHaveBeenCalled()
247
+ expect(result).toEqual({ id: 1, title: 'Home', featuredAuthorId: 'a1' })
248
+ expect(result).not.toHaveProperty('featuredAuthor')
249
+ })
250
+
251
+ it('a caller include on get() is merged with the access-controlled include, row-scoped like any other read', async () => {
252
+ const homeAuthorQuerySpy = vi.fn(() => ({ published: { equals: true } }))
253
+ const relConfig: OpenSaasConfig = {
254
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
255
+ lists: {
256
+ HomePage: {
257
+ fields: {
258
+ title: { type: 'text' },
259
+ featuredAuthor: { type: 'relationship', ref: 'Author' },
260
+ },
261
+ access: { operation: { query: () => true, create: () => true } },
262
+ isSingleton: true,
263
+ },
264
+ Author: {
265
+ fields: { name: { type: 'text' } },
266
+ access: { operation: { query: homeAuthorQuerySpy } },
267
+ },
268
+ },
269
+ }
270
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
271
+ const relPrisma: any = {
272
+ homePage: { findFirst: vi.fn(), create: vi.fn(), count: vi.fn() },
273
+ author: { findFirst: vi.fn(), findMany: vi.fn() },
274
+ }
275
+ relPrisma.homePage.findFirst.mockResolvedValue({
276
+ id: 1,
277
+ title: 'Home',
278
+ featuredAuthorId: 'a1',
279
+ featuredAuthor: { id: 'a1', name: 'Ann' },
280
+ })
281
+
282
+ const context = getContext(relConfig, relPrisma, null)
283
+ const result = await context.db.homePage.get({ include: { featuredAuthor: true } })
284
+
285
+ const call = relPrisma.homePage.findFirst.mock.calls[0][0]
286
+ expect(call.include.featuredAuthor).toEqual({ where: { published: { equals: true } } })
287
+ expect(result?.featuredAuthor).toEqual({ id: 'a1', name: 'Ann' })
288
+ })
289
+ })
290
+
208
291
  describe('delete operation', () => {
209
292
  it('should block delete on singleton lists', async () => {
210
293
  mockPrisma.settings.findUnique.mockResolvedValue({