From d786593de1a76fb9a199d454f2e7edff78e7c631 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:56:28 +0300 Subject: [PATCH 1/4] feat(db): db schema rules prints the schema design rules with the exact field shapes Emails and links need a format, language / currency / country and every list people pick from are taxonomy pickers (enum only for developer flags), user ids are user references. Works offline (ux-fixes item 13). Co-Authored-By: Claude Opus 5.5 --- src/commands/db/schema/rules.ts | 21 ++++ src/lib/schema-rules.ts | 128 ++++++++++++++++++++++++ tests/__snapshots__/schema.test.ts.snap | 7 ++ 3 files changed, 156 insertions(+) create mode 100644 src/commands/db/schema/rules.ts create mode 100644 src/lib/schema-rules.ts diff --git a/src/commands/db/schema/rules.ts b/src/commands/db/schema/rules.ts new file mode 100644 index 0000000..5ce6d9b --- /dev/null +++ b/src/commands/db/schema/rules.ts @@ -0,0 +1,21 @@ +import {BaseCommand} from '../../../base.js' +import {SCHEMA_NEVER, SCHEMA_RULES, schemaRulesText} from '../../../lib/schema-rules.js' + +export default class DbSchemaRules extends BaseCommand { + static description = `Print the schema design rules — read them before writing a data schema + +Which field for which value: emails and links need a format, language / +currency / country and every list people pick from are taxonomy pickers, +user ids are user references, money is a currency field, dates are epoch +integers. Each rule comes with the exact dataSchema JSON shape. Works +offline: nothing is sent to the Hub. \`db schema create\` prints the Hub's +warnings when a saved schema breaks one of these rules.` + + static examples = ['<%= config.bin %> db schema rules', '<%= config.bin %> db schema rules --json'] + + async run(): Promise { + await this.parse(DbSchemaRules) + this.print(schemaRulesText()) + return {rules: SCHEMA_RULES, never: SCHEMA_NEVER} + } +} diff --git a/src/lib/schema-rules.ts b/src/lib/schema-rules.ts new file mode 100644 index 0000000..76d74bd --- /dev/null +++ b/src/lib/schema-rules.ts @@ -0,0 +1,128 @@ +/** + * The schema design rules a coding agent needs before it writes a Norbix + * data schema by hand — the short form of the gateway's + * SCHEMA-DESIGN-RULES.md (§2, §4, §6, §7b, §9) and of the Hub's + * database-manager rule 6 (the exact field shapes). The Hub serves no + * endpoint for that file, so the text is bundled here; keep it in step + * with gateway/src/Isidos.CodeMash.Services.Api/SeedService/Database/SchemaTemplates/SCHEMA-DESIGN-RULES.md. + * + * The Hub also checks these rules on save: `db schema create` and + * `hub database schema save` print its `warnings` (see printSchemaWarnings). + * + * No oclif import. + */ + +export interface SchemaRule { + /** What the field holds, in plain words. */ + when: string + /** The field to use. */ + use: string + /** The exact dataSchema shape (JSON) of that field. */ + shape: string +} + +export const SCHEMA_RULES: SchemaRule[] = [ + {when: 'email address', use: 'string with format email — never bare text', shape: '{"type":"string","format":"email"}'}, + { + when: 'link, URL, website, homepage, app link, domain', + use: 'string with format uri — never bare text', + shape: '{"type":"string","format":"uri"}', + }, + {when: 'phone number', use: 'string with a pattern', shape: '{"type":"string","pattern":"^\\\\+?[0-9 ()\\\\-]{6,20}$"}'}, + { + when: 'language / locale, currency code, country, status, stage, category, priority — any list people pick from', + use: 'taxonomy picker (create the taxonomy and its terms first) — never bare text, never an inline enum', + shape: '{"source":"taxonomy","taxonomyId":"","multiple":false}', + }, + { + when: 'a developer-facing flag that app code branches on (never shown as a list in the UI)', + use: 'inline enum — the only place for it', + shape: '{"source":"enum","values":["a","b"],"multiple":false}', + }, + { + when: 'a person who signs in: assignee, author, reviewer, *_user_id', + use: 'user reference — never a text id', + shape: '{"source":"user","displayField":"displayName","multiple":false}', + }, + { + when: 'a link to a record of another collection', + use: 'collection reference', + shape: '{"source":"collection","collectionId":"","displayField":"","multiple":false}', + }, + { + when: 'money amount', + use: 'currency field (amount + currency together) — never a string or a bare number', + shape: '{"type":"object","properties":{"value":{},"currency":{"enum":["USD","EUR"]}}}', + }, + { + when: 'date or time', + use: 'unix-epoch integer; the minimum marks it as a date — never a string', + shape: '{"type":"integer","minimum":16777216}', + }, + {when: 'free labels the user invents', use: 'tags', shape: '{"type":"array","uniqueItems":true,"items":{"type":"string"}}'}, + {when: 'file or image', use: 'file', shape: '{"type":"array","items":{"type":"string","format":"uuid"}}'}, +] + +export const SCHEMA_NEVER: string[] = [ + 'Never add system fields: createdAt / updatedAt, createdBy / updatedBy, owner, isDeleted. The platform keeps them in _meta; turn on settings.hasRecordOwner / settings.softDelete instead. A business link to a person (assignee) is a user reference.', + 'Free text for a language, currency or country only when the value is deliberately not tied to any list (say so in the field description).', + 'No nested objects except currency and geolocation: flatten (address_street, address_city) or reference another collection.', + 'Never set additionalProperties at the root.', + 'Mark user-facing text translatable when the project has more than one language; never codes, emails, URLs, phones or ids.', +] + +/** The rules as text, for `norbix db schema rules` and the help pages. */ +export function schemaRulesText(): string { + const width = Math.max(...SCHEMA_RULES.map((r) => r.when.length)) + const rows = SCHEMA_RULES.map((r) => `- ${r.when.padEnd(Math.min(width, 48))} → ${r.use}\n ${r.shape}`) + return [ + 'Norbix schema design rules — read before writing a data schema.', + 'A data schema is {"type":"object","properties":{"":},"required":[""]}.', + '', + 'Which field for which value:', + ...rows, + '', + 'Never:', + ...SCHEMA_NEVER.map((n) => `- ${n}`), + '', + 'The Hub checks the email / link / language / currency / country / user-id rules on save and answers', + 'with "warnings" (the save still succeeds): fix each named field and save again.', + 'Full rules: SCHEMA-DESIGN-RULES.md in the Norbix schema catalog.', + ].join('\n') +} + +/** One line for help pages and agent files that points at the rules command. */ +export const SCHEMA_RULES_POINTER = + 'Before you write a data schema, run `norbix db schema rules`: emails and links need a format, language / currency / country are taxonomy pickers, user ids are user references.' + +/** The `warnings` the Hub sends back with a saved schema, if any. */ +export function schemaWarnings(response: unknown): string[] { + if (response === null || typeof response !== 'object') return [] + const warnings = (response as {warnings?: unknown}).warnings + if (!Array.isArray(warnings)) return [] + return warnings + .map((w) => { + if (typeof w === 'string') return w + if (!w || typeof w !== 'object' || !('message' in w)) return '' + const {where, message} = w as {where?: unknown; message: unknown} + return `${typeof where === 'string' && where ? `${where}: ` : ''}${String(message)}` + }) + .filter((w) => w.length > 0) +} + +/** + * Print the Hub's schema design warnings on stderr, so with --json stdout + * stays the one JSON document (which carries `warnings` itself). + */ +export function printSchemaWarnings(response: unknown, write: (text: string) => void = (t) => process.stderr.write(t)): void { + const warnings = schemaWarnings(response) + if (warnings.length === 0) return + const n = `${warnings.length} field${warnings.length === 1 ? '' : 's'} break${warnings.length === 1 ? 's' : ''} a design rule` + // validate_schema answers with `valid`; a save answers with the schema `id`. + const checkOnly = response !== null && typeof response === 'object' && 'valid' in response + write( + `Warning: ${checkOnly ? 'the schema check says' : 'the schema was saved, but'} ${n}:\n` + + warnings.map((w) => ` - ${w}`).join('\n') + + `\nFix ${warnings.length === 1 ? 'it' : 'them'}${checkOnly ? '' : ' and save again'}. Rules: norbix db schema rules\n`, + ) +} diff --git a/tests/__snapshots__/schema.test.ts.snap b/tests/__snapshots__/schema.test.ts.snap index f55b20e..7446e0d 100644 --- a/tests/__snapshots__/schema.test.ts.snap +++ b/tests/__snapshots__/schema.test.ts.snap @@ -1038,6 +1038,13 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "id": "db schema publish", "supportsDryRun": true, }, + { + "args": [], + "destructive": false, + "flags": [], + "id": "db schema rules", + "supportsDryRun": false, + }, { "args": [ "id", From 79cc8f73f39609a5a648de957a819ee159209329 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:56:28 +0300 Subject: [PATCH 2/4] feat(db): print the Hub's schema design warnings after a schema save, a schema check and in the bundle dry run Warnings go to stderr so --json stdout stays one document; only saveDatabaseSchema and validateSchema print them. The database help and the create / update help point at db schema rules. Co-Authored-By: Claude Opus 5.5 --- src/base.ts | 2 ++ src/commands/db/schema/create.ts | 10 +++++++++- src/commands/db/schema/update.ts | 6 +++++- src/lib/namespace-command.ts | 11 +++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/base.ts b/src/base.ts index 5db6d59..7662245 100644 --- a/src/base.ts +++ b/src/base.ts @@ -168,6 +168,8 @@ export interface DryRunValidation { valid?: boolean issues?: Array<{where?: string; code?: string; message?: string}> collections?: string[] + /** Schema design warnings (bare-text email, link, language, currency, country, user id); never make it invalid. */ + warnings?: string[] /** Why the input was not checked (no bundleJson, the check failed). */ note?: string } diff --git a/src/commands/db/schema/create.ts b/src/commands/db/schema/create.ts index 96be92b..8564fed 100644 --- a/src/commands/db/schema/create.ts +++ b/src/commands/db/schema/create.ts @@ -2,13 +2,20 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' import {fileSource, readJsonObject, readJsonText, type SchemaSettings} from '../../../lib/database.js' +import {printSchemaWarnings} from '../../../lib/schema-rules.js' export default class DbSchemaCreate extends BaseCommand { static description = `Create a database schema (a collection) from a JSON file --file is the data schema (JSON Schema of one record). --ui-file is the optional UI schema the dashboard form uses. The schema starts as a draft: -run \`db schema publish \` to make the collection usable.` +run \`db schema publish \` to make the collection usable. + +Before you write the file, run \`db schema rules\`: emails and links need a +format, language / currency / country are taxonomy pickers, user ids are user +references. When a field breaks one of these rules the Hub still saves the +schema and answers with "warnings"; they are printed on stderr (with --json +they are also in the JSON on stdout).` static examples = [ '<%= config.bin %> db schema create --name orders --file orders.schema.json', @@ -37,6 +44,7 @@ run \`db schema publish \` to make the collection usable.` }) this.print(res) + printSchemaWarnings(res) return res } } diff --git a/src/commands/db/schema/update.ts b/src/commands/db/schema/update.ts index 862b00f..6bcaef4 100644 --- a/src/commands/db/schema/update.ts +++ b/src/commands/db/schema/update.ts @@ -8,7 +8,11 @@ export default class DbSchemaUpdate extends BaseCommand { The change is saved as the schema's draft; records keep using the published version until you run \`db schema publish \`. \`db schema draft \` shows -the draft, \`db schema discard \` throws it away.` +the draft, \`db schema discard \` throws it away. + +Before you change the file, run \`db schema rules\`: emails and links need a +format, language / currency / country are taxonomy pickers, user ids are user +references.` static examples = [ '<%= config.bin %> db schema update 66b2f0a1c3d4e5f6a7b8c9d0 --file orders.schema.json', diff --git a/src/lib/namespace-command.ts b/src/lib/namespace-command.ts index 0612444..990bf2f 100644 --- a/src/lib/namespace-command.ts +++ b/src/lib/namespace-command.ts @@ -5,6 +5,10 @@ import {usageError} from './cli-error.js' import {MODULE_ALIASES, camelSplit, isDestructive, matchMethods, parseArgv, typeFields} from './dispatch.js' import {readJsonInput} from './json.js' import {describeFields, fieldKinds, methodInfo, type FieldKind} from './request-fields.js' +import {SCHEMA_RULES_POINTER, printSchemaWarnings, schemaWarnings} from './schema-rules.js' + +/** The SDK methods whose `warnings` are schema design warnings. */ +const SCHEMA_WARNING_METHODS = new Set(['saveDatabaseSchema', 'validateSchema']) type SdkModule = Record) => Promise> @@ -113,6 +117,7 @@ export abstract class NamespaceCommand extends BaseCommand { `Add an ID as a plain value and request fields as flags:\n` + ` norbix ${this.target} ${moduleWord} ${this.wordForm(list[0], moduleName, hide)} --someField value\n\n` + `${VALUE_RULES}\n\n` + + `${moduleName === 'database' ? `${SCHEMA_RULES_POINTER}\n\n` : ''}` + `Useful extras: --dry-run (preview the exact request), --yes (skip confirmation),\n` + `--json (machine output), --profile/--env/--region/--project (context).`, ) @@ -216,6 +221,10 @@ export abstract class NamespaceCommand extends BaseCommand { const liveModule = (client[this.target] as unknown as Record)[moduleName] const res = await liveModule[method](request) this.print(res) + // save_database_schema / validate_schema answer with design warnings + // (bare-text email, link, language, currency, country, user id). Only + // those two: IdResponse.warnings is shared by every id-returning endpoint. + if (SCHEMA_WARNING_METHODS.has(method)) printSchemaWarnings(res) return res } @@ -241,6 +250,8 @@ export abstract class NamespaceCommand extends BaseCommand { valid: Boolean(res.valid), issues: (res.issues ?? []).map((i) => ({where: i.where, code: i.code, message: i.message})), collections: res.collections ?? [], + // Only when the Hub sent design warnings, so a clean check keeps its shape. + ...(schemaWarnings(res).length > 0 ? {warnings: schemaWarnings(res)} : {}), } } catch (error) { return {checkedWith, checked: false, note: `the check failed: ${error instanceof Error ? error.message : String(error)}`} From 9587b46be0ec28fc82f8ef75975d8f879968d386 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:56:28 +0300 Subject: [PATCH 3/4] docs(agents): the agent recipe, skill and docs send agents to db schema rules before writing a schema Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/AGENTS.md | 27 +++++++++++++++++++++++++++ docs/database.md | 20 +++++++++++++++++++- src/lib/agent-files.ts | 2 ++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index efe9274..6e8b4f5 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ norbix scheduler list | --- | --- | | `norbix login` / `logout` / `whoami` | Authenticate and inspect the current context | | `norbix config list/get/set/unset` | Read and change one profile in `~/.norbix/config` (`--profile`, default `[default]`) | -| `norbix db ...` | Database records (find/get/count/insert/update/replace/delete/aggregate/change-owner), schemas (create/update/publish/delete/versions/diff), schema triggers, integrations, taxonomies and terms, indexes, test-data seed — see [docs/database.md](docs/database.md) | +| `norbix db ...` | Database records (find/get/count/insert/update/replace/delete/aggregate/change-owner), schemas (create/update/publish/delete/versions/diff, and `db schema rules` — the design rules to read before writing one), schema triggers, integrations, taxonomies and terms, indexes, test-data seed — see [docs/database.md](docs/database.md) | | `norbix files list/info/get-by-id/upload/download/sign/delete/publish/unpublish` | Upload, download, manage and publish files | | `norbix files integrations test ` | Check that a files integration really works (live upload/read/list/delete probe) | | `norbix users list/get/invite/block/unblock/delete` | Manage project users (membership) | diff --git a/docs/AGENTS.md b/docs/AGENTS.md index c47b7e7..9e65559 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -80,6 +80,33 @@ norbix hub scheduler task save --body '{"name":"nightly","cron":"0 2 * * *","ini `--field` flags); `--field:str value` forces a string when a value looks like a number. +## 3a. Design a database schema by the rules + +Before writing a data schema, print the rules and follow them: + +```sh +norbix db schema rules # which field for which value, with the exact JSON shape +norbix db schema rules --json # the same as data +``` + +- Email → `{"type":"string","format":"email"}`; link / website / app link → + `{"type":"string","format":"uri"}` — never bare text. +- Language / locale, currency code, country, status, category — any list + people pick from → a taxonomy picker + (`{"source":"taxonomy","taxonomyId":""}`), never bare text and not an + inline enum. An enum is only for a developer-facing flag the app code + branches on. +- A person (assignee, author, `*_user_id`) → a user reference + (`{"source":"user","displayField":"displayName"}`), never a text id. + Never add `createdBy` / `owner` / `createdAt`: the platform keeps them in + `_meta` (`settings.hasRecordOwner`). + +The Hub checks these rules when a schema is saved (`db schema create`, +`hub database schema save`) and when `hub account schema validate` runs. A +schema that breaks one is still saved; the answer carries `warnings` (on +stderr as text, and in the JSON on stdout). Fix each named field and save +again. + ## 4. Change things in two steps ```sh diff --git a/docs/database.md b/docs/database.md index 467fe3a..8d2edab 100644 --- a/docs/database.md +++ b/docs/database.md @@ -153,7 +153,8 @@ counts, the new ids, and any errors. |---|---|---| | `norbix db schemas [--page-size ] [--after ]` | list the schemas of one environment (`--env`, default PROD); each row carries `env` | `GET /schemas` | | `norbix db schema ` | show one schema | `GET /schemas/{id}` | -| `norbix db schema create --name --file [--ui-file ] [--settings ]` | create a schema | `POST /schemas` | +| `norbix db schema rules [--json]` | print the schema design rules (which field for which value, with the exact JSON shape); sends nothing | — | +| `norbix db schema create --name --file [--ui-file ] [--settings ]` | create a schema; prints the Hub's design `warnings` on stderr | `POST /schemas` | | `norbix db schema update [--file ] [--ui-file ]` | save a change as the draft | `PUT /schemas/{Id}/draft` | | `norbix db schema draft ` | show the unpublished draft | `GET /schemas/{Id}/draft` | | `norbix db schema discard [--yes]` | throw the draft away | `DELETE /schemas/{Id}/draft` | @@ -163,6 +164,23 @@ counts, the new ids, and any errors. | `norbix db schema index-status ` | the last schema-index run: the indexes Norbix made (`idx_` per reference, `uniq_` per unique field, `idx___id` for the default sort, at most 8), state `building` / `ready` / `refused` / `partial` per database | `GET /schemas/{Id}/index-status` | | `norbix db schema delete [--yes]` | delete the schema **and its records**: the collection (records and indexes) in the environment is dropped, and removed from the AI knowledge when AI embed is on; no undo. Refused while a schema trigger uses it (`CM-ERRORS-SCHEMA-017`) or a saved aggregate starts on / joins it (`CM-ERRORS-SCHEMA-018`, the aggregates are in `BlockerAggregateNames`); then nothing is dropped | `DELETE /schemas/{Id}` | +**Design rules first.** Run `norbix db schema rules` before writing +`--file`: an email is `{"type":"string","format":"email"}`, a link +`{"type":"string","format":"uri"}`; language / locale, currency code, +country and every list people pick from are a taxonomy picker +(`{"source":"taxonomy","taxonomyId":""}`, an enum only for a +developer-facing flag); a person is a user reference +(`{"source":"user","displayField":"displayName"}`). When a field breaks one of +these rules the Hub still saves the schema and answers with `warnings`, for +example: + +``` +Warning: the schema was saved, but 2 fields break a design rule: + - app_link: 'app_link' looks like a link: use a string with "format": "uri" (UI widget website), not bare text. + - language: 'language' looks like a language: use a taxonomy-selection field (a language taxonomy, one term per language) so values are validated and translatable. Free text only when the value is deliberately not tied to any list. +Fix them and save again. Rules: norbix db schema rules +``` + `--file` is the data schema (the JSON Schema of one record); `--ui-file` is the UI schema the dashboard form uses. A change is a draft first: records keep the published version until `schema publish`. `publish` asks first and sends the diff --git a/src/lib/agent-files.ts b/src/lib/agent-files.ts index 5c78e4d..b9e63d6 100644 --- a/src/lib/agent-files.ts +++ b/src/lib/agent-files.ts @@ -30,6 +30,7 @@ export function recipeBlock(): string { '- Pass `--json` to every command whose output you read: stdout is then exactly one JSON document (a result, or `{"error": {code, message, exit, fieldErrors, hint, docs}}`).', '- Discover, do not guess: `norbix schema --json` (every command, `destructive`, `supportsDryRun`), then `norbix --help`. No named command? `norbix hub --json` lists the SDK methods and their fields.', '- Before any change, run it with `--dry-run --json` (sends nothing) and show the user the request. Then re-run without `--dry-run`, adding `--yes` only for a destructive command (without it the command exits 3 and sends nothing).', + '- Designing a database schema? Run `norbix db schema rules` first: emails and links need a format (`email` / `uri`), language / currency / country and every list people pick from are taxonomy pickers (enum only for developer flags), user ids are user references, no createdBy / owner fields. Fix every `warnings` entry a schema save returns.', '- Never print, paste or ask for API keys, passwords or tokens, and never read `~/.norbix`. Credentials come from `norbix login`, a `--profile`, or `NORBIX_*` variables the user set (CI: `NORBIX_HOST` + `NORBIX_API_KEY` + `NORBIX_PROJECT_ID`).', '- Branch on the exit code: 0 ok · 2 usage (fix the call) · 3 needs `--yes` · 4 auth · 5 not found · 6 validation (`fieldErrors`) · 7 network · 8 server (retry with backoff) · 9 cancelled.', '- Full contract: `docs/agent-contract.md` in the norbix-code/cli repo; `error.hint` and `error.docs` name the next step.', @@ -58,6 +59,7 @@ export function claudeSkill(): string { ' - Not on norbix.ai? Add `--host ` (or `--profile `) to both steps.', '2. Find the command: `norbix schema --json` lists every command with `destructive` and `supportsDryRun`; `norbix --help` has a realistic example. No named command? `norbix hub --json` (or `norbix api --json`) lists the SDK methods with their fields; call one with plain words and `--body \'\'`.', '3. Read with `--json` and parse the one document on stdout. Never parse the text output.', + ' Designing a database schema? Run `norbix db schema rules` first and follow it: emails and links need a format (`email` / `uri`), language / currency / country and every list people pick from are taxonomy pickers (enum only for developer flags), user ids are user references, no createdBy / owner fields. A schema save that breaks a rule still succeeds but returns `warnings`: fix each named field and save again.', '4. Change in two steps: `norbix ... --dry-run --json` prints the exact request and sends nothing — show it to the user. Then re-run without `--dry-run`, adding `--yes` only if the command is destructive. Never add `--yes` to a first attempt.', '5. Branch on the exit code and read `error.hint` / `error.docs`: 0 ok · 2 usage · 3 add `--yes` after a dry run · 4 auth (sign in again as in step 1) · 5 not found (check id / `--env` / `--project`) · 6 validation (`error.fieldErrors`) · 7 network · 8 server (retry with backoff, keep `traceId`) · 9 cancelled.', '', From ef24d281e01d2fe9600d7ab1a89a2f58eda15ad6 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:56:28 +0300 Subject: [PATCH 4/4] test(db): schema rules command, design warnings on save, check and bundle dry run, and the agent files Co-Authored-By: Claude Opus 5.5 --- tests/ai-init.test.ts | 5 ++ tests/schema-rules.test.ts | 171 +++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 tests/schema-rules.test.ts diff --git a/tests/ai-init.test.ts b/tests/ai-init.test.ts index fd66a4e..181010d 100644 --- a/tests/ai-init.test.ts +++ b/tests/ai-init.test.ts @@ -102,6 +102,7 @@ describe('norbix ai init', () => { '- Pass `--json` to every command whose o', '- Discover, do not guess: `norbix schema', '- Before any change, run it with `--dry-', + '- Designing a database schema? Run `norb', '- Never print, paste or ask for API keys', '- Branch on the exit code: 0 ok · 2 usag', '- Full contract: `docs/agent-contract.md', @@ -110,11 +111,15 @@ describe('norbix ai init', () => { expect(bullets[0]).toContain('pick the roles you get') expect(bullets[0]).toContain('Account → AI service users') expect(bullets[3]).toContain('show the user the request') + // ux-fixes item 13: the schema design rules reach the agent. + expect(bullets[4]).toContain('`norbix db schema rules`') + expect(bullets[4]).toContain('language / currency / country and every list people pick from are taxonomy pickers') expect(block).not.toMatch(/norbix login --api-key|nbk_/) expect(block.split('\n').filter((l) => l.trim()).length).toBeLessThanOrEqual(12) const skill = readFileSync(join(project, '.claude/skills/norbix/SKILL.md'), 'utf8') expect(skill).toContain('## Secrets\n\nNever print, echo, paste or ask for API keys, passwords or tokens') + expect(skill).toContain('Designing a database schema? Run `norbix db schema rules` first') expect(skill).toContain('`"auth": "none"` (or exit 4 from any other command) means not signed in: sign in in two steps (your shell commands time out; the code lives 10 minutes):') expect(skill).toContain('Show the user the link and the code: they open it, check the code, pick the roles you get, and press Allow. Never open the link yourself.') expect(skill).toContain('`norbix ... --dry-run --json` prints the exact request and sends nothing — show it to the user.') diff --git a/tests/schema-rules.test.ts b/tests/schema-rules.test.ts new file mode 100644 index 0000000..829c03b --- /dev/null +++ b/tests/schema-rules.test.ts @@ -0,0 +1,171 @@ +import {mkdtempSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {afterAll, beforeAll, describe, expect, it} from 'vitest' + +import {SCHEMA_RULES_POINTER, schemaWarnings} from '../src/lib/schema-rules.js' +import {cli, makeHome, parseSingleJson, startFakeGateway, type FakeGateway} from './_cli.js' + +/** + * ux-fixes item 13: a coding agent built a schema through the CLI with an app + * link, an email, language and currency as bare text and created_by_user_id + * as text. The CLI now (1) prints the design rules (`db schema rules`) and + * (2) shows the Hub's design `warnings` after a schema save — on stderr, so + * with --json stdout stays one JSON document. + * + * The built CLI runs as a child process against a fake gateway on 127.0.0.1. + */ + +const WARNINGS = [ + `app_link: 'app_link' looks like a link: use a string with "format": "uri" (UI widget website), not bare text.`, + `language: 'language' looks like a language: use a taxonomy-selection field (a language taxonomy, one term per language) so values are validated and translatable. Free text only when the value is deliberately not tied to any list.`, +] + +const dir = mkdtempSync(join(tmpdir(), 'norbix-schema-rules-')) +const SCHEMA_FILE = join(dir, 'apps.schema.json') +writeFileSync(SCHEMA_FILE, JSON.stringify({type: 'object', properties: {app_link: {type: 'string'}, language: {type: 'string'}}})) + +let warned: FakeGateway +let clean: FakeGateway +let warnedHome: string +let cleanHome: string + +beforeAll(async () => { + warned = await startFakeGateway({'/v3/database/schemas': {id: 'sch_1', warnings: WARNINGS}}) + clean = await startFakeGateway({'/v3/database/schemas': {id: 'sch_2'}}) + warnedHome = makeHome(warned.port) + cleanHome = makeHome(clean.port) +}) + +afterAll(async () => { + await warned.close() + await clean.close() +}) + +describe('db schema rules', () => { + it('prints the rules with the exact shapes for email, link, taxonomy and user fields', async () => { + const r = await cli(cleanHome, ['db', 'schema', 'rules']) + expect(r.code).toBe(0) + expect(r.stdout).toContain('{"type":"string","format":"email"}') + expect(r.stdout).toContain('{"type":"string","format":"uri"}') + expect(r.stdout).toContain('language / locale, currency code, country') + expect(r.stdout).toContain('{"source":"taxonomy","taxonomyId":"","multiple":false}') + expect(r.stdout).toContain('{"source":"user","displayField":"displayName","multiple":false}') + expect(r.stdout).toContain('developer-facing flag') + }) + + it('--json returns the rules as data and sends nothing', async () => { + const before = clean.hits.length + const r = await cli(cleanHome, ['db', 'schema', 'rules', '--json']) + expect(r.code).toBe(0) + const doc = parseSingleJson(r.stdout) as {rules: Array<{when: string; shape: string}>; never: string[]} + expect(doc.rules.find((x) => x.when.startsWith('email'))?.shape).toBe('{"type":"string","format":"email"}') + expect(doc.never.some((n) => n.includes('createdBy'))).toBe(true) + expect(clean.hits.length).toBe(before) + }) +}) + +describe('db schema create — design warnings', () => { + it('prints the Hub warnings on stderr after a save', async () => { + const r = await cli(warnedHome, ['db', 'schema', 'create', '--name', 'apps', '--file', SCHEMA_FILE, '--profile', 'x']) + expect(r.code).toBe(0) + expect(r.stderr).toContain('the schema was saved, but 2 fields break a design rule') + expect(r.stderr).toContain(WARNINGS[0]) + expect(r.stderr).toContain('Rules: norbix db schema rules') + }) + + it('with --json, stdout is one JSON document that carries the warnings; the text goes to stderr', async () => { + const r = await cli(warnedHome, ['db', 'schema', 'create', '--name', 'apps', '--file', SCHEMA_FILE, '--json', '--profile', 'x']) + expect(r.code).toBe(0) + expect((parseSingleJson(r.stdout) as {warnings: string[]}).warnings).toEqual(WARNINGS) + expect(r.stderr).toContain(WARNINGS[1]) + }) + + it('prints nothing extra when the schema follows the rules', async () => { + const r = await cli(cleanHome, ['db', 'schema', 'create', '--name', 'apps', '--file', SCHEMA_FILE, '--profile', 'x']) + expect(r.code).toBe(0) + expect(r.stderr).not.toContain('design rule') + }) + + it('the create help points at db schema rules', async () => { + const r = await cli(cleanHome, ['db', 'schema', 'create', '--help']) + expect(r.stdout).toContain('db schema rules') + }) +}) + +describe('hub database — design warnings and the rules pointer', () => { + it('hub database schema save prints the warnings too', async () => { + const r = await cli(warnedHome, [ + 'hub', 'database', 'schema', 'save', '--schemaName', 'apps', '--dataSchema', '{"type":"object","properties":{"app_link":{"type":"string"}}}', '--profile', 'x', + ]) + expect(r.code).toBe(0) + expect(r.stderr).toContain(WARNINGS[0]) + }) + + it('hub account schema validate prints them as a check, not as a save', async () => { + const gateway = await startFakeGateway({ + '/v3/account/ai/schemas/validate': {valid: true, issues: [], collections: ['apps'], warnings: [{where: 'apps.email', code: 'SchemaDesignEmailFormat', message: 'bare email'}]}, + }) + try { + const r = await cli(makeHome(gateway.port), ['hub', 'account', 'schema', 'validate', '--schemaJson', '{"name":"apps","fields":[{"name":"email","type":"string"}]}', '--profile', 'x']) + expect(r.code).toBe(0) + expect(r.stderr).toContain('the schema check says 1 field breaks a design rule') + expect(r.stderr).toContain('apps.email: bare email') + expect(r.stderr).not.toContain('saved') + } finally { + await gateway.close() + } + }) + + it('another id-returning hub method with warnings stays silent (only schema save / validate print them)', async () => { + const gateway = await startFakeGateway({'/v3/database/taxonomies': {id: 'tax_1', warnings: ['not a schema warning']}}) + try { + const r = await cli(makeHome(gateway.port), ['hub', 'database', 'taxonomy', 'save', '--name', 'Languages', '--profile', 'x']) + expect(r.code).toBe(0) + expect(r.stderr).not.toContain('design rule') + } finally { + await gateway.close() + } + }) + + it('the database scope help points at db schema rules', async () => { + const r = await cli(cleanHome, ['hub', 'database', 'schema', '--help']) + expect(r.stdout).toContain(SCHEMA_RULES_POINTER) + }) +}) + +describe('schema bundle apply --dry-run — design warnings from the Hub check', () => { + it('the dry-run report carries the validate_schema warnings next to valid / issues', async () => { + const gateway = await startFakeGateway({ + '/v3/account/ai/schemas/validate': { + valid: true, + issues: [], + collections: ['apps'], + warnings: [{where: 'apps.app_link', code: 'SchemaDesignUriFormat', message: WARNINGS[0].slice('app_link: '.length)}], + }, + }) + try { + const home = makeHome(gateway.port) + const bundle = '[{"entity":"apps","fields":[{"name":"app_link","kind":"string"}]}]' + const r = await cli(home, ['hub', 'database', 'schema', 'bundle', 'apply', '--bundleJson', bundle, '--profile', 'x', '--dry-run', '--json']) + expect(r.code).toBe(0) + const doc = parseSingleJson(r.stdout) as {validation: {valid: boolean; warnings: string[]}} + expect(doc.validation.valid).toBe(true) + expect(doc.validation.warnings).toEqual([`apps.app_link: ${WARNINGS[0].slice('app_link: '.length)}`]) + } finally { + await gateway.close() + } + }) +}) + +describe('schemaWarnings', () => { + it('reads the save answer (strings) and the validate answer ({where, code, message})', () => { + expect(schemaWarnings({warnings: ['a: b']})).toEqual(['a: b']) + expect( + schemaWarnings({warnings: [{where: 'apps.email', code: 'SchemaDesignEmailFormat', message: "'email' looks like an email address"}]}), + ).toEqual(["apps.email: 'email' looks like an email address"]) + expect(schemaWarnings({id: 'sch_1'})).toEqual([]) + expect(schemaWarnings(null)).toEqual([]) + }) +})