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

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,7 +29,7 @@ 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*.
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 field reads so the generated `find`/`get` fetches exactly those — for a computed field, an in-place override, **and a field on a nested relation** (the source columns are resolved from a bootstrap registry, so declarations apply at every level). Undeclared field resolvers fall back to fetching the model's scalar columns; relations always stay selection-driven. See [docs/graphql.md](./docs/graphql.md) → *Shaping read output*.
33
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`.
34
34
 
35
35
  ### Fixed
@@ -1,22 +1,22 @@
1
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`).
2
+ * Reflect metadata key under which {@link FieldRequires} records a field
3
+ * resolver's source columns. Read at bootstrap by the GraphQL field-requires
4
+ * scanner (see `field-requires.registry.ts`) to build the model→field→columns
5
+ * registry the read resolver consults.
6
6
  */
7
- export declare const FIELD_REQUIRES_EXTENSION = "requires";
7
+ export declare const FIELD_REQUIRES_METADATA = "appx:field_requires";
8
8
  /**
9
9
  * Declare the model columns a custom GraphQL `@ResolveField` reads from its
10
10
  * `@Parent()`, so the generated `find` / `get` fetches those columns even when
11
- * the client didn't select them.
11
+ * the client didn't select them — at the top level and on nested relations.
12
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`.
13
+ * Use it on a computed field or an in-place transform whose value derives from a
14
+ * column, e.g. a signed `coverUrl` built from a stored `coverKey`. Without it,
15
+ * only the columns the client selected are fetched and `@Parent().coverKey` would
16
+ * be `undefined`.
16
17
  *
17
18
  * - Declared → only the listed columns are fetched for the field.
18
19
  * - 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
20
  *
21
21
  * ABAC applies: a column the caller's role may not read is omitted at query time,
22
22
  * so the resolver sees it as absent — a transform can never surface a
@@ -46,5 +46,5 @@ export declare const FIELD_REQUIRES_EXTENSION = "requires";
46
46
  * }
47
47
  * ```
48
48
  */
49
- export declare const FieldRequires: (...columns: (string | string[])[]) => MethodDecorator & ClassDecorator & PropertyDecorator;
49
+ export declare const FieldRequires: (...columns: (string | string[])[]) => import("@nestjs/common").CustomDecorator<string>;
50
50
  //# sourceMappingURL=field-requires.decorator.d.ts.map
@@ -1 +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"}
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,uBAAuB,wBAAwB,CAAC;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,aAAa,eAAgB,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC,EAAE,qDACP,CAAC"}
@@ -1,26 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.FieldRequires = exports.FIELD_REQUIRES_EXTENSION = void 0;
4
- const graphql_1 = require("@nestjs/graphql");
3
+ exports.FieldRequires = exports.FIELD_REQUIRES_METADATA = void 0;
4
+ const common_1 = require("@nestjs/common");
5
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`).
6
+ * Reflect metadata key under which {@link FieldRequires} records a field
7
+ * resolver's source columns. Read at bootstrap by the GraphQL field-requires
8
+ * scanner (see `field-requires.registry.ts`) to build the model→field→columns
9
+ * registry the read resolver consults.
10
10
  */
11
- exports.FIELD_REQUIRES_EXTENSION = 'requires';
11
+ exports.FIELD_REQUIRES_METADATA = 'appx:field_requires';
12
12
  /**
13
13
  * Declare the model columns a custom GraphQL `@ResolveField` reads from its
14
14
  * `@Parent()`, so the generated `find` / `get` fetches those columns even when
15
- * the client didn't select them.
15
+ * the client didn't select them — at the top level and on nested relations.
16
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`.
17
+ * Use it on a computed field or an in-place transform whose value derives from a
18
+ * column, e.g. a signed `coverUrl` built from a stored `coverKey`. Without it,
19
+ * only the columns the client selected are fetched and `@Parent().coverKey` would
20
+ * be `undefined`.
20
21
  *
21
22
  * - Declared → only the listed columns are fetched for the field.
22
23
  * - 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
24
  *
25
25
  * ABAC applies: a column the caller's role may not read is omitted at query time,
26
26
  * so the resolver sees it as absent — a transform can never surface a
@@ -50,5 +50,5 @@ exports.FIELD_REQUIRES_EXTENSION = 'requires';
50
50
  * }
51
51
  * ```
52
52
  */
53
- const FieldRequires = (...columns) => (0, graphql_1.Extensions)({ [exports.FIELD_REQUIRES_EXTENSION]: columns.flat() });
53
+ const FieldRequires = (...columns) => (0, common_1.SetMetadata)(exports.FIELD_REQUIRES_METADATA, columns.flat());
54
54
  exports.FieldRequires = FieldRequires;
@@ -0,0 +1,27 @@
1
+ import { OnModuleInit } from '@nestjs/common';
2
+ import { DiscoveryService, MetadataScanner } from '@nestjs/core';
3
+ /**
4
+ * Look up a field resolver on a model. Returns `undefined` if the field is not a
5
+ * custom `@ResolveField` (i.e. a plain column), otherwise `{ requires }` where
6
+ * `requires` is the declared columns or `null` if none were declared.
7
+ */
8
+ export declare function getFieldRequires(model: string, field: string): {
9
+ requires: string[] | null;
10
+ } | undefined;
11
+ /**
12
+ * Scans every resolver provider once at startup and records each `@ResolveField`
13
+ * as `Model.field` → its `@FieldRequires` columns. Runs after all providers are
14
+ * instantiated, so field resolvers registered in any feature module are included.
15
+ *
16
+ * This scan (not a per-request read) is required because the code that builds the
17
+ * Prisma query — the generated `find`/`get` — must know the source columns of
18
+ * field resolvers on OTHER classes and nested relation models, not just the
19
+ * executing handler.
20
+ */
21
+ export declare class FieldRequiresScanner implements OnModuleInit {
22
+ private readonly discovery;
23
+ private readonly scanner;
24
+ constructor(discovery: DiscoveryService, scanner: MetadataScanner);
25
+ onModuleInit(): void;
26
+ }
27
+ //# sourceMappingURL=field-requires.registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field-requires.registry.d.ts","sourceRoot":"","sources":["../../src/graphql/field-requires.registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,YAAY,EAAC,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAC,gBAAgB,EAAE,eAAe,EAAC,MAAM,cAAc,CAAC;AAqB/D;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG;IAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;CAAC,GAAG,SAAS,CAMtG;AAED;;;;;;;;;GASG;AACH,qBACa,oBAAqB,YAAW,YAAY;IAEjD,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO;gBADP,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE,eAAe;IAG7C,YAAY,IAAI,IAAI;CA+BvB"}
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.FieldRequiresScanner = exports.getFieldRequires = void 0;
13
+ const common_1 = require("@nestjs/common");
14
+ const core_1 = require("@nestjs/core");
15
+ const field_requires_decorator_1 = require("../common/decorators/field-requires.decorator");
16
+ // NestJS GraphQL reflect keys (set by @Resolver / @ResolveField). Reading them is
17
+ // how we recover, for each field resolver, the model it belongs to and the field
18
+ // it resolves — including in-place overrides, whose declared columns never reach
19
+ // the built schema field.
20
+ const RESOLVER_NAME_METADATA = 'graphql:resolver_name';
21
+ const RESOLVER_PROPERTY_METADATA = 'graphql:resolve_property';
22
+ /**
23
+ * `Model.field` → the columns the field resolver reads, or `null` when the field
24
+ * is a custom resolver that declared nothing (an undeclared resolver means "fetch
25
+ * this model's scalars, we can't know what it reads"). Absence of a key means the
26
+ * field is not a custom resolver at all (a plain column).
27
+ *
28
+ * Populated once at bootstrap by {@link FieldRequiresScanner}; read by the
29
+ * generated read resolver when building the Prisma `select`.
30
+ */
31
+ const registry = new Map();
32
+ /**
33
+ * Look up a field resolver on a model. Returns `undefined` if the field is not a
34
+ * custom `@ResolveField` (i.e. a plain column), otherwise `{ requires }` where
35
+ * `requires` is the declared columns or `null` if none were declared.
36
+ */
37
+ function getFieldRequires(model, field) {
38
+ const key = `${model}.${field}`;
39
+ if (!registry.has(key)) {
40
+ return undefined;
41
+ }
42
+ return { requires: registry.get(key) ?? null };
43
+ }
44
+ exports.getFieldRequires = getFieldRequires;
45
+ /**
46
+ * Scans every resolver provider once at startup and records each `@ResolveField`
47
+ * as `Model.field` → its `@FieldRequires` columns. Runs after all providers are
48
+ * instantiated, so field resolvers registered in any feature module are included.
49
+ *
50
+ * This scan (not a per-request read) is required because the code that builds the
51
+ * Prisma query — the generated `find`/`get` — must know the source columns of
52
+ * field resolvers on OTHER classes and nested relation models, not just the
53
+ * executing handler.
54
+ */
55
+ let FieldRequiresScanner = exports.FieldRequiresScanner = class FieldRequiresScanner {
56
+ constructor(discovery, scanner) {
57
+ this.discovery = discovery;
58
+ this.scanner = scanner;
59
+ }
60
+ onModuleInit() {
61
+ registry.clear();
62
+ for (const wrapper of this.discovery.getProviders()) {
63
+ const instance = wrapper.instance;
64
+ if (!instance || typeof instance !== 'object') {
65
+ continue;
66
+ }
67
+ // The model a resolver is bound to: @Resolver(() => Model) records the
68
+ // model name on the class under RESOLVER_NAME_METADATA.
69
+ const model = Reflect.getMetadata(RESOLVER_NAME_METADATA, instance.constructor);
70
+ if (!model || typeof model !== 'string') {
71
+ continue; // not a model-bound resolver (root resolvers, other providers)
72
+ }
73
+ const prototype = Object.getPrototypeOf(instance);
74
+ if (!prototype) {
75
+ continue;
76
+ }
77
+ for (const methodName of this.scanner.getAllMethodNames(prototype)) {
78
+ const handler = prototype[methodName];
79
+ if (typeof handler !== 'function') {
80
+ continue;
81
+ }
82
+ if (Reflect.getMetadata(RESOLVER_PROPERTY_METADATA, handler) !== true) {
83
+ continue; // not a @ResolveField
84
+ }
85
+ const field = Reflect.getMetadata(RESOLVER_NAME_METADATA, handler) || methodName;
86
+ const requires = Reflect.getMetadata(field_requires_decorator_1.FIELD_REQUIRES_METADATA, handler);
87
+ registry.set(`${model}.${field}`, Array.isArray(requires) ? requires : null);
88
+ }
89
+ }
90
+ }
91
+ };
92
+ exports.FieldRequiresScanner = FieldRequiresScanner = __decorate([
93
+ (0, common_1.Injectable)(),
94
+ __metadata("design:paramtypes", [core_1.DiscoveryService,
95
+ core_1.MetadataScanner])
96
+ ], FieldRequiresScanner);
@@ -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;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"}
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;AAoIrC;;;;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"}
@@ -18,7 +18,7 @@ const common_1 = require("@nestjs/common");
18
18
  const plugins_1 = require("@paljs/plugins");
19
19
  const graphql_2 = require("graphql");
20
20
  const prisma_service_1 = require("../prisma/prisma.service");
21
- const field_requires_decorator_1 = require("../common/decorators/field-requires.decorator");
21
+ const field_requires_registry_1 = require("./field-requires.registry");
22
22
  /**
23
23
  * Run a read and translate a Prisma **validation** error into a `400`. A
24
24
  * malformed `where`/`orderBy` value (e.g. `createdAt: { gt: 2024 }` — a number
@@ -39,83 +39,99 @@ async function readOrBadRequest(op) {
39
39
  }
40
40
  }
41
41
  /**
42
- * Turn a `PrismaSelect`-derived selection into the columns actually fetched,
43
- * resolving the source columns that custom `@ResolveField`s need.
42
+ * Build the Prisma `select` for a read so that every custom `@ResolveField` — at
43
+ * the top level and on nested relations — receives its source columns in
44
+ * `@Parent()`.
44
45
  *
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:
46
+ * `PrismaSelect` narrows each query to the columns the GraphQL request selected,
47
+ * which breaks the standard NestJS contract that a `@ResolveField` receives the
48
+ * full entity: a transform or computed field can't read a column the client
49
+ * didn't also select, and on a nested relation not even its own key is
50
+ * guaranteed. Using the `@FieldRequires` registry (populated at bootstrap), each
51
+ * selected field that is a custom resolver contributes its declared source
52
+ * columns; a resolver that declared nothing falls back to the model's scalar
53
+ * columns. Plain columns are fetched as-is, so a query with no custom fields is
54
+ * exactly what was asked for. Relations stay selection-driven — only selected
55
+ * relations are fetched, and each is recursed into so its own resolvers get their
56
+ * columns too. ABAC field omission runs afterward in the proxy and strips
57
+ * `@Role`-restricted columns at select-time, so a caller — and a transform — only
58
+ * ever sees readable columns.
52
59
  *
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.
60
+ * Custom field names with no backing column are dropped: `PrismaSelect` puts every
61
+ * requested GraphQL field into the `select`, and passing a name Prisma doesn't
62
+ * recognise throws a validation error. They resolve from the parent afterward.
71
63
  */
72
64
  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
65
  const named = (0, graphql_2.getNamedType)(info.returnType);
78
- const gqlFields = named instanceof graphql_2.GraphQLObjectType ? named.getFields() : {};
66
+ const type = named instanceof graphql_2.GraphQLObjectType ? named : null;
67
+ const requested = select?.select && typeof select.select === 'object' ? select.select : {};
68
+ const resolved = resolveLevelColumns(requested, type, prisma, modelName);
69
+ return { ...(select && typeof select === 'object' ? select : {}), select: resolved };
70
+ }
71
+ /**
72
+ * Per-level worker for {@link selectReadColumns}: resolve the columns fetched for
73
+ * one model, recursing into each selected relation against its own model type.
74
+ */
75
+ function resolveLevelColumns(requested, type, prisma, modelName) {
76
+ const scalars = prisma.getScalarFields(modelName);
77
+ const scalarSet = new Set(scalars);
78
+ const fields = type ? type.getFields() : undefined;
79
79
  const out = {};
80
- const requiredColumns = new Set();
81
- let needAllScalars = false;
80
+ const needed = new Set();
81
+ let fetchAllScalars = false;
82
82
  for (const [key, value] of Object.entries(requested)) {
83
83
  if (value && typeof value === 'object') {
84
- out[key] = value; // a relation (or `_count`) selection — keep as-is
84
+ // A relation or `_count`. Recurse into a known relation model so its
85
+ // own field resolvers get their source columns (a relation's GraphQL
86
+ // type name is its Prisma model name); keep `_count`/opaque verbatim.
87
+ const relField = fields?.[key];
88
+ if (relField && value.select && typeof value.select === 'object') {
89
+ const relType = (0, graphql_2.getNamedType)(relField.type);
90
+ if (relType instanceof graphql_2.GraphQLObjectType && prisma.getScalarFields(relType.name).length) {
91
+ out[key] = {
92
+ ...value,
93
+ select: resolveLevelColumns(value.select, relType, prisma, relType.name),
94
+ };
95
+ continue;
96
+ }
97
+ }
98
+ out[key] = value;
99
+ continue;
85
100
  }
86
- else if (scalars.has(key)) {
87
- out[key] = value; // a real scalar column
101
+ if (scalarSet.has(key)) {
102
+ out[key] = value; // a real scalar column — keep even if also overridden by a resolver
88
103
  }
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);
104
+ // Is this field a custom @ResolveField on this model (new field OR in-place
105
+ // override)? The registry knows, and knows its declared source columns.
106
+ const entry = (0, field_requires_registry_1.getFieldRequires)(modelName, key);
107
+ if (entry) {
108
+ if (entry.requires) {
109
+ for (const col of entry.requires) {
110
+ if (scalarSet.has(col))
111
+ needed.add(col);
97
112
  }
98
113
  }
99
114
  else {
100
- needAllScalars = true;
115
+ fetchAllScalars = true; // a resolver that declared nothing — fetch all scalars
101
116
  }
102
117
  }
118
+ // else: a plain column (already added above) or an unknown non-column
119
+ // field name — dropped; it resolves from @Parent() afterward.
103
120
  }
104
- if (needAllScalars) {
121
+ if (fetchAllScalars) {
105
122
  for (const field of scalars)
106
123
  out[field] = true;
107
124
  }
108
125
  else {
109
- for (const col of requiredColumns)
126
+ for (const col of needed)
110
127
  out[col] = true;
111
128
  }
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.
129
+ // An empty select is invalid Prisma; fall back to the model's scalar columns.
114
130
  if (Object.keys(out).length === 0) {
115
131
  for (const field of scalars)
116
132
  out[field] = true;
117
133
  }
118
- return { ...(select && typeof select === 'object' ? select : {}), select: out };
134
+ return out;
119
135
  }
120
136
  /**
121
137
  * Build a **namespaced, read-only** GraphQL resolver for one model.
@@ -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,qBAwBa,aAAa;CAAG"}
1
+ {"version":3,"file":"graphql.module.d.ts","sourceRoot":"","sources":["../../src/graphql/graphql.module.ts"],"names":[],"mappings":"AAOA;;;;;GAKG;AACH,qBACa,oBAAoB;IAK7B,QAAQ,IAAI,MAAM;CAGrB;AAED,qBA0Ba,aAAa;CAAG"}
@@ -11,8 +11,10 @@ var __metadata = (this && this.__metadata) || function (k, v) {
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.GraphqlModule = exports.AppxCoreRootResolver = void 0;
13
13
  const common_1 = require("@nestjs/common");
14
+ const core_1 = require("@nestjs/core");
14
15
  const graphql_1 = require("@nestjs/graphql");
15
16
  const apollo_1 = require("@nestjs/apollo");
17
+ const field_requires_registry_1 = require("./field-requires.registry");
16
18
  /**
17
19
  * Always-present root query. GraphQL requires a non-empty `Query` type, but a
18
20
  * project may register `GraphqlModule` before any model has opted in (models
@@ -41,6 +43,8 @@ let GraphqlModule = exports.GraphqlModule = class GraphqlModule {
41
43
  exports.GraphqlModule = GraphqlModule = __decorate([
42
44
  (0, common_1.Module)({
43
45
  imports: [
46
+ // DiscoveryService/MetadataScanner power the @FieldRequires bootstrap scan.
47
+ core_1.DiscoveryModule,
44
48
  graphql_1.GraphQLModule.forRoot({
45
49
  driver: apollo_1.ApolloDriver,
46
50
  context: ({ req, res }) => ({ req, res }),
@@ -60,6 +64,6 @@ exports.GraphqlModule = GraphqlModule = __decorate([
60
64
  process.env.APPX_GRAPHQL_INTROSPECTION === 'true',
61
65
  }),
62
66
  ],
63
- providers: [AppxCoreRootResolver],
67
+ providers: [AppxCoreRootResolver, field_requires_registry_1.FieldRequiresScanner],
64
68
  })
65
69
  ], GraphqlModule);
package/docs/graphql.md CHANGED
@@ -214,9 +214,9 @@ coverUrl(@Parent() p: Project) { … }
214
214
  fullName(@Parent() p: Project) { … }
215
215
  ```
216
216
 
217
- - **Declared** → only those columns are fetched for the field.
217
+ - **Declared** → only those columns are fetched for the field. Works the same for a computed field, an in-place override, and a field on a nested relation.
218
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.
219
+ - **In-place transform reading only its own column** → needs no declaration; that column is fetched whenever the client selects the field. Declare `@FieldRequires` for any *other* columns it reads.
220
220
 
221
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
222
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appxdigital/appx-core",
3
- "version": "0.1.128-beta.4",
3
+ "version": "0.1.128-beta.5",
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",