@opensaas/stack-core 0.1.7 → 0.3.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 (50) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +202 -0
  3. package/CLAUDE.md +46 -1
  4. package/dist/access/engine.d.ts +6 -5
  5. package/dist/access/engine.d.ts.map +1 -1
  6. package/dist/access/engine.js +17 -0
  7. package/dist/access/engine.js.map +1 -1
  8. package/dist/access/engine.test.d.ts +2 -0
  9. package/dist/access/engine.test.d.ts.map +1 -0
  10. package/dist/access/engine.test.js +125 -0
  11. package/dist/access/engine.test.js.map +1 -0
  12. package/dist/access/types.d.ts +39 -9
  13. package/dist/access/types.d.ts.map +1 -1
  14. package/dist/config/index.d.ts +38 -18
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js +34 -14
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/config/plugin-engine.d.ts.map +1 -1
  19. package/dist/config/plugin-engine.js +6 -0
  20. package/dist/config/plugin-engine.js.map +1 -1
  21. package/dist/config/types.d.ts +128 -21
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/context/index.d.ts +5 -3
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +127 -14
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/fields/index.d.ts.map +1 -1
  28. package/dist/fields/index.js +9 -8
  29. package/dist/fields/index.js.map +1 -1
  30. package/dist/hooks/index.d.ts +28 -12
  31. package/dist/hooks/index.d.ts.map +1 -1
  32. package/dist/hooks/index.js +16 -0
  33. package/dist/hooks/index.js.map +1 -1
  34. package/package.json +3 -4
  35. package/src/access/engine.test.ts +145 -0
  36. package/src/access/engine.ts +25 -6
  37. package/src/access/types.ts +38 -8
  38. package/src/config/index.ts +46 -19
  39. package/src/config/plugin-engine.ts +7 -0
  40. package/src/config/types.ts +149 -18
  41. package/src/context/index.ts +163 -17
  42. package/src/fields/index.ts +8 -7
  43. package/src/hooks/index.ts +63 -20
  44. package/tests/context.test.ts +38 -6
  45. package/tests/field-types.test.ts +728 -0
  46. package/tests/password-type-distribution.test.ts +0 -1
  47. package/tests/password-types.test.ts +0 -1
  48. package/tests/plugin-engine.test.ts +1102 -0
  49. package/tests/sudo.test.ts +0 -1
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { filterWritableFields } from './engine.js'
3
+
4
+ describe('filterWritableFields', () => {
5
+ it('should filter out foreign key fields when their corresponding relationship field exists', async () => {
6
+ // Setup: Define field configs with a relationship field
7
+ const fieldConfigs = {
8
+ title: {
9
+ type: 'text',
10
+ },
11
+ author: {
12
+ type: 'relationship',
13
+ many: false,
14
+ },
15
+ tags: {
16
+ type: 'relationship',
17
+ many: true, // Many-to-many relationships don't have foreign keys
18
+ },
19
+ }
20
+
21
+ // Data that includes both the foreign key (authorId) and other fields
22
+ const data = {
23
+ title: 'Test Post',
24
+ authorId: 'user-123', // This should be filtered out
25
+ tagsId: 'tag-456', // This should NOT be filtered (tags is many:true)
26
+ author: {
27
+ connect: { id: 'user-123' },
28
+ },
29
+ }
30
+
31
+ const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
32
+ session: null,
33
+ context: {
34
+ session: null,
35
+ _isSudo: true, // Use sudo to bypass access control checks
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ } as any,
38
+ })
39
+
40
+ // authorId should be filtered out
41
+ expect(filtered).not.toHaveProperty('authorId')
42
+
43
+ // title should remain
44
+ expect(filtered).toHaveProperty('title', 'Test Post')
45
+
46
+ // author relationship should remain
47
+ expect(filtered).toHaveProperty('author')
48
+ expect(filtered.author).toEqual({ connect: { id: 'user-123' } })
49
+
50
+ // tagsId should remain (tags is many:true, so no foreign key is created)
51
+ expect(filtered).toHaveProperty('tagsId', 'tag-456')
52
+ })
53
+
54
+ it('should filter out system fields', async () => {
55
+ const fieldConfigs = {
56
+ title: { type: 'text' },
57
+ }
58
+
59
+ const data = {
60
+ id: 'post-123',
61
+ title: 'Test',
62
+ createdAt: new Date(),
63
+ updatedAt: new Date(),
64
+ }
65
+
66
+ const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
67
+ session: null,
68
+ context: {
69
+ session: null,
70
+ _isSudo: true,
71
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
+ } as any,
73
+ })
74
+
75
+ // System fields should be filtered out
76
+ expect(filtered).not.toHaveProperty('id')
77
+ expect(filtered).not.toHaveProperty('createdAt')
78
+ expect(filtered).not.toHaveProperty('updatedAt')
79
+
80
+ // Regular fields should remain
81
+ expect(filtered).toHaveProperty('title', 'Test')
82
+ })
83
+
84
+ it('should handle update operation', async () => {
85
+ const fieldConfigs = {
86
+ title: { type: 'text' },
87
+ author: {
88
+ type: 'relationship',
89
+ many: false,
90
+ },
91
+ }
92
+
93
+ const data = {
94
+ title: 'Updated Title',
95
+ authorId: 'user-456', // Should be filtered out
96
+ author: {
97
+ connect: { id: 'user-456' },
98
+ },
99
+ }
100
+
101
+ const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
102
+ session: null,
103
+ item: { id: 'post-123' },
104
+ context: {
105
+ session: null,
106
+ _isSudo: true,
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ } as any,
109
+ })
110
+
111
+ expect(filtered).not.toHaveProperty('authorId')
112
+ expect(filtered).toHaveProperty('title', 'Updated Title')
113
+ expect(filtered).toHaveProperty('author')
114
+ })
115
+
116
+ it('should not filter fields that happen to end with "Id" but are not foreign keys', async () => {
117
+ const fieldConfigs = {
118
+ trackingId: { type: 'text' }, // Regular field that happens to end with "Id"
119
+ author: {
120
+ type: 'relationship',
121
+ many: false,
122
+ },
123
+ }
124
+
125
+ const data = {
126
+ trackingId: 'track-123', // Should NOT be filtered (it's a regular field)
127
+ authorId: 'user-456', // SHOULD be filtered (it's a foreign key)
128
+ }
129
+
130
+ const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
131
+ session: null,
132
+ context: {
133
+ session: null,
134
+ _isSudo: true,
135
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
+ } as any,
137
+ })
138
+
139
+ // trackingId is a defined field, so it should remain
140
+ expect(filtered).toHaveProperty('trackingId', 'track-123')
141
+
142
+ // authorId is a foreign key for author relationship, so it should be filtered
143
+ expect(filtered).not.toHaveProperty('authorId')
144
+ })
145
+ })
@@ -63,7 +63,7 @@ export function getRelatedListConfig(
63
63
  export async function checkAccess<T = Record<string, unknown>>(
64
64
  accessControl: AccessControl<T> | undefined,
65
65
  args: {
66
- session: Session
66
+ session: Session | null
67
67
  item?: T
68
68
  context: AccessContext
69
69
  },
@@ -114,7 +114,7 @@ export async function checkFieldAccess(
114
114
  fieldAccess: FieldAccess | undefined,
115
115
  operation: 'read' | 'create' | 'update',
116
116
  args: {
117
- session: Session
117
+ session: Session | null
118
118
  item?: Record<string, unknown>
119
119
  context: AccessContext & { _isSudo?: boolean }
120
120
  },
@@ -190,7 +190,7 @@ function matchesFilter(item: Record<string, unknown>, filter: Record<string, unk
190
190
  export async function buildIncludeWithAccessControl(
191
191
  fieldConfigs: Record<string, FieldConfig>,
192
192
  args: {
193
- session: Session
193
+ session: Session | null
194
194
  context: AccessContext
195
195
  },
196
196
  config: OpenSaasConfig,
@@ -261,7 +261,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
261
261
  item: T,
262
262
  fieldConfigs: Record<string, FieldConfig>,
263
263
  args: {
264
- session: Session
264
+ session: Session | null
265
265
  context: AccessContext & { _isSudo?: boolean }
266
266
  },
267
267
  config?: OpenSaasConfig,
@@ -365,16 +365,29 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
365
365
  */
366
366
  export async function filterWritableFields<T extends Record<string, unknown>>(
367
367
  data: T,
368
- fieldConfigs: Record<string, { access?: FieldAccess }>,
368
+ fieldConfigs: Record<string, { access?: FieldAccess; type?: string }>,
369
369
  operation: 'create' | 'update',
370
370
  args: {
371
- session: Session
371
+ session: Session | null
372
372
  item?: Record<string, unknown>
373
373
  context: AccessContext & { _isSudo?: boolean }
374
374
  },
375
375
  ): Promise<Partial<T>> {
376
376
  const filtered: Record<string, unknown> = {}
377
377
 
378
+ // Build a set of foreign key field names to exclude
379
+ // Foreign keys should not be in the data when using Prisma's relation syntax
380
+ const foreignKeyFields = new Set<string>()
381
+ for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
382
+ if (fieldConfig.type === 'relationship') {
383
+ // For non-many relationships, Prisma creates a foreign key field named `${fieldName}Id`
384
+ const relConfig = fieldConfig as { many?: boolean }
385
+ if (!relConfig.many) {
386
+ foreignKeyFields.add(`${fieldName}Id`)
387
+ }
388
+ }
389
+ }
390
+
378
391
  for (const [fieldName, value] of Object.entries(data)) {
379
392
  const fieldConfig = fieldConfigs[fieldName]
380
393
 
@@ -383,6 +396,12 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
383
396
  continue
384
397
  }
385
398
 
399
+ // Skip foreign key fields (e.g., authorId) when their corresponding relationship field exists
400
+ // This prevents conflicts when using Prisma's relation syntax (e.g., author: { connect: { id } })
401
+ if (foreignKeyFields.has(fieldName)) {
402
+ continue
403
+ }
404
+
386
405
  // Check field access (checkFieldAccess already handles sudo mode)
387
406
  const canWrite = await checkFieldAccess(fieldConfig?.access, operation, {
388
407
  ...args,
@@ -1,10 +1,39 @@
1
1
  /**
2
- * Session type - can be extended by users
2
+ * Session interface - can be augmented by developers to add custom fields
3
+ *
4
+ * By default, Session is a permissive object that can contain any properties.
5
+ * To get type safety and autocomplete, use module augmentation:
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // types/session.d.ts
10
+ * import '@opensaas/stack-core'
11
+ *
12
+ * declare module '@opensaas/stack-core' {
13
+ * interface Session {
14
+ * userId: string
15
+ * email: string
16
+ * role: 'admin' | 'user'
17
+ * }
18
+ * }
19
+ * ```
20
+ *
21
+ * After augmentation, session will be fully typed everywhere:
22
+ * - Access control functions
23
+ * - Hooks (resolveInput, validateInput, etc.)
24
+ * - Context object
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * // With augmentation, this is fully typed:
29
+ * const isAdmin: AccessControl = ({ session }) => {
30
+ * return session?.role === 'admin' // ✅ Autocomplete works
31
+ * }
32
+ * ```
3
33
  */
4
- export type Session = {
5
- userId?: string
34
+ export interface Session {
6
35
  [key: string]: unknown
7
- } | null
36
+ }
8
37
 
9
38
  /**
10
39
  * Generic Prisma model delegate type
@@ -113,14 +142,15 @@ export type StorageUtils = {
113
142
 
114
143
  /**
115
144
  * Context type (simplified for access control)
145
+ * Using interface instead of type to allow module augmentation
116
146
  */
117
- export type AccessContext<TPrisma extends PrismaClientLike = PrismaClientLike> = {
118
- session: Session
147
+ export interface AccessContext<TPrisma extends PrismaClientLike = PrismaClientLike> {
148
+ session: Session | null
119
149
  prisma: TPrisma
120
150
  db: AccessControlledDB<TPrisma>
121
151
  storage: StorageUtils
152
+ plugins: Record<string, unknown>
122
153
  _isSudo: boolean
123
- [key: string]: unknown
124
154
  }
125
155
 
126
156
  /**
@@ -136,7 +166,7 @@ export type PrismaFilter<T = Record<string, unknown>> = Partial<Record<keyof T,
136
166
  * - PrismaFilter: Prisma where clause to filter results
137
167
  */
138
168
  export type AccessControl<T = Record<string, unknown>> = (args: {
139
- session: Session
169
+ session: Session | null
140
170
  item?: T // Present for update/delete operations
141
171
  context: AccessContext
142
172
  }) => boolean | PrismaFilter<T> | Promise<boolean | PrismaFilter<T>>
@@ -21,40 +21,66 @@ export function config(userConfig: OpenSaasConfig): OpenSaasConfig | Promise<Ope
21
21
  /**
22
22
  * Helper function to define a list with type safety
23
23
  *
24
- * Accepts raw field configs and transforms them to inject the item type T
25
- * This enables proper typing in field hooks where item: T
24
+ * Accepts raw field configs and transforms them to inject the item type
25
+ * This enables proper typing in field hooks where item is typed correctly
26
26
  *
27
27
  * @example
28
28
  * ```typescript
29
- * import type { User } from './.opensaas/types'
29
+ * // Basic usage (before generation)
30
+ * Post: list({
31
+ * fields: { title: text() },
32
+ * hooks: {
33
+ * resolveInput: async ({ resolvedData }) => {
34
+ * // resolvedData: Record<string, unknown>
35
+ * return resolvedData
36
+ * }
37
+ * }
38
+ * })
39
+ *
40
+ * // With TypeInfo (after generation)
41
+ * import type { Lists } from './.opensaas/lists'
30
42
  *
31
- * User: list<User>({
32
- * fields: {
33
- * password: password({
34
- * hooks: {
35
- * resolveInput: async ({ inputValue, item }) => {
36
- * // item is typed as User | undefined
37
- * // inputValue is typed as string | undefined
38
- * return hashPassword(inputValue)
39
- * }
43
+ * Post: list<Lists.Post.TypeInfo>({
44
+ * fields: { title: text() },
45
+ * hooks: {
46
+ * resolveInput: async ({ operation, resolvedData, item }) => {
47
+ * if (operation === 'create') {
48
+ * // resolvedData: Prisma.PostCreateInput
49
+ * // item: undefined
50
+ * } else {
51
+ * // resolvedData: Prisma.PostUpdateInput
52
+ * // item: Post
40
53
  * }
41
- * })
54
+ * return resolvedData
55
+ * }
42
56
  * }
43
57
  * })
58
+ *
59
+ * // Or as a typed constant
60
+ * const Post: Lists.Post = list({
61
+ * fields: { title: text() },
62
+ * hooks: { ... }
63
+ * })
44
64
  * ```
45
65
  */
46
66
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
- export function list<T = any>(config: {
67
+ export function list<
68
+ TTypeInfo extends import('./types.js').TypeInfo = import('./types.js').TypeInfo,
69
+ >(config: {
48
70
  fields: Record<string, FieldConfig>
49
71
  access?: {
50
- operation?: OperationAccess<T>
72
+ operation?: OperationAccess<TTypeInfo['item']>
51
73
  }
52
- hooks?: Hooks<T>
74
+ hooks?: Hooks<TTypeInfo['item'], TTypeInfo['inputs']['create'], TTypeInfo['inputs']['update']>
53
75
  mcp?: import('./types.js').ListMcpConfig
54
- }): ListConfig<T> {
76
+ }): ListConfig<TTypeInfo['item'], TTypeInfo['inputs']['create'], TTypeInfo['inputs']['update']> {
55
77
  // At runtime, field configs are unchanged
56
- // At type level, they're transformed to inject T as the item type
57
- return config as ListConfig<T>
78
+ // At type level, they're transformed to inject TypeInfo types
79
+ return config as ListConfig<
80
+ TTypeInfo['item'],
81
+ TTypeInfo['inputs']['create'],
82
+ TTypeInfo['inputs']['update']
83
+ >
58
84
  }
59
85
 
60
86
  // Re-export all types
@@ -70,6 +96,7 @@ export type {
70
96
  PasswordField,
71
97
  SelectField,
72
98
  RelationshipField,
99
+ TypeInfo,
73
100
  OperationAccess,
74
101
  Hooks,
75
102
  FieldHooks,
@@ -257,6 +257,13 @@ export async function executePlugins(config: OpenSaasConfig): Promise<OpenSaasCo
257
257
  currentConfig._pluginData.__mcpTools = mcpToolsRegistry
258
258
  }
259
259
 
260
+ // Store plugin instances in config for runtime access
261
+ // This allows context creation to call plugin.runtime() functions
262
+ if (!currentConfig._plugins) {
263
+ currentConfig._plugins = []
264
+ }
265
+ currentConfig._plugins = sortedPlugins
266
+
260
267
  return currentConfig
261
268
  }
262
269
 
@@ -345,6 +345,42 @@ export type FieldsWithItemType<TFields extends Record<string, FieldConfig>, TIte
345
345
  [K in keyof TFields]: WithItemType<TFields[K], TItem>
346
346
  }
347
347
 
348
+ /**
349
+ * TypeInfo interface for list type information
350
+ * Provides a structured way to pass all type information for a list
351
+ * Inspired by Keystone's TypeInfo pattern
352
+ *
353
+ * @template TKey - The list key/name (e.g., 'Post', 'User')
354
+ * @template TItem - The output type (Prisma model type)
355
+ * @template TCreateInput - The Prisma create input type
356
+ * @template TUpdateInput - The Prisma update input type
357
+ *
358
+ * @example
359
+ * ```typescript
360
+ * type PostTypeInfo = {
361
+ * key: 'Post'
362
+ * item: Post
363
+ * inputs: {
364
+ * create: Prisma.PostCreateInput
365
+ * update: Prisma.PostUpdateInput
366
+ * }
367
+ * }
368
+ * ```
369
+ */
370
+ export interface TypeInfo<
371
+ TKey extends string = string,
372
+ TItem = any, // eslint-disable-line @typescript-eslint/no-explicit-any
373
+ TCreateInput = any, // eslint-disable-line @typescript-eslint/no-explicit-any
374
+ TUpdateInput = any, // eslint-disable-line @typescript-eslint/no-explicit-any
375
+ > {
376
+ key: TKey
377
+ item: TItem
378
+ inputs: {
379
+ create: TCreateInput
380
+ update: TUpdateInput
381
+ }
382
+ }
383
+
348
384
  // Generic `any` default allows OperationAccess to work with any list item type
349
385
  // This is needed because the item type varies per list and is inferred from Prisma models
350
386
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -355,36 +391,74 @@ export type OperationAccess<T = any> = {
355
391
  delete?: AccessControl<T>
356
392
  }
357
393
 
358
- export type HookArgs<T = Record<string, unknown>> = {
394
+ /**
395
+ * Hook arguments for resolveInput hook
396
+ * Uses discriminated union to provide proper types based on operation
397
+ * - create: resolvedData is CreateInput, item is undefined
398
+ * - update: resolvedData is UpdateInput, item is the existing record
399
+ */
400
+ export type ResolveInputHookArgs<
401
+ TOutput = Record<string, unknown>,
402
+ TCreateInput = Record<string, unknown>,
403
+ TUpdateInput = Record<string, unknown>,
404
+ > =
405
+ | {
406
+ operation: 'create'
407
+ resolvedData: TCreateInput
408
+ item: undefined
409
+ context: import('../access/types.js').AccessContext
410
+ }
411
+ | {
412
+ operation: 'update'
413
+ resolvedData: TUpdateInput
414
+ item: TOutput
415
+ context: import('../access/types.js').AccessContext
416
+ }
417
+
418
+ /**
419
+ * Hook arguments for other hooks (validateInput, beforeOperation, afterOperation)
420
+ * These hooks receive the same structure regardless of operation
421
+ */
422
+ export type HookArgs<
423
+ TOutput = Record<string, unknown>,
424
+ TCreateInput = Record<string, unknown>,
425
+ TUpdateInput = Record<string, unknown>,
426
+ > = {
359
427
  operation: 'create' | 'update' | 'delete'
360
- resolvedData?: Partial<T>
361
- item?: T
428
+ resolvedData?: TCreateInput | TUpdateInput
429
+ item?: TOutput
362
430
  context: import('../access/types.js').AccessContext
363
431
  }
364
432
 
365
- export type Hooks<T = Record<string, unknown>> = {
366
- resolveInput?: (args: HookArgs<T> & { operation: 'create' | 'update' }) => Promise<Partial<T>>
433
+ export type Hooks<
434
+ TOutput = Record<string, unknown>,
435
+ TCreateInput = Record<string, unknown>,
436
+ TUpdateInput = Record<string, unknown>,
437
+ > = {
438
+ resolveInput?: (
439
+ args: ResolveInputHookArgs<TOutput, TCreateInput, TUpdateInput>,
440
+ ) => Promise<TCreateInput | TUpdateInput>
367
441
  validateInput?: (
368
- args: HookArgs<T> & {
442
+ args: HookArgs<TOutput, TCreateInput, TUpdateInput> & {
369
443
  operation: 'create' | 'update'
370
444
  addValidationError: (msg: string) => void
371
445
  },
372
446
  ) => Promise<void>
373
- beforeOperation?: (args: HookArgs<T>) => Promise<void>
374
- afterOperation?: (args: HookArgs<T>) => Promise<void>
447
+ beforeOperation?: (args: HookArgs<TOutput, TCreateInput, TUpdateInput>) => Promise<void>
448
+ afterOperation?: (args: HookArgs<TOutput, TCreateInput, TUpdateInput>) => Promise<void>
375
449
  }
376
450
 
377
451
  // Generic `any` default allows ListConfig to work with any list item type
378
452
  // This is needed because the item type varies per list and is inferred from Prisma models
379
453
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
380
- export type ListConfig<T = any> = {
454
+ export type ListConfig<TOutput = any, TCreateInput = any, TUpdateInput = any> = {
381
455
  // Field configs are automatically transformed to inject the item type T
382
456
  // This enables proper typing in field hooks where item: TItem
383
- fields: FieldsWithItemType<Record<string, FieldConfig>, T>
457
+ fields: FieldsWithItemType<Record<string, FieldConfig>, TOutput>
384
458
  access?: {
385
- operation?: OperationAccess<T>
459
+ operation?: OperationAccess<TOutput>
386
460
  }
387
- hooks?: Hooks<T>
461
+ hooks?: Hooks<TOutput, TCreateInput, TUpdateInput>
388
462
  /**
389
463
  * MCP server configuration for this list
390
464
  */
@@ -396,12 +470,37 @@ export type ListConfig<T = any> = {
396
470
  */
397
471
  export type DatabaseConfig = {
398
472
  provider: 'postgresql' | 'mysql' | 'sqlite'
399
- url: string
400
473
  /**
401
- * Optional factory function to create a custom Prisma client instance
402
- * Receives the PrismaClient class and returns a configured instance
474
+ * Factory function to create a Prisma client instance with a database adapter
475
+ * Required in Prisma 7+ - receives the PrismaClient class and returns a configured instance
403
476
  *
404
- * @example
477
+ * The connection URL is passed directly to the adapter, not to the config.
478
+ *
479
+ * @example SQLite with better-sqlite3
480
+ * ```typescript
481
+ * import { PrismaBetterSQLite3 } from '@prisma/adapter-better-sqlite3'
482
+ * import Database from 'better-sqlite3'
483
+ *
484
+ * prismaClientConstructor: (PrismaClient) => {
485
+ * const db = new Database(process.env.DATABASE_URL || './dev.db')
486
+ * const adapter = new PrismaBetterSQLite3(db)
487
+ * return new PrismaClient({ adapter })
488
+ * }
489
+ * ```
490
+ *
491
+ * @example PostgreSQL with pg
492
+ * ```typescript
493
+ * import { PrismaPg } from '@prisma/adapter-pg'
494
+ * import pg from 'pg'
495
+ *
496
+ * prismaClientConstructor: (PrismaClient) => {
497
+ * const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
498
+ * const adapter = new PrismaPg(pool)
499
+ * return new PrismaClient({ adapter })
500
+ * }
501
+ * ```
502
+ *
503
+ * @example Neon serverless (PostgreSQL)
405
504
  * ```typescript
406
505
  * import { PrismaNeon } from '@prisma/adapter-neon'
407
506
  * import { neonConfig } from '@neondatabase/serverless'
@@ -419,7 +518,7 @@ export type DatabaseConfig = {
419
518
  // Uses `any` for maximum flexibility with Prisma client constructors and adapters
420
519
  // Different database adapters have varying type signatures that are hard to unify
421
520
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
422
- prismaClientConstructor?: (PrismaClientClass: any) => any
521
+ prismaClientConstructor: (PrismaClientClass: any) => any
423
522
  }
424
523
 
425
524
  /**
@@ -846,12 +945,38 @@ export type Plugin = {
846
945
  * Return value is stored in context.plugins[pluginName]
847
946
  */
848
947
  runtime?: (context: import('../access/types.js').AccessContext) => unknown
948
+
949
+ /**
950
+ * Optional: Type metadata for runtime services
951
+ * Enables type-safe code generation for context.plugins
952
+ *
953
+ * @example
954
+ * ```typescript
955
+ * {
956
+ * import: "import type { AuthRuntimeServices } from '@opensaas/stack-auth/runtime'",
957
+ * typeName: "AuthRuntimeServices"
958
+ * }
959
+ * ```
960
+ */
961
+ runtimeServiceTypes?: {
962
+ /**
963
+ * Import statement to include in generated types file
964
+ * Must be a complete import statement with 'import type' and quotes
965
+ */
966
+ import: string
967
+ /**
968
+ * TypeScript type name to use in PluginServices interface
969
+ * Should match the exported type from the import
970
+ */
971
+ typeName: string
972
+ }
849
973
  }
850
974
 
851
975
  /**
852
976
  * Main configuration type
977
+ * Using interface instead of type to allow module augmentation
853
978
  */
854
- export type OpenSaasConfig = {
979
+ export interface OpenSaasConfig {
855
980
  db: DatabaseConfig
856
981
  lists: Record<string, ListConfig>
857
982
  session?: SessionConfig
@@ -881,4 +1006,10 @@ export type OpenSaasConfig = {
881
1006
  * @internal
882
1007
  */
883
1008
  _pluginData?: Record<string, unknown>
1009
+ /**
1010
+ * Sorted plugin instances (stored after plugin execution)
1011
+ * Used at runtime to call plugin.runtime() functions
1012
+ * @internal
1013
+ */
1014
+ _plugins?: Plugin[]
884
1015
  }