@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/README.md +23 -0
- package/dist/cli.js +85 -59
- package/dist/cli.js.map +1 -1
- package/dist/index.js +57 -51
- package/dist/schema.d.ts +253 -13
- package/dist/src/cli.js +53 -6
- package/dist/src/converter/aggregate.d.ts +127 -0
- package/dist/src/converter/aggregate.d.ts.map +1 -0
- package/dist/src/converter/aggregate.js +235 -0
- package/dist/src/converter/authored.d.ts +43 -0
- package/dist/src/converter/authored.d.ts.map +1 -0
- package/dist/src/converter/authored.js +52 -0
- package/dist/src/converter/heuristics.d.ts +2 -2
- package/dist/src/converter/heuristics.d.ts.map +1 -1
- package/dist/src/converter/heuristics.js +43 -4
- package/dist/src/converter/index.d.ts +3 -1
- package/dist/src/converter/index.d.ts.map +1 -1
- package/dist/src/converter/index.js +2 -0
- package/dist/src/converter/merge.d.ts +25 -10
- package/dist/src/converter/merge.d.ts.map +1 -1
- package/dist/src/converter/merge.js +17 -26
- package/dist/src/doctype.d.ts +38 -0
- package/dist/src/doctype.d.ts.map +1 -1
- package/dist/src/doctype.js +55 -1
- package/dist/src/field.d.ts +56 -8
- package/dist/src/field.d.ts.map +1 -1
- package/dist/src/field.js +83 -14
- package/dist/src/index.d.ts +3 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -3
- package/dist/validation-DveCm7gu.js +999 -0
- package/dist/validation-DveCm7gu.js.map +1 -0
- package/package.json +3 -1
- package/dist/validation-CQtfIFHQ.js +0 -583
- package/dist/validation-CQtfIFHQ.js.map +0 -1
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
|
|
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>,
|
|
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
|
|
825
|
-
* -
|
|
826
|
-
* children are
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
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 {
|
|
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
|
|
128
|
-
if (
|
|
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')),
|
|
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
|
-
|
|
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"}
|