@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +72 -0
- package/CLAUDE.md +18 -2
- package/dist/access/access-filter.d.ts +29 -0
- package/dist/access/access-filter.d.ts.map +1 -0
- package/dist/access/access-filter.js +68 -0
- package/dist/access/access-filter.js.map +1 -0
- package/dist/access/engine.d.ts +15 -48
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +14 -280
- package/dist/access/engine.js.map +1 -1
- package/dist/access/field-access.d.ts +44 -0
- package/dist/access/field-access.d.ts.map +1 -0
- package/dist/access/field-access.js +123 -0
- package/dist/access/field-access.js.map +1 -0
- package/dist/access/field-access.test.d.ts +2 -0
- package/dist/access/field-access.test.d.ts.map +1 -0
- package/dist/access/{engine.test.js → field-access.test.js} +2 -2
- package/dist/access/field-access.test.js.map +1 -0
- package/dist/access/field-visibility.d.ts +13 -0
- package/dist/access/field-visibility.d.ts.map +1 -0
- package/dist/access/field-visibility.js +155 -0
- package/dist/access/field-visibility.js.map +1 -0
- package/dist/access/index.d.ts +4 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +8 -1
- package/dist/access/index.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +45 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +49 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -0
- package/dist/context/hook-pipeline.js +75 -0
- package/dist/context/hook-pipeline.js.map +1 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +30 -462
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +72 -68
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +158 -0
- package/dist/context/write-pipeline.d.ts.map +1 -0
- package/dist/context/write-pipeline.js +306 -0
- package/dist/context/write-pipeline.js.map +1 -0
- package/dist/extend.d.ts +3 -0
- package/dist/extend.d.ts.map +1 -0
- package/dist/extend.js +10 -0
- package/dist/extend.js.map +1 -0
- package/dist/fields/index.d.ts +1 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +213 -2
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +20 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +202 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +5 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -10
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +8 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +16 -0
- package/dist/internal.js.map +1 -0
- package/dist/validation/field-config.d.ts +55 -0
- package/dist/validation/field-config.d.ts.map +1 -0
- package/dist/validation/field-config.js +100 -0
- package/dist/validation/field-config.js.map +1 -0
- package/dist/validation/field-config.test.d.ts +2 -0
- package/dist/validation/field-config.test.d.ts.map +1 -0
- package/dist/validation/field-config.test.js +159 -0
- package/dist/validation/field-config.test.js.map +1 -0
- package/package.json +11 -3
- package/src/access/access-filter.ts +97 -0
- package/src/access/engine.ts +13 -396
- package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
- package/src/access/field-access.ts +159 -0
- package/src/access/field-visibility.ts +247 -0
- package/src/access/index.ts +7 -4
- package/src/config/index.ts +1 -0
- package/src/config/types.ts +51 -4
- package/src/context/hook-pipeline.ts +160 -0
- package/src/context/index.ts +29 -667
- package/src/context/nested-operations.ts +142 -111
- package/src/context/write-pipeline.ts +543 -0
- package/src/extend.ts +14 -0
- package/src/fields/index.ts +310 -2
- package/src/hooks/index.ts +227 -0
- package/src/index.ts +27 -90
- package/src/internal.ts +49 -0
- package/src/validation/field-config.test.ts +199 -0
- package/src/validation/field-config.ts +145 -0
- package/tests/access-relationships.test.ts +4 -4
- package/tests/access.test.ts +1 -1
- package/tests/field-hooks.test.ts +410 -0
- package/tests/field-types.test.ts +1 -1
- package/tests/hook-pipeline.test.ts +233 -0
- package/tests/nested-operation-registry.test.ts +206 -0
- package/tests/write-pipeline.test.ts +588 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +43 -1
- package/dist/access/engine.test.d.ts +0 -2
- package/dist/access/engine.test.d.ts.map +0 -1
- 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 @@
|
|
|
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.
|
|
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.
|
|
52
|
-
"@types/node": "^
|
|
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
|
+
}
|