@stonecrop/schema 0.16.4 → 0.16.6

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/dist/src/cli.js CHANGED
@@ -16,7 +16,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
16
16
  import { resolve, join } from 'node:path';
17
17
  import { parseArgs } from 'node:util';
18
18
  import { getIntrospectionQuery } from 'graphql';
19
- import { convertGraphQLSchema } from './converter/index';
19
+ import { convertGraphQLSchema, formatDoctypeDrift, mergeIntrospectedDoctype } from './converter/index';
20
20
  import { validateDoctype } from './validation';
21
21
  /**
22
22
  * Fetch an introspection result from a live GraphQL endpoint.
@@ -58,9 +58,10 @@ async function main() {
58
58
  output: { type: 'string', short: 'o' },
59
59
  include: { type: 'string' },
60
60
  exclude: { type: 'string' },
61
- overrides: { type: 'string' },
61
+ names: { type: 'string' },
62
62
  'custom-scalars': { type: 'string' },
63
63
  'include-unmapped': { type: 'boolean', default: false },
64
+ check: { type: 'boolean', default: false },
64
65
  help: { type: 'boolean', short: 'h' },
65
66
  },
66
67
  });
@@ -95,11 +96,11 @@ async function main() {
95
96
  if (values.exclude) {
96
97
  options.exclude = values.exclude.split(',').map(s => s.trim());
97
98
  }
98
- if (values.overrides) {
99
- const overridesPath = resolve(values.overrides);
100
- const overridesContent = readFileSync(overridesPath, 'utf-8');
101
- options.typeOverrides = JSON.parse(overridesContent);
99
+ if (values.names) {
100
+ const namesPath = resolve(values.names);
101
+ options.doctypeNames = JSON.parse(readFileSync(namesPath, 'utf-8'));
102
102
  }
103
+ options.onWarning = message => console.warn(` WARN: ${message}`);
103
104
  if (values['custom-scalars']) {
104
105
  const scalarsPath = resolve(values['custom-scalars']);
105
106
  const scalarsContent = readFileSync(scalarsPath, 'utf-8');
@@ -134,13 +135,35 @@ async function main() {
134
135
  }
135
136
  let warnings = 0;
136
137
  let errors = 0;
137
- for (const doctype of doctypes) {
138
- const fileName = `${doctype.slug}.json`;
138
+ let changed = 0;
139
+ const driftLines = [];
140
+ for (const generated of doctypes) {
141
+ const fileName = `${generated.slug}.json`;
139
142
  const filePath = join(outputDir, fileName);
140
- const json = JSON.stringify(doctype, null, '\t');
141
- writeFileSync(filePath, json + '\n', 'utf-8');
143
+ // When a doctype already exists it is the source of truth: generation confirms it and adds
144
+ // provenance markers, and reports anything it disagrees with rather than applying it. A
145
+ // doctype legitimately declares identity the schema cannot express — most often a natural
146
+ // key that is a UNIQUE constraint, not the table's PRIMARY KEY — and overwriting that would
147
+ // silently re-key the doctype on every run. A first generation has nothing to merge into,
148
+ // so converter output is written verbatim.
149
+ let output = generated;
150
+ if (existsSync(filePath)) {
151
+ const { doctype: merged, drift } = mergeIntrospectedDoctype(JSON.parse(readFileSync(filePath, 'utf-8')), generated);
152
+ output = merged;
153
+ driftLines.push(...formatDoctypeDrift(drift));
154
+ }
155
+ // Serialize the merged object directly. Never round-trip it through the Zod parser first:
156
+ // that runs in strip mode and would silently drop every key this package does not model,
157
+ // `handler` on an action being the one consumers actually rely on.
158
+ const json = JSON.stringify(output, null, '\t') + '\n';
159
+ const unchanged = existsSync(filePath) && readFileSync(filePath, 'utf-8') === json;
160
+ if (!unchanged)
161
+ changed++;
162
+ if (!values.check && !unchanged) {
163
+ writeFileSync(filePath, json, 'utf-8');
164
+ }
142
165
  // Validate the output
143
- const validation = validateDoctype(doctype);
166
+ const validation = validateDoctype(output);
144
167
  if (!validation.success) {
145
168
  errors++;
146
169
  console.error(` ERROR: ${fileName} failed validation:`);
@@ -150,7 +173,7 @@ async function main() {
150
173
  }
151
174
  else {
152
175
  // Check for unmapped fields
153
- const unmappedFields = doctype.fields.filter((f) => f._unmapped);
176
+ const unmappedFields = generated.fields.filter((f) => f._unmapped);
154
177
  if (unmappedFields.length > 0) {
155
178
  warnings++;
156
179
  console.warn(` WARN: ${fileName} has ${unmappedFields.length} unmapped field(s): ${unmappedFields
@@ -159,10 +182,16 @@ async function main() {
159
182
  }
160
183
  }
161
184
  }
162
- console.log(`\nGenerated ${doctypes.length} doctype(s) in ${outputDir}` +
185
+ if (driftLines.length > 0) {
186
+ console.log('\nDrift between the authored doctypes and the schema (reported, not applied):');
187
+ for (const line of driftLines)
188
+ console.log(line);
189
+ }
190
+ console.log(`\n${values.check ? 'Checked' : 'Generated'} ${doctypes.length} doctype(s) in ${outputDir}` +
191
+ (changed ? ` (${changed} ${values.check ? 'would change' : 'written'})` : ' (all up to date)') +
163
192
  (warnings ? ` (${warnings} with warnings)` : '') +
164
193
  (errors ? ` (${errors} with errors)` : ''));
165
- if (errors > 0) {
194
+ if (errors > 0 || (values.check && changed > 0)) {
166
195
  process.exit(1);
167
196
  }
168
197
  }
@@ -184,11 +213,16 @@ OUTPUT:
184
213
  OPTIONS:
185
214
  --include <types> Comma-separated list of type names to include
186
215
  --exclude <types> Comma-separated list of type names to exclude
187
- --overrides <file> JSON file with per-type field overrides
216
+ --names <file> JSON file mapping GraphQL type name to doctype name
188
217
  --custom-scalars <file> JSON file mapping custom scalar names to field templates
189
218
  --include-unmapped Include _graphqlType metadata on unmapped fields
219
+ --check Report drift and exit non-zero if anything would change; write nothing
190
220
  --help, -h Show this help message
191
221
 
222
+ NOTE: an existing doctype file is the source of truth. Regeneration verifies it against the
223
+ schema and adds 'source: introspected' markers; it reports disagreements rather than
224
+ overwriting them, so hand-curation survives. Use --check in CI.
225
+
192
226
  EXAMPLES:
193
227
  # From a live PostGraphile server
194
228
  stonecrop-schema generate -e http://localhost:5000/graphql -o ./schemas
@@ -43,5 +43,7 @@ export { convertGraphQLSchema as default };
43
43
  export type { IntrospectionSource, GraphQLConversionOptions, GraphQLConversionFieldMeta, ConvertedGraphQLDoctype, } from './types';
44
44
  export { GQL_SCALAR_MAP, WELL_KNOWN_SCALARS, INTERNAL_SCALARS, buildScalarMap } from './scalars';
45
45
  export { defaultIsEntityType, defaultIsEntityField, classifyFieldType } from './heuristics';
46
+ export { mergeIntrospectedDoctype, formatDoctypeDrift } from './merge';
47
+ export type { AuthoredDoctype, DoctypeDrift, MergeResult } from './merge';
46
48
  export { toSlug, toPascalCase, pascalToSnake, snakeToCamel, camelToSnake, snakeToLabel, camelToLabel } from '../naming';
47
49
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/converter/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,wBAAwB,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAA;AAIrG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,oBAAoB,CACnC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,GAAE,wBAA6B,GACpC,uBAAuB,EAAE,CA+H3B;AAwBD,OAAO,EAAE,oBAAoB,IAAI,OAAO,EAAE,CAAA;AAG1C,YAAY,EACX,mBAAmB,EACnB,wBAAwB,EACxB,0BAA0B,EAC1B,uBAAuB,GACvB,MAAM,SAAS,CAAA;AAGhB,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAGhG,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAG3F,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,WAAW,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/converter/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,wBAAwB,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAA;AAIrG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,oBAAoB,CACnC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,GAAE,wBAA6B,GACpC,uBAAuB,EAAE,CAkJ3B;AAwBD,OAAO,EAAE,oBAAoB,IAAI,OAAO,EAAE,CAAA;AAG1C,YAAY,EACX,mBAAmB,EACnB,wBAAwB,EACxB,0BAA0B,EAC1B,uBAAuB,GACvB,MAAM,SAAS,CAAA;AAGhB,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAGhG,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAG3F,OAAO,EAAE,wBAAwB,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AACtE,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAGzE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,WAAW,CAAA"}
@@ -86,8 +86,18 @@ export function convertGraphQLSchema(source, options = {}) {
86
86
  if (!isObjectType(type))
87
87
  continue;
88
88
  const fields = type.getFields();
89
- const typeOverrides = options.typeOverrides?.[typeName];
90
- const entityFields = Object.entries(fields).filter(([fieldName, field]) => isEntityField(fieldName, field, type));
89
+ // A type carrying BOTH `id` and `rowId` is PostGraphile Amber with its default inflection:
90
+ // the Relay global identifier has taken `id`, displacing the real column to `rowId`. Neither
91
+ // name can be emitted as-is — `id` is an opaque node id, and `rowId` does not name a column.
92
+ // Refuse to guess: drop the Relay field and tell the caller to fix it at the inflector, where
93
+ // it belongs. Normalizing here would bake a database fact into the doctype.
94
+ const isUnnormalizedPostGraphile = 'id' in fields && 'rowId' in fields;
95
+ if (isUnnormalizedPostGraphile) {
96
+ options.onWarning?.(`${typeName}: schema exposes both 'id' (Relay identifier) and 'rowId' (the real column). ` +
97
+ `Skipping 'id' and emitting 'rowId' verbatim — no primary key can be derived. ` +
98
+ `Override the '_attributeName' and 'nodeIdFieldName' inflectors so the column keeps its own name.`);
99
+ }
100
+ const entityFields = Object.entries(fields).filter(([fieldName, field]) => isEntityField(fieldName, field, type) && !(isUnnormalizedPostGraphile && fieldName === 'id'));
91
101
  // oxlint-disable-next-line oxc/no-map-spread -- ...custom spread required; Object.assign cannot preserve the metadata-carrying inferred union type from classifyField
92
102
  const allClassifiedFields = entityFields.map(([fieldName, field]) => {
93
103
  // Check for full custom classification first
@@ -104,13 +114,14 @@ export function convertGraphQLSchema(source, options = {}) {
104
114
  }
105
115
  }
106
116
  // Default classification
107
- const classified = classifyFieldType(fieldName, field, entityTypes, options);
108
- // Apply per-field overrides
109
- if (typeOverrides?.[fieldName]) {
110
- return Object.assign(classified, typeOverrides[fieldName]);
111
- }
112
- return classified;
117
+ return classifyFieldType(fieldName, field, entityTypes, options);
113
118
  });
119
+ // Derive the primary key, but only for the one case SDL actually settles: a non-null `id`
120
+ // that is a plain scalar. A natural key is typically a UNIQUE constraint indistinguishable
121
+ // from any other column here, and a table may carry several — so anything else is left for
122
+ // the author to declare. Emitting a guess would be worse than emitting nothing, because the
123
+ // middleware builds its identity predicate from this and the client keys records by it.
124
+ const primaryKeyFieldname = allClassifiedFields.find(field => field.fieldname === 'id' && field.required && !field.doctype && !field._isLink)?.fieldname;
114
125
  // Separate scalar fields from link fields
115
126
  const links = {};
116
127
  const convertedFields = allClassifiedFields
@@ -124,21 +135,22 @@ export function convertGraphQLSchema(source, options = {}) {
124
135
  }
125
136
  return true;
126
137
  })
127
- // Clean up internal metadata unless requested, and stamp provenance.
128
- // Stamped last so every classification path (default, classifyField,
129
- // typeOverrides) carries the marker — the docbuilder's identity lock
130
- // keys off it, and an override must not be able to unset it.
138
+ // Clean up internal metadata unless requested, and stamp identity + provenance.
139
+ // Stamped last so every classification path (default, classifyField) carries the marker —
140
+ // the docbuilder's identity lock keys off it, and no classifier may unset it.
131
141
  .map(field => {
142
+ const identity = field.fieldname === primaryKeyFieldname ? { primaryKey: true } : {};
132
143
  if (!options.includeUnmappedMeta) {
133
144
  const { _graphqlType, _unmapped, _isLink, ...clean } = field;
134
- return Object.assign(clean, { source: 'introspected' });
145
+ return Object.assign(clean, identity, { source: 'introspected' });
135
146
  }
136
147
  const { _isLink, ...rest } = field;
137
- return Object.assign(rest, { source: 'introspected' });
148
+ return Object.assign(rest, identity, { source: 'introspected' });
138
149
  });
150
+ const doctypeName = options.doctypeNames?.[typeName] ?? typeName;
139
151
  const doctype = {
140
- name: typeName,
141
- slug: toSlug(typeName),
152
+ name: doctypeName,
153
+ slug: toSlug(doctypeName),
142
154
  fields: convertedFields,
143
155
  };
144
156
  if (Object.keys(links).length > 0) {
@@ -175,5 +187,7 @@ export { convertGraphQLSchema as default };
175
187
  export { GQL_SCALAR_MAP, WELL_KNOWN_SCALARS, INTERNAL_SCALARS, buildScalarMap } from './scalars';
176
188
  // Heuristics
177
189
  export { defaultIsEntityType, defaultIsEntityField, classifyFieldType } from './heuristics';
190
+ // Merge — verifies an authored doctype against the schema and stamps provenance
191
+ export { mergeIntrospectedDoctype, formatDoctypeDrift } from './merge';
178
192
  // Naming utilities
179
193
  export { toSlug, toPascalCase, pascalToSnake, snakeToCamel, camelToSnake, snakeToLabel, camelToLabel } from '../naming';
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Merge introspected schema facts into an already-authored doctype.
3
+ *
4
+ * The authored doctype is the source of truth. Generation **verifies** it and stamps provenance;
5
+ * it does not overwrite. That polarity is deliberate and load-bearing — a doctype legitimately
6
+ * declares a `primaryKey` the schema cannot express. A natural business key is very often a
7
+ * `UNIQUE` constraint rather than the table's `PRIMARY KEY`, and where a table carries several
8
+ * uniques no rule can pick between them. Overwriting identity from the schema would silently
9
+ * re-key such a doctype on every regeneration and break the handlers that key on the old value.
10
+ *
11
+ * So divergence is **reported, never applied** — a human decides. The only mutation this performs
12
+ * is adding `source: 'introspected'` to fields confirmed to exist in the GraphQL schema.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ import type { ConvertedGraphQLDoctype } from './types';
17
+ /**
18
+ * A doctype as it exists on disk: a plain object that may carry keys this package does not model
19
+ * (`handler` on an action, `filterFunction` on a field, whatever an app has added). Typing it
20
+ * loosely is what lets the merge round-trip those keys untouched instead of dropping them.
21
+ *
22
+ * @public
23
+ */
24
+ export type AuthoredDoctype = Record<string, unknown>;
25
+ /**
26
+ * What generation found that the authored doctype does not agree with. Every bucket is advisory —
27
+ * nothing here is applied automatically.
28
+ *
29
+ * @public
30
+ */
31
+ export interface DoctypeDrift {
32
+ /** The authored doctype's name. */
33
+ doctype: string;
34
+ /**
35
+ * `clean` — the authored primary key is the one generation would derive.
36
+ * `partial` — the doctype declares an identity generation cannot derive, so identity was left alone.
37
+ */
38
+ mode: 'clean' | 'partial';
39
+ /** Why the mode is `partial`, when it is. */
40
+ reason?: string;
41
+ /** Fieldnames confirmed against the schema and stamped. */
42
+ tagged: string[];
43
+ /** Authored fields with no matching schema field — app components, fieldsets, or stale entries. */
44
+ orphan: string[];
45
+ /** Schema fields absent from the doctype. Usually deliberate curation, occasionally an oversight. */
46
+ omitted: string[];
47
+ /** `fieldname: authored=… schema=…` where the chosen component differs from the scalar mapping. */
48
+ componentDrift: string[];
49
+ /** `fieldname: authored=… schema=…` where nullability disagrees. */
50
+ requiredDrift: string[];
51
+ /** Identity properties that differ. These are the ones a human must adjudicate. */
52
+ identityDrift: string[];
53
+ }
54
+ /** Outcome of a merge: the doctype to write, plus what generation disagreed with. @public */
55
+ export interface MergeResult {
56
+ /** The authored doctype with `source` markers added and nothing else changed. */
57
+ doctype: AuthoredDoctype;
58
+ /** Advisory report. Never applied. */
59
+ drift: DoctypeDrift;
60
+ }
61
+ /**
62
+ * Verify an authored doctype against freshly generated output and stamp provenance.
63
+ *
64
+ * @param authored - the doctype as it exists on disk; every key not named below is preserved verbatim
65
+ * @param generated - `convertGraphQLSchema` output for the corresponding GraphQL type
66
+ * @returns the doctype to write, plus a drift report
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * const [generated] = convertGraphQLSchema(introspection, { include: ['Uom'] })
71
+ * const { doctype, drift } = mergeIntrospectedDoctype(JSON.parse(onDisk), generated)
72
+ * if (drift.identityDrift.length) console.warn(drift.identityDrift.join('\n'))
73
+ * ```
74
+ *
75
+ * @public
76
+ */
77
+ export declare function mergeIntrospectedDoctype(authored: AuthoredDoctype, generated: ConvertedGraphQLDoctype): MergeResult;
78
+ /**
79
+ * Render a drift report as human-readable lines. Empty when generation agrees with the doctype.
80
+ *
81
+ * @param drift - a report from {@link mergeIntrospectedDoctype}
82
+ * @returns one line per finding, ready to print
83
+ *
84
+ * @public
85
+ */
86
+ export declare function formatDoctypeDrift(drift: DoctypeDrift): string[];
87
+ //# sourceMappingURL=merge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["../../../src/converter/merge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAA;AAEtD;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAErD;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC5B,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,IAAI,EAAE,OAAO,GAAG,SAAS,CAAA;IACzB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,2DAA2D;IAC3D,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,mGAAmG;IACnG,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,qGAAqG;IACrG,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,mGAAmG;IACnG,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,oEAAoE;IACpE,aAAa,EAAE,MAAM,EAAE,CAAA;IACvB,mFAAmF;IACnF,aAAa,EAAE,MAAM,EAAE,CAAA;CACvB;AAED,6FAA6F;AAC7F,MAAM,WAAW,WAAW;IAC3B,iFAAiF;IACjF,OAAO,EAAE,eAAe,CAAA;IACxB,sCAAsC;IACtC,KAAK,EAAE,YAAY,CAAA;CACnB;AAuBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,eAAe,EAAE,SAAS,EAAE,uBAAuB,GAAG,WAAW,CA4EnH;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,EAAE,CAYhE"}
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Merge introspected schema facts into an already-authored doctype.
3
+ *
4
+ * The authored doctype is the source of truth. Generation **verifies** it and stamps provenance;
5
+ * it does not overwrite. That polarity is deliberate and load-bearing — a doctype legitimately
6
+ * declares a `primaryKey` the schema cannot express. A natural business key is very often a
7
+ * `UNIQUE` constraint rather than the table's `PRIMARY KEY`, and where a table carries several
8
+ * uniques no rule can pick between them. Overwriting identity from the schema would silently
9
+ * re-key such a doctype on every regeneration and break the handlers that key on the old value.
10
+ *
11
+ * So divergence is **reported, never applied** — a human decides. The only mutation this performs
12
+ * is adding `source: 'introspected'` to fields confirmed to exist in the GraphQL schema.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ import { INTROSPECTED_IDENTITY_PROPS } from '../field';
17
+ /** Recursively flatten authored fields, descending into fieldsets. */
18
+ function flattenAuthored(fields) {
19
+ const out = [];
20
+ for (const f of fields) {
21
+ if (Array.isArray(f.schema)) {
22
+ out.push(...flattenAuthored(f.schema.filter(isRecord)));
23
+ }
24
+ else {
25
+ out.push(f);
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+ function isRecord(value) {
31
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
32
+ }
33
+ function describe(value) {
34
+ return value === undefined ? '—' : JSON.stringify(value);
35
+ }
36
+ /**
37
+ * Verify an authored doctype against freshly generated output and stamp provenance.
38
+ *
39
+ * @param authored - the doctype as it exists on disk; every key not named below is preserved verbatim
40
+ * @param generated - `convertGraphQLSchema` output for the corresponding GraphQL type
41
+ * @returns the doctype to write, plus a drift report
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * const [generated] = convertGraphQLSchema(introspection, { include: ['Uom'] })
46
+ * const { doctype, drift } = mergeIntrospectedDoctype(JSON.parse(onDisk), generated)
47
+ * if (drift.identityDrift.length) console.warn(drift.identityDrift.join('\n'))
48
+ * ```
49
+ *
50
+ * @public
51
+ */
52
+ export function mergeIntrospectedDoctype(authored, generated) {
53
+ const authoredFields = Array.isArray(authored.fields) ? authored.fields.filter(isRecord) : [];
54
+ const generatedByName = new Map(generated.fields.map(f => [f.fieldname, f]));
55
+ // Expanding links live in `links`, not `fields`, so a field naming one is modelled, not orphaned.
56
+ const generatedLinkNames = new Set(Object.keys(generated.links ?? {}));
57
+ const drift = {
58
+ doctype: typeof authored.name === 'string' ? authored.name : '(unnamed)',
59
+ mode: 'clean',
60
+ tagged: [],
61
+ orphan: [],
62
+ omitted: [],
63
+ componentDrift: [],
64
+ requiredDrift: [],
65
+ identityDrift: [],
66
+ };
67
+ const tag = (field) => {
68
+ // Containers have no column of their own; recurse and leave the container itself alone.
69
+ if (Array.isArray(field.schema)) {
70
+ return { ...field, schema: field.schema.filter(isRecord).map(tag) };
71
+ }
72
+ const name = typeof field.fieldname === 'string' ? field.fieldname : '';
73
+ const match = generatedByName.get(name);
74
+ if (!match) {
75
+ // A computed field declares up front that it has no backing column, so it is not a
76
+ // discrepancy. Everything else is worth surfacing — it may be an app component, or a
77
+ // column that has since been dropped.
78
+ if (field.computed !== true && !generatedLinkNames.has(name))
79
+ drift.orphan.push(name);
80
+ return field;
81
+ }
82
+ drift.tagged.push(name);
83
+ if (match.component !== field.component) {
84
+ drift.componentDrift.push(`${name}: authored=${describe(field.component)} schema=${describe(match.component)}`);
85
+ }
86
+ if (Boolean(match.required) !== Boolean(field.required)) {
87
+ drift.requiredDrift.push(`${name}: authored=${Boolean(field.required)} schema=${Boolean(match.required)}`);
88
+ }
89
+ for (const prop of INTROSPECTED_IDENTITY_PROPS) {
90
+ if (prop === 'fieldname' || prop === 'required')
91
+ continue;
92
+ const authoredValue = field[prop];
93
+ const schemaValue = match[prop];
94
+ // Absent on both sides is agreement, not drift — most fields set none of these.
95
+ if (authoredValue === undefined && schemaValue === undefined)
96
+ continue;
97
+ if (JSON.stringify(authoredValue) !== JSON.stringify(schemaValue)) {
98
+ drift.identityDrift.push(`${name}.${prop}: authored=${describe(authoredValue)} schema=${describe(schemaValue)}`);
99
+ }
100
+ }
101
+ return { ...field, source: 'introspected' };
102
+ };
103
+ const merged = { ...authored, fields: authoredFields.map(tag) };
104
+ const authoredNames = new Set(flattenAuthored(authoredFields).map(f => f.fieldname));
105
+ drift.omitted = generated.fields.map(f => f.fieldname).filter(n => !authoredNames.has(n));
106
+ // Classify identity last, once every field has been compared.
107
+ const authoredPk = flattenAuthored(authoredFields).find(f => f.primaryKey === true);
108
+ const generatedPk = generated.fields.find(f => f.primaryKey === true);
109
+ if (authoredPk && generatedPk && authoredPk.fieldname !== generatedPk.fieldname) {
110
+ drift.mode = 'partial';
111
+ drift.reason = `authored primary key '${String(authoredPk.fieldname)}' is not the derivable '${generatedPk.fieldname}' — left as authored`;
112
+ }
113
+ else if (authoredPk && !generatedPk) {
114
+ drift.mode = 'partial';
115
+ drift.reason = `authored primary key '${String(authoredPk.fieldname)}' is not derivable from the schema — left as authored`;
116
+ }
117
+ else if (!authoredPk && generatedPk) {
118
+ drift.mode = 'partial';
119
+ drift.reason = `schema suggests '${generatedPk.fieldname}' as primary key but the doctype declares none — not applied`;
120
+ }
121
+ return { doctype: merged, drift };
122
+ }
123
+ /**
124
+ * Render a drift report as human-readable lines. Empty when generation agrees with the doctype.
125
+ *
126
+ * @param drift - a report from {@link mergeIntrospectedDoctype}
127
+ * @returns one line per finding, ready to print
128
+ *
129
+ * @public
130
+ */
131
+ export function formatDoctypeDrift(drift) {
132
+ const lines = [];
133
+ if (drift.reason)
134
+ lines.push(` ${drift.doctype}: ${drift.reason}`);
135
+ const bucket = (label, entries) => {
136
+ if (entries.length)
137
+ lines.push(` ${drift.doctype}: ${label} ${entries.join('; ')}`);
138
+ };
139
+ bucket('identity drift', drift.identityDrift);
140
+ bucket('component drift', drift.componentDrift);
141
+ bucket('required drift', drift.requiredDrift);
142
+ bucket('authored fields with no schema field:', drift.orphan);
143
+ bucket('schema fields not modelled:', drift.omitted);
144
+ return lines;
145
+ }
@@ -50,19 +50,28 @@ export interface GraphQLConversionOptions {
50
50
  */
51
51
  include?: string[];
52
52
  /**
53
- * Per-type, per-field overrides for the converted field definitions.
54
- * Outer key is the GraphQL type name, inner key is the field name.
53
+ * Emit a doctype under a different name than its GraphQL type. Key is the GraphQL type name,
54
+ * value is the doctype `name`; `slug` is derived from the value.
55
+ *
56
+ * This exists for the case where a doctype is not one-to-one with a table — a second view over
57
+ * an existing type, say, distinguished only by presentation. Without it the converter can only
58
+ * ever name a doctype after its type.
59
+ *
60
+ * Keep it consistent with the middleware's `tables` option, which maps the resulting doctype
61
+ * name to its SQL target.
55
62
  *
56
63
  * @example
57
64
  * ```typescript
58
- * {
59
- * SalesOrder: {
60
- * totalAmount: { component: 'ANumericInput', align: 'right' }
61
- * }
62
- * }
65
+ * { Plan: 'Planner' } // emits a doctype named Planner, slug 'planner', from type Plan
63
66
  * ```
64
67
  */
65
- typeOverrides?: Record<string, Record<string, Omit<Partial<ValueField>, 'kind'>>>;
68
+ doctypeNames?: Record<string, string>;
69
+ /**
70
+ * Called with any advisory message raised during conversion — currently only the
71
+ * un-normalized-PostGraphile warning. Left to the caller so the library never writes to the
72
+ * console itself.
73
+ */
74
+ onWarning?: (message: string) => void;
66
75
  /**
67
76
  * Map custom or non-standard GraphQL scalar types to the component that renders them.
68
77
  * Merged with the built-in scalar maps (GQL_SCALAR_MAP + WELL_KNOWN_SCALARS).
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/converter/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AACjD,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAE1C;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC7B,yFAAyF;IACzF,SAAS,EAAE,MAAM,CAAA;CACjB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,mBAAmB,GAAG,kBAAkB,GAAG,MAAM,CAAA;AAE7D;;;;;GAKG;AACH,MAAM,WAAW,wBAAwB;IACxC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAElB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAElB;;;;;;;;;;;;OAYG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAA;IAEjF;;;;;;;;;;;;OAYG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC,CAAA;IAEtD;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,KAAK,OAAO,CAAA;IAErE;;;;;;;;;;OAUG;IACH,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE,UAAU,EAAE,iBAAiB,KAAK,OAAO,CAAA;IAEpH;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,CACf,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,YAAY,CAAC,OAAO,EAAE,OAAO,CAAC,EACrC,UAAU,EAAE,iBAAiB,KACzB,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;IAE7C;;;OAGG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAA;CAC7B;AAED;;;;;GAKG;AACH,MAAM,WAAW,0BAA2B,SAAQ,UAAU;IAC7D,2DAA2D;IAC3D,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yDAAyD;IACzD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,qEAAqE;IACrE,OAAO,CAAC,EAAE,OAAO,CAAA;CACjB;AAED;;;;GAIG;AACH,MAAM,WAAW,uBAAwB,SAAQ,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC;IAC3E,iGAAiG;IACjG,MAAM,EAAE,UAAU,EAAE,CAAA;IACpB,2DAA2D;IAC3D,gBAAgB,CAAC,EAAE,MAAM,CAAA;CACzB;AAGD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/converter/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AACjD,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAE1C;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC7B,yFAAyF;IACzF,SAAS,EAAE,MAAM,CAAA;CACjB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,mBAAmB,GAAG,kBAAkB,GAAG,MAAM,CAAA;AAE7D;;;;;GAKG;AACH,MAAM,WAAW,wBAAwB;IACxC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAElB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAElB;;;;;;;;;;;;;;;OAeG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAErC;;;;OAIG;IACH,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;IAErC;;;;;;;;;;;;OAYG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC,CAAA;IAEtD;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,KAAK,OAAO,CAAA;IAErE;;;;;;;;;;OAUG;IACH,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE,UAAU,EAAE,iBAAiB,KAAK,OAAO,CAAA;IAEpH;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,CACf,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,YAAY,CAAC,OAAO,EAAE,OAAO,CAAC,EACrC,UAAU,EAAE,iBAAiB,KACzB,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;IAE7C;;;OAGG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAA;CAC7B;AAED;;;;;GAKG;AACH,MAAM,WAAW,0BAA2B,SAAQ,UAAU;IAC7D,2DAA2D;IAC3D,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yDAAyD;IACzD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,qEAAqE;IACrE,OAAO,CAAC,EAAE,OAAO,CAAA;CACjB;AAED;;;;GAIG;AACH,MAAM,WAAW,uBAAwB,SAAQ,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC;IAC3E,iGAAiG;IACjG,MAAM,EAAE,UAAU,EAAE,CAAA;IACpB,2DAA2D;IAC3D,gBAAgB,CAAC,EAAE,MAAM,CAAA;CACzB;AAGD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA"}
@@ -171,6 +171,53 @@ export type DoctypeField = ValueField | FieldsetField | TableField;
171
171
  * @public
172
172
  */
173
173
  export declare function normalizeFieldKind(field: unknown): unknown;
174
+ /**
175
+ * The field properties a `source: 'introspected'` marker freezes — the ones the database owns.
176
+ *
177
+ * This is the single definition of the identity set. The docbuilder greys these inputs on an
178
+ * introspected field, and the converter's merge refuses to rewrite them. Stating it twice is how
179
+ * the two drift, so both read this constant.
180
+ *
181
+ * Everything absent from this list is author-owned, `component` most importantly: it chooses the
182
+ * widget, which is an authoring decision the database has no opinion about.
183
+ *
184
+ * @public
185
+ */
186
+ export declare const INTROSPECTED_IDENTITY_PROPS: readonly ["fieldname", "primaryKey", "required", "options", "cardinality", "doctype"];
187
+ /**
188
+ * Find the field a doctype marks as its primary key, or `undefined` when none is marked.
189
+ *
190
+ * This is the single definition of "which field identifies a record". Both sides depend on it:
191
+ * the middleware builds the SQL identity predicate from it, and the client resolves a record's
192
+ * route/store key from it. Call this; never re-derive the rule at the call site, or the two will
193
+ * drift and the client will key records by a column the server never queried.
194
+ *
195
+ * Two deliberate limits, both matching the shape `primaryKey` actually has:
196
+ * - Only **top-level** fields are scanned. `primaryKey` is a `ValueField` flag and a fieldset's
197
+ * children are not identity columns, so a nested match would be an authoring error, not a PK.
198
+ * - The **first** match wins. Nothing in the schema enforces exactly one `primaryKey: true`, and
199
+ * there is no composite-key representation — a doctype with several is already malformed, and
200
+ * picking the first is what the middleware has always done.
201
+ *
202
+ * @param fields - the doctype's top-level fields
203
+ * @returns the primary-key field, or `undefined` for a PK-less doctype
204
+ * @public
205
+ */
206
+ export declare function getPrimaryKeyField(fields: readonly DoctypeField[]): ValueField | undefined;
207
+ /**
208
+ * Resolve a record's identity value using the doctype's declared primary key.
209
+ *
210
+ * Falls back to `record.id` when the doctype declares no `primaryKey`. That fallback is
211
+ * load-bearing, not defensive: surrogate-key doctypes carry an `id` column and never mark a
212
+ * primary key, and PostGraphile renames a single-column `id` PK to `rowId` — so the declared
213
+ * field and `id` are both real sources, in that order.
214
+ *
215
+ * @param fields - the doctype's top-level fields
216
+ * @param record - the record to read the identity from
217
+ * @returns the identity as a string, or `undefined` when neither source yields a usable value
218
+ * @public
219
+ */
220
+ export declare function getRecordIdentity(fields: readonly DoctypeField[], record: Record<string, unknown>): string | undefined;
174
221
  /**
175
222
  * Zod runtime validation schema for ValueField.
176
223
  * @public
@@ -1 +1 @@
1
- {"version":3,"file":"field.d.ts","sourceRoot":"","sources":["../../src/field.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"field.d.ts","sourceRoot":"","sources":["../../src/field.ts"],"names":[],"mappings":"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"}
package/dist/src/field.js CHANGED
@@ -96,6 +96,73 @@ export function normalizeFieldKind(field) {
96
96
  }
97
97
  return injected;
98
98
  }
99
+ /**
100
+ * The field properties a `source: 'introspected'` marker freezes — the ones the database owns.
101
+ *
102
+ * This is the single definition of the identity set. The docbuilder greys these inputs on an
103
+ * introspected field, and the converter's merge refuses to rewrite them. Stating it twice is how
104
+ * the two drift, so both read this constant.
105
+ *
106
+ * Everything absent from this list is author-owned, `component` most importantly: it chooses the
107
+ * widget, which is an authoring decision the database has no opinion about.
108
+ *
109
+ * @public
110
+ */
111
+ export const INTROSPECTED_IDENTITY_PROPS = [
112
+ 'fieldname',
113
+ 'primaryKey',
114
+ 'required',
115
+ 'options',
116
+ 'cardinality',
117
+ 'doctype',
118
+ ];
119
+ /**
120
+ * Find the field a doctype marks as its primary key, or `undefined` when none is marked.
121
+ *
122
+ * This is the single definition of "which field identifies a record". Both sides depend on it:
123
+ * the middleware builds the SQL identity predicate from it, and the client resolves a record's
124
+ * route/store key from it. Call this; never re-derive the rule at the call site, or the two will
125
+ * drift and the client will key records by a column the server never queried.
126
+ *
127
+ * Two deliberate limits, both matching the shape `primaryKey` actually has:
128
+ * - Only **top-level** fields are scanned. `primaryKey` is a `ValueField` flag and a fieldset's
129
+ * children are not identity columns, so a nested match would be an authoring error, not a PK.
130
+ * - The **first** match wins. Nothing in the schema enforces exactly one `primaryKey: true`, and
131
+ * there is no composite-key representation — a doctype with several is already malformed, and
132
+ * picking the first is what the middleware has always done.
133
+ *
134
+ * @param fields - the doctype's top-level fields
135
+ * @returns the primary-key field, or `undefined` for a PK-less doctype
136
+ * @public
137
+ */
138
+ export function getPrimaryKeyField(fields) {
139
+ return fields.find((f) => f.kind === 'field' && Boolean(f.primaryKey));
140
+ }
141
+ /**
142
+ * Resolve a record's identity value using the doctype's declared primary key.
143
+ *
144
+ * Falls back to `record.id` when the doctype declares no `primaryKey`. That fallback is
145
+ * load-bearing, not defensive: surrogate-key doctypes carry an `id` column and never mark a
146
+ * primary key, and PostGraphile renames a single-column `id` PK to `rowId` — so the declared
147
+ * field and `id` are both real sources, in that order.
148
+ *
149
+ * @param fields - the doctype's top-level fields
150
+ * @param record - the record to read the identity from
151
+ * @returns the identity as a string, or `undefined` when neither source yields a usable value
152
+ * @public
153
+ */
154
+ export function getRecordIdentity(fields, record) {
155
+ const pkField = getPrimaryKeyField(fields);
156
+ const candidates = pkField ? [record[pkField.fieldname], record.id] : [record.id];
157
+ for (const value of candidates) {
158
+ // Numbers are valid keys (a serial PK); 0 is a legitimate id, so test the type, not truthiness.
159
+ if (typeof value === 'number')
160
+ return String(value);
161
+ if (typeof value === 'string' && value !== '')
162
+ return value;
163
+ }
164
+ return undefined;
165
+ }
99
166
  function createDoctypeFieldSchemas() {
100
167
  const ValueFieldSchema = z
101
168
  .object({