@stonecrop/schema 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/schema.d.ts CHANGED
@@ -23,6 +23,46 @@ export declare const ActionDefinition: z.ZodObject<{
23
23
  */
24
24
  export declare type ActionDefinition = z.infer<typeof ActionDefinition>;
25
25
 
26
+ /**
27
+ * The name an entity's aggregate doctype is generated under: the entity's name, pluralised.
28
+ *
29
+ * One definition, because the CLI writes the file under `toSlug` of this and any later caller
30
+ * (a scaffolder, a docs generator) must land on the same name or it silently addresses a
31
+ * different file.
32
+ *
33
+ * `pluralize` rather than appending `s`, because the irregulars are not rare in practice —
34
+ * measured against a consumer's 41 hand-authored aggregate doctypes, this rule reproduces every
35
+ * one of their names, slugs and filenames exactly, while `+ 's'` gets five wrong
36
+ * (`Currencys`, `JournalEntrys`, …).
37
+ *
38
+ * The rule is not total: an already-plural name pluralises to itself. Callers must handle that —
39
+ * see {@link buildAggregateDoctype}.
40
+ *
41
+ * @param doctypeName - the entity doctype's `name`
42
+ * @returns the aggregate doctype's `name`
43
+ * @public
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * aggregateDoctypeName('SalesOrder') // 'SalesOrders' -> slug 'sales-orders'
48
+ * ```
49
+ */
50
+ export declare function aggregateDoctypeName(doctypeName: string): string;
51
+
52
+ /**
53
+ * Reading an authored doctype — the JSON as it sits on disk, before any parsing.
54
+ *
55
+ * A separate reader from `@stonecrop/schema`'s `flattenFields`/`getPrimaryKeyField` because the two
56
+ * operate on different *shapes*, not different rules: those take parsed `DoctypeField`s and branch
57
+ * on the `kind` discriminant the Zod parser synthesizes, which authored JSON does not carry.
58
+ * `getPrimaryKeyField` on a raw file therefore returns `undefined` — indistinguishable from "no key
59
+ * declared", which is the exact condition its callers are testing.
60
+ *
61
+ * Every question about authored JSON is answered here once, so the rule cannot drift between the
62
+ * merge and the generation plan.
63
+ *
64
+ * @internal
65
+ */
26
66
  /**
27
67
  * A doctype as it exists on disk: a plain object that may carry keys this package does not model
28
68
  * (`handler` on an action, `filterFunction` on a field, whatever an app has added). Typing it
@@ -70,6 +110,38 @@ export declare interface BadgeSpecObject {
70
110
  */
71
111
  export declare type BadgeVariant = 'neutral' | 'success' | 'warning' | 'danger' | 'brand';
72
112
 
113
+ /**
114
+ * Derive the aggregate doctype for a converted entity.
115
+ *
116
+ * Returns `undefined` when no identity column can be found — a natural-key table whose key the
117
+ * converter refuses to guess and whose author has not declared one, or a foreign PostGraphile
118
+ * endpoint that has left the Relay identifier occupying `id` (Stonecrop's own preset moves it to
119
+ * `nodeId`). That is deliberate: an aggregate with an empty `fields` array is a valid doctype that
120
+ * renders a table with no columns, which looks like a data problem rather than a generation one.
121
+ * Emitting nothing and saying so is the loud failure.
122
+ *
123
+ * Identity resolves the same way `getRecordIdField` resolves it — the declared `primaryKey`, then
124
+ * the conventional `id` — so an aggregate is always keyed on the column the client will later ask
125
+ * for. `declaredIdentity` overrides both: SDL cannot express which `UNIQUE` column is the key, so
126
+ * for a natural-key table the answer only exists in the authored file, and the caller that read it
127
+ * passes the fieldname back.
128
+ *
129
+ * @param doctype - a converted entity doctype, as returned by `convertGraphQLSchema`
130
+ * @param declaredIdentity - fieldname the authored doctype declares as its `primaryKey`, when the
131
+ * caller has read one. Must name a field the converter emitted; the caller checks that, because
132
+ * only it can say whether a missing one is a dropped column or a typo.
133
+ * @returns the aggregate doctype, or `undefined` when no identity column exists
134
+ * @public
135
+ *
136
+ * @example
137
+ * ```typescript
138
+ * const [order] = convertGraphQLSchema(sdl, { include: ['Order'] })
139
+ * const aggregate = buildAggregateDoctype(order)
140
+ * // { name: 'Orders', slug: 'orders', fields: [ the id field ] }
141
+ * ```
142
+ */
143
+ export declare function buildAggregateDoctype(doctype: ConvertedGraphQLDoctype, declaredIdentity?: string): ConvertedGraphQLDoctype | undefined;
144
+
73
145
  /**
74
146
  * Build a merged scalar map from the built-in maps and user-provided custom scalars.
75
147
  * Precedence (highest to lowest): customScalars → GQL_SCALAR_MAP → WELL_KNOWN_SCALARS
@@ -479,11 +551,11 @@ export declare interface DataClient<T extends DoctypeRef = DoctypeRef, M = Docty
479
551
  *
480
552
  * @param fieldName - The GraphQL field name
481
553
  * @param _field - The GraphQL field definition (unused in default implementation)
482
- * @param _parentType - The parent entity type (unused in default implementation)
554
+ * @param parentType - The parent entity type, whose interfaces declare its Relay identifier
483
555
  * @returns `true` if this field should be included
484
556
  * @public
485
557
  */
486
- export declare function defaultIsEntityField(fieldName: string, _field: GraphQLField<unknown, unknown>, _parentType: GraphQLObjectType): boolean;
558
+ export declare function defaultIsEntityField(fieldName: string, _field: GraphQLField<unknown, unknown>, parentType: GraphQLObjectType): boolean;
487
559
 
488
560
  /**
489
561
  * Default heuristic to determine if a GraphQL object type represents an entity.
@@ -568,6 +640,7 @@ export declare const DoctypeMeta: z.ZodObject<{
568
640
  name: z.ZodString;
569
641
  slug: z.ZodOptional<z.ZodString>;
570
642
  displayField: z.ZodOptional<z.ZodString>;
643
+ route: z.ZodOptional<z.ZodString>;
571
644
  fields: z.ZodArray<z.ZodType<DoctypeField, unknown, z.core.$ZodTypeInternals<DoctypeField, unknown>>>;
572
645
  links: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
573
646
  target: z.ZodString;
@@ -792,6 +865,45 @@ export declare function flattenFields(fields: readonly DoctypeField[]): (ValueFi
792
865
  */
793
866
  export declare function formatDoctypeDrift(drift: DoctypeDrift): string[];
794
867
 
868
+ /**
869
+ * One file the generator will write, and what that file is verified against.
870
+ *
871
+ * `basis` exists because the two are not always the same document. An aggregate is written from
872
+ * its own one-field generation but verified against the **entity**, since its purpose is to carry
873
+ * fewer columns than the table — checking it against itself reports every curated column as one
874
+ * the table had dropped.
875
+ *
876
+ * @public
877
+ */
878
+ export declare interface GenerationPlanEntry {
879
+ /** The doctype to write. */
880
+ generated: ConvertedGraphQLDoctype;
881
+ /** The doctype whose fields an existing file on disk is verified against. */
882
+ basis: ConvertedGraphQLDoctype;
883
+ /** Whether the file is a curated subset of `basis` — passed through to `MergeOptions.subset`. */
884
+ subset: boolean;
885
+ }
886
+
887
+ /** Options for {@link planGeneration}. @public */
888
+ export declare interface GenerationPlanOptions {
889
+ /** Emit only the entity doctypes, skipping their aggregates. Defaults to `false`. */
890
+ noAggregates?: boolean;
891
+ /** Called with an advisory message for each entity that yields no aggregate. */
892
+ onWarning?: (message: string) => void;
893
+ /**
894
+ * Identity the authored doctype on disk declares, keyed by doctype `name`.
895
+ *
896
+ * SDL cannot say which `UNIQUE` column is a table's key, so for a natural-key table the converter
897
+ * derives nothing and the answer exists only in the file. Without this the aggregate is
898
+ * unreachable: generation says "declare a primaryKey and re-run", and re-running after declaring
899
+ * one changes nothing, because planning never reads the file.
900
+ *
901
+ * Passed in rather than read here so this stays a pure function of its inputs; the CLI owns the
902
+ * IO. The plan is then a function of the schema *and* what is already on disk.
903
+ */
904
+ identity?: Record<string, string>;
905
+ }
906
+
795
907
  /**
796
908
  * Resolve the field a doctype nominates as its display text, or `undefined` when the nomination
797
909
  * does not name a readable column.
@@ -813,6 +925,44 @@ export declare function formatDoctypeDrift(drift: DoctypeDrift): string[];
813
925
  */
814
926
  export declare function getDisplayField(fields: readonly DoctypeField[], displayField: string | undefined): ValueField | undefined;
815
927
 
928
+ /**
929
+ * The one string a doctype is addressed by.
930
+ *
931
+ * A doctype carries two names — `name` (`OrderItem`) and `slug` (`order-item`) — and every registry
932
+ * must agree on which one keys it. Three implementations had drifted apart: the adapter's registry is
933
+ * keyed by `name` and its `getMeta` also scans for a matching `slug`, so it accepts **either**; the
934
+ * client's registry is keyed by a slug it derives itself and accepts **only** that; and
935
+ * `Doctype.fromObject` dropped an authored `slug` on the floor and re-derived one regardless. The
936
+ * adapter's accepted set was therefore a strict superset of the client's, and a link target written
937
+ * as the Name booted the server, passed its reference check, served rows over GraphQL, and was
938
+ * silently dropped by the client — an expanding child table rendering as one empty text input, with
939
+ * nothing logged.
940
+ *
941
+ * Resolving through this in both runtimes is what makes the two answers the same answer. It is the
942
+ * derivation only; a *lookup* still belongs to whichever registry owns the corpus, because the two
943
+ * corpora legitimately differ (a client registers lazily, and a client-only host has no adapter at
944
+ * all).
945
+ *
946
+ * An authored `slug` wins over the derived one because the authored doctype is the source of truth:
947
+ * generation verifies a file and never overwrites it, so a doctype that states its own slug means it.
948
+ * Deriving unconditionally is what `fromObject` did, and it made an authored `slug` a silent no-op on
949
+ * one side of the wire while the other honoured it.
950
+ *
951
+ * @param doctype - anything carrying a doctype's `name` and optional authored `slug`
952
+ * @returns the canonical slug
953
+ * @public
954
+ *
955
+ * @example
956
+ * ```typescript
957
+ * getDoctypeSlug({ name: 'OrderItem' }) // 'order-item'
958
+ * getDoctypeSlug({ name: 'Planner', slug: 'planner-board' }) // 'planner-board'
959
+ * ```
960
+ */
961
+ export declare function getDoctypeSlug(doctype: {
962
+ name: string;
963
+ slug?: string;
964
+ }): string;
965
+
816
966
  /**
817
967
  * Find the field a doctype marks as its primary key, or `undefined` when none is marked.
818
968
  *
@@ -821,15 +971,19 @@ export declare function getDisplayField(fields: readonly DoctypeField[], display
821
971
  * route/store key from it. Call this; never re-derive the rule at the call site, or the two will
822
972
  * drift and the client will key records by a column the server never queried.
823
973
  *
824
- * Two deliberate limits, both matching the shape `primaryKey` actually has:
825
- * - Only **top-level** fields are scanned. `primaryKey` is a `ValueField` flag and a fieldset's
826
- * children are not identity columns, so a nested match would be an authoring error, not a PK.
827
- * - The **first** match wins. Identity is single-valued by design — a doctype describes the API
828
- * surface, and mapping a composite database key onto one identity there is the adapter's job —
829
- * so a doctype declaring several is malformed rather than composite. `DoctypeMeta` rejects that
830
- * at the load gate; this stays total for callers holding fields that never went through it.
831
- *
832
- * @param fields - the doctype's top-level fields
974
+ * Two deliberate rules, both matching the shape `primaryKey` actually has:
975
+ * - Fieldset children are **included**, via {@link flattenFields}. A fieldset is layout, not
976
+ * scope: its children are fields of the doctype with columns of their own, which is why the
977
+ * adapter's SELECT already descends and why `getDisplayField` does too. Scanning top level only
978
+ * did not *refuse* a nested declaration — it ignored one, so an author marked identity and
979
+ * nothing honoured it and nothing said so.
980
+ * - The **first** match in document order wins. Identity is single-valued by design — a doctype
981
+ * describes the API surface, and mapping a composite database key onto one identity there is the
982
+ * adapter's job — so a doctype declaring several is malformed rather than composite.
983
+ * `DoctypeMeta` rejects that at the load gate; this stays total for callers holding fields that
984
+ * never went through it.
985
+ *
986
+ * @param fields - the doctype's fields; fieldset children are descended into
833
987
  * @returns the primary-key field, or `undefined` for a PK-less doctype
834
988
  * @public
835
989
  */
@@ -1072,6 +1226,30 @@ export declare interface GraphQLConversionOptions {
1072
1226
  */
1073
1227
  export declare function hasBadgeOptions(options: FieldOptions | undefined): boolean;
1074
1228
 
1229
+ /**
1230
+ * Which of the three field shapes an entry has, read from the entry's own structure.
1231
+ *
1232
+ * The single definition of that question. It had three copies before this — the parser's
1233
+ * `injectKind`, {@link stripFieldKind}'s agreement check, and the docbuilder's own
1234
+ * `isValueField` in another package — each free to drift, and drift here re-types a field rather
1235
+ * than throwing: a value field read as a fieldset loses its column, a fieldset read as a value
1236
+ * field loses every child.
1237
+ *
1238
+ * Deliberately **shape-only**: a declared `kind` is ignored. Two callers depend on that. The
1239
+ * stripper compares this against the declaration to decide whether removing it is lossless, which
1240
+ * it cannot do if this honours it. The docbuilder reads raw JSON off disk and classifies entries to
1241
+ * decide which to render as editable rows — and `kind` is Stonecrop's own discriminant, not
1242
+ * something a doctype author writes, so a tool reading a file has no business consulting it.
1243
+ *
1244
+ * `injectKind` is the one place a declaration still wins, and only to leave an already-parsed
1245
+ * object untouched on its way back through.
1246
+ *
1247
+ * @param field - a field entry, authored or parsed
1248
+ * @returns the kind its shape implies
1249
+ * @public
1250
+ */
1251
+ export declare function inferFieldKind(field: unknown): DoctypeField['kind'];
1252
+
1075
1253
  /**
1076
1254
  * Controls the level of user interaction for a field, container, or table.
1077
1255
  *
@@ -1261,7 +1439,9 @@ export declare function lookupBadge(options: FieldOptions | undefined, key: stri
1261
1439
  * Verify an authored doctype against freshly generated output and stamp provenance.
1262
1440
  *
1263
1441
  * @param authored - the doctype as it exists on disk; every key not named below is preserved verbatim
1264
- * @param generated - `convertGraphQLSchema` output for the corresponding GraphQL type
1442
+ * @param generated - `convertGraphQLSchema` output for the corresponding GraphQL type. For a
1443
+ * `subset` merge this is the **entity**, whose fields are the set the subset is curated from
1444
+ * @param options - see {@link MergeOptions}
1265
1445
  * @returns the doctype to write, plus a drift report
1266
1446
  *
1267
1447
  * @example
@@ -1273,7 +1453,27 @@ export declare function lookupBadge(options: FieldOptions | undefined, key: stri
1273
1453
  *
1274
1454
  * @public
1275
1455
  */
1276
- export declare function mergeIntrospectedDoctype(authored: AuthoredDoctype, generated: ConvertedGraphQLDoctype): MergeResult;
1456
+ export declare function mergeIntrospectedDoctype(authored: AuthoredDoctype, generated: ConvertedGraphQLDoctype, options?: MergeOptions): MergeResult;
1457
+
1458
+ /**
1459
+ * How to verify the authored doctype against the schema.
1460
+ *
1461
+ * @public
1462
+ */
1463
+ export declare interface MergeOptions {
1464
+ /**
1465
+ * The authored doctype is a curated **subset** of the schema's columns rather than a model of
1466
+ * all of them — an aggregate being the case this exists for.
1467
+ *
1468
+ * This changes what counts as drift in both directions, so `generated` must be passed the
1469
+ * *entity's* full field set, not the subset's. A column the author added to an aggregate is
1470
+ * then confirmed against the real table (so a genuinely dropped column still reports as an
1471
+ * orphan), while the columns deliberately left out stop reporting as omissions. Without it an
1472
+ * aggregate reports phantom drift on every run, which both spams `--check` and buries the one
1473
+ * finding that matters.
1474
+ */
1475
+ subset?: boolean;
1476
+ }
1277
1477
 
1278
1478
  /** Outcome of a merge: the doctype to write, plus what generation disagreed with. @public */
1279
1479
  export declare interface MergeResult {
@@ -1331,6 +1531,24 @@ export declare function parseField(data: unknown): DoctypeField;
1331
1531
  */
1332
1532
  export declare function pascalToSnake(pascal: string): string;
1333
1533
 
1534
+ /**
1535
+ * Expand converted entities into the set of doctype files to write.
1536
+ *
1537
+ * Each table yields two: the entity, whose fields carry every column and which backs the record
1538
+ * form, and its aggregate — the collection view. They are written as peers, one file each, with
1539
+ * no key relating them.
1540
+ *
1541
+ * Separate from the CLI because the pairing of a file to its verification basis is the part that
1542
+ * is easy to get wrong and impossible to notice: getting it wrong does not throw, it just reports
1543
+ * drift that is not there, forever.
1544
+ *
1545
+ * @param entities - `convertGraphQLSchema` output
1546
+ * @param options - see {@link GenerationPlanOptions}
1547
+ * @returns one entry per file to write
1548
+ * @public
1549
+ */
1550
+ export declare function planGeneration(entities: readonly ConvertedGraphQLDoctype[], options?: GenerationPlanOptions): GenerationPlanEntry[];
1551
+
1334
1552
  /**
1335
1553
  * Decide how a *declared* link (one with a `LinkDeclaration`) renders.
1336
1554
  *
@@ -1400,6 +1618,28 @@ export declare function snakeToCamel(snakeCase: string): string;
1400
1618
  */
1401
1619
  export declare function snakeToLabel(snakeCase: string): string;
1402
1620
 
1621
+ /**
1622
+ * Remove the `kind` discriminant from a field, recursing into a fieldset's children.
1623
+ *
1624
+ * The outbound half of the boundary {@link normalizeFieldKind} owns inbound. `kind` is a
1625
+ * discriminated-union tag the parser synthesizes, not something an author writes, so nothing that
1626
+ * *writes* a doctype should put it on disk — the generator and the docbuilder's save both call
1627
+ * this. Without it the two round-trip asymmetrically: every save adds a key the file never had.
1628
+ *
1629
+ * Strips only when `injectKind` would restore exactly what was removed. A fieldset carrying no
1630
+ * `schema` re-infers as a plain field, so its `kind` is kept rather than silently re-typing the
1631
+ * document; `DoctypeMeta` requires `schema` on a fieldset, so that shape is already invalid and
1632
+ * belongs to the load gate, not here.
1633
+ *
1634
+ * Table `columns` are {@link ColumnSchema} entries rather than `DoctypeField`s and never carry an
1635
+ * injected `kind`, so they are passed through untouched — the same asymmetry `injectKind` has.
1636
+ *
1637
+ * @param field - a field object, as held in memory after parsing
1638
+ * @returns the field without `kind`, safe to serialize
1639
+ * @public
1640
+ */
1641
+ export declare function stripFieldKind(field: unknown): unknown;
1642
+
1403
1643
  /**
1404
1644
  * Sync fetch strategy - data is fetched in the initial query.
1405
1645
  * @public
package/dist/src/cli.js CHANGED
@@ -16,7 +16,9 @@ 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, formatDoctypeDrift, mergeIntrospectedDoctype } from './converter/index';
19
+ import { authoredPrimaryKey } from './converter/authored';
20
+ import { convertGraphQLSchema, formatDoctypeDrift, mergeIntrospectedDoctype, planGeneration } from './converter/index';
21
+ import { stripFieldKind } from './field';
20
22
  import { validateDoctype } from './validation';
21
23
  /**
22
24
  * Fetch an introspection result from a live GraphQL endpoint.
@@ -61,6 +63,7 @@ async function main() {
61
63
  names: { type: 'string' },
62
64
  'custom-scalars': { type: 'string' },
63
65
  'include-unmapped': { type: 'boolean', default: false },
66
+ 'no-aggregates': { type: 'boolean', default: false },
64
67
  check: { type: 'boolean', default: false },
65
68
  help: { type: 'boolean', short: 'h' },
66
69
  },
@@ -124,11 +127,33 @@ async function main() {
124
127
  source = readFileSync(filePath, 'utf-8');
125
128
  }
126
129
  // Convert
127
- const doctypes = convertGraphQLSchema(source, options);
128
- if (doctypes.length === 0) {
130
+ const entities = convertGraphQLSchema(source, options);
131
+ if (entities.length === 0) {
129
132
  console.warn('No entity types found in the schema. Check your include/exclude filters.');
130
133
  process.exit(0);
131
134
  }
135
+ // Read the identity each authored doctype declares, before planning rather than during the write
136
+ // loop below. SDL cannot express which UNIQUE column is a table's key, so for a natural-key
137
+ // doctype the declaration in the file is the only answer that exists — and planning is what
138
+ // decides whether the aggregate can be built at all. Reading it 20 lines later, as the loop
139
+ // does, made the "declare a primaryKey and re-run" warning a dead end: re-running after
140
+ // declaring one changed nothing.
141
+ const identity = {};
142
+ for (const entity of entities) {
143
+ const entityPath = join(outputDir, `${entity.slug}.json`);
144
+ if (!existsSync(entityPath))
145
+ continue;
146
+ const declared = authoredPrimaryKey(JSON.parse(readFileSync(entityPath, 'utf-8')));
147
+ if (declared !== undefined)
148
+ identity[entity.name] = declared;
149
+ }
150
+ // Each table yields two doctypes — the entity and its aggregate — written as peers, one file
151
+ // each, along with what each is verified against. See `planGeneration`.
152
+ const doctypes = planGeneration(entities, {
153
+ noAggregates: values['no-aggregates'],
154
+ identity,
155
+ onWarning: message => console.warn(` WARN: ${message}`),
156
+ });
132
157
  // Write output
133
158
  if (!existsSync(outputDir)) {
134
159
  mkdirSync(outputDir, { recursive: true });
@@ -137,7 +162,7 @@ async function main() {
137
162
  let errors = 0;
138
163
  let changed = 0;
139
164
  const driftLines = [];
140
- for (const generated of doctypes) {
165
+ for (const { generated, basis, subset } of doctypes) {
141
166
  const fileName = `${generated.slug}.json`;
142
167
  const filePath = join(outputDir, fileName);
143
168
  // When a doctype already exists it is the source of truth: generation confirms it and adds
@@ -148,14 +173,25 @@ async function main() {
148
173
  // so converter output is written verbatim.
149
174
  let output = generated;
150
175
  if (existsSync(filePath)) {
151
- const { doctype: merged, drift } = mergeIntrospectedDoctype(JSON.parse(readFileSync(filePath, 'utf-8')), generated);
176
+ const { doctype: merged, drift } = mergeIntrospectedDoctype(JSON.parse(readFileSync(filePath, 'utf-8')), basis, {
177
+ subset,
178
+ });
152
179
  output = merged;
153
180
  driftLines.push(...formatDoctypeDrift(drift));
154
181
  }
155
182
  // Serialize the merged object directly. Never round-trip it through the Zod parser first:
156
183
  // that runs in strip mode and would silently drop every key this package does not model,
157
184
  // `handler` on an action being the one consumers actually rely on.
158
- const json = JSON.stringify(output, null, '\t') + '\n';
185
+ //
186
+ // `kind` is dropped on the way out because it is the discriminated-union tag the parser
187
+ // synthesizes, not authored data — `injectKind` puts it back on every read. Writing it made
188
+ // the generator and the docbuilder disagree with hand-authored files about what a doctype
189
+ // looks like, and re-added the key on every regeneration.
190
+ const serializable = { ...output };
191
+ if (Array.isArray(serializable.fields)) {
192
+ serializable.fields = serializable.fields.map(stripFieldKind);
193
+ }
194
+ const json = JSON.stringify(serializable, null, '\t') + '\n';
159
195
  const unchanged = existsSync(filePath) && readFileSync(filePath, 'utf-8') === json;
160
196
  if (!unchanged)
161
197
  changed++;
@@ -216,9 +252,20 @@ OPTIONS:
216
252
  --names <file> JSON file mapping GraphQL type name to doctype name
217
253
  --custom-scalars <file> JSON file mapping custom scalar names to field templates
218
254
  --include-unmapped Include _graphqlType metadata on unmapped fields
255
+ --no-aggregates Emit only the entity doctype, not its aggregate
219
256
  --check Report drift and exit non-zero if anything would change; write nothing
220
257
  --help, -h Show this help message
221
258
 
259
+ Each table generates TWO doctypes: the entity (every column, backs the record form) and its
260
+ aggregate (the collection view, identity column only by default), named as simple plurals —
261
+ 'task.json' and 'tasks.json'. Widen an aggregate by adding fields to it — curation survives
262
+ regeneration. A doctype whose name is already plural gets no aggregate, because it would claim
263
+ its own name and its own file; the run says so and still writes the entity.
264
+
265
+ An aggregate needs an identity column. A table keyed on 'id' gives one up; for a natural key,
266
+ declare 'primaryKey' on the field in the entity's own file and re-run — the aggregate is keyed
267
+ on whatever that file declares.
268
+
222
269
  NOTE: an existing doctype file is the source of truth. Regeneration verifies it against the
223
270
  schema and adds 'source: introspected' markers; it reports disagreements rather than
224
271
  overwriting them, so hand-curation survives. Use --check in CI.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Aggregate doctype derivation.
3
+ *
4
+ * A table gets two generated doctypes: the entity itself, whose `fields` carry every column and
5
+ * which backs the record form, and an **aggregate** — the collection view over the same table.
6
+ * The aggregate starts with identity alone, because the useful default for a collection is the
7
+ * one column that lets a row be opened, not all forty. Widening it is curation, and curation
8
+ * survives regeneration (see `mergeIntrospectedDoctype`).
9
+ *
10
+ * The two are peers: each is a complete doctype with its own `name` and `slug`, and nothing here
11
+ * encodes a relationship between them. Deriving the aggregate's name from the entity's is a
12
+ * generated encoding, not a readable one — no consumer recovers the pair by parsing a slug.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ import type { ConvertedGraphQLDoctype } from './types';
17
+ /**
18
+ * The name an entity's aggregate doctype is generated under: the entity's name, pluralised.
19
+ *
20
+ * One definition, because the CLI writes the file under `toSlug` of this and any later caller
21
+ * (a scaffolder, a docs generator) must land on the same name or it silently addresses a
22
+ * different file.
23
+ *
24
+ * `pluralize` rather than appending `s`, because the irregulars are not rare in practice —
25
+ * measured against a consumer's 41 hand-authored aggregate doctypes, this rule reproduces every
26
+ * one of their names, slugs and filenames exactly, while `+ 's'` gets five wrong
27
+ * (`Currencys`, `JournalEntrys`, …).
28
+ *
29
+ * The rule is not total: an already-plural name pluralises to itself. Callers must handle that —
30
+ * see {@link buildAggregateDoctype}.
31
+ *
32
+ * @param doctypeName - the entity doctype's `name`
33
+ * @returns the aggregate doctype's `name`
34
+ * @public
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * aggregateDoctypeName('SalesOrder') // 'SalesOrders' -> slug 'sales-orders'
39
+ * ```
40
+ */
41
+ export declare function aggregateDoctypeName(doctypeName: string): string;
42
+ /**
43
+ * Derive the aggregate doctype for a converted entity.
44
+ *
45
+ * Returns `undefined` when no identity column can be found — a natural-key table whose key the
46
+ * converter refuses to guess and whose author has not declared one, or a foreign PostGraphile
47
+ * endpoint that has left the Relay identifier occupying `id` (Stonecrop's own preset moves it to
48
+ * `nodeId`). That is deliberate: an aggregate with an empty `fields` array is a valid doctype that
49
+ * renders a table with no columns, which looks like a data problem rather than a generation one.
50
+ * Emitting nothing and saying so is the loud failure.
51
+ *
52
+ * Identity resolves the same way `getRecordIdField` resolves it — the declared `primaryKey`, then
53
+ * the conventional `id` — so an aggregate is always keyed on the column the client will later ask
54
+ * for. `declaredIdentity` overrides both: SDL cannot express which `UNIQUE` column is the key, so
55
+ * for a natural-key table the answer only exists in the authored file, and the caller that read it
56
+ * passes the fieldname back.
57
+ *
58
+ * @param doctype - a converted entity doctype, as returned by `convertGraphQLSchema`
59
+ * @param declaredIdentity - fieldname the authored doctype declares as its `primaryKey`, when the
60
+ * caller has read one. Must name a field the converter emitted; the caller checks that, because
61
+ * only it can say whether a missing one is a dropped column or a typo.
62
+ * @returns the aggregate doctype, or `undefined` when no identity column exists
63
+ * @public
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * const [order] = convertGraphQLSchema(sdl, { include: ['Order'] })
68
+ * const aggregate = buildAggregateDoctype(order)
69
+ * // { name: 'Orders', slug: 'orders', fields: [ the id field ] }
70
+ * ```
71
+ */
72
+ export declare function buildAggregateDoctype(doctype: ConvertedGraphQLDoctype, declaredIdentity?: string): ConvertedGraphQLDoctype | undefined;
73
+ /**
74
+ * One file the generator will write, and what that file is verified against.
75
+ *
76
+ * `basis` exists because the two are not always the same document. An aggregate is written from
77
+ * its own one-field generation but verified against the **entity**, since its purpose is to carry
78
+ * fewer columns than the table — checking it against itself reports every curated column as one
79
+ * the table had dropped.
80
+ *
81
+ * @public
82
+ */
83
+ export interface GenerationPlanEntry {
84
+ /** The doctype to write. */
85
+ generated: ConvertedGraphQLDoctype;
86
+ /** The doctype whose fields an existing file on disk is verified against. */
87
+ basis: ConvertedGraphQLDoctype;
88
+ /** Whether the file is a curated subset of `basis` — passed through to `MergeOptions.subset`. */
89
+ subset: boolean;
90
+ }
91
+ /** Options for {@link planGeneration}. @public */
92
+ export interface GenerationPlanOptions {
93
+ /** Emit only the entity doctypes, skipping their aggregates. Defaults to `false`. */
94
+ noAggregates?: boolean;
95
+ /** Called with an advisory message for each entity that yields no aggregate. */
96
+ onWarning?: (message: string) => void;
97
+ /**
98
+ * Identity the authored doctype on disk declares, keyed by doctype `name`.
99
+ *
100
+ * SDL cannot say which `UNIQUE` column is a table's key, so for a natural-key table the converter
101
+ * derives nothing and the answer exists only in the file. Without this the aggregate is
102
+ * unreachable: generation says "declare a primaryKey and re-run", and re-running after declaring
103
+ * one changes nothing, because planning never reads the file.
104
+ *
105
+ * Passed in rather than read here so this stays a pure function of its inputs; the CLI owns the
106
+ * IO. The plan is then a function of the schema *and* what is already on disk.
107
+ */
108
+ identity?: Record<string, string>;
109
+ }
110
+ /**
111
+ * Expand converted entities into the set of doctype files to write.
112
+ *
113
+ * Each table yields two: the entity, whose fields carry every column and which backs the record
114
+ * form, and its aggregate — the collection view. They are written as peers, one file each, with
115
+ * no key relating them.
116
+ *
117
+ * Separate from the CLI because the pairing of a file to its verification basis is the part that
118
+ * is easy to get wrong and impossible to notice: getting it wrong does not throw, it just reports
119
+ * drift that is not there, forever.
120
+ *
121
+ * @param entities - `convertGraphQLSchema` output
122
+ * @param options - see {@link GenerationPlanOptions}
123
+ * @returns one entry per file to write
124
+ * @public
125
+ */
126
+ export declare function planGeneration(entities: readonly ConvertedGraphQLDoctype[], options?: GenerationPlanOptions): GenerationPlanEntry[];
127
+ //# sourceMappingURL=aggregate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregate.d.ts","sourceRoot":"","sources":["../../../src/converter/aggregate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAQH,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAA;AAEtD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,qBAAqB,CACpC,OAAO,EAAE,uBAAuB,EAChC,gBAAgB,CAAC,EAAE,MAAM,GACvB,uBAAuB,GAAG,SAAS,CAyBrC;AAkED;;;;;;;;;GASG;AACH,MAAM,WAAW,mBAAmB;IACnC,4BAA4B;IAC5B,SAAS,EAAE,uBAAuB,CAAA;IAClC,6EAA6E;IAC7E,KAAK,EAAE,uBAAuB,CAAA;IAC9B,iGAAiG;IACjG,MAAM,EAAE,OAAO,CAAA;CACf;AAED,kDAAkD;AAClD,MAAM,WAAW,qBAAqB;IACrC,qFAAqF;IACrF,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,gFAAgF;IAChF,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;IACrC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAC7B,QAAQ,EAAE,SAAS,uBAAuB,EAAE,EAC5C,OAAO,GAAE,qBAA0B,GACjC,mBAAmB,EAAE,CAmEvB"}