@appxdigital/appx-core 0.1.128-beta.3 → 0.1.128-beta.4

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/CHANGELOG.md CHANGED
@@ -29,6 +29,8 @@ Read-only GraphQL, reworked into a **namespaced, opt-in-per-module** API.
29
29
  - **Nested selection is to-one only.** Generated GraphQL model types expose scalars, **to-one** relations, and `_count`; **to-many relations are not nested-selectable** (nested lists have no pagination, so they could return unbounded rows). Fetch a list at the top level with pagination — `track { find(where: { albumId … }, take, skip) }`. Filtering by a to-many relation in `where` (`some`/`every`/`none`) and `_count { … }` still work.
30
30
  - **Self-documenting operations.** `find` / `get` / `count` and their `where` arg carry GraphQL descriptions (visible in the Apollo Sandbox); model-field descriptions come from Prisma `///` doc comments as before. See [docs/graphql.md](./docs/graphql.md) for the full `where`/`orderBy`/pagination reference.
31
31
  - **Malformed filter values return `400`, not `500`.** A `where`/`orderBy` value Prisma can't parse (e.g. `createdAt: { gt: 2024 }` — a number where a date is expected) now surfaces a clean `400`, instead of an internal error that leaked the raw Prisma invocation.
32
+ - **Output shaping via native field resolvers.** Transform a field on the way out (sign a stored key into a URL, mask, format) or add a computed field with a `@Resolver(() => Model)` + `@ResolveField`, registered in the module. A field resolver reads its source columns from `@Parent()`; ABAC field omission still runs at query time, so a transform never sees a `@Role`-restricted column. New **`@FieldRequires('col')`** (one column, several, or an array) declares which columns a computed field reads so the generated `find`/`get` fetches exactly those; undeclared computed fields fall back to fetching all scalar columns, and relations always stay selection-driven. See [docs/graphql.md](./docs/graphql.md) → *Shaping read output*.
33
+ - **GraphQL introspection on in development.** The Apollo Sandbox (`GET /graphql`) needs introspection, which Apollo Server v4 disables under `NODE_ENV=production` (`INTROSPECTION_DISABLED`). It is now enabled in development and disabled in production by default (defense-in-depth — introspection exposes schema shape, not data, which ABAC still governs). Opt in for a production deployment with `APPX_GRAPHQL_INTROSPECTION=true`.
32
34
 
33
35
  ### Fixed
34
36
 
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The GraphQL schema-field `extensions` key under which {@link FieldRequires}
3
+ * records the source columns. The generated read resolver reads it back from the
4
+ * schema when deciding which columns to fetch
5
+ * (see `generic.resolver.ts` → `selectReadColumns`).
6
+ */
7
+ export declare const FIELD_REQUIRES_EXTENSION = "requires";
8
+ /**
9
+ * Declare the model columns a custom GraphQL `@ResolveField` reads from its
10
+ * `@Parent()`, so the generated `find` / `get` fetches those columns even when
11
+ * the client didn't select them.
12
+ *
13
+ * Use it on a computed field whose value derives from a column — e.g. a signed
14
+ * `coverUrl` built from a stored `coverKey`. Without it, only the columns the
15
+ * client selected are fetched and `@Parent().coverKey` would be `undefined`.
16
+ *
17
+ * - Declared → only the listed columns are fetched for the field.
18
+ * - Not declared → all of the model's scalar columns are fetched.
19
+ * - Not needed for an in-place transform that reuses an existing field's name.
20
+ *
21
+ * ABAC applies: a column the caller's role may not read is omitted at query time,
22
+ * so the resolver sees it as absent — a transform can never surface a
23
+ * `@Role`-restricted value.
24
+ *
25
+ * Accepts a single column, several columns, or an array.
26
+ *
27
+ * @param columns - The scalar column name(s) the resolver reads from its parent.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * @Resolver(() => Project)
32
+ * export class ProjectFields {
33
+ * constructor(private readonly urls: UrlSigner) {}
34
+ *
35
+ * @ResolveField(() => String, { nullable: true })
36
+ * @FieldRequires('coverKey')
37
+ * coverUrl(@Parent() p: Project) {
38
+ * return p.coverKey ? this.urls.sign(p.coverKey) : null;
39
+ * }
40
+ *
41
+ * @ResolveField(() => String, { nullable: true })
42
+ * @FieldRequires(['firstName', 'lastName'])
43
+ * fullName(@Parent() p: Project) {
44
+ * return [p.firstName, p.lastName].filter(Boolean).join(' ') || null;
45
+ * }
46
+ * }
47
+ * ```
48
+ */
49
+ export declare const FieldRequires: (...columns: (string | string[])[]) => MethodDecorator & ClassDecorator & PropertyDecorator;
50
+ //# sourceMappingURL=field-requires.decorator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field-requires.decorator.d.ts","sourceRoot":"","sources":["../../../src/common/decorators/field-requires.decorator.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,aAAa,CAAC;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,aAAa,eAAgB,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC,EAAE,yDACH,CAAC"}
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FieldRequires = exports.FIELD_REQUIRES_EXTENSION = void 0;
4
+ const graphql_1 = require("@nestjs/graphql");
5
+ /**
6
+ * The GraphQL schema-field `extensions` key under which {@link FieldRequires}
7
+ * records the source columns. The generated read resolver reads it back from the
8
+ * schema when deciding which columns to fetch
9
+ * (see `generic.resolver.ts` → `selectReadColumns`).
10
+ */
11
+ exports.FIELD_REQUIRES_EXTENSION = 'requires';
12
+ /**
13
+ * Declare the model columns a custom GraphQL `@ResolveField` reads from its
14
+ * `@Parent()`, so the generated `find` / `get` fetches those columns even when
15
+ * the client didn't select them.
16
+ *
17
+ * Use it on a computed field whose value derives from a column — e.g. a signed
18
+ * `coverUrl` built from a stored `coverKey`. Without it, only the columns the
19
+ * client selected are fetched and `@Parent().coverKey` would be `undefined`.
20
+ *
21
+ * - Declared → only the listed columns are fetched for the field.
22
+ * - Not declared → all of the model's scalar columns are fetched.
23
+ * - Not needed for an in-place transform that reuses an existing field's name.
24
+ *
25
+ * ABAC applies: a column the caller's role may not read is omitted at query time,
26
+ * so the resolver sees it as absent — a transform can never surface a
27
+ * `@Role`-restricted value.
28
+ *
29
+ * Accepts a single column, several columns, or an array.
30
+ *
31
+ * @param columns - The scalar column name(s) the resolver reads from its parent.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * @Resolver(() => Project)
36
+ * export class ProjectFields {
37
+ * constructor(private readonly urls: UrlSigner) {}
38
+ *
39
+ * @ResolveField(() => String, { nullable: true })
40
+ * @FieldRequires('coverKey')
41
+ * coverUrl(@Parent() p: Project) {
42
+ * return p.coverKey ? this.urls.sign(p.coverKey) : null;
43
+ * }
44
+ *
45
+ * @ResolveField(() => String, { nullable: true })
46
+ * @FieldRequires(['firstName', 'lastName'])
47
+ * fullName(@Parent() p: Project) {
48
+ * return [p.firstName, p.lastName].filter(Boolean).join(' ') || null;
49
+ * }
50
+ * }
51
+ * ```
52
+ */
53
+ const FieldRequires = (...columns) => (0, graphql_1.Extensions)({ [exports.FIELD_REQUIRES_EXTENSION]: columns.flat() });
54
+ exports.FieldRequires = FieldRequires;
@@ -1 +1 @@
1
- {"version":3,"file":"generic.resolver.d.ts","sourceRoot":"","sources":["../../src/graphql/generic.resolver.ts"],"names":[],"mappings":"AAEA,OAAO,EAAC,IAAI,EAAC,MAAM,iBAAiB,CAAC;AAwBrC;;;;GAIG;AACH,MAAM,WAAW,kBAAkB,CAAC,SAAS,GAAG,GAAG;IAC/C,iFAAiF;IACjF,KAAK,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;IACvB,gFAAgF;IAChF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,YAAY,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IACxB,oDAAoD;IACpD,aAAa,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IACzB,8DAA8D;IAC9D,UAAU,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,kBAAkB,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAuE/F"}
1
+ {"version":3,"file":"generic.resolver.d.ts","sourceRoot":"","sources":["../../src/graphql/generic.resolver.ts"],"names":[],"mappings":"AAEA,OAAO,EAAC,IAAI,EAAC,MAAM,iBAAiB,CAAC;AA4GrC;;;;GAIG;AACH,MAAM,WAAW,kBAAkB,CAAC,SAAS,GAAG,GAAG;IAC/C,iFAAiF;IACjF,KAAK,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;IACvB,gFAAgF;IAChF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,YAAY,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IACxB,oDAAoD;IACpD,aAAa,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IACzB,8DAA8D;IAC9D,UAAU,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,kBAAkB,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAuE/F"}
@@ -16,7 +16,9 @@ exports.CoreGraphqlResolver = void 0;
16
16
  const graphql_1 = require("@nestjs/graphql");
17
17
  const common_1 = require("@nestjs/common");
18
18
  const plugins_1 = require("@paljs/plugins");
19
+ const graphql_2 = require("graphql");
19
20
  const prisma_service_1 = require("../prisma/prisma.service");
21
+ const field_requires_decorator_1 = require("../common/decorators/field-requires.decorator");
20
22
  /**
21
23
  * Run a read and translate a Prisma **validation** error into a `400`. A
22
24
  * malformed `where`/`orderBy` value (e.g. `createdAt: { gt: 2024 }` — a number
@@ -36,6 +38,85 @@ async function readOrBadRequest(op) {
36
38
  throw e;
37
39
  }
38
40
  }
41
+ /**
42
+ * Turn a `PrismaSelect`-derived selection into the columns actually fetched,
43
+ * resolving the source columns that custom `@ResolveField`s need.
44
+ *
45
+ * `PrismaSelect` narrows the Prisma query to exactly the fields the GraphQL
46
+ * request asked for. That breaks the standard NestJS code-first contract where a
47
+ * `@ResolveField` on the model type receives the full entity as `@Parent()`: a
48
+ * custom field (`coverUrl` derived from a `coverKey` column, or a signed-URL
49
+ * transform of an existing field) can't read a source column the client didn't
50
+ * also select. We restore that contract with a **hybrid** strategy that avoids
51
+ * over-fetching:
52
+ *
53
+ * - A custom field can declare its source columns natively, via the standard
54
+ * NestJS field `extensions`:
55
+ * `@ResolveField(() => String, { extensions: { requires: ['coverKey'] } })`
56
+ * When declared, ONLY those columns are added — no over-fetch.
57
+ * - A custom field that declares nothing falls back to fetching all of the
58
+ * model's scalar columns (safe default: the resolver can read anything from
59
+ * `@Parent()`), at the cost of reading columns the query didn't select.
60
+ * - A query that selects no custom fields fetches exactly what was asked for.
61
+ *
62
+ * Relations stay selection-driven throughout (no unbounded relation fetching).
63
+ * ABAC field omission runs afterward in the proxy and strips `@Role`-restricted
64
+ * columns at select-time, so a caller still only ever receives — and a transform
65
+ * only ever sees — the columns their role may read.
66
+ *
67
+ * Custom field names (no backing column) are dropped from the Prisma `select`:
68
+ * `PrismaSelect` puts every requested GraphQL field there, and passing a name
69
+ * Prisma doesn't recognise throws a validation error. They resolve from the
70
+ * parent afterward, so they must not reach the query.
71
+ */
72
+ function selectReadColumns(select, info, prisma, modelName) {
73
+ const scalars = new Set(prisma.getScalarFields(modelName));
74
+ const requested = select?.select && typeof select.select === 'object' ? select.select : {};
75
+ // The GraphQL type of the returned rows, to read each field's
76
+ // `extensions.requires` (find → [Model], get → Model; getNamedType unwraps).
77
+ const named = (0, graphql_2.getNamedType)(info.returnType);
78
+ const gqlFields = named instanceof graphql_2.GraphQLObjectType ? named.getFields() : {};
79
+ const out = {};
80
+ const requiredColumns = new Set();
81
+ let needAllScalars = false;
82
+ for (const [key, value] of Object.entries(requested)) {
83
+ if (value && typeof value === 'object') {
84
+ out[key] = value; // a relation (or `_count`) selection — keep as-is
85
+ }
86
+ else if (scalars.has(key)) {
87
+ out[key] = value; // a real scalar column
88
+ }
89
+ else {
90
+ // A custom field resolver (no backing column). Prefer its declared
91
+ // dependencies (@FieldRequires); otherwise fall back to all scalars.
92
+ const requires = gqlFields[key]?.extensions?.[field_requires_decorator_1.FIELD_REQUIRES_EXTENSION];
93
+ if (Array.isArray(requires)) {
94
+ for (const col of requires) {
95
+ if (scalars.has(col))
96
+ requiredColumns.add(col);
97
+ }
98
+ }
99
+ else {
100
+ needAllScalars = true;
101
+ }
102
+ }
103
+ }
104
+ if (needAllScalars) {
105
+ for (const field of scalars)
106
+ out[field] = true;
107
+ }
108
+ else {
109
+ for (const col of requiredColumns)
110
+ out[col] = true;
111
+ }
112
+ // Guard against an empty select (e.g. only a custom field with `requires: []`)
113
+ // — an empty Prisma `select` is invalid; fall back to all scalars.
114
+ if (Object.keys(out).length === 0) {
115
+ for (const field of scalars)
116
+ out[field] = true;
117
+ }
118
+ return { ...(select && typeof select === 'object' ? select : {}), select: out };
119
+ }
39
120
  /**
40
121
  * Build a **namespaced, read-only** GraphQL resolver for one model.
41
122
  *
@@ -87,11 +168,11 @@ function CoreGraphqlResolver(bundle) {
87
168
  return {};
88
169
  }
89
170
  async find(args, info) {
90
- const select = new plugins_1.PrismaSelect(info).value;
171
+ const select = selectReadColumns(new plugins_1.PrismaSelect(info).value, info, this.prisma, modelName);
91
172
  return readOrBadRequest(() => this.prisma.getModelDelegate(modelName).findMany({ ...args, ...select }));
92
173
  }
93
174
  async get(args, info) {
94
- const select = new plugins_1.PrismaSelect(info).value;
175
+ const select = selectReadColumns(new plugins_1.PrismaSelect(info).value, info, this.prisma, modelName);
95
176
  return readOrBadRequest(() => this.prisma.getModelDelegate(modelName).findFirst({ ...args, ...select }));
96
177
  }
97
178
  async count(where) {
@@ -1 +1 @@
1
- {"version":3,"file":"graphql.module.d.ts","sourceRoot":"","sources":["../../src/graphql/graphql.module.ts"],"names":[],"mappings":"AAKA;;;;;GAKG;AACH,qBACa,oBAAoB;IAK7B,QAAQ,IAAI,MAAM;CAGrB;AAED,qBAca,aAAa;CAAG"}
1
+ {"version":3,"file":"graphql.module.d.ts","sourceRoot":"","sources":["../../src/graphql/graphql.module.ts"],"names":[],"mappings":"AAKA;;;;;GAKG;AACH,qBACa,oBAAoB;IAK7B,QAAQ,IAAI,MAAM;CAGrB;AAED,qBAwBa,aAAa;CAAG"}
@@ -49,6 +49,15 @@ exports.GraphqlModule = GraphqlModule = __decorate([
49
49
  // `src/generated/` path at boot (CI, read-only deploys, tests).
50
50
  autoSchemaFile: true,
51
51
  sortSchema: true,
52
+ // Introspection powers the Apollo Sandbox (GET /graphql). It exposes
53
+ // the schema shape (not data — ABAC still governs every field), so we
54
+ // keep it ON in development for the Sandbox, and OFF in production as
55
+ // defense-in-depth (less recon surface). Opt back in for a production
56
+ // deployment with APPX_GRAPHQL_INTROSPECTION=true. Without this, Apollo
57
+ // Server v4 disables introspection under NODE_ENV=production and the
58
+ // Sandbox fails with `INTROSPECTION_DISABLED`.
59
+ introspection: process.env.NODE_ENV !== 'production' ||
60
+ process.env.APPX_GRAPHQL_INTROSPECTION === 'true',
52
61
  }),
53
62
  ],
54
63
  providers: [AppxCoreRootResolver],
package/dist/index.d.ts CHANGED
@@ -37,6 +37,7 @@ export * from './common/interfaces/storage-service.interface';
37
37
  export * from './modules/file/file-upload.controller';
38
38
  export * from './modules/file/file-upload.service';
39
39
  export * from './common/decorators/permission.decorator';
40
+ export * from './common/decorators/field-requires.decorator';
40
41
  export * from './common/setup/setup-core-security';
41
42
  export * from './common/setup/core-config';
42
43
  export * from './common/interceptors/file.interceptor';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,yBAAyB,CAAC;AACxC,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAI3C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,iCAAiC,CAAC;AAChD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qCAAqC,CAAC;AACpD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,iCAAiC,CAAC;AAChD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC;AACtD,cAAc,oCAAoC,CAAC;AACnD,cAAc,2CAA2C,CAAC;AAC1D,cAAc,2CAA2C,CAAC;AAC1D,cAAc,sCAAsC,CAAC;AACrD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uCAAuC,CAAC;AACtD,cAAc,yBAAyB,CAAC;AACxC,cAAc,0CAA0C,CAAC;AACzD,cAAc,4CAA4C,CAAC;AAC3D,cAAc,+BAA+B,CAAC;AAC9C,cAAc,oCAAoC,CAAC;AACnD,cAAc,wCAAwC,CAAC;AACvD,cAAc,iCAAiC,CAAC;AAChD,cAAc,6CAA6C,CAAC;AAC5D,cAAc,oBAAoB,CAAC;AACnC,cAAc,mCAAmC,CAAC;AAClD,cAAc,2CAA2C,CAAC;AAC1D,cAAc,wCAAwC,CAAC;AACvD,cAAc,+CAA+C,CAAC;AAC9D,cAAc,uCAAuC,CAAC;AACtD,cAAc,oCAAoC,CAAC;AACnD,cAAc,0CAA0C,CAAC;AACzD,cAAc,oCAAoC,CAAC;AACnD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,oCAAoC,CAAC;AACnD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,qCAAqC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,yBAAyB,CAAC;AACxC,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC;AAI3C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,iCAAiC,CAAC;AAChD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qCAAqC,CAAC;AACpD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,iCAAiC,CAAC;AAChD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC;AACtD,cAAc,oCAAoC,CAAC;AACnD,cAAc,2CAA2C,CAAC;AAC1D,cAAc,2CAA2C,CAAC;AAC1D,cAAc,sCAAsC,CAAC;AACrD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uCAAuC,CAAC;AACtD,cAAc,yBAAyB,CAAC;AACxC,cAAc,0CAA0C,CAAC;AACzD,cAAc,4CAA4C,CAAC;AAC3D,cAAc,+BAA+B,CAAC;AAC9C,cAAc,oCAAoC,CAAC;AACnD,cAAc,wCAAwC,CAAC;AACvD,cAAc,iCAAiC,CAAC;AAChD,cAAc,6CAA6C,CAAC;AAC5D,cAAc,oBAAoB,CAAC;AACnC,cAAc,mCAAmC,CAAC;AAClD,cAAc,2CAA2C,CAAC;AAC1D,cAAc,wCAAwC,CAAC;AACvD,cAAc,+CAA+C,CAAC;AAC9D,cAAc,uCAAuC,CAAC;AACtD,cAAc,oCAAoC,CAAC;AACnD,cAAc,0CAA0C,CAAC;AACzD,cAAc,8CAA8C,CAAC;AAC7D,cAAc,oCAAoC,CAAC;AACnD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,oCAAoC,CAAC;AACnD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,qCAAqC,CAAC"}
package/dist/index.js CHANGED
@@ -56,6 +56,7 @@ __exportStar(require("./common/interfaces/storage-service.interface"), exports);
56
56
  __exportStar(require("./modules/file/file-upload.controller"), exports);
57
57
  __exportStar(require("./modules/file/file-upload.service"), exports);
58
58
  __exportStar(require("./common/decorators/permission.decorator"), exports);
59
+ __exportStar(require("./common/decorators/field-requires.decorator"), exports);
59
60
  __exportStar(require("./common/setup/setup-core-security"), exports);
60
61
  __exportStar(require("./common/setup/core-config"), exports);
61
62
  __exportStar(require("./common/interceptors/file.interceptor"), exports);
@@ -229,6 +229,21 @@ export declare class PrismaService {
229
229
  * @returns The condition with placeholders replaced by actual values.
230
230
  */
231
231
  private static replacePlaceholders;
232
+ /**
233
+ * The scalar (and enum) field names of a model, as derived from the Prisma
234
+ * DMMF at bootstrap. Relations are excluded.
235
+ *
236
+ * Used by the GraphQL generic resolver to fetch the model's own columns even
237
+ * when a query only selects a subset, so that a custom `@ResolveField` on the
238
+ * model type (e.g. a signed-URL transform) can read its source column from
239
+ * `@Parent()`. ABAC field omission still runs afterward at select-time, so a
240
+ * `@Role`-restricted column is never actually fetched — callers only ever get
241
+ * the readable projection.
242
+ *
243
+ * @param modelName - The model name (any casing; matched case-insensitively).
244
+ * @returns The model's scalar field names, or `[]` if the model is unknown.
245
+ */
246
+ getScalarFields(modelName: string): string[];
232
247
  /**
233
248
  * Retrieves the list of fields to omit based on the user's role.
234
249
  * It checks the field configurations stored in `fieldConfigs` and compares them against the role.
@@ -1 +1 @@
1
- {"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"prisma.service.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.service.ts"],"names":[],"mappings":"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"}
@@ -981,6 +981,23 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
981
981
  }
982
982
  return condition;
983
983
  }
984
+ /**
985
+ * The scalar (and enum) field names of a model, as derived from the Prisma
986
+ * DMMF at bootstrap. Relations are excluded.
987
+ *
988
+ * Used by the GraphQL generic resolver to fetch the model's own columns even
989
+ * when a query only selects a subset, so that a custom `@ResolveField` on the
990
+ * model type (e.g. a signed-URL transform) can read its source column from
991
+ * `@Parent()`. ABAC field omission still runs afterward at select-time, so a
992
+ * `@Role`-restricted column is never actually fetched — callers only ever get
993
+ * the readable projection.
994
+ *
995
+ * @param modelName - The model name (any casing; matched case-insensitively).
996
+ * @returns The model's scalar field names, or `[]` if the model is unknown.
997
+ */
998
+ getScalarFields(modelName) {
999
+ return this.fieldConfigs[modelName.toLowerCase()]?.scalarFields ?? [];
1000
+ }
984
1001
  /**
985
1002
  * Retrieves the list of fields to omit based on the user's role.
986
1003
  * It checks the field configurations stored in `fieldConfigs` and compares them against the role.
package/docs/graphql.md CHANGED
@@ -162,6 +162,68 @@ Always let the generated resolver serve the data — a custom resolver that reac
162
162
 
163
163
  ---
164
164
 
165
+ ## Shaping read output (transforms & computed fields)
166
+
167
+ To transform a field on the way out (sign a stored S3 key into a URL, mask a value, format a date) or to add a computed field (`slug`, `fullName`), attach a field resolver to the generated model type in the model's module:
168
+
169
+ ```ts
170
+ // src/modules/project/project.fields.ts
171
+ import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
172
+ import { FieldRequires } from '@appxdigital/appx-core';
173
+ import { Project } from '../../generated/project/project.model';
174
+ import { UrlSigner } from '../../shared/url-signer.service';
175
+
176
+ @Resolver(() => Project)
177
+ export class ProjectFields {
178
+ constructor(private readonly urls: UrlSigner) {}
179
+
180
+ // transform an existing field in place — same name, same type
181
+ @ResolveField(() => String, { name: 'coverKey', nullable: true })
182
+ coverKey(@Parent() p: Project) {
183
+ return p.coverKey ? this.urls.sign(p.coverKey) : null; // stored key → signed URL
184
+ }
185
+
186
+ // add a computed field — declare the column(s) it reads
187
+ @ResolveField(() => String, { nullable: true })
188
+ @FieldRequires('name')
189
+ slug(@Parent() p: Project) {
190
+ return p.name ? p.name.toLowerCase().replace(/\s+/g, '-') : null;
191
+ }
192
+ }
193
+ ```
194
+ ```ts
195
+ // project.module.ts
196
+ providers: [ProjectService, ProjectResolver, ProjectFields]
197
+ ```
198
+
199
+ The resolver runs wherever the model appears, including nested — `project { find { coverKey } }` and `projectMember { find { project { coverKey } } }` both return the transformed value.
200
+
201
+ It reads from the already-filtered parent row, so it only ever sees columns the caller's role may read. A field computed from a `@Role`-restricted column receives that column as absent, so a transform cannot surface a value the role can't read.
202
+
203
+ ### `@FieldRequires` — declare a computed field's source columns
204
+
205
+ A computed field reads columns from its parent, but the query fetches only the columns the client selected. Declare the columns the field reads so they are fetched even when the client didn't select them:
206
+
207
+ ```ts
208
+ @ResolveField(() => String, { nullable: true })
209
+ @FieldRequires('coverKey') // one column
210
+ coverUrl(@Parent() p: Project) { … }
211
+
212
+ @ResolveField(() => String, { nullable: true })
213
+ @FieldRequires(['firstName', 'lastName']) // a list, or @FieldRequires('firstName', 'lastName')
214
+ fullName(@Parent() p: Project) { … }
215
+ ```
216
+
217
+ - **Declared** → only those columns are fetched for the field.
218
+ - **Not declared** → all of the model's scalar columns are fetched, so the field can read any of them from its parent.
219
+ - **In-place transform** (a resolver reusing an existing field's name) → needs no declaration; that column is fetched whenever the client selects the field.
220
+
221
+ Read from `@Parent()`, not the raw Prisma client — re-querying bypasses ABAC. For key-to-URL transforms, reuse the existing field's name so the raw stored value never leaves the server.
222
+
223
+ > The REST CRUD endpoints return rows as stored; output shaping applies to the GraphQL read API.
224
+
225
+ ---
226
+
165
227
  ## Not available yet
166
228
 
167
229
  - **Mutations** (create/update/delete) — use REST.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appxdigital/appx-core",
3
- "version": "0.1.128-beta.3",
3
+ "version": "0.1.128-beta.4",
4
4
  "description": "Appx Core is a library that provides a set of tools to help you build your application faster.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",