@stonecrop/schema 0.31.0 → 0.32.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 +10 -10
- package/dist/cli.js +194 -121
- package/dist/cli.js.map +1 -1
- package/dist/converter-CLwduvT_.js +2102 -0
- package/dist/converter-CLwduvT_.js.map +1 -0
- package/dist/flatten-Bx2cfvw3.js +37 -0
- package/dist/flatten-Bx2cfvw3.js.map +1 -0
- package/dist/index.js +123 -117
- package/dist/index.js.map +1 -1
- package/dist/record-BQOOi83C.js +134 -0
- package/dist/record-BQOOi83C.js.map +1 -0
- package/dist/record.js +2 -6
- package/dist/tsdoc-metadata.json +1 -1
- package/package.json +34 -18
- package/dist/flatten-C1MjkzFh.js +0 -10
- package/dist/flatten-C1MjkzFh.js.map +0 -1
- package/dist/record-Bc0lI9Rq.js +0 -61
- package/dist/record-Bc0lI9Rq.js.map +0 -1
- package/dist/record.js.map +0 -1
- package/dist/schema.tsbuildinfo +0 -1
- package/dist/src/badge.d.ts +0 -74
- package/dist/src/badge.d.ts.map +0 -1
- package/dist/src/badge.js +0 -158
- package/dist/src/cli.d.ts +0 -3
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js +0 -292
- package/dist/src/column-schema.d.ts +0 -163
- package/dist/src/column-schema.d.ts.map +0 -1
- package/dist/src/column-schema.js +0 -0
- package/dist/src/component-meta.d.ts +0 -96
- package/dist/src/component-meta.d.ts.map +0 -1
- package/dist/src/component-meta.js +0 -88
- package/dist/src/converter/aggregate.d.ts +0 -127
- package/dist/src/converter/aggregate.d.ts.map +0 -1
- package/dist/src/converter/aggregate.js +0 -235
- package/dist/src/converter/authored.d.ts +0 -43
- package/dist/src/converter/authored.d.ts.map +0 -1
- package/dist/src/converter/authored.js +0 -52
- package/dist/src/converter/heuristics.d.ts +0 -60
- package/dist/src/converter/heuristics.d.ts.map +0 -1
- package/dist/src/converter/heuristics.js +0 -304
- package/dist/src/converter/index.d.ts +0 -51
- package/dist/src/converter/index.d.ts.map +0 -1
- package/dist/src/converter/index.js +0 -195
- package/dist/src/converter/merge.d.ts +0 -102
- package/dist/src/converter/merge.d.ts.map +0 -1
- package/dist/src/converter/merge.js +0 -136
- package/dist/src/converter/scalars.d.ts +0 -46
- package/dist/src/converter/scalars.d.ts.map +0 -1
- package/dist/src/converter/scalars.js +0 -83
- package/dist/src/converter/types.d.ts +0 -157
- package/dist/src/converter/types.d.ts.map +0 -1
- package/dist/src/converter/types.js +0 -5
- package/dist/src/doctype.d.ts +0 -516
- package/dist/src/doctype.d.ts.map +0 -1
- package/dist/src/doctype.js +0 -343
- package/dist/src/field.d.ts +0 -423
- package/dist/src/field.d.ts.map +0 -1
- package/dist/src/field.js +0 -378
- package/dist/src/flatten.d.ts +0 -29
- package/dist/src/flatten.d.ts.map +0 -1
- package/dist/src/flatten.js +0 -38
- package/dist/src/index.d.ts +0 -16
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js +0 -20
- package/dist/src/mode.d.ts +0 -15
- package/dist/src/mode.d.ts.map +0 -1
- package/dist/src/mode.js +0 -0
- package/dist/src/naming.d.ts +0 -80
- package/dist/src/naming.d.ts.map +0 -1
- package/dist/src/naming.js +0 -106
- package/dist/src/record.d.ts +0 -29
- package/dist/src/record.d.ts.map +0 -1
- package/dist/src/record.js +0 -55
- package/dist/src/table.d.ts +0 -33
- package/dist/src/table.d.ts.map +0 -1
- package/dist/src/table.js +0 -25
- package/dist/src/validation.d.ts +0 -54
- package/dist/src/validation.d.ts.map +0 -1
- package/dist/src/validation.js +0 -60
- package/dist/validation-C9P__pRF.js +0 -994
- package/dist/validation-C9P__pRF.js.map +0 -1
|
@@ -0,0 +1,2102 @@
|
|
|
1
|
+
import { t as flattenFields } from "./flatten-Bx2cfvw3.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { buildClientSchema, buildSchema, isEnumType, isListType, isNamedType, isNonNullType, isObjectType, isScalarType } from "graphql";
|
|
4
|
+
//#region \0rolldown/runtime.js
|
|
5
|
+
var __create = Object.create;
|
|
6
|
+
var __defProp = Object.defineProperty;
|
|
7
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
8
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
9
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
10
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
11
|
+
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
14
|
+
key = keys[i];
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
16
|
+
get: ((k) => from[k]).bind(null, key),
|
|
17
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return to;
|
|
21
|
+
};
|
|
22
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
23
|
+
value: mod,
|
|
24
|
+
enumerable: true
|
|
25
|
+
}) : target, mod));
|
|
26
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) {
|
|
27
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
28
|
+
throw Error("Calling `require` for \"" + x + "\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.");
|
|
29
|
+
});
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/table.ts
|
|
32
|
+
/**
|
|
33
|
+
* JSON-safe view configuration for table fields in doctype authoring.
|
|
34
|
+
*
|
|
35
|
+
* This is the authoring-time subset of `@stonecrop/atable`'s `TableConfig`. It covers
|
|
36
|
+
* the view discriminator and structural options that can be expressed in static JSON.
|
|
37
|
+
* `rowActions` (which requires function-typed handlers) stays in the runtime `TableConfig`.
|
|
38
|
+
*
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
var TableViewConfig = z.object({
|
|
42
|
+
/** The table view type */
|
|
43
|
+
view: z.enum([
|
|
44
|
+
"list",
|
|
45
|
+
"uncounted",
|
|
46
|
+
"list-expansion",
|
|
47
|
+
"tree",
|
|
48
|
+
"gantt",
|
|
49
|
+
"tree-gantt"
|
|
50
|
+
]).optional(),
|
|
51
|
+
/** Allow the table to use the full width of its container */
|
|
52
|
+
fullWidth: z.boolean().optional(),
|
|
53
|
+
/** Default expansion state for tree views */
|
|
54
|
+
defaultTreeExpansion: z.enum([
|
|
55
|
+
"root",
|
|
56
|
+
"branch",
|
|
57
|
+
"leaf"
|
|
58
|
+
]).optional(),
|
|
59
|
+
/** Enable dependency graph connections for Gantt views */
|
|
60
|
+
dependencyGraph: z.boolean().optional()
|
|
61
|
+
}).meta({
|
|
62
|
+
title: "TableViewConfig",
|
|
63
|
+
description: "JSON-safe view configuration for table fields in doctype authoring"
|
|
64
|
+
});
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/field.ts
|
|
67
|
+
/**
|
|
68
|
+
* Field options - flexible bag for type-specific configuration.
|
|
69
|
+
*
|
|
70
|
+
* Usage:
|
|
71
|
+
* - Select: array of choices (["Draft", "Submitted", "Cancelled"])
|
|
72
|
+
* - Select with badges: \{ choices: [...], badges: \{ Open: "warning", ... \} \} or bare map
|
|
73
|
+
* - Decimal: config object (\{ precision: 10, scale: 2 \})
|
|
74
|
+
* - Code: config object (\{ language: "python" \})
|
|
75
|
+
*
|
|
76
|
+
* Deliberately *not* a bare string: a string once meant "link target", which made the value's
|
|
77
|
+
* shape encode its meaning. That job belongs to `ValueField.doctype`, leaving this a plain
|
|
78
|
+
* choices-or-config bag.
|
|
79
|
+
*
|
|
80
|
+
* @public
|
|
81
|
+
*/
|
|
82
|
+
var FieldOptions = z.union([z.array(z.string()), z.record(z.string(), z.unknown())]).meta({
|
|
83
|
+
title: "FieldOptions",
|
|
84
|
+
description: "Field options - flexible bag for type-specific configuration"
|
|
85
|
+
});
|
|
86
|
+
/**
|
|
87
|
+
* Validation configuration for form fields
|
|
88
|
+
* @public
|
|
89
|
+
*/
|
|
90
|
+
var FieldValidation = z.looseObject({
|
|
91
|
+
/** Error message to display when validation fails */
|
|
92
|
+
errorMessage: z.string() }).meta({
|
|
93
|
+
title: "FieldValidation",
|
|
94
|
+
description: "Validation configuration for form fields"
|
|
95
|
+
});
|
|
96
|
+
/**
|
|
97
|
+
* Which of the three field shapes an entry has, read from the entry's own structure.
|
|
98
|
+
*
|
|
99
|
+
* The single definition of that question. It had three copies before this — the parser's
|
|
100
|
+
* `injectKind`, {@link stripFieldKind}'s agreement check, and the docbuilder's own
|
|
101
|
+
* `isValueField` in another package — each free to drift, and drift here re-types a field rather
|
|
102
|
+
* than throwing: a value field read as a fieldset loses its column, a fieldset read as a value
|
|
103
|
+
* field loses every child.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately **shape-only**: a declared `kind` is ignored. Two callers depend on that. The
|
|
106
|
+
* stripper compares this against the declaration to decide whether removing it is lossless, which
|
|
107
|
+
* it cannot do if this honours it. The docbuilder reads raw JSON off disk and classifies entries to
|
|
108
|
+
* decide which to render as editable rows — and `kind` is Stonecrop's own discriminant, not
|
|
109
|
+
* something a doctype author writes, so a tool reading a file has no business consulting it.
|
|
110
|
+
*
|
|
111
|
+
* `injectKind` is the one place a declaration still wins, and only to leave an already-parsed
|
|
112
|
+
* object untouched on its way back through.
|
|
113
|
+
*
|
|
114
|
+
* @param field - a field entry, authored or parsed
|
|
115
|
+
* @returns the kind its shape implies
|
|
116
|
+
* @public
|
|
117
|
+
*/
|
|
118
|
+
function inferFieldKind(field) {
|
|
119
|
+
if (typeof field !== "object" || field === null || Array.isArray(field)) return "field";
|
|
120
|
+
if ("schema" in field) return "fieldset";
|
|
121
|
+
if ("columns" in field) return "table";
|
|
122
|
+
return "field";
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Infers the `kind` discriminant from the structural properties of a raw field
|
|
126
|
+
* object, then injects it if absent. This allows authored JSON to omit `kind`
|
|
127
|
+
* entirely — a `schema` key means fieldset, `columns` means table, anything else
|
|
128
|
+
* is a value field.
|
|
129
|
+
*
|
|
130
|
+
* Rules (applied in order):
|
|
131
|
+
* has `schema` → fieldset
|
|
132
|
+
* has `columns` → table
|
|
133
|
+
* otherwise → field (value-holding scalar or link)
|
|
134
|
+
*
|
|
135
|
+
* Objects that already carry `kind` pass through unchanged (backward-compatible).
|
|
136
|
+
*
|
|
137
|
+
* Single-node only. Zod applies this at every level of the discriminated union (via the
|
|
138
|
+
* `z.lazy` in the fieldset schema), so nested fieldset children are normalized during a
|
|
139
|
+
* parse. Callers that bypass Zod — notably `Doctype.fromObject` — must use the exported
|
|
140
|
+
* {@link normalizeFieldKind} instead, which replicates that recursion.
|
|
141
|
+
*/
|
|
142
|
+
function injectKind(data) {
|
|
143
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return data;
|
|
144
|
+
const obj = data;
|
|
145
|
+
if ("kind" in obj) return data;
|
|
146
|
+
return {
|
|
147
|
+
kind: inferFieldKind(obj),
|
|
148
|
+
...obj
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Recursively injects the `kind` discriminant into a raw field object and, for fieldsets,
|
|
153
|
+
* into each of its nested `schema` children — mirroring exactly what Zod's `preprocess`
|
|
154
|
+
* does at every level of the discriminated union.
|
|
155
|
+
*
|
|
156
|
+
* Table `columns` are {@link ColumnSchema} entries, not `DoctypeField`s, so they are left
|
|
157
|
+
* untouched — the Zod table schema validates them with a plain passthrough and never injects
|
|
158
|
+
* `kind` there either.
|
|
159
|
+
*
|
|
160
|
+
* Needed because `Doctype.fromObject` constructs a Doctype without running Zod, yet the
|
|
161
|
+
* registry's `resolveFields` gates link and fieldset handling on `field.kind`. Without this,
|
|
162
|
+
* a JSON-authored link resolves to a flat scalar and a fieldset's children are dropped.
|
|
163
|
+
*
|
|
164
|
+
* @public
|
|
165
|
+
*/
|
|
166
|
+
function normalizeFieldKind(field) {
|
|
167
|
+
const injected = injectKind(field);
|
|
168
|
+
if (typeof injected !== "object" || injected === null) return injected;
|
|
169
|
+
const obj = injected;
|
|
170
|
+
if (obj.kind === "fieldset" && Array.isArray(obj.schema)) return {
|
|
171
|
+
...obj,
|
|
172
|
+
schema: obj.schema.map(normalizeFieldKind)
|
|
173
|
+
};
|
|
174
|
+
return injected;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Remove the `kind` discriminant from a field, recursing into a fieldset's children.
|
|
178
|
+
*
|
|
179
|
+
* The outbound half of the boundary {@link normalizeFieldKind} owns inbound. `kind` is a
|
|
180
|
+
* discriminated-union tag the parser synthesizes, not something an author writes, so nothing that
|
|
181
|
+
* *writes* a doctype should put it on disk — the generator and the docbuilder's save both call
|
|
182
|
+
* this. Without it the two round-trip asymmetrically: every save adds a key the file never had.
|
|
183
|
+
*
|
|
184
|
+
* Strips only when `injectKind` would restore exactly what was removed. A fieldset carrying no
|
|
185
|
+
* `schema` re-infers as a plain field, so its `kind` is kept rather than silently re-typing the
|
|
186
|
+
* document; `DoctypeMeta` requires `schema` on a fieldset, so that shape is already invalid and
|
|
187
|
+
* belongs to the load gate, not here.
|
|
188
|
+
*
|
|
189
|
+
* Table `columns` are {@link ColumnSchema} entries rather than `DoctypeField`s and never carry an
|
|
190
|
+
* injected `kind`, so they are passed through untouched — the same asymmetry `injectKind` has.
|
|
191
|
+
*
|
|
192
|
+
* @param field - a field object, as held in memory after parsing
|
|
193
|
+
* @returns the field without `kind`, safe to serialize
|
|
194
|
+
* @public
|
|
195
|
+
*/
|
|
196
|
+
function stripFieldKind(field) {
|
|
197
|
+
if (typeof field !== "object" || field === null || Array.isArray(field)) return field;
|
|
198
|
+
const obj = field;
|
|
199
|
+
if (obj.kind !== void 0 && obj.kind !== inferFieldKind(obj)) return field;
|
|
200
|
+
const { kind: _kind, ...rest } = obj;
|
|
201
|
+
if (Array.isArray(rest.schema)) return {
|
|
202
|
+
...rest,
|
|
203
|
+
schema: rest.schema.map(stripFieldKind)
|
|
204
|
+
};
|
|
205
|
+
return rest;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The field properties a `source: 'introspected'` marker freezes — the ones the database owns.
|
|
209
|
+
*
|
|
210
|
+
* This is the single definition of the identity set. The docbuilder greys these inputs on an
|
|
211
|
+
* introspected field, and the converter's merge refuses to rewrite them. Stating it twice is how
|
|
212
|
+
* the two drift, so both read this constant.
|
|
213
|
+
*
|
|
214
|
+
* Everything absent from this list is author-owned, `component` most importantly: it chooses the
|
|
215
|
+
* widget, which is an authoring decision the database has no opinion about.
|
|
216
|
+
*
|
|
217
|
+
* @public
|
|
218
|
+
*/
|
|
219
|
+
var INTROSPECTED_IDENTITY_PROPS = [
|
|
220
|
+
"fieldname",
|
|
221
|
+
"primaryKey",
|
|
222
|
+
"required",
|
|
223
|
+
"options",
|
|
224
|
+
"cardinality",
|
|
225
|
+
"doctype"
|
|
226
|
+
];
|
|
227
|
+
/**
|
|
228
|
+
* Find the field a doctype marks as its primary key, or `undefined` when none is marked.
|
|
229
|
+
*
|
|
230
|
+
* This is the single definition of "which field identifies a record". Both sides depend on it:
|
|
231
|
+
* the middleware builds the SQL identity predicate from it, and the client resolves a record's
|
|
232
|
+
* route/store key from it. Call this; never re-derive the rule at the call site, or the two will
|
|
233
|
+
* drift and the client will key records by a column the server never queried.
|
|
234
|
+
*
|
|
235
|
+
* Two deliberate rules, both matching the shape `primaryKey` actually has:
|
|
236
|
+
* - Fieldset children are **included**, via {@link flattenFields}. A fieldset is layout, not
|
|
237
|
+
* scope: its children are fields of the doctype with columns of their own, which is why the
|
|
238
|
+
* adapter's SELECT already descends and why `getDisplayField` does too. Scanning top level only
|
|
239
|
+
* did not *refuse* a nested declaration — it ignored one, so an author marked identity and
|
|
240
|
+
* nothing honoured it and nothing said so.
|
|
241
|
+
* - The **first** match in document order wins. Identity is single-valued by design — a doctype
|
|
242
|
+
* describes the API surface, and mapping a composite database key onto one identity there is the
|
|
243
|
+
* adapter's job — so a doctype declaring several is malformed rather than composite.
|
|
244
|
+
* `DoctypeMeta` rejects that at the load gate; this stays total for callers holding fields that
|
|
245
|
+
* never went through it.
|
|
246
|
+
*
|
|
247
|
+
* @param fields - the doctype's fields; fieldset children are descended into
|
|
248
|
+
* @returns the primary-key field, or `undefined` for a PK-less doctype
|
|
249
|
+
* @public
|
|
250
|
+
*/
|
|
251
|
+
function getPrimaryKeyField(fields) {
|
|
252
|
+
return flattenFields(fields).find((f) => f.kind === "field" && Boolean(f.primaryKey));
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Resolve the field a doctype nominates as its display text, or `undefined` when the nomination
|
|
256
|
+
* does not name a readable column.
|
|
257
|
+
*
|
|
258
|
+
* This is the single definition of "is this a usable `displayField`". Both sides depend on it:
|
|
259
|
+
* `DoctypeMeta` refuses a bad nomination at the load gate, and the adapter builds a SELECT from
|
|
260
|
+
* the field it returns. Call this; never re-derive the rule, or the gate and the query will
|
|
261
|
+
* disagree about which nominations are legal — which they did, in both directions at once.
|
|
262
|
+
*
|
|
263
|
+
* Two things disqualify a nomination, and both are the doctype saying so itself:
|
|
264
|
+
* - it names no field at all, fieldset children included
|
|
265
|
+
* - it names a `computed` field, which is declared precisely to state it has no column, so a
|
|
266
|
+
* SELECT built from it would reference a column the database does not have
|
|
267
|
+
*
|
|
268
|
+
* @param fields - the doctype's top-level fields
|
|
269
|
+
* @param displayField - the nominated fieldname
|
|
270
|
+
* @returns the nominated field, or `undefined` when it is not a readable column
|
|
271
|
+
* @public
|
|
272
|
+
*/
|
|
273
|
+
function getDisplayField(fields, displayField) {
|
|
274
|
+
if (!displayField) return void 0;
|
|
275
|
+
return flattenFields(fields).find((f) => f.kind === "field" && !f.computed && f.fieldname === displayField);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The name of the field a record is identified by: the declared `primaryKey`, or `id` when the
|
|
279
|
+
* doctype declares none.
|
|
280
|
+
*
|
|
281
|
+
* The `id` fallback is load-bearing, not defensive — a surrogate-key doctype carries an `id`
|
|
282
|
+
* column and marks no primary key, so "nothing declared" means `id`, not "no identity".
|
|
283
|
+
*
|
|
284
|
+
* This exists because that one-line rule had been restated at four sites — the client's
|
|
285
|
+
* `Doctype.recordIdField`, both nuxt hosts' `recordLookupField`, and the Postgres adapter — and
|
|
286
|
+
* the fourth had omitted the fallback, so a doctype the client keyed by `id` was one the adapter
|
|
287
|
+
* could not look up at all. Call this; a fifth restatement is how they diverge again.
|
|
288
|
+
*
|
|
289
|
+
* The returned name is not guaranteed to be a declared field: a doctype that declares no
|
|
290
|
+
* `primaryKey` and no `id` yields `'id'` regardless. An adapter that must build a SQL predicate
|
|
291
|
+
* from it has to confirm the field exists and say so when it does not, because selecting a column
|
|
292
|
+
* the doctype never declared returns nothing rather than failing.
|
|
293
|
+
*
|
|
294
|
+
* @param fields - the doctype's top-level fields
|
|
295
|
+
* @returns the identifying fieldname
|
|
296
|
+
* @public
|
|
297
|
+
*/
|
|
298
|
+
function getRecordIdField(fields) {
|
|
299
|
+
return getPrimaryKeyField(fields)?.fieldname ?? "id";
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Resolve a record's identity value using the doctype's declared primary key.
|
|
303
|
+
*
|
|
304
|
+
* Falls back to `record.id` when the doctype declares no `primaryKey`. That fallback is
|
|
305
|
+
* load-bearing, not defensive: surrogate-key doctypes carry an `id` column and never mark a
|
|
306
|
+
* primary key, and PostGraphile renames a single-column `id` PK to `rowId` — so the declared
|
|
307
|
+
* field and `id` are both real sources, in that order.
|
|
308
|
+
*
|
|
309
|
+
* @param fields - the doctype's top-level fields
|
|
310
|
+
* @param record - the record to read the identity from
|
|
311
|
+
* @returns the identity as a string, or `undefined` when neither source yields a usable value
|
|
312
|
+
* @public
|
|
313
|
+
*/
|
|
314
|
+
function getRecordIdentity(fields, record) {
|
|
315
|
+
const pkField = getPrimaryKeyField(fields);
|
|
316
|
+
const candidates = pkField ? [record[pkField.fieldname], record.id] : [record.id];
|
|
317
|
+
for (const value of candidates) {
|
|
318
|
+
if (typeof value === "number") return String(value);
|
|
319
|
+
if (typeof value === "string" && value !== "") return value;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function createDoctypeFieldSchemas() {
|
|
323
|
+
const ValueFieldSchema = z.object({
|
|
324
|
+
kind: z.literal("field"),
|
|
325
|
+
fieldname: z.string().min(1),
|
|
326
|
+
component: z.string().min(1),
|
|
327
|
+
primaryKey: z.boolean().optional(),
|
|
328
|
+
computed: z.boolean().optional(),
|
|
329
|
+
language: z.string().optional(),
|
|
330
|
+
doctype: z.string().min(1).optional(),
|
|
331
|
+
label: z.string().optional(),
|
|
332
|
+
width: z.string().optional(),
|
|
333
|
+
height: z.string().optional(),
|
|
334
|
+
align: z.enum([
|
|
335
|
+
"left",
|
|
336
|
+
"center",
|
|
337
|
+
"right",
|
|
338
|
+
"start",
|
|
339
|
+
"end"
|
|
340
|
+
]).optional(),
|
|
341
|
+
edit: z.boolean().optional(),
|
|
342
|
+
mask: z.string().optional(),
|
|
343
|
+
format: z.string().optional(),
|
|
344
|
+
mode: z.enum([
|
|
345
|
+
"edit",
|
|
346
|
+
"read",
|
|
347
|
+
"display"
|
|
348
|
+
]).optional(),
|
|
349
|
+
options: FieldOptions.optional(),
|
|
350
|
+
required: z.boolean().optional(),
|
|
351
|
+
readOnly: z.boolean().optional(),
|
|
352
|
+
hidden: z.boolean().optional(),
|
|
353
|
+
default: z.unknown().optional(),
|
|
354
|
+
validation: FieldValidation.optional(),
|
|
355
|
+
cardinality: z.enum([
|
|
356
|
+
"atMostOne",
|
|
357
|
+
"one",
|
|
358
|
+
"noneOrMany",
|
|
359
|
+
"atLeastOne"
|
|
360
|
+
]).optional(),
|
|
361
|
+
source: z.literal("introspected").optional()
|
|
362
|
+
}).meta({ title: "ValueField" });
|
|
363
|
+
const TableFieldSchema = z.object({
|
|
364
|
+
kind: z.literal("table"),
|
|
365
|
+
fieldname: z.string().min(1),
|
|
366
|
+
component: z.string().optional(),
|
|
367
|
+
label: z.string().optional(),
|
|
368
|
+
columns: z.array(z.object({ fieldname: z.string().min(1) }).passthrough()),
|
|
369
|
+
config: TableViewConfig.optional(),
|
|
370
|
+
mode: z.enum([
|
|
371
|
+
"edit",
|
|
372
|
+
"read",
|
|
373
|
+
"display"
|
|
374
|
+
]).optional()
|
|
375
|
+
}).meta({ title: "TableField" });
|
|
376
|
+
let DoctypeFieldSchema = z.never();
|
|
377
|
+
const FieldsetFieldSchema = z.object({
|
|
378
|
+
kind: z.literal("fieldset"),
|
|
379
|
+
fieldname: z.string().min(1),
|
|
380
|
+
component: z.string().optional(),
|
|
381
|
+
label: z.string().optional(),
|
|
382
|
+
collapsible: z.boolean().optional(),
|
|
383
|
+
mode: z.enum([
|
|
384
|
+
"edit",
|
|
385
|
+
"read",
|
|
386
|
+
"display"
|
|
387
|
+
]).optional(),
|
|
388
|
+
schema: z.lazy(() => DoctypeFieldSchema.array())
|
|
389
|
+
}).meta({ title: "FieldsetField" });
|
|
390
|
+
const rawUnion = z.discriminatedUnion("kind", [
|
|
391
|
+
ValueFieldSchema,
|
|
392
|
+
FieldsetFieldSchema,
|
|
393
|
+
TableFieldSchema
|
|
394
|
+
]);
|
|
395
|
+
DoctypeFieldSchema = z.preprocess(injectKind, rawUnion);
|
|
396
|
+
return {
|
|
397
|
+
ValueFieldSchema,
|
|
398
|
+
TableFieldSchema,
|
|
399
|
+
FieldsetFieldSchema,
|
|
400
|
+
DoctypeFieldSchema
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
var schemas = createDoctypeFieldSchemas();
|
|
404
|
+
/**
|
|
405
|
+
* Zod runtime validation schema for ValueField.
|
|
406
|
+
* @public
|
|
407
|
+
*/
|
|
408
|
+
var ValueFieldSchema = schemas.ValueFieldSchema;
|
|
409
|
+
/**
|
|
410
|
+
* Zod runtime validation schema for FieldsetField.
|
|
411
|
+
* Recursive — FieldsetField.schema is validated against DoctypeFieldSchema.
|
|
412
|
+
* @public
|
|
413
|
+
*/
|
|
414
|
+
var FieldsetFieldSchema = schemas.FieldsetFieldSchema;
|
|
415
|
+
/**
|
|
416
|
+
* Zod runtime validation schema for TableField.
|
|
417
|
+
* @public
|
|
418
|
+
*/
|
|
419
|
+
var TableFieldSchema = schemas.TableFieldSchema;
|
|
420
|
+
/**
|
|
421
|
+
* Zod runtime validation schema for the DoctypeField discriminated union.
|
|
422
|
+
* Validates all three field variants: `'field'`, `'fieldset'`, `'table'`.
|
|
423
|
+
* @public
|
|
424
|
+
*/
|
|
425
|
+
var DoctypeFieldSchema = schemas.DoctypeFieldSchema;
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region src/naming.ts
|
|
428
|
+
/**
|
|
429
|
+
* Naming Convention Utilities
|
|
430
|
+
* Converts between various naming conventions (snake_case, camelCase, PascalCase, kebab-case)
|
|
431
|
+
* @packageDocumentation
|
|
432
|
+
*/
|
|
433
|
+
/**
|
|
434
|
+
* Converts snake_case to camelCase
|
|
435
|
+
* @param snakeCase - Snake case string
|
|
436
|
+
* @returns Camel case string
|
|
437
|
+
* @public
|
|
438
|
+
* @example
|
|
439
|
+
* ```typescript
|
|
440
|
+
* snakeToCamel('user_email') // 'userEmail'
|
|
441
|
+
* snakeToCamel('created_at') // 'createdAt'
|
|
442
|
+
* ```
|
|
443
|
+
*/
|
|
444
|
+
function snakeToCamel(snakeCase) {
|
|
445
|
+
return snakeCase.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Converts camelCase to snake_case
|
|
449
|
+
* @param camelCase - Camel case string
|
|
450
|
+
* @returns Snake case string
|
|
451
|
+
* @public
|
|
452
|
+
* @example
|
|
453
|
+
* ```typescript
|
|
454
|
+
* camelToSnake('userEmail') // 'user_email'
|
|
455
|
+
* camelToSnake('createdAt') // 'created_at'
|
|
456
|
+
* ```
|
|
457
|
+
*/
|
|
458
|
+
function camelToSnake(camelCase) {
|
|
459
|
+
return camelCase.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Converts snake_case to Title Case label
|
|
463
|
+
* @param snakeCase - Snake case string
|
|
464
|
+
* @returns Title case label
|
|
465
|
+
* @public
|
|
466
|
+
* @example
|
|
467
|
+
* ```typescript
|
|
468
|
+
* snakeToLabel('user_email') // 'User Email'
|
|
469
|
+
* snakeToLabel('first_name') // 'First Name'
|
|
470
|
+
* ```
|
|
471
|
+
*/
|
|
472
|
+
function snakeToLabel(snakeCase) {
|
|
473
|
+
return snakeCase.split("_").map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(" ");
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Converts camelCase to Title Case label
|
|
477
|
+
* @param camelCase - Camel case string
|
|
478
|
+
* @returns Title case label
|
|
479
|
+
* @public
|
|
480
|
+
* @example
|
|
481
|
+
* ```typescript
|
|
482
|
+
* camelToLabel('userEmail') // 'User Email'
|
|
483
|
+
* camelToLabel('firstName') // 'First Name'
|
|
484
|
+
* ```
|
|
485
|
+
*/
|
|
486
|
+
function camelToLabel(camelCase) {
|
|
487
|
+
const withSpaces = camelCase.replace(/([A-Z])/g, " $1").trim();
|
|
488
|
+
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Convert table name to PascalCase doctype name
|
|
492
|
+
* @param tableName - SQL table name (snake_case)
|
|
493
|
+
* @returns PascalCase name
|
|
494
|
+
* @public
|
|
495
|
+
*/
|
|
496
|
+
function toPascalCase(tableName) {
|
|
497
|
+
return tableName.split(/[-_\s]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("");
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Convert to kebab-case slug
|
|
501
|
+
* @param name - Name to convert
|
|
502
|
+
* @returns kebab-case slug
|
|
503
|
+
* @public
|
|
504
|
+
*/
|
|
505
|
+
function toSlug(name) {
|
|
506
|
+
return name.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Convert PascalCase to snake_case (e.g., for deriving table names from type names)
|
|
510
|
+
* @param pascal - PascalCase string
|
|
511
|
+
* @returns snake_case string
|
|
512
|
+
* @public
|
|
513
|
+
* @example
|
|
514
|
+
* ```typescript
|
|
515
|
+
* pascalToSnake('SalesOrder') // 'sales_order'
|
|
516
|
+
* pascalToSnake('SalesOrderItem') // 'sales_order_item'
|
|
517
|
+
* ```
|
|
518
|
+
*/
|
|
519
|
+
function pascalToSnake(pascal) {
|
|
520
|
+
return pascal.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase();
|
|
521
|
+
}
|
|
522
|
+
//#endregion
|
|
523
|
+
//#region src/doctype.ts
|
|
524
|
+
/**
|
|
525
|
+
* Cardinality for relationship links.
|
|
526
|
+
* @public
|
|
527
|
+
*/
|
|
528
|
+
var Cardinality = z.enum([
|
|
529
|
+
"atMostOne",
|
|
530
|
+
"one",
|
|
531
|
+
"noneOrMany",
|
|
532
|
+
"atLeastOne"
|
|
533
|
+
]).meta({
|
|
534
|
+
title: "Cardinality",
|
|
535
|
+
description: "Cardinality for relationship links between doctypes"
|
|
536
|
+
});
|
|
537
|
+
/**
|
|
538
|
+
* Sync fetch strategy - data is fetched in the initial query.
|
|
539
|
+
* @public
|
|
540
|
+
*/
|
|
541
|
+
var SyncFetch = z.object({
|
|
542
|
+
/** Fetch method type */
|
|
543
|
+
method: z.literal("sync"),
|
|
544
|
+
/** Optional limit on number of records to fetch */
|
|
545
|
+
limit: z.number().int().positive().optional()
|
|
546
|
+
}).meta({
|
|
547
|
+
title: "SyncFetch",
|
|
548
|
+
description: "Sync fetch strategy - data is fetched in the initial query"
|
|
549
|
+
});
|
|
550
|
+
/**
|
|
551
|
+
* Lazy fetch strategy - data is fetched on demand in a separate query.
|
|
552
|
+
* @public
|
|
553
|
+
*/
|
|
554
|
+
var LazyFetch = z.object({
|
|
555
|
+
/** Fetch method type */
|
|
556
|
+
method: z.literal("lazy") }).meta({
|
|
557
|
+
title: "LazyFetch",
|
|
558
|
+
description: "Lazy fetch strategy - data is fetched on demand in a separate query"
|
|
559
|
+
});
|
|
560
|
+
/**
|
|
561
|
+
* Custom fetch strategy - uses a custom handler function.
|
|
562
|
+
* @public
|
|
563
|
+
*/
|
|
564
|
+
var CustomFetch = z.object({
|
|
565
|
+
/** Fetch method type */
|
|
566
|
+
method: z.literal("custom"),
|
|
567
|
+
/** Serialized handler function to invoke */
|
|
568
|
+
handler: z.string()
|
|
569
|
+
}).meta({
|
|
570
|
+
title: "CustomFetch",
|
|
571
|
+
description: "Custom fetch strategy - uses a custom handler function"
|
|
572
|
+
});
|
|
573
|
+
/**
|
|
574
|
+
* Fetch strategy for link data loading.
|
|
575
|
+
* - sync: fetched in the initial query
|
|
576
|
+
* - lazy: fetched on demand in a separate query
|
|
577
|
+
* - custom: uses a custom handler function
|
|
578
|
+
* @public
|
|
579
|
+
*/
|
|
580
|
+
var FetchStrategy = z.discriminatedUnion("method", [
|
|
581
|
+
SyncFetch,
|
|
582
|
+
LazyFetch,
|
|
583
|
+
CustomFetch
|
|
584
|
+
]).meta({
|
|
585
|
+
title: "FetchStrategy",
|
|
586
|
+
description: "Fetch strategy for link data loading"
|
|
587
|
+
});
|
|
588
|
+
/**
|
|
589
|
+
* Link declaration - describes a relationship from one doctype to another.
|
|
590
|
+
* @public
|
|
591
|
+
*/
|
|
592
|
+
var LinkDeclaration = z.object({
|
|
593
|
+
/** Target doctype slug */
|
|
594
|
+
target: z.string().min(1),
|
|
595
|
+
/** Cardinality of the relationship */
|
|
596
|
+
cardinality: Cardinality,
|
|
597
|
+
/** Backlink fieldname on the target doctype that points back to this link */
|
|
598
|
+
backlink: z.string().optional(),
|
|
599
|
+
/** Override default rendering component (AForm for 1:1, ATable for 1:many) */
|
|
600
|
+
component: z.string().optional(),
|
|
601
|
+
/** Fieldname of the corresponding Link field in the fields array */
|
|
602
|
+
fieldname: z.string().min(1).optional(),
|
|
603
|
+
/** Fetch strategy for loading nested data */
|
|
604
|
+
fetch: FetchStrategy.optional(),
|
|
605
|
+
/** Whether to block workflow actions until nested data is loaded (default: true) */
|
|
606
|
+
blockWorkflows: z.boolean().optional()
|
|
607
|
+
}).meta({
|
|
608
|
+
title: "LinkDeclaration",
|
|
609
|
+
description: "Declares a relationship from one doctype to another"
|
|
610
|
+
});
|
|
611
|
+
/**
|
|
612
|
+
* Action definition within a workflow
|
|
613
|
+
* @public
|
|
614
|
+
*/
|
|
615
|
+
var ActionDefinition = z.object({
|
|
616
|
+
/** Display label for the action */
|
|
617
|
+
label: z.string().min(1),
|
|
618
|
+
/** Fields that must have values before action can execute */
|
|
619
|
+
requiredFields: z.array(z.string()).optional(),
|
|
620
|
+
/** Workflow states where this action is available */
|
|
621
|
+
allowedStates: z.array(z.string()).optional(),
|
|
622
|
+
/** The state the record transitions to after this action executes */
|
|
623
|
+
nextState: z.string().optional(),
|
|
624
|
+
/** True for stateless command actions with no workflow effect at all (print, email, etc.) */
|
|
625
|
+
stateless: z.boolean().optional(),
|
|
626
|
+
/**
|
|
627
|
+
* True for an internal self-transition: the action runs within the current state without
|
|
628
|
+
* advancing the workflow (e.g. `save`, which mutates record data but stays put). Scoped by
|
|
629
|
+
* `allowedStates`, rendered as a self-loop in the graph, and has no `nextState`. Distinct from
|
|
630
|
+
* `stateless` (which has no workflow presence at all): a self-transition is graph-owned and,
|
|
631
|
+
* unlike a stateless command, persists record data on dispatch.
|
|
632
|
+
*/
|
|
633
|
+
selfTransition: z.boolean().optional(),
|
|
634
|
+
/** JS function body stored as a string; executed client-side via AsyncFunction with injected API surface */
|
|
635
|
+
clientHandler: z.string().optional()
|
|
636
|
+
}).meta({
|
|
637
|
+
title: "ActionDefinition",
|
|
638
|
+
description: "Action definition within a workflow"
|
|
639
|
+
});
|
|
640
|
+
/**
|
|
641
|
+
* Reactive field-validation trigger — advisory, client-side only.
|
|
642
|
+
*
|
|
643
|
+
* A Trigger is a docbuilder-authored validator: when any field in `on` is edited, its
|
|
644
|
+
* `clientHandler` runs (client-side, no rollback) and may flag a field inline to block save
|
|
645
|
+
* in the UI. It is deliberately a **sibling** to {@link (ActionDefinition:type)}, not a member of it —
|
|
646
|
+
* a reactive validator is not a user-invoked action, so it lives in the `triggers` map on
|
|
647
|
+
* {@link (WorkflowMeta:type)} and never appears to action readers (transition/command dropdowns, the FSM graph).
|
|
648
|
+
*
|
|
649
|
+
* The two bindings are independent: `on` is the fire-set (which fields' edits run it), while the
|
|
650
|
+
* `setError(field, msg)` call inside `clientHandler` chooses which field displays the error.
|
|
651
|
+
* @public
|
|
652
|
+
*/
|
|
653
|
+
var TriggerDefinition = z.object({
|
|
654
|
+
/** Optional display label; the map key is the trigger's identity */
|
|
655
|
+
label: z.string().optional(),
|
|
656
|
+
/** Fieldnames whose edits fire this trigger (fires when any listed field changes) */
|
|
657
|
+
on: z.array(z.string()),
|
|
658
|
+
/** JS function body stored as a string; run client-side with `{ record, value, setError }`. Advisory. */
|
|
659
|
+
clientHandler: z.string()
|
|
660
|
+
}).meta({
|
|
661
|
+
title: "TriggerDefinition",
|
|
662
|
+
description: "Reactive field-validation trigger — advisory client-side"
|
|
663
|
+
});
|
|
664
|
+
/**
|
|
665
|
+
* Whether a workflow action may run from `currentState`.
|
|
666
|
+
*
|
|
667
|
+
* Single source of truth for the "is this action available here" rule, shared by
|
|
668
|
+
* the frontend (`getAvailableTransitions`) and the server-side dispatch guard so
|
|
669
|
+
* the two can never disagree. Empty or absent `allowedStates` means the action is
|
|
670
|
+
* available in ALL states — a plain `allowedStates.includes(currentState)` would
|
|
671
|
+
* wrongly block such actions everywhere.
|
|
672
|
+
*
|
|
673
|
+
* @public
|
|
674
|
+
*/
|
|
675
|
+
function isActionAllowedInState(action, currentState) {
|
|
676
|
+
const allowedStates = action.allowedStates;
|
|
677
|
+
if (!allowedStates || allowedStates.length === 0) return true;
|
|
678
|
+
return allowedStates.includes(currentState);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* DocBuilder graph layout — node positions for the workflow-state graph, keyed by state name.
|
|
682
|
+
* Pure authoring view-state: persisted in the doctype JSON so an author's manual arrangement
|
|
683
|
+
* survives reloads, but — exactly like {@link (WorkflowMeta:type)}'s `triggers` — it is client-only
|
|
684
|
+
* and never mirrored into the runtime GraphQL SDL (see the WorkflowMeta type in the host SDLs, which
|
|
685
|
+
* expose only `states`/`actions`). The shape mirrors VueFlow's node fields; `position` is the node's
|
|
686
|
+
* canvas coordinate and `targetPosition`/`sourcePosition` are the handle sides.
|
|
687
|
+
* @public
|
|
688
|
+
*/
|
|
689
|
+
var WorkflowLayout = z.record(z.string(), z.object({
|
|
690
|
+
position: z.object({
|
|
691
|
+
x: z.number(),
|
|
692
|
+
y: z.number()
|
|
693
|
+
}).optional(),
|
|
694
|
+
targetPosition: z.enum([
|
|
695
|
+
"left",
|
|
696
|
+
"top",
|
|
697
|
+
"right",
|
|
698
|
+
"bottom"
|
|
699
|
+
]).optional(),
|
|
700
|
+
sourcePosition: z.enum([
|
|
701
|
+
"left",
|
|
702
|
+
"top",
|
|
703
|
+
"right",
|
|
704
|
+
"bottom"
|
|
705
|
+
]).optional()
|
|
706
|
+
}));
|
|
707
|
+
/**
|
|
708
|
+
* Workflow metadata - states and actions for a doctype
|
|
709
|
+
* @public
|
|
710
|
+
*/
|
|
711
|
+
var WorkflowMeta = z.object({
|
|
712
|
+
/** List of workflow states */
|
|
713
|
+
states: z.array(z.string()).optional(),
|
|
714
|
+
/** Actions available in this workflow */
|
|
715
|
+
actions: z.record(z.string(), ActionDefinition).optional(),
|
|
716
|
+
/** Reactive field-validation triggers (advisory, client-side), keyed by trigger name */
|
|
717
|
+
triggers: z.record(z.string(), TriggerDefinition).optional(),
|
|
718
|
+
/**
|
|
719
|
+
* DocBuilder node positions keyed by state name — authoring view-state. Persisted here so a
|
|
720
|
+
* doctype author's manual graph arrangement survives reloads; like `triggers`, it is client-only
|
|
721
|
+
* and never enters the runtime GraphQL SDL. See {@link (WorkflowLayout:variable)}.
|
|
722
|
+
*/
|
|
723
|
+
layout: WorkflowLayout.optional()
|
|
724
|
+
}).meta({
|
|
725
|
+
title: "WorkflowMeta",
|
|
726
|
+
description: "Workflow metadata - states and actions for a doctype"
|
|
727
|
+
});
|
|
728
|
+
/**
|
|
729
|
+
* Doctype metadata - complete definition of a doctype
|
|
730
|
+
* @public
|
|
731
|
+
*/
|
|
732
|
+
var DoctypeMeta = z.object({
|
|
733
|
+
/** Display name of the doctype */
|
|
734
|
+
name: z.string().min(1),
|
|
735
|
+
/** URL-friendly slug (kebab-case) */
|
|
736
|
+
slug: z.string().min(1).optional(),
|
|
737
|
+
/**
|
|
738
|
+
* Field on this doctype used when displaying a reference to one of its records.
|
|
739
|
+
* When a record elsewhere holds an inline foreign key to this doctype, the middleware
|
|
740
|
+
* returns that field as `{ id, displayText }`, reading `displayText` from this field.
|
|
741
|
+
*/
|
|
742
|
+
displayField: z.string().min(1).optional(),
|
|
743
|
+
/**
|
|
744
|
+
* URL path this doctype registers at, written literally — `/order` for a collection,
|
|
745
|
+
* `/order/:id` for a record. Absent means the doctype has no page of its own, which is the
|
|
746
|
+
* common case: a child table is reached inside its parent, never at a URL.
|
|
747
|
+
*
|
|
748
|
+
* A path rather than a segment because the record parameter has to be somewhere, and a host
|
|
749
|
+
* that reads a bare segment has to know which kind of doctype it is holding to decide where
|
|
750
|
+
* to put it. Writing it out means nothing downstream re-derives it.
|
|
751
|
+
*/
|
|
752
|
+
route: z.string().startsWith("/").optional(),
|
|
753
|
+
/** Field definitions (a link field is one carrying `doctype`) */
|
|
754
|
+
fields: z.array(DoctypeFieldSchema),
|
|
755
|
+
/** Relationship links to other doctypes */
|
|
756
|
+
links: z.record(z.string(), LinkDeclaration).optional(),
|
|
757
|
+
/** Workflow configuration */
|
|
758
|
+
workflow: WorkflowMeta.optional(),
|
|
759
|
+
/** Parent doctype for inheritance */
|
|
760
|
+
inherits: z.string().optional()
|
|
761
|
+
}).meta({
|
|
762
|
+
title: "DoctypeMeta",
|
|
763
|
+
description: "Doctype metadata - complete definition of a doctype"
|
|
764
|
+
}).superRefine((doctype, ctx) => {
|
|
765
|
+
const declared = flattenFields(doctype.fields).filter((f) => f.kind === "field" && f.primaryKey);
|
|
766
|
+
if (declared.length > 1) ctx.addIssue({
|
|
767
|
+
code: "custom",
|
|
768
|
+
path: ["fields"],
|
|
769
|
+
message: `Doctype declares ${declared.length} primaryKey fields (${declared.map((f) => f.kind === "field" ? f.fieldname : "").join(", ")}); a record is identified by exactly one field. A composite database key is mapped to a single identity by the adapter, so a doctype never declares its parts`
|
|
770
|
+
});
|
|
771
|
+
if (doctype.displayField && !getDisplayField(doctype.fields, doctype.displayField)) {
|
|
772
|
+
const named = flattenFields(doctype.fields).find((f) => f.fieldname === doctype.displayField);
|
|
773
|
+
ctx.addIssue({
|
|
774
|
+
code: "custom",
|
|
775
|
+
path: ["displayField"],
|
|
776
|
+
message: named ? `displayField "${doctype.displayField}" names a computed field, which has no column to read a display value from` : `displayField "${doctype.displayField}" is not declared on this doctype`
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
/**
|
|
781
|
+
* The one string a doctype is addressed by.
|
|
782
|
+
*
|
|
783
|
+
* A doctype carries two names — `name` (`OrderItem`) and `slug` (`order-item`) — and every registry
|
|
784
|
+
* must agree on which one keys it. Three implementations had drifted apart: the adapter's registry is
|
|
785
|
+
* keyed by `name` and its `getMeta` also scans for a matching `slug`, so it accepts **either**; the
|
|
786
|
+
* client's registry is keyed by a slug it derives itself and accepts **only** that; and
|
|
787
|
+
* `Doctype.fromObject` dropped an authored `slug` on the floor and re-derived one regardless. The
|
|
788
|
+
* adapter's accepted set was therefore a strict superset of the client's, and a link target written
|
|
789
|
+
* as the Name booted the server, passed its reference check, served rows over GraphQL, and was
|
|
790
|
+
* silently dropped by the client — an expanding child table rendering as one empty text input, with
|
|
791
|
+
* nothing logged.
|
|
792
|
+
*
|
|
793
|
+
* Resolving through this in both runtimes is what makes the two answers the same answer. It is the
|
|
794
|
+
* derivation only; a *lookup* still belongs to whichever registry owns the corpus, because the two
|
|
795
|
+
* corpora legitimately differ (a client registers lazily, and a client-only host has no adapter at
|
|
796
|
+
* all).
|
|
797
|
+
*
|
|
798
|
+
* An authored `slug` wins over the derived one because the authored doctype is the source of truth:
|
|
799
|
+
* generation verifies a file and never overwrites it, so a doctype that states its own slug means it.
|
|
800
|
+
* Deriving unconditionally is what `fromObject` did, and it made an authored `slug` a silent no-op on
|
|
801
|
+
* one side of the wire while the other honoured it.
|
|
802
|
+
*
|
|
803
|
+
* @param doctype - anything carrying a doctype's `name` and optional authored `slug`
|
|
804
|
+
* @returns the canonical slug
|
|
805
|
+
* @public
|
|
806
|
+
*
|
|
807
|
+
* @example
|
|
808
|
+
* ```typescript
|
|
809
|
+
* getDoctypeSlug({ name: 'OrderItem' }) // 'order-item'
|
|
810
|
+
* getDoctypeSlug({ name: 'Planner', slug: 'planner-board' }) // 'planner-board'
|
|
811
|
+
* ```
|
|
812
|
+
*/
|
|
813
|
+
function getDoctypeSlug(doctype) {
|
|
814
|
+
return doctype.slug || toSlug(doctype.name);
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Suffix appended to a link fieldname for its pre-resolved display text in record payloads.
|
|
818
|
+
*
|
|
819
|
+
* @deprecated The `__display` suffix pattern is no longer used. Inline link fields are enriched
|
|
820
|
+
* server-side by `@stonecrop/graphql-middleware` as `{ id, displayText }` objects on the link
|
|
821
|
+
* field itself.
|
|
822
|
+
* @public
|
|
823
|
+
*/
|
|
824
|
+
var LINK_DISPLAY_SUFFIX = "__display";
|
|
825
|
+
/**
|
|
826
|
+
* Build the payload key for a link field's display text (e.g. `customerId__display`).
|
|
827
|
+
*
|
|
828
|
+
* @deprecated The `__display` suffix pattern is no longer used. Inline link fields are enriched
|
|
829
|
+
* server-side by `@stonecrop/graphql-middleware` as `{ id, displayText }` objects on the link
|
|
830
|
+
* field itself.
|
|
831
|
+
* @public
|
|
832
|
+
*/
|
|
833
|
+
function linkDisplayFieldname(fieldname) {
|
|
834
|
+
return `${fieldname}${LINK_DISPLAY_SUFFIX}`;
|
|
835
|
+
}
|
|
836
|
+
//#endregion
|
|
837
|
+
//#region src/validation.ts
|
|
838
|
+
/**
|
|
839
|
+
* Validate a field definition against the DoctypeField discriminated union
|
|
840
|
+
* @param data - Data to validate
|
|
841
|
+
* @returns Validation result
|
|
842
|
+
* @public
|
|
843
|
+
*/
|
|
844
|
+
function validateField(data) {
|
|
845
|
+
const result = DoctypeFieldSchema.safeParse(data);
|
|
846
|
+
if (result.success) return {
|
|
847
|
+
success: true,
|
|
848
|
+
errors: []
|
|
849
|
+
};
|
|
850
|
+
return {
|
|
851
|
+
success: false,
|
|
852
|
+
errors: result.error.issues.map((issue) => ({
|
|
853
|
+
path: issue.path,
|
|
854
|
+
message: issue.message
|
|
855
|
+
}))
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Validate a doctype definition
|
|
860
|
+
* @param data - Data to validate
|
|
861
|
+
* @returns Validation result
|
|
862
|
+
* @public
|
|
863
|
+
*/
|
|
864
|
+
function validateDoctype(data) {
|
|
865
|
+
const result = DoctypeMeta.safeParse(data);
|
|
866
|
+
if (result.success) return {
|
|
867
|
+
success: true,
|
|
868
|
+
errors: []
|
|
869
|
+
};
|
|
870
|
+
return {
|
|
871
|
+
success: false,
|
|
872
|
+
errors: result.error.issues.map((issue) => ({
|
|
873
|
+
path: issue.path,
|
|
874
|
+
message: issue.message
|
|
875
|
+
}))
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Parse and validate a field, throwing on failure
|
|
880
|
+
* @param data - Data to parse
|
|
881
|
+
* @returns Validated DoctypeField
|
|
882
|
+
* @throws ZodError if validation fails
|
|
883
|
+
* @public
|
|
884
|
+
*/
|
|
885
|
+
function parseField(data) {
|
|
886
|
+
return DoctypeFieldSchema.parse(data);
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Parse and validate a doctype, throwing on failure
|
|
890
|
+
* @param data - Data to parse
|
|
891
|
+
* @returns Validated DoctypeMeta
|
|
892
|
+
* @throws ZodError if validation fails
|
|
893
|
+
* @public
|
|
894
|
+
*/
|
|
895
|
+
function parseDoctype(data) {
|
|
896
|
+
return DoctypeMeta.parse(data);
|
|
897
|
+
}
|
|
898
|
+
//#endregion
|
|
899
|
+
//#region src/converter/scalars.ts
|
|
900
|
+
/**
|
|
901
|
+
* Mapping from standard GraphQL scalar types to Stonecrop field types.
|
|
902
|
+
* These are defined by the GraphQL specification and are always available.
|
|
903
|
+
*
|
|
904
|
+
* @public
|
|
905
|
+
*/
|
|
906
|
+
var GQL_SCALAR_MAP = {
|
|
907
|
+
String: { component: "ATextInput" },
|
|
908
|
+
Int: { component: "ANumericInput" },
|
|
909
|
+
Float: { component: "ANumericInput" },
|
|
910
|
+
Boolean: { component: "ACheckbox" },
|
|
911
|
+
ID: { component: "ATextInput" }
|
|
912
|
+
};
|
|
913
|
+
/**
|
|
914
|
+
* Mapping from well-known custom GraphQL scalars to Stonecrop field types.
|
|
915
|
+
* These cover scalars commonly used across GraphQL servers (PostGraphile, Hasura, etc.)
|
|
916
|
+
* without baking in knowledge of any specific server.
|
|
917
|
+
*
|
|
918
|
+
* Entries here have lower precedence than `customScalars` from options, but higher
|
|
919
|
+
* precedence than unknown/unmapped scalars.
|
|
920
|
+
*
|
|
921
|
+
* @public
|
|
922
|
+
*/
|
|
923
|
+
var WELL_KNOWN_SCALARS = {
|
|
924
|
+
BigFloat: { component: "ANumericInput" },
|
|
925
|
+
BigDecimal: { component: "ANumericInput" },
|
|
926
|
+
Decimal: { component: "ANumericInput" },
|
|
927
|
+
BigInt: { component: "ANumericInput" },
|
|
928
|
+
Long: { component: "ANumericInput" },
|
|
929
|
+
UUID: { component: "ATextInput" },
|
|
930
|
+
DateTime: { component: "ADateTime" },
|
|
931
|
+
Datetime: { component: "ADateTime" },
|
|
932
|
+
Date: { component: "ADate" },
|
|
933
|
+
Time: { component: "ATextInput" },
|
|
934
|
+
Interval: { component: "ADuration" },
|
|
935
|
+
Duration: { component: "ADuration" },
|
|
936
|
+
JSON: { component: "ACodeEditor" },
|
|
937
|
+
JSONObject: { component: "ACodeEditor" },
|
|
938
|
+
JsonNode: { component: "ACodeEditor" }
|
|
939
|
+
};
|
|
940
|
+
/**
|
|
941
|
+
* Set of scalar type names that are internal to GraphQL servers and should be skipped
|
|
942
|
+
* during field conversion (they don't represent meaningful data fields).
|
|
943
|
+
*
|
|
944
|
+
* @public
|
|
945
|
+
*/
|
|
946
|
+
var INTERNAL_SCALARS = /* @__PURE__ */ new Set(["Cursor"]);
|
|
947
|
+
/**
|
|
948
|
+
* Build a merged scalar map from the built-in maps and user-provided custom scalars.
|
|
949
|
+
* Precedence (highest to lowest): customScalars → GQL_SCALAR_MAP → WELL_KNOWN_SCALARS
|
|
950
|
+
*
|
|
951
|
+
* @param customScalars - User-provided scalar overrides
|
|
952
|
+
* @returns Merged scalar map
|
|
953
|
+
* @public
|
|
954
|
+
*/
|
|
955
|
+
function buildScalarMap(customScalars) {
|
|
956
|
+
const merged = { ...WELL_KNOWN_SCALARS };
|
|
957
|
+
for (const [key, value] of Object.entries(GQL_SCALAR_MAP)) merged[key] = value;
|
|
958
|
+
if (customScalars) for (const [key, value] of Object.entries(customScalars)) merged[key] = { component: value.component ?? "ATextInput" };
|
|
959
|
+
return merged;
|
|
960
|
+
}
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region src/converter/heuristics.ts
|
|
963
|
+
/**
|
|
964
|
+
* Default heuristics for identifying entity types and fields in a GraphQL schema.
|
|
965
|
+
*
|
|
966
|
+
* These heuristics work across common GraphQL servers (PostGraphile, Hasura, Apollo, etc.)
|
|
967
|
+
* by detecting widely-adopted conventions like the Relay connection pattern.
|
|
968
|
+
*
|
|
969
|
+
* All heuristics can be overridden via the `isEntityType`, `isEntityField`, and
|
|
970
|
+
* `classifyField` options in `GraphQLConversionOptions`.
|
|
971
|
+
*
|
|
972
|
+
* @packageDocumentation
|
|
973
|
+
*/
|
|
974
|
+
/**
|
|
975
|
+
* Suffixes that identify synthetic/framework types generated by GraphQL servers.
|
|
976
|
+
* Types ending with these suffixes are typically not entities.
|
|
977
|
+
*/
|
|
978
|
+
var SYNTHETIC_SUFFIXES = [
|
|
979
|
+
"Connection",
|
|
980
|
+
"Edge",
|
|
981
|
+
"Input",
|
|
982
|
+
"Patch",
|
|
983
|
+
"Payload",
|
|
984
|
+
"Condition",
|
|
985
|
+
"Filter",
|
|
986
|
+
"OrderBy",
|
|
987
|
+
"Aggregate",
|
|
988
|
+
"AggregateResult",
|
|
989
|
+
"AggregateFilter",
|
|
990
|
+
"DeleteResponse",
|
|
991
|
+
"InsertResponse",
|
|
992
|
+
"UpdateResponse",
|
|
993
|
+
"MutationResponse"
|
|
994
|
+
];
|
|
995
|
+
/**
|
|
996
|
+
* Root operation type names that are never entities.
|
|
997
|
+
*/
|
|
998
|
+
var ROOT_TYPE_NAMES = /* @__PURE__ */ new Set([
|
|
999
|
+
"Query",
|
|
1000
|
+
"Mutation",
|
|
1001
|
+
"Subscription"
|
|
1002
|
+
]);
|
|
1003
|
+
/**
|
|
1004
|
+
* Default heuristic to determine if a GraphQL object type represents an entity.
|
|
1005
|
+
* An entity type becomes a Stonecrop doctype.
|
|
1006
|
+
*
|
|
1007
|
+
* This heuristic excludes:
|
|
1008
|
+
* - Introspection types (`__*`)
|
|
1009
|
+
* - Root operation types (`Query`, `Mutation`, `Subscription`)
|
|
1010
|
+
* - Types with synthetic suffixes (e.g., `*Connection`, `*Edge`, `*Input`)
|
|
1011
|
+
* - Types starting with `Node` interface marker (exact match only)
|
|
1012
|
+
*
|
|
1013
|
+
* @param typeName - The GraphQL type name
|
|
1014
|
+
* @param type - The GraphQL object type definition
|
|
1015
|
+
* @returns `true` if this type should become a Stonecrop doctype
|
|
1016
|
+
* @public
|
|
1017
|
+
*/
|
|
1018
|
+
function defaultIsEntityType(typeName, type) {
|
|
1019
|
+
if (typeName.startsWith("__")) return false;
|
|
1020
|
+
if (ROOT_TYPE_NAMES.has(typeName)) return false;
|
|
1021
|
+
if (typeName === "Node") return false;
|
|
1022
|
+
for (const suffix of SYNTHETIC_SUFFIXES) if (typeName.endsWith(suffix)) return false;
|
|
1023
|
+
const fields = type.getFields();
|
|
1024
|
+
if (Object.keys(fields).length === 0) return false;
|
|
1025
|
+
return true;
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Fields to skip by default on entity types.
|
|
1029
|
+
* These are internal to GraphQL servers and don't represent semantic data.
|
|
1030
|
+
*
|
|
1031
|
+
* Relay's global object identifier is deliberately absent: which field carries it is a
|
|
1032
|
+
* declaration, not a name. See {@link relayNodeIdField}.
|
|
1033
|
+
*/
|
|
1034
|
+
var SKIP_FIELDS = /* @__PURE__ */ new Set(["__typename", "clientMutationId"]);
|
|
1035
|
+
/**
|
|
1036
|
+
* The name Relay's Object Identification spec gives its marker interface.
|
|
1037
|
+
*/
|
|
1038
|
+
var RELAY_NODE_INTERFACE = "Node";
|
|
1039
|
+
/**
|
|
1040
|
+
* The field carrying Relay's global object identifier on this type, or `undefined` for a type that
|
|
1041
|
+
* declares none.
|
|
1042
|
+
*
|
|
1043
|
+
* Read off the interface rather than matched against a list of names, because the name is a server
|
|
1044
|
+
* setting: PostGraphile exposes it as `nodeIdFieldName`, which is `id` under the un-overridden
|
|
1045
|
+
* Amber preset, `nodeId` under Stonecrop's, and whatever a foreign host chose under theirs. A
|
|
1046
|
+
* hardcoded name is a snapshot of one of those, and gets it wrong in both directions at once — it
|
|
1047
|
+
* emits an opaque identifier as a column (whose every read then fails on a column that does not
|
|
1048
|
+
* exist), and drops a real column that happens to share the name.
|
|
1049
|
+
*
|
|
1050
|
+
* The interface must be Relay's marker and not a domain interface that shares its name, so it has
|
|
1051
|
+
* to declare exactly one field, a non-null `ID`, and nothing else — anything carrying domain fields
|
|
1052
|
+
* is a different interface, and skipping against it would drop real columns.
|
|
1053
|
+
*
|
|
1054
|
+
* @internal
|
|
1055
|
+
*/
|
|
1056
|
+
function relayNodeIdField(type) {
|
|
1057
|
+
for (const iface of type.getInterfaces()) {
|
|
1058
|
+
if (iface.name !== RELAY_NODE_INTERFACE) continue;
|
|
1059
|
+
const declared = Object.values(iface.getFields());
|
|
1060
|
+
if (declared.length !== 1) continue;
|
|
1061
|
+
const { namedType, required, isList } = unwrapType(declared[0].type);
|
|
1062
|
+
if (required && !isList && namedType.name === "ID") return declared[0].name;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Default heuristic to filter fields on entity types.
|
|
1067
|
+
* Skips internal fields that don't represent meaningful data.
|
|
1068
|
+
*
|
|
1069
|
+
* @param fieldName - The GraphQL field name
|
|
1070
|
+
* @param _field - The GraphQL field definition (unused in default implementation)
|
|
1071
|
+
* @param parentType - The parent entity type, whose interfaces declare its Relay identifier
|
|
1072
|
+
* @returns `true` if this field should be included
|
|
1073
|
+
* @public
|
|
1074
|
+
*/
|
|
1075
|
+
function defaultIsEntityField(fieldName, _field, parentType) {
|
|
1076
|
+
if (SKIP_FIELDS.has(fieldName)) return false;
|
|
1077
|
+
return fieldName !== relayNodeIdField(parentType);
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Unwrap NonNull and List wrappers from a GraphQL type, tracking nullability.
|
|
1081
|
+
*
|
|
1082
|
+
* @param type - The GraphQL output type
|
|
1083
|
+
* @returns The unwrapped named type, whether it's required, and whether it's a list
|
|
1084
|
+
* @internal
|
|
1085
|
+
*/
|
|
1086
|
+
function unwrapType(type) {
|
|
1087
|
+
let required = false;
|
|
1088
|
+
let isList = false;
|
|
1089
|
+
let current = type;
|
|
1090
|
+
if (isNonNullType(current)) {
|
|
1091
|
+
required = true;
|
|
1092
|
+
current = current.ofType;
|
|
1093
|
+
}
|
|
1094
|
+
if (isListType(current)) {
|
|
1095
|
+
isList = true;
|
|
1096
|
+
current = current.ofType;
|
|
1097
|
+
if (isNonNullType(current)) current = current.ofType;
|
|
1098
|
+
}
|
|
1099
|
+
if (!isNamedType(current)) throw new Error(`Expected a named GraphQL type, got: ${String(current)}`);
|
|
1100
|
+
return {
|
|
1101
|
+
namedType: current,
|
|
1102
|
+
required,
|
|
1103
|
+
isList
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Check if a GraphQL object type looks like a Relay Connection type.
|
|
1108
|
+
* A connection type has an `edges` field returning a list of edge types,
|
|
1109
|
+
* where each edge has a `node` field.
|
|
1110
|
+
*
|
|
1111
|
+
* @param type - The GraphQL object type to check
|
|
1112
|
+
* @returns The node type name if this is a connection, or `undefined`
|
|
1113
|
+
* @internal
|
|
1114
|
+
*/
|
|
1115
|
+
function getConnectionNodeType(type) {
|
|
1116
|
+
const edgesField = type.getFields()["edges"];
|
|
1117
|
+
if (!edgesField) return void 0;
|
|
1118
|
+
const { namedType: edgesType, isList: edgesIsList } = unwrapType(edgesField.type);
|
|
1119
|
+
if (!edgesIsList || !isObjectType(edgesType)) return void 0;
|
|
1120
|
+
const nodeField = edgesType.getFields()["node"];
|
|
1121
|
+
if (!nodeField) return void 0;
|
|
1122
|
+
const { namedType: nodeType } = unwrapType(nodeField.type);
|
|
1123
|
+
if (!isObjectType(nodeType)) return void 0;
|
|
1124
|
+
return nodeType.name;
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Classify a single GraphQL field into a Stonecrop field definition.
|
|
1128
|
+
*
|
|
1129
|
+
* Classification rules (in order):
|
|
1130
|
+
* 1. Scalar types → look up in merged scalar map
|
|
1131
|
+
* 2. Enum types → `Select` with enum values as options
|
|
1132
|
+
* 3. Object types that are entities → `Link` with slug as options
|
|
1133
|
+
* 4. Object types that are Connections → `Doctype` with node type slug as options
|
|
1134
|
+
* 5. List of entity type → `Doctype` with item type slug as options
|
|
1135
|
+
* 6. Anything else → `Data` with `_unmapped: true`
|
|
1136
|
+
*
|
|
1137
|
+
* @param fieldName - The GraphQL field name
|
|
1138
|
+
* @param field - The GraphQL field definition
|
|
1139
|
+
* @param entityTypes - Set of type names classified as entities
|
|
1140
|
+
* @param options - Conversion options (for custom scalars, unmapped meta, etc.)
|
|
1141
|
+
* @returns The Stonecrop field definition
|
|
1142
|
+
* @public
|
|
1143
|
+
*/
|
|
1144
|
+
function classifyFieldType(fieldName, field, entityTypes, options = {}) {
|
|
1145
|
+
const { namedType, required, isList } = unwrapType(field.type);
|
|
1146
|
+
const scalarMap = buildScalarMap(options.customScalars);
|
|
1147
|
+
const base = {
|
|
1148
|
+
kind: "field",
|
|
1149
|
+
fieldname: fieldName,
|
|
1150
|
+
label: camelToLabel(fieldName),
|
|
1151
|
+
component: "ATextInput"
|
|
1152
|
+
};
|
|
1153
|
+
if (required) base.required = true;
|
|
1154
|
+
if (isScalarType(namedType)) {
|
|
1155
|
+
if (INTERNAL_SCALARS.has(namedType.name)) {
|
|
1156
|
+
base._unmapped = true;
|
|
1157
|
+
if (options.includeUnmappedMeta) base._graphqlType = namedType.name;
|
|
1158
|
+
return base;
|
|
1159
|
+
}
|
|
1160
|
+
if (namedType.name === "ID") {
|
|
1161
|
+
const candidateTypeName = toPascalCase(fieldName);
|
|
1162
|
+
if (entityTypes.has(candidateTypeName)) {
|
|
1163
|
+
base.component = "AFormLink";
|
|
1164
|
+
base.doctype = toSlug(candidateTypeName);
|
|
1165
|
+
return base;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
const template = scalarMap[namedType.name];
|
|
1169
|
+
if (template) base.component = template.component;
|
|
1170
|
+
else {
|
|
1171
|
+
base._unmapped = true;
|
|
1172
|
+
if (options.includeUnmappedMeta) base._graphqlType = namedType.name;
|
|
1173
|
+
}
|
|
1174
|
+
return base;
|
|
1175
|
+
}
|
|
1176
|
+
if (isEnumType(namedType)) {
|
|
1177
|
+
base.component = "ADropdown";
|
|
1178
|
+
base.options = namedType.getValues().map((v) => v.name);
|
|
1179
|
+
return base;
|
|
1180
|
+
}
|
|
1181
|
+
if (isObjectType(namedType)) {
|
|
1182
|
+
if (!isList && entityTypes.has(namedType.name)) {
|
|
1183
|
+
base.component = "AFormLink";
|
|
1184
|
+
base.doctype = toSlug(namedType.name);
|
|
1185
|
+
return base;
|
|
1186
|
+
}
|
|
1187
|
+
const connectionNodeTypeName = getConnectionNodeType(namedType);
|
|
1188
|
+
if (connectionNodeTypeName && entityTypes.has(connectionNodeTypeName)) {
|
|
1189
|
+
base.component = "ATable";
|
|
1190
|
+
base._isLink = true;
|
|
1191
|
+
base.doctype = toSlug(connectionNodeTypeName);
|
|
1192
|
+
base.cardinality = "noneOrMany";
|
|
1193
|
+
return base;
|
|
1194
|
+
}
|
|
1195
|
+
if (isList && entityTypes.has(namedType.name)) {
|
|
1196
|
+
base.component = "ATable";
|
|
1197
|
+
base._isLink = true;
|
|
1198
|
+
base.doctype = toSlug(namedType.name);
|
|
1199
|
+
base.cardinality = "noneOrMany";
|
|
1200
|
+
return base;
|
|
1201
|
+
}
|
|
1202
|
+
base._unmapped = true;
|
|
1203
|
+
if (options.includeUnmappedMeta) base._graphqlType = namedType.name;
|
|
1204
|
+
return base;
|
|
1205
|
+
}
|
|
1206
|
+
base._unmapped = true;
|
|
1207
|
+
if (options.includeUnmappedMeta) base._graphqlType = namedType.name;
|
|
1208
|
+
return base;
|
|
1209
|
+
}
|
|
1210
|
+
//#endregion
|
|
1211
|
+
//#region src/converter/aggregate.ts
|
|
1212
|
+
var import_pluralize = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
1213
|
+
(function(root, pluralize) {
|
|
1214
|
+
/* istanbul ignore else */
|
|
1215
|
+
if (typeof __require === "function" && typeof exports === "object" && typeof module === "object") module.exports = pluralize();
|
|
1216
|
+
else if (typeof define === "function" && define.amd) define(function() {
|
|
1217
|
+
return pluralize();
|
|
1218
|
+
});
|
|
1219
|
+
else root.pluralize = pluralize();
|
|
1220
|
+
})(exports, function() {
|
|
1221
|
+
var pluralRules = [];
|
|
1222
|
+
var singularRules = [];
|
|
1223
|
+
var uncountables = {};
|
|
1224
|
+
var irregularPlurals = {};
|
|
1225
|
+
var irregularSingles = {};
|
|
1226
|
+
/**
|
|
1227
|
+
* Sanitize a pluralization rule to a usable regular expression.
|
|
1228
|
+
*
|
|
1229
|
+
* @param {(RegExp|string)} rule
|
|
1230
|
+
* @return {RegExp}
|
|
1231
|
+
*/
|
|
1232
|
+
function sanitizeRule(rule) {
|
|
1233
|
+
if (typeof rule === "string") return new RegExp("^" + rule + "$", "i");
|
|
1234
|
+
return rule;
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Pass in a word token to produce a function that can replicate the case on
|
|
1238
|
+
* another word.
|
|
1239
|
+
*
|
|
1240
|
+
* @param {string} word
|
|
1241
|
+
* @param {string} token
|
|
1242
|
+
* @return {Function}
|
|
1243
|
+
*/
|
|
1244
|
+
function restoreCase(word, token) {
|
|
1245
|
+
if (word === token) return token;
|
|
1246
|
+
if (word === word.toLowerCase()) return token.toLowerCase();
|
|
1247
|
+
if (word === word.toUpperCase()) return token.toUpperCase();
|
|
1248
|
+
if (word[0] === word[0].toUpperCase()) return token.charAt(0).toUpperCase() + token.substr(1).toLowerCase();
|
|
1249
|
+
return token.toLowerCase();
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Interpolate a regexp string.
|
|
1253
|
+
*
|
|
1254
|
+
* @param {string} str
|
|
1255
|
+
* @param {Array} args
|
|
1256
|
+
* @return {string}
|
|
1257
|
+
*/
|
|
1258
|
+
function interpolate(str, args) {
|
|
1259
|
+
return str.replace(/\$(\d{1,2})/g, function(match, index) {
|
|
1260
|
+
return args[index] || "";
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Replace a word using a rule.
|
|
1265
|
+
*
|
|
1266
|
+
* @param {string} word
|
|
1267
|
+
* @param {Array} rule
|
|
1268
|
+
* @return {string}
|
|
1269
|
+
*/
|
|
1270
|
+
function replace(word, rule) {
|
|
1271
|
+
return word.replace(rule[0], function(match, index) {
|
|
1272
|
+
var result = interpolate(rule[1], arguments);
|
|
1273
|
+
if (match === "") return restoreCase(word[index - 1], result);
|
|
1274
|
+
return restoreCase(match, result);
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Sanitize a word by passing in the word and sanitization rules.
|
|
1279
|
+
*
|
|
1280
|
+
* @param {string} token
|
|
1281
|
+
* @param {string} word
|
|
1282
|
+
* @param {Array} rules
|
|
1283
|
+
* @return {string}
|
|
1284
|
+
*/
|
|
1285
|
+
function sanitizeWord(token, word, rules) {
|
|
1286
|
+
if (!token.length || uncountables.hasOwnProperty(token)) return word;
|
|
1287
|
+
var len = rules.length;
|
|
1288
|
+
while (len--) {
|
|
1289
|
+
var rule = rules[len];
|
|
1290
|
+
if (rule[0].test(word)) return replace(word, rule);
|
|
1291
|
+
}
|
|
1292
|
+
return word;
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Replace a word with the updated word.
|
|
1296
|
+
*
|
|
1297
|
+
* @param {Object} replaceMap
|
|
1298
|
+
* @param {Object} keepMap
|
|
1299
|
+
* @param {Array} rules
|
|
1300
|
+
* @return {Function}
|
|
1301
|
+
*/
|
|
1302
|
+
function replaceWord(replaceMap, keepMap, rules) {
|
|
1303
|
+
return function(word) {
|
|
1304
|
+
var token = word.toLowerCase();
|
|
1305
|
+
if (keepMap.hasOwnProperty(token)) return restoreCase(word, token);
|
|
1306
|
+
if (replaceMap.hasOwnProperty(token)) return restoreCase(word, replaceMap[token]);
|
|
1307
|
+
return sanitizeWord(token, word, rules);
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Check if a word is part of the map.
|
|
1312
|
+
*/
|
|
1313
|
+
function checkWord(replaceMap, keepMap, rules, bool) {
|
|
1314
|
+
return function(word) {
|
|
1315
|
+
var token = word.toLowerCase();
|
|
1316
|
+
if (keepMap.hasOwnProperty(token)) return true;
|
|
1317
|
+
if (replaceMap.hasOwnProperty(token)) return false;
|
|
1318
|
+
return sanitizeWord(token, token, rules) === token;
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Pluralize or singularize a word based on the passed in count.
|
|
1323
|
+
*
|
|
1324
|
+
* @param {string} word The word to pluralize
|
|
1325
|
+
* @param {number} count How many of the word exist
|
|
1326
|
+
* @param {boolean} inclusive Whether to prefix with the number (e.g. 3 ducks)
|
|
1327
|
+
* @return {string}
|
|
1328
|
+
*/
|
|
1329
|
+
function pluralize(word, count, inclusive) {
|
|
1330
|
+
var pluralized = count === 1 ? pluralize.singular(word) : pluralize.plural(word);
|
|
1331
|
+
return (inclusive ? count + " " : "") + pluralized;
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Pluralize a word.
|
|
1335
|
+
*
|
|
1336
|
+
* @type {Function}
|
|
1337
|
+
*/
|
|
1338
|
+
pluralize.plural = replaceWord(irregularSingles, irregularPlurals, pluralRules);
|
|
1339
|
+
/**
|
|
1340
|
+
* Check if a word is plural.
|
|
1341
|
+
*
|
|
1342
|
+
* @type {Function}
|
|
1343
|
+
*/
|
|
1344
|
+
pluralize.isPlural = checkWord(irregularSingles, irregularPlurals, pluralRules);
|
|
1345
|
+
/**
|
|
1346
|
+
* Singularize a word.
|
|
1347
|
+
*
|
|
1348
|
+
* @type {Function}
|
|
1349
|
+
*/
|
|
1350
|
+
pluralize.singular = replaceWord(irregularPlurals, irregularSingles, singularRules);
|
|
1351
|
+
/**
|
|
1352
|
+
* Check if a word is singular.
|
|
1353
|
+
*
|
|
1354
|
+
* @type {Function}
|
|
1355
|
+
*/
|
|
1356
|
+
pluralize.isSingular = checkWord(irregularPlurals, irregularSingles, singularRules);
|
|
1357
|
+
/**
|
|
1358
|
+
* Add a pluralization rule to the collection.
|
|
1359
|
+
*
|
|
1360
|
+
* @param {(string|RegExp)} rule
|
|
1361
|
+
* @param {string} replacement
|
|
1362
|
+
*/
|
|
1363
|
+
pluralize.addPluralRule = function(rule, replacement) {
|
|
1364
|
+
pluralRules.push([sanitizeRule(rule), replacement]);
|
|
1365
|
+
};
|
|
1366
|
+
/**
|
|
1367
|
+
* Add a singularization rule to the collection.
|
|
1368
|
+
*
|
|
1369
|
+
* @param {(string|RegExp)} rule
|
|
1370
|
+
* @param {string} replacement
|
|
1371
|
+
*/
|
|
1372
|
+
pluralize.addSingularRule = function(rule, replacement) {
|
|
1373
|
+
singularRules.push([sanitizeRule(rule), replacement]);
|
|
1374
|
+
};
|
|
1375
|
+
/**
|
|
1376
|
+
* Add an uncountable word rule.
|
|
1377
|
+
*
|
|
1378
|
+
* @param {(string|RegExp)} word
|
|
1379
|
+
*/
|
|
1380
|
+
pluralize.addUncountableRule = function(word) {
|
|
1381
|
+
if (typeof word === "string") {
|
|
1382
|
+
uncountables[word.toLowerCase()] = true;
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
pluralize.addPluralRule(word, "$0");
|
|
1386
|
+
pluralize.addSingularRule(word, "$0");
|
|
1387
|
+
};
|
|
1388
|
+
/**
|
|
1389
|
+
* Add an irregular word definition.
|
|
1390
|
+
*
|
|
1391
|
+
* @param {string} single
|
|
1392
|
+
* @param {string} plural
|
|
1393
|
+
*/
|
|
1394
|
+
pluralize.addIrregularRule = function(single, plural) {
|
|
1395
|
+
plural = plural.toLowerCase();
|
|
1396
|
+
single = single.toLowerCase();
|
|
1397
|
+
irregularSingles[single] = plural;
|
|
1398
|
+
irregularPlurals[plural] = single;
|
|
1399
|
+
};
|
|
1400
|
+
/**
|
|
1401
|
+
* Irregular rules.
|
|
1402
|
+
*/
|
|
1403
|
+
[
|
|
1404
|
+
["I", "we"],
|
|
1405
|
+
["me", "us"],
|
|
1406
|
+
["he", "they"],
|
|
1407
|
+
["she", "they"],
|
|
1408
|
+
["them", "them"],
|
|
1409
|
+
["myself", "ourselves"],
|
|
1410
|
+
["yourself", "yourselves"],
|
|
1411
|
+
["itself", "themselves"],
|
|
1412
|
+
["herself", "themselves"],
|
|
1413
|
+
["himself", "themselves"],
|
|
1414
|
+
["themself", "themselves"],
|
|
1415
|
+
["is", "are"],
|
|
1416
|
+
["was", "were"],
|
|
1417
|
+
["has", "have"],
|
|
1418
|
+
["this", "these"],
|
|
1419
|
+
["that", "those"],
|
|
1420
|
+
["echo", "echoes"],
|
|
1421
|
+
["dingo", "dingoes"],
|
|
1422
|
+
["volcano", "volcanoes"],
|
|
1423
|
+
["tornado", "tornadoes"],
|
|
1424
|
+
["torpedo", "torpedoes"],
|
|
1425
|
+
["genus", "genera"],
|
|
1426
|
+
["viscus", "viscera"],
|
|
1427
|
+
["stigma", "stigmata"],
|
|
1428
|
+
["stoma", "stomata"],
|
|
1429
|
+
["dogma", "dogmata"],
|
|
1430
|
+
["lemma", "lemmata"],
|
|
1431
|
+
["schema", "schemata"],
|
|
1432
|
+
["anathema", "anathemata"],
|
|
1433
|
+
["ox", "oxen"],
|
|
1434
|
+
["axe", "axes"],
|
|
1435
|
+
["die", "dice"],
|
|
1436
|
+
["yes", "yeses"],
|
|
1437
|
+
["foot", "feet"],
|
|
1438
|
+
["eave", "eaves"],
|
|
1439
|
+
["goose", "geese"],
|
|
1440
|
+
["tooth", "teeth"],
|
|
1441
|
+
["quiz", "quizzes"],
|
|
1442
|
+
["human", "humans"],
|
|
1443
|
+
["proof", "proofs"],
|
|
1444
|
+
["carve", "carves"],
|
|
1445
|
+
["valve", "valves"],
|
|
1446
|
+
["looey", "looies"],
|
|
1447
|
+
["thief", "thieves"],
|
|
1448
|
+
["groove", "grooves"],
|
|
1449
|
+
["pickaxe", "pickaxes"],
|
|
1450
|
+
["passerby", "passersby"]
|
|
1451
|
+
].forEach(function(rule) {
|
|
1452
|
+
return pluralize.addIrregularRule(rule[0], rule[1]);
|
|
1453
|
+
});
|
|
1454
|
+
/**
|
|
1455
|
+
* Pluralization rules.
|
|
1456
|
+
*/
|
|
1457
|
+
[
|
|
1458
|
+
[/s?$/i, "s"],
|
|
1459
|
+
[/[^\u0000-\u007F]$/i, "$0"],
|
|
1460
|
+
[/([^aeiou]ese)$/i, "$1"],
|
|
1461
|
+
[/(ax|test)is$/i, "$1es"],
|
|
1462
|
+
[/(alias|[^aou]us|t[lm]as|gas|ris)$/i, "$1es"],
|
|
1463
|
+
[/(e[mn]u)s?$/i, "$1s"],
|
|
1464
|
+
[/([^l]ias|[aeiou]las|[ejzr]as|[iu]am)$/i, "$1"],
|
|
1465
|
+
[/(alumn|syllab|vir|radi|nucle|fung|cact|stimul|termin|bacill|foc|uter|loc|strat)(?:us|i)$/i, "$1i"],
|
|
1466
|
+
[/(alumn|alg|vertebr)(?:a|ae)$/i, "$1ae"],
|
|
1467
|
+
[/(seraph|cherub)(?:im)?$/i, "$1im"],
|
|
1468
|
+
[/(her|at|gr)o$/i, "$1oes"],
|
|
1469
|
+
[/(agend|addend|millenni|dat|extrem|bacteri|desiderat|strat|candelabr|errat|ov|symposi|curricul|automat|quor)(?:a|um)$/i, "$1a"],
|
|
1470
|
+
[/(apheli|hyperbat|periheli|asyndet|noumen|phenomen|criteri|organ|prolegomen|hedr|automat)(?:a|on)$/i, "$1a"],
|
|
1471
|
+
[/sis$/i, "ses"],
|
|
1472
|
+
[/(?:(kni|wi|li)fe|(ar|l|ea|eo|oa|hoo)f)$/i, "$1$2ves"],
|
|
1473
|
+
[/([^aeiouy]|qu)y$/i, "$1ies"],
|
|
1474
|
+
[/([^ch][ieo][ln])ey$/i, "$1ies"],
|
|
1475
|
+
[/(x|ch|ss|sh|zz)$/i, "$1es"],
|
|
1476
|
+
[/(matr|cod|mur|sil|vert|ind|append)(?:ix|ex)$/i, "$1ices"],
|
|
1477
|
+
[/\b((?:tit)?m|l)(?:ice|ouse)$/i, "$1ice"],
|
|
1478
|
+
[/(pe)(?:rson|ople)$/i, "$1ople"],
|
|
1479
|
+
[/(child)(?:ren)?$/i, "$1ren"],
|
|
1480
|
+
[/eaux$/i, "$0"],
|
|
1481
|
+
[/m[ae]n$/i, "men"],
|
|
1482
|
+
["thou", "you"]
|
|
1483
|
+
].forEach(function(rule) {
|
|
1484
|
+
return pluralize.addPluralRule(rule[0], rule[1]);
|
|
1485
|
+
});
|
|
1486
|
+
/**
|
|
1487
|
+
* Singularization rules.
|
|
1488
|
+
*/
|
|
1489
|
+
[
|
|
1490
|
+
[/s$/i, ""],
|
|
1491
|
+
[/(ss)$/i, "$1"],
|
|
1492
|
+
[/(wi|kni|(?:after|half|high|low|mid|non|night|[^\w]|^)li)ves$/i, "$1fe"],
|
|
1493
|
+
[/(ar|(?:wo|[ae])l|[eo][ao])ves$/i, "$1f"],
|
|
1494
|
+
[/ies$/i, "y"],
|
|
1495
|
+
[/\b([pl]|zomb|(?:neck|cross)?t|coll|faer|food|gen|goon|group|lass|talk|goal|cut)ies$/i, "$1ie"],
|
|
1496
|
+
[/\b(mon|smil)ies$/i, "$1ey"],
|
|
1497
|
+
[/\b((?:tit)?m|l)ice$/i, "$1ouse"],
|
|
1498
|
+
[/(seraph|cherub)im$/i, "$1"],
|
|
1499
|
+
[/(x|ch|ss|sh|zz|tto|go|cho|alias|[^aou]us|t[lm]as|gas|(?:her|at|gr)o|[aeiou]ris)(?:es)?$/i, "$1"],
|
|
1500
|
+
[/(analy|diagno|parenthe|progno|synop|the|empha|cri|ne)(?:sis|ses)$/i, "$1sis"],
|
|
1501
|
+
[/(movie|twelve|abuse|e[mn]u)s$/i, "$1"],
|
|
1502
|
+
[/(test)(?:is|es)$/i, "$1is"],
|
|
1503
|
+
[/(alumn|syllab|vir|radi|nucle|fung|cact|stimul|termin|bacill|foc|uter|loc|strat)(?:us|i)$/i, "$1us"],
|
|
1504
|
+
[/(agend|addend|millenni|dat|extrem|bacteri|desiderat|strat|candelabr|errat|ov|symposi|curricul|quor)a$/i, "$1um"],
|
|
1505
|
+
[/(apheli|hyperbat|periheli|asyndet|noumen|phenomen|criteri|organ|prolegomen|hedr|automat)a$/i, "$1on"],
|
|
1506
|
+
[/(alumn|alg|vertebr)ae$/i, "$1a"],
|
|
1507
|
+
[/(cod|mur|sil|vert|ind)ices$/i, "$1ex"],
|
|
1508
|
+
[/(matr|append)ices$/i, "$1ix"],
|
|
1509
|
+
[/(pe)(rson|ople)$/i, "$1rson"],
|
|
1510
|
+
[/(child)ren$/i, "$1"],
|
|
1511
|
+
[/(eau)x?$/i, "$1"],
|
|
1512
|
+
[/men$/i, "man"]
|
|
1513
|
+
].forEach(function(rule) {
|
|
1514
|
+
return pluralize.addSingularRule(rule[0], rule[1]);
|
|
1515
|
+
});
|
|
1516
|
+
/**
|
|
1517
|
+
* Uncountable rules.
|
|
1518
|
+
*/
|
|
1519
|
+
[
|
|
1520
|
+
"adulthood",
|
|
1521
|
+
"advice",
|
|
1522
|
+
"agenda",
|
|
1523
|
+
"aid",
|
|
1524
|
+
"aircraft",
|
|
1525
|
+
"alcohol",
|
|
1526
|
+
"ammo",
|
|
1527
|
+
"analytics",
|
|
1528
|
+
"anime",
|
|
1529
|
+
"athletics",
|
|
1530
|
+
"audio",
|
|
1531
|
+
"bison",
|
|
1532
|
+
"blood",
|
|
1533
|
+
"bream",
|
|
1534
|
+
"buffalo",
|
|
1535
|
+
"butter",
|
|
1536
|
+
"carp",
|
|
1537
|
+
"cash",
|
|
1538
|
+
"chassis",
|
|
1539
|
+
"chess",
|
|
1540
|
+
"clothing",
|
|
1541
|
+
"cod",
|
|
1542
|
+
"commerce",
|
|
1543
|
+
"cooperation",
|
|
1544
|
+
"corps",
|
|
1545
|
+
"debris",
|
|
1546
|
+
"diabetes",
|
|
1547
|
+
"digestion",
|
|
1548
|
+
"elk",
|
|
1549
|
+
"energy",
|
|
1550
|
+
"equipment",
|
|
1551
|
+
"excretion",
|
|
1552
|
+
"expertise",
|
|
1553
|
+
"firmware",
|
|
1554
|
+
"flounder",
|
|
1555
|
+
"fun",
|
|
1556
|
+
"gallows",
|
|
1557
|
+
"garbage",
|
|
1558
|
+
"graffiti",
|
|
1559
|
+
"hardware",
|
|
1560
|
+
"headquarters",
|
|
1561
|
+
"health",
|
|
1562
|
+
"herpes",
|
|
1563
|
+
"highjinks",
|
|
1564
|
+
"homework",
|
|
1565
|
+
"housework",
|
|
1566
|
+
"information",
|
|
1567
|
+
"jeans",
|
|
1568
|
+
"justice",
|
|
1569
|
+
"kudos",
|
|
1570
|
+
"labour",
|
|
1571
|
+
"literature",
|
|
1572
|
+
"machinery",
|
|
1573
|
+
"mackerel",
|
|
1574
|
+
"mail",
|
|
1575
|
+
"media",
|
|
1576
|
+
"mews",
|
|
1577
|
+
"moose",
|
|
1578
|
+
"music",
|
|
1579
|
+
"mud",
|
|
1580
|
+
"manga",
|
|
1581
|
+
"news",
|
|
1582
|
+
"only",
|
|
1583
|
+
"personnel",
|
|
1584
|
+
"pike",
|
|
1585
|
+
"plankton",
|
|
1586
|
+
"pliers",
|
|
1587
|
+
"police",
|
|
1588
|
+
"pollution",
|
|
1589
|
+
"premises",
|
|
1590
|
+
"rain",
|
|
1591
|
+
"research",
|
|
1592
|
+
"rice",
|
|
1593
|
+
"salmon",
|
|
1594
|
+
"scissors",
|
|
1595
|
+
"series",
|
|
1596
|
+
"sewage",
|
|
1597
|
+
"shambles",
|
|
1598
|
+
"shrimp",
|
|
1599
|
+
"software",
|
|
1600
|
+
"species",
|
|
1601
|
+
"staff",
|
|
1602
|
+
"swine",
|
|
1603
|
+
"tennis",
|
|
1604
|
+
"traffic",
|
|
1605
|
+
"transportation",
|
|
1606
|
+
"trout",
|
|
1607
|
+
"tuna",
|
|
1608
|
+
"wealth",
|
|
1609
|
+
"welfare",
|
|
1610
|
+
"whiting",
|
|
1611
|
+
"wildebeest",
|
|
1612
|
+
"wildlife",
|
|
1613
|
+
"you",
|
|
1614
|
+
/pok[eé]mon$/i,
|
|
1615
|
+
/[^aeiou]ese$/i,
|
|
1616
|
+
/deer$/i,
|
|
1617
|
+
/fish$/i,
|
|
1618
|
+
/measles$/i,
|
|
1619
|
+
/o[iu]s$/i,
|
|
1620
|
+
/pox$/i,
|
|
1621
|
+
/sheep$/i
|
|
1622
|
+
].forEach(pluralize.addUncountableRule);
|
|
1623
|
+
return pluralize;
|
|
1624
|
+
});
|
|
1625
|
+
})))(), 1);
|
|
1626
|
+
/**
|
|
1627
|
+
* The name an entity's aggregate doctype is generated under: the entity's name, pluralised.
|
|
1628
|
+
*
|
|
1629
|
+
* One definition, because the CLI writes the file under `toSlug` of this and any later caller
|
|
1630
|
+
* (a scaffolder, a docs generator) must land on the same name or it silently addresses a
|
|
1631
|
+
* different file.
|
|
1632
|
+
*
|
|
1633
|
+
* `pluralize` rather than appending `s`, because the irregulars are not rare in practice —
|
|
1634
|
+
* measured against a consumer's 41 hand-authored aggregate doctypes, this rule reproduces every
|
|
1635
|
+
* one of their names, slugs and filenames exactly, while `+ 's'` gets five wrong
|
|
1636
|
+
* (`Currencys`, `JournalEntrys`, …).
|
|
1637
|
+
*
|
|
1638
|
+
* The rule is not total: an already-plural name pluralises to itself. Callers must handle that —
|
|
1639
|
+
* see {@link buildAggregateDoctype}.
|
|
1640
|
+
*
|
|
1641
|
+
* @param doctypeName - the entity doctype's `name`
|
|
1642
|
+
* @returns the aggregate doctype's `name`
|
|
1643
|
+
* @public
|
|
1644
|
+
*
|
|
1645
|
+
* @example
|
|
1646
|
+
* ```typescript
|
|
1647
|
+
* aggregateDoctypeName('SalesOrder') // 'SalesOrders' -> slug 'sales-orders'
|
|
1648
|
+
* ```
|
|
1649
|
+
*/
|
|
1650
|
+
function aggregateDoctypeName(doctypeName) {
|
|
1651
|
+
return import_pluralize.default.plural(doctypeName);
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Derive the aggregate doctype for a converted entity.
|
|
1655
|
+
*
|
|
1656
|
+
* Returns `undefined` when no identity column can be found — a natural-key table whose key the
|
|
1657
|
+
* converter refuses to guess and whose author has not declared one, or a foreign PostGraphile
|
|
1658
|
+
* endpoint that has left the Relay identifier occupying `id` (Stonecrop's own preset moves it to
|
|
1659
|
+
* `nodeId`). That is deliberate: an aggregate with an empty `fields` array is a valid doctype that
|
|
1660
|
+
* renders a table with no columns, which looks like a data problem rather than a generation one.
|
|
1661
|
+
* Emitting nothing and saying so is the loud failure.
|
|
1662
|
+
*
|
|
1663
|
+
* Identity resolves the same way `getRecordIdField` resolves it — the declared `primaryKey`, then
|
|
1664
|
+
* the conventional `id` — so an aggregate is always keyed on the column the client will later ask
|
|
1665
|
+
* for. `declaredIdentity` overrides both: SDL cannot express which `UNIQUE` column is the key, so
|
|
1666
|
+
* for a natural-key table the answer only exists in the authored file, and the caller that read it
|
|
1667
|
+
* passes the fieldname back.
|
|
1668
|
+
*
|
|
1669
|
+
* @param doctype - a converted entity doctype, as returned by `convertGraphQLSchema`
|
|
1670
|
+
* @param declaredIdentity - fieldname the authored doctype declares as its `primaryKey`, when the
|
|
1671
|
+
* caller has read one. Must name a field the converter emitted; the caller checks that, because
|
|
1672
|
+
* only it can say whether a missing one is a dropped column or a typo.
|
|
1673
|
+
* @returns the aggregate doctype, or `undefined` when no identity column exists
|
|
1674
|
+
* @public
|
|
1675
|
+
*
|
|
1676
|
+
* @example
|
|
1677
|
+
* ```typescript
|
|
1678
|
+
* const [order] = convertGraphQLSchema(sdl, { include: ['Order'] })
|
|
1679
|
+
* const aggregate = buildAggregateDoctype(order)
|
|
1680
|
+
* // { name: 'Orders', slug: 'orders', fields: [ the id field ] }
|
|
1681
|
+
* ```
|
|
1682
|
+
*/
|
|
1683
|
+
function buildAggregateDoctype(doctype, declaredIdentity) {
|
|
1684
|
+
const identity = findIdentityField(doctype.fields, declaredIdentity);
|
|
1685
|
+
if (!identity) return void 0;
|
|
1686
|
+
const name = aggregateDoctypeName(doctype.name);
|
|
1687
|
+
if (name === doctype.name) return void 0;
|
|
1688
|
+
const { source, ...rest } = identity;
|
|
1689
|
+
return {
|
|
1690
|
+
name,
|
|
1691
|
+
slug: toSlug(name),
|
|
1692
|
+
fields: [{
|
|
1693
|
+
...rest,
|
|
1694
|
+
primaryKey: true,
|
|
1695
|
+
...source === void 0 ? {} : { source }
|
|
1696
|
+
}]
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* The field an aggregate is keyed on: an identity the author declared, else the primary key the
|
|
1701
|
+
* converter derived, else the conventional `id`.
|
|
1702
|
+
*
|
|
1703
|
+
* The author wins because the authored doctype is the source of truth — generation verifies it and
|
|
1704
|
+
* never overwrites it (see `mergeIntrospectedDoctype`), and the divergence is already reported as
|
|
1705
|
+
* identity drift.
|
|
1706
|
+
*
|
|
1707
|
+
* Calls `getPrimaryKeyField` for the derived half rather than restating it: a restatement drifted
|
|
1708
|
+
* exactly as one does, staying top-level while the helper learned to descend into fieldsets.
|
|
1709
|
+
*
|
|
1710
|
+
* @internal
|
|
1711
|
+
*/
|
|
1712
|
+
function findIdentityField(fields, declared) {
|
|
1713
|
+
if (declared !== void 0) return fields.find((field) => field.fieldname === declared);
|
|
1714
|
+
return getPrimaryKeyField(fields) ?? fields.find((field) => field.fieldname === "id");
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* The doctypes in a run that get a URL of their own.
|
|
1718
|
+
*
|
|
1719
|
+
* A child table has no page: its rows exist inside a parent and are edited there, so a route for
|
|
1720
|
+
* it is an address nothing can link to. The declaration that says so is the parent's `links` entry
|
|
1721
|
+
* with a to-many cardinality — which the server derives from the foreign keys it treats as owning,
|
|
1722
|
+
* so this reads what the schema states rather than guessing from a name.
|
|
1723
|
+
*
|
|
1724
|
+
* The rule is *listed by something, referenced by nothing*. A single reference wins over any number
|
|
1725
|
+
* of listings, and the asymmetry is deliberate: a doctype that is both a parent's rows and another
|
|
1726
|
+
* doctype's link target — a recipe task, say, embedded in its recipe and pointed at by four other
|
|
1727
|
+
* records — needs somewhere for those links to navigate to. Denying it leaves the arrow on an
|
|
1728
|
+
* `AFormLink` dead, which fails silently; granting it leaves a URL nobody visits, which does not.
|
|
1729
|
+
*
|
|
1730
|
+
* Scoped to one run, so a partial generation sees a partial graph and grants more routes than a
|
|
1731
|
+
* whole one would. That is the safe direction, and the extra routes are deletable — an authored
|
|
1732
|
+
* file's keys survive regeneration untouched.
|
|
1733
|
+
*
|
|
1734
|
+
* @internal
|
|
1735
|
+
*/
|
|
1736
|
+
function routableDoctypes(entities) {
|
|
1737
|
+
const listed = /* @__PURE__ */ new Set();
|
|
1738
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
1739
|
+
for (const entity of entities) {
|
|
1740
|
+
for (const link of Object.values(entity.links ?? {})) if (link.cardinality === "noneOrMany" || link.cardinality === "atLeastOne") listed.add(link.target);
|
|
1741
|
+
else referenced.add(link.target);
|
|
1742
|
+
for (const field of flattenFields(entity.fields)) if ("doctype" in field && typeof field.doctype === "string") referenced.add(field.doctype);
|
|
1743
|
+
}
|
|
1744
|
+
return new Set(entities.filter((entity) => {
|
|
1745
|
+
const slug = getDoctypeSlug(entity);
|
|
1746
|
+
return referenced.has(slug) || !listed.has(slug);
|
|
1747
|
+
}).map((entity) => entity.name));
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Expand converted entities into the set of doctype files to write.
|
|
1751
|
+
*
|
|
1752
|
+
* Each table yields two: the entity, whose fields carry every column and which backs the record
|
|
1753
|
+
* form, and its aggregate — the collection view. They are written as peers, one file each, with
|
|
1754
|
+
* no key relating them.
|
|
1755
|
+
*
|
|
1756
|
+
* Separate from the CLI because the pairing of a file to its verification basis is the part that
|
|
1757
|
+
* is easy to get wrong and impossible to notice: getting it wrong does not throw, it just reports
|
|
1758
|
+
* drift that is not there, forever.
|
|
1759
|
+
*
|
|
1760
|
+
* @param entities - `convertGraphQLSchema` output
|
|
1761
|
+
* @param options - see {@link GenerationPlanOptions}
|
|
1762
|
+
* @returns one entry per file to write
|
|
1763
|
+
* @public
|
|
1764
|
+
*/
|
|
1765
|
+
function planGeneration(entities, options = {}) {
|
|
1766
|
+
const entityNames = new Set(entities.map((entity) => entity.name));
|
|
1767
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
1768
|
+
const routable = routableDoctypes(entities);
|
|
1769
|
+
return entities.flatMap((entity) => {
|
|
1770
|
+
const segment = `/${getDoctypeSlug(entity)}`;
|
|
1771
|
+
const routed = routable.has(entity.name) ? {
|
|
1772
|
+
...entity,
|
|
1773
|
+
route: `${segment}/:id`
|
|
1774
|
+
} : entity;
|
|
1775
|
+
const self = {
|
|
1776
|
+
generated: routed,
|
|
1777
|
+
basis: routed,
|
|
1778
|
+
subset: false
|
|
1779
|
+
};
|
|
1780
|
+
if (options.noAggregates) return [self];
|
|
1781
|
+
const name = aggregateDoctypeName(entity.name);
|
|
1782
|
+
if (name === entity.name) {
|
|
1783
|
+
options.onWarning?.(`${entity.name} is already plural, so its aggregate would take the same name and the same file. No aggregate was generated. Rename the doctype to its singular form, or author ${entity.slug}.json's collection view by hand.`);
|
|
1784
|
+
return [self];
|
|
1785
|
+
}
|
|
1786
|
+
if (entityNames.has(name) || claimed.has(name)) {
|
|
1787
|
+
options.onWarning?.(`${entity.name}'s aggregate would be named ${name}, which is already taken by another doctype in this run. No aggregate was generated — one of the two needs an explicit name via the doctypeNames option.`);
|
|
1788
|
+
return [self];
|
|
1789
|
+
}
|
|
1790
|
+
const declared = options.identity?.[entity.name];
|
|
1791
|
+
if (declared !== void 0 && !entity.fields.some((field) => field.fieldname === declared)) {
|
|
1792
|
+
options.onWarning?.(`${entity.name} declares its primaryKey on '${declared}', which the schema has no column for. No aggregate was generated — correct the declaration in ${entity.slug}.json, or restore the column to the table.`);
|
|
1793
|
+
return [self];
|
|
1794
|
+
}
|
|
1795
|
+
const aggregate = buildAggregateDoctype(entity, declared);
|
|
1796
|
+
if (!aggregate) {
|
|
1797
|
+
options.onWarning?.(`${entity.name} has no derivable identity column, so no aggregate doctype was generated. Declare a primaryKey on ${entity.slug}.json and re-run.`);
|
|
1798
|
+
return [self];
|
|
1799
|
+
}
|
|
1800
|
+
claimed.add(name);
|
|
1801
|
+
return [self, {
|
|
1802
|
+
generated: routable.has(entity.name) ? {
|
|
1803
|
+
...aggregate,
|
|
1804
|
+
route: segment
|
|
1805
|
+
} : aggregate,
|
|
1806
|
+
basis: entity,
|
|
1807
|
+
subset: true
|
|
1808
|
+
}];
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
//#endregion
|
|
1812
|
+
//#region src/converter/authored.ts
|
|
1813
|
+
/** @internal */
|
|
1814
|
+
function isAuthoredRecord(value) {
|
|
1815
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Flatten authored fields, descending into fieldsets.
|
|
1819
|
+
*
|
|
1820
|
+
* A fieldset is a layout grouping, not a scope: a field inside one is still a field of the doctype,
|
|
1821
|
+
* with a column of its own and a key it may declare.
|
|
1822
|
+
*
|
|
1823
|
+
* @internal
|
|
1824
|
+
*/
|
|
1825
|
+
function flattenAuthored(fields) {
|
|
1826
|
+
const out = [];
|
|
1827
|
+
for (const field of fields) if (Array.isArray(field.schema)) out.push(...flattenAuthored(field.schema.filter(isAuthoredRecord)));
|
|
1828
|
+
else out.push(field);
|
|
1829
|
+
return out;
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* The fieldname an authored doctype declares as its identity, or `undefined` when it declares none.
|
|
1833
|
+
*
|
|
1834
|
+
* Descends into fieldsets, because a nested `primaryKey` is a real declaration — ignoring one is
|
|
1835
|
+
* what `getPrimaryKeyField` was fixed for.
|
|
1836
|
+
*
|
|
1837
|
+
* @internal
|
|
1838
|
+
*/
|
|
1839
|
+
function authoredPrimaryKey(doctype) {
|
|
1840
|
+
if (!Array.isArray(doctype.fields)) return void 0;
|
|
1841
|
+
const declared = flattenAuthored(doctype.fields.filter(isAuthoredRecord)).find((f) => f.primaryKey === true);
|
|
1842
|
+
return typeof declared?.fieldname === "string" ? declared.fieldname : void 0;
|
|
1843
|
+
}
|
|
1844
|
+
//#endregion
|
|
1845
|
+
//#region src/converter/merge.ts
|
|
1846
|
+
/**
|
|
1847
|
+
* Merge introspected schema facts into an already-authored doctype.
|
|
1848
|
+
*
|
|
1849
|
+
* The authored doctype is the source of truth. Generation **verifies** it and stamps provenance;
|
|
1850
|
+
* it does not overwrite. That polarity is deliberate and load-bearing — a doctype legitimately
|
|
1851
|
+
* declares a `primaryKey` the schema cannot express. A natural business key is very often a
|
|
1852
|
+
* `UNIQUE` constraint rather than the table's `PRIMARY KEY`, and where a table carries several
|
|
1853
|
+
* uniques no rule can pick between them. Overwriting identity from the schema would silently
|
|
1854
|
+
* re-key such a doctype on every regeneration and break the handlers that key on the old value.
|
|
1855
|
+
*
|
|
1856
|
+
* So divergence is **reported, never applied** — a human decides. The only mutation this performs
|
|
1857
|
+
* is adding `source: 'introspected'` to fields confirmed to exist in the GraphQL schema.
|
|
1858
|
+
*
|
|
1859
|
+
* @packageDocumentation
|
|
1860
|
+
*/
|
|
1861
|
+
function describe(value) {
|
|
1862
|
+
return value === void 0 ? "—" : JSON.stringify(value);
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Verify an authored doctype against freshly generated output and stamp provenance.
|
|
1866
|
+
*
|
|
1867
|
+
* @param authored - the doctype as it exists on disk; every key not named below is preserved verbatim
|
|
1868
|
+
* @param generated - `convertGraphQLSchema` output for the corresponding GraphQL type. For a
|
|
1869
|
+
* `subset` merge this is the **entity**, whose fields are the set the subset is curated from
|
|
1870
|
+
* @param options - see {@link MergeOptions}
|
|
1871
|
+
* @returns the doctype to write, plus a drift report
|
|
1872
|
+
*
|
|
1873
|
+
* @example
|
|
1874
|
+
* ```ts
|
|
1875
|
+
* const [generated] = convertGraphQLSchema(introspection, { include: ['Uom'] })
|
|
1876
|
+
* const { doctype, drift } = mergeIntrospectedDoctype(JSON.parse(onDisk), generated)
|
|
1877
|
+
* if (drift.identityDrift.length) console.warn(drift.identityDrift.join('\n'))
|
|
1878
|
+
* ```
|
|
1879
|
+
*
|
|
1880
|
+
* @public
|
|
1881
|
+
*/
|
|
1882
|
+
function mergeIntrospectedDoctype(authored, generated, options = {}) {
|
|
1883
|
+
const authoredFields = Array.isArray(authored.fields) ? authored.fields.filter(isAuthoredRecord) : [];
|
|
1884
|
+
const generatedByName = new Map(generated.fields.map((f) => [f.fieldname, f]));
|
|
1885
|
+
const generatedLinkNames = new Set(Object.keys(generated.links ?? {}));
|
|
1886
|
+
const drift = {
|
|
1887
|
+
doctype: typeof authored.name === "string" ? authored.name : "(unnamed)",
|
|
1888
|
+
mode: "clean",
|
|
1889
|
+
tagged: [],
|
|
1890
|
+
orphan: [],
|
|
1891
|
+
omitted: [],
|
|
1892
|
+
componentDrift: [],
|
|
1893
|
+
requiredDrift: [],
|
|
1894
|
+
identityDrift: []
|
|
1895
|
+
};
|
|
1896
|
+
const tag = (field) => {
|
|
1897
|
+
if (Array.isArray(field.schema)) return {
|
|
1898
|
+
...field,
|
|
1899
|
+
schema: field.schema.filter(isAuthoredRecord).map(tag)
|
|
1900
|
+
};
|
|
1901
|
+
const name = typeof field.fieldname === "string" ? field.fieldname : "";
|
|
1902
|
+
const match = generatedByName.get(name);
|
|
1903
|
+
if (!match) {
|
|
1904
|
+
if (field.computed !== true && !generatedLinkNames.has(name)) drift.orphan.push(name);
|
|
1905
|
+
return field;
|
|
1906
|
+
}
|
|
1907
|
+
drift.tagged.push(name);
|
|
1908
|
+
if (match.component !== field.component) drift.componentDrift.push(`${name}: authored=${describe(field.component)} schema=${describe(match.component)}`);
|
|
1909
|
+
if (Boolean(match.required) !== Boolean(field.required)) drift.requiredDrift.push(`${name}: authored=${Boolean(field.required)} schema=${Boolean(match.required)}`);
|
|
1910
|
+
for (const prop of INTROSPECTED_IDENTITY_PROPS) {
|
|
1911
|
+
if (prop === "fieldname" || prop === "required") continue;
|
|
1912
|
+
const authoredValue = field[prop];
|
|
1913
|
+
const schemaValue = match[prop];
|
|
1914
|
+
if (authoredValue === void 0 && schemaValue === void 0) continue;
|
|
1915
|
+
if (JSON.stringify(authoredValue) !== JSON.stringify(schemaValue)) drift.identityDrift.push(`${name}.${prop}: authored=${describe(authoredValue)} schema=${describe(schemaValue)}`);
|
|
1916
|
+
}
|
|
1917
|
+
return {
|
|
1918
|
+
...field,
|
|
1919
|
+
source: "introspected"
|
|
1920
|
+
};
|
|
1921
|
+
};
|
|
1922
|
+
const merged = {
|
|
1923
|
+
...authored,
|
|
1924
|
+
fields: authoredFields.map(tag)
|
|
1925
|
+
};
|
|
1926
|
+
if (!options.subset) {
|
|
1927
|
+
const authoredNames = new Set(flattenAuthored(authoredFields).map((f) => f.fieldname));
|
|
1928
|
+
drift.omitted = generated.fields.map((f) => f.fieldname).filter((n) => !authoredNames.has(n));
|
|
1929
|
+
}
|
|
1930
|
+
const authoredPk = authoredPrimaryKey(authored);
|
|
1931
|
+
const generatedPk = generated.fields.find((f) => f.primaryKey === true);
|
|
1932
|
+
if (authoredPk && generatedPk && authoredPk !== generatedPk.fieldname) {
|
|
1933
|
+
drift.mode = "partial";
|
|
1934
|
+
drift.reason = `authored primary key '${authoredPk}' is not the derivable '${generatedPk.fieldname}' — left as authored`;
|
|
1935
|
+
} else if (authoredPk && !generatedPk) {
|
|
1936
|
+
drift.mode = "partial";
|
|
1937
|
+
drift.reason = `authored primary key '${authoredPk}' is not derivable from the schema — left as authored`;
|
|
1938
|
+
} else if (!authoredPk && generatedPk) {
|
|
1939
|
+
drift.mode = "partial";
|
|
1940
|
+
drift.reason = `schema suggests '${generatedPk.fieldname}' as primary key but the doctype declares none — not applied`;
|
|
1941
|
+
}
|
|
1942
|
+
return {
|
|
1943
|
+
doctype: merged,
|
|
1944
|
+
drift
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Render a drift report as human-readable lines. Empty when generation agrees with the doctype.
|
|
1949
|
+
*
|
|
1950
|
+
* @param drift - a report from {@link mergeIntrospectedDoctype}
|
|
1951
|
+
* @returns one line per finding, ready to print
|
|
1952
|
+
*
|
|
1953
|
+
* @public
|
|
1954
|
+
*/
|
|
1955
|
+
function formatDoctypeDrift(drift) {
|
|
1956
|
+
const lines = [];
|
|
1957
|
+
if (drift.reason) lines.push(` ${drift.doctype}: ${drift.reason}`);
|
|
1958
|
+
const bucket = (label, entries) => {
|
|
1959
|
+
if (entries.length) lines.push(` ${drift.doctype}: ${label} ${entries.join("; ")}`);
|
|
1960
|
+
};
|
|
1961
|
+
bucket("identity drift", drift.identityDrift);
|
|
1962
|
+
bucket("component drift", drift.componentDrift);
|
|
1963
|
+
bucket("required drift", drift.requiredDrift);
|
|
1964
|
+
bucket("authored fields with no schema field:", drift.orphan);
|
|
1965
|
+
bucket("schema fields not modelled:", drift.omitted);
|
|
1966
|
+
return lines;
|
|
1967
|
+
}
|
|
1968
|
+
//#endregion
|
|
1969
|
+
//#region src/converter/index.ts
|
|
1970
|
+
/**
|
|
1971
|
+
* GraphQL Introspection to Stonecrop Schema Converter
|
|
1972
|
+
*
|
|
1973
|
+
* Converts a standard GraphQL introspection result (or SDL string) into
|
|
1974
|
+
* Stonecrop doctype schemas. Source-agnostic — works with any GraphQL server.
|
|
1975
|
+
*
|
|
1976
|
+
* @packageDocumentation
|
|
1977
|
+
*/
|
|
1978
|
+
/**
|
|
1979
|
+
* Convert a GraphQL schema to Stonecrop doctype schemas.
|
|
1980
|
+
*
|
|
1981
|
+
* Accepts either an `IntrospectionQuery` result object or an SDL string.
|
|
1982
|
+
* Entity types are identified using heuristics (or a custom `isEntityType` function)
|
|
1983
|
+
* and converted to `DoctypeMeta`-compatible JSON objects.
|
|
1984
|
+
*
|
|
1985
|
+
* @param source - GraphQL introspection result or SDL string
|
|
1986
|
+
* @param options - Conversion options for controlling output format and behavior
|
|
1987
|
+
* @returns Array of converted Stonecrop doctype definitions
|
|
1988
|
+
*
|
|
1989
|
+
* @example
|
|
1990
|
+
* ```typescript
|
|
1991
|
+
* // From introspection result (fetched from any GraphQL server)
|
|
1992
|
+
* const introspection = await fetchIntrospection('http://localhost:5000/graphql')
|
|
1993
|
+
* const doctypes = convertGraphQLSchema(introspection)
|
|
1994
|
+
*
|
|
1995
|
+
* // From SDL string
|
|
1996
|
+
* const sdl = fs.readFileSync('schema.graphql', 'utf-8')
|
|
1997
|
+
* const doctypes = convertGraphQLSchema(sdl)
|
|
1998
|
+
*
|
|
1999
|
+
* // With PostGraphile custom scalars
|
|
2000
|
+
* const doctypes = convertGraphQLSchema(introspection, {
|
|
2001
|
+
* customScalars: {
|
|
2002
|
+
* BigFloat: { component: 'ANumericInput' }
|
|
2003
|
+
* }
|
|
2004
|
+
* })
|
|
2005
|
+
* ```
|
|
2006
|
+
*
|
|
2007
|
+
* @public
|
|
2008
|
+
*/
|
|
2009
|
+
function convertGraphQLSchema(source, options = {}) {
|
|
2010
|
+
const schema = buildGraphQLSchema(source);
|
|
2011
|
+
const typeMap = schema.getTypeMap();
|
|
2012
|
+
const rootTypeNames = /* @__PURE__ */ new Set();
|
|
2013
|
+
const queryType = schema.getQueryType();
|
|
2014
|
+
const mutationType = schema.getMutationType();
|
|
2015
|
+
const subscriptionType = schema.getSubscriptionType();
|
|
2016
|
+
if (queryType) rootTypeNames.add(queryType.name);
|
|
2017
|
+
if (mutationType) rootTypeNames.add(mutationType.name);
|
|
2018
|
+
if (subscriptionType) rootTypeNames.add(subscriptionType.name);
|
|
2019
|
+
const isEntityType = options.isEntityType ?? defaultIsEntityType;
|
|
2020
|
+
const entityTypes = /* @__PURE__ */ new Set();
|
|
2021
|
+
for (const [typeName, type] of Object.entries(typeMap)) {
|
|
2022
|
+
if (!isObjectType(type)) continue;
|
|
2023
|
+
if (rootTypeNames.has(typeName)) continue;
|
|
2024
|
+
if (isEntityType(typeName, type)) entityTypes.add(typeName);
|
|
2025
|
+
}
|
|
2026
|
+
let filteredEntityTypes = entityTypes;
|
|
2027
|
+
if (options.include) {
|
|
2028
|
+
const includeSet = new Set(options.include);
|
|
2029
|
+
filteredEntityTypes = new Set([...entityTypes].filter((t) => includeSet.has(t)));
|
|
2030
|
+
}
|
|
2031
|
+
if (options.exclude) {
|
|
2032
|
+
const excludeSet = new Set(options.exclude);
|
|
2033
|
+
filteredEntityTypes = new Set([...filteredEntityTypes].filter((t) => !excludeSet.has(t)));
|
|
2034
|
+
}
|
|
2035
|
+
const isEntityField = options.isEntityField ?? defaultIsEntityField;
|
|
2036
|
+
const doctypes = [];
|
|
2037
|
+
for (const typeName of filteredEntityTypes) {
|
|
2038
|
+
const type = typeMap[typeName];
|
|
2039
|
+
if (!isObjectType(type)) continue;
|
|
2040
|
+
const fields = type.getFields();
|
|
2041
|
+
const isUnnormalizedPostGraphile = "id" in fields && "rowId" in fields;
|
|
2042
|
+
if (isUnnormalizedPostGraphile) options.onWarning?.(`${typeName}: schema exposes both 'id' (Relay identifier) and 'rowId' (the real column). Skipping 'id' and emitting 'rowId' verbatim — no primary key can be derived. Override the '_attributeName' and 'nodeIdFieldName' inflectors so the column keeps its own name.`);
|
|
2043
|
+
const allClassifiedFields = Object.entries(fields).filter(([fieldName, field]) => isEntityField(fieldName, field, type) && !(isUnnormalizedPostGraphile && fieldName === "id")).map(([fieldName, field]) => {
|
|
2044
|
+
if (options.classifyField) {
|
|
2045
|
+
const custom = options.classifyField(fieldName, field, type);
|
|
2046
|
+
if (custom !== null && custom !== void 0) return {
|
|
2047
|
+
kind: "field",
|
|
2048
|
+
fieldname: fieldName,
|
|
2049
|
+
label: custom.label ?? fieldName,
|
|
2050
|
+
component: custom.component ?? "ATextInput",
|
|
2051
|
+
...custom
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
return classifyFieldType(fieldName, field, entityTypes, options);
|
|
2055
|
+
});
|
|
2056
|
+
const primaryKeyFieldname = allClassifiedFields.find((field) => field.fieldname === "id" && field.required && !field.doctype && !field._isLink)?.fieldname;
|
|
2057
|
+
const links = {};
|
|
2058
|
+
const convertedFields = allClassifiedFields.filter((field) => {
|
|
2059
|
+
if (field._isLink && field.doctype && field.cardinality) {
|
|
2060
|
+
links[field.fieldname] = {
|
|
2061
|
+
target: field.doctype,
|
|
2062
|
+
cardinality: field.cardinality
|
|
2063
|
+
};
|
|
2064
|
+
return false;
|
|
2065
|
+
}
|
|
2066
|
+
return true;
|
|
2067
|
+
}).map((field) => {
|
|
2068
|
+
const identity = field.fieldname === primaryKeyFieldname ? { primaryKey: true } : {};
|
|
2069
|
+
if (!options.includeUnmappedMeta) {
|
|
2070
|
+
const { _graphqlType, _unmapped, _isLink, ...clean } = field;
|
|
2071
|
+
return Object.assign(clean, identity, { source: "introspected" });
|
|
2072
|
+
}
|
|
2073
|
+
const { _isLink, ...rest } = field;
|
|
2074
|
+
return Object.assign(rest, identity, { source: "introspected" });
|
|
2075
|
+
});
|
|
2076
|
+
const doctypeName = options.doctypeNames?.[typeName] ?? typeName;
|
|
2077
|
+
const doctype = {
|
|
2078
|
+
name: doctypeName,
|
|
2079
|
+
slug: toSlug(doctypeName),
|
|
2080
|
+
fields: convertedFields
|
|
2081
|
+
};
|
|
2082
|
+
if (Object.keys(links).length > 0) doctype.links = links;
|
|
2083
|
+
if (options.includeUnmappedMeta) doctype._graphqlTypeName = typeName;
|
|
2084
|
+
doctypes.push(doctype);
|
|
2085
|
+
}
|
|
2086
|
+
return doctypes;
|
|
2087
|
+
}
|
|
2088
|
+
/**
|
|
2089
|
+
* Build a GraphQLSchema from either an introspection result or SDL string.
|
|
2090
|
+
*
|
|
2091
|
+
* @param source - IntrospectionQuery object or SDL string
|
|
2092
|
+
* @returns A complete GraphQLSchema
|
|
2093
|
+
* @internal
|
|
2094
|
+
*/
|
|
2095
|
+
function buildGraphQLSchema(source) {
|
|
2096
|
+
if (typeof source === "string") return buildSchema(source);
|
|
2097
|
+
return buildClientSchema(source);
|
|
2098
|
+
}
|
|
2099
|
+
//#endregion
|
|
2100
|
+
export { snakeToCamel as A, getPrimaryKeyField as B, WorkflowMeta as C, camelToLabel as D, linkDisplayFieldname as E, FieldsetFieldSchema as F, stripFieldKind as G, getRecordIdentity as H, INTROSPECTED_IDENTITY_PROPS as I, TableViewConfig as K, TableFieldSchema as L, toPascalCase as M, toSlug as N, camelToSnake as O, DoctypeFieldSchema as P, ValueFieldSchema as R, WorkflowLayout as S, isActionAllowedInState as T, inferFieldKind as U, getRecordIdField as V, normalizeFieldKind as W, validateDoctype as _, aggregateDoctypeName as a, LINK_DISPLAY_SUFFIX as b, classifyFieldType as c, GQL_SCALAR_MAP as d, INTERNAL_SCALARS as f, parseField as g, parseDoctype as h, authoredPrimaryKey as i, snakeToLabel as j, pascalToSnake as k, defaultIsEntityField as l, buildScalarMap as m, formatDoctypeDrift as n, buildAggregateDoctype as o, WELL_KNOWN_SCALARS as p, mergeIntrospectedDoctype as r, planGeneration as s, convertGraphQLSchema as t, defaultIsEntityType as u, validateField as v, getDoctypeSlug as w, TriggerDefinition as x, ActionDefinition as y, getDisplayField as z };
|
|
2101
|
+
|
|
2102
|
+
//# sourceMappingURL=converter-CLwduvT_.js.map
|