@opensaas/stack-core 0.20.1 → 0.21.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 (105) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +72 -0
  3. package/CLAUDE.md +18 -2
  4. package/dist/access/access-filter.d.ts +29 -0
  5. package/dist/access/access-filter.d.ts.map +1 -0
  6. package/dist/access/access-filter.js +68 -0
  7. package/dist/access/access-filter.js.map +1 -0
  8. package/dist/access/engine.d.ts +15 -48
  9. package/dist/access/engine.d.ts.map +1 -1
  10. package/dist/access/engine.js +14 -280
  11. package/dist/access/engine.js.map +1 -1
  12. package/dist/access/field-access.d.ts +44 -0
  13. package/dist/access/field-access.d.ts.map +1 -0
  14. package/dist/access/field-access.js +123 -0
  15. package/dist/access/field-access.js.map +1 -0
  16. package/dist/access/field-access.test.d.ts +2 -0
  17. package/dist/access/field-access.test.d.ts.map +1 -0
  18. package/dist/access/{engine.test.js → field-access.test.js} +2 -2
  19. package/dist/access/field-access.test.js.map +1 -0
  20. package/dist/access/field-visibility.d.ts +13 -0
  21. package/dist/access/field-visibility.d.ts.map +1 -0
  22. package/dist/access/field-visibility.js +155 -0
  23. package/dist/access/field-visibility.js.map +1 -0
  24. package/dist/access/index.d.ts +4 -1
  25. package/dist/access/index.d.ts.map +1 -1
  26. package/dist/access/index.js +8 -1
  27. package/dist/access/index.js.map +1 -1
  28. package/dist/config/index.d.ts +1 -1
  29. package/dist/config/index.d.ts.map +1 -1
  30. package/dist/config/types.d.ts +45 -4
  31. package/dist/config/types.d.ts.map +1 -1
  32. package/dist/context/hook-pipeline.d.ts +49 -0
  33. package/dist/context/hook-pipeline.d.ts.map +1 -0
  34. package/dist/context/hook-pipeline.js +75 -0
  35. package/dist/context/hook-pipeline.js.map +1 -0
  36. package/dist/context/index.d.ts.map +1 -1
  37. package/dist/context/index.js +30 -462
  38. package/dist/context/index.js.map +1 -1
  39. package/dist/context/nested-operations.d.ts.map +1 -1
  40. package/dist/context/nested-operations.js +72 -68
  41. package/dist/context/nested-operations.js.map +1 -1
  42. package/dist/context/write-pipeline.d.ts +158 -0
  43. package/dist/context/write-pipeline.d.ts.map +1 -0
  44. package/dist/context/write-pipeline.js +306 -0
  45. package/dist/context/write-pipeline.js.map +1 -0
  46. package/dist/extend.d.ts +3 -0
  47. package/dist/extend.d.ts.map +1 -0
  48. package/dist/extend.js +10 -0
  49. package/dist/extend.js.map +1 -0
  50. package/dist/fields/index.d.ts +1 -0
  51. package/dist/fields/index.d.ts.map +1 -1
  52. package/dist/fields/index.js +213 -2
  53. package/dist/fields/index.js.map +1 -1
  54. package/dist/hooks/index.d.ts +20 -0
  55. package/dist/hooks/index.d.ts.map +1 -1
  56. package/dist/hooks/index.js +202 -0
  57. package/dist/hooks/index.js.map +1 -1
  58. package/dist/index.d.ts +5 -9
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +19 -10
  61. package/dist/index.js.map +1 -1
  62. package/dist/internal.d.ts +8 -0
  63. package/dist/internal.d.ts.map +1 -0
  64. package/dist/internal.js +16 -0
  65. package/dist/internal.js.map +1 -0
  66. package/dist/validation/field-config.d.ts +55 -0
  67. package/dist/validation/field-config.d.ts.map +1 -0
  68. package/dist/validation/field-config.js +100 -0
  69. package/dist/validation/field-config.js.map +1 -0
  70. package/dist/validation/field-config.test.d.ts +2 -0
  71. package/dist/validation/field-config.test.d.ts.map +1 -0
  72. package/dist/validation/field-config.test.js +159 -0
  73. package/dist/validation/field-config.test.js.map +1 -0
  74. package/package.json +11 -3
  75. package/src/access/access-filter.ts +97 -0
  76. package/src/access/engine.ts +13 -396
  77. package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
  78. package/src/access/field-access.ts +159 -0
  79. package/src/access/field-visibility.ts +247 -0
  80. package/src/access/index.ts +7 -4
  81. package/src/config/index.ts +1 -0
  82. package/src/config/types.ts +51 -4
  83. package/src/context/hook-pipeline.ts +160 -0
  84. package/src/context/index.ts +29 -667
  85. package/src/context/nested-operations.ts +142 -111
  86. package/src/context/write-pipeline.ts +543 -0
  87. package/src/extend.ts +14 -0
  88. package/src/fields/index.ts +310 -2
  89. package/src/hooks/index.ts +227 -0
  90. package/src/index.ts +27 -90
  91. package/src/internal.ts +49 -0
  92. package/src/validation/field-config.test.ts +199 -0
  93. package/src/validation/field-config.ts +145 -0
  94. package/tests/access-relationships.test.ts +4 -4
  95. package/tests/access.test.ts +1 -1
  96. package/tests/field-hooks.test.ts +410 -0
  97. package/tests/field-types.test.ts +1 -1
  98. package/tests/hook-pipeline.test.ts +233 -0
  99. package/tests/nested-operation-registry.test.ts +206 -0
  100. package/tests/write-pipeline.test.ts +588 -0
  101. package/tsconfig.tsbuildinfo +1 -1
  102. package/vitest.config.ts +43 -1
  103. package/dist/access/engine.test.d.ts +0 -2
  104. package/dist/access/engine.test.d.ts.map +0 -1
  105. package/dist/access/engine.test.js.map +0 -1
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Describe a field for an error message. Falls back to a literal when a
3
+ * `type`-less value slips through (e.g. a malformed third-party field).
4
+ */
5
+ function describeFieldType(field) {
6
+ return typeof field.type === 'string' && field.type.length > 0 ? field.type : 'unknown';
7
+ }
8
+ /**
9
+ * Type-safe probe for a function-valued property on a field config.
10
+ *
11
+ * The three scalar contract methods live on `BaseFieldConfig` and are checked
12
+ * directly. `getPrismaRelation` only exists on the relationship field variant,
13
+ * so it is probed structurally here without widening the public type or
14
+ * reaching for a cast.
15
+ */
16
+ function hasFieldMethod(field, method) {
17
+ const value = Reflect.get(field, method);
18
+ return typeof value === 'function';
19
+ }
20
+ /**
21
+ * Build the canonical error message for a missing contract method.
22
+ */
23
+ function buildMessage(fieldType, method, fieldKey, listKey) {
24
+ const location = listKey ? `Field "${listKey}.${fieldKey}"` : `Field "${fieldKey}"`;
25
+ return (`${location} (type "${fieldType}") is not self-contained: it does not implement ` +
26
+ `${method}(). Field builders must provide this method so the generator can ` +
27
+ `produce schema and types without inspecting field internals.`);
28
+ }
29
+ /**
30
+ * Validate a single field against the self-containment contract.
31
+ *
32
+ * The contract is conditional on field kind, mirroring exactly where the
33
+ * generators delegate:
34
+ *
35
+ * - `relationship` fields contribute schema via `getPrismaRelation` and are
36
+ * skipped by the scalar Prisma/TypeScript/Zod paths, so only
37
+ * `getPrismaRelation` is required.
38
+ * - `virtual` fields are not stored in the database, so `getPrismaType` is
39
+ * legitimately absent; they must still provide `getTypeScriptType` and
40
+ * `getZodSchema`.
41
+ * - every other (stored scalar) field must provide `getPrismaType`,
42
+ * `getTypeScriptType`, and `getZodSchema`.
43
+ *
44
+ * @param field - The field config produced by a field builder.
45
+ * @param fieldKey - The field's key within its list (for messages).
46
+ * @param listKey - The owning list's key (optional, for messages).
47
+ * @returns Zero or more structured errors; empty means the field is compliant.
48
+ */
49
+ export function validateFieldConfig(field, fieldKey, listKey) {
50
+ const errors = [];
51
+ const fieldType = describeFieldType(field);
52
+ const requireMethod = (method) => {
53
+ if (!hasFieldMethod(field, method)) {
54
+ errors.push({
55
+ listKey,
56
+ fieldKey,
57
+ fieldType,
58
+ missingMethod: method,
59
+ message: buildMessage(fieldType, method, fieldKey, listKey),
60
+ });
61
+ }
62
+ };
63
+ if (field.type === 'relationship') {
64
+ // Relationships render through the relationship path only.
65
+ requireMethod('getPrismaRelation');
66
+ return errors;
67
+ }
68
+ if (field.virtual === true || field.type === 'virtual') {
69
+ // Virtual fields are not persisted, so getPrismaType is intentionally absent.
70
+ requireMethod('getTypeScriptType');
71
+ requireMethod('getZodSchema');
72
+ return errors;
73
+ }
74
+ // Stored scalar fields must implement the full generation contract.
75
+ requireMethod('getPrismaType');
76
+ requireMethod('getTypeScriptType');
77
+ requireMethod('getZodSchema');
78
+ return errors;
79
+ }
80
+ /**
81
+ * Validate every field across every list in a config.
82
+ *
83
+ * Intended to run once, before any generation, so a misimplemented field
84
+ * surfaces a clear per-field message instead of a deep generator stack trace.
85
+ *
86
+ * @param config - The fully resolved OpenSaas config.
87
+ * @returns All self-containment violations, flattened across lists and fields.
88
+ */
89
+ export function validateConfigFields(config) {
90
+ const errors = [];
91
+ for (const [listKey, listConfig] of Object.entries(config.lists)) {
92
+ if (!listConfig?.fields)
93
+ continue;
94
+ for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
95
+ errors.push(...validateFieldConfig(fieldConfig, fieldKey, listKey));
96
+ }
97
+ }
98
+ return errors;
99
+ }
100
+ //# sourceMappingURL=field-config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field-config.js","sourceRoot":"","sources":["../../src/validation/field-config.ts"],"names":[],"mappings":"AAyBA;;;GAGG;AACH,SAAS,iBAAiB,CAAC,KAAkB;IAC3C,OAAO,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAA;AACzF,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,cAAc,CAAC,KAAkB,EAAE,MAAc;IACxD,MAAM,KAAK,GAAY,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;IACjD,OAAO,OAAO,KAAK,KAAK,UAAU,CAAA;AACpC,CAAC;AAED;;GAEG;AACH,SAAS,YAAY,CACnB,SAAiB,EACjB,MAAmD,EACnD,QAAgB,EAChB,OAAgB;IAEhB,MAAM,QAAQ,GAAG,OAAO,CAAC,CAAC,CAAC,UAAU,OAAO,IAAI,QAAQ,GAAG,CAAC,CAAC,CAAC,UAAU,QAAQ,GAAG,CAAA;IACnF,OAAO,CACL,GAAG,QAAQ,WAAW,SAAS,kDAAkD;QACjF,GAAG,MAAM,mEAAmE;QAC5E,8DAA8D,CAC/D,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAAkB,EAClB,QAAgB,EAChB,OAAgB;IAEhB,MAAM,MAAM,GAAiC,EAAE,CAAA;IAC/C,MAAM,SAAS,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAA;IAE1C,MAAM,aAAa,GAAG,CAAC,MAAmD,EAAQ,EAAE;QAClF,IAAI,CAAC,cAAc,CAAC,KAAK,EAAE,MAAM,CAAC,EAAE,CAAC;YACnC,MAAM,CAAC,IAAI,CAAC;gBACV,OAAO;gBACP,QAAQ;gBACR,SAAS;gBACT,aAAa,EAAE,MAAM;gBACrB,OAAO,EAAE,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,CAAC;aAC5D,CAAC,CAAA;QACJ,CAAC;IACH,CAAC,CAAA;IAED,IAAI,KAAK,CAAC,IAAI,KAAK,cAAc,EAAE,CAAC;QAClC,2DAA2D;QAC3D,aAAa,CAAC,mBAAmB,CAAC,CAAA;QAClC,OAAO,MAAM,CAAA;IACf,CAAC;IAED,IAAI,KAAK,CAAC,OAAO,KAAK,IAAI,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QACvD,8EAA8E;QAC9E,aAAa,CAAC,mBAAmB,CAAC,CAAA;QAClC,aAAa,CAAC,cAAc,CAAC,CAAA;QAC7B,OAAO,MAAM,CAAA;IACf,CAAC;IAED,oEAAoE;IACpE,aAAa,CAAC,eAAe,CAAC,CAAA;IAC9B,aAAa,CAAC,mBAAmB,CAAC,CAAA;IAClC,aAAa,CAAC,cAAc,CAAC,CAAA;IAE7B,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAsB;IACzD,MAAM,MAAM,GAAiC,EAAE,CAAA;IAE/C,KAAK,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;QACjE,IAAI,CAAC,UAAU,EAAE,MAAM;YAAE,SAAQ;QACjC,KAAK,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;YACxE,MAAM,CAAC,IAAI,CAAC,GAAG,mBAAmB,CAAC,WAAW,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAA;QACrE,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=field-config.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field-config.test.d.ts","sourceRoot":"","sources":["../../src/validation/field-config.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateFieldConfig, validateConfigFields } from './field-config.js';
3
+ import { text, integer, checkbox, timestamp, password, select, json, relationship, virtual, } from '../fields/index.js';
4
+ describe('validateFieldConfig', () => {
5
+ describe('well-formed fields pass', () => {
6
+ it.each([
7
+ ['text', text()],
8
+ ['integer', integer()],
9
+ ['checkbox', checkbox()],
10
+ ['timestamp', timestamp()],
11
+ ['password', password()],
12
+ ['select', select({ options: [{ label: 'A', value: 'a' }] })],
13
+ ['json', json()],
14
+ ])('a built-in %s field is self-contained', (_name, field) => {
15
+ expect(validateFieldConfig(field, 'myField', 'MyList')).toEqual([]);
16
+ });
17
+ it('a relationship field is self-contained via getPrismaRelation', () => {
18
+ const field = relationship({ ref: 'User.posts' });
19
+ expect(validateFieldConfig(field, 'author', 'Post')).toEqual([]);
20
+ });
21
+ it('a virtual field is self-contained without getPrismaType', () => {
22
+ const field = virtual({
23
+ type: 'string',
24
+ hooks: { resolveOutput: () => 'x' },
25
+ });
26
+ expect(validateFieldConfig(field, 'fullName', 'User')).toEqual([]);
27
+ });
28
+ });
29
+ describe('missing scalar contract methods fail', () => {
30
+ it('reports a missing getPrismaType naming the list, field, and method', () => {
31
+ const field = text();
32
+ delete field.getPrismaType;
33
+ const errors = validateFieldConfig(field, 'title', 'Post');
34
+ expect(errors).toHaveLength(1);
35
+ expect(errors[0]).toMatchObject({
36
+ listKey: 'Post',
37
+ fieldKey: 'title',
38
+ fieldType: 'text',
39
+ missingMethod: 'getPrismaType',
40
+ });
41
+ expect(errors[0].message).toContain('Post.title');
42
+ expect(errors[0].message).toContain('getPrismaType');
43
+ expect(errors[0].message).toContain('not self-contained');
44
+ });
45
+ it('reports a missing getTypeScriptType naming the method', () => {
46
+ const field = text();
47
+ delete field.getTypeScriptType;
48
+ const errors = validateFieldConfig(field, 'title', 'Post');
49
+ expect(errors).toHaveLength(1);
50
+ expect(errors[0].missingMethod).toBe('getTypeScriptType');
51
+ expect(errors[0].message).toContain('getTypeScriptType');
52
+ });
53
+ it('reports a missing getZodSchema naming the method', () => {
54
+ const field = text();
55
+ delete field.getZodSchema;
56
+ const errors = validateFieldConfig(field, 'title', 'Post');
57
+ expect(errors).toHaveLength(1);
58
+ expect(errors[0].missingMethod).toBe('getZodSchema');
59
+ expect(errors[0].message).toContain('getZodSchema');
60
+ });
61
+ it('reports every missing method when a field implements none', () => {
62
+ const field = { type: 'custom' };
63
+ const errors = validateFieldConfig(field, 'mystery', 'Widget');
64
+ expect(errors.map((e) => e.missingMethod).sort()).toEqual([
65
+ 'getPrismaType',
66
+ 'getTypeScriptType',
67
+ 'getZodSchema',
68
+ ]);
69
+ for (const error of errors) {
70
+ expect(error.message).toContain('Widget.mystery');
71
+ expect(error.message).toContain('custom');
72
+ }
73
+ });
74
+ it('works without a listKey (bare field validation)', () => {
75
+ const field = { type: 'custom' };
76
+ const errors = validateFieldConfig(field, 'mystery');
77
+ expect(errors).toHaveLength(3);
78
+ expect(errors[0].listKey).toBeUndefined();
79
+ expect(errors[0].message).toContain('Field "mystery"');
80
+ });
81
+ });
82
+ describe('relationship and virtual variants', () => {
83
+ it('reports a relationship missing getPrismaRelation', () => {
84
+ const field = { type: 'relationship' };
85
+ const errors = validateFieldConfig(field, 'author', 'Post');
86
+ expect(errors).toHaveLength(1);
87
+ expect(errors[0].missingMethod).toBe('getPrismaRelation');
88
+ expect(errors[0].message).toContain('Post.author');
89
+ });
90
+ it('reports a virtual field missing getTypeScriptType and getZodSchema', () => {
91
+ const field = { type: 'virtual', virtual: true };
92
+ const errors = validateFieldConfig(field, 'fullName', 'User');
93
+ expect(errors.map((e) => e.missingMethod).sort()).toEqual([
94
+ 'getTypeScriptType',
95
+ 'getZodSchema',
96
+ ]);
97
+ });
98
+ it('does not require getPrismaType for virtual fields', () => {
99
+ const field = { type: 'virtual', virtual: true };
100
+ const errors = validateFieldConfig(field, 'fullName', 'User');
101
+ expect(errors.some((e) => e.missingMethod === 'getPrismaType')).toBe(false);
102
+ });
103
+ });
104
+ });
105
+ describe('validateConfigFields', () => {
106
+ it('returns no errors for a fully compliant config', () => {
107
+ const config = {
108
+ db: {
109
+ provider: 'sqlite',
110
+ prismaClientConstructor: () => null,
111
+ },
112
+ lists: {
113
+ User: {
114
+ fields: {
115
+ name: text({ validation: { isRequired: true } }),
116
+ posts: relationship({ ref: 'Post.author', many: true }),
117
+ },
118
+ },
119
+ Post: {
120
+ fields: {
121
+ title: text(),
122
+ author: relationship({ ref: 'User.posts' }),
123
+ },
124
+ },
125
+ },
126
+ };
127
+ expect(validateConfigFields(config)).toEqual([]);
128
+ });
129
+ it('collects per-field errors across every list and names each location', () => {
130
+ const brokenTitle = text();
131
+ delete brokenTitle.getPrismaType;
132
+ const brokenName = text();
133
+ delete brokenName.getZodSchema;
134
+ const config = {
135
+ db: {
136
+ provider: 'sqlite',
137
+ prismaClientConstructor: () => null,
138
+ },
139
+ lists: {
140
+ User: {
141
+ fields: {
142
+ name: brokenName,
143
+ },
144
+ },
145
+ Post: {
146
+ fields: {
147
+ title: brokenTitle,
148
+ },
149
+ },
150
+ },
151
+ };
152
+ const errors = validateConfigFields(config);
153
+ expect(errors).toHaveLength(2);
154
+ const byField = Object.fromEntries(errors.map((e) => [`${e.listKey}.${e.fieldKey}`, e]));
155
+ expect(byField['User.name'].missingMethod).toBe('getZodSchema');
156
+ expect(byField['Post.title'].missingMethod).toBe('getPrismaType');
157
+ });
158
+ });
159
+ //# sourceMappingURL=field-config.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field-config.test.js","sourceRoot":"","sources":["../../src/validation/field-config.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opensaas/stack-core",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "Core stack for OpenSaas - schema definition, access control, and runtime utilities",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,6 +14,14 @@
14
14
  "types": "./dist/fields/index.d.ts",
15
15
  "default": "./dist/fields/index.js"
16
16
  },
17
+ "./extend": {
18
+ "types": "./dist/extend.d.ts",
19
+ "default": "./dist/extend.js"
20
+ },
21
+ "./internal": {
22
+ "types": "./dist/internal.d.ts",
23
+ "default": "./dist/internal.js"
24
+ },
17
25
  "./mcp": {
18
26
  "types": "./dist/mcp/index.d.ts",
19
27
  "default": "./dist/mcp/index.js"
@@ -48,8 +56,8 @@
48
56
  "zod": "^4.3.6"
49
57
  },
50
58
  "devDependencies": {
51
- "@prisma/client": "^7.4.2",
52
- "@types/node": "^24.12.0",
59
+ "@prisma/client": "^7.8.0",
60
+ "@types/node": "^25.9.1",
53
61
  "@vitest/coverage-v8": "^4.0.18",
54
62
  "typescript": "^5.9.3",
55
63
  "vitest": "^4.1.0"
@@ -0,0 +1,97 @@
1
+ import type { Session, AccessContext, PrismaFilter } from './types.js'
2
+ import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
3
+ import { checkAccess, getRelatedListConfig } from './engine.js'
4
+
5
+ /**
6
+ * Access Filter — phase 1 of the two-phase read (pre-query).
7
+ *
8
+ * This module scopes which rows and relationships the database is allowed to
9
+ * return, before the query runs. It evaluates *operation-level* `query` access
10
+ * on related lists and turns the results into a Prisma `include`/`where` clause,
11
+ * so denied rows and relations never leave the database.
12
+ *
13
+ * Phase 2 (post-query field stripping + `resolveOutput` + virtual computation)
14
+ * lives in `field-visibility.ts`. The two phases cannot be merged: virtual
15
+ * fields are computed in JavaScript and post-query field access can depend on
16
+ * the fetched row, neither of which is expressible in SQL. See
17
+ * `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
18
+ * glossary in `CONTEXT.md`.
19
+ */
20
+
21
+ /**
22
+ * Build Prisma include object with access control filters
23
+ * This allows us to filter relationships at the database level instead of in memory
24
+ */
25
+ export async function buildIncludeWithAccessControl(
26
+ fieldConfigs: Record<string, FieldConfig>,
27
+ args: {
28
+ session: Session | null
29
+ context: AccessContext
30
+ },
31
+ config: OpenSaasConfig,
32
+ depth: number = 0,
33
+ ) {
34
+ const MAX_DEPTH = 5
35
+ if (depth >= MAX_DEPTH) {
36
+ return undefined
37
+ }
38
+
39
+ // Skip auto-including relationships when inside a resolveOutput hook
40
+ // This prevents infinite loops when hooks make DB queries that include
41
+ // relationships back to the same entity (e.g., User virtual field queries Posts
42
+ // which includes author back to User, triggering the virtual field again)
43
+ if (args.context._resolveOutputCounter.depth > 0) {
44
+ return undefined
45
+ }
46
+
47
+ type IncludeEntry = boolean | { where?: PrismaFilter; include?: Record<string, IncludeEntry> }
48
+
49
+ const include: Record<string, IncludeEntry> = {}
50
+ let hasRelationships = false
51
+
52
+ for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
53
+ if (fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && fieldConfig.ref) {
54
+ hasRelationships = true
55
+ const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
56
+
57
+ if (relatedConfig) {
58
+ // Check query access for the related list
59
+ const queryAccess = relatedConfig.listConfig.access?.operation?.query
60
+ const accessResult = await checkAccess(queryAccess, {
61
+ session: args.session,
62
+ context: args.context,
63
+ })
64
+
65
+ // If access is completely denied, exclude this relationship
66
+ if (accessResult === false) {
67
+ continue
68
+ }
69
+
70
+ // Build the include entry
71
+ const includeEntry: Record<string, unknown> = {}
72
+
73
+ // If access returns a filter, add it to the where clause
74
+ if (typeof accessResult === 'object') {
75
+ includeEntry.where = accessResult
76
+ }
77
+
78
+ // Recursively build nested includes
79
+ const nestedInclude = await buildIncludeWithAccessControl(
80
+ relatedConfig.listConfig.fields,
81
+ args,
82
+ config,
83
+ depth + 1,
84
+ )
85
+
86
+ if (nestedInclude && Object.keys(nestedInclude).length > 0) {
87
+ includeEntry.include = nestedInclude
88
+ }
89
+
90
+ // Add to include object
91
+ include[fieldName] = Object.keys(includeEntry).length > 0 ? includeEntry : true
92
+ }
93
+ }
94
+ }
95
+
96
+ return hasRelationships ? include : undefined
97
+ }