@stonecrop/schema 0.10.16 → 0.11.1

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 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.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;
package/dist/doctype.js CHANGED
@@ -1,5 +1,92 @@
1
1
  import { z } from 'zod';
2
2
  import { FieldMeta } from './field';
3
+ /**
4
+ * Cardinality for relationship links.
5
+ * @public
6
+ */
7
+ export const Cardinality = z.enum(['atMostOne', 'one', 'noneOrMany', 'atLeastOne']).meta({
8
+ title: 'Cardinality',
9
+ description: 'Cardinality for relationship links between doctypes',
10
+ });
11
+ /**
12
+ * Sync fetch strategy - data is fetched in the initial query.
13
+ * @public
14
+ */
15
+ export const SyncFetch = z
16
+ .object({
17
+ /** Fetch method type */
18
+ method: z.literal('sync'),
19
+ /** Optional limit on number of records to fetch */
20
+ limit: z.number().int().positive().optional(),
21
+ })
22
+ .meta({
23
+ title: 'SyncFetch',
24
+ description: 'Sync fetch strategy - data is fetched in the initial query',
25
+ });
26
+ /**
27
+ * Lazy fetch strategy - data is fetched on demand in a separate query.
28
+ * @public
29
+ */
30
+ export const LazyFetch = z
31
+ .object({
32
+ /** Fetch method type */
33
+ method: z.literal('lazy'),
34
+ })
35
+ .meta({
36
+ title: 'LazyFetch',
37
+ description: 'Lazy fetch strategy - data is fetched on demand in a separate query',
38
+ });
39
+ /**
40
+ * Custom fetch strategy - uses a custom handler function.
41
+ * @public
42
+ */
43
+ export const CustomFetch = z
44
+ .object({
45
+ /** Fetch method type */
46
+ method: z.literal('custom'),
47
+ /** Serialized handler function to invoke */
48
+ handler: z.string(),
49
+ })
50
+ .meta({
51
+ title: 'CustomFetch',
52
+ description: 'Custom fetch strategy - uses a custom handler function',
53
+ });
54
+ /**
55
+ * Fetch strategy for link data loading.
56
+ * - sync: fetched in the initial query
57
+ * - lazy: fetched on demand in a separate query
58
+ * - custom: uses a custom handler function
59
+ * @public
60
+ */
61
+ export const FetchStrategy = z.discriminatedUnion('method', [SyncFetch, LazyFetch, CustomFetch]).meta({
62
+ title: 'FetchStrategy',
63
+ description: 'Fetch strategy for link data loading',
64
+ });
65
+ /**
66
+ * Link declaration - describes a relationship from one doctype to another.
67
+ * @public
68
+ */
69
+ export const LinkDeclaration = z
70
+ .object({
71
+ /** Target doctype slug */
72
+ target: z.string().min(1),
73
+ /** Cardinality of the relationship */
74
+ cardinality: Cardinality,
75
+ /** Backlink fieldname on the target doctype that points back to this link */
76
+ backlink: z.string().optional(),
77
+ /** Override default rendering component (AForm for 1:1, ATable for 1:many) */
78
+ component: z.string().optional(),
79
+ /** Fieldname of the corresponding Link field in the fields array */
80
+ fieldname: z.string().min(1).optional(),
81
+ /** Fetch strategy for loading nested data */
82
+ fetch: FetchStrategy.optional(),
83
+ /** Whether to block workflow actions until nested data is loaded (default: true) */
84
+ blockWorkflows: z.boolean().optional(),
85
+ })
86
+ .meta({
87
+ title: 'LinkDeclaration',
88
+ description: 'Declares a relationship from one doctype to another',
89
+ });
3
90
  /**
4
91
  * Action definition within a workflow
5
92
  * @public
@@ -50,16 +137,14 @@ export const DoctypeMeta = z
50
137
  slug: z.string().min(1).optional(),
51
138
  /** Database table name */
52
139
  tableName: z.string().optional(),
53
- /** Field definitions */
140
+ /** Field definitions (including link fields with fieldtype: 'Link') */
54
141
  fields: z.array(FieldMeta),
142
+ /** Relationship links to other doctypes */
143
+ links: z.record(z.string(), LinkDeclaration).optional(),
55
144
  /** Workflow configuration */
56
145
  workflow: WorkflowMeta.optional(),
57
146
  /** Parent doctype for inheritance */
58
147
  inherits: z.string().optional(),
59
- /** Doctype to use for list views */
60
- listDoctype: z.string().optional(),
61
- /** Parent doctype for child tables */
62
- parentDoctype: z.string().optional(),
63
148
  })
64
149
  .meta({
65
150
  title: 'DoctypeMeta',
package/dist/field.js CHANGED
@@ -85,10 +85,12 @@ export const FieldMeta = z
85
85
  options: FieldOptions.optional(),
86
86
  /**
87
87
  * Cardinality for Doctype fields:
88
- * - 'one': 1:1 nested form (default)
89
- * - 'many': 1:many child table
88
+ * - 'one': exactly 1 (default)
89
+ * - 'atMostOne': 0 or 1
90
+ * - 'noneOrMany': 0 or more
91
+ * - 'atLeastOne': 1 or more
90
92
  */
91
- cardinality: z.enum(['one', 'many']).optional(),
93
+ cardinality: z.enum(['one', 'atMostOne', 'noneOrMany', 'atLeastOne']).optional(),
92
94
  /**
93
95
  * Input mask pattern. Accepts either a plain mask string or a stringified
94
96
  * arrow function that receives `locale` and returns a mask string.
package/dist/fieldtype.js CHANGED
@@ -20,7 +20,6 @@ export const StonecropFieldType = z
20
20
  'JSON', // JSON data
21
21
  'Code', // Code/source (with syntax highlighting)
22
22
  'Link', // Reference to another doctype
23
- 'Doctype', // Child doctype (1:1 nested form or 1:many table via cardinality)
24
23
  'Attach', // File attachment
25
24
  'Currency', // Currency value
26
25
  'Quantity', // Quantity with unit
@@ -56,7 +55,6 @@ export const TYPE_MAP = {
56
55
  Code: { component: 'ACodeEditor', fieldtype: 'Code' },
57
56
  // Relational
58
57
  Link: { component: 'ALink', fieldtype: 'Link' },
59
- Doctype: { component: 'AForm', fieldtype: 'Doctype' },
60
58
  // Files
61
59
  Attach: { component: 'AFileAttach', fieldtype: 'Attach' },
62
60
  // Specialized