@stonecrop/schema 0.10.15 → 0.11.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.
Files changed (81) hide show
  1. package/README.md +87 -26
  2. package/dist/cli.cjs +41 -0
  3. package/dist/cli.cjs.map +1 -0
  4. package/dist/cli.js +1 -1
  5. package/dist/converter/heuristics.js +8 -6
  6. package/dist/converter/index.js +20 -3
  7. package/dist/converter-DNwpowpq.js +459 -0
  8. package/dist/converter-DNwpowpq.js.map +1 -0
  9. package/dist/doctype.js +90 -5
  10. package/dist/field.js +5 -3
  11. package/dist/fieldtype.js +0 -2
  12. package/dist/index-0qWNlDQ5.js +450 -0
  13. package/dist/index-0qWNlDQ5.js.map +1 -0
  14. package/dist/index-2UVTfbcY.js +448 -0
  15. package/dist/index-2UVTfbcY.js.map +1 -0
  16. package/dist/index-9QjWZ_PC.js +449 -0
  17. package/dist/index-9QjWZ_PC.js.map +1 -0
  18. package/dist/index-BCADNO5M.js +449 -0
  19. package/dist/index-BCADNO5M.js.map +1 -0
  20. package/dist/index-BRQJGVFR.js +453 -0
  21. package/dist/index-BRQJGVFR.js.map +1 -0
  22. package/dist/index-BatnoC-J.js +429 -0
  23. package/dist/index-BatnoC-J.js.map +1 -0
  24. package/dist/index-BhMd2_xl.js +454 -0
  25. package/dist/index-BhMd2_xl.js.map +1 -0
  26. package/dist/index-CLc5mUMQ.js +478 -0
  27. package/dist/index-CLc5mUMQ.js.map +1 -0
  28. package/dist/index-COrltkHl.js +401 -0
  29. package/dist/index-COrltkHl.js.map +1 -0
  30. package/dist/index-CvN9xK1B.js +453 -0
  31. package/dist/index-CvN9xK1B.js.map +1 -0
  32. package/dist/index-CzoRIy1-.js +408 -0
  33. package/dist/index-CzoRIy1-.js.map +1 -0
  34. package/dist/index-DIb_Z0wI.js +476 -0
  35. package/dist/index-DIb_Z0wI.js.map +1 -0
  36. package/dist/index-DNROIEMe.js +449 -0
  37. package/dist/index-DNROIEMe.js.map +1 -0
  38. package/dist/index-DUFcQC-H.js +449 -0
  39. package/dist/index-DUFcQC-H.js.map +1 -0
  40. package/dist/index-DmD8vmuB.js +449 -0
  41. package/dist/index-DmD8vmuB.js.map +1 -0
  42. package/dist/index-XZZbfFxT.js +434 -0
  43. package/dist/index-XZZbfFxT.js.map +1 -0
  44. package/dist/index-aeXXzPET.cjs +2 -0
  45. package/dist/index-aeXXzPET.cjs.map +1 -0
  46. package/dist/index-neVkjlgB.js +446 -0
  47. package/dist/index-neVkjlgB.js.map +1 -0
  48. package/dist/index-xdlVWldg.js +408 -0
  49. package/dist/index-xdlVWldg.js.map +1 -0
  50. package/dist/index.cjs +2 -0
  51. package/dist/index.cjs.map +1 -0
  52. package/dist/index.js +23 -29
  53. package/dist/jsonschema.js +225 -0
  54. package/dist/schema.d.ts +206 -35
  55. package/dist/src/cli.js +210 -0
  56. package/dist/src/converter/heuristics.d.ts.map +1 -1
  57. package/dist/src/converter/heuristics.js +256 -0
  58. package/dist/src/converter/index.d.ts.map +1 -1
  59. package/dist/src/converter/index.js +164 -0
  60. package/dist/src/converter/scalars.js +86 -0
  61. package/dist/src/converter/types.d.ts +2 -0
  62. package/dist/src/converter/types.d.ts.map +1 -1
  63. package/dist/src/converter/types.js +5 -0
  64. package/dist/src/doctype.d.ts +176 -12
  65. package/dist/src/doctype.d.ts.map +1 -1
  66. package/dist/src/doctype.js +67 -0
  67. package/dist/src/field.d.ts +3 -2
  68. package/dist/src/field.d.ts.map +1 -1
  69. package/dist/src/field.js +108 -0
  70. package/dist/src/fieldtype.d.ts +0 -1
  71. package/dist/src/fieldtype.d.ts.map +1 -1
  72. package/dist/src/fieldtype.js +75 -0
  73. package/dist/src/index.d.ts +5 -9
  74. package/dist/src/index.d.ts.map +1 -1
  75. package/dist/src/index.js +12 -0
  76. package/dist/src/jsonschema.d.ts +95 -0
  77. package/dist/src/jsonschema.d.ts.map +1 -0
  78. package/dist/src/naming.js +106 -0
  79. package/dist/src/tsdoc-metadata.json +11 -0
  80. package/dist/src/validation.js +60 -0
  81. package/package.json +1 -1
package/README.md CHANGED
@@ -86,7 +86,7 @@ const decimalField: FieldMeta = {
86
86
 
87
87
  ### Doctype Metadata
88
88
 
89
- `DoctypeMeta` defines a complete doctype with fields, workflow, and inheritance:
89
+ `DoctypeMeta` defines a complete doctype with fields, links, workflow, and inheritance:
90
90
 
91
91
  ```typescript
92
92
  import { DoctypeMeta } from '@stonecrop/schema'
@@ -105,11 +105,19 @@ const doctype: DoctypeMeta = {
105
105
  },
106
106
  {
107
107
  fieldname: 'items',
108
- fieldtype: 'Doctype',
109
- label: 'Order Items',
110
- options: 'sales-order-item', // Child doctype
108
+ fieldtype: 'Link',
109
+ label: 'Items',
110
+ options: 'sales-order-item',
111
111
  },
112
112
  ],
113
+ links: {
114
+ items: {
115
+ target: 'sales-order-item',
116
+ cardinality: 'noneOrMany',
117
+ backlink: 'sales_order',
118
+ fieldname: 'items',
119
+ },
120
+ },
113
121
  workflow: {
114
122
  states: ['Draft', 'Submitted', 'Cancelled'],
115
123
  actions: {
@@ -124,6 +132,35 @@ const doctype: DoctypeMeta = {
124
132
  }
125
133
  ```
126
134
 
135
+ ### Link Declarations
136
+
137
+ `links` on `DoctypeMeta` declares relationships to other doctypes. Each link has a `target`, `cardinality`, and optional `backlink`:
138
+
139
+ ```typescript
140
+ import { LinkDeclaration, Cardinality } from '@stonecrop/schema'
141
+
142
+ // Cardinality values:
143
+ // 'one' — exactly 1 (required pointer)
144
+ // 'atMostOne' — 0 or 1 (optional pointer)
145
+ // 'noneOrMany' — 0 or more (optional collection)
146
+ // 'atLeastOne' — 1 or more (required collection)
147
+
148
+ const links: Record<string, LinkDeclaration> = {
149
+ // 1:many — ancestor has descendants
150
+ tasks: {
151
+ target: 'recipe-task',
152
+ cardinality: 'noneOrMany',
153
+ backlink: 'recipe', // fieldname on recipe-task that points back
154
+ },
155
+ // Self-referential — version lineage
156
+ supersededBy: {
157
+ target: 'recipe',
158
+ cardinality: 'atMostOne',
159
+ backlink: 'supersededBy',
160
+ },
161
+ }
162
+ ```
163
+
127
164
  ### Workflow and Actions
128
165
 
129
166
  Define state machines and actions for doctypes:
@@ -151,6 +188,36 @@ const workflow: WorkflowMeta = {
151
188
  }
152
189
  ```
153
190
 
191
+ ## Client Interfaces
192
+
193
+ `DataClient` is the interface that any data transport must implement. `GetRecordOptions` and `GetRecordsOptions` are the option types:
194
+
195
+ ```typescript
196
+ import type { DataClient, GetRecordOptions, GetRecordsOptions } from '@stonecrop/schema'
197
+
198
+ // Fetch a record — with optional nested link sub-selections
199
+ const record = await client.getRecord({ name: 'Recipe' }, 'r1', {
200
+ includeNested: true, // fetch all descendant links
201
+ maxDepth: 2, // limit recursion depth
202
+ })
203
+
204
+ // Fetch only specific links
205
+ const record = await client.getRecord({ name: 'Recipe' }, 'r1', {
206
+ includeNested: ['tasks'], // fetch only the tasks link
207
+ })
208
+
209
+ // Fetch multiple records
210
+ const records = await client.getRecords(
211
+ { name: 'Recipe' },
212
+ {
213
+ filters: { status: 'Active' },
214
+ orderBy: 'name',
215
+ limit: 20,
216
+ offset: 0,
217
+ }
218
+ )
219
+ ```
220
+
154
221
  ## Validation
155
222
 
156
223
  Runtime validation with detailed error reporting:
@@ -237,18 +304,18 @@ then `--exclude` removes any remaining unwanted names.
237
304
 
238
305
  ### All options
239
306
 
240
- | Flag | Short | Description |
241
- |------|-------|-------------|
242
- | `--endpoint <url>` | `-e` | Fetch introspection from a live GraphQL endpoint |
243
- | `--introspection <file>` | `-i` | Read from a saved introspection JSON file |
244
- | `--sdl <file>` | `-s` | Read from a GraphQL SDL (`.graphql`) file |
245
- | `--output <dir>` | `-o` | Directory to write doctype JSON files (required) |
246
- | `--include <types>` | | Comma-separated allowlist of type names to generate |
247
- | `--exclude <types>` | | Comma-separated list of type names to skip |
248
- | `--overrides <file>` | | JSON file with per-type, per-field overrides |
249
- | `--custom-scalars <file>` | | JSON file mapping custom scalar names to field templates |
250
- | `--include-unmapped` | | Retain `_graphqlType` metadata on fields with no mapping |
251
- | `--help` | `-h` | Show help |
307
+ | Flag | Short | Description |
308
+ | ------------------------- | ----- | -------------------------------------------------------- |
309
+ | `--endpoint <url>` | `-e` | Fetch introspection from a live GraphQL endpoint |
310
+ | `--introspection <file>` | `-i` | Read from a saved introspection JSON file |
311
+ | `--sdl <file>` | `-s` | Read from a GraphQL SDL (`.graphql`) file |
312
+ | `--output <dir>` | `-o` | Directory to write doctype JSON files (required) |
313
+ | `--include <types>` | | Comma-separated allowlist of type names to generate |
314
+ | `--exclude <types>` | | Comma-separated list of type names to skip |
315
+ | `--overrides <file>` | | JSON file with per-type, per-field overrides |
316
+ | `--custom-scalars <file>` | | JSON file mapping custom scalar names to field templates |
317
+ | `--include-unmapped` | | Retain `_graphqlType` metadata on fields with no mapping |
318
+ | `--help` | `-h` | Show help |
252
319
 
253
320
  ### Custom scalars
254
321
 
@@ -258,7 +325,7 @@ a JSON mapping file:
258
325
  ```json
259
326
  {
260
327
  "BigFloat": { "component": "ADecimalInput", "fieldtype": "Decimal" },
261
- "Datetime": { "component": "ADatetimeInput", "fieldtype": "Datetime" }
328
+ "Datetime": { "component": "ADatetimeInput", "fieldtype": "Datetime" }
262
329
  }
263
330
  ```
264
331
 
@@ -332,14 +399,7 @@ doctypes.forEach(doctype => {
332
399
  Convert between different naming conventions:
333
400
 
334
401
  ```typescript
335
- import {
336
- snakeToCamel,
337
- camelToSnake,
338
- snakeToLabel,
339
- camelToLabel,
340
- toPascalCase,
341
- toSlug,
342
- } from '@stonecrop/schema'
402
+ import { snakeToCamel, camelToSnake, snakeToLabel, camelToLabel, toPascalCase, toSlug } from '@stonecrop/schema'
343
403
 
344
404
  snakeToCamel('customer_name') // 'customerName'
345
405
  camelToSnake('customerName') // 'customer_name'
@@ -367,7 +427,8 @@ console.log(TYPE_MAP['Link']) // { component: 'ALink', fieldtype: 'Link' }
367
427
 
368
428
  This package provides the type system used throughout Stonecrop:
369
429
 
370
- - **`@stonecrop/stonecrop`** - Registry uses `DoctypeMeta` for schema storage
430
+ - **`@stonecrop/stonecrop`** - Registry uses `DoctypeMeta` for schema storage; `getDescendantLinks()` / `getAncestorLinks()` for relationship traversal
431
+ - **`@stonecrop/graphql-client`** - `StonecropClient` implements `DataClient`; uses `GetRecordOptions` / `GetRecordsOptions` for fetch parameters
371
432
  - **`@stonecrop/aform`** - Renders fields based on `FieldMeta` definitions
372
433
  - **`@stonecrop/atable`** - Uses `FieldMeta` for column configuration
373
434
  - **Backend APIs** - Validates and stores doctypes using these schemas
package/dist/cli.cjs ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ "use strict";const r=require("node:fs"),c=require("node:path"),w=require("node:util"),x=require("graphql"),v=require("./index-aeXXzPET.cjs");async function O(e,h){const t=await fetch(e,{method:"POST",headers:{"Content-Type":"application/json",...h},body:JSON.stringify({query:x.getIntrospectionQuery()})});if(!t.ok)throw new Error(`Failed to fetch introspection: ${t.status} ${t.statusText}`);const a=await t.json();if(a.errors?.length)throw new Error(`GraphQL errors: ${a.errors.map(n=>n.message).join(", ")}`);if(!a.data)throw new Error("No data in introspection response");return a.data}async function $(){const{values:e,positionals:h}=w.parseArgs({allowPositionals:!0,options:{endpoint:{type:"string",short:"e"},introspection:{type:"string",short:"i"},sdl:{type:"string",short:"s"},output:{type:"string",short:"o"},include:{type:"string"},exclude:{type:"string"},overrides:{type:"string"},"custom-scalars":{type:"string"},"include-unmapped":{type:"boolean",default:!1},help:{type:"boolean",short:"h"}}}),t=h[0];(e.help||!t)&&(q(),process.exit(t?0:1)),t!=="generate"&&(console.error(`Unknown command: ${t}`),console.error("Available commands: generate"),process.exit(1)),[e.endpoint,e.introspection,e.sdl].filter(Boolean).length!==1&&(console.error("Exactly one of --endpoint, --introspection, or --sdl must be provided"),process.exit(1)),e.output||(console.error("--output <dir> is required"),process.exit(1));const n=c.resolve(e.output),l={includeUnmappedMeta:e["include-unmapped"]};if(e.include&&(l.include=e.include.split(",").map(o=>o.trim())),e.exclude&&(l.exclude=e.exclude.split(",").map(o=>o.trim())),e.overrides){const o=c.resolve(e.overrides),s=r.readFileSync(o,"utf-8");l.typeOverrides=JSON.parse(s)}if(e["custom-scalars"]){const o=c.resolve(e["custom-scalars"]),s=r.readFileSync(o,"utf-8");l.customScalars=JSON.parse(s)}let p;if(e.endpoint)console.log(`Fetching introspection from ${e.endpoint}...`),p=await O(e.endpoint);else if(e.introspection){const o=c.resolve(e.introspection),s=r.readFileSync(o,"utf-8"),u=JSON.parse(s);p=u.data??u}else{const o=c.resolve(e.sdl);p=r.readFileSync(o,"utf-8")}const m=v.convertGraphQLSchema(p,l);m.length===0&&(console.warn("No entity types found in the schema. Check your include/exclude filters."),process.exit(0)),r.existsSync(n)||r.mkdirSync(n,{recursive:!0});let f=0,d=0;for(const o of m){const s=`${o.slug}.json`,u=c.join(n,s),S=JSON.stringify(o,null," ");r.writeFileSync(u,S+`
3
+ `,"utf-8");const g=v.validateDoctype(o);if(g.success){const i=o.fields.filter(y=>y._unmapped);i.length>0&&(f++,console.warn(` WARN: ${s} has ${i.length} unmapped field(s): ${i.map(y=>y.fieldname).join(", ")}`))}else{d++,console.error(` ERROR: ${s} failed validation:`);for(const i of g.errors)console.error(` ${i.path.join(".")}: ${i.message}`)}}console.log(`
4
+ Generated ${m.length} doctype(s) in ${n}`+(f?` (${f} with warnings)`:"")+(d?` (${d} with errors)`:"")),d>0&&process.exit(1)}function q(){console.log(`
5
+ stonecrop-schema - Convert GraphQL schemas to Stonecrop doctypes
6
+
7
+ USAGE:
8
+ stonecrop-schema generate [options]
9
+
10
+ SOURCE (exactly one required):
11
+ --endpoint, -e <url> Fetch introspection from a live GraphQL endpoint
12
+ --introspection, -i <file> Read from a saved introspection JSON file
13
+ --sdl, -s <file> Read from a GraphQL SDL (.graphql) file
14
+
15
+ OUTPUT:
16
+ --output, -o <dir> Directory to write doctype JSON files (required)
17
+
18
+ OPTIONS:
19
+ --include <types> Comma-separated list of type names to include
20
+ --exclude <types> Comma-separated list of type names to exclude
21
+ --overrides <file> JSON file with per-type field overrides
22
+ --custom-scalars <file> JSON file mapping custom scalar names to field templates
23
+ --include-unmapped Include _graphqlType metadata on unmapped fields
24
+ --help, -h Show this help message
25
+
26
+ EXAMPLES:
27
+ # From a live PostGraphile server
28
+ stonecrop-schema generate -e http://localhost:5000/graphql -o ./schemas
29
+
30
+ # From a saved introspection result
31
+ stonecrop-schema generate -i introspection.json -o ./schemas
32
+
33
+ # From an SDL file with custom scalars
34
+ stonecrop-schema generate -s schema.graphql -o ./schemas \\
35
+ --custom-scalars custom-scalars.json
36
+
37
+ # Only convert specific types
38
+ stonecrop-schema generate -e http://localhost:5000/graphql -o ./schemas \\
39
+ --include "User,Post,Comment"
40
+ `)}$().catch(e=>{console.error("Error:",e.message),process.exit(1)});
41
+ //# sourceMappingURL=cli.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.cjs","sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * Stonecrop Schema CLI\n *\n * Converts GraphQL introspection results to Stonecrop doctype JSON schemas.\n *\n * Usage:\n * stonecrop-schema generate --endpoint <url> --output <dir>\n * stonecrop-schema generate --introspection <file.json> --output <dir>\n * stonecrop-schema generate --sdl <file.graphql> --output <dir>\n *\n * @packageDocumentation\n */\n\nimport { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'\nimport { resolve, join } from 'node:path'\nimport { parseArgs } from 'node:util'\nimport { getIntrospectionQuery, type IntrospectionQuery } from 'graphql'\n\nimport { convertGraphQLSchema } from './converter/index'\nimport { validateDoctype } from './validation'\nimport type { GraphQLConversionOptions } from './converter/types'\n\n/**\n * Fetch an introspection result from a live GraphQL endpoint.\n *\n * @param endpoint - The GraphQL endpoint URL\n * @param headers - Optional HTTP headers\n * @returns The introspection query result\n */\nasync function fetchIntrospection(endpoint: string, headers?: Record<string, string>): Promise<IntrospectionQuery> {\n\tconst response = await fetch(endpoint, {\n\t\tmethod: 'POST',\n\t\theaders: {\n\t\t\t'Content-Type': 'application/json',\n\t\t\t...headers,\n\t\t},\n\t\tbody: JSON.stringify({\n\t\t\tquery: getIntrospectionQuery(),\n\t\t}),\n\t})\n\n\tif (!response.ok) {\n\t\tthrow new Error(`Failed to fetch introspection: ${response.status} ${response.statusText}`)\n\t}\n\n\tconst json = (await response.json()) as {\n\t\tdata?: IntrospectionQuery\n\t\terrors?: Array<{ message: string }>\n\t}\n\n\tif (json.errors?.length) {\n\t\tthrow new Error(`GraphQL errors: ${json.errors.map(e => e.message).join(', ')}`)\n\t}\n\n\tif (!json.data) {\n\t\tthrow new Error('No data in introspection response')\n\t}\n\n\treturn json.data\n}\n\nasync function main(): Promise<void> {\n\tconst { values, positionals } = parseArgs({\n\t\tallowPositionals: true,\n\t\toptions: {\n\t\t\tendpoint: { type: 'string', short: 'e' },\n\t\t\tintrospection: { type: 'string', short: 'i' },\n\t\t\tsdl: { type: 'string', short: 's' },\n\t\t\toutput: { type: 'string', short: 'o' },\n\t\t\tinclude: { type: 'string' },\n\t\t\texclude: { type: 'string' },\n\t\t\toverrides: { type: 'string' },\n\t\t\t'custom-scalars': { type: 'string' },\n\t\t\t'include-unmapped': { type: 'boolean', default: false },\n\t\t\thelp: { type: 'boolean', short: 'h' },\n\t\t},\n\t})\n\n\tconst command = positionals[0]\n\n\tif (values.help || !command) {\n\t\tprintHelp()\n\t\tprocess.exit(command ? 0 : 1)\n\t}\n\n\tif (command !== 'generate') {\n\t\tconsole.error(`Unknown command: ${command}`)\n\t\tconsole.error('Available commands: generate')\n\t\tprocess.exit(1)\n\t}\n\n\t// Determine source\n\tconst sourceCount = [values.endpoint, values.introspection, values.sdl].filter(Boolean).length\n\tif (sourceCount !== 1) {\n\t\tconsole.error('Exactly one of --endpoint, --introspection, or --sdl must be provided')\n\t\tprocess.exit(1)\n\t}\n\n\tif (!values.output) {\n\t\tconsole.error('--output <dir> is required')\n\t\tprocess.exit(1)\n\t}\n\n\tconst outputDir = resolve(values.output)\n\n\t// Build conversion options\n\tconst options: GraphQLConversionOptions = {\n\t\tincludeUnmappedMeta: values['include-unmapped'],\n\t}\n\n\tif (values.include) {\n\t\toptions.include = values.include.split(',').map(s => s.trim())\n\t}\n\n\tif (values.exclude) {\n\t\toptions.exclude = values.exclude.split(',').map(s => s.trim())\n\t}\n\n\tif (values.overrides) {\n\t\tconst overridesPath = resolve(values.overrides)\n\t\tconst overridesContent = readFileSync(overridesPath, 'utf-8')\n\t\toptions.typeOverrides = JSON.parse(overridesContent)\n\t}\n\n\tif (values['custom-scalars']) {\n\t\tconst scalarsPath = resolve(values['custom-scalars'])\n\t\tconst scalarsContent = readFileSync(scalarsPath, 'utf-8')\n\t\toptions.customScalars = JSON.parse(scalarsContent)\n\t}\n\n\t// Resolve source\n\tlet source: IntrospectionQuery | string\n\n\tif (values.endpoint) {\n\t\tconsole.log(`Fetching introspection from ${values.endpoint}...`)\n\t\tsource = await fetchIntrospection(values.endpoint)\n\t} else if (values.introspection) {\n\t\tconst filePath = resolve(values.introspection)\n\t\tconst content = readFileSync(filePath, 'utf-8')\n\t\tconst parsed = JSON.parse(content)\n\t\t// Handle both { data: { __schema: ... } } and { __schema: ... } formats\n\t\tsource = parsed.data ?? parsed\n\t} else {\n\t\tconst filePath = resolve(values.sdl!)\n\t\tsource = readFileSync(filePath, 'utf-8')\n\t}\n\n\t// Convert\n\tconst doctypes = convertGraphQLSchema(source, options)\n\n\tif (doctypes.length === 0) {\n\t\tconsole.warn('No entity types found in the schema. Check your include/exclude filters.')\n\t\tprocess.exit(0)\n\t}\n\n\t// Write output\n\tif (!existsSync(outputDir)) {\n\t\tmkdirSync(outputDir, { recursive: true })\n\t}\n\n\tlet warnings = 0\n\tlet errors = 0\n\n\tfor (const doctype of doctypes) {\n\t\tconst fileName = `${doctype.slug}.json`\n\t\tconst filePath = join(outputDir, fileName)\n\t\tconst json = JSON.stringify(doctype, null, '\\t')\n\n\t\twriteFileSync(filePath, json + '\\n', 'utf-8')\n\n\t\t// Validate the output\n\t\tconst validation = validateDoctype(doctype)\n\t\tif (!validation.success) {\n\t\t\terrors++\n\t\t\tconsole.error(` ERROR: ${fileName} failed validation:`)\n\t\t\tfor (const err of validation.errors) {\n\t\t\t\tconsole.error(` ${err.path.join('.')}: ${err.message}`)\n\t\t\t}\n\t\t} else {\n\t\t\t// Check for unmapped fields\n\t\t\tconst unmappedFields = doctype.fields.filter((f: any) => f._unmapped)\n\t\t\tif (unmappedFields.length > 0) {\n\t\t\t\twarnings++\n\t\t\t\tconsole.warn(\n\t\t\t\t\t` WARN: ${fileName} has ${unmappedFields.length} unmapped field(s): ${unmappedFields\n\t\t\t\t\t\t.map((f: any) => f.fieldname)\n\t\t\t\t\t\t.join(', ')}`\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n\n\tconsole.log(\n\t\t`\\nGenerated ${doctypes.length} doctype(s) in ${outputDir}` +\n\t\t\t(warnings ? ` (${warnings} with warnings)` : '') +\n\t\t\t(errors ? ` (${errors} with errors)` : '')\n\t)\n\n\tif (errors > 0) {\n\t\tprocess.exit(1)\n\t}\n}\n\nfunction printHelp(): void {\n\tconsole.log(`\nstonecrop-schema - Convert GraphQL schemas to Stonecrop doctypes\n\nUSAGE:\n stonecrop-schema generate [options]\n\nSOURCE (exactly one required):\n --endpoint, -e <url> Fetch introspection from a live GraphQL endpoint\n --introspection, -i <file> Read from a saved introspection JSON file\n --sdl, -s <file> Read from a GraphQL SDL (.graphql) file\n\nOUTPUT:\n --output, -o <dir> Directory to write doctype JSON files (required)\n\nOPTIONS:\n --include <types> Comma-separated list of type names to include\n --exclude <types> Comma-separated list of type names to exclude\n --overrides <file> JSON file with per-type field overrides\n --custom-scalars <file> JSON file mapping custom scalar names to field templates\n --include-unmapped Include _graphqlType metadata on unmapped fields\n --help, -h Show this help message\n\nEXAMPLES:\n # From a live PostGraphile server\n stonecrop-schema generate -e http://localhost:5000/graphql -o ./schemas\n\n # From a saved introspection result\n stonecrop-schema generate -i introspection.json -o ./schemas\n\n # From an SDL file with custom scalars\n stonecrop-schema generate -s schema.graphql -o ./schemas \\\\\n --custom-scalars custom-scalars.json\n\n # Only convert specific types\n stonecrop-schema generate -e http://localhost:5000/graphql -o ./schemas \\\\\n --include \"User,Post,Comment\"\n`)\n}\n\nmain().catch(err => {\n\tconsole.error('Error:', err.message)\n\tprocess.exit(1)\n})\n"],"names":["fetchIntrospection","endpoint","headers","response","getIntrospectionQuery","json","e","main","values","positionals","parseArgs","command","printHelp","outputDir","resolve","options","s","overridesPath","overridesContent","readFileSync","scalarsPath","scalarsContent","source","filePath","content","parsed","doctypes","convertGraphQLSchema","existsSync","mkdirSync","warnings","errors","doctype","fileName","join","writeFileSync","validation","validateDoctype","unmappedFields","f","err"],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { readFileSync as u, existsSync as S, mkdirSync as w, writeFileSync as x
3
3
  import { resolve as c, join as O } from "node:path";
4
4
  import { parseArgs as $ } from "node:util";
5
5
  import { getIntrospectionQuery as N } from "graphql";
6
- import { h as P, v as j } from "./index-D6Up-BP5.js";
6
+ import { e as P, v as j } from "./index-CLc5mUMQ.js";
7
7
  async function C(e, m) {
8
8
  const t = await fetch(e, {
9
9
  method: "POST",
@@ -223,21 +223,23 @@ export function classifyFieldType(fieldName, field, entityTypes, options = {}) {
223
223
  base.options = toSlug(namedType.name);
224
224
  return base;
225
225
  }
226
- // 4. Connection type → Doctype (child table)
226
+ // 4. Connection type → link (child table)
227
227
  const connectionNodeTypeName = getConnectionNodeType(namedType);
228
228
  if (connectionNodeTypeName && entityTypes.has(connectionNodeTypeName)) {
229
229
  base.component = 'ATable';
230
- base.fieldtype = 'Doctype';
230
+ base._isLink = true;
231
231
  base.options = toSlug(connectionNodeTypeName);
232
- base.cardinality = 'many';
232
+ base.cardinality = 'noneOrMany';
233
+ delete base.fieldtype;
233
234
  return base;
234
235
  }
235
- // 5. List of entity type → Doctype
236
+ // 5. List of entity type → link
236
237
  if (isList && entityTypes.has(namedType.name)) {
237
238
  base.component = 'ATable';
238
- base.fieldtype = 'Doctype';
239
+ base._isLink = true;
239
240
  base.options = toSlug(namedType.name);
240
- base.cardinality = 'many';
241
+ base.cardinality = 'noneOrMany';
242
+ delete base.fieldtype;
241
243
  return base;
242
244
  }
243
245
  // Unknown object type — mark as unmapped
@@ -88,7 +88,7 @@ export function convertGraphQLSchema(source, options = {}) {
88
88
  continue;
89
89
  const fields = type.getFields();
90
90
  const typeOverrides = options.typeOverrides?.[typeName];
91
- const convertedFields = Object.entries(fields)
91
+ const allClassifiedFields = Object.entries(fields)
92
92
  .filter(([fieldName, field]) => isEntityField(fieldName, field, type))
93
93
  .map(([fieldName, field]) => {
94
94
  // Check for full custom classification first
@@ -111,20 +111,37 @@ export function convertGraphQLSchema(source, options = {}) {
111
111
  return { ...classified, ...typeOverrides[fieldName] };
112
112
  }
113
113
  return classified;
114
+ });
115
+ // Separate scalar fields from link fields
116
+ const links = {};
117
+ const convertedFields = allClassifiedFields
118
+ .filter(field => {
119
+ if (field._isLink && typeof field.options === 'string' && field.cardinality) {
120
+ links[field.fieldname] = {
121
+ target: field.options,
122
+ cardinality: field.cardinality,
123
+ };
124
+ return false;
125
+ }
126
+ return true;
114
127
  })
115
128
  // Clean up internal metadata unless requested
116
129
  .map(field => {
117
130
  if (!options.includeUnmappedMeta) {
118
- const { _graphqlType, _unmapped, ...clean } = field;
131
+ const { _graphqlType, _unmapped, _isLink, ...clean } = field;
119
132
  return clean;
120
133
  }
121
- return field;
134
+ const { _isLink, ...rest } = field;
135
+ return rest;
122
136
  });
123
137
  const doctype = {
124
138
  name: typeName,
125
139
  slug: toSlug(typeName),
126
140
  fields: convertedFields,
127
141
  };
142
+ if (Object.keys(links).length > 0) {
143
+ doctype.links = links;
144
+ }
128
145
  const tableName = deriveTableName(typeName);
129
146
  if (tableName) {
130
147
  doctype.tableName = tableName;