@stndrds/cli 1.0.0-alpha.192 → 1.0.0-alpha.194
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/schema-builder-reference.md +513 -0
- package/dist/bin.mjs +207 -9
- package/package.json +1 -1
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
# Standards Schema Builder — Reference
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Standards uses a fluent builder API to define your data schema in TypeScript.
|
|
6
|
+
All objects and attributes declared in code are **system** (immutable at runtime).
|
|
7
|
+
Users can extend your schema at runtime by adding custom attributes via the UI.
|
|
8
|
+
|
|
9
|
+
## Importing
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import {
|
|
13
|
+
object,
|
|
14
|
+
text, richtext,
|
|
15
|
+
number, currency,
|
|
16
|
+
checkbox,
|
|
17
|
+
date,
|
|
18
|
+
phone,
|
|
19
|
+
select, multiselect, status,
|
|
20
|
+
relation,
|
|
21
|
+
user,
|
|
22
|
+
file, document,
|
|
23
|
+
location,
|
|
24
|
+
formula, rollup,
|
|
25
|
+
} from "@stndrds/schema";
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## `object()` — Define an Object (table)
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
const DEAL = object({ name: "deals", label: "Deal" })
|
|
32
|
+
.pluralLabel("Deals")
|
|
33
|
+
.description("Sales opportunities")
|
|
34
|
+
.icon("briefcase")
|
|
35
|
+
.order(10) // sidebar display order
|
|
36
|
+
.labelExpression("{{ name }}") // REQUIRED — how records display
|
|
37
|
+
.attribute(text({ name: "name", label: "Name" }).required())
|
|
38
|
+
.attribute(number({ name: "amount", label: "Amount" }))
|
|
39
|
+
.build();
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Object names must be **kebab-case** (e.g. `"deals"`, `"order-items"`). Max 63 characters.
|
|
43
|
+
|
|
44
|
+
The `.build()` call is required at the end. The returned value is the `ObjectDefinition`.
|
|
45
|
+
|
|
46
|
+
### `.sealed()` — Prevent custom attributes
|
|
47
|
+
|
|
48
|
+
Marks this object as closed: `standards diff` will error if any user-created
|
|
49
|
+
custom attribute is found in the DB that is not listed in `.tolerate()`.
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
object({ name: "invoice", label: "Invoice" })
|
|
53
|
+
.sealed()
|
|
54
|
+
.labelExpression("{{ ref }}")
|
|
55
|
+
.attribute(number({ name: "amount", label: "Amount" }))
|
|
56
|
+
.build();
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `.tolerate(["name"])` — Acknowledge known custom attributes
|
|
60
|
+
|
|
61
|
+
On a **sealed** object: these names won't cause a CI failure.
|
|
62
|
+
On an **extensible** object: purely documentary.
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
object({ name: "invoice", label: "Invoice" })
|
|
66
|
+
.sealed()
|
|
67
|
+
.tolerate(["legacy_ref", "old_notes"])
|
|
68
|
+
.labelExpression("{{ ref }}")
|
|
69
|
+
.attribute(number({ name: "amount", label: "Amount" }))
|
|
70
|
+
.build();
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### `.labelExpression()` — Display template (REQUIRED)
|
|
74
|
+
|
|
75
|
+
Every object must declare a label expression. Uses `{{ attribute }}` syntax.
|
|
76
|
+
Pipes are supported: `{{ name | UPPER }}`, `{{ name | capitalize }}`, `{{ name | trim }}`, `{{ name | LOWER }}`.
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
.labelExpression("{{ firstName }} {{ lastName }}")
|
|
80
|
+
.labelExpression("{{ name | UPPER }}")
|
|
81
|
+
.labelExpression("{{ ref }} — {{ title }}")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Other object methods
|
|
85
|
+
|
|
86
|
+
| Method | Purpose |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `.pluralLabel("Deals")` | Plural form shown in collections |
|
|
89
|
+
| `.description("...")` | Shown in admin UI tooltips |
|
|
90
|
+
| `.icon("briefcase")` | Icon name from `@stndrds/constants` |
|
|
91
|
+
| `.order(10)` | Sidebar sort order (lower = first) |
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Attribute Types
|
|
96
|
+
|
|
97
|
+
All attribute builders accept `{ name, label }` as first argument.
|
|
98
|
+
`name` must be **camelCase** or **snake_case**, unique within the object.
|
|
99
|
+
|
|
100
|
+
### `text()` — Plain text
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
text({ name: "name", label: "Name" })
|
|
104
|
+
.required()
|
|
105
|
+
.minLength(1)
|
|
106
|
+
.maxLength(255)
|
|
107
|
+
.pattern("^[A-Z].*") // custom regex
|
|
108
|
+
.email() // shortcut: validates as email
|
|
109
|
+
.url() // shortcut: validates as URL
|
|
110
|
+
.slug() // shortcut: validates as slug (a-z0-9-)
|
|
111
|
+
.placeholder("Enter name...")
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Use `.multiline()` for longer plain text:
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
text({ name: "notes", label: "Notes" })
|
|
118
|
+
.multiline()
|
|
119
|
+
.optional()
|
|
120
|
+
.placeholder("Add notes...")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### `richtext()` — Rich text (Tiptap editor)
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
richtext({ name: "description", label: "Description" }).required()
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `number()` — Numeric value
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
number({ name: "amount", label: "Amount" })
|
|
133
|
+
.decimal(2) // decimal with 2 places (default unit="decimal")
|
|
134
|
+
.integer() // whole numbers only (unit="integer", decimals=0)
|
|
135
|
+
.percentage() // percentage display (unit="percentage")
|
|
136
|
+
.min(0)
|
|
137
|
+
.max(100)
|
|
138
|
+
.required()
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Use `.renderAs("rating")` for rating UI backed by a number:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
number({ name: "score", label: "Score" })
|
|
145
|
+
.renderAs("rating")
|
|
146
|
+
.max(5)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### `currency()` — Monetary amount with currency code
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
currency({ name: "price", label: "Price" })
|
|
153
|
+
.defaultCurrency("EUR")
|
|
154
|
+
.allowedCurrencies(["EUR", "USD", "GBP"])
|
|
155
|
+
.allowNegative()
|
|
156
|
+
.required()
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### `checkbox()` — Boolean toggle
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
checkbox({ name: "isVerified", label: "Verified" })
|
|
163
|
+
.defaultValue(false)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### `date()` — Date picker
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
date({ name: "closeDate", label: "Close Date" })
|
|
170
|
+
.format("short") // "short" | "long" | "full" | "relative"
|
|
171
|
+
.minDate("2024-01-01")
|
|
172
|
+
.maxDate("2030-12-31")
|
|
173
|
+
.required()
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### `phone()` — Phone number with country code
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
phone({ name: "mobile", label: "Mobile" })
|
|
180
|
+
.defaultCountry("FRA") // ISO 3166-1 alpha-3 country code
|
|
181
|
+
.required()
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### `select()` — Single-choice dropdown
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
select({ name: "status", label: "Status" })
|
|
188
|
+
.options([
|
|
189
|
+
{ label: "Lead", value: "lead", color: "blue" },
|
|
190
|
+
{ label: "Client", value: "client", color: "green" },
|
|
191
|
+
])
|
|
192
|
+
.required()
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Options shape: `{ label: string, value: string, color?: string, description?: string }`.
|
|
196
|
+
Colors: `"gray"` | `"red"` | `"orange"` | `"yellow"` | `"green"` | `"blue"` | `"purple"`.
|
|
197
|
+
|
|
198
|
+
Use `.option({ ... })` to append a single option instead of replacing all.
|
|
199
|
+
|
|
200
|
+
### `multiselect()` — Multi-choice dropdown
|
|
201
|
+
|
|
202
|
+
Same API as `select()`, but the value is an array of selected option values.
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
multiselect({ name: "tags", label: "Tags" })
|
|
206
|
+
.options([
|
|
207
|
+
{ label: "Urgent", value: "urgent", color: "red" },
|
|
208
|
+
{ label: "Pending", value: "pending", color: "orange" },
|
|
209
|
+
])
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### `status()` — Status with workflow groups
|
|
213
|
+
|
|
214
|
+
Like `select()` but options support a `group` field for visual grouping.
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
status({ name: "stage", label: "Stage" })
|
|
218
|
+
.options([
|
|
219
|
+
{ label: "To Do", value: "todo", color: "gray", group: "idle" },
|
|
220
|
+
{ label: "In Progress", value: "in_progress", color: "blue", group: "in_progress" },
|
|
221
|
+
{ label: "Done", value: "done", color: "green", group: "finished" },
|
|
222
|
+
])
|
|
223
|
+
.required()
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Option groups: `"idle"` | `"in_progress"` | `"finished"`. The `group` field is optional.
|
|
227
|
+
|
|
228
|
+
### `user()` — User reference
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
user({ name: "owner", label: "Owner" })
|
|
232
|
+
.multiple() // allow multiple actors
|
|
233
|
+
.types(["user", "agent"]) // allow users, agents, or both
|
|
234
|
+
.required()
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### `file()` — File upload
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
file({ name: "attachment", label: "Attachment" })
|
|
241
|
+
.multiple() // allow multiple files
|
|
242
|
+
.maxFiles(5)
|
|
243
|
+
.maxSize(10 * 1024 * 1024) // 10 MB in bytes
|
|
244
|
+
.allowedTypes(["image/png", "image/jpeg", "application/pdf"])
|
|
245
|
+
.required()
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### `document()` — Structured document with slots and edge properties
|
|
249
|
+
|
|
250
|
+
Documents wrap files with agent-compatible processing and edge-level metadata.
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
document({ name: "contracts", label: "Contracts" })
|
|
254
|
+
.slots([{ name: "file", label: "File", acceptedMimeTypes: ["application/pdf"] }])
|
|
255
|
+
.qualifyWith(
|
|
256
|
+
date({ name: "signedDate", label: "Signed Date" }),
|
|
257
|
+
select({ name: "status", label: "Status" }).options([
|
|
258
|
+
{ label: "Pending", value: "pending", color: "orange" },
|
|
259
|
+
{ label: "Validated", value: "validated", color: "green" },
|
|
260
|
+
])
|
|
261
|
+
)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
For multi-sided documents (e.g. identity card), define slots:
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
document({ name: "identityDoc", label: "Identity Document" })
|
|
268
|
+
.slots([
|
|
269
|
+
{
|
|
270
|
+
name: "recto",
|
|
271
|
+
label: "Front",
|
|
272
|
+
required: true,
|
|
273
|
+
acceptedMimeTypes: ["image/*", "application/pdf"],
|
|
274
|
+
maxSizeBytes: 10 * 1024 * 1024,
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: "verso",
|
|
278
|
+
label: "Back",
|
|
279
|
+
required: false,
|
|
280
|
+
acceptedMimeTypes: ["image/*", "application/pdf"],
|
|
281
|
+
maxSizeBytes: 10 * 1024 * 1024,
|
|
282
|
+
},
|
|
283
|
+
])
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### `location()` — Address / geographic location
|
|
287
|
+
|
|
288
|
+
```typescript
|
|
289
|
+
location({ name: "address", label: "Address" })
|
|
290
|
+
.granularity("full") // "full" | "address" | "city" | "state" | "country"
|
|
291
|
+
.defaultCountry("FRA")
|
|
292
|
+
.allowedCountries(["FRA", "BEL", "CHE"])
|
|
293
|
+
.required()
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### `relation()` — Record reference (single or many)
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
// Single relation (default cardinality: "one")
|
|
300
|
+
relation({ name: "company", label: "Company" })
|
|
301
|
+
.to("companies")
|
|
302
|
+
.required()
|
|
303
|
+
|
|
304
|
+
// Multi relation
|
|
305
|
+
relation({ name: "contacts", label: "Contacts" })
|
|
306
|
+
.to("contacts")
|
|
307
|
+
.many()
|
|
308
|
+
|
|
309
|
+
// Polymorphic (multiple targets)
|
|
310
|
+
relation({ name: "linked", label: "Linked" })
|
|
311
|
+
.to("companies")
|
|
312
|
+
.to("contacts")
|
|
313
|
+
.many()
|
|
314
|
+
|
|
315
|
+
// Universal (link to any object)
|
|
316
|
+
relation({ name: "reference", label: "Reference" })
|
|
317
|
+
.toAny()
|
|
318
|
+
.many()
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**Qualified relations** — add metadata on each edge:
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
relation({ name: "shareholders", label: "Shareholders" })
|
|
325
|
+
.to("contacts")
|
|
326
|
+
.many()
|
|
327
|
+
.qualifyWith(
|
|
328
|
+
select({ name: "role", label: "Role" }).options([
|
|
329
|
+
{ label: "Founder", value: "founder", color: "blue" },
|
|
330
|
+
{ label: "Investor", value: "investor", color: "green" },
|
|
331
|
+
]).required(),
|
|
332
|
+
number({ name: "shares", label: "Shares %" }).min(0).max(100),
|
|
333
|
+
)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**Bilateral relations** — automatically sync the reverse side:
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
relation({ name: "relationships", label: "Relationships" })
|
|
340
|
+
.to("contacts")
|
|
341
|
+
.many()
|
|
342
|
+
.bilateral({ object: "contacts", attribute: "relationships" })
|
|
343
|
+
.qualifyWith(
|
|
344
|
+
select({ name: "type", label: "Type" }).options([...]).required()
|
|
345
|
+
)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Multi-relation constraints:
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
.maxItems(10) // maximum number of linked records
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### `formula()` — Computed read-only value
|
|
355
|
+
|
|
356
|
+
Formula attributes cannot be required (they are always read-only).
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
formula({ name: "total", label: "Total" })
|
|
360
|
+
.expression("price * quantity")
|
|
361
|
+
.returns("number") // "text" | "number" | "boolean" | "date"
|
|
362
|
+
.decimals(2)
|
|
363
|
+
|
|
364
|
+
formula({ name: "fullName", label: "Full Name" })
|
|
365
|
+
.expression("CONCAT(firstName, ' ', lastName)")
|
|
366
|
+
.returns("text")
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### `rollup()` — Aggregation from related records
|
|
370
|
+
|
|
371
|
+
Rollup attributes cannot be required (they are always read-only).
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
// Sum a numeric field across related records
|
|
375
|
+
rollup({ name: "totalRevenue", label: "Total Revenue" })
|
|
376
|
+
.from("orders") // name of the relation attribute on THIS object
|
|
377
|
+
.aggregate("amount") // attribute name on the RELATED object
|
|
378
|
+
.using("sum")
|
|
379
|
+
.decimals(2)
|
|
380
|
+
|
|
381
|
+
// Count related records
|
|
382
|
+
rollup({ name: "orderCount", label: "Order Count" })
|
|
383
|
+
.from("orders")
|
|
384
|
+
.aggregate("id")
|
|
385
|
+
.using("count")
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Available `.using()` functions:
|
|
389
|
+
- Numeric only: `"sum"` | `"avg"`
|
|
390
|
+
- Date only: `"earliest"` | `"latest"`
|
|
391
|
+
- Universal: `"count"` | `"countValues"` | `"countUniqueValues"` | `"countEmpty"` | `"percentEmpty"` | `"percentNotEmpty"` | `"original"`
|
|
392
|
+
|
|
393
|
+
The `"original"` function returns raw values as an array rendered as the target type.
|
|
394
|
+
When using `"original"` on a select/status/multiselect, also call `.targetType()` and `.targetOptions()`:
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
rollup({ name: "dealStages", label: "Deal Stages" })
|
|
398
|
+
.from("deals")
|
|
399
|
+
.aggregate("stage")
|
|
400
|
+
.using("original")
|
|
401
|
+
.targetType("status")
|
|
402
|
+
.targetOptions([
|
|
403
|
+
{ label: "Open", value: "open", color: "blue" },
|
|
404
|
+
{ label: "Won", value: "won", color: "green" },
|
|
405
|
+
{ label: "Lost", value: "lost", color: "red" },
|
|
406
|
+
])
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## Common Attribute Options (all types)
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
.required() // value is mandatory
|
|
415
|
+
.optional() // value is optional (default)
|
|
416
|
+
.hidden() // hidden from default UI views
|
|
417
|
+
.description("Tooltip help text") // shown as tooltip in UI
|
|
418
|
+
.icon("star") // IconName from @stndrds/constants
|
|
419
|
+
.placeholder("Enter a value...") // input placeholder text
|
|
420
|
+
.order(5) // display order within the form
|
|
421
|
+
.defaultValue(...) // pre-fill new records
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## Promoting a Custom Attribute
|
|
427
|
+
|
|
428
|
+
When a user has created a custom attribute and you want to make it system:
|
|
429
|
+
|
|
430
|
+
**Before (user-created in DB, not in code):**
|
|
431
|
+
```
|
|
432
|
+
drift: deal.urgence (text, label: "Urgence")
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
**After (promoted to system in code):**
|
|
436
|
+
```typescript
|
|
437
|
+
const DEAL = object({ name: "deals", label: "Deal" })
|
|
438
|
+
.labelExpression("{{ name }}")
|
|
439
|
+
.attribute(text({ name: "name", label: "Name" }).required())
|
|
440
|
+
.attribute(text({ name: "urgence", label: "Urgence" })) // ← add this
|
|
441
|
+
.build();
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
On next boot, the existing custom attribute is automatically promoted to system.
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## Tolerating a Custom Attribute (sealed objects only)
|
|
449
|
+
|
|
450
|
+
When you seal an object but want to acknowledge an existing custom attribute:
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
const INVOICE = object({ name: "invoice", label: "Invoice" })
|
|
454
|
+
.sealed()
|
|
455
|
+
.tolerate(["legacy_ref"]) // ← known custom attr, won't fail CI
|
|
456
|
+
.labelExpression("{{ ref }}")
|
|
457
|
+
.attribute(text({ name: "ref", label: "Reference" }).required())
|
|
458
|
+
.build();
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## Migrations
|
|
464
|
+
|
|
465
|
+
When you rename or change the type of an attribute, declare a migration.
|
|
466
|
+
Versions must start at 2 and be sequential (2, 3, 4, ...).
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
object({ name: "deals", label: "Deal" })
|
|
470
|
+
.migration(2, (m) => m.renameAttribute("old_name", "new_name"))
|
|
471
|
+
.migration(3, (m) => m.changeType("score", "text", "number", { transform: (v) => v }))
|
|
472
|
+
.migration(4, (m) => m.removeAttribute("deprecated_field"))
|
|
473
|
+
.labelExpression("{{ new_name }}")
|
|
474
|
+
.attribute(text({ name: "new_name", label: "Name" }).required())
|
|
475
|
+
.build();
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Available migration operations:
|
|
479
|
+
- `m.renameAttribute(from, to)` — rename an attribute
|
|
480
|
+
- `m.changeType(name, fromType, toType, options?)` — change attribute type
|
|
481
|
+
- `m.removeAttribute(name)` — permanently remove an attribute
|
|
482
|
+
- `m.addAttribute(attribute)` — add a new attribute via migration
|
|
483
|
+
- `m.updateConfig(name, config)` — patch attribute config (label, options, etc.)
|
|
484
|
+
|
|
485
|
+
---
|
|
486
|
+
|
|
487
|
+
## Type Inference
|
|
488
|
+
|
|
489
|
+
Extract TypeScript types from your schema builders for use in frontend code:
|
|
490
|
+
|
|
491
|
+
```typescript
|
|
492
|
+
// ✅ Correct: infer types from the builder (before .build())
|
|
493
|
+
const dealBuilder = object({ name: "deals", label: "Deal" })
|
|
494
|
+
.labelExpression("{{ name }}")
|
|
495
|
+
.attribute(text({ name: "name", label: "Name" }).required())
|
|
496
|
+
.attribute(number({ name: "amount", label: "Amount" }));
|
|
497
|
+
|
|
498
|
+
type DealRecord = typeof dealBuilder.$infer.record; // full record with id, timestamps
|
|
499
|
+
type DealCreate = typeof dealBuilder.$infer.create; // input for creation
|
|
500
|
+
type DealUpdate = typeof dealBuilder.$infer.update; // input for partial update
|
|
501
|
+
|
|
502
|
+
// Export the compiled definition separately
|
|
503
|
+
export const DEAL = dealBuilder.build();
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
You can also use `ExtractRecord` from `@stndrds/schema`:
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
import { ExtractRecord } from "@stndrds/schema";
|
|
510
|
+
type DealRecord = ExtractRecord<typeof dealBuilder>; // same as $infer.record
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Note: `ExtractRecord` works on the **builder** (`typeof dealBuilder`), not the compiled definition (`typeof DEAL`).
|
package/dist/bin.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/program.ts
|
|
4
|
-
import
|
|
4
|
+
import chalk6 from "chalk";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
|
|
7
7
|
// src/client.ts
|
|
@@ -26,10 +26,11 @@ function buildUrl(base, path, params) {
|
|
|
26
26
|
return url.toString();
|
|
27
27
|
}
|
|
28
28
|
function createClient(config) {
|
|
29
|
-
const { apiUrl, apiKey } = config;
|
|
29
|
+
const { apiUrl, apiKey, tenantId } = config;
|
|
30
30
|
const headers = {
|
|
31
31
|
Authorization: `Bearer ${apiKey}`,
|
|
32
|
-
"Content-Type": "application/json"
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
...tenantId ? { "X-Tenant-ID": tenantId } : {}
|
|
33
34
|
};
|
|
34
35
|
async function request(method, path, options) {
|
|
35
36
|
const url = buildUrl(apiUrl, path, options?.params);
|
|
@@ -91,7 +92,7 @@ function getClientFromCommand(cmd) {
|
|
|
91
92
|
if (!(root.apiUrl && root.apiKey)) {
|
|
92
93
|
throw new Error('No Standards instance configured. Run "standards login" or pass --api-key.');
|
|
93
94
|
}
|
|
94
|
-
return createClient({ apiUrl: root.apiUrl, apiKey: root.apiKey });
|
|
95
|
+
return createClient({ apiUrl: root.apiUrl, apiKey: root.apiKey, tenantId: root.tenant });
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
// src/commands/auth.ts
|
|
@@ -505,6 +506,11 @@ function registerRootCommands(program) {
|
|
|
505
506
|
}
|
|
506
507
|
|
|
507
508
|
// src/commands/schema.ts
|
|
509
|
+
import { createHash } from "crypto";
|
|
510
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
511
|
+
import { join as join2 } from "path";
|
|
512
|
+
import { fileURLToPath } from "url";
|
|
513
|
+
import chalk5 from "chalk";
|
|
508
514
|
function registerSchemaCommand(program) {
|
|
509
515
|
const schema = program.command("schema").description("Inspect schema objects and attributes");
|
|
510
516
|
schema.command("list").description("List all schema objects").action(async (_opts, cmd) => {
|
|
@@ -517,6 +523,197 @@ function registerSchemaCommand(program) {
|
|
|
517
523
|
const result = await client.get(`/schema/objects/${objectName}`);
|
|
518
524
|
formatOutput(result, getFormat(cmd));
|
|
519
525
|
});
|
|
526
|
+
schema.command("diff").description("Show runtime drift between DB and code schema").option("--object <name>", "Scope to a single object").option("--quiet", "Exit code only, no output").action(async (opts, cmd) => {
|
|
527
|
+
const format = getFormat(cmd);
|
|
528
|
+
const result = await fetchDrift(getClientFromCommand(cmd), opts.object);
|
|
529
|
+
if (format === "json" && cmd.getOptionValueSourceWithGlobals("format") !== "default") {
|
|
530
|
+
formatOutput(result, "json");
|
|
531
|
+
} else if (!opts.quiet) {
|
|
532
|
+
printDiffOutput(result);
|
|
533
|
+
}
|
|
534
|
+
process.exit(result.hasUnexpectedDrift ? 1 : 0);
|
|
535
|
+
});
|
|
536
|
+
schema.command("pull").description("Generate an AI-ready prompt to resolve schema drift").option("--object <name>", "Scope to a single object").option("--no-save", "Print to stdout only, do not write to .standards/").option("--output <file>", "Write prompt to a specific file path").action(async (opts, cmd) => {
|
|
537
|
+
const result = await fetchDrift(getClientFromCommand(cmd), opts.object);
|
|
538
|
+
const totalDrift = result.summary.totalCustomObjects + result.summary.totalCustomAttributes;
|
|
539
|
+
if (totalDrift === 0) {
|
|
540
|
+
console.info(chalk5.green("\u2713 No drift detected \u2014 nothing to pull."));
|
|
541
|
+
process.exit(0);
|
|
542
|
+
}
|
|
543
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
544
|
+
const prompt = buildPullPrompt(result, globalOpts.apiUrl ?? "unknown");
|
|
545
|
+
const shouldSave = opts.save !== false;
|
|
546
|
+
if (opts.output) {
|
|
547
|
+
writeFileSync(opts.output, prompt, "utf-8");
|
|
548
|
+
console.info(chalk5.green(`\u2713 Prompt written to ${opts.output}`));
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (shouldSave) {
|
|
552
|
+
const standardsDir = resolveStandardsDir();
|
|
553
|
+
const pullsDir = join2(standardsDir, "pulls");
|
|
554
|
+
const diffDir = join2(standardsDir, "diff");
|
|
555
|
+
mkdirSync(pullsDir, { recursive: true });
|
|
556
|
+
mkdirSync(diffDir, { recursive: true });
|
|
557
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
558
|
+
const hash = createHash("sha1").update(JSON.stringify(result)).digest("hex").slice(0, 6);
|
|
559
|
+
const filename = join2(pullsDir, `${date}-${hash}.md`);
|
|
560
|
+
writeFileSync(filename, prompt, "utf-8");
|
|
561
|
+
writeFileSync(join2(diffDir, "latest.json"), JSON.stringify(result, null, 2), "utf-8");
|
|
562
|
+
console.info(chalk5.green(`\u2713 Pull prompt written to ${filename}`));
|
|
563
|
+
console.info(chalk5.dim(" Copy-paste its contents into your LLM to resolve the drift."));
|
|
564
|
+
} else {
|
|
565
|
+
process.stdout.write(prompt);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
var fetchDrift = (client, object) => client.get("/schema/drift", object ? { object } : {});
|
|
570
|
+
var relationTag = (attr) => attr.isRelation ? " [relation]" : "";
|
|
571
|
+
var attrInline = (attr) => {
|
|
572
|
+
const suffix = attr.tolerated ? ", tolerated" : `${relationTag(attr)}, label: "${attr.label}"`;
|
|
573
|
+
return ` \xB7 ${attr.name} (${attr.type}${suffix})`;
|
|
574
|
+
};
|
|
575
|
+
function printDiffOutput(result) {
|
|
576
|
+
const lines = [];
|
|
577
|
+
for (const obj of result.customObjects) {
|
|
578
|
+
lines.push(chalk5.yellow(`\u26A0 ${obj.name} (${obj.label}) \u2014 custom object, not in code`));
|
|
579
|
+
lines.push(chalk5.dim(' \u2192 Run "standards pull" to promote or leave.'));
|
|
580
|
+
}
|
|
581
|
+
for (const entry of result.systemObjectDrift) {
|
|
582
|
+
const unexpected = entry.sealed ? entry.customAttributes.filter((a) => !a.tolerated) : [];
|
|
583
|
+
const tolerated = entry.customAttributes.filter((a) => a.tolerated);
|
|
584
|
+
if (entry.sealed && unexpected.length > 0) {
|
|
585
|
+
lines.push(
|
|
586
|
+
chalk5.red(
|
|
587
|
+
`\u2717 ${entry.objectName} \u2014 ${unexpected.length} unexpected custom attribute(s) (sealed object)`
|
|
588
|
+
)
|
|
589
|
+
);
|
|
590
|
+
for (const attr of unexpected) lines.push(chalk5.red(attrInline(attr)));
|
|
591
|
+
lines.push(chalk5.dim(' \u2192 Run "standards pull" to promote or tolerate these attributes.'));
|
|
592
|
+
if (tolerated.length > 0) {
|
|
593
|
+
lines.push(chalk5.dim(` Also tolerated: ${tolerated.length} attribute(s)`));
|
|
594
|
+
for (const attr of tolerated) lines.push(chalk5.dim(attrInline(attr)));
|
|
595
|
+
}
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const okAttrs = entry.sealed ? tolerated : entry.customAttributes;
|
|
599
|
+
if (okAttrs.length === 0) continue;
|
|
600
|
+
const okSuffix = entry.sealed ? "tolerated custom attribute(s) (sealed)" : "custom attribute(s) (extensible)";
|
|
601
|
+
lines.push(chalk5.green(`\u2713 ${entry.objectName} \u2014 ${okAttrs.length} ${okSuffix}`));
|
|
602
|
+
for (const attr of okAttrs) lines.push(chalk5.dim(attrInline(attr)));
|
|
603
|
+
}
|
|
604
|
+
if (lines.length === 0) {
|
|
605
|
+
console.info(chalk5.green("\u2713 No drift detected."));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
console.info(lines.join("\n"));
|
|
609
|
+
if (result.hasUnexpectedDrift) {
|
|
610
|
+
console.info(
|
|
611
|
+
chalk5.red(
|
|
612
|
+
`
|
|
613
|
+
\u2717 Unexpected drift: ${result.summary.totalUnexpected} attribute(s) on sealed object(s).`
|
|
614
|
+
)
|
|
615
|
+
);
|
|
616
|
+
} else {
|
|
617
|
+
console.info(chalk5.green("\n\u2713 No unexpected drift."));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
function resolveStandardsDir() {
|
|
621
|
+
let dir = process.cwd();
|
|
622
|
+
while (true) {
|
|
623
|
+
if (existsSync(join2(dir, "package.json"))) {
|
|
624
|
+
return join2(dir, ".standards");
|
|
625
|
+
}
|
|
626
|
+
const parent = join2(dir, "..");
|
|
627
|
+
if (parent === dir) break;
|
|
628
|
+
dir = parent;
|
|
629
|
+
}
|
|
630
|
+
return join2(process.cwd(), ".standards");
|
|
631
|
+
}
|
|
632
|
+
function buildPullPrompt(result, instanceUrl) {
|
|
633
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
634
|
+
const builderRef = loadBuilderReference();
|
|
635
|
+
const formatAttr = (attr) => {
|
|
636
|
+
const toleratedNote = attr.tolerated ? " *(already tolerated)*" : "";
|
|
637
|
+
return ` - ${attr.name}${relationTag(attr)}${toleratedNote} \u2014 type: ${attr.type}, label: "${attr.label}"`;
|
|
638
|
+
};
|
|
639
|
+
const lines = [
|
|
640
|
+
"# Standards Schema Pull",
|
|
641
|
+
"generated-with: standards-cli",
|
|
642
|
+
`generated-at: ${now}`,
|
|
643
|
+
`instance: ${instanceUrl}`,
|
|
644
|
+
"",
|
|
645
|
+
"---",
|
|
646
|
+
"",
|
|
647
|
+
"## Standards Schema Builder \u2014 Reference",
|
|
648
|
+
"",
|
|
649
|
+
builderRef,
|
|
650
|
+
"",
|
|
651
|
+
"---",
|
|
652
|
+
"",
|
|
653
|
+
"## Runtime Drift",
|
|
654
|
+
"",
|
|
655
|
+
"The following attributes and objects exist in the database but are not declared",
|
|
656
|
+
"in the code schema. For each one, the user will decide what to do.",
|
|
657
|
+
""
|
|
658
|
+
];
|
|
659
|
+
if (result.customObjects.length > 0) {
|
|
660
|
+
lines.push("### Custom Objects (not declared in code)");
|
|
661
|
+
lines.push("");
|
|
662
|
+
for (const obj of result.customObjects) {
|
|
663
|
+
lines.push(`**${obj.name}** \u2014 label: "${obj.label}"`);
|
|
664
|
+
for (const attr of obj.attributes) lines.push(formatAttr(attr));
|
|
665
|
+
lines.push("");
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (result.systemObjectDrift.length > 0) {
|
|
669
|
+
lines.push("### Custom Attributes on Existing Objects");
|
|
670
|
+
lines.push("");
|
|
671
|
+
for (const entry of result.systemObjectDrift) {
|
|
672
|
+
const sealedNote = entry.sealed ? " **(sealed \u2014 CI fails until resolved)**" : " (extensible)";
|
|
673
|
+
lines.push(`**${entry.objectName}**${sealedNote} \u2014 label: "${entry.objectLabel}"`);
|
|
674
|
+
for (const attr of entry.customAttributes) lines.push(formatAttr(attr));
|
|
675
|
+
lines.push("");
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
lines.push("---");
|
|
679
|
+
lines.push("");
|
|
680
|
+
lines.push("## Your Task");
|
|
681
|
+
lines.push("");
|
|
682
|
+
lines.push("1. **Explore the codebase** to find the schema files where each object is declared");
|
|
683
|
+
lines.push(' (look for `object("name")` calls importing from `@stndrds/schema`)');
|
|
684
|
+
lines.push("");
|
|
685
|
+
lines.push("2. **Present each attribute and custom object to the user**, one object at a time.");
|
|
686
|
+
lines.push(" For each, explain what it is and ask the user to choose:");
|
|
687
|
+
lines.push(
|
|
688
|
+
" - **Promote** \u2192 will add `.attribute(...)` to the object builder (becomes system on next boot)"
|
|
689
|
+
);
|
|
690
|
+
lines.push(
|
|
691
|
+
' - **Tolerate** \u2192 will add to `.tolerate(["name"])` (required on sealed objects to fix CI)'
|
|
692
|
+
);
|
|
693
|
+
lines.push(" - **Leave** \u2192 do nothing (will keep appearing in future `standards diff` runs)");
|
|
694
|
+
lines.push("");
|
|
695
|
+
lines.push("3. **Collect all decisions** before writing any code. Once the user has decided");
|
|
696
|
+
lines.push(" for every attribute, **apply all changes in one pass**.");
|
|
697
|
+
lines.push("");
|
|
698
|
+
lines.push("4. **Verify** by running: `standards diff`");
|
|
699
|
+
lines.push("");
|
|
700
|
+
lines.push("Refer to the Standards Schema Builder Reference above for the exact API.");
|
|
701
|
+
lines.push(
|
|
702
|
+
"For relation attributes, use the `relation()` builder with bilateral config if needed."
|
|
703
|
+
);
|
|
704
|
+
return lines.join("\n");
|
|
705
|
+
}
|
|
706
|
+
function loadBuilderReference() {
|
|
707
|
+
const candidates = [
|
|
708
|
+
join2(fileURLToPath(new URL(".", import.meta.url)), "../assets/schema-builder-reference.md"),
|
|
709
|
+
join2(process.cwd(), "packages/cli/src/assets/schema-builder-reference.md")
|
|
710
|
+
];
|
|
711
|
+
for (const candidate of candidates) {
|
|
712
|
+
if (existsSync(candidate)) {
|
|
713
|
+
return readFileSync(candidate, "utf-8");
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return "[Standards Schema Builder Reference \u2014 see packages/cli/src/assets/schema-builder-reference.md]";
|
|
520
717
|
}
|
|
521
718
|
|
|
522
719
|
// src/program.ts
|
|
@@ -527,7 +724,7 @@ function isPublicCommand(actionCommand) {
|
|
|
527
724
|
}
|
|
528
725
|
function createProgram() {
|
|
529
726
|
const program = new Command();
|
|
530
|
-
program.name("standards").description("CLI to interact with Standards API").version("1.0.0-alpha.139").option("--format <format>", "output format (json, table, csv)", "json").option("--api-url <url>", "API base URL").option("--api-key <key>", "API key for authentication").option("--instance <name>", "Standards instance profile to use");
|
|
727
|
+
program.name("standards").description("CLI to interact with Standards API").version("1.0.0-alpha.139").option("--format <format>", "output format (json, table, csv)", "json").option("--api-url <url>", "API base URL").option("--api-key <key>", "API key for authentication").option("--instance <name>", "Standards instance profile to use").option("--tenant <id>", "Tenant ID (multi-tenant setups, defaults to primary tenant)");
|
|
531
728
|
registerRootCommands(program);
|
|
532
729
|
registerRecordsCommand(program);
|
|
533
730
|
registerSchemaCommand(program);
|
|
@@ -544,9 +741,10 @@ function createProgram() {
|
|
|
544
741
|
program.setOptionValue("apiUrl", resolved.apiUrl);
|
|
545
742
|
program.setOptionValue("apiKey", resolved.apiKey);
|
|
546
743
|
program.setOptionValue("profileName", resolved.profileName);
|
|
744
|
+
program.setOptionValue("tenant", raw.tenant);
|
|
547
745
|
if (!(resolved.apiKey || isPublicCommand(actionCommand))) {
|
|
548
746
|
console.error(
|
|
549
|
-
|
|
747
|
+
chalk6.red(
|
|
550
748
|
`\u2717 Error: No Standards instance configured. Run "standards login" or pass --api-key.`
|
|
551
749
|
)
|
|
552
750
|
);
|
|
@@ -560,12 +758,12 @@ async function runProgram(argv = process.argv) {
|
|
|
560
758
|
await program.parseAsync(argv).catch((error) => {
|
|
561
759
|
if (error instanceof ApiClientError) {
|
|
562
760
|
if (error.statusCode > 0) {
|
|
563
|
-
console.error(
|
|
761
|
+
console.error(chalk6.red(`\u2717 Error (${error.statusCode}): ${error.message}`));
|
|
564
762
|
} else {
|
|
565
|
-
console.error(
|
|
763
|
+
console.error(chalk6.red(`\u2717 Error: ${error.message}`));
|
|
566
764
|
}
|
|
567
765
|
} else if (error instanceof Error) {
|
|
568
|
-
console.error(
|
|
766
|
+
console.error(chalk6.red(`\u2717 Error: ${error.message}`));
|
|
569
767
|
}
|
|
570
768
|
process.exit(1);
|
|
571
769
|
});
|