diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5860c70..4770091 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,6 +12,18 @@ updates: patterns: - "react" - "react-dom" + # The @objectstack/spec that check-doc-samples.mjs parses the docs samples + # with (#316). Its own npm project, outside the pnpm workspace, so it needs + # its own entry. A bump PR runs that gate, so a release that newly refuses a + # sample turns its own PR red. + - package-ecosystem: npm + directory: "/.github/scripts/doc-samples" + schedule: + interval: weekly + groups: + objectstack: + patterns: + - "@objectstack/*" - package-ecosystem: github-actions directory: "/" schedule: diff --git a/.github/scripts/check-doc-samples.mjs b/.github/scripts/check-doc-samples.mjs new file mode 100644 index 0000000..076247f --- /dev/null +++ b/.github/scripts/check-doc-samples.mjs @@ -0,0 +1,813 @@ +#!/usr/bin/env node +/** + * Docs code samples parse — every ts/js block on an English page, checked against the + * `@objectstack/spec` this repository pins (#316). + * + * ## Why this exists + * + * A reader copies a sample and gets a schema error. #301, #307 and #316 each found a batch of + * them by hand: flows written in a step shape the spec retired, views missing a key that became + * required, an import of a type that left the package, a removed agent key. The spec closes a + * shape or retires a key in most releases, so a sample that parsed the day it was written can be + * refused by the next release — and nothing ran the samples. This gate does, on the pull request + * that writes a sample and on the dependency bump that moves the spec under it. + * + * ## What it reads + * + * Every English page under `content/docs/` (a file with a locale suffix from + * `apps/docs/lib/i18n.ts` is a translation of one, and is not read), and in it every fenced block + * whose language is `ts`, `js` or a dialect of them. A `bash`, `json`, `yaml` or `text` block is + * not a sample of the spec's TypeScript surface and is not read. + * + * ## How a block is judged — the first rule that matches decides + * + * 1. **Opted out** (SKIP): the marker below is the last line before its fence. + * 2. **Not a spec sample** (N/A): it imports only from packages other than `@objectstack/spec` + * (a plugin's options, `node:crypto`), or it constructs only a class an earlier block on the + * page imported from one. + * 3. **CEL strings**, one quoted string per line: only the slot shape is checked + * (`EvaluatedExpressionInputSchema`). The spec ships no CEL parser, so an expression's + * meaning is out of reach here. + * 4. **A fragment** — an object literal, or `key: value` pairs: its first comment says what it + * is (the kinds below), and it is parsed inside the smallest whole it belongs to. Pairs whose + * every value is built by `Field.*` are fields of an object without saying so. + * 5. **Statements**, evaluated as published, with only TypeScript syntax removed. Every call of a + * spec constructor is a parse: `define*()`, `ObjectSchema.create()`, `Action.create()`, + * `App.create()`, `Dashboard.create()`. A `Field.*` statement is parsed as a field of an + * object, `defineDataset()` (which does not parse) is followed by `DatasetSchema.parse`, and + * a declaration typed `const x: T` parses its value with `TSchema`. Imports are checked too: + * a value must be exported by the entry point it is imported from, and a type import is + * resolved by the TypeScript compiler against the spec's declarations. + * + * Every flow any rule produces is also parsed node by node against the executor contracts + * (`getBuiltinNodeConfigContracts()`) — what each executor parses before it runs, which is + * stricter than `defineFlow`. A stack that declares `requires` is judged again with every flow + * its page declares, because that is where `requires` is decided. A block that matches no rule + * fails; nothing is skipped quietly. + * + * A page is read in order: a later block sees the spec names an earlier one imported and the + * values it declared. A block that imports from a relative path (`./src/objects`) is judged after + * the rest of its page, with those declarations bound to its imports. + * + * ## Fragments say what they are + * + * The first comment of a fragment names its kind, and says what it omits. The gate supplies only + * the smallest whole around it: a name, a label, a start and an end node — never a key the + * fragment itself was meant to carry. The phrases it knows: + * + * One list view · One form view a list or form view (three ways, as #307) + * One flow node · The `config` of an `X` node in a minimal flow (a `start` config is the start) + * …config.timeRelative (variants) a start node's time-relative trigger + * Keys of a `T` flow on `O`. The nodes are omitted: `a` is an `http` node, … (#307's wrap) + * One field · Keys of a `T` field · Fields of an object · Keys of an object + * … validation rule… `validations` an entry of an object's validations + * Keys of an app · One navigation item + * One dashboard · Keys of a dashboard · Keys of a dashboard widget + * One page · Keys of a page · One page component + * Keys of a form view · Keys of a view container · Keys of a permission set + * + * "Keys of a dashboard; each widget's dataset, values and title are omitted" supplies exactly the + * widget keys it names. A new kind is a row in `KINDS` plus a fixture it passes and one it refuses; + * the self-test fails a kind that lacks either. + * + * ## The opt-out marker + * + * {/* doc-sample: skip — *\/} + * + * on the last non-blank line before the fence. It is an MDX comment: it renders nothing, and + * `apps/docs/lib/source.ts` strips it from the llms bodies (#299). Use it for a block that is + * deliberately not a parseable sample — a counter-example showing a refused shape, or a type + * signature written as an object — never to quiet a refusal. A marker without a reason, a marker + * that is not directly before a fence, and a marker on a block this gate does not read all fail. + * + * ## The pinned spec, and how it moves + * + * `.github/scripts/doc-samples/package.json` pins `@objectstack/spec` to one exact version, with + * its `package-lock.json`, and the gate refuses to run on any other installed version. CI installs + * it with `npm ci --prefix .github/scripts/doc-samples`. It sits OUTSIDE the pnpm workspace on + * purpose. Measured on `cf449fa`: declaring it in a workspace package (`tools/ci-scripts`) re-resolved + * `fumadocs-core`'s optional `zod` peer from 4.4.3 to the spec's 4.6.5 while `fumadocs-mdx` kept + * 4.4.3 — two copies of zod inside the shipped docs build, for a CI tool. + * + * Bumping it: Dependabot watches that directory (`.github/dependabot.yml`, the `objectstack` + * group) and opens the bump as a pull request. This gate runs on that pull request, so a release + * that newly refuses a sample turns its own bump red, and the fix lands with it. By hand: + * `npm install --prefix .github/scripts/doc-samples --save-exact @objectstack/spec@`. + * + * TypeScript is the repository root's devDependency. It only removes TypeScript syntax before a + * block is evaluated, and resolves type imports. + * + * ## What it does not check + * + * CEL semantics (above); what a sample does at runtime; `bash`, `json` and other blocks; prose and + * tables. A key or type named in a table can still be wrong, and still needs a reader. + * + * ## Usage + * + * node .github/scripts/check-doc-samples.mjs # gate: every English page + * node .github/scripts/check-doc-samples.mjs ... # only these pages + * node .github/scripts/check-doc-samples.mjs --docs # another content/docs tree + * node .github/scripts/check-doc-samples.mjs --self-test # fixtures; add --verbose for every row + * + * Exits 1 on any refused or unchecked block, and on a missing or stale install (NOT MEASURED). + */ +import { readFileSync, readdirSync } from 'node:fs'; +import { join, dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, '../..'); +const DOCS = join(ROOT, 'content/docs'); +const I18N = join(ROOT, 'apps/docs/lib/i18n.ts'); +const SPEC_HOME = join(HERE, 'doc-samples'); +const INSTALL = 'npm ci --prefix .github/scripts/doc-samples'; + +const rel = (p) => relative(ROOT, p); + +/* ------------------------------------------------------------------ toolchain */ + +/** The pinned spec and the repository's TypeScript, or a loud reason why not. */ +function loadToolchain() { + const manifest = JSON.parse(readFileSync(join(SPEC_HOME, 'package.json'), 'utf8')); + const pin = manifest.devDependencies?.['@objectstack/spec']; + if (!pin) throw new Error(`${rel(join(SPEC_HOME, 'package.json'))} declares no @objectstack/spec devDependency`); + const specRequire = createRequire(join(SPEC_HOME, 'package.json')); + let pkgFile; + try { + pkgFile = specRequire.resolve('@objectstack/spec/package.json'); + } catch { + throw new Error(`@objectstack/spec is not installed for this gate — run \`${INSTALL}\``); + } + const pkg = JSON.parse(readFileSync(pkgFile, 'utf8')); + if (pkg.version !== pin) { + throw new Error(`installed @objectstack/spec is ${pkg.version} but ${rel(join(SPEC_HOME, 'package.json'))} pins ${pin} — run \`${INSTALL}\``); + } + const ts = createRequire(join(ROOT, 'package.json'))('typescript'); + const entries = Object.keys(pkg.exports ?? {}) + .filter((k) => k === '.' || (k.startsWith('./') && !/\.json$/.test(k))) + .map((k) => (k === '.' ? '@objectstack/spec' : `@objectstack/spec/${k.slice(2)}`)); + const cache = new Map(); + const load = (specifier) => { + if (!cache.has(specifier)) cache.set(specifier, entries.includes(specifier) ? specRequire(specifier) : null); + return cache.get(specifier); + }; + return { version: pkg.version, pin, ts, entries, load, specRequire }; +} + +/* ------------------------------------------------------------------ pages and blocks */ + +/** Non-default locales, read from `apps/docs/lib/i18n.ts` the way check-translations.mjs reads them. */ +function locales() { + const m = readFileSync(I18N, 'utf8').match(/languages:\s*\[([^\]]+)\]/); + if (!m) throw new Error(`could not parse languages[] out of ${rel(I18N)}`); + return m[1].split(',').map((s) => s.trim().replace(/['"]/g, '')).filter((l) => l && l !== 'en'); +} + +function englishPages(dir = DOCS) { + const suffixes = locales().map((l) => `.${l}.mdx`); + const walk = (d) => readdirSync(d, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(join(d, e.name)) : [join(d, e.name)])); + return walk(dir).filter((f) => f.endsWith('.mdx') && !suffixes.some((s) => f.endsWith(s))).sort(); +} + +const CHECKED_LANGS = new Set(['ts', 'tsx', 'typescript', 'js', 'jsx', 'javascript', 'mjs', 'cjs', 'mts', 'cts']); +const MARKER = /^\s*\{\/\*\s*doc-sample:\s*skip\b\s*(?:[—–-]+\s*)?(.*?)\s*\*\/\}\s*$/; +const MARKER_LOOSE = /\{\/\*\s*doc-sample\b/; + +/** + * Every fenced block of a page, as the site renders it: a fence of three or more backticks or + * tildes, indented at most three spaces, closed by the same character at least as long. A block's + * opt-out marker is the last non-blank line before its fence. A marker anywhere else is an error, + * so a marker cannot drift away from the block it was written for and keep working. + */ +export function extractBlocks(text) { + const lines = text.split('\n'); + const blocks = []; + const problems = []; + const fenced = new Set(); + let open = null; + let lastNonBlank = -1; + lines.forEach((l, n) => { + if (open) { + fenced.add(n); + if (new RegExp(`^ {0,3}${open.char === '`' ? '`' : '~'}{${open.len},}\\s*$`).test(l)) { + blocks.push({ ...open, code: open.body.join('\n') }); + open = null; + lastNonBlank = n; + } else open.body.push(l); + return; + } + const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(l); + if (m) { + fenced.add(n); + const info = m[2].trim(); + const marker = lastNonBlank >= 0 ? MARKER.exec(lines[lastNonBlank]) : null; + open = { line: n + 1, char: m[1][0], len: m[1].length, lang: (info.split(/\s+/)[0] || '').toLowerCase(), body: [], skip: marker ? { reason: marker[1], line: lastNonBlank + 1 } : null }; + return; + } + if (l.trim()) lastNonBlank = n; + }); + if (open) problems.push({ line: open.line, why: 'a fence that is never closed' }); + const claimed = new Set(blocks.filter((b) => b.skip).map((b) => b.skip.line)); + lines.forEach((l, n) => { + if (!fenced.has(n) && MARKER_LOOSE.test(l) && !claimed.has(n + 1)) problems.push({ line: n + 1, why: 'a doc-sample marker that is not the last line before a fenced block' }); + }); + return { blocks, problems }; +} + +/* ------------------------------------------------------------------ the evaluator */ + +const isSpecModule = (s) => s === '@objectstack/spec' || s.startsWith('@objectstack/spec/'); +const isRelative = (s) => s.startsWith('.'); + +/** + * Names a block may use without importing them. A page imports once and its later blocks continue + * it, so a block also sees every spec name an earlier block on its page imported; these are the + * names it sees even when no block did (every root `define*` is added to them). + */ +const AMBIENT = { + '@objectstack/spec': ['P', 'F', 'cel', 'tmpl', 'cron'], + '@objectstack/spec/data': ['ObjectSchema', 'Field'], + '@objectstack/spec/ui': ['Action', 'App', 'Dashboard', 'defineDataset'], +}; + +const short = (e) => { + const m = String(e?.message ?? e); + try { + const j = JSON.parse(m); + if (Array.isArray(j)) return j.map((x) => `${(x.path ?? []).join('.') || '(root)'}: ${String(x.message).split('\n')[0]}`).join(' · ').slice(0, 600); + } catch { /* not a zod issue list */ } + return m.replace(/\s+/g, ' ').slice(0, 600); +}; + +/** Type-import answers, shared by every evaluator: each costs a TypeScript program. */ +const typeCache = new Map(); + +export function makeEvaluator(tc) { + const { ts } = tc; + const root = tc.load('@objectstack/spec'); + const data = tc.load('@objectstack/spec/data'); + const ui = tc.load('@objectstack/spec/ui'); + const auto = tc.load('@objectstack/spec/automation'); + const shared = tc.load('@objectstack/spec/shared'); + const contracts = auto.getBuiltinNodeConfigContracts(); + + /* -------- a type import names a type the entry point declares -------- */ + const typeError = (specifier, name) => { + const key = `${tc.version}#${specifier}#${name}`; + if (!typeCache.has(key)) { + const file = join(SPEC_HOME, '__doc_sample_type_probe__.ts'); + const source = `import type { ${name} } from '${specifier}';\nexport type Probe = ${name};\n`; + const host = ts.createCompilerHost({}); + const getSourceFile = host.getSourceFile.bind(host); + const fileExists = host.fileExists.bind(host); + host.getSourceFile = (f, ...more) => (f === file ? ts.createSourceFile(f, source, ts.ScriptTarget.ES2022, true) : getSourceFile(f, ...more)); + host.fileExists = (f) => f === file || fileExists(f); + const program = ts.createProgram([file], { module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, target: ts.ScriptTarget.ES2022, noEmit: true, skipLibCheck: true, types: [] }, host); + const diags = ts.getPreEmitDiagnostics(program, program.getSourceFile(file)); + typeCache.set(key, diags.length ? diags.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' ')).join(' ') : null); + } + return typeCache.get(key); + }; + + /* -------- every constructor call is a judged parse -------- */ + const builtFields = new WeakSet(); + const flows = []; + const stacks = []; + let legs = []; + /** Record one judgement. A refusal is recorded, never thrown, so one bad block cannot hide the next. */ + const judge = (how, fn, fallback) => { + try { const out = fn(); legs.push({ how, ok: true }); return out; } catch (e) { legs.push({ how, ok: false, why: short(e) }); return fallback; } + }; + const contractCheck = (flow) => { + const issues = []; + for (const g of auto.collectFlowGraphs(flow)) { + for (const n of g.nodes ?? []) { + const c = contracts.get(n.type); + if (c && (!c.parsedWhen || c.parsedWhen(n.config ?? {}))) { + const r = c.schema.safeParse(n.config ?? {}); + if (!r.success) issues.push(`${n.id} (${n.type}): ` + r.error.issues.map((i) => `${i.path.join('.') || '(config)'}: ${String(i.message).split('\n')[0]}`).join('; ')); + } + } + } + judge(`executor contracts of ${flow?.name ?? 'the flow'}'s nodes (getBuiltinNodeConfigContracts)`, () => { if (issues.length) throw new Error(issues.join(' · ')); }); + }; + const FAILED = Symbol('refused'); + /** True while a fragment kind builds its wrap: what it defines is a stand-in, not the page's own. */ + let standIn = false; + const asStandIn = (fn) => { standIn = true; try { return fn(); } finally { standIn = false; } }; + const wrapDefine = (name, real) => (...args) => { + const out = judge(`${name}()`, () => real(...args), FAILED); + if (out === FAILED) return args[0]; + if (name === 'defineFlow') { if (!standIn) flows.push(out); contractCheck(out); } + if (name === 'defineStack' && !standIn) stacks.push(args[0]); + if (name === 'defineDataset') judge('DatasetSchema.parse — defineDataset itself does not parse', () => ui.DatasetSchema.parse(out)); + return out; + }; + const wrapCreate = (owner, real) => new Proxy(real, { + get(target, prop, recv) { + const v = Reflect.get(target, prop, recv); + if (prop !== 'create' || typeof v !== 'function') return v; + return (...args) => { const out = judge(`${owner}.create()`, () => v.apply(target, args), FAILED); return out === FAILED ? args[0] : out; }; + }, + }); + const wrapField = (real) => new Proxy(real, { + get(target, prop, recv) { + const v = Reflect.get(target, prop, recv); + if (typeof v !== 'function') return v; + return (...args) => { const out = v.apply(target, args); if (out && typeof out === 'object') builtFields.add(out); return out; }; + }, + }); + const bindValue = (name, value) => { + if (typeof value === 'function' && /^define[A-Z]/.test(name)) return wrapDefine(name, value); + if (name === 'Field' && value) return wrapField(value); + if (value && typeof value.create === 'function' && /^[A-Z]/.test(name)) return wrapCreate(name, value); + return value; + }; + /** Every `define*` of the root entry point, plus the names in AMBIENT. */ + const ambient = () => { + const out = {}; + for (const n of Object.keys(root).filter((k) => /^define[A-Z]/.test(k))) out[n] = bindValue(n, root[n]); + for (const [mod, names] of Object.entries(AMBIENT)) for (const n of names) out[n] = bindValue(n, tc.load(mod)[n]); + return out; + }; + + /* -------- fragment kinds: what a partial block says, in its first comment, that it is -------- */ + const DATA = { provider: 'object', object: 'doc_sample' }; + const STAND_IN = { + http: { url: 'https://example.com/hook', method: 'POST' }, + update_record: { objectName: 'doc_sample', filter: { id: '{record.id}' }, fields: { status: 'done' } }, + notify: { recipients: '{record.owner}', title: 'Doc sample' }, + }; + const node = (id, type, config) => ({ id, type, label: id, ...(config ? { config } : {}) }); + const flowAround = (inner, extra = {}) => ({ + name: 'doc_sample_flow', label: 'Doc sample', type: 'autolaunched', status: 'active', + nodes: [{ id: 'start', type: 'start', label: 'Start' }, ...inner, { id: 'end', type: 'end', label: 'End' }], + edges: [...inner.map((n, k) => ({ id: `w${k}`, source: k ? inner[k - 1].id : 'start', target: n.id ?? 'missing' })), { id: 'w_end', source: inner.at(-1)?.id ?? 'start', target: 'end' }], + ...extra, + }); + const obj = (keys) => ({ name: 'doc_sample', label: 'Doc sample', fields: { name: { type: 'text', label: 'Name' } }, ...keys }); + const KINDS = [ + { id: 'one list view', test: /\bone list view\b/i, judge: (v, _m, b) => { + judge('ListViewSchema.parse(fragment)', () => ui.ListViewSchema.parse(v)); + judge('defineView({ list: fragment })', () => b.defineView({ list: v })); + judge('…with the omitted data supplied, as a listViews entry', () => b.defineView({ listViews: { v: { ...v, data: DATA } } })); + } }, + { id: 'one form view', test: /\bone form view\b/i, judge: (v, _m, b) => { + judge('FormViewSchema.parse(fragment)', () => ui.FormViewSchema.parse(v)); + judge('defineView({ form: fragment })', () => b.defineView({ form: v })); + judge('…with the omitted data supplied', () => b.defineView({ form: { ...v, data: DATA } })); + } }, + { id: 'one flow node', test: /\bone flow node\b/i, judge: (v, _m, b) => b.defineFlow(flowAround([v])) }, + { id: 'the config of a node', test: /\bthe `?config`? of an? `([a-z_]+)` node\b/i, judge: (v, m, b) => { + const config = v.config ?? v; + if (m[1] !== 'start') return b.defineFlow(flowAround([node('fragment', m[1], config)])); + // A start node's config decides the flow's type: a schedule, a record change, or neither. + const type = config.schedule || config.timeRelative || config.triggerType === 'schedule' ? 'schedule' : config.objectName ? 'record_change' : 'autolaunched'; + return b.defineFlow({ ...flowAround([]), type, ...(type === 'schedule' ? { runAs: 'system' } : {}), nodes: [{ id: 'start', type: 'start', label: 'Start', config }, { id: 'end', type: 'end', label: 'End' }], edges: [{ id: 'w', source: 'start', target: 'end' }] }); + } }, + { id: "a start node's config.timeRelative", test: /\bconfig\.timeRelative\b/i, variants: true, judge: (v, _m, b) => { + judge('TimeRelativeTriggerSchema.parse', () => auto.TimeRelativeTriggerSchema.parse(v.timeRelative)); + b.defineFlow({ ...flowAround([]), type: 'schedule', runAs: 'system', nodes: [{ id: 'start', type: 'start', label: 'Start', config: { timeRelative: v.timeRelative } }, { id: 'end', type: 'end', label: 'End' }], edges: [{ id: 'w', source: 'start', target: 'end' }] }); + } }, + { id: 'keys of a flow', test: /\bkeys of an? `?([a-z_]+)`? flow on `([a-z_]+)`\.\s*the nodes are omitted:\s*(.*)$/i, judge: (v, m, b) => { + const named = [...m[3].matchAll(/`([a-z_]\w*)`\s+(?:is\s+)?an?\s+`([a-z_]+)`/gi)].map(([, id, type]) => ({ id, type })); + const missing = named.filter((n) => !STAND_IN[n.type]).map((n) => `${n.id} (${n.type})`); + if (!named.length || missing.length) { judge('stand in for the omitted nodes', () => { throw new Error(`no stand-in for ${missing.join(', ') || 'a node the comment names'}`); }); return; } + const ids = named.map((n) => n.id); + const edges = v.edges ?? []; + b.defineFlow({ + name: 'doc_sample_flow', label: 'Doc sample', type: m[1], status: 'active', + nodes: [{ id: 'start', type: 'start', label: 'Start', config: { objectName: m[2], triggerType: 'record-after-update' } }, ...named.map((n) => node(n.id, n.type, STAND_IN[n.type])), { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'w_start', source: 'start', target: edges[0]?.source ?? ids[0] }, ...edges, ...ids.filter((n) => !edges.some((e) => e.source === n)).map((n, k) => ({ id: `w_end_${k}`, source: n, target: 'end' }))], + ...Object.fromEntries(Object.entries(v).filter(([k]) => k !== 'edges')), + }); + } }, + { id: 'one field', test: /\bone field\b/i, judge: (v, _m, b) => b.ObjectSchema.create(obj({ fields: { doc_sample_field: v } })) }, + { id: 'keys of a field', test: /\bkeys of an? `([a-z_]+)` field\b/i, judge: (v, m, b) => b.ObjectSchema.create(obj({ fields: { doc_sample_field: { type: m[1], label: 'Doc sample', ...v } } })) }, + { id: 'fields of an object', test: /\bfields of an object\b/i, judge: (v, _m, b) => b.ObjectSchema.create(obj({ fields: v.fields ?? v })) }, + { id: 'keys of an object', test: /\bkeys of an object\b/i, judge: (v, _m, b) => b.ObjectSchema.create(obj(v)) }, + { id: 'validation rules', test: /\bvalidation rules?\b.*\bvalidations\b/i, judge: (v, _m, b) => b.ObjectSchema.create(obj({ fields: {}, validations: [v] })) }, + { id: 'keys of an app', test: /\bkeys of an app\b/i, judge: (v, _m, b) => b.App.create({ name: 'doc_sample', label: 'Doc sample', ...v }) }, + { id: 'one navigation item', test: /\bone navigation item\b/i, judge: (v, _m, b) => b.App.create({ name: 'doc_sample', label: 'Doc sample', navigation: [v] }) }, + { id: 'one dashboard', test: /\bone dashboard\b/i, judge: (v, _m, b) => b.Dashboard.create(v) }, + { id: 'keys of a dashboard', test: /\bkeys of a dashboard\b(?! widget)/i, judge: (v, _m, b, lead) => { + // "each widget's dataset, values and title are omitted": the omitted keys are supplied, nothing else. + const omitted = /\beach widget's ([^.]*?) (?:is|are) omitted/i.exec(lead)?.[1] ?? ''; + const fill = { ...(/\bdataset\b/.test(omitted) ? { dataset: 'doc_sample' } : {}), ...(/\bvalues\b/.test(omitted) ? { values: ['doc_sample_measure'] } : {}), ...(/\btitle\b/.test(omitted) ? { title: 'Doc sample' } : {}) }; + const widgets = v.widgets?.map((w) => ({ ...fill, ...w })); + return b.Dashboard.create({ name: 'doc_sample', label: 'Doc sample', widgets: [], ...v, ...(widgets ? { widgets } : {}) }); + } }, + { id: 'keys of a dashboard widget', test: /\bkeys of a dashboard widget\b/i, judge: (v, _m, b) => b.Dashboard.create({ name: 'doc_sample', label: 'Doc sample', widgets: [{ id: 'doc_sample_widget', title: 'Doc sample', type: 'metric', dataset: 'doc_sample', values: ['doc_sample_measure'], ...v }] }) }, + { id: 'one page', test: /\bone page\b(?! component)/i, judge: (v, _m, b) => b.definePage(v) }, + { id: 'keys of a page', test: /\bkeys of a page\b/i, judge: (v, _m, b) => b.definePage({ name: 'doc_sample', label: 'Doc sample', type: 'app', regions: [], ...v }) }, + { id: 'one page component', test: /\bone page component\b/i, judge: (v, _m, b) => b.definePage({ name: 'doc_sample', label: 'Doc sample', type: 'app', regions: [{ name: 'main', components: [v] }] }) }, + { id: 'keys of a form view', test: /\bkeys of a form view\b/i, judge: (v) => judge('FormViewSchema.parse', () => ui.FormViewSchema.parse({ type: 'simple', data: DATA, sections: [{ fields: ['name'] }], ...v })) }, + { id: 'keys of a view container', test: /\bkeys of a view container\b/i, judge: (v, _m, b) => b.defineView(v) }, + { id: 'keys of a permission set', test: /\bkeys of a permission set\b/i, judge: (v, _m, b) => b.definePermissionSet({ name: 'doc_sample', label: 'Doc sample', objects: {}, ...v }) }, + ]; + const kindOf = (lead) => { for (const k of KINDS) { const m = k.test.exec(lead); if (m) return { k, m }; } return null; }; + + /* -------- reading a block -------- */ + function analyze(code) { + const sf = ts.createSourceFile('block.ts', code, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); + const imports = []; + let restStart = 0; + for (const st of sf.statements) { + if (!ts.isImportDeclaration(st)) break; + const clause = st.importClause; + const typeOnly = !!clause?.isTypeOnly; + const names = []; + if (clause?.name) names.push({ local: clause.name.text, imported: 'default', type: typeOnly }); + const nb = clause?.namedBindings; + if (nb && ts.isNamespaceImport(nb)) names.push({ local: nb.name.text, imported: '*', type: typeOnly }); + if (nb && ts.isNamedImports(nb)) for (const el of nb.elements) names.push({ local: el.name.text, imported: (el.propertyName ?? el.name).text, type: typeOnly || el.isTypeOnly }); + imports.push({ spec: st.moduleSpecifier.text, names }); + restStart = st.end; + } + return { imports, rest: code.slice(restStart) }; + } + /** The comment lines a block opens with (before and after its imports): where a fragment says what it is. */ + const leadOf = (code) => { + const out = []; + for (const l of code.split('\n')) { + const t = l.trim(); + if (!t) continue; + if (t.startsWith('//')) { out.push(t.replace(/^\/\/+\s?/, '')); continue; } + break; + } + return out.join(' '); + }; + const stripLineComments = (code) => code.split('\n').filter((l) => !/^\s*\/\//.test(l)).join('\n'); + const transpile = (src) => { + const r = ts.transpileModule(src, { reportDiagnostics: true, compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, isolatedModules: true } }); + const errors = (r.diagnostics ?? []).filter((d) => d.category === ts.DiagnosticCategory.Error); + if (errors.length) throw new Error('syntax: ' + errors.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' ')).join('; ')); + return r.outputText; + }; + const run = (js, bindings, declared = []) => { + const names = Object.keys(bindings); + const body = `${js}\n;return { ${declared.map((d) => `${JSON.stringify(d)}: (typeof ${d} === 'undefined' ? undefined : ${d})`).join(', ')} };`; + return new Function(...names, body)(...names.map((n) => bindings[n])); + }; + /** Top-level items of a fragment: split where bracket depth returns to zero. */ + const topLevelItems = (src) => { + const items = []; + let depth = 0, start = -1, quote = null; + for (let i = 0; i < src.length; i++) { + const c = src[i]; + if (quote) { if (c === '\\') i++; else if (c === quote) quote = null; continue; } + if (c === '/' && src[i + 1] === '*') { i = src.indexOf('*/', i + 2) + 1; continue; } + if (c === '"' || c === "'" || c === '`') { quote = c; if (depth === 0 && start < 0) start = i; continue; } + if ('{[('.includes(c)) { if (depth === 0 && start < 0) start = i; depth++; continue; } + if ('}])'.includes(c)) { depth--; if (depth === 0) { items.push(src.slice(start, i + 1)); start = -1; } } + } + return items; + }; + const noKind = (lead) => judge('the fragment says, in its first comment, what it is', () => { + throw new Error(`no fragment kind this gate knows in its leading comment (${lead ? JSON.stringify(lead.slice(0, 90)) : 'there is none'})`); + }); + + /** Judge one block. Returns { verdict, how, why }. */ + function evaluateBlock(block, page) { + legs = []; + const { imports, rest } = analyze(block.code); + const lead = [leadOf(block.code), imports.length ? leadOf(rest) : ''].filter(Boolean).join(' '); + const first = stripLineComments(rest).split('\n').map((l) => l.trim()).find(Boolean) ?? ''; + const done = (how, extra = {}) => { + const bad = legs.filter((l) => !l.ok); + return { verdict: bad.length ? 'FAIL' : 'PASS', how, why: bad.map((l) => `${l.how}: ${l.why}`).join(' || '), legs: legs.length, ...extra }; + }; + + // Not a spec sample: it imports only from other modules, or builds only what such an import gave. + const foreign = imports.filter((i) => !isSpecModule(i.spec) && !isRelative(i.spec)); + if (imports.length && foreign.length === imports.length) { + for (const i of foreign) for (const n of i.names) page.foreign.add(n.local); + return { verdict: 'N/A', how: `not a spec sample: it imports only from ${[...new Set(foreign.map((i) => i.spec))].join(', ')}` }; + } + if (!imports.length) { + const built = [...rest.matchAll(/\bnew\s+([A-Za-z_$][\w$]*)/g)].map((m) => m[1]); + if (built.length && built.every((c) => page.foreign.has(c))) return { verdict: 'N/A', how: `not a spec sample: it constructs ${[...new Set(built)].join(', ')}, imported on this page from a module other than @objectstack/spec` }; + } + + const bindings = { ...ambient(), ...page.scope }; + for (const imp of imports) { + if (isSpecModule(imp.spec)) { + const mod = tc.load(imp.spec); + for (const n of imp.names) { + if (!mod) { judge(`import from '${imp.spec}'`, () => { throw new Error(`not an entry point of @objectstack/spec ${tc.version}`); }); break; } + if (n.imported === '*') { bindings[n.local] = mod; continue; } + if (n.type) { + judge(`import type { ${n.imported} } from '${imp.spec}'`, () => { if (typeError(imp.spec, n.imported)) throw new Error(`'${imp.spec}' exports no type ${n.imported}`); }); + page.types.set(n.local, { spec: imp.spec, name: n.imported }); + continue; + } + if (n.imported in mod) { + judge(`import { ${n.imported} } from '${imp.spec}'`, () => true); + bindings[n.local] = page.scope[n.local] = bindValue(n.imported, mod[n.imported]); + continue; + } + if (!typeError(imp.spec, n.imported)) { page.types.set(n.local, { spec: imp.spec, name: n.imported }); continue; } + judge(`import { ${n.imported} } from '${imp.spec}'`, () => { throw new Error(`'${imp.spec}' exports no ${n.imported}`); }); + } + } else if (isRelative(imp.spec)) { + for (const n of imp.names) { + if (n.imported === '*') { + const seg = imp.spec.replace(/\/index(\.[cm]?[jt]s)?$/, '').replace(/\.[cm]?[jt]s$/, '').split('/').pop(); + const want = { objects: 'object', flows: 'flow', views: 'view', datasources: 'datasource' }[seg]; + bindings[n.local] = Object.fromEntries([...page.declared].filter(([, d]) => !want || d.kind === want).map(([k, d]) => [k, d.value])); + } else if (page.declared.has(n.imported)) bindings[n.local] = page.declared.get(n.imported).value; + else judge(`import { ${n.imported} } from '${imp.spec}'`, () => { throw new Error(`no block on this page declares ${n.imported}`); }); + } + } else for (const n of imp.names) page.foreign.add(n.local); + } + + if (!first) return imports.length ? done('imports only') : { verdict: 'FAIL', how: 'an empty block', why: 'nothing to check' }; + const evalExpr = (src) => run(transpile(`const __v = (${src});`), bindings, ['__v']).__v; + + try { + // CEL strings, one per line. The spec ships no CEL parser, so only the slot shape is checked. + const lines = stripLineComments(rest).split('\n').map((l) => l.trim()).filter(Boolean); + if (lines.every((l) => /^'(?:[^'\\]|\\.)*'$|^"(?:[^"\\]|\\.)*"$/.test(l))) { + lines.forEach((l, k) => judge(`CEL ${k + 1}: slot shape only (EvaluatedExpressionInputSchema; the spec ships no CEL parser)`, () => shared.EvaluatedExpressionInputSchema.parse(new Function(`return ${l};`)()))); + return done('CEL strings'); + } + + // One object literal, or several, each a value of the kind the comment names. + if (first.startsWith('{')) { + const kind = kindOf(lead); + if (!kind) { noKind(lead); return done('an object-literal fragment'); } + const items = topLevelItems(stripLineComments(rest)); + for (const it of items) { const v = judge('evaluate', () => evalExpr(it), FAILED); if (v !== FAILED) asStandIn(() => kind.k.judge(v, kind.m, bindings, lead)); } + return done(`${kind.k.id}${items.length > 1 ? ` ×${items.length}` : ''}`); + } + + // Keys of something: `key: value, …`. + if (/^(?:[A-Za-z_$][\w$]*|'[^']*'|"[^"]*")\s*:(?!:)/.test(first)) { + const kind = kindOf(lead); + const body = stripLineComments(rest); + const sfo = ts.createSourceFile('k.ts', `({\n${body}\n})`, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); + const lit = sfo.statements[0]?.expression?.expression; + const props = lit && ts.isObjectLiteralExpression(lit) ? [...lit.properties] : []; + const names = props.map((p) => p.name?.getText(sfo)); + const variants = kind?.k.variants || new Set(names).size !== names.length; + const sources = variants ? props.map((p) => `{ ${p.getText(sfo)} }`) : [`{\n${body}\n}`]; + const values = sources.map((s) => judge('evaluate', () => evalExpr(s), FAILED)).filter((v) => v !== FAILED); + if (kind) { for (const v of values) asStandIn(() => kind.k.judge(v, kind.m, bindings, lead)); return done(`${kind.k.id}${variants ? ` ×${values.length}` : ''}`); } + if (values.length === 1 && Object.values(values[0]).length && Object.values(values[0]).every((x) => builtFields.has(x))) { + judge('fields of an object (every value is built by Field.*): ObjectSchema.create({ fields })', () => data.ObjectSchema.create(obj({ fields: values[0] }))); + return done('fields of an object'); + } + noKind(lead); + return done('a fragment of keys'); + } + + // Statements, evaluated as published. + const sf = ts.createSourceFile('m.ts', rest, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); + const edits = []; + const declared = []; + const wrap = (n, fn) => { edits.push({ at: n.getStart(sf), text: `${fn}(` }); edits.push({ at: n.end, text: ')' }); }; + for (const st of sf.statements) { + const mods = ts.canHaveModifiers(st) ? ts.getModifiers(st) ?? [] : []; + const exp = mods.find((m) => m.kind === ts.SyntaxKind.ExportKeyword); + if (exp) edits.push({ at: exp.getStart(sf), del: exp.end - exp.getStart(sf), text: '' }); + if (ts.isExportAssignment(st)) edits.push({ at: st.getStart(sf), del: st.expression.getStart(sf) - st.getStart(sf), text: '__default = ' }); + if (ts.isVariableStatement(st)) { + for (const d of st.declarationList.declarations) { + if (!ts.isIdentifier(d.name) || !d.initializer) continue; + declared.push(d.name.text); + const t = d.type && ts.isTypeReferenceNode(d.type) ? d.type.typeName.getText(sf) : null; + if (t) { edits.push({ at: d.initializer.getStart(sf), text: `__typed(${JSON.stringify(t)}, ` }); edits.push({ at: d.initializer.end, text: ')' }); } + else if (ts.isObjectLiteralExpression(d.initializer) || (ts.isAsExpression(d.initializer) && ts.isObjectLiteralExpression(d.initializer.expression))) wrap(d.initializer, '__plain'); + } + } + if (ts.isFunctionDeclaration(st) && st.name) declared.push(st.name.text); + if (ts.isExpressionStatement(st) && ts.isCallExpression(st.expression) && /^Field\.\w+$/.test(st.expression.expression.getText(sf))) wrap(st.expression, '__field'); + } + let src = rest; + for (const e of edits.sort((a, b) => b.at - a.at)) src = src.slice(0, e.at) + e.text + src.slice(e.at + (e.del ?? 0)); + const js = transpile(src); + const plains = []; + const typed = (t, v) => { + const imp = page.types.get(t); + if (!imp) { judge(`typed ${t}`, () => { throw new Error(`${t} is not a type imported from @objectstack/spec on this page`); }); return v; } + const schema = `${imp.name}Schema`; + const owner = [imp.spec, ...tc.entries].map((s) => tc.load(s)).find((m) => m && schema in m); + judge(`${schema}.parse — the value is typed ${imp.name}`, () => { if (!owner) throw new Error(`@objectstack/spec has no ${schema} to parse a ${imp.name} with`); owner[schema].parse(v); }); + return v; + }; + const before = legs.filter((l) => !/^import/.test(l.how)).length; + // A name this block declares itself shadows the page's earlier one of the same name. + const own = Object.fromEntries(Object.entries(bindings).filter(([k]) => !declared.includes(k))); + const out = judge('evaluate as published', () => run(`let __default;\n${js}\n;var __defaultOut = __default;`, { + ...own, __typed: typed, __plain: (v) => { plains.push(v); return v; }, + __field: (v) => { judge('Field.* value, as a field of ObjectSchema.create({ fields })', () => data.ObjectSchema.create(obj({ fields: { doc_sample_field: v } }))); return v; }, + }, [...declared, '__defaultOut']), FAILED); + if (out !== FAILED) for (const d of declared) if (out[d] !== undefined) page.declare(d, out[d], flows); + const judgedHere = legs.filter((l) => !/^import|^evaluate as published/.test(l.how)).length - before; + if (!judgedHere && plains.length) { + const kind = kindOf(lead); + if (!kind) { noKind(lead); return done('statements'); } + for (const v of plains) asStandIn(() => kind.k.judge(v, kind.m, bindings, lead)); + return done(`${kind.k.id} (a declared value)`); + } else if (!judgedHere && out !== FAILED) { + judge('reaches a spec parse', () => { throw new Error('nothing in this block calls a spec constructor or holds a value typed with a spec type'); }); + } + return done('statements'); + } catch (e) { + legs.push({ how: 'evaluate', ok: false, why: short(e) }); + return done('evaluate'); + } + } + + function newPage() { + const page = { scope: {}, declared: new Map(), foreign: new Set(), types: new Map() }; + page.declare = (name, value, allFlows) => { + const kind = allFlows.includes(value) ? 'flow' : value && typeof value === 'object' && value.fields && value.name ? 'object' : value?.driver ? 'datasource' : 'other'; + page.declared.set(name, { value, kind }); + page.scope[name] = value; + }; + return page; + } + + /** A stack that declares `requires`, judged again with every flow on its page: that is where `requires` is decided. */ + function stackWithPageFlows(stack, pageFlows) { + legs = []; + judge(`defineStack with this stack's requires and the ${pageFlows.length} flow(s) on the page`, () => root.defineStack({ requires: stack.requires, flows: pageFlows })); + const bad = legs.filter((l) => !l.ok); + return { verdict: bad.length ? 'FAIL' : 'PASS', how: legs[0].how, why: bad.map((l) => l.why).join(' || ') }; + } + + return { evaluateBlock, newPage, stackWithPageFlows, flows, stacks, KINDS }; +} + +/* ------------------------------------------------------------------ the gate */ + +const RELATIVE_IMPORT = /^\s*import\s[^;]*?from\s+['"]\./m; + +export function checkPages(files, tc, read = (f) => readFileSync(f, 'utf8')) { + const ev = makeEvaluator(tc); + const rows = []; + for (const file of files) { + const { blocks, problems } = extractBlocks(read(file)); + const out = problems.map((p) => ({ file, line: p.line, verdict: 'FAIL', how: 'the page', why: p.why })); + const page = ev.newPage(); + const flows0 = ev.flows.length; + // A block that imports from a relative path is judged after the rest of its page: it assembles what they declare. + const order = [...blocks.filter((b) => !RELATIVE_IMPORT.test(b.code)), ...blocks.filter((b) => RELATIVE_IMPORT.test(b.code))]; + for (const b of order) { + if (!CHECKED_LANGS.has(b.lang)) { + if (b.skip) out.push({ file, line: b.line, verdict: 'FAIL', how: `a ${b.lang || 'plain'} block`, why: 'a doc-sample marker on a block this gate does not read' }); + continue; + } + if (b.skip) { out.push({ file, line: b.line, verdict: b.skip.reason ? 'SKIP' : 'FAIL', how: 'opted out', why: b.skip.reason || 'a doc-sample: skip marker must say why' }); continue; } + const stacksBefore = ev.stacks.length; + const r = ev.evaluateBlock(b, page); + out.push({ file, line: b.line, ...r, stacks: ev.stacks.slice(stacksBefore) }); + } + const pageFlows = ev.flows.slice(flows0); + for (const row of out) { + for (const s of row.stacks ?? []) { + if (!s?.requires || !pageFlows.length) continue; + const r = ev.stackWithPageFlows(s, pageFlows); + if (r.verdict === 'FAIL') { row.verdict = 'FAIL'; row.why = [row.why, `${r.how}: ${r.why}`].filter(Boolean).join(' || '); } + row.how += `; ${r.how}`; + } + delete row.stacks; + } + rows.push(...out.sort((a, b) => a.line - b.line)); + } + return rows; +} + +function gate(argv) { + let tc; + try { tc = loadToolchain(); } catch (e) { console.error(`✗ doc samples: NOT MEASURED — ${e.message}`); return 1; } + const at = argv.indexOf('--docs'); + const docs = at >= 0 ? resolve(process.cwd(), argv[at + 1] ?? '') : DOCS; + const named = argv.filter((a, k) => !a.startsWith('--') && argv[k - 1] !== '--docs'); + const files = named.length ? named.map((a) => resolve(process.cwd(), a)) : englishPages(docs); + // Paths print relative to the tree's root (`content/docs/…`), or absolute when outside it. + const shown = (from, p) => { const r = relative(from, p); return r.startsWith('..') ? p : r || '.'; }; + const base = resolve(docs, '../..'); + const rows = checkPages(files, tc); + console.log(`@objectstack/spec ${tc.version} (pinned in ${rel(join(SPEC_HOME, 'package.json'))}) · ${files.length} page(s) under ${shown(process.cwd(), docs)}`); + for (const r of rows) console.log(`${r.verdict.padEnd(4)} ${shown(base, r.file)}${r.line ? `:${r.line}` : ''} — ${r.how}${r.why ? ` — ${r.why}` : ''}`); + const count = (v) => rows.filter((r) => r.verdict === v).length; + const failed = count('FAIL'); + const pages = new Set(rows.filter((r) => r.verdict === 'FAIL').map((r) => r.file)).size; + const tally = `${count('PASS')} pass · ${count('N/A')} not a spec sample · ${count('SKIP')} opted out`; + console.log(failed + ? `\n✗ doc samples: ${failed} block(s) refused or unchecked on ${pages} page(s) · ${tally}` + : `\n✓ doc samples: every ts/js block on ${files.length} English page(s) parses with @objectstack/spec ${tc.version} · ${tally}`); + return failed ? 1 : 0; +} + +/* ------------------------------------------------------------------ self-test */ + +/* + * Each case is a page, written inline, and the verdict every row of it must get, in line order. + * The cases run the gate's own entry point (`checkPages`) against the pinned spec — the fixtures + * never read content/docs — so a rule that stopped being able to go red fails here, and so does + * a spec bump that changes what a fixture means. On top of the table, every fragment kind must + * appear in at least one PASS row and one FAIL row: a kind is a wrap, and a wrap that cannot fail + * is a skip with a nicer name. + */ +const fence = (code, lang = 'ts') => `\`\`\`${lang}\n${code}\n\`\`\``; +const page = (...parts) => `# Fixture\n\n${parts.join('\n\n')}\n`; +const FLOW = (extra = '', node = `{ id: 'note', type: 'notify', label: 'Note', config: { recipients: '{record.owner}', title: 'Hi' } }`) => `defineFlow({ + name: 'f', label: 'F', type: 'record_change', status: 'active',${extra} + nodes: [ + { id: 'start', type: 'start', label: 'S', config: { objectName: 'ticket', triggerType: 'record-after-create' } }, + ${node}, + { id: 'end', type: 'end', label: 'E' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'note' }, { id: 'e2', source: 'note', target: 'end' }], +});`; +const KANBAN = `{ type: 'kanban', columns: ['subject'], kanban: { groupByField: 'status', columns: ['subject'] } }`; +const CASES = [ + // The page: fences, markers. + ['fences: tildes, a longer closing fence, a three-space indent', page(`~~~ts\nObjectSchema.create({ name: 'a', fields: {} });\n~~~`, ' ````ts\nObjectSchema.create({ name: \'b\', fields: {} });\n `````'), ['PASS', 'PASS']], + ['a fence that is never closed', page('```ts\nObjectSchema.create({ name: \'a\', fields: {} });'), ['FAIL']], + ['a marker with a reason skips its block; the block it would hide is refused', page('{/* doc-sample: skip — a counter-example: the refused shape */}', fence(`defineView({ bogus: 1 });`)), ['SKIP']], + ['…the same block without the marker', page(fence(`defineView({ bogus: 1 });`)), ['FAIL']], + ['a marker without a reason', page('{/* doc-sample: skip */}', fence(`defineView({ bogus: 1 });`)), ['FAIL']], + ['a marker that is not the last line before a fence', page('{/* doc-sample: skip — drifted */}', 'A paragraph.', fence(`ObjectSchema.create({ name: 'a', fields: {} });`)), ['FAIL', 'PASS']], + ['a marker on a block this gate does not read', page('{/* doc-sample: skip — bash */}', fence('os dev', 'bash')), ['FAIL']], + ['bash, json and text blocks are not read', page(fence('os dev', 'bash'), fence('{ "a": 1 }', 'json'), fence('Agent → Skill', 'text')), []], + // Not a spec sample. + ['a block that imports only from another package, and a later one that constructs what it imported', page(fence(`import { StoragePlugin } from '@objectstack/service-storage';\nnew StoragePlugin({ adapter: 'local' });`), fence(`new StoragePlugin({ adapter: 's3' });`)), ['N/A', 'N/A']], + ['a construction nothing on the page imported is not waved through', page(fence(`new StoragePlugin({ adapter: 's3' });`)), ['FAIL']], + // Imports. + ['a name the entry point exports, and one it does not', page(fence(`import { defineView } from '@objectstack/spec/ui';\ndefineView({ list: { type: 'grid', columns: ['a'] } });`), fence(`import { Action } from '@objectstack/spec';\nAction.create({ name: 'go_now', label: 'Go', type: 'url', target: 'https://example.com' });`)), ['PASS', 'FAIL']], + ['an entry point the spec does not have', page(fence(`import { Field } from '@objectstack/spec/fields';`)), ['FAIL']], + ['a type import the entry point declares, its value parsed with the matching schema', page(fence(`import type { Datasource } from '@objectstack/spec/data';\nexport const Db: Datasource = { name: 'main_db', label: 'Main', driver: 'sqlite', config: { filename: ':memory:' } };`)), ['PASS']], + ['…a type it does not declare', page(fence(`import type { StateMachineConfig } from '@objectstack/spec/automation';\nexport const m: StateMachineConfig = { id: 'm', initial: 'a', states: {} };`)), ['FAIL']], + ['…a declared type whose value the schema refuses', page(fence(`import type { Datasource } from '@objectstack/spec/data';\nexport const Db: Datasource = { name: 'main_db', label: 'Main', driver: 'postgres', config: { connection: { host: 'h' } } };`)), ['FAIL']], + // Statements. + ['constructors are the parse: accepted, and an unknown key', page(fence(`defineView({ list: { type: 'grid', columns: ['a'] } });`), fence(`defineView({ list: { type: 'grid', columns: ['a'] }, actions: ['go'] });`)), ['PASS', 'FAIL']], + ['defineDataset does not parse, so the gate parses what it returns', page(fence(`defineDataset({ name: 'sales', label: 'Sales', object: 'deal', dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], measures: [{ name: 'deal_count', aggregate: 'count' }] });`), fence(`defineDataset({ name: 'sales', label: 'Sales', object: 'deal', dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], measures: [{ name: 'deal_count', aggregate: 'count', certified: true }] });`)), ['PASS', 'FAIL']], + ['a Field.* statement is judged as a field of an object', page(fence(`Field.text({ label: 'Code', maxLength: 8 })`), fence(`Field.text({ label: 'Code', pattern: '^[A-Z]+$' })`)), ['PASS', 'FAIL']], + ['every builtin node is also parsed against its executor contract', page(fence(FLOW()), fence(FLOW('', `{ id: 'note', type: 'notify', label: 'Note', config: { recipients: '{record.owner}', title: 'Hi', subject: 's' } }`))), ['PASS', 'FAIL']], + ['a stack with requires is judged again with the flows on its page', page(fence(FLOW()), fence(`export default defineStack({ requires: ['automation'] });`)), ['PASS', 'FAIL']], + ['…and passes with what those flows need', page(fence(FLOW()), fence(`export default defineStack({ requires: ['automation', 'triggers'] });`)), ['PASS', 'PASS']], + ['a relative import binds what an earlier or later block declares; an undeclared one is refused', page(fence(`import { defineStack } from '@objectstack/spec';\nimport * as objects from './src/objects';\nexport default defineStack({ manifest: { id: 'a.b', namespace: 'acme', version: '1.0.0', type: 'app', name: 'A' }, objects: Object.values(objects) });`), fence(`export const Task = ObjectSchema.create({ name: 'acme_task', fields: { subject: Field.text({ label: 'S' }) } });`), fence(`import { Missing } from './src/missing';`)), ['PASS', 'PASS', 'FAIL']], + ['…and the stack refuses what it assembled when that is wrong', page(fence(`import * as objects from './src/objects';\nexport default defineStack({ manifest: { id: 'a.b', namespace: 'acme', version: '1.0.0', type: 'app', name: 'A' }, objects: Object.values(objects) });`), fence(`export const Task = ObjectSchema.create({ name: 'todo_task', fields: { subject: Field.text({ label: 'S' }) } });`)), ['FAIL', 'PASS']], + ['a block that reaches no spec parse', page(fence(`const answer = 42;`)), ['FAIL']], + ['a plain object needs a constructor, a spec type, or a comment saying what it is', page(fence(`const board = { name: 'b', label: 'B', widgets: [] };`)), ['FAIL']], + ['a block that does not evaluate', page(fence(`defineView({ list: ListOptions });`)), ['FAIL']], + // CEL strings: the slot shape only. + ['CEL strings, one per line; a blank one is refused', page(fence(`'record.amount > 10'\n'!isBlank(record.notes)'`), fence(`'record.amount > 10'\n''`)), ['PASS', 'FAIL']], + // Fragments. + ['a fragment with no comment saying what it is', page(fence(KANBAN), fence(`config: { approvers: [] }`)), ['FAIL', 'FAIL']], + ['keys whose every value is built by Field.* are fields of an object', page(fence(`a: Field.text({ label: 'A' }),\nb: Field.number({ label: 'B', min: 1 }),`), fence(`a: Field.text({ label: 'A', helpText: 'x' }),`)), ['PASS', 'FAIL']], + ['one list view: #307 added the top-level columns the old kanban lacked', page(fence(`// One list view; the container and \`data\` are omitted.\n${KANBAN}`), fence(`// One list view; the container and \`data\` are omitted.\n{ type: 'kanban', kanban: { groupByField: 'status', columns: ['subject'] } }`)), ['PASS', 'FAIL']], + ['one form view', page(fence(`// One form view; the container and \`data\` are omitted.\n{ type: 'simple', sections: [{ fields: ['a'] }] }`), fence(`// One form view; the container and \`data\` are omitted.\n{ type: 'simple', sections: [{ fields: ['a'] }], submitBehavior: { kind: 'confetti' } }`)), ['PASS', 'FAIL']], + ['one flow node: the retired step shape is refused', page(fence(`// One flow node; the flow around it is omitted.\n{ id: 'approve', type: 'approval', label: 'Approve', config: { approvers: [{ type: 'manager' }] } }`), fence(`// One flow node; the flow around it is omitted.\n{ type: 'action', action: 'approve_invoice', inputs: {} }`)), ['PASS', 'FAIL']], + ['the config of a node, including a start node', page(fence(`// The \`config\` of an \`approval\` node; the node around it is omitted.\nconfig: { approvers: [{ type: 'manager' }], behavior: 'unanimous' }`), fence(`// The \`config\` of a \`start\` node; the rest of the flow is omitted.\nconfig: { objectName: 'deal', triggerType: 'record-after-update', condition: 'record.amount > 1' }`), fence(`// The \`config\` of an \`approval\` node; the node around it is omitted.\nconfig: { approvers: [{ type: 'manager' }], resolveAs: 'department' }`)), ['PASS', 'PASS', 'FAIL']], + ["a start node's config.timeRelative, in variants", page(fence(`// Two variants of the start node's config.timeRelative.\ntimeRelative: { object: 'doc', dateField: 'due', withinDays: 30 }\ntimeRelative: { object: 'doc', dateField: 'due', offsetDays: [7] }`), fence(`// Two variants of the start node's config.timeRelative.\ntimeRelative: { object: 'doc', dateField: 'due', withinDays: 30, offsetDays: [7] }`)), ['PASS', 'FAIL']], + ['keys of a flow, with the nodes its comment names', page(fence("// Keys of a `record_change` flow on `invoice`. The nodes are omitted: `charge` is an `http` node, and `paid` an `update_record`.\nedges: [{ id: 'ok', source: 'charge', target: 'paid' }],\nerrorHandling: { strategy: 'retry', maxRetries: 3 },"), fence("// Keys of a `record_change` flow on `invoice`. The nodes are omitted: `charge` is an `http` node, and `paid` an `update_record`.\nedges: [{ id: 'ok', source: 'charge', target: 'paid' }],\nerrorHandling: { strategy: 'retry' },"), fence("// Keys of a `record_change` flow on `invoice`. The nodes are omitted: `ask` is a `screen` node.\nedges: [{ id: 'ok', source: 'ask', target: 'end' }],")), ['PASS', 'FAIL', 'FAIL']], + ['one field', page(fence(`// One field.\n{ type: 'lookup', reference: 'account', deleteBehavior: 'set_null' }`), fence(`// One field.\n{ type: 'lookup', reference: 'account', deleteBehavior: 'orphan' }`)), ['PASS', 'FAIL']], + ['keys of a typed field', page(fence("// Keys of a `select` field; its other keys are omitted.\noptions: [{ value: 'low', label: 'Low' }],"), fence("// Keys of a `select` field; its other keys are omitted.\noptions: [],")), ['PASS', 'FAIL']], + ['fields of an object', page(fence(`// Fields of an object; the object around them is omitted.\nfields: { a: Field.text({ label: 'A' }) }`), fence(`// Fields of an object; the object around them is omitted.\nfields: { a: Field.decimal({ label: 'A' }) }`)), ['PASS', 'FAIL']], + ['keys of an object', page(fence(`// Keys of an object; its name and fields are omitted.\nenable: { apiMethods: ['get', 'list'] }`), fence(`// Keys of an object; its name and fields are omitted.\nenable: { trash: true }`)), ['PASS', 'FAIL']], + ['validation rules', page(fence("// One validation rule, an entry of the object's `validations`.\n{ type: 'script', name: 'positive', message: 'M', condition: 'record.amount <= 0' }"), fence("// One validation rule, an entry of the object's `validations`.\n{ name: 'positive', message: 'M', condition: 'record.amount <= 0' }")), ['PASS', 'FAIL']], + ['keys of an app', page(fence(`// Keys of an app; its other keys are omitted.\nnavigation: [{ id: 'nav_a', type: 'object', label: 'A', objectName: 'a' }]`), fence(`// Keys of an app; its other keys are omitted.\nmobileNavigation: { mode: 'bottom_nav' }`)), ['PASS', 'FAIL']], + ['one navigation item', page(fence(`// One navigation item of an app.\n{ id: 'nav_board', type: 'dashboard', label: 'Board', dashboardName: 'board' }`), fence(`// One navigation item of an app.\n{ id: 'nav_board', type: 'dashboard', label: 'Board', dashboard: 'board' }`)), ['PASS', 'FAIL']], + ['one dashboard', page(fence(`// One dashboard.\nconst board = { name: 'board', label: 'Board', refreshIntervalSeconds: 60, widgets: [] };`), fence(`// One dashboard.\nconst board = { name: 'board', label: 'Board', refreshInterval: 60, widgets: [] };`)), ['PASS', 'FAIL']], + ['keys of a dashboard; only the widget keys the comment says are omitted are supplied', page(fence("// Keys of a dashboard; each widget's dataset, values and title are omitted.\nwidgets: [{ id: 'total' }]"), fence("// Keys of a dashboard; each widget's dataset is omitted.\nwidgets: [{ id: 'total' }]")), ['PASS', 'FAIL']], + ['keys of a dashboard widget', page(fence(`// Keys of a dashboard widget; its other keys are omitted.\nlayout: { x: 0, y: 0, w: 6, h: 4 }`), fence(`// Keys of a dashboard widget; its other keys are omitted.\ncategoryField: 'region'`)), ['PASS', 'FAIL']], + ['one page', page(fence(`// One page.\nconst home = { name: 'home', label: 'Home', type: 'home', regions: [] };`), fence(`// One page.\nconst home = { name: 'home', label: 'Home', type: 'portal', regions: [] };`)), ['PASS', 'FAIL']], + ['keys of a page', page(fence(`// Keys of a page; its other keys are omitted.\nvariables: [{ name: 'tab', type: 'string' }]`), fence(`// Keys of a page; its other keys are omitted.\nvariables: [{ name: 'tab', type: 'date' }]`)), ['PASS', 'FAIL']], + ['one page component', page(fence(`// One page component; the page and region around it are omitted.\n{ type: 'chart', id: 'trend', properties: { dataset: 'sales', values: ['revenue'] } }`), fence(`// One page component; the page and region around it are omitted.\n{ type: 'chart', id: 'trend', object: 'deal', categoryField: 'stage' }`)), ['PASS', 'FAIL']], + ['keys of a form view', page(fence(`// Keys of a form view; its other keys are omitted.\nsubmitBehavior: { kind: 'thank-you', title: 'Thanks' }`), fence(`// Keys of a form view; its other keys are omitted.\nsubmitBehavior: { kind: 'confetti' }`)), ['PASS', 'FAIL']], + ['keys of a view container', page(fence(`// Keys of a view container; its other keys are omitted.\nform: { type: 'simple', sections: [{ fields: ['a'] }] }`), fence(`// Keys of a view container; its other keys are omitted.\nactions: ['approve']`)), ['PASS', 'FAIL']], + ['keys of a permission set', page(fence(`// Keys of a permission set; its other keys are omitted.\nobjects: { deal: { allowRead: true, allowExport: true } }`), fence(`// Keys of a permission set; its other keys are omitted.\nobjects: { deal: { read: true } }`)), ['PASS', 'FAIL']], +]; + +function selfTest(verbose = false) { + let tc; + try { tc = loadToolchain(); } catch (e) { console.error(`✗ self-test: NOT MEASURED — ${e.message}`); return 1; } + const wrong = []; + const seen = new Map(); + const kinds = makeEvaluator(tc).KINDS.map((k) => k.id); + for (const [name, text, want] of CASES) { + const rows = checkPages(['fixture.mdx'], tc, () => text); + const got = rows.map((r) => r.verdict); + const ok = got.length === want.length && got.every((v, k) => v === want[k]); + console.log(`${ok ? '✓' : '✗'} ${name}: ${got.join(' ') || '(no rows)'}${ok ? '' : `, expected ${want.join(' ') || '(no rows)'}`}`); + if (!ok) wrong.push(name); + if (!ok || verbose) for (const r of rows) console.error(` ${r.verdict} :${r.line} ${r.how}${r.why ? ` — ${r.why}` : ''}`); + for (const r of rows) for (const k of kinds) if (r.how?.startsWith(k)) seen.set(`${k}|${r.verdict}`, true); + } + const unproven = kinds.flatMap((k) => ['PASS', 'FAIL'].filter((v) => !seen.has(`${k}|${v}`)).map((v) => `${k} has no ${v} fixture`)); + for (const u of unproven) console.error(`✗ ${u}`); + const bad = wrong.length + unproven.length; + console.log(bad + ? `\n✗ self-test: ${wrong.length} case(s) wrong, ${unproven.length} fragment kind verdict(s) unproven` + : `\n✓ self-test: ${CASES.length} case(s) hold, and each of the ${kinds.length} fragment kinds has a fixture it passes and one it refuses (@objectstack/spec ${tc.version})`); + return bad ? 1 : 0; +} + +const argv = process.argv.slice(2); +process.exitCode = argv.includes('--self-test') ? selfTest(argv.includes('--verbose')) : gate(argv); diff --git a/.github/scripts/doc-samples/package-lock.json b/.github/scripts/doc-samples/package-lock.json new file mode 100644 index 0000000..38e60f5 --- /dev/null +++ b/.github/scripts/doc-samples/package-lock.json @@ -0,0 +1,55 @@ +{ + "name": "@objectos/doc-samples-spec", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@objectos/doc-samples-spec", + "version": "0.0.0", + "license": "Apache-2.0", + "devDependencies": { + "@objectstack/spec": "17.7.0" + } + }, + "node_modules/@objectstack/spec": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/@objectstack/spec/-/spec-17.7.0.tgz", + "integrity": "sha512-PmKFK2C+NHiVoCAC+hYifJJWKiiJxZxN/FlpCQKwqPwajPxCcEUk0oVH0Af0yG3AFRQVCXxwIgEHGGeAa8Z4tA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "pg-connection-string": "^2.14.0", + "zod": "^4.6.5" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "ai": "^7.0.0" + }, + "peerDependenciesMeta": { + "ai": { + "optional": true + } + } + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.github/scripts/doc-samples/package.json b/.github/scripts/doc-samples/package.json new file mode 100644 index 0000000..9253ce4 --- /dev/null +++ b/.github/scripts/doc-samples/package.json @@ -0,0 +1,10 @@ +{ + "name": "@objectos/doc-samples-spec", + "version": "0.0.0", + "private": true, + "description": "The @objectstack/spec that .github/scripts/check-doc-samples.mjs parses the docs code samples with. Installed with `npm ci --prefix .github/scripts/doc-samples`, outside the pnpm workspace on purpose: see the header of check-doc-samples.mjs.", + "license": "Apache-2.0", + "devDependencies": { + "@objectstack/spec": "17.7.0" + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c257e7a..266f341 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,31 @@ jobs: - name: Generated zh-Hant is current run: node apps/docs/scripts/gen-zh-hant.mjs --check + # #316: every ts/js block on an English page is parsed against the + # `@objectstack/spec` pinned in `.github/scripts/doc-samples/` — the + # defect #301, #307 and #316 each found by hand, a sample a reader + # copies and gets a schema error. The script header has the rules, + # the fragment convention and the opt-out marker. + # + # The spec is installed here, with npm, OUTSIDE the pnpm workspace on + # purpose: as a workspace dependency it re-resolved fumadocs-core's zod + # peer and put two zod copies in the docs build. `--ignore-scripts` + # because nothing in it needs a build step. This install also serves + # the gate's `--self-test`, which `pnpm turbo run test` runs below. + # + # Before `type-check`, like the step above: it needs no build, it reads + # sources, and a refused sample reported as a build failure would be a + # wrong first diagnosis. Dependabot bumps the pin (`.github/dependabot.yml`), + # so a spec release that newly refuses a sample turns its own pull + # request red here. `shell: bash` for the pipefail reason given on the + # `Locale surface` step below. + - name: Install the spec the docs samples are parsed with + run: npm ci --prefix .github/scripts/doc-samples --ignore-scripts --no-audit --no-fund + + - name: Docs code samples parse + shell: bash + run: node .github/scripts/check-doc-samples.mjs | tee -a "$GITHUB_STEP_SUMMARY" + - run: pnpm turbo run type-check --continue - run: pnpm turbo run build diff --git a/content/docs/build/agents.de.mdx b/content/docs/build/agents.de.mdx deleted file mode 100644 index 89183fa..0000000 --- a/content/docs/build/agents.de.mdx +++ /dev/null @@ -1,192 +0,0 @@ ---- -title: Agents -description: KI-Assistenten für Endnutzer — Agent → Skill → Tool — verdrahtet aus Ihren Daten und Aktionen. -translation: - source_sha: e3b9515cd83593f2841304ea479e9be0c0cc381acf89a8cb5499d5aed498b8bf - guide_rev: 1 - mode: auto ---- - -Agents sind die KI-Assistenten, mit denen Ihre **Endnutzer** chatten — ein -Helpdesk-Co-Pilot, ein Vertriebs-BDR, ein interner HR-Q&A-Bot. Sie setzen auf -den Daten und Aktionen auf, die Sie bereits definiert haben; Sie schreiben -keinen neuen Code, sondern komponieren vorhandene Primitive zu einer Persona. - -Dreistufige Architektur, abgestimmt auf Salesforce Agentforce, Microsoft -Copilot Studio und ServiceNow Now Assist: - -```text -Agent ──→ Skill ──→ Tool -(persona) (capability) (callable function) -``` - -| Stufe | Was es ist | Beispiel | -|---|---|---| -| **Tool** | Eine aufrufbare Funktion (Aktion, Abfrage, Wissenssuche, MCP-Methode) | `create_ticket`, `get_order_status`, `search_kb` | -| **Skill** | Ein benanntes Bündel verwandter Tools mit gemeinsamen LLM-Anweisungen | `ticket_management` = create + update + close + escalate | -| **Agent** | Eine Persona mit einer Rolle, einem System-Prompt, angehängten Skills und Wissen | `tier1_support` = empathisch, verifiziert die Identität, verfügt über `ticket_management` + `kb_search` | - -## Einen Agent definieren (eine Datei) - -```ts -// src/agents/tier1_support.agent.ts -import { defineAgent } from '@objectstack/spec/ai'; - -export const tier1Support = defineAgent({ - name: 'tier1_support', - label: 'First Line Support', - role: 'Help Desk Assistant', - instructions: ` - You are a friendly first-line support agent. - Always verify the user's identity before discussing account specifics. - Escalate to tier 2 if the issue involves billing or security. - `, - skills: ['ticket_management', 'knowledge_search'], - knowledge: { - topics: ['faq', 'policies'], - indexes: ['support_docs'], - }, - model: { provider: 'openai', model: 'gpt-4o', temperature: 0.3 }, - memory: { shortTerm: { maxMessages: 30 } }, -}); -``` - -Oder in der Console: **Console → Agents → New Agent**. - -Oder — und das ist der entscheidende Punkt — sagen Sie zum AI Builder: - -> *„Erstelle einen Tier-1-Support-Agent, der das Ticket-Management übernimmt -> und die FAQ durchsucht. Er soll die Identität verifizieren, bevor er über -> Kontodetails spricht."* - -## Einen Skill definieren - -```ts -// src/skills/ticket_management.skill.ts -import { defineSkill } from '@objectstack/spec/ai'; - -export const ticketManagement = defineSkill({ - name: 'ticket_management', - label: 'Ticket Management', - instructions: ` - Always confirm the ticket subject and priority before creating one. - Use 'urgent' priority sparingly — only for outages or security incidents. - `, - tools: [ - 'create_ticket', - 'update_ticket', - 'close_ticket', - 'escalate_ticket', - 'action_*', // wildcard: pick up any future actions on the active object - ], -}); -``` - -Skills sind **die richtige Einheit für die Wiederverwendung**. Ein Skill -funktioniert über viele Agents hinweg. - -## Tools stammen aus Ihren deklarierten Metadaten - -Jede `*.action.ts`, die Sie deklarieren, materialisiert sich automatisch als -`action_`-Tool — ohne separate Verdrahtung. Wenn Sie also bereits -`escalate_ticket` als Aktion auf dem Objekt `support_ticket` definiert haben, -können sowohl der AI Builder als auch Ihre Agents sie aufrufen. Berechtigungen -gelten weiterhin: Der Agent ruft die Aktion **als der Nutzer** auf, sodass das -Berechtigungsset des Nutzers entscheidet, ob sie erfolgreich ist. - -Sie können außerdem Folgendes bereitstellen: - -| Tool-Typ | Quelle | -|---|---| -| **Action** | Jede `*.action.ts` in jedem installierten Paket | -| **Flow** | Jeder manuelle Flow (`type: 'manual'`) | -| **Query** | Gespeicherte ObjectQL-Abfragen (`*.query.ts`) | -| **Wissenssuche** | Jeder Wissensindex, der dem Agent angehängt ist | -| **MCP-Methode** | Alles, was von einem angehängten MCP-Server bereitgestellt wird | -| **Integrierte Metadaten-Tools** | `create_object`, `add_field`, … — jedoch nur für Admin-Agents | - -## Muster für Ambient-Assistenten - -Wenn Sie **eine Chatbox für die gesamte App** wollen (im Stil von Claude Code / -Agentforce), anstatt den Nutzer zur Auswahl eines Agents zu zwingen, -deklarieren Sie einen `defaultAgent` in den App-Metadaten und rufen den -Ambient-Chat-Endpunkt mit dem App-Kontext auf: - -```text -POST /api/v1/ai/chat { context: { appName: 'crm' }, ... } -``` - -Wenn `context.appName` zu einer App aufgelöst wird, die einen `defaultAgent` -deklariert, wählt die Laufzeitumgebung diesen Agent automatisch aus — der -Nutzer wählt nie aus einer Liste. Das integrierte KI-Panel der Console nutzt -dies. Die Laufzeitumgebung löst auf: - -1. Den **Standard-Agent** für die aktive App (der `defaultAgent` der App) - oder den ersten Agent, auf den der Nutzer Zugriff hat. -2. Die **aktiven Skills** — die `skills:`-Liste des Agents, geladen aus der - Skill Registry, gefiltert nach dem Berechtigungsset des Nutzers und dem - aktuellen Objekt-/Datensatzkontext. -3. Das **Wissen**, das dem Agent angehängt ist. - -Sie müssen nicht verdrahten, welcher Agent wo angezeigt wird. Deklarieren Sie -eine App, legen Sie ihren `defaultAgent` fest, und er erscheint. - -## Berechtigungen - -| Fähigkeit | Berechtigung | -|---|---| -| Mit einem Agent chatten | `ai:chat` (und Zugriff auf die Tools der Skills des Agents) | -| Metadatenänderungen genehmigen | `ai:approve` | -| Agents und Skills definieren / bearbeiten | `ai:author` (typischerweise Setup Administrator) | -| KI-Konversationen lesen (Audit) | `ai:read` | - -Konversationen sind auf den Nutzer beschränkt — ein Nutzer kann den -Chatverlauf eines anderen nicht sehen, es sei denn, er verfügt über eine -delegierte Berechtigung. - -## Speicher und Konversationsstatus - -Der `memory`-Block des Agents hat zwei Stufen: - -| Feld | Was es tut | -|---|---| -| `shortTerm.maxMessages` | Aktuelle Nachrichten, die im Arbeitsspeicher gehalten werden (Standard `50`) | -| `shortTerm.maxTokens` | Optionales Token-Budget für das Kurzzeit-Kontextfenster | -| `longTerm.enabled` | Speicher über Sitzungen hinweg persistieren (Standard `false`) | -| `longTerm.store` | Backend für persistierten Speicher: `vector` (Standard), `database` oder `redis` | -| `reflectionInterval` | Alle N Interaktionen reflektieren, um das Verhalten zu verfeinern | - -Das Beschneiden des Live-Kontextfensters wird durch die -Token-Budget-Strategie der Konversation gesteuert — `sliding_window` -(Standard), `fifo`, `importance`, `semantic` oder `summary`. - -Konversationszeilen liegen in `ai_conversations`. Tool-Aufruf-Ergebnisse und -ausstehende Aktionen verweisen zur Auditierung auf die zugehörige Konversation -zurück. - -## Observability - -Jeder Agent-Lauf gibt aus: - -- `audit:ai:chat`-Ereignisse (pro Runde) -- `audit:ai:tool`-Ereignisse (pro Tool-Aufruf, mit Eingaben + Ausgaben) -- `audit:ai:pending_action`-Ereignisse (wenn eine Mutation in die Warteschlange gestellt wird) -- Token-Zählmetriken (pro Modell, pro Anbieter) in das Audit-Log für die - Kostenzuordnung - -Sie können diese in Ihren üblichen Observability-Stack einbinden — siehe -[Observability](/docs/operate/observability). - -## Hinweis zur Mandantenfähigkeit - -Agents sind pro Environment. Der `tier1_support`-Agent von Mandant A sieht -niemals die Daten, Konversationen oder das Wissen von Mandant B — selbst wenn -Sie dieselbe Agent-Definition in einem Marketplace-Paket ausliefern. - -## Wie es weitergeht - -- [AI Builder](/docs/build/ai-builder) — der Assistent zur Build-Zeit -- [IDE Skills](/docs/build/ai-skills) — `npx skills add objectstack-ai/objectstack/skills`, damit Ihr IDE-Agent Metadaten korrekt erstellt -- [Actions](/docs/build/interface/actions) — deklarieren Sie die Tools, die Ihre Agents verwenden werden -- [AI Service](/docs/configure/ai) — Anbieter-, Embedder- und MCP-Einrichtung -- [`@objectstack/spec/ai`](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec/src/ai) — vollständige Schemas diff --git a/content/docs/build/agents.es.mdx b/content/docs/build/agents.es.mdx deleted file mode 100644 index 6cfe478..0000000 --- a/content/docs/build/agents.es.mdx +++ /dev/null @@ -1,192 +0,0 @@ ---- -title: Agentes -description: Asistentes de IA para usuarios finales — Agente → Skill → Tool — conectados desde tus datos y acciones. -translation: - source_sha: e3b9515cd83593f2841304ea479e9be0c0cc381acf89a8cb5499d5aed498b8bf - guide_rev: 1 - mode: auto ---- - -Los agentes son los asistentes de IA con los que conversan tus **usuarios -finales** — un copiloto de mesa de ayuda, un BDR de ventas, un bot interno -de preguntas y respuestas de RR. HH. Se apoyan en los datos y las acciones -que ya has definido; no escribes código nuevo, compones primitivas -existentes para formar una persona. - -Arquitectura de tres niveles, alineada con Salesforce Agentforce, Microsoft -Copilot Studio y ServiceNow Now Assist: - -```text -Agent ──→ Skill ──→ Tool -(persona) (capability) (callable function) -``` - -| Nivel | Qué es | Ejemplo | -|---|---|---| -| **Tool** | Una función invocable (acción, consulta, búsqueda de conocimiento, método MCP) | `create_ticket`, `get_order_status`, `search_kb` | -| **Skill** | Un paquete con nombre de tools relacionadas con instrucciones de LLM compartidas | `ticket_management` = create + update + close + escalate | -| **Agent** | Una persona con un rol, prompt de sistema, skills adjuntas y conocimiento | `tier1_support` = empático, verifica identidad, tiene `ticket_management` + `kb_search` | - -## Definir un agente (un archivo) - -```ts -// src/agents/tier1_support.agent.ts -import { defineAgent } from '@objectstack/spec/ai'; - -export const tier1Support = defineAgent({ - name: 'tier1_support', - label: 'First Line Support', - role: 'Help Desk Assistant', - instructions: ` - You are a friendly first-line support agent. - Always verify the user's identity before discussing account specifics. - Escalate to tier 2 if the issue involves billing or security. - `, - skills: ['ticket_management', 'knowledge_search'], - knowledge: { - topics: ['faq', 'policies'], - indexes: ['support_docs'], - }, - model: { provider: 'openai', model: 'gpt-4o', temperature: 0.3 }, - memory: { shortTerm: { maxMessages: 30 } }, -}); -``` - -O en Console: **Console → Agents → New Agent**. - -O — y este es el punto clave — dile al AI Builder: - -> *"Crea un agente de soporte de nivel 1 que gestione la administración de -> tickets y busque en las preguntas frecuentes. Debe verificar la identidad -> antes de hablar de los detalles de la cuenta."* - -## Definir una Skill - -```ts -// src/skills/ticket_management.skill.ts -import { defineSkill } from '@objectstack/spec/ai'; - -export const ticketManagement = defineSkill({ - name: 'ticket_management', - label: 'Ticket Management', - instructions: ` - Always confirm the ticket subject and priority before creating one. - Use 'urgent' priority sparingly — only for outages or security incidents. - `, - tools: [ - 'create_ticket', - 'update_ticket', - 'close_ticket', - 'escalate_ticket', - 'action_*', // wildcard: pick up any future actions on the active object - ], -}); -``` - -Las skills son **la unidad correcta para la reutilización**. Una skill -funciona en muchos agentes. - -## Las tools provienen de tus metadatos declarados - -Cada `*.action.ts` que declaras se materializa automáticamente como una -tool `action_` — sin conexiones por separado. Por lo tanto, si ya has -definido `escalate_ticket` como una Action en el objeto `support_ticket`, -tanto el AI Builder como tus agentes pueden invocarla. Los permisos siguen -aplicándose: el agente invoca la acción **como el usuario**, por lo que el -conjunto de permisos del usuario decide si tiene éxito. - -También puedes exponer: - -| Tipo de tool | Origen | -|---|---| -| **Action** | Cualquier `*.action.ts` en cualquier paquete instalado | -| **Flow** | Cualquier flujo manual (`type: 'manual'`) | -| **Query** | Consultas ObjectQL guardadas (`*.query.ts`) | -| **Knowledge search** | Cualquier índice de conocimiento adjunto al agente | -| **MCP method** | Cualquier cosa expuesta por un servidor MCP adjunto | -| **Built-in metadata tools** | `create_object`, `add_field`, … — pero solo para agentes administradores | - -## Patrón de asistente ambiental - -Si quieres **un solo cuadro de chat para toda la app** (estilo Claude Code / -Agentforce) en lugar de obligar al usuario a elegir un agente, declara un -`defaultAgent` en los metadatos de la App y llama al endpoint de chat -ambiental con el contexto de la app: - -```text -POST /api/v1/ai/chat { context: { appName: 'crm' }, ... } -``` - -Cuando `context.appName` se resuelve a una app que declara un -`defaultAgent`, el runtime selecciona automáticamente ese agente — el -usuario nunca elige de una lista. El panel de IA integrado de Console usa -esto. El runtime resuelve: - -1. El **agente predeterminado** para la app activa (el `defaultAgent` de la - app), o el primer agente al que el usuario tenga acceso. -2. Las **skills activas** — la lista `skills:` del agente cargada desde el - Skill Registry, filtrada por el conjunto de permisos del usuario y el - contexto actual de objeto/registro. -3. El **conocimiento** adjunto al agente. - -No tienes que conectar qué agente se muestra dónde. Declara una app, -configura su `defaultAgent` y aparece. - -## Permisos - -| Capacidad | Permiso | -|---|---| -| Conversar con un agente | `ai:chat` (y acceso a las tools de las skills del agente) | -| Aprobar cambios de metadatos | `ai:approve` | -| Definir / editar agentes y skills | `ai:author` (normalmente Setup Administrator) | -| Leer conversaciones de IA (auditoría) | `ai:read` | - -Las conversaciones tienen alcance por usuario — un usuario no puede ver el -historial de chat de otro a menos que tenga una concesión delegada. - -## Memoria y estado de la conversación - -El bloque `memory` del agente tiene dos niveles: - -| Campo | Qué hace | -|---|---| -| `shortTerm.maxMessages` | Mensajes recientes guardados en la memoria de trabajo (predeterminado `50`) | -| `shortTerm.maxTokens` | Presupuesto opcional de tokens para la ventana de contexto a corto plazo | -| `longTerm.enabled` | Persiste la memoria entre sesiones (predeterminado `false`) | -| `longTerm.store` | Backend para la memoria persistida: `vector` (predeterminado), `database` o `redis` | -| `reflectionInterval` | Reflexiona cada N interacciones para refinar el comportamiento | - -La poda de la ventana de contexto en vivo se rige por la estrategia de -presupuesto de tokens de la conversación — `sliding_window` (predeterminado), -`fifo`, `importance`, `semantic` o `summary`. - -Las filas de conversación viven en `ai_conversations`. Los resultados de las -llamadas a tools y las acciones pendientes hacen referencia cruzada a la -conversación de origen para la auditoría. - -## Observabilidad - -Cada ejecución de un agente emite: - -- eventos `audit:ai:chat` (por turno) -- eventos `audit:ai:tool` (por llamada a tool, con entradas + salidas) -- eventos `audit:ai:pending_action` (cuando se pone en cola una mutación) -- métricas de recuento de tokens (por modelo, por proveedor) en el registro - de auditoría para la facturación interna - -Puedes conectarlas a tu pila de observabilidad habitual — consulta -[Observability](/docs/operate/observability). - -## Nota sobre multi-tenant - -Los agentes son por Environment. El agente `tier1_support` del tenant A -nunca ve los datos, conversaciones ni conocimiento del tenant B — incluso si -distribuyes la misma definición de agente en un paquete del marketplace. - -## A dónde ir después - -- [AI Builder](/docs/build/ai-builder) — el asistente de tiempo de build -- [IDE Skills](/docs/build/ai-skills) — `npx skills add objectstack-ai/objectstack/skills` para que el agente de tu IDE genere los metadatos correctamente -- [Actions](/docs/build/interface/actions) — declara las tools que usarán tus agentes -- [AI Service](/docs/configure/ai) — configuración de proveedor, embedder y MCP -- [`@objectstack/spec/ai`](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec/src/ai) — esquemas completos diff --git a/content/docs/build/agents.fr.mdx b/content/docs/build/agents.fr.mdx deleted file mode 100644 index eab5fb9..0000000 --- a/content/docs/build/agents.fr.mdx +++ /dev/null @@ -1,197 +0,0 @@ ---- -title: Agents -description: Assistants IA pour utilisateurs finaux — Agent → Skill → Tool — câblés à partir de vos données et de vos actions. -translation: - source_sha: e3b9515cd83593f2841304ea479e9be0c0cc381acf89a8cb5499d5aed498b8bf - guide_rev: 1 - mode: auto ---- - -Les agents sont les assistants IA avec lesquels vos **utilisateurs -finaux** dialoguent — un copilote de service d'assistance, un BDR -commercial, un bot de questions-réponses RH interne. Ils s'appuient sur -les données et les actions que vous avez déjà définies ; vous n'écrivez -pas de nouveau code, vous composez des primitives existantes en une -persona. - -Architecture à trois niveaux, alignée sur Salesforce Agentforce, -Microsoft Copilot Studio et ServiceNow Now Assist : - -```text -Agent ──→ Skill ──→ Tool -(persona) (capability) (callable function) -``` - -| Niveau | Ce que c'est | Exemple | -|---|---|---| -| **Tool** | Une fonction appelable (action, requête, recherche de connaissances, méthode MCP) | `create_ticket`, `get_order_status`, `search_kb` | -| **Skill** | Un ensemble nommé d'outils connexes partageant des instructions LLM communes | `ticket_management` = create + update + close + escalate | -| **Agent** | Une persona dotée d'un rôle, d'un prompt système, de compétences rattachées et de connaissances | `tier1_support` = empathique, vérifie l'identité, possède `ticket_management` + `kb_search` | - -## Définir un agent (un seul fichier) - -```ts -// src/agents/tier1_support.agent.ts -import { defineAgent } from '@objectstack/spec/ai'; - -export const tier1Support = defineAgent({ - name: 'tier1_support', - label: 'First Line Support', - role: 'Help Desk Assistant', - instructions: ` - You are a friendly first-line support agent. - Always verify the user's identity before discussing account specifics. - Escalate to tier 2 if the issue involves billing or security. - `, - skills: ['ticket_management', 'knowledge_search'], - knowledge: { - topics: ['faq', 'policies'], - indexes: ['support_docs'], - }, - model: { provider: 'openai', model: 'gpt-4o', temperature: 0.3 }, - memory: { shortTerm: { maxMessages: 30 } }, -}); -``` - -Ou dans la Console : **Console → Agents → New Agent**. - -Ou — et c'est tout l'intérêt — dites à l'AI Builder : - -> *"Create a tier-1 support agent that handles ticket management and -> searches the FAQ. It should verify identity before discussing -> account details."* - -## Définir une compétence (Skill) - -```ts -// src/skills/ticket_management.skill.ts -import { defineSkill } from '@objectstack/spec/ai'; - -export const ticketManagement = defineSkill({ - name: 'ticket_management', - label: 'Ticket Management', - instructions: ` - Always confirm the ticket subject and priority before creating one. - Use 'urgent' priority sparingly — only for outages or security incidents. - `, - tools: [ - 'create_ticket', - 'update_ticket', - 'close_ticket', - 'escalate_ticket', - 'action_*', // wildcard: pick up any future actions on the active object - ], -}); -``` - -Les compétences sont **la bonne unité de réutilisation**. Une seule -compétence fonctionne avec de nombreux agents. - -## Les outils proviennent de vos métadonnées déclarées - -Chaque `*.action.ts` que vous déclarez se matérialise automatiquement -en un outil `action_` — aucun câblage séparé. Ainsi, si vous avez -déjà défini `escalate_ticket` comme une Action sur l'objet -`support_ticket`, à la fois l'AI Builder et vos agents peuvent -l'appeler. Les permissions continuent de s'appliquer : l'agent appelle -l'action **en tant qu'utilisateur**, c'est donc l'ensemble de -permissions de l'utilisateur qui détermine si elle aboutit. - -Vous pouvez aussi exposer : - -| Type d'outil | Source | -|---|---| -| **Action** | N'importe quel `*.action.ts` dans n'importe quel package installé | -| **Flow** | N'importe quel flow manuel (`type: 'manual'`) | -| **Query** | Requêtes ObjectQL enregistrées (`*.query.ts`) | -| **Knowledge search** | N'importe quel index de connaissances rattaché à l'agent | -| **MCP method** | Tout ce qui est exposé par un serveur MCP rattaché | -| **Built-in metadata tools** | `create_object`, `add_field`, … — mais uniquement pour les agents administrateurs | - -## Modèle d'assistant ambiant - -Si vous souhaitez **une seule zone de chat pour toute l'application** -(à la manière de Claude Code / Agentforce) plutôt que de forcer -l'utilisateur à choisir un agent, déclarez un `defaultAgent` dans les -métadonnées de l'App et appelez le point de terminaison de chat ambiant -avec le contexte de l'application : - -```text -POST /api/v1/ai/chat { context: { appName: 'crm' }, ... } -``` - -Lorsque `context.appName` correspond à une application qui déclare un -`defaultAgent`, le runtime sélectionne automatiquement cet agent — -l'utilisateur ne choisit jamais dans une liste. Le panneau IA intégré -de la Console utilise ce mécanisme. Le runtime résout : - -1. L'**agent par défaut** pour l'application active (le `defaultAgent` - de l'application), ou le premier agent auquel l'utilisateur a accès. -2. Les **compétences actives** — la liste `skills:` de l'agent chargée - depuis le Skill Registry, filtrée par l'ensemble de permissions de - l'utilisateur et par le contexte objet/enregistrement courant. -3. Les **connaissances** rattachées à l'agent. - -Vous n'avez pas à câbler quel agent s'affiche où. Déclarez une -application, définissez son `defaultAgent`, et il apparaît. - -## Permissions - -| Capacité | Permission | -|---|---| -| Dialoguer avec un agent | `ai:chat` (et accès aux outils des compétences de l'agent) | -| Approuver les modifications de métadonnées | `ai:approve` | -| Définir / modifier des agents et des compétences | `ai:author` (généralement Setup Administrator) | -| Lire les conversations IA (audit) | `ai:read` | - -Les conversations sont cantonnées à l'utilisateur — un utilisateur ne -peut pas voir l'historique de chat d'un autre, sauf s'il dispose d'une -autorisation déléguée. - -## Mémoire et état de la conversation - -Le bloc `memory` de l'agent comporte deux niveaux : - -| Champ | Ce qu'il fait | -|---|---| -| `shortTerm.maxMessages` | Messages récents conservés en mémoire de travail (par défaut `50`) | -| `shortTerm.maxTokens` | Budget de tokens facultatif pour la fenêtre de contexte à court terme | -| `longTerm.enabled` | Persister la mémoire entre les sessions (par défaut `false`) | -| `longTerm.store` | Backend pour la mémoire persistée : `vector` (par défaut), `database` ou `redis` | -| `reflectionInterval` | Réfléchir toutes les N interactions pour affiner le comportement | - -L'élagage de la fenêtre de contexte active est régi par la stratégie de -budget de tokens de la conversation — `sliding_window` (par défaut), -`fifo`, `importance`, `semantic` ou `summary`. - -Les lignes de conversation résident dans `ai_conversations`. Les -résultats des appels d'outils et les actions en attente sont -référencés en retour vers la conversation d'origine à des fins d'audit. - -## Observabilité - -Chaque exécution d'agent émet : - -- des événements `audit:ai:chat` (par tour) -- des événements `audit:ai:tool` (par appel d'outil, avec entrées + sorties) -- des événements `audit:ai:pending_action` (lorsqu'une mutation est mise en file d'attente) -- des métriques de comptage de tokens (par modèle, par fournisseur) dans - le journal d'audit pour la refacturation - -Vous pouvez intégrer ces données à votre pile d'observabilité -habituelle — voir [Observability](/docs/operate/observability). - -## Note multi-tenant - -Les agents sont propres à chaque environnement. L'agent `tier1_support` -du tenant A ne voit jamais les données, les conversations ni les -connaissances du tenant B — même si vous livrez la même définition -d'agent dans un package du marketplace. - -## Pour aller plus loin - -- [AI Builder](/docs/build/ai-builder) — l'assistant au moment du build -- [IDE Skills](/docs/build/ai-skills) — `npx skills add objectstack-ai/objectstack/skills` pour que votre agent d'IDE rédige correctement les métadonnées -- [Actions](/docs/build/interface/actions) — déclarez les outils que vos agents utiliseront -- [AI Service](/docs/configure/ai) — configuration du fournisseur, de l'embedder et de MCP -- [`@objectstack/spec/ai`](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec/src/ai) — schémas complets diff --git a/content/docs/build/agents.ja.mdx b/content/docs/build/agents.ja.mdx deleted file mode 100644 index 4bfd48e..0000000 --- a/content/docs/build/agents.ja.mdx +++ /dev/null @@ -1,188 +0,0 @@ ---- -title: エージェント -description: エンドユーザー向けの AI アシスタント — Agent → Skill → Tool — あなたのデータとアクションから組み立てます。 -translation: - source_sha: e3b9515cd83593f2841304ea479e9be0c0cc381acf89a8cb5499d5aed498b8bf - guide_rev: 1 - mode: auto ---- - -エージェントは、**エンドユーザー**が対話する AI アシスタントです — ヘルプ -デスクの副操縦士、営業の BDR、社内向け HR Q&A ボットなど。これらは、 -すでに定義済みのデータとアクションの上に構築されます。新しいコードを書く -必要はなく、既存のプリミティブを組み合わせて 1 つのペルソナを作り上げます。 - -Salesforce Agentforce、Microsoft Copilot Studio、ServiceNow Now Assist -と整合する 3 階層アーキテクチャ: - -```text -Agent ──→ Skill ──→ Tool -(persona) (capability) (callable function) -``` - -| 階層 | 概要 | 例 | -|---|---|---| -| **Tool** | 1 つの呼び出し可能な関数(アクション、クエリ、ナレッジ検索、MCP メソッド) | `create_ticket`, `get_order_status`, `search_kb` | -| **Skill** | 共有 LLM 指示を持つ、関連するツールの名前付きバンドル | `ticket_management` = create + update + close + escalate | -| **Agent** | ロール、システムプロンプト、付属スキル、ナレッジを備えたペルソナ | `tier1_support` = 共感的、本人確認を行い、`ticket_management` + `kb_search` を持つ | - -## エージェントを定義する(1 ファイル) - -```ts -// src/agents/tier1_support.agent.ts -import { defineAgent } from '@objectstack/spec/ai'; - -export const tier1Support = defineAgent({ - name: 'tier1_support', - label: 'First Line Support', - role: 'Help Desk Assistant', - instructions: ` - You are a friendly first-line support agent. - Always verify the user's identity before discussing account specifics. - Escalate to tier 2 if the issue involves billing or security. - `, - skills: ['ticket_management', 'knowledge_search'], - knowledge: { - topics: ['faq', 'policies'], - indexes: ['support_docs'], - }, - model: { provider: 'openai', model: 'gpt-4o', temperature: 0.3 }, - memory: { shortTerm: { maxMessages: 30 } }, -}); -``` - -または Console から: **Console → Agents → New Agent**。 - -あるいは — そしてこれが本質ですが — AI Builder にこう伝えます: - -> *「チケット管理を扱い、FAQ を検索する tier-1 サポートエージェントを作成して。 -> アカウントの詳細を話す前に本人確認を行うようにして。」* - -## スキルを定義する - -```ts -// src/skills/ticket_management.skill.ts -import { defineSkill } from '@objectstack/spec/ai'; - -export const ticketManagement = defineSkill({ - name: 'ticket_management', - label: 'Ticket Management', - instructions: ` - Always confirm the ticket subject and priority before creating one. - Use 'urgent' priority sparingly — only for outages or security incidents. - `, - tools: [ - 'create_ticket', - 'update_ticket', - 'close_ticket', - 'escalate_ticket', - 'action_*', // wildcard: pick up any future actions on the active object - ], -}); -``` - -スキルは**再利用に適した単位**です。1 つのスキルは多くのエージェントにまたがって -機能します。 - -## ツールは宣言したメタデータから生成される - -宣言したすべての `*.action.ts` は、自動的に `action_` ツールとして -具現化されます — 個別の配線は不要です。したがって、`support_ticket` オブジェクト -上のアクションとして `escalate_ticket` をすでに定義していれば、AI Builder と -あなたのエージェントの両方がそれを呼び出せます。権限は引き続き適用されます。 -エージェントは**ユーザーとして**アクションを呼び出すため、それが成功するかどうかは -ユーザーの権限セットが決定します。 - -次のものも公開できます: - -| ツールの種類 | ソース | -|---|---| -| **Action** | インストール済みパッケージ内の任意の `*.action.ts` | -| **Flow** | 任意の手動フロー(`type: 'manual'`) | -| **Query** | 保存済みの ObjectQL クエリ(`*.query.ts`) | -| **Knowledge search** | エージェントに付属する任意のナレッジインデックス | -| **MCP method** | 付属の MCP サーバーが公開する任意のもの | -| **組み込みメタデータツール** | `create_object`, `add_field`, … — ただし管理者エージェントのみ | - -## アンビエントアシスタントパターン - -ユーザーにエージェントを選ばせる代わりに、**アプリ全体で 1 つのチャットボックス** -(Claude Code / Agentforce スタイル)を使いたい場合は、App メタデータに -`defaultAgent` を宣言し、アプリのコンテキストとともにアンビエントチャット -エンドポイントを呼び出します: - -```text -POST /api/v1/ai/chat { context: { appName: 'crm' }, ... } -``` - -`context.appName` が `defaultAgent` を宣言するアプリに解決されると、ランタイムは -そのエージェントを自動選択します — ユーザーがリストから選ぶことはありません。 -Console の組み込み AI パネルはこれを使用しています。ランタイムは次を解決します: - -1. アクティブなアプリの**デフォルトエージェント**(アプリの `defaultAgent`)、 - またはユーザーがアクセスできる最初のエージェント。 -2. **アクティブなスキル** — Skill Registry から読み込まれたエージェントの - `skills:` リストで、ユーザーの権限セットと現在のオブジェクト/レコードの - コンテキストでフィルタリングされたもの。 -3. エージェントに付属する**ナレッジ**。 - -どのエージェントをどこに表示するかを配線する必要はありません。アプリを宣言し、 -その `defaultAgent` を設定すれば、表示されます。 - -## 権限 - -| 機能 | 権限 | -|---|---| -| エージェントとチャットする | `ai:chat`(およびエージェントのスキルのツールへのアクセス) | -| メタデータの変更を承認する | `ai:approve` | -| エージェントとスキルを定義/編集する | `ai:author`(通常は Setup Administrator) | -| AI 会話を読む(監査) | `ai:read` | - -会話はユーザー単位にスコープされます — あるユーザーは、委任された付与がない限り、 -別のユーザーのチャット履歴を見ることはできません。 - -## メモリと会話の状態 - -エージェントの `memory` ブロックには 2 つの階層があります: - -| フィールド | 役割 | -|---|---| -| `shortTerm.maxMessages` | ワーキングメモリに保持する直近のメッセージ数(デフォルト `50`) | -| `shortTerm.maxTokens` | 短期コンテキストウィンドウの任意のトークン予算 | -| `longTerm.enabled` | セッションをまたいでメモリを永続化する(デフォルト `false`) | -| `longTerm.store` | 永続メモリのバックエンド: `vector`(デフォルト)、`database`、または `redis` | -| `reflectionInterval` | N 回のインタラクションごとに振り返り、挙動を改善する | - -ライブコンテキストウィンドウのプルーニングは、会話のトークン予算戦略によって -管理されます — `sliding_window`(デフォルト)、`fifo`、`importance`、`semantic`、 -または `summary`。 - -会話の行は `ai_conversations` に存在します。ツール呼び出しの結果と保留中の -アクションは、監査のために発生元の会話への相互参照を保持します。 - -## オブザーバビリティ - -すべてのエージェント実行は次を発行します: - -- `audit:ai:chat` イベント(ターンごと) -- `audit:ai:tool` イベント(ツール呼び出しごと、入力 + 出力付き) -- `audit:ai:pending_action` イベント(ミューテーションがキューに入ったとき) -- トークン数のメトリクス(モデルごと、プロバイダーごと)をチャージバックのために - 監査ログへ - -これらは通常のオブザーバビリティスタックに配線できます — -[Observability](/docs/operate/observability) を参照してください。 - -## マルチテナントに関する注意 - -エージェントは Environment 単位です。テナント A の `tier1_support` エージェントは、 -marketplace パッケージで同じエージェント定義を出荷していても、テナント B の -データ、会話、ナレッジを決して見ることはありません。 - -## 次に進む先 - -- [AI Builder](/docs/build/ai-builder) — ビルド時のアシスタント -- [IDE Skills](/docs/build/ai-skills) — `npx skills add objectstack-ai/objectstack/skills` で、IDE エージェントがメタデータを正しく作成できるようにします -- [Actions](/docs/build/interface/actions) — エージェントが使用するツールを宣言します -- [AI Service](/docs/configure/ai) — プロバイダー、エンベッダー、MCP のセットアップ -- [`@objectstack/spec/ai`](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec/src/ai) — 完全なスキーマ diff --git a/content/docs/build/agents.ko.mdx b/content/docs/build/agents.ko.mdx deleted file mode 100644 index eb8dc8a..0000000 --- a/content/docs/build/agents.ko.mdx +++ /dev/null @@ -1,188 +0,0 @@ ---- -title: Agents -description: 엔드 유저용 AI 어시스턴트 — Agent → Skill → Tool — 데이터와 액션으로 연결됩니다. -translation: - source_sha: e3b9515cd83593f2841304ea479e9be0c0cc381acf89a8cb5499d5aed498b8bf - guide_rev: 1 - mode: auto ---- - -Agent는 **엔드 유저**가 대화하는 AI 어시스턴트입니다 — 헬프데스크 -부조종사, 영업 BDR, 사내 HR Q&A 봇 등이 그 예입니다. 이들은 여러분이 -이미 정의해 둔 데이터와 액션 위에서 동작합니다. 새 코드를 작성하는 것이 -아니라, 기존 기본 요소들을 하나의 페르소나로 조합하는 것입니다. - -Salesforce Agentforce, Microsoft Copilot Studio, ServiceNow Now Assist와 -같은 3계층 아키텍처입니다: - -```text -Agent ──→ Skill ──→ Tool -(persona) (capability) (callable function) -``` - -| 계층 | 무엇인지 | 예시 | -|---|---|---| -| **Tool** | 호출 가능한 단일 함수 (액션, 쿼리, 지식 검색, MCP 메서드) | `create_ticket`, `get_order_status`, `search_kb` | -| **Skill** | 공유 LLM 지침을 가진, 관련 도구들의 명명된 묶음 | `ticket_management` = create + update + close + escalate | -| **Agent** | 역할, 시스템 프롬프트, 연결된 스킬, 지식을 가진 페르소나 | `tier1_support` = 공감적이고, 신원을 확인하며, `ticket_management` + `kb_search`를 보유 | - -## Agent 정의하기 (파일 하나) - -```ts -// src/agents/tier1_support.agent.ts -import { defineAgent } from '@objectstack/spec/ai'; - -export const tier1Support = defineAgent({ - name: 'tier1_support', - label: 'First Line Support', - role: 'Help Desk Assistant', - instructions: ` - You are a friendly first-line support agent. - Always verify the user's identity before discussing account specifics. - Escalate to tier 2 if the issue involves billing or security. - `, - skills: ['ticket_management', 'knowledge_search'], - knowledge: { - topics: ['faq', 'policies'], - indexes: ['support_docs'], - }, - model: { provider: 'openai', model: 'gpt-4o', temperature: 0.3 }, - memory: { shortTerm: { maxMessages: 30 } }, -}); -``` - -또는 Console에서: **Console → Agents → New Agent**. - -또는 — 그리고 이것이 핵심입니다 — AI Builder에게 이렇게 말하세요: - -> *"티켓 관리를 처리하고 FAQ를 검색하는 1차 지원 에이전트를 만들어 줘. -> 계정 세부 정보를 논의하기 전에 신원을 확인해야 해."* - -## Skill 정의하기 - -```ts -// src/skills/ticket_management.skill.ts -import { defineSkill } from '@objectstack/spec/ai'; - -export const ticketManagement = defineSkill({ - name: 'ticket_management', - label: 'Ticket Management', - instructions: ` - Always confirm the ticket subject and priority before creating one. - Use 'urgent' priority sparingly — only for outages or security incidents. - `, - tools: [ - 'create_ticket', - 'update_ticket', - 'close_ticket', - 'escalate_ticket', - 'action_*', // wildcard: pick up any future actions on the active object - ], -}); -``` - -Skill은 **재사용에 적합한 단위**입니다. 하나의 스킬이 여러 에이전트에 -걸쳐 동작합니다. - -## Tool은 선언한 메타데이터에서 나옵니다 - -여러분이 선언하는 모든 `*.action.ts`는 별도의 연결 작업 없이 자동으로 -`action_` 도구로 구체화됩니다. 따라서 `support_ticket` 객체에 -`escalate_ticket`을 Action으로 이미 정의해 두었다면, AI Builder와 -여러분의 Agent 모두가 이를 호출할 수 있습니다. 권한은 여전히 -적용됩니다: 에이전트는 **사용자 자격으로** 액션을 호출하므로, 사용자의 -권한 집합이 성공 여부를 결정합니다. - -다음도 노출할 수 있습니다: - -| 도구 유형 | 출처 | -|---|---| -| **Action** | 설치된 모든 패키지의 모든 `*.action.ts` | -| **Flow** | 모든 수동 플로우 (`type: 'manual'`) | -| **Query** | 저장된 ObjectQL 쿼리 (`*.query.ts`) | -| **Knowledge search** | 에이전트에 연결된 모든 지식 인덱스 | -| **MCP method** | 연결된 MCP 서버가 노출하는 모든 것 | -| **Built-in metadata tools** | `create_object`, `add_field`, … — 단, 관리자 에이전트에만 한정 | - -## 앰비언트 어시스턴트 패턴 - -사용자가 에이전트를 직접 고르게 강제하는 대신 **앱 전체에 하나의 채팅 -박스**(Claude Code / Agentforce 스타일)를 두고 싶다면, App 메타데이터에 -`defaultAgent`를 선언하고 앱 컨텍스트와 함께 앰비언트 채팅 엔드포인트를 -호출하세요: - -```text -POST /api/v1/ai/chat { context: { appName: 'crm' }, ... } -``` - -`context.appName`이 `defaultAgent`를 선언한 앱으로 해석되면, 런타임이 -해당 에이전트를 자동으로 선택합니다 — 사용자는 목록에서 고를 필요가 -없습니다. Console의 내장 AI 패널이 이 방식을 사용합니다. 런타임은 다음을 -해석합니다: - -1. 활성 앱의 **기본 에이전트**(앱의 `defaultAgent`), 또는 사용자가 접근할 - 수 있는 첫 번째 에이전트. -2. **활성 스킬** — Skill Registry에서 로드된 에이전트의 `skills:` 목록으로, - 사용자 권한 집합과 현재 객체/레코드 컨텍스트로 필터링됩니다. -3. 에이전트에 연결된 **지식**. - -어떤 에이전트를 어디에 표시할지 일일이 연결할 필요가 없습니다. 앱을 -선언하고 그 `defaultAgent`를 설정하면 나타납니다. - -## 권한 - -| 기능 | 권한 | -|---|---| -| 에이전트와 대화 | `ai:chat` (그리고 에이전트의 스킬 도구에 대한 접근) | -| 메타데이터 변경 승인 | `ai:approve` | -| 에이전트 및 스킬 정의/편집 | `ai:author` (일반적으로 Setup Administrator) | -| AI 대화 읽기 (감사) | `ai:read` | - -대화는 사용자 단위로 범위가 한정됩니다 — 위임 권한이 부여되지 않는 한 -한 사용자는 다른 사용자의 채팅 기록을 볼 수 없습니다. - -## 메모리와 대화 상태 - -에이전트의 `memory` 블록에는 두 개의 계층이 있습니다: - -| 필드 | 역할 | -|---|---| -| `shortTerm.maxMessages` | 작업 메모리에 유지되는 최근 메시지 (기본값 `50`) | -| `shortTerm.maxTokens` | 단기 컨텍스트 윈도우를 위한 선택적 토큰 예산 | -| `longTerm.enabled` | 세션 간 메모리 유지 (기본값 `false`) | -| `longTerm.store` | 유지되는 메모리의 백엔드: `vector` (기본값), `database`, 또는 `redis` | -| `reflectionInterval` | N회 상호작용마다 반영하여 동작을 정교화 | - -라이브 컨텍스트 윈도우의 가지치기는 대화의 토큰 예산 전략에 의해 -좌우됩니다 — `sliding_window` (기본값), `fifo`, `importance`, `semantic`, -또는 `summary`. - -대화 행은 `ai_conversations`에 저장됩니다. 도구 호출 결과와 대기 중인 -액션은 감사를 위해 출발점이 된 대화를 상호 참조합니다. - -## 관찰 가능성 - -모든 에이전트 실행은 다음을 발생시킵니다: - -- `audit:ai:chat` 이벤트 (턴당) -- `audit:ai:tool` 이벤트 (도구 호출당, 입력 + 출력 포함) -- `audit:ai:pending_action` 이벤트 (변경이 큐에 들어갈 때) -- 토큰 카운트 지표 (모델별, 프로바이더별)를 청구 분담을 위해 감사 로그에 - 기록 - -이것들을 평소 사용하는 관찰 가능성 스택에 연결할 수 있습니다 — -[Observability](/docs/operate/observability)를 참조하세요. - -## 멀티테넌트 참고 사항 - -Agent는 환경(Environment)별로 존재합니다. 동일한 에이전트 정의를 -marketplace 패키지로 배포하더라도, 테넌트 A의 `tier1_support` 에이전트는 -테넌트 B의 데이터, 대화, 지식을 결코 보지 못합니다. - -## 다음으로 갈 곳 - -- [AI Builder](/docs/build/ai-builder) — 빌드 타임 어시스턴트 -- [IDE Skills](/docs/build/ai-skills) — `npx skills add objectstack-ai/objectstack/skills` 로 IDE 에이전트가 메타데이터를 올바르게 작성하도록 합니다 -- [Actions](/docs/build/interface/actions) — 에이전트가 사용할 도구를 선언합니다 -- [AI Service](/docs/configure/ai) — 프로바이더, 임베더, MCP 설정 -- [`@objectstack/spec/ai`](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec/src/ai) — 전체 스키마 diff --git a/content/docs/build/agents.mdx b/content/docs/build/agents.mdx index 752157b..7dbb210 100644 --- a/content/docs/build/agents.mdx +++ b/content/docs/build/agents.mdx @@ -21,7 +21,7 @@ Agent ──→ Skill ──→ Tool |---|---|---| | **Tool** | One callable function (action, query, knowledge search, MCP method) | `create_ticket`, `get_order_status`, `search_kb` | | **Skill** | A named bundle of related tools with shared LLM instructions | `ticket_management` = create + update + close + escalate | -| **Agent** | A persona with a role, system prompt, attached skills, and knowledge | `tier1_support` = empathetic, verifies identity, has `ticket_management` + `kb_search` | +| **Agent** | A persona with a role, a system prompt and attached skills | `tier1_support` = empathetic, verifies identity, has `ticket_management` + `kb_search` | ## Define an Agent (one file) @@ -36,18 +36,19 @@ export const tier1Support = defineAgent({ instructions: ` You are a friendly first-line support agent. Always verify the user's identity before discussing account specifics. + Search the FAQ and the policy articles before you answer a how-to question. Escalate to tier 2 if the issue involves billing or security. `, skills: ['ticket_management', 'knowledge_search'], - knowledge: { - topics: ['faq', 'policies'], - indexes: ['support_docs'], - }, model: { provider: 'openai', model: 'gpt-4o', temperature: 0.3 }, - memory: { shortTerm: { maxMessages: 30 } }, + memory: { longTerm: { enabled: true, maxEntries: 50 }, reflectionInterval: 10 }, }); ``` +An agent has no `knowledge` block. Which sources the `search_knowledge` tool +may read is restricted per source at the knowledge service; say in +`instructions` which sources the agent should ground its answers in. + Or in the UI: **Studio → Agents → New Agent**. Or — and this is the point — say to the AI Builder: @@ -96,9 +97,9 @@ You can also expose: | Tool type | Source | |---|---| | **Action** | Any `*.action.ts` in any installed package | -| **Flow** | Any manual flow (`type: 'manual'`) | +| **Flow** | A `type: 'flow'` action — the agent calls the action, which starts the flow | | **Query** | Saved ObjectQL queries (`*.query.ts`) | -| **Knowledge search** | Any knowledge index attached to the agent | +| **Knowledge search** | The `search_knowledge` tool, over the sources the user may read | | **MCP method** | Anything exposed by an attached MCP server | | **Built-in metadata tools** | `create_object`, `add_field`, … — but only to admin agents | @@ -123,7 +124,6 @@ resolves: 2. The **active skills** — the agent's `skills:` list loaded from the Skill Registry, filtered by user permission set and the current object/record context. -3. The **knowledge** attached to the agent. You don't have to wire which agent is shown where. Declare an app, set its `defaultAgent`, and it appears. @@ -142,15 +142,18 @@ chat history unless they have a delegated grant. ## Memory and conversation state -The agent's `memory` block has two tiers: +The agent's `memory` block configures long-term memory: distilled notes +about each user that carry across sessions. | Field | What it does | |---|---| -| `shortTerm.maxMessages` | Recent messages kept in working memory (default `50`) | -| `shortTerm.maxTokens` | Optional token budget for the short-term context window | -| `longTerm.enabled` | Persist memory across sessions (default `false`) | -| `longTerm.store` | Backend for persisted memory: `vector` (default), `database`, or `redis` | -| `reflectionInterval` | Reflect every N interactions to refine behavior | +| `longTerm.enabled` | Keep notes across sessions (default `false`) | +| `longTerm.maxEntries` | How many notes are kept per user; the newest are recalled first. Required when `enabled` is true | +| `reflectionInterval` | How many interactions pass between reflections — a reflection is what writes a note. Required when `enabled` is true | + +There is no short-term block: the context window of one request is +bounded by the token budget. Where the notes are stored is the platform's +choice, not agent metadata. Pruning of the live context window is governed by the conversation's token-budget strategy — `sliding_window` (default), `fifo`, diff --git a/content/docs/build/agents.zh-Hans.mdx b/content/docs/build/agents.zh-Hans.mdx deleted file mode 100644 index e48296c..0000000 --- a/content/docs/build/agents.zh-Hans.mdx +++ /dev/null @@ -1,160 +0,0 @@ ---- -title: Agents -description: 面向终端用户的 AI 助手 —— Agent → Skill → Tool —— 由你的数据和操作连接而成。 -translation: - source_sha: e3b9515cd83593f2841304ea479e9be0c0cc381acf89a8cb5499d5aed498b8bf - guide_rev: 1 - mode: auto ---- - -Agents 是供你的**终端用户**对话的 AI 助手 —— 服务台副驾、销售 BDR、内部 HR 问答机器人。它们构建在你已经定义好的数据和操作之上;你无需编写新代码,只需将现有的基本要素组合成一个角色。 - -三层架构,与 Salesforce Agentforce、Microsoft Copilot Studio 和 ServiceNow Now Assist 保持一致: - -```text -Agent ──→ Skill ──→ Tool -(persona) (capability) (callable function) -``` - -| 层级 | 它是什么 | 示例 | -|---|---|---| -| **Tool** | 单个可调用函数(操作、查询、知识搜索、MCP 方法) | `create_ticket`、`get_order_status`、`search_kb` | -| **Skill** | 一组相关 tool 的命名集合,带有共享的 LLM 指令 | `ticket_management` = create + update + close + escalate | -| **Agent** | 一个角色,具有职责、系统提示、所附 skill 和知识 | `tier1_support` = 富有同理心、会验证身份、拥有 `ticket_management` + `kb_search` | - -## 定义一个 Agent(单个文件) - -```ts -// src/agents/tier1_support.agent.ts -import { defineAgent } from '@objectstack/spec/ai'; - -export const tier1Support = defineAgent({ - name: 'tier1_support', - label: 'First Line Support', - role: 'Help Desk Assistant', - instructions: ` - You are a friendly first-line support agent. - Always verify the user's identity before discussing account specifics. - Escalate to tier 2 if the issue involves billing or security. - `, - skills: ['ticket_management', 'knowledge_search'], - knowledge: { - topics: ['faq', 'policies'], - indexes: ['support_docs'], - }, - model: { provider: 'openai', model: 'gpt-4o', temperature: 0.3 }, - memory: { shortTerm: { maxMessages: 30 } }, -}); -``` - -或者在 Console 中:**Console → Agents → New Agent**。 - -又或者 —— 这才是重点 —— 对 AI Builder 说: - -> *"创建一个一线支持 agent,处理工单管理并搜索 FAQ。在讨论账户细节之前,它应当先验证身份。"* - -## 定义一个 Skill - -```ts -// src/skills/ticket_management.skill.ts -import { defineSkill } from '@objectstack/spec/ai'; - -export const ticketManagement = defineSkill({ - name: 'ticket_management', - label: 'Ticket Management', - instructions: ` - Always confirm the ticket subject and priority before creating one. - Use 'urgent' priority sparingly — only for outages or security incidents. - `, - tools: [ - 'create_ticket', - 'update_ticket', - 'close_ticket', - 'escalate_ticket', - 'action_*', // wildcard: pick up any future actions on the active object - ], -}); -``` - -Skill 是**最适合复用的单元**。一个 skill 可以服务多个 agent。 - -## Tool 来自你声明的元数据 - -你声明的每个 `*.action.ts` 都会自动具现为一个 `action_` tool —— 无需另外连接。因此,如果你已经在 `support_ticket` 对象上将 `escalate_ticket` 定义为一个 Action,那么 AI Builder 和你的 Agents 都能调用它。权限仍然生效:agent **以用户身份**调用该操作,因此能否成功由该用户的权限集决定。 - -你还可以暴露: - -| Tool 类型 | 来源 | -|---|---| -| **Action** | 已安装的任何包中的任意 `*.action.ts` | -| **Flow** | 任意手动 flow(`type: 'manual'`) | -| **Query** | 已保存的 ObjectQL 查询(`*.query.ts`) | -| **Knowledge search** | 附加到 agent 的任意知识索引 | -| **MCP method** | 接入的 MCP 服务器所暴露的任意内容 | -| **内置元数据 tool** | `create_object`、`add_field`…… —— 但仅对管理员 agent 开放 | - -## 环境助手模式 - -如果你想要**整个应用只有一个聊天框**(Claude Code / Agentforce 风格),而不是强迫用户去挑选某个 agent,那就在 App 元数据上声明一个 `defaultAgent`,并带上 app 上下文调用环境聊天端点: - -```text -POST /api/v1/ai/chat { context: { appName: 'crm' }, ... } -``` - -当 `context.appName` 解析到一个声明了 `defaultAgent` 的 app 时,运行时会自动选择该 agent —— 用户永远无需从列表中挑选。Console 内置的 AI 面板正是这样工作的。运行时会解析: - -1. 当前 app 的**默认 agent**(即 app 的 `defaultAgent`),或用户有权访问的第一个 agent。 -2. **激活的 skill** —— 从 Skill Registry 加载的 agent `skills:` 列表,并按用户权限集以及当前对象/记录上下文进行过滤。 -3. 附加到该 agent 的**知识**。 - -你不必去连接哪个 agent 显示在哪里。声明一个 app,设置它的 `defaultAgent`,它便会出现。 - -## 权限 - -| 能力 | 权限 | -|---|---| -| 与 agent 对话 | `ai:chat`(以及对该 agent 各 skill 所含 tool 的访问权) | -| 审批元数据变更 | `ai:approve` | -| 定义 / 编辑 agent 和 skill | `ai:author`(通常是 Setup Administrator) | -| 读取 AI 对话(审计) | `ai:read` | - -对话以用户为范围 —— 除非有委派授权,否则一个用户无法看到另一个用户的聊天记录。 - -## 记忆与会话状态 - -agent 的 `memory` 块有两个层级: - -| 字段 | 作用 | -|---|---| -| `shortTerm.maxMessages` | 保留在工作记忆中的近期消息数(默认 `50`) | -| `shortTerm.maxTokens` | 短期上下文窗口的可选 token 预算 | -| `longTerm.enabled` | 跨会话持久化记忆(默认 `false`) | -| `longTerm.store` | 持久化记忆的后端:`vector`(默认)、`database` 或 `redis` | -| `reflectionInterval` | 每 N 次交互反思一次,以优化行为 | - -实时上下文窗口的裁剪由会话的 token 预算策略决定 —— `sliding_window`(默认)、`fifo`、`importance`、`semantic` 或 `summary`。 - -会话记录存于 `ai_conversations`。Tool 调用结果与待执行的操作都会反向引用其原始会话以便审计。 - -## 可观测性 - -每次 agent 运行都会发出: - -- `audit:ai:chat` 事件(每轮一条) -- `audit:ai:tool` 事件(每次 tool 调用,含输入 + 输出) -- `audit:ai:pending_action` 事件(变更入队时) -- token 计数指标(按模型、按 provider)进入审计日志,用于成本分摊 - -你可以把这些接入你常用的可观测性技术栈 —— 见 [Observability](/docs/operate/observability)。 - -## 多租户说明 - -Agents 是按 Environment 划分的。租户 A 的 `tier1_support` agent 永远看不到租户 B 的数据、对话或知识 —— 即便你在某个 marketplace 包里分发的是同一份 agent 定义。 - -## 下一步去哪里 - -- [AI Builder](/docs/build/ai-builder) —— 构建期助手 -- [IDE Skills](/docs/build/ai-skills) —— `npx skills add objectstack-ai/objectstack/skills`,让你的 IDE agent 正确地编写元数据 -- [Actions](/docs/build/interface/actions) —— 声明你的 agent 将使用的 tool -- [AI Service](/docs/configure/ai) —— provider、embedder、MCP 设置 -- [`@objectstack/spec/ai`](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec/src/ai) —— 完整 schema diff --git a/content/docs/build/agents.zh-Hant.mdx b/content/docs/build/agents.zh-Hant.mdx deleted file mode 100644 index 5c60ee0..0000000 --- a/content/docs/build/agents.zh-Hant.mdx +++ /dev/null @@ -1,161 +0,0 @@ ---- -# @generated zh-Hant from zh-Hans -- do not edit. Edit the English source, then regenerate: pnpm --filter @objectos/docs gen:zh-hant -title: Agents -description: 面向終端使用者的 AI 助手 —— Agent → Skill → Tool —— 由你的資料和操作連線而成。 -translation: - source_sha: e3b9515cd83593f2841304ea479e9be0c0cc381acf89a8cb5499d5aed498b8bf - guide_rev: 1 - mode: auto ---- - -Agents 是供你的**終端使用者**對話的 AI 助手 —— 服務檯副駕、銷售 BDR、內部 HR 問答機器人。它們構建在你已經定義好的資料和操作之上;你無需編寫新程式碼,只需將現有的基本要素組合成一個角色。 - -三層架構,與 Salesforce Agentforce、Microsoft Copilot Studio 和 ServiceNow Now Assist 保持一致: - -```text -Agent ──→ Skill ──→ Tool -(persona) (capability) (callable function) -``` - -| 層級 | 它是什麼 | 示例 | -|---|---|---| -| **Tool** | 單個可呼叫函式(操作、查詢、知識搜尋、MCP 方法) | `create_ticket`、`get_order_status`、`search_kb` | -| **Skill** | 一組相關 tool 的命名集合,帶有共享的 LLM 指令 | `ticket_management` = create + update + close + escalate | -| **Agent** | 一個角色,具有職責、系統提示、所附 skill 和知識 | `tier1_support` = 富有同理心、會驗證身份、擁有 `ticket_management` + `kb_search` | - -## 定義一個 Agent(單個檔案) - -```ts -// src/agents/tier1_support.agent.ts -import { defineAgent } from '@objectstack/spec/ai'; - -export const tier1Support = defineAgent({ - name: 'tier1_support', - label: 'First Line Support', - role: 'Help Desk Assistant', - instructions: ` - You are a friendly first-line support agent. - Always verify the user's identity before discussing account specifics. - Escalate to tier 2 if the issue involves billing or security. - `, - skills: ['ticket_management', 'knowledge_search'], - knowledge: { - topics: ['faq', 'policies'], - indexes: ['support_docs'], - }, - model: { provider: 'openai', model: 'gpt-4o', temperature: 0.3 }, - memory: { shortTerm: { maxMessages: 30 } }, -}); -``` - -或者在 Console 中:**Console → Agents → New Agent**。 - -又或者 —— 這才是重點 —— 對 AI Builder 說: - -> *"建立一個一線支援 agent,處理工單管理並搜尋 FAQ。在討論賬戶細節之前,它應當先驗證身份。"* - -## 定義一個 Skill - -```ts -// src/skills/ticket_management.skill.ts -import { defineSkill } from '@objectstack/spec/ai'; - -export const ticketManagement = defineSkill({ - name: 'ticket_management', - label: 'Ticket Management', - instructions: ` - Always confirm the ticket subject and priority before creating one. - Use 'urgent' priority sparingly — only for outages or security incidents. - `, - tools: [ - 'create_ticket', - 'update_ticket', - 'close_ticket', - 'escalate_ticket', - 'action_*', // wildcard: pick up any future actions on the active object - ], -}); -``` - -Skill 是**最適合複用的單元**。一個 skill 可以服務多個 agent。 - -## Tool 來自你宣告的後設資料 - -你宣告的每個 `*.action.ts` 都會自動具現為一個 `action_` tool —— 無需另外連線。因此,如果你已經在 `support_ticket` 物件上將 `escalate_ticket` 定義為一個 Action,那麼 AI Builder 和你的 Agents 都能呼叫它。許可權仍然生效:agent **以使用者身份**呼叫該操作,因此能否成功由該使用者的許可權集決定。 - -你還可以暴露: - -| Tool 型別 | 來源 | -|---|---| -| **Action** | 已安裝的任何包中的任意 `*.action.ts` | -| **Flow** | 任意手動 flow(`type: 'manual'`) | -| **Query** | 已儲存的 ObjectQL 查詢(`*.query.ts`) | -| **Knowledge search** | 附加到 agent 的任意知識索引 | -| **MCP method** | 接入的 MCP 伺服器所暴露的任意內容 | -| **內建後設資料 tool** | `create_object`、`add_field`…… —— 但僅對管理員 agent 開放 | - -## 環境助手模式 - -如果你想要**整個應用只有一個聊天框**(Claude Code / Agentforce 風格),而不是強迫使用者去挑選某個 agent,那就在 App 後設資料上宣告一個 `defaultAgent`,並帶上 app 上下文呼叫環境聊天端點: - -```text -POST /api/v1/ai/chat { context: { appName: 'crm' }, ... } -``` - -當 `context.appName` 解析到一個聲明瞭 `defaultAgent` 的 app 時,執行時會自動選擇該 agent —— 使用者永遠無需從列表中挑選。Console 內建的 AI 面板正是這樣工作的。執行時會解析: - -1. 當前 app 的**預設 agent**(即 app 的 `defaultAgent`),或使用者有權訪問的第一個 agent。 -2. **啟用的 skill** —— 從 Skill Registry 載入的 agent `skills:` 列表,並按使用者許可權集以及當前物件/記錄上下文進行過濾。 -3. 附加到該 agent 的**知識**。 - -你不必去連線哪個 agent 顯示在哪裡。宣告一個 app,設定它的 `defaultAgent`,它便會出現。 - -## 許可權 - -| 能力 | 許可權 | -|---|---| -| 與 agent 對話 | `ai:chat`(以及對該 agent 各 skill 所含 tool 的訪問權) | -| 審批後設資料變更 | `ai:approve` | -| 定義 / 編輯 agent 和 skill | `ai:author`(通常是 Setup Administrator) | -| 讀取 AI 對話(審計) | `ai:read` | - -對話以使用者為範圍 —— 除非有委派授權,否則一個使用者無法看到另一個使用者的聊天記錄。 - -## 記憶與會話狀態 - -agent 的 `memory` 塊有兩個層級: - -| 欄位 | 作用 | -|---|---| -| `shortTerm.maxMessages` | 保留在工作記憶中的近期訊息數(預設 `50`) | -| `shortTerm.maxTokens` | 短期上下文視窗的可選 token 預算 | -| `longTerm.enabled` | 跨會話持久化記憶(預設 `false`) | -| `longTerm.store` | 持久化記憶的後端:`vector`(預設)、`database` 或 `redis` | -| `reflectionInterval` | 每 N 次互動反思一次,以最佳化行為 | - -即時上下文視窗的裁剪由會話的 token 預算策略決定 —— `sliding_window`(預設)、`fifo`、`importance`、`semantic` 或 `summary`。 - -會話記錄存於 `ai_conversations`。Tool 呼叫結果與待執行的操作都會反向引用其原始會話以便審計。 - -## 可觀測性 - -每次 agent 執行都會發出: - -- `audit:ai:chat` 事件(每輪一條) -- `audit:ai:tool` 事件(每次 tool 呼叫,含輸入 + 輸出) -- `audit:ai:pending_action` 事件(變更入隊時) -- token 計數指標(按模型、按 provider)進入審計日誌,用於成本分攤 - -你可以把這些接入你常用的可觀測性技術棧 —— 見 [Observability](/docs/operate/observability)。 - -## 多租戶說明 - -Agents 是按 Environment 劃分的。租戶 A 的 `tier1_support` agent 永遠看不到租戶 B 的資料、對話或知識 —— 即便你在某個 marketplace 包裡分發的是同一份 agent 定義。 - -## 下一步去哪裡 - -- [AI Builder](/docs/build/ai-builder) —— 構建期助手 -- [IDE Skills](/docs/build/ai-skills) —— `npx skills add objectstack-ai/objectstack/skills`,讓你的 IDE agent 正確地編寫後設資料 -- [Actions](/docs/build/interface/actions) —— 宣告你的 agent 將使用的 tool -- [AI Service](/docs/configure/ai) —— provider、embedder、MCP 設定 -- [`@objectstack/spec/ai`](https://github.com/objectstack-ai/objectstack/tree/main/packages/spec/src/ai) —— 完整 schema diff --git a/content/docs/build/interface/apps.mdx b/content/docs/build/interface/apps.mdx index a5ee6ef..57083a7 100644 --- a/content/docs/build/interface/apps.mdx +++ b/content/docs/build/interface/apps.mdx @@ -40,7 +40,6 @@ export const CrmApp = App.create({ | `branding` | `AppBranding` | — | `primaryColor`, `logo`, `favicon` | | `requiredPermissions` | `string[]` | — | Who may open the app at all | | `homePageId` | `string` | — | Nav item `id` to use as the landing page | -| `mobileNavigation` | `object` | — | Mobile-specific navigation | ## Navigation items @@ -57,7 +56,7 @@ The navigation tree supports eight item types. The common five are | `group` | Collapsible section | `children`, `expanded` | Every item **must** declare a unique `id` (lowercase `snake_case`) — it's -what `homePageId` and `mobileNavigation.bottomNavItems` reference. Shared +what `homePageId` references. Shared properties: `label`, `icon`, `order`, `badge`, plus the gating trio below. ### Targeting an object entry @@ -74,18 +73,15 @@ precedence `recordId` → `filters` → `viewName`: feature — row-level permissions still decide what's shown. ```ts +// One navigation item of an app. { id: 'nav_my_open', type: 'object', label: 'My Open Deals', objectName: 'opportunity', filters: { owner_id: '{current_user_id}', status: 'open' }, icon: 'user-check' } ``` ### Mobile navigation -```ts -mobileNavigation: { - mode: 'bottom_nav', // 'drawer' (default) | 'bottom_nav' | 'hamburger' - bottomNavItems: ['nav_home', 'nav_accounts', 'nav_contacts'], // nav item ids, max 5 -} -``` +There is no mobile-specific navigation block: `mobileNavigation` was +removed in ObjectStack 17.0 because no renderer ever read it. ## Gating apps by audience @@ -112,6 +108,7 @@ Each nav item supports three independent gates: | `requiresObject` / `requiresService` | `string` | The named object / kernel service is installed | ```ts +// Keys of an app; its other keys are omitted. navigation: [ { id: 'nav_contacts', type: 'object', label: 'Contacts', objectName: 'showcase_contact' }, // Builder-only entry — consumers never render it: diff --git a/content/docs/build/interface/apps.zh-Hans.mdx b/content/docs/build/interface/apps.zh-Hans.mdx deleted file mode 100644 index 8a57380..0000000 --- a/content/docs/build/interface/apps.zh-Hans.mdx +++ /dev/null @@ -1,139 +0,0 @@ ---- -title: 应用与导航 -description: 把对象、视图、页面和仪表盘打包成一个带品牌、可导航的外壳 —— 并精确控制谁能看到什么。 -translation: - source_sha: f4f41b20402a42960508f3e839d2080924206579a486904b1d5e91ce06427777 - guide_rev: 1 - mode: auto ---- - -**应用**是一个逻辑容器,把对象、视图、页面和仪表盘打包成一体化的体验。它定义导航树、品牌,以及 —— 最关键的 —— 谁能进来。 - -```ts -import { App } from '@objectstack/spec/ui' - -export const CrmApp = App.create({ - name: 'crm_app', - label: 'CRM', - icon: 'briefcase', - branding: { primaryColor: '#2563EB' }, - navigation: [ - { id: 'group_sales', type: 'group', label: 'Sales', icon: 'briefcase', children: [ - { id: 'nav_leads', type: 'object', objectName: 'crm_lead', label: 'Leads', icon: 'funnel' }, - { id: 'nav_accounts', type: 'object', objectName: 'crm_account', label: 'Accounts', icon: 'building' }, - ]}, - ], - requiredPermissions: ['crm_access'], -}) -``` - -## 应用属性 - -| 属性 | 类型 | 必填 | 说明 | -|:--|:--|:--|:--| -| `name` | `string` | 是 | 机器名(`snake_case`) | -| `label` | `string` | 是 | 显示名 | -| `icon` | `string` | — | 应用图标(Lucide) | -| `description` / `version` | `string` | — | 用于列表展示的元数据 | -| `active` | `boolean` | — | 应用是否激活(默认 `true`) | -| `isDefault` | `boolean` | — | 是否为默认应用 | -| `navigation` | `NavigationItem[]` | — | 导航树 | -| `branding` | `AppBranding` | — | `primaryColor`、`logo`、`favicon` | -| `requiredPermissions` | `string[]` | — | 谁可以打开这个应用 | -| `homePageId` | `string` | — | 用作着陆页的导航项 `id` | -| `mobileNavigation` | `object` | — | 移动端专属导航 | - -## 导航项 - -导航树支持八种项目类型。常用的五种是 `object`、`dashboard`、`page`、`url` 和 `group`;规范还定义了 `report`、`action` 和 `component` 三种。 - -| `type` | 落到哪里 | 关键配置 | -|:--|:--|:--| -| `object` | 对象的列表视图 | `objectName`,外加可选的定位配置(见下) | -| `dashboard` | 一个仪表盘 | `dashboardName` | -| `page` | 一个自定义页面 | `pageName`、可选 `params` | -| `url` | 一个外部 URL | `url`、`target: '_blank'` | -| `group` | 可折叠分组 | `children`、`expanded` | - -每个导航项**必须**声明唯一的 `id`(小写 `snake_case`)—— `homePageId` 和 `mobileNavigation.bottomNavItems` 引用的就是它。公共属性:`label`、`icon`、`order`、`badge`,再加下面的门控三件套。 - -### 定位 `object` 入口 - -三个可选字段可以细化 `object` 入口落到哪里,优先级为 `recordId` → `filters` → `viewName`: - -- `viewName` —— 把入口锚定到某个具名列表视图。 -- `recordId` —— 深链到单条记录("我的资料");支持 `{current_user_id}` / `{current_org_id}` 模板变量。 -- `filters` —— 一次性的参数化切片:入口落到裸数据界面上,每个条件是一个可移除的 URL 筛选标签。适合"分派给我"这类链接,不必专门写视图。这不是安全特性 —— 显示什么仍由行级权限决定。 - -```ts -{ id: 'nav_my_open', type: 'object', label: 'My Open Deals', objectName: 'opportunity', - filters: { owner_id: '{current_user_id}', status: 'open' }, icon: 'user-check' } -``` - -### 移动端导航 - -```ts -mobileNavigation: { - mode: 'bottom_nav', // 'drawer'(默认)| 'bottom_nav' | 'hamburger' - bottomNavItems: ['nav_home', 'nav_accounts', 'nav_contacts'], // 导航项 id,最多 5 个 -} -``` - -## 按受众门控应用 - -同一份数据服务于两类截然不同的受众:设计 Schema 的**构建者**,和只录入、查看数据的**最终用户**。默认就把这两类界面分开 —— 别指望每位管理员手动把东西藏起来。 - -| 受众 | 界面 | 门控方式 | -|:--|:--|:--| -| 最终用户(消费者) | 精心组织的应用 → `page` / `view` 入口 | `App.requiredPermissions`、导航项门控 | -| 构建者 / 管理员 | Setup / Studio、原始对象表格 | 能力:`setup.access`、`studio.access`、`manage_metadata` | - -内置权限集已经编码了这种分割:`member_default` 和 `viewer_readonly` **不**携带 `studio.access` / `manage_metadata`,因此构建者界面对他们不可见;`admin_full_access` 和 `organization_admin` 则携带。 - -每个导航项支持三种相互独立的门控: - -| 门控 | 类型 | 隐藏该项,除非…… | -|:--|:--|:--| -| `requiredPermissions` | `string[]` | 用户持有该 RBAC 能力(如 `manage_metadata`) | -| `visible` | CEL 表达式 | 谓词求值为 true(如 `'org_admin' in current_user.positions`) | -| `requiresObject` / `requiresService` | `string` | 具名对象 / 内核服务已安装 | - -```ts -navigation: [ - { id: 'nav_contacts', type: 'object', label: 'Contacts', objectName: 'showcase_contact' }, - // 仅构建者可见的入口 —— 消费者永远不会渲染它: - { id: 'nav_designer', type: 'component', label: 'Object Designer', - componentRef: 'metadata:resource', params: { type: 'object' }, - requiredPermissions: ['manage_metadata'] }, -] -``` - -> **隐藏,而不是禁用。**禁用但可见的构建者入口仍是噪音。被门控的导航项对缺少该能力的用户*根本不渲染* —— 不留下让最终用户困惑的灰色摆设。 - -还有两个习惯能让消费者界面保持干净: - -- **给最终用户一个页面,而不是原始表格。**`page` 入口让你精确策划暴露的内容 —— 精选的列、固定的可视化、只有你启用的筛选和操作。`object` 入口给出的则是宽松的表格:可切换视图、个人视图、完整工具栏。 -- **也要策划数据界面本身。**优先用[精心组织的视图](/docs/build/interface/views),而不是放开原始对象读取,把用户扔到一个 40 列的表格上。 - -操作门控是双面的:UI 隐藏或禁用按钮,**同时**服务端拒绝调用 —— 不存在"只在 UI 门控、服务端敞开"的坑。见[操作](/docs/build/interface/actions)。 - -## Setup 应用 —— 一个内置示例 - -平台自身的管理 UI —— **Setup(管理后台)应用** —— 本身就是用同一套应用元数据协议渲染的:导航、页面与门控都声明为数据,由渲染你的应用的同一个渲染器绘制。它的门控方式正是你的构建者界面应有的方式:藏在消费者权限集不携带的 `setup.access` 能力后面。如果你想要"应用即元数据"可以规模化的证据 —— 你已经在用了。 - -## 反模式 - -- **把每个最终用户都当成构建者级协作者**,再一个个隐藏选项卡。应该让消费者 / 构建者分割成为默认。 -- **禁用而不是隐藏构建者入口。**可见但失效的摆设照样让人困惑。 -- **放开原始对象读取**,而其实一个精心组织的页面就能恰好暴露最终用户需要的内容。 - -## 下一步 - -| 页面 | 原因 | -|:--|:--| -| [视图](/docs/build/interface/views) | `object` 导航入口落到的地方 | -| [页面](/docs/build/interface/pages) | 面向最终用户的精心组织的 `page` 入口 | -| [仪表盘](/docs/build/interface/dashboards) | `dashboard` 入口渲染的内容 | -| [操作](/docs/build/interface/actions) | 按钮及其双面门控 | -| [权限](/docs/configure/permissions) | `requiredPermissions` 背后的权限集 | -| [界面总览](/docs/build/interface) | 各部分如何组合 | diff --git a/content/docs/build/interface/apps.zh-Hant.mdx b/content/docs/build/interface/apps.zh-Hant.mdx deleted file mode 100644 index e9a7006..0000000 --- a/content/docs/build/interface/apps.zh-Hant.mdx +++ /dev/null @@ -1,140 +0,0 @@ ---- -# @generated zh-Hant from zh-Hans -- do not edit. Edit the English source, then regenerate: pnpm --filter @objectos/docs gen:zh-hant -title: 應用與導航 -description: 把物件、檢視、頁面和儀表盤打包成一個帶品牌、可導航的外殼 —— 並精確控制誰能看到什麼。 -translation: - source_sha: f4f41b20402a42960508f3e839d2080924206579a486904b1d5e91ce06427777 - guide_rev: 1 - mode: auto ---- - -**應用**是一個邏輯容器,把物件、檢視、頁面和儀表盤打包成一體化的體驗。它定義導航樹、品牌,以及 —— 最關鍵的 —— 誰能進來。 - -```ts -import { App } from '@objectstack/spec/ui' - -export const CrmApp = App.create({ - name: 'crm_app', - label: 'CRM', - icon: 'briefcase', - branding: { primaryColor: '#2563EB' }, - navigation: [ - { id: 'group_sales', type: 'group', label: 'Sales', icon: 'briefcase', children: [ - { id: 'nav_leads', type: 'object', objectName: 'crm_lead', label: 'Leads', icon: 'funnel' }, - { id: 'nav_accounts', type: 'object', objectName: 'crm_account', label: 'Accounts', icon: 'building' }, - ]}, - ], - requiredPermissions: ['crm_access'], -}) -``` - -## 應用屬性 - -| 屬性 | 型別 | 必填 | 說明 | -|:--|:--|:--|:--| -| `name` | `string` | 是 | 機器名(`snake_case`) | -| `label` | `string` | 是 | 顯示名 | -| `icon` | `string` | — | 應用圖示(Lucide) | -| `description` / `version` | `string` | — | 用於列表展示的後設資料 | -| `active` | `boolean` | — | 應用是否啟用(預設 `true`) | -| `isDefault` | `boolean` | — | 是否為預設應用 | -| `navigation` | `NavigationItem[]` | — | 導航樹 | -| `branding` | `AppBranding` | — | `primaryColor`、`logo`、`favicon` | -| `requiredPermissions` | `string[]` | — | 誰可以開啟這個應用 | -| `homePageId` | `string` | — | 用作著陸頁的導航項 `id` | -| `mobileNavigation` | `object` | — | 移動端專屬導航 | - -## 導航項 - -導航樹支援八種專案型別。常用的五種是 `object`、`dashboard`、`page`、`url` 和 `group`;規範還定義了 `report`、`action` 和 `component` 三種。 - -| `type` | 落到哪裡 | 關鍵配置 | -|:--|:--|:--| -| `object` | 物件的列表檢視 | `objectName`,外加可選的定位配置(見下) | -| `dashboard` | 一個儀表盤 | `dashboardName` | -| `page` | 一個自定義頁面 | `pageName`、可選 `params` | -| `url` | 一個外部 URL | `url`、`target: '_blank'` | -| `group` | 可摺疊分組 | `children`、`expanded` | - -每個導航項**必須**宣告唯一的 `id`(小寫 `snake_case`)—— `homePageId` 和 `mobileNavigation.bottomNavItems` 引用的就是它。公共屬性:`label`、`icon`、`order`、`badge`,再加下面的門控三件套。 - -### 定位 `object` 入口 - -三個可選欄位可以細化 `object` 入口落到哪裡,優先順序為 `recordId` → `filters` → `viewName`: - -- `viewName` —— 把入口錨定到某個具名列表檢視。 -- `recordId` —— 深鏈到單條記錄("我的資料");支援 `{current_user_id}` / `{current_org_id}` 模板變數。 -- `filters` —— 一次性的引數化切片:入口落到裸資料介面上,每個條件是一個可移除的 URL 篩選標籤。適合"分派給我"這類連結,不必專門寫檢視。這不是安全特性 —— 顯示什麼仍由行級許可權決定。 - -```ts -{ id: 'nav_my_open', type: 'object', label: 'My Open Deals', objectName: 'opportunity', - filters: { owner_id: '{current_user_id}', status: 'open' }, icon: 'user-check' } -``` - -### 移動端導航 - -```ts -mobileNavigation: { - mode: 'bottom_nav', // 'drawer'(默认)| 'bottom_nav' | 'hamburger' - bottomNavItems: ['nav_home', 'nav_accounts', 'nav_contacts'], // 导航项 id,最多 5 个 -} -``` - -## 按受眾門控應用 - -同一份資料服務於兩類截然不同的受眾:設計 Schema 的**構建者**,和只錄入、檢視資料的**終端使用者**。預設就把這兩類介面分開 —— 別指望每位管理員手動把東西藏起來。 - -| 受眾 | 介面 | 門控方式 | -|:--|:--|:--| -| 終端使用者(消費者) | 精心組織的應用 → `page` / `view` 入口 | `App.requiredPermissions`、導航項門控 | -| 構建者 / 管理員 | Setup / Studio、原始物件表格 | 能力:`setup.access`、`studio.access`、`manage_metadata` | - -內建許可權集已經編碼了這種分割:`member_default` 和 `viewer_readonly` **不**攜帶 `studio.access` / `manage_metadata`,因此構建者介面對他們不可見;`admin_full_access` 和 `organization_admin` 則攜帶。 - -每個導航項支援三種相互獨立的門控: - -| 門控 | 型別 | 隱藏該項,除非…… | -|:--|:--|:--| -| `requiredPermissions` | `string[]` | 使用者持有該 RBAC 能力(如 `manage_metadata`) | -| `visible` | CEL 表示式 | 謂詞求值為 true(如 `'org_admin' in current_user.positions`) | -| `requiresObject` / `requiresService` | `string` | 具名物件 / 核心服務已安裝 | - -```ts -navigation: [ - { id: 'nav_contacts', type: 'object', label: 'Contacts', objectName: 'showcase_contact' }, - // 仅构建者可见的入口 —— 消费者永远不会渲染它: - { id: 'nav_designer', type: 'component', label: 'Object Designer', - componentRef: 'metadata:resource', params: { type: 'object' }, - requiredPermissions: ['manage_metadata'] }, -] -``` - -> **隱藏,而不是停用。**停用但可見的構建者入口仍是噪音。被門控的導航項對缺少該能力的使用者*根本不渲染* —— 不留下讓終端使用者困惑的灰色擺設。 - -還有兩個習慣能讓消費者介面保持乾淨: - -- **給終端使用者一個頁面,而不是原始表格。**`page` 入口讓你精確策劃暴露的內容 —— 精選的列、固定的視覺化、只有你啟用的篩選和操作。`object` 入口給出的則是寬鬆的表格:可切換檢視、個人檢視、完整工具欄。 -- **也要策劃資料介面本身。**優先用[精心組織的檢視](/docs/build/interface/views),而不是放開原始物件讀取,把使用者扔到一個 40 列的表格上。 - -操作門控是雙面的:UI 隱藏或停用按鈕,**同時**服務端拒絕呼叫 —— 不存在"只在 UI 門控、服務端敞開"的坑。見[操作](/docs/build/interface/actions)。 - -## Setup 應用 —— 一個內建示例 - -平臺自身的管理 UI —— **Setup(管理後臺)應用** —— 本身就是用同一套應用後設資料協議渲染的:導航、頁面與門控都宣告為資料,由渲染你的應用的同一個渲染器繪製。它的門控方式正是你的構建者介面應有的方式:藏在消費者許可權集不攜帶的 `setup.access` 能力後面。如果你想要"應用即後設資料"可以規模化的證據 —— 你已經在用了。 - -## 反模式 - -- **把每個終端使用者都當成構建者級協作者**,再一個個隱藏選項卡。應該讓消費者 / 構建者分割成為預設。 -- **停用而不是隱藏構建者入口。**可見但失效的擺設照樣讓人困惑。 -- **放開原始物件讀取**,而其實一個精心組織的頁面就能恰好暴露終端使用者需要的內容。 - -## 下一步 - -| 頁面 | 原因 | -|:--|:--| -| [檢視](/docs/build/interface/views) | `object` 導航入口落到的地方 | -| [頁面](/docs/build/interface/pages) | 面向終端使用者的精心組織的 `page` 入口 | -| [儀表盤](/docs/build/interface/dashboards) | `dashboard` 入口渲染的內容 | -| [操作](/docs/build/interface/actions) | 按鈕及其雙面門控 | -| [許可權](/docs/configure/permissions) | `requiredPermissions` 背後的許可權集 | -| [介面總覽](/docs/build/interface) | 各部分如何組合 | diff --git a/content/docs/build/interface/dashboards.mdx b/content/docs/build/interface/dashboards.mdx index 7aafc88..08d0ab5 100644 --- a/content/docs/build/interface/dashboards.mdx +++ b/content/docs/build/interface/dashboards.mdx @@ -6,16 +6,17 @@ description: Analytics pages built from chart widgets bound to named datasets **A dashboard is a grid of widgets; every widget binds to a dataset.** The dataset is the semantic layer: it owns the base object, joins, -dimensions, and certified measures. Widgets select those by name, so +dimensions, and measures. Widgets select those by name, so "revenue" means the same thing on every dashboard and report that uses it. ```ts +// One dashboard, bound to the `sales` dataset defined below. const salesDashboard = { name: 'sales_overview', label: 'Sales Overview', description: 'Key sales metrics and pipeline analysis', - refreshInterval: 300, // auto-refresh every 5 minutes + refreshIntervalSeconds: 300, // auto-refresh every 5 minutes dateRange: { field: 'close_date', @@ -45,7 +46,7 @@ const salesDashboard = { | `label` | `string` | yes | Display label | | `description` | `string` | — | Dashboard description | | `widgets` | `DashboardWidget[]` | yes | Chart and metric widgets | -| `refreshInterval` | `number` | — | Auto-refresh interval (seconds) | +| `refreshIntervalSeconds` | `number` | — | Auto-refresh interval, in seconds | | `dateRange` | `object` | — | Global date range filter | | `globalFilters` | `GlobalFilter[]` | — | Interactive filter controls | @@ -66,7 +67,7 @@ export const salesDataset = defineDataset({ { name: 'close_month', field: 'close_date', type: 'date', dateGranularity: 'month' }, ], measures: [ - { name: 'revenue', field: 'amount', aggregate: 'sum', certified: true }, + { name: 'revenue', field: 'amount', aggregate: 'sum' }, { name: 'deal_count', aggregate: 'count' }, ], }) @@ -129,6 +130,7 @@ object and joined objects — a dashboard never shows a user records their Widgets sit on a 12-column grid: ```ts +// Keys of a dashboard widget; its other keys are omitted. layout: { x: 0, // column position (0-11) y: 0, // row position @@ -160,6 +162,7 @@ detail through a `table` or `pivot` widget instead. A global time filter applies to all widgets: ```ts +// Keys of a dashboard; its other keys are omitted. dateRange: { field: 'created_at', defaultRange: 'this_month', allowCustomRange: true } ``` @@ -170,6 +173,7 @@ Preset ranges: `today`, `yesterday`, `this_week`, `last_week`, Interactive global filters work the same way: ```ts +// Keys of a dashboard; its other keys are omitted. globalFilters: [ { name: 'region', field: 'region', label: 'Region', type: 'select' }, { field: 'owner', label: 'Sales Rep', type: 'lookup' }, @@ -188,6 +192,7 @@ widget stores the concept under a different field — or should ignore a filter — declare `filterBindings`: ```ts +// Keys of a dashboard; each widget's dataset, values and title are omitted (`/* … */`). widgets: [ // Default binding: dateRange → created_at, region → region. { id: 'invoices_by_status', /* … */ }, @@ -208,6 +213,7 @@ widgets: [ Add a `dashboard` navigation entry to an [app](/docs/build/interface/apps): ```ts +// One navigation item of an app. { id: 'nav_analytics', type: 'dashboard', label: 'Analytics', dashboardName: 'sales_overview', icon: 'bar-chart' } ``` diff --git a/content/docs/build/interface/dashboards.zh-Hans.mdx b/content/docs/build/interface/dashboards.zh-Hans.mdx deleted file mode 100644 index d1f97d2..0000000 --- a/content/docs/build/interface/dashboards.zh-Hans.mdx +++ /dev/null @@ -1,192 +0,0 @@ ---- -title: 仪表盘 -description: 由绑定具名数据集的图表组件构成的分析页面 —— 带全局筛选、自动刷新,以及到底层记录的下钻。 -translation: - source_sha: e78323eca34fb899bfca8d42c29c8504444845bed49522f9d3f58e00ad1620f9 - guide_rev: 1 - mode: auto ---- - -**仪表盘是一张组件网格;每个组件都绑定到一个数据集。**数据集是语义层:它拥有基础对象、连接、维度和经过认证的度量。组件按名称选用它们,因此"revenue"在每个用到它的仪表盘和报表上含义都相同。 - -```ts -const salesDashboard = { - name: 'sales_overview', - label: 'Sales Overview', - description: 'Key sales metrics and pipeline analysis', - refreshInterval: 300, // 每 5 分钟自动刷新 - - dateRange: { - field: 'close_date', - defaultRange: 'this_quarter', - allowCustomRange: true, - }, - - widgets: [ - { id: 'total_revenue', title: 'Total Revenue', type: 'metric', - dataset: 'sales', values: ['revenue'], - layout: { x: 0, y: 0, w: 3, h: 2 } }, - { id: 'revenue_by_region', title: 'Revenue by Region', type: 'bar', - dataset: 'sales', dimensions: ['region'], values: ['revenue'], - layout: { x: 3, y: 0, w: 6, h: 4 } }, - { id: 'deals_by_month', title: 'Deals by Month', type: 'pie', - dataset: 'sales', dimensions: ['close_month'], values: ['deal_count'], - layout: { x: 9, y: 0, w: 3, h: 4 } }, - ], -} -``` - -## 仪表盘属性 - -| 属性 | 类型 | 必填 | 说明 | -|:--|:--|:--|:--| -| `name` | `string` | 是 | 机器名(`snake_case`) | -| `label` | `string` | 是 | 显示标签 | -| `description` | `string` | — | 仪表盘描述 | -| `widgets` | `DashboardWidget[]` | 是 | 图表与指标组件 | -| `refreshInterval` | `number` | — | 自动刷新间隔(秒) | -| `dateRange` | `object` | — | 全局日期范围筛选 | -| `globalFilters` | `GlobalFilter[]` | — | 交互式筛选控件 | - -## 数据集优先 - -数据集定义一次;每个组件都绑定到它: - -```ts -import { defineDataset } from '@objectstack/spec/ui' - -export const salesDataset = defineDataset({ - name: 'sales', - label: 'Sales', - object: 'opportunity', - include: ['account'], - dimensions: [ - { name: 'region', field: 'account.region', type: 'string' }, - { name: 'close_month', field: 'close_date', type: 'date', dateGranularity: 'month' }, - ], - measures: [ - { name: 'revenue', field: 'amount', aggregate: 'sum', certified: true }, - { name: 'deal_count', aggregate: 'count' }, - ], -}) -``` - -聚合放在数据集的度量上(`aggregate`),而不是组件上:`count`、`sum`、`avg`、`min`、`max`、`count_distinct`、`array_agg`、`string_agg`。组件的 `dimensions` 和 `values` 必须引用其所绑定数据集上声明的名称。 - -运行时,数据集查询经由分析服务执行,并自动把调用者的行级安全范围应用到基础对象和被连接的对象上 —— 仪表盘绝不会向用户展示其[权限](/docs/configure/permissions)不允许看的记录。 - -> 自 16.0 起,组件 Schema 是**严格**的:任何未声明的顶层键 —— 打错的键、幻觉出来的键,或已移除的内联查询键(`object` + `categoryField` + `valueField` + `aggregate`,以及透视表的 `rowField` / `columnField`)—— 都是**指名道姓的解析错误**,并指向数据集形态,而不再被悄悄剥掉、留下一个什么都不渲染的组件。请定义数据集并把组件绑定上去;`options` 仍是渲染器专属额外配置的自由格式逃生口。 - -## 组件 - -| 属性 | 类型 | 必填 | 说明 | -|:--|:--|:--|:--| -| `id` | `string` | 是 | 唯一的组件 id(`snake_case`) | -| `dataset` | `string` | 是 | 要绑定的数据集名 | -| `values` | `string[]` | 是 | 度量名(至少一个) | -| `dimensions` | `string[]` | — | 维度名 —— X 轴 / 分组 / 拆分 | -| `type` | `ChartType` | — | 可视化类型(默认 `metric`) | -| `title` / `description` | `string` | — | 显示标题与副标题 | -| `filter` | `FilterCondition` | — | 展示层筛选 | -| `layout` | `object` | — | 网格位置(省略时自动排布) | -| `colorVariant` | `enum` | — | KPI / 卡片强调色 | -| `compareTo` | `enum \| object` | — | 同比 / 环比对比窗口 | -| `filterBindings` | `object` | — | 组件级全局筛选映射(见下) | - -### 图表类型 - -| 类型 | 最适合 | -|:--|:--| -| `metric`*(默认)* | 单数字 KPI —— 营收、数量、百分比 | -| `bar` / `horizontal-bar` / `column` | 类别对比 | -| `line` | 随时间的趋势 | -| `pie` / `donut` | 分布 | -| `area` | 随时间的体量 | -| `scatter` | 相关性 | -| `radar` | 多维对比 | -| `funnel` | 转化阶段 | -| `gauge` / `solid-gauge` / `bullet` / `kpi` | 目标进度 | -| `treemap` / `sankey` | 层级占比 / 流向 | -| `table` | 明细记录 —— **可下钻** | -| `pivot` | 交叉汇总 —— **可下钻** | - -### 布局 - -组件排布在 12 列网格上: - -```ts -layout: { - x: 0, // 列位置(0-11) - y: 0, // 行位置 - w: 6, // 宽度,按列计(1-12) - h: 4, // 高度,按行计 -} -``` - -## 下钻 - -`table` 和 `pivot` 组件支持**下钻**:点击一行聚合数据或一个单元格,会打开一个侧边抽屉,列出该分组背后的*底层记录*。数据集保留了原始分组键,所以抽屉的筛选精确匹配那些记录 —— 不做标签到 id 的猜测。点击抽屉里的任意一行即可打开该记录的详情:完整的**分组 → 记录列表 → 单条记录**链路。 - -抽屉还提供一个 **"Open in list →"**(在列表中打开)逃生口,把速览升级为该对象的完整列表页(排序、批量选择、导出、可分享 URL),并沿用同一个下钻筛选。 - -下钻是自动的 —— 无需按组件配置 —— 只要数据集暴露了基础对象,且组件按至少一个维度分组。`metric` 与图表类组件只渲染聚合值;要展示明细,请改用 `table` 或 `pivot` 组件。 - -## 全局日期范围与筛选 - -全局时间筛选作用于所有组件: - -```ts -dateRange: { field: 'created_at', defaultRange: 'this_month', allowCustomRange: true } -``` - -预设范围:`today`、`yesterday`、`this_week`、`last_week`、`this_month`、`last_month`、`this_quarter`、`last_quarter`、`this_year`、`last_year`、`last_7_days`、`last_30_days`、`last_90_days`、`custom`。 - -交互式全局筛选的用法相同: - -```ts -globalFilters: [ - { name: 'region', field: 'region', label: 'Region', type: 'select' }, - { field: 'owner', label: 'Sales Rep', type: 'lookup' }, -] -``` - -每个筛选的 `name`(默认取 `field`)是它的稳定身份 —— 组件在 `filterBindings` 里引用的键,也是组件表达式中可通过 `page.` 读取的仪表盘级变量。名称 `dateRange` 保留给内置日期范围。 - -### 组件级筛选绑定 - -默认情况下,筛选按其自身的 `field` 作用于每个组件。当某个组件用不同的字段存储同一概念 —— 或应忽略某个筛选 —— 时,声明 `filterBindings`: - -```ts -widgets: [ - // 默认绑定:dateRange → created_at,region → region。 - { id: 'invoices_by_status', /* … */ }, - // 这个组件的字段不同 —— 逐个显式映射筛选。 - { id: 'accounts_signed', - filterBindings: { dateRange: 'signed_at', region: 'sales_region' }, /* … */ }, - // 用 `false` 退出某个筛选。 - { id: 'total_invoices', filterBindings: { region: false }, /* … */ }, -] -``` - -> 优先级:显式的 `filterBindings` 条目(字符串覆盖或 `false` 退出)→ 筛选的旧式 `targetWidgets` 白名单 → 筛选自身的 `field`。 - -## 让仪表盘出现在界面上 - -给[应用](/docs/build/interface/apps)添加一个 `dashboard` 导航入口: - -```ts -{ id: 'nav_analytics', type: 'dashboard', label: 'Analytics', - dashboardName: 'sales_overview', icon: 'bar-chart' } -``` - -或者描述给 [AI Builder](/docs/build/ai-builder) —— *"一个销售总览仪表盘,含按区域的营收和月度成交趋势"* —— 然后在批准前审阅生成的数据集 + 仪表盘元数据。 - -## 下一步 - -| 页面 | 原因 | -|:--|:--| -| [应用](/docs/build/interface/apps) | 把仪表盘放进导航 | -| [视图](/docs/build/interface/views) | 基于同一数据集的内联 `chart` 列表视图 | -| [页面](/docs/build/interface/pages) | 当组件网格不够用时的自由布局 | -| [数据模型](/docs/build/data) | 数据集聚合的对象 | -| [权限](/docs/configure/permissions) | 仪表盘继承的行级安全 | diff --git a/content/docs/build/interface/dashboards.zh-Hant.mdx b/content/docs/build/interface/dashboards.zh-Hant.mdx deleted file mode 100644 index f1efaa0..0000000 --- a/content/docs/build/interface/dashboards.zh-Hant.mdx +++ /dev/null @@ -1,193 +0,0 @@ ---- -# @generated zh-Hant from zh-Hans -- do not edit. Edit the English source, then regenerate: pnpm --filter @objectos/docs gen:zh-hant -title: 儀表盤 -description: 由繫結具名資料集的圖表元件構成的分析頁面 —— 帶全域性篩選、自動重新整理,以及到底層記錄的下鑽。 -translation: - source_sha: e78323eca34fb899bfca8d42c29c8504444845bed49522f9d3f58e00ad1620f9 - guide_rev: 1 - mode: auto ---- - -**儀表盤是一張元件網格;每個元件都繫結到一個數據集。**資料集是語義層:它擁有基礎物件、連線、維度和經過認證的度量。元件按名稱選用它們,因此"revenue"在每個用到它的儀表盤和報表上含義都相同。 - -```ts -const salesDashboard = { - name: 'sales_overview', - label: 'Sales Overview', - description: 'Key sales metrics and pipeline analysis', - refreshInterval: 300, // 每 5 分钟自动刷新 - - dateRange: { - field: 'close_date', - defaultRange: 'this_quarter', - allowCustomRange: true, - }, - - widgets: [ - { id: 'total_revenue', title: 'Total Revenue', type: 'metric', - dataset: 'sales', values: ['revenue'], - layout: { x: 0, y: 0, w: 3, h: 2 } }, - { id: 'revenue_by_region', title: 'Revenue by Region', type: 'bar', - dataset: 'sales', dimensions: ['region'], values: ['revenue'], - layout: { x: 3, y: 0, w: 6, h: 4 } }, - { id: 'deals_by_month', title: 'Deals by Month', type: 'pie', - dataset: 'sales', dimensions: ['close_month'], values: ['deal_count'], - layout: { x: 9, y: 0, w: 3, h: 4 } }, - ], -} -``` - -## 儀表盤屬性 - -| 屬性 | 型別 | 必填 | 說明 | -|:--|:--|:--|:--| -| `name` | `string` | 是 | 機器名(`snake_case`) | -| `label` | `string` | 是 | 顯示標籤 | -| `description` | `string` | — | 儀表盤描述 | -| `widgets` | `DashboardWidget[]` | 是 | 圖表與指標元件 | -| `refreshInterval` | `number` | — | 自動重新整理間隔(秒) | -| `dateRange` | `object` | — | 全域性日期範圍篩選 | -| `globalFilters` | `GlobalFilter[]` | — | 互動式篩選控制元件 | - -## 資料集優先 - -資料集定義一次;每個元件都繫結到它: - -```ts -import { defineDataset } from '@objectstack/spec/ui' - -export const salesDataset = defineDataset({ - name: 'sales', - label: 'Sales', - object: 'opportunity', - include: ['account'], - dimensions: [ - { name: 'region', field: 'account.region', type: 'string' }, - { name: 'close_month', field: 'close_date', type: 'date', dateGranularity: 'month' }, - ], - measures: [ - { name: 'revenue', field: 'amount', aggregate: 'sum', certified: true }, - { name: 'deal_count', aggregate: 'count' }, - ], -}) -``` - -聚合放在資料集的度量上(`aggregate`),而不是元件上:`count`、`sum`、`avg`、`min`、`max`、`count_distinct`、`array_agg`、`string_agg`。元件的 `dimensions` 和 `values` 必須引用其所繫結資料集上宣告的名稱。 - -執行時,資料集查詢經由分析服務執行,並自動把呼叫者的行級安全範圍應用到基礎物件和被連線的物件上 —— 儀表盤絕不會向用戶展示其[許可權](/docs/configure/permissions)不允許看的記錄。 - -> 自 16.0 起,元件 Schema 是**嚴格**的:任何未宣告的頂層鍵 —— 打錯的鍵、幻覺出來的鍵,或已移除的內聯查詢鍵(`object` + `categoryField` + `valueField` + `aggregate`,以及透視表的 `rowField` / `columnField`)—— 都是**指名道姓的解析錯誤**,並指向資料集形態,而不再被悄悄剝掉、留下一個什麼都不渲染的元件。請定義資料集並把元件繫結上去;`options` 仍是渲染器專屬額外配置的自由格式逃生口。 - -## 元件 - -| 屬性 | 型別 | 必填 | 說明 | -|:--|:--|:--|:--| -| `id` | `string` | 是 | 唯一的元件 id(`snake_case`) | -| `dataset` | `string` | 是 | 要繫結的資料集名 | -| `values` | `string[]` | 是 | 度量名(至少一個) | -| `dimensions` | `string[]` | — | 維度名 —— X 軸 / 分組 / 拆分 | -| `type` | `ChartType` | — | 視覺化型別(預設 `metric`) | -| `title` / `description` | `string` | — | 顯示標題與副標題 | -| `filter` | `FilterCondition` | — | 展示層篩選 | -| `layout` | `object` | — | 網格位置(省略時自動排布) | -| `colorVariant` | `enum` | — | KPI / 卡片強調色 | -| `compareTo` | `enum \| object` | — | 同比 / 環比對比視窗 | -| `filterBindings` | `object` | — | 元件級全域性篩選對映(見下) | - -### 圖表型別 - -| 型別 | 最適合 | -|:--|:--| -| `metric`*(預設)* | 單數字 KPI —— 營收、數量、百分比 | -| `bar` / `horizontal-bar` / `column` | 類別對比 | -| `line` | 隨時間的趨勢 | -| `pie` / `donut` | 分佈 | -| `area` | 隨時間的體量 | -| `scatter` | 相關性 | -| `radar` | 多維對比 | -| `funnel` | 轉化階段 | -| `gauge` / `solid-gauge` / `bullet` / `kpi` | 目標進度 | -| `treemap` / `sankey` | 層級佔比 / 流向 | -| `table` | 明細記錄 —— **可下鑽** | -| `pivot` | 交叉彙總 —— **可下鑽** | - -### 佈局 - -元件排布在 12 列網格上: - -```ts -layout: { - x: 0, // 列位置(0-11) - y: 0, // 行位置 - w: 6, // 宽度,按列计(1-12) - h: 4, // 高度,按行计 -} -``` - -## 下鑽 - -`table` 和 `pivot` 元件支援**下鑽**:點選一行聚合資料或一個單元格,會開啟一個側邊抽屜,列出該分組背後的*底層記錄*。資料集保留了原始分組鍵,所以抽屜的篩選精確匹配那些記錄 —— 不做標籤到 id 的猜測。點選抽屜裡的任意一行即可開啟該記錄的詳情:完整的**分組 → 記錄列表 → 單條記錄**鏈路。 - -抽屜還提供一個 **"Open in list →"**(在列表中開啟)逃生口,把速覽升級為該物件的完整列表頁(排序、批次選擇、匯出、可分享 URL),並沿用同一個下鑽篩選。 - -下鑽是自動的 —— 無需按元件配置 —— 只要資料集暴露了基礎物件,且元件按至少一個維度分組。`metric` 與圖表類元件只渲染聚合值;要展示明細,請改用 `table` 或 `pivot` 元件。 - -## 全域性日期範圍與篩選 - -全域性時間篩選作用於所有元件: - -```ts -dateRange: { field: 'created_at', defaultRange: 'this_month', allowCustomRange: true } -``` - -預設範圍:`today`、`yesterday`、`this_week`、`last_week`、`this_month`、`last_month`、`this_quarter`、`last_quarter`、`this_year`、`last_year`、`last_7_days`、`last_30_days`、`last_90_days`、`custom`。 - -互動式全域性篩選的用法相同: - -```ts -globalFilters: [ - { name: 'region', field: 'region', label: 'Region', type: 'select' }, - { field: 'owner', label: 'Sales Rep', type: 'lookup' }, -] -``` - -每個篩選的 `name`(預設取 `field`)是它的穩定身份 —— 元件在 `filterBindings` 裡引用的鍵,也是元件表示式中可通過 `page.` 讀取的儀表盤級變數。名稱 `dateRange` 保留給內建日期範圍。 - -### 元件級篩選繫結 - -預設情況下,篩選按其自身的 `field` 作用於每個元件。當某個元件用不同的欄位儲存同一概念 —— 或應忽略某個篩選 —— 時,宣告 `filterBindings`: - -```ts -widgets: [ - // 默认绑定:dateRange → created_at,region → region。 - { id: 'invoices_by_status', /* … */ }, - // 这个组件的字段不同 —— 逐个显式映射筛选。 - { id: 'accounts_signed', - filterBindings: { dateRange: 'signed_at', region: 'sales_region' }, /* … */ }, - // 用 `false` 退出某个筛选。 - { id: 'total_invoices', filterBindings: { region: false }, /* … */ }, -] -``` - -> 優先順序:顯式的 `filterBindings` 條目(字串覆蓋或 `false` 退出)→ 篩選的舊式 `targetWidgets` 白名單 → 篩選自身的 `field`。 - -## 讓儀表盤出現在介面上 - -給[應用](/docs/build/interface/apps)新增一個 `dashboard` 導航入口: - -```ts -{ id: 'nav_analytics', type: 'dashboard', label: 'Analytics', - dashboardName: 'sales_overview', icon: 'bar-chart' } -``` - -或者描述給 [AI Builder](/docs/build/ai-builder) —— *"一個銷售總覽儀表盤,含按區域的營收和月度成交趨勢"* —— 然後在批准前審閱生成的資料集 + 儀表盤後設資料。 - -## 下一步 - -| 頁面 | 原因 | -|:--|:--| -| [應用](/docs/build/interface/apps) | 把儀表盤放進導航 | -| [檢視](/docs/build/interface/views) | 基於同一資料集的內聯 `chart` 列表檢視 | -| [頁面](/docs/build/interface/pages) | 當元件網格不夠用時的自由佈局 | -| [資料模型](/docs/build/data) | 資料集聚合的物件 | -| [許可權](/docs/configure/permissions) | 儀表盤繼承的行級安全 | diff --git a/content/docs/build/interface/forms.mdx b/content/docs/build/interface/forms.mdx index d5ad351..f64fbdd 100644 --- a/content/docs/build/interface/forms.mdx +++ b/content/docs/build/interface/forms.mdx @@ -115,10 +115,18 @@ logical home. It travels with the model and seeds the *default* sectioning of auto-generated forms: ```ts +// Fields of an object; the object around them is omitted. fields: { name: Field.text({ label: 'Full name', group: 'contact' }), email: Field.email({ label: 'Email', group: 'contact' }), - stage: Field.select({ label: 'Stage', group: 'status', options: [/* … */] }), + stage: Field.select({ + label: 'Stage', + group: 'status', + options: [ + { label: 'Lead', value: 'lead' }, + { label: 'Customer', value: 'customer' }, + ], + }), } ``` @@ -128,6 +136,7 @@ collapsible. It inherits `field.group` as the default and overrides it per form when needed. ```ts +// Keys of a view container; `data` and its other keys are omitted. form: { type: 'simple', sections: [ @@ -152,6 +161,7 @@ Add `submitBehavior` to a form view to control the post-submit experience: ```ts +// Keys of a form view; its other keys are omitted. submitBehavior: { kind: 'thank-you', title: 'Thanks!', diff --git a/content/docs/build/interface/pages.mdx b/content/docs/build/interface/pages.mdx index f4c0ae5..5d0f53a 100644 --- a/content/docs/build/interface/pages.mdx +++ b/content/docs/build/interface/pages.mdx @@ -10,6 +10,7 @@ page combines multiple components, embeds views, and manages local state — your home screens, custom record layouts, and utility panels. ```ts +// One page. const homePage = { name: 'sales_home', label: 'Sales Home', @@ -59,6 +60,7 @@ shipped a renderer were removed from the schema. Regions are the layout zones; each holds components: ```ts +// Keys of a page; its other keys, and the regions' components, are omitted. regions: [ { name: 'sidebar', width: 'small', components: [/* … */] }, { name: 'content', width: 'large', components: [/* … */] }, @@ -72,6 +74,7 @@ regions: [ Components are the building blocks inside regions: ```ts +// One page component; the page and region around it are omitted. { type: 'chart', id: 'revenue_chart', @@ -120,6 +123,7 @@ string types are accepted for project-specific widgets. Pages hold local state shared across components: ```ts +// Keys of a page; its other keys are omitted. variables: [ { name: 'selected_tab', type: 'string', defaultValue: 'overview' }, { name: 'date_range', type: 'object', defaultValue: { start: null, end: null } }, diff --git a/content/docs/configure/data-sources.mdx b/content/docs/configure/data-sources.mdx index 8d13aa6..1d135d1 100644 --- a/content/docs/configure/data-sources.mdx +++ b/content/docs/configure/data-sources.mdx @@ -46,31 +46,29 @@ The shipped drivers are: ## Declaring datasources Datasources are declared on the stack and assembled with `defineStack`. -Define each connection as a typed `Datasource` object, then list them -under `datasources`: +Define each connection with `defineDatasource()`, then list them under +`datasources`: ```ts // src/datasources/business.datasource.ts -import type { Datasource } from '@objectstack/spec'; +import { defineDatasource } from '@objectstack/spec'; -// Connect to an EXISTING production database. Credentials come from the -// environment — never inline secrets in source. -export const BusinessDb: Datasource = { +// Connect to an EXISTING production database. The password is never inline: +// it lives in the secrets store, and the datasource only references it. +export const BusinessDb = defineDatasource({ name: 'business_primary', label: 'Business System (Postgres)', driver: 'postgres', config: { - connection: { - host: process.env.BIZ_DB_HOST, - port: Number(process.env.BIZ_DB_PORT ?? 5432), - user: process.env.BIZ_DB_USER, - password: process.env.BIZ_DB_PASSWORD, - database: process.env.BIZ_DB_NAME, - }, + host: 'db.internal.example.com', + port: 5432, + database: 'business', + username: 'objectos', }, + external: { credentialsRef: 'secret:business_db/password' }, pool: { min: 1, max: 10 }, active: true, -}; +}); ``` ```ts @@ -80,20 +78,32 @@ import * as objects from './src/objects/index.js'; import { BusinessDb } from './src/datasources/business.datasource.js'; export default defineStack({ - manifest: { id: 'app.example.crm-extend', namespace: 'biz', version: '1.0.0' }, + manifest: { + id: 'app.example.crm-extend', + namespace: 'biz', + version: '1.0.0', + type: 'app', + name: 'CRM extension', + }, datasources: [BusinessDb], objects: Object.values(objects), }); ``` -> **A `defineDatasource()` helper is also available.** The plain -> `Datasource` object above is a valid datasource — it is exactly what the -> `examples/app-crm` stack does in the framework repo — and -> `defineDatasource()`, exported from `@objectstack/spec` (and -> `@objectstack/spec/data`), takes the same config and runs the +> **`defineDatasource()` validates where you write it.** Exported from +> `@objectstack/spec` (and `@objectstack/spec/data`), it runs the > `DatasourceSchema` validation at the point of declaration, so a bad field -> is reported where you wrote it. The framework's own federation guide uses -> it. +> is reported in the file that has it. The framework's `examples/app-crm` +> stack declares its datasources this way. A plain object typed `Datasource` +> (from `@objectstack/spec/data`) is also accepted, and is validated when the +> stack is assembled. +> +> **Credentials are never inline.** `DatasourceSchema` refuses a `password` +> in `config`, even one read from the environment: the datasource is stored +> whole in `sys_metadata`, so an inline secret would sit there in cleartext. +> Put the secret in the secrets store and name it in +> `external.credentialsRef`. The resolved secret is injected at connect +> time. ## Binding objects to a datasource @@ -111,7 +121,13 @@ export const Customer = ObjectSchema.create({ fields: { name: Field.text({ label: 'Name', required: true }), email: Field.text({ label: 'Email' }), - tier: Field.select({ label: 'Tier', options: [/* … */] }), + tier: Field.select({ + label: 'Tier', + options: [ + { label: 'Standard', value: 'standard' }, + { label: 'Premium', value: 'premium' }, + ], + }), }, }); ``` @@ -136,6 +152,20 @@ packages, declare routing rules once on the stack. Rules are evaluated in order (or by `priority`); first match wins: ```ts +// A second connection, for reporting. +export const AnalyticsReplica = defineDatasource({ + name: 'analytics_replica', + label: 'Analytics replica', + driver: 'postgres', + config: { + host: 'replica.internal.example.com', + port: 5432, + database: 'business', + username: 'objectos_reports', + }, + external: { credentialsRef: 'secret:analytics_db/password' }, +}); + export default defineStack({ datasources: [BusinessDb, AnalyticsReplica], datasourceMapping: [ diff --git a/content/docs/configure/permissions/field-level-security.mdx b/content/docs/configure/permissions/field-level-security.mdx index 921cb6f..b95a296 100644 --- a/content/docs/configure/permissions/field-level-security.mdx +++ b/content/docs/configure/permissions/field-level-security.mdx @@ -22,6 +22,7 @@ Field permissions are keyed `.` and use `readable` / `editable`: ```ts +// Keys of a permission set; its other keys are omitted. fields: { // Read-only: visible but not editable 'account.annual_revenue': { readable: true, editable: false }, diff --git a/content/docs/configure/permissions/permission-sets.mdx b/content/docs/configure/permissions/permission-sets.mdx index efc5c7c..034d295 100644 --- a/content/docs/configure/permissions/permission-sets.mdx +++ b/content/docs/configure/permissions/permission-sets.mdx @@ -64,6 +64,7 @@ either — "may see all data" and "may take a bulk copy of it" are deliberately different grants. ```ts +// Keys of a permission set; its other keys are omitted. objects: { deal: { allowRead: true, allowExport: true }, // ← add the grant } diff --git a/content/docs/configure/permissions/record-access.mdx b/content/docs/configure/permissions/record-access.mdx index 161a1a2..4fa1e55 100644 --- a/content/docs/configure/permissions/record-access.mdx +++ b/content/docs/configure/permissions/record-access.mdx @@ -32,7 +32,10 @@ open behavior: ObjectSchema.create({ name: 'crm_lead', sharingModel: 'public_read_write', - // … + fields: { + company: Field.text({ label: 'Company' }), + // … + }, }); ``` diff --git a/content/docs/quickstart.mdx b/content/docs/quickstart.mdx index b56b608..d42b0e6 100644 --- a/content/docs/quickstart.mdx +++ b/content/docs/quickstart.mdx @@ -249,7 +249,7 @@ export const Task = ObjectSchema.create({ subject: Field.text({ label: 'Subject', required: true, maxLength: 200 }), done: Field.boolean({ label: 'Done', defaultValue: false }), due: Field.date({ label: 'Due' }), - assignee: Field.lookup({ label: 'Assignee', reference: 'sys_user' }), + assignee: Field.lookup('sys_user', { label: 'Assignee' }), }, }); ``` diff --git a/content/docs/reference/cel.de.mdx b/content/docs/reference/cel.de.mdx deleted file mode 100644 index bcf38c1..0000000 --- a/content/docs/reference/cel.de.mdx +++ /dev/null @@ -1,174 +0,0 @@ ---- -title: CEL-Ausdrücke -description: Die Ausdruckssprache, die für Formeln, Prädikate, Zeitpläne und vorlagenbasierte Zeichenketten verwendet wird — bereitgestellt über fünf getaggte Templates. -translation: - source_sha: 9489636c35a7a16dd066125812e7e218008b3e558ba4c8dd92d6d3126e60f234 - guide_rev: 1 - mode: auto ---- - -ObjectOS verwendet [CEL](https://github.com/google/cel-spec) (Common -Expression Language) überall dort, wo Sie einen kleinen, sicheren, -sandboxed Ausdruck benötigen: Formelfelder, Validierungsregeln, -Sichtbarkeitsprädikate, Freigabebedingungen, Flow-Guards, Zeitpläne und -vorlagenbasierte Zeichenketten. - -Das Erstellen erfolgt über **fünf getaggte Templates**, die aus -`@objectstack/spec` importiert werden. Sie alle erzeugen ein kleines -JSON-Objekt, das die Laufzeit parst: - -```ts -{ dialect: 'cel' | 'template' | 'cron', source: string } -``` - -Schema-Quelle: -[`packages/spec/src/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts). - -## Die fünf getaggten Templates - -| Template | Dialekt | Verwendung für | Beispiel | -|:--|:--|:--|:--| -| `` F`...` `` | `cel` | **Formelfelder** — abgeleitete Werte, die zusammen mit dem Datensatz gespeichert werden | `` F`record.amount * 0.1` `` | -| `` P`...` `` | `cel` | **Prädikate** — Booleans für Validierung / Freigabe / Sichtbarkeit / Bedingungen | `` P`record.status == "open"` `` | -| `` cel`...` `` | `cel` | **Allgemeines CEL** — wenn weder F noch P passt (z. B. ein Parameterwert) | `` cel`now() + duration("P30D")` `` | -| `` tmpl`...` `` | `template` | **Zeichenketten-Templates** mit `{{var}}`-Interpolation | `` tmpl`Order from {{record.customer.name}}` `` | -| `` cron`...` `` | `cron` | **Zeitpläne** — standardmäßige Cron-Syntax mit 5 Feldern | `` cron`0 9 * * 1-5` `` | - -Zur Auswertungszeit gibt es keinen funktionalen Unterschied zwischen `F`, -`P` und `cel` — sie alle führen CEL aus. Die Aufteilung existiert, damit -Schemas (und KI-Agenten) wissen, welche Rolle der Ausdruck spielt, und -damit der Editor eine Typprüfung durchführen kann (Formeln müssen einen -Wert zurückgeben, Prädikate müssen einen Boolean zurückgeben). - -### Import - -```ts -import { F, P, cel, tmpl, cron } from '@objectstack/spec' -``` - -## Wo jedes verwendet wird - -| Feld einer Spec | Tag | Beispielort | -|:--|:--|:--| -| `Field.expression` (Formeltyp) | `F` | `*.object.ts` Formelfelder | -| `Field.conditionalRequired` | `P` | Objektfelder | -| `Validation.predicate` | `P` | Objektvalidierungen | -| `SharingRule.condition` | `P` | Freigaberegeln | -| `View.conditionalFormatting[].condition` | `P` | Ansichten | -| `Flow.step.when` / `Flow.transition.when` | `P` | Flows | -| `Action.guard` | `P` | Aktionen | -| Benachrichtigungsbetreffe / Nachrichtentexte | `tmpl` | Benachrichtigungen | -| `Schedule.cron` | `cron` | geplante Flows / Berichte | -| Beliebiger Wertparameter | `cel` | Flow-Schritt-Eingaben | - -## Variablen-Geltungsbereich - -CEL-Ausdrücke werden in einem Kontext mit diesen Top-Level-Variablen -ausgewertet: - -| Variable | Verfügbar wenn | Inhalt | -|:--|:--|:--| -| `record` | fast immer | Der aktuell ausgewertete Datensatz | -| `previous` | bei Update-Hooks / Änderungserkennung | Der Zustand des Datensatzes vor der Änderung (oder `null`) | -| `input` | Aktionen, Flow-Schritte | Die vom Benutzer bereitgestellte Eingabe-Payload | -| `os.user` | immer | `{ id, roles: string[], permissions: string[] }` | -| `os.org` | immer | Organisations- / Mandantenkontext | -| `os.env` | immer | Für Ausdrücke verfügbar gemachte Umgebungsvariablen | - -> Die veralteten Variablen `OLD` / `NEW` wurden in M9.5 entfernt. -> Verwenden Sie `previous` und `record`. - -## Standardbibliothek - -Registriert in -[`packages/formula/src/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts). -Die am häufigsten verwendeten Built-ins: - -### Zeit - -| Funktion | Gibt zurück | Hinweise | -|:--|:--|:--| -| `now()` | `Timestamp` | An den Auswertungskontext gebunden — innerhalb einer Abfrage stabil | -| `today()` | `Timestamp` | Beginn des UTC-Tages | -| `daysFromNow(int)` | `Timestamp` | Zukünftiges Datum | -| `daysAgo(int)` | `Timestamp` | Vergangenes Datum | - -CEL enthält außerdem die nativen `timestamp(...)`, `duration(...)`, -`date.getDayOfWeek()` usw. — siehe die -[CEL-Spezifikation](https://github.com/google/cel-spec/blob/master/doc/langdef.md#standard-definitions). - -### Hilfsfunktionen - -| Funktion | Zweck | -|:--|:--| -| `isBlank(x)` | `true` wenn `null`, `undefined`, `""` oder leere Liste | -| `coalesce(a, b)` | Erster Nicht-Null-Wert | -| `trim(s)` | Whitespace entfernen | -| `joinNonEmpty(list, sep)` | Nicht-leere Einträge verketten | - -Native CEL-String-Helfer (`.contains(...)`, `.startsWith(...)`, -`.matches(...)`, `.size()`) sind immer verfügbar. - -## Beispiele - -**Formelfeld** — Positionssumme: - -```ts -{ name: 'subtotal', type: 'formula', expression: F`record.quantity * record.unit_price` } -``` - -**Validierung** — Abschlussdatum muss nach heute liegen: - -```ts -{ message: 'Close date must be in the future', predicate: P`record.close_date > today()` } -``` - -**Sichtbarkeit** — Feld nur für Manager anzeigen: - -```ts -{ visibleIf: P`'manager' in os.user.roles` } -``` - -**Flow-Guard** — Schritt überspringen, wenn der Betrag klein ist: - -```ts -{ when: P`record.amount >= 1000` } -``` - -**Zeitplan** — wochentags 9 Uhr: - -```ts -{ schedule: cron`0 9 * * 1-5` } -``` - -**Template** — Benachrichtigungsbetreff: - -```ts -{ subject: tmpl`[{{record.priority}}] {{record.subject}}` } -``` - -## Fehler - -Ausdrücke werden zur Ladezeit kompiliert. Fehler erscheinen als -`VALIDATION_ERROR` mit der Quellposition: - -```json -{ "code": "VALIDATION_ERROR", "message": "CEL: unknown field 'amout' on Record", "details": { "field": "subtotal", "expression": "record.amout * 0.1" } } -``` - -Ungültige Ausdrücke **scheitern nicht stillschweigend**. Ein fehlerhafter -Ausdruck oder einer mit unbekanntem Feld lässt `os compile` mit der oben -gezeigten lokalisierten Meldung fehlschlagen (einschließlich eines -„Meinten Sie"-Hinweises bei einem falsch geschriebenen Feld). Zur Laufzeit -**wirft** ein fehlerhafter Ausdruck einen zugeordneten Fehler, anstatt -stillschweigend zu `null` oder `false` ausgewertet zu werden, sodass der -Fehler in den Logs und im Audit-Trail sichtbar ist, statt einen Formelwert -oder eine Guard-Entscheidung unbemerkt zu beschädigen. - -## Siehe auch - -- [Feldtypen](./field-types) — Formel- und bedingt erforderliche Felder -- [Build → Datenmodell](/docs/build/data) — Validierungen und Prädikate -- [Build → Flows](/docs/build/automation/flows) — Guards und Zeitpläne -- [`@objectstack/spec/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts) — Schema -- [`@objectstack/formula/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts) — Built-ins diff --git a/content/docs/reference/cel.es.mdx b/content/docs/reference/cel.es.mdx deleted file mode 100644 index f93d787..0000000 --- a/content/docs/reference/cel.es.mdx +++ /dev/null @@ -1,173 +0,0 @@ ---- -title: Expresiones CEL -description: El lenguaje de expresiones usado para fórmulas, predicados, programaciones y cadenas con plantilla — expuesto mediante cinco plantillas etiquetadas. -translation: - source_sha: 9489636c35a7a16dd066125812e7e218008b3e558ba4c8dd92d6d3126e60f234 - guide_rev: 1 - mode: auto ---- - -ObjectOS usa [CEL](https://github.com/google/cel-spec) (Common -Expression Language) para cada lugar donde necesitas una expresión -pequeña, segura y aislada: campos de fórmula, reglas de validación, -predicados de visibilidad, condiciones de compartición, guardas de flujo, -programaciones y cadenas con plantilla. - -La autoría se realiza a través de **cinco plantillas etiquetadas** -importadas desde `@objectstack/spec`. Todas producen un pequeño objeto JSON -que el runtime analiza: - -```ts -{ dialect: 'cel' | 'template' | 'cron', source: string } -``` - -Fuente del esquema: -[`packages/spec/src/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts). - -## Las cinco plantillas etiquetadas - -| Plantilla | Dialecto | Úsala para | Ejemplo | -|:--|:--|:--|:--| -| `` F`...` `` | `cel` | **Campos de fórmula** — valores derivados almacenados junto al registro | `` F`record.amount * 0.1` `` | -| `` P`...` `` | `cel` | **Predicados** — booleanos para validación / compartición / visibilidad / condiciones | `` P`record.status == "open"` `` | -| `` cel`...` `` | `cel` | **CEL general** — cuando ni F ni P encajan (p. ej. el valor de un parámetro) | `` cel`now() + duration("P30D")` `` | -| `` tmpl`...` `` | `template` | **Plantillas de cadenas** con interpolación `{{var}}` | `` tmpl`Order from {{record.customer.name}}` `` | -| `` cron`...` `` | `cron` | **Programaciones** — sintaxis cron estándar de 5 campos | `` cron`0 9 * * 1-5` `` | - -No hay diferencia funcional entre `F`, `P` y `cel` en el momento de la -evaluación — todas ejecutan CEL. La división existe para que los esquemas -(y los agentes de IA) sepan qué rol desempeña la expresión y para que el -editor pueda comprobar tipos (las fórmulas deben devolver un valor, los -predicados deben devolver un bool). - -### Importación - -```ts -import { F, P, cel, tmpl, cron } from '@objectstack/spec' -``` - -## Dónde se usa cada una - -| Campo en una spec | Etiqueta | Ubicación de ejemplo | -|:--|:--|:--| -| `Field.expression` (tipo fórmula) | `F` | campos de fórmula en `*.object.ts` | -| `Field.conditionalRequired` | `P` | campos de objeto | -| `Validation.predicate` | `P` | validaciones de objeto | -| `SharingRule.condition` | `P` | reglas de compartición | -| `View.conditionalFormatting[].condition` | `P` | vistas | -| `Flow.step.when` / `Flow.transition.when` | `P` | flujos | -| `Action.guard` | `P` | acciones | -| Asuntos de notificaciones / cuerpos de mensaje | `tmpl` | notificaciones | -| `Schedule.cron` | `cron` | flujos / informes programados | -| Cualquier parámetro de valor | `cel` | entradas de pasos de flujo | - -## Ámbito de variables - -Las expresiones CEL se evalúan en un contexto con estas variables de nivel superior: - -| Variable | Disponible cuando | Contenido | -|:--|:--|:--| -| `record` | casi siempre | El registro actual que se está evaluando | -| `previous` | en hooks de actualización / detección de cambios | El estado del registro previo al cambio (o `null`) | -| `input` | acciones, pasos de flujo | La carga útil de entrada proporcionada por el usuario | -| `os.user` | siempre | `{ id, roles: string[], permissions: string[] }` | -| `os.org` | siempre | Contexto de organización / tenant | -| `os.env` | siempre | Variables de entorno expuestas a las expresiones | - -> Las variables heredadas `OLD` / `NEW` se eliminaron en M9.5. Usa `previous` -> y `record`. - -## Biblioteca estándar - -Registrada en -[`packages/formula/src/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts). -Las funciones integradas más usadas: - -### Tiempo - -| Función | Devuelve | Notas | -|:--|:--|:--| -| `now()` | `Timestamp` | Fijada al contexto de evaluación — estable dentro de una consulta | -| `today()` | `Timestamp` | Inicio del día UTC | -| `daysFromNow(int)` | `Timestamp` | Fecha futura | -| `daysAgo(int)` | `Timestamp` | Fecha pasada | - -CEL también incluye los nativos `timestamp(...)`, `duration(...)`, -`date.getDayOfWeek()`, etc. — consulta la -[especificación de CEL](https://github.com/google/cel-spec/blob/master/doc/langdef.md#standard-definitions). - -### Utilidad - -| Función | Propósito | -|:--|:--| -| `isBlank(x)` | `true` si es `null`, `undefined`, `""` o una lista vacía | -| `coalesce(a, b)` | Primer valor no nulo | -| `trim(s)` | Elimina espacios en blanco | -| `joinNonEmpty(list, sep)` | Concatena las entradas no vacías | - -Los ayudantes de cadenas nativos de CEL (`.contains(...)`, `.startsWith(...)`, -`.matches(...)`, `.size()`) siempre están disponibles. - -## Ejemplos - -**Campo de fórmula** — total de línea de artículo: - -```ts -{ name: 'subtotal', type: 'formula', expression: F`record.quantity * record.unit_price` } -``` - -**Validación** — la fecha de cierre debe ser posterior a hoy: - -```ts -{ message: 'Close date must be in the future', predicate: P`record.close_date > today()` } -``` - -**Visibilidad** — mostrar el campo solo a los gerentes: - -```ts -{ visibleIf: P`'manager' in os.user.roles` } -``` - -**Guarda de flujo** — omitir el paso cuando el importe es pequeño: - -```ts -{ when: P`record.amount >= 1000` } -``` - -**Programación** — días laborables a las 9 a. m.: - -```ts -{ schedule: cron`0 9 * * 1-5` } -``` - -**Plantilla** — asunto de notificación: - -```ts -{ subject: tmpl`[{{record.priority}}] {{record.subject}}` } -``` - -## Errores - -Las expresiones se compilan en el momento de la carga. Los fallos aparecen como -`VALIDATION_ERROR` con la ubicación en la fuente: - -```json -{ "code": "VALIDATION_ERROR", "message": "CEL: unknown field 'amout' on Record", "details": { "field": "subtotal", "expression": "record.amout * 0.1" } } -``` - -Las expresiones inválidas **no fallan en silencio**. Una expresión mal formada o -con un campo desconocido hace fallar `os compile` con el mensaje localizado de -arriba (incluida una sugerencia «quizás quisiste decir» para un campo mal -escrito). En tiempo de ejecución, una expresión incorrecta **lanza** un error -atribuido en lugar de evaluarse silenciosamente a `null` o `false`, de modo que -el fallo es visible en los logs y en el registro de auditoría en lugar de -corromper de forma silenciosa el valor de una fórmula o la decisión de una -guarda. - -## Véase también - -- [Tipos de campo](./field-types) — campos de fórmula y de obligatoriedad condicional -- [Build → Modelo de datos](/docs/build/data) — validaciones y predicados -- [Build → Flujos](/docs/build/automation/flows) — guardas y programaciones -- [`@objectstack/spec/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts) — esquema -- [`@objectstack/formula/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts) — funciones integradas diff --git a/content/docs/reference/cel.fr.mdx b/content/docs/reference/cel.fr.mdx deleted file mode 100644 index 30732fa..0000000 --- a/content/docs/reference/cel.fr.mdx +++ /dev/null @@ -1,176 +0,0 @@ ---- -title: Expressions CEL -description: Le langage d'expression utilisé pour les formules, les prédicats, les planifications et les chaînes à modèle — exposé via cinq tagged templates. -translation: - source_sha: 9489636c35a7a16dd066125812e7e218008b3e558ba4c8dd92d6d3126e60f234 - guide_rev: 1 - mode: auto ---- - -ObjectOS utilise [CEL](https://github.com/google/cel-spec) (Common -Expression Language) partout où vous avez besoin d'une expression -petite, sûre et isolée : champs de formule, règles de validation, -prédicats de visibilité, conditions de partage, gardes de flux, -planifications et chaînes à modèle. - -La rédaction se fait via **cinq tagged templates** importés depuis -`@objectstack/spec`. Ils produisent tous un petit objet JSON que le -runtime analyse : - -```ts -{ dialect: 'cel' | 'template' | 'cron', source: string } -``` - -Source du schéma : -[`packages/spec/src/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts). - -## Les cinq tagged templates - -| Template | Dialecte | À utiliser pour | Exemple | -|:--|:--|:--|:--| -| `` F`...` `` | `cel` | **Champs de formule** — valeurs dérivées stockées avec l'enregistrement | `` F`record.amount * 0.1` `` | -| `` P`...` `` | `cel` | **Prédicats** — booléens pour la validation / le partage / la visibilité / les conditions | `` P`record.status == "open"` `` | -| `` cel`...` `` | `cel` | **CEL générique** — quand ni F ni P ne convient (par ex. une valeur de paramètre) | `` cel`now() + duration("P30D")` `` | -| `` tmpl`...` `` | `template` | **Chaînes à modèle** avec interpolation `{{var}}` | `` tmpl`Order from {{record.customer.name}}` `` | -| `` cron`...` `` | `cron` | **Planifications** — syntaxe cron standard à 5 champs | `` cron`0 9 * * 1-5` `` | - -Il n'y a aucune différence fonctionnelle entre `F`, `P` et `cel` au -moment de l'évaluation — ils exécutent tous CEL. La distinction existe -pour que les schémas (et les agents IA) sachent quel rôle joue -l'expression et pour que l'éditeur puisse vérifier les types (les -formules doivent renvoyer une valeur, les prédicats doivent renvoyer un -booléen). - -### Import - -```ts -import { F, P, cel, tmpl, cron } from '@objectstack/spec' -``` - -## Où chacun est utilisé - -| Champ sur une spec | Tag | Emplacement d'exemple | -|:--|:--|:--| -| `Field.expression` (type formule) | `F` | champs de formule `*.object.ts` | -| `Field.conditionalRequired` | `P` | champs d'objet | -| `Validation.predicate` | `P` | validations d'objet | -| `SharingRule.condition` | `P` | règles de partage | -| `View.conditionalFormatting[].condition` | `P` | vues | -| `Flow.step.when` / `Flow.transition.when` | `P` | flux | -| `Action.guard` | `P` | actions | -| Sujets de notification / corps de message | `tmpl` | notifications | -| `Schedule.cron` | `cron` | flux / rapports planifiés | -| Toute valeur de paramètre | `cel` | entrées d'étape de flux | - -## Portée des variables - -Les expressions CEL s'évaluent dans un contexte comportant ces -variables de premier niveau : - -| Variable | Disponible quand | Contenu | -|:--|:--|:--| -| `record` | presque toujours | L'enregistrement en cours d'évaluation | -| `previous` | sur les hooks de mise à jour / la détection de changement | L'état de l'enregistrement avant le changement (ou `null`) | -| `input` | actions, étapes de flux | La charge utile d'entrée fournie par l'utilisateur | -| `os.user` | toujours | `{ id, roles: string[], permissions: string[] }` | -| `os.org` | toujours | Contexte d'organisation / de locataire | -| `os.env` | toujours | Variables d'environnement exposées aux expressions | - -> Les anciennes variables `OLD` / `NEW` ont été supprimées en M9.5. -> Utilisez `previous` et `record`. - -## Bibliothèque standard - -Enregistrée dans -[`packages/formula/src/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts). -Les fonctions intégrées les plus utilisées : - -### Temps - -| Fonction | Renvoie | Notes | -|:--|:--|:--| -| `now()` | `Timestamp` | Figé au contexte d'évaluation — stable au sein d'une même requête | -| `today()` | `Timestamp` | Début du jour UTC | -| `daysFromNow(int)` | `Timestamp` | Date future | -| `daysAgo(int)` | `Timestamp` | Date passée | - -CEL inclut aussi les fonctions natives `timestamp(...)`, -`duration(...)`, `date.getDayOfWeek()`, etc. — voir la -[spécification CEL](https://github.com/google/cel-spec/blob/master/doc/langdef.md#standard-definitions). - -### Utilitaires - -| Fonction | Objet | -|:--|:--| -| `isBlank(x)` | `true` si `null`, `undefined`, `""` ou liste vide | -| `coalesce(a, b)` | Première valeur non nulle | -| `trim(s)` | Supprime les espaces | -| `joinNonEmpty(list, sep)` | Concatène les entrées non vides | - -Les fonctions natives CEL pour les chaînes (`.contains(...)`, -`.startsWith(...)`, `.matches(...)`, `.size()`) sont toujours -disponibles. - -## Exemples - -**Champ de formule** — total d'une ligne d'article : - -```ts -{ name: 'subtotal', type: 'formula', expression: F`record.quantity * record.unit_price` } -``` - -**Validation** — la date de clôture doit être postérieure à aujourd'hui : - -```ts -{ message: 'Close date must be in the future', predicate: P`record.close_date > today()` } -``` - -**Visibilité** — afficher le champ uniquement aux managers : - -```ts -{ visibleIf: P`'manager' in os.user.roles` } -``` - -**Garde de flux** — ignorer l'étape lorsque le montant est faible : - -```ts -{ when: P`record.amount >= 1000` } -``` - -**Planification** — 9h en semaine : - -```ts -{ schedule: cron`0 9 * * 1-5` } -``` - -**Modèle** — sujet de notification : - -```ts -{ subject: tmpl`[{{record.priority}}] {{record.subject}}` } -``` - -## Erreurs - -Les expressions sont compilées au moment du chargement. Les échecs -apparaissent comme `VALIDATION_ERROR` avec l'emplacement source : - -```json -{ "code": "VALIDATION_ERROR", "message": "CEL: unknown field 'amout' on Record", "details": { "field": "subtotal", "expression": "record.amout * 0.1" } } -``` - -Les expressions invalides **n'échouent pas silencieusement**. Une -expression mal formée ou comportant un champ inconnu fait échouer -`os compile` avec le message localisé ci-dessus (y compris une suggestion -« vouliez-vous dire » pour un champ mal orthographié). Au runtime, une -mauvaise expression **lève** une erreur attribuée plutôt que de s'évaluer -silencieusement à `null` ou `false`, de sorte que l'échec est visible dans -les journaux et la piste d'audit au lieu de corrompre discrètement la valeur -d'un formule ou la décision d'une garde. - -## Voir aussi - -- [Types de champs](./field-types) — champs de formule et à exigence conditionnelle -- [Build → Modèle de données](/docs/build/data) — validations et prédicats -- [Build → Flux](/docs/build/automation/flows) — gardes et planifications -- [`@objectstack/spec/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts) — schéma -- [`@objectstack/formula/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts) — fonctions intégrées diff --git a/content/docs/reference/cel.ja.mdx b/content/docs/reference/cel.ja.mdx deleted file mode 100644 index 6e3be43..0000000 --- a/content/docs/reference/cel.ja.mdx +++ /dev/null @@ -1,162 +0,0 @@ ---- -title: CEL 式 -description: 数式、述語、スケジュール、テンプレート文字列に使用される式言語 — 5 つのタグ付きテンプレートを通じて提供されます。 -translation: - source_sha: 9489636c35a7a16dd066125812e7e218008b3e558ba4c8dd92d6d3126e60f234 - guide_rev: 1 - mode: auto ---- - -ObjectOS は、小さく安全でサンドボックス化された式が必要なあらゆる場所で [CEL](https://github.com/google/cel-spec)(Common -Expression Language)を使用します。対象は、数式フィールド、検証ルール、表示述語、共有条件、フロー -ガード、スケジュール、テンプレート文字列です。 - -オーサリングは `@objectstack/spec` からインポートする **5 つのタグ付きテンプレート** を通じて行います。これらはいずれも、ランタイムが解析する小さな JSON オブジェクトを生成します。 - -```ts -{ dialect: 'cel' | 'template' | 'cron', source: string } -``` - -スキーマソース: -[`packages/spec/src/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts)。 - -## 5 つのタグ付きテンプレート - -| テンプレート | Dialect | 用途 | 例 | -|:--|:--|:--|:--| -| `` F`...` `` | `cel` | **数式フィールド** — レコードと共に保存される派生値 | `` F`record.amount * 0.1` `` | -| `` P`...` `` | `cel` | **述語** — 検証 / 共有 / 表示 / 条件のためのブール値 | `` P`record.status == "open"` `` | -| `` cel`...` `` | `cel` | **汎用 CEL** — F でも P でも合わない場合(例: パラメータ値) | `` cel`now() + duration("P30D")` `` | -| `` tmpl`...` `` | `template` | `{{var}}` 補間を伴う **文字列テンプレート** | `` tmpl`Order from {{record.customer.name}}` `` | -| `` cron`...` `` | `cron` | **スケジュール** — 標準の 5 フィールド cron 構文 | `` cron`0 9 * * 1-5` `` | - -`F`、`P`、`cel` は評価時には機能的な違いはありません — いずれも CEL を実行します。この区別は、スキーマ(および AI エージェント)が式の役割を把握し、エディタが型チェックを行えるようにするために存在します(数式は値を返さなければならず、述語はブール値を返さなければなりません)。 - -### インポート - -```ts -import { F, P, cel, tmpl, cron } from '@objectstack/spec' -``` - -## それぞれの使用場所 - -| spec 上のフィールド | タグ | 使用場所の例 | -|:--|:--|:--| -| `Field.expression`(formula 型) | `F` | `*.object.ts` の数式フィールド | -| `Field.conditionalRequired` | `P` | オブジェクトフィールド | -| `Validation.predicate` | `P` | オブジェクト検証 | -| `SharingRule.condition` | `P` | 共有ルール | -| `View.conditionalFormatting[].condition` | `P` | ビュー | -| `Flow.step.when` / `Flow.transition.when` | `P` | フロー | -| `Action.guard` | `P` | アクション | -| 通知の件名 / メッセージ本文 | `tmpl` | 通知 | -| `Schedule.cron` | `cron` | スケジュール実行されるフロー / レポート | -| 任意の値パラメータ | `cel` | フローステップの入力 | - -## 変数スコープ - -CEL 式は、次のトップレベル変数を持つコンテキスト内で評価されます。 - -| 変数 | 利用可能なタイミング | 内容 | -|:--|:--|:--| -| `record` | ほぼ常時 | 評価中の現在のレコード | -| `previous` | 更新フック / 変更検出時 | レコードの変更前の状態(または `null`) | -| `input` | アクション、フローステップ | ユーザーが指定した入力ペイロード | -| `os.user` | 常時 | `{ id, roles: string[], permissions: string[] }` | -| `os.org` | 常時 | 組織 / テナントのコンテキスト | -| `os.env` | 常時 | 式に公開される環境変数 | - -> レガシーの `OLD` / `NEW` 変数は M9.5 で削除されました。`previous` -> と `record` を使用してください。 - -## 標準ライブラリ - -[`packages/formula/src/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts) -に登録されています。 -最もよく使われる組み込み関数: - -### 時刻 - -| 関数 | 戻り値 | 備考 | -|:--|:--|:--| -| `now()` | `Timestamp` | 評価コンテキストに固定される — 1 つのクエリ内では安定 | -| `today()` | `Timestamp` | UTC の日の開始時刻 | -| `daysFromNow(int)` | `Timestamp` | 未来の日付 | -| `daysAgo(int)` | `Timestamp` | 過去の日付 | - -CEL にはネイティブの `timestamp(...)`、`duration(...)`、 -`date.getDayOfWeek()` なども含まれます — -[CEL spec](https://github.com/google/cel-spec/blob/master/doc/langdef.md#standard-definitions) を参照してください。 - -### ユーティリティ - -| 関数 | 目的 | -|:--|:--| -| `isBlank(x)` | `null`、`undefined`、`""`、または空リストの場合に `true` | -| `coalesce(a, b)` | 最初の非 null 値 | -| `trim(s)` | 空白を除去 | -| `joinNonEmpty(list, sep)` | 空でないエントリを連結 | - -ネイティブの CEL 文字列ヘルパー(`.contains(...)`、`.startsWith(...)`、 -`.matches(...)`、`.size()`)は常に利用可能です。 - -## 例 - -**数式フィールド** — 明細項目の合計: - -```ts -{ name: 'subtotal', type: 'formula', expression: F`record.quantity * record.unit_price` } -``` - -**検証** — 完了日は今日より後でなければならない: - -```ts -{ message: 'Close date must be in the future', predicate: P`record.close_date > today()` } -``` - -**表示** — マネージャーにのみフィールドを表示する: - -```ts -{ visibleIf: P`'manager' in os.user.roles` } -``` - -**フローガード** — 金額が小さい場合はステップをスキップする: - -```ts -{ when: P`record.amount >= 1000` } -``` - -**スケジュール** — 平日の午前 9 時: - -```ts -{ schedule: cron`0 9 * * 1-5` } -``` - -**テンプレート** — 通知の件名: - -```ts -{ subject: tmpl`[{{record.priority}}] {{record.subject}}` } -``` - -## エラー - -式はロード時にコンパイルされます。失敗はソースの場所とともに -`VALIDATION_ERROR` として表示されます。 - -```json -{ "code": "VALIDATION_ERROR", "message": "CEL: unknown field 'amout' on Record", "details": { "field": "subtotal", "expression": "record.amout * 0.1" } } -``` - -無効な式は**暗黙的に失敗しません**。不正な式や未知のフィールドを参照する式は、 -上記の場所特定済みメッセージ(誤記したフィールドに対する did-you-mean ヒントを含む)とともに -`os compile` を失敗させます。ランタイムでは、不正な式は暗黙的に `null` や `false` に -評価されるのではなく、帰属情報付きのエラーを**スロー**します。そのため、数式の値や -ガードの判定を静かに破壊する代わりに、ログと監査証跡で失敗を確認できます。 - -## 関連項目 - -- [Field types](./field-types) — 数式フィールドと条件付き必須フィールド -- [Build → Data model](/docs/build/data) — 検証と述語 -- [Build → Flows](/docs/build/automation/flows) — ガードとスケジュール -- [`@objectstack/spec/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts) — スキーマ -- [`@objectstack/formula/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts) — 組み込み関数 diff --git a/content/docs/reference/cel.ko.mdx b/content/docs/reference/cel.ko.mdx deleted file mode 100644 index fdd12f6..0000000 --- a/content/docs/reference/cel.ko.mdx +++ /dev/null @@ -1,168 +0,0 @@ ---- -title: CEL 표현식 -description: 수식, 술어, 스케줄, 템플릿 문자열에 사용되는 표현식 언어 — 다섯 가지 태그드 템플릿으로 제공됩니다. -translation: - source_sha: 9489636c35a7a16dd066125812e7e218008b3e558ba4c8dd92d6d3126e60f234 - guide_rev: 1 - mode: auto ---- - -ObjectOS는 작고 안전하며 샌드박스화된 표현식이 필요한 모든 곳에서 -[CEL](https://github.com/google/cel-spec)(Common Expression Language)을 -사용합니다. 수식 필드, 검증 규칙, 가시성 술어, 공유 조건, 플로우 가드, -스케줄, 템플릿 문자열 등이 여기에 해당합니다. - -작성은 `@objectstack/spec`에서 가져오는 **다섯 가지 태그드 템플릿**을 통해 -이루어집니다. 이들은 모두 런타임이 파싱하는 작은 JSON 객체를 생성합니다: - -```ts -{ dialect: 'cel' | 'template' | 'cron', source: string } -``` - -스키마 소스: -[`packages/spec/src/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts). - -## 다섯 가지 태그드 템플릿 - -| 템플릿 | Dialect | 용도 | 예시 | -|:--|:--|:--|:--| -| `` F`...` `` | `cel` | **수식 필드** — 레코드와 함께 저장되는 파생 값 | `` F`record.amount * 0.1` `` | -| `` P`...` `` | `cel` | **술어** — 검증 / 공유 / 가시성 / 조건을 위한 불리언 | `` P`record.status == "open"` `` | -| `` cel`...` `` | `cel` | **일반 CEL** — F나 P가 적합하지 않을 때(예: 파라미터 값) | `` cel`now() + duration("P30D")` `` | -| `` tmpl`...` `` | `template` | `{{var}}` 보간을 사용하는 **문자열 템플릿** | `` tmpl`Order from {{record.customer.name}}` `` | -| `` cron`...` `` | `cron` | **스케줄** — 표준 5필드 cron 구문 | `` cron`0 9 * * 1-5` `` | - -평가 시점에서 `F`, `P`, `cel` 사이에는 기능적 차이가 없습니다 — 모두 CEL을 -실행합니다. 이렇게 나눈 이유는 스키마(및 AI 에이전트)가 표현식이 담당하는 -역할을 알 수 있도록 하고, 에디터가 타입 검사를 할 수 있도록 하기 -위함입니다(수식은 값을 반환해야 하고, 술어는 불리언을 반환해야 합니다). - -### Import - -```ts -import { F, P, cel, tmpl, cron } from '@objectstack/spec' -``` - -## 각각의 사용 위치 - -| 스펙의 필드 | 태그 | 예시 위치 | -|:--|:--|:--| -| `Field.expression` (formula 타입) | `F` | `*.object.ts` 수식 필드 | -| `Field.conditionalRequired` | `P` | object 필드 | -| `Validation.predicate` | `P` | object 검증 | -| `SharingRule.condition` | `P` | 공유 규칙 | -| `View.conditionalFormatting[].condition` | `P` | 뷰 | -| `Flow.step.when` / `Flow.transition.when` | `P` | 플로우 | -| `Action.guard` | `P` | 액션 | -| 알림 제목 / 메시지 본문 | `tmpl` | 알림 | -| `Schedule.cron` | `cron` | 예약된 플로우 / 리포트 | -| 임의의 값 파라미터 | `cel` | 플로우 단계 입력 | - -## 변수 스코프 - -CEL 표현식은 다음과 같은 최상위 변수를 가진 컨텍스트에서 평가됩니다: - -| 변수 | 사용 가능한 경우 | 내용 | -|:--|:--|:--| -| `record` | 거의 항상 | 현재 평가 중인 레코드 | -| `previous` | 업데이트 훅 / 변경 감지 시 | 레코드의 변경 전 상태(또는 `null`) | -| `input` | 액션, 플로우 단계 | 사용자가 제공한 입력 페이로드 | -| `os.user` | 항상 | `{ id, roles: string[], permissions: string[] }` | -| `os.org` | 항상 | 조직 / 테넌트 컨텍스트 | -| `os.env` | 항상 | 표현식에 노출되는 환경 변수 | - -> 레거시 `OLD` / `NEW` 변수는 M9.5에서 제거되었습니다. `previous`와 -> `record`를 사용하세요. - -## 표준 라이브러리 - -[`packages/formula/src/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts)에 -등록되어 있습니다. 가장 많이 사용되는 빌트인: - -### 시간 - -| 함수 | 반환 | 비고 | -|:--|:--|:--| -| `now()` | `Timestamp` | 평가 컨텍스트에 고정됨 — 단일 쿼리 내에서 안정적 | -| `today()` | `Timestamp` | UTC 날짜의 시작 | -| `daysFromNow(int)` | `Timestamp` | 미래 날짜 | -| `daysAgo(int)` | `Timestamp` | 과거 날짜 | - -CEL은 네이티브 `timestamp(...)`, `duration(...)`, -`date.getDayOfWeek()` 등도 포함합니다 — -[CEL 스펙](https://github.com/google/cel-spec/blob/master/doc/langdef.md#standard-definitions)을 -참고하세요. - -### 유틸리티 - -| 함수 | 목적 | -|:--|:--| -| `isBlank(x)` | `null`, `undefined`, `""`, 또는 빈 리스트이면 `true` | -| `coalesce(a, b)` | 첫 번째 non-null 값 | -| `trim(s)` | 공백 제거 | -| `joinNonEmpty(list, sep)` | 비어 있지 않은 항목들을 연결 | - -네이티브 CEL 문자열 헬퍼(`.contains(...)`, `.startsWith(...)`, -`.matches(...)`, `.size()`)는 항상 사용할 수 있습니다. - -## 예시 - -**수식 필드** — 라인 항목 합계: - -```ts -{ name: 'subtotal', type: 'formula', expression: F`record.quantity * record.unit_price` } -``` - -**검증** — 마감일은 오늘 이후여야 함: - -```ts -{ message: 'Close date must be in the future', predicate: P`record.close_date > today()` } -``` - -**가시성** — 매니저에게만 필드 표시: - -```ts -{ visibleIf: P`'manager' in os.user.roles` } -``` - -**플로우 가드** — 금액이 작을 때 단계 건너뛰기: - -```ts -{ when: P`record.amount >= 1000` } -``` - -**스케줄** — 평일 오전 9시: - -```ts -{ schedule: cron`0 9 * * 1-5` } -``` - -**템플릿** — 알림 제목: - -```ts -{ subject: tmpl`[{{record.priority}}] {{record.subject}}` } -``` - -## 오류 - -표현식은 로드 시점에 컴파일됩니다. 실패는 소스 위치와 함께 -`VALIDATION_ERROR`로 나타납니다: - -```json -{ "code": "VALIDATION_ERROR", "message": "CEL: unknown field 'amout' on Record", "details": { "field": "subtotal", "expression": "record.amout * 0.1" } } -``` - -잘못된 표현식은 **조용히 실패하지 않습니다**. 형식이 잘못되었거나 알 수 없는 필드를 -참조하는 표현식은 위와 같이 위치가 표시된 메시지(철자가 틀린 필드에 대한 -did-you-mean 힌트 포함)와 함께 `os compile`에서 실패합니다. 런타임에서는 잘못된 -표현식이 조용히 `null` 또는 `false`로 평가되는 대신 출처가 명시된 오류를 **던지므로**, -수식 값이나 가드 결정이 조용히 손상되는 대신 로그와 감사 추적에서 실패를 확인할 수 -있습니다. - -## 참고 - -- [필드 타입](./field-types) — 수식 및 조건부 필수 필드 -- [Build → 데이터 모델](/docs/build/data) — 검증 및 술어 -- [Build → 플로우](/docs/build/automation/flows) — 가드 및 스케줄 -- [`@objectstack/spec/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts) — 스키마 -- [`@objectstack/formula/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts) — 빌트인 diff --git a/content/docs/reference/cel.mdx b/content/docs/reference/cel.mdx index 6915c77..282ceb7 100644 --- a/content/docs/reference/cel.mdx +++ b/content/docs/reference/cel.mdx @@ -7,13 +7,14 @@ description: The expression language used for formulas, predicates, schedules, a ObjectOS uses [CEL](https://github.com/google/cel-spec) (Common Expression Language) for every place where you need a small, safe, sandboxed expression: formula fields, validation rules, visibility -predicates, sharing conditions, flow guards, schedules, and templated +predicates, sharing conditions, flow conditions, schedules, and templated strings. Authoring is done through **five tagged templates** imported from `@objectstack/spec`. They all produce a small JSON object the runtime parses: +{/* doc-sample: skip — a type signature, not a value */} ```ts { dialect: 'cel' | 'template' | 'cron', source: string } ``` @@ -47,16 +48,16 @@ import { F, P, cel, tmpl, cron } from '@objectstack/spec' | Field on a spec | Tag | Example location | |:--|:--|:--| -| `Field.expression` (formula type) | `F` | `*.object.ts` formula fields | -| `Field.conditionalRequired` | `P` | object fields | -| `Validation.predicate` | `P` | object validations | +| A formula field's `expression` | `F` | `*.object.ts` formula fields | +| A field's `requiredWhen` / `visibleWhen` / `readonlyWhen` | `P` | object fields | +| A `script` validation rule's `condition` | `P` | object `validations` | | `SharingRule.condition` | `P` | sharing rules | -| `View.conditionalFormatting[].condition` | `P` | views | -| `Flow.step.when` / `Flow.transition.when` | `P` | flows | -| `Action.guard` | `P` | actions | -| Notification subjects / message bodies | `tmpl` | notifications | -| `Schedule.cron` | `cron` | scheduled flows / reports | -| Any value parameter | `cel` | flow step inputs | +| A list view's `conditionalFormatting[].condition` | `P` | views | +| A start node's `config.condition`, an edge's `condition` | `P` | flows | +| An action's `visible` / `disabled` | `P` | actions | +| A schedule's `expression` | `cron` | scheduled flows and jobs | +| An object's `titleFormat` (deprecated) and AI prompt templates | `tmpl` | objects, AI models | +| A field's `defaultValue` | `cel` | object fields | ## Variable scope @@ -110,37 +111,65 @@ Native CEL string helpers (`.contains(...)`, `.startsWith(...)`, **Formula field** — line-item total: ```ts -{ name: 'subtotal', type: 'formula', expression: F`record.quantity * record.unit_price` } +subtotal: Field.formula({ + label: 'Subtotal', + expression: F`record.quantity * record.unit_price`, +}), ``` -**Validation** — close date must be after today: +**Validation** — close date must be in the future. A `script` rule's +`condition` describes the *invalid* record: when it is true, the write is +refused. ```ts -{ message: 'Close date must be in the future', predicate: P`record.close_date > today()` } +// One validation rule, an entry of the object's `validations` +{ + type: 'script', + name: 'close_date_in_future', + message: 'Close date must be in the future', + condition: P`record.close_date <= today()`, +} ``` -**Visibility** — show field only to managers: +**Visibility** — show a field only once the deal is won: ```ts -{ visibleIf: P`'manager' in os.user.positions` } +close_reason: Field.textarea({ + label: 'Close reason', + visibleWhen: P`record.stage == 'closed_won'`, +}), ``` -**Flow guard** — skip step when amount is small: +**Flow condition** — start a record-change flow only for large amounts: ```ts -{ when: P`record.amount >= 1000` } +// The `config` of a `start` node; the rest of the flow is omitted (see Flows). +config: { + objectName: 'opportunity', + triggerType: 'record-after-update', + condition: P`record.amount >= 1000`, +} ``` **Schedule** — weekday 9am: ```ts -{ schedule: cron`0 9 * * 1-5` } +// The `config` of a `start` node; the rest of the flow is omitted (see Flows). +config: { + triggerType: 'schedule', + schedule: { type: 'cron', expression: cron`0 9 * * 1-5` }, +} ``` -**Template** — notification subject: +**Notification text** is not a `tmpl` slot. A `notify` node's `title` and +`message` are plain strings with `{record.field}` placeholders: ```ts -{ subject: tmpl`[{{record.priority}}] {{record.subject}}` } +// The `config` of a `notify` node; the node around it is omitted. +config: { + recipients: '{record.owner}', + title: '[{record.priority}] {record.subject}', +} ``` ## Errors @@ -161,8 +190,8 @@ quietly corrupting a formula value or a guard decision. ## See also -- [Field types](./field-types) — formula and conditional-required fields +- [Field types](./field-types) — formula fields and `requiredWhen` - [Build → Data model](/docs/build/data) — validations and predicates -- [Build → Flows](/docs/build/automation/flows) — guards and schedules +- [Build → Flows](/docs/build/automation/flows) — conditions and schedules - [`@objectstack/spec/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts) — schema - [`@objectstack/formula/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts) — built-ins diff --git a/content/docs/reference/cel.zh-Hans.mdx b/content/docs/reference/cel.zh-Hans.mdx deleted file mode 100644 index 3288ce0..0000000 --- a/content/docs/reference/cel.zh-Hans.mdx +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: CEL 表达式 -description: 用于公式、谓词、调度和模板字符串的表达式语言 —— 通过五个标签模板暴露。 -translation: - source_sha: 9489636c35a7a16dd066125812e7e218008b3e558ba4c8dd92d6d3126e60f234 - guide_rev: 1 - mode: auto ---- - -ObjectOS 在所有需要小型、安全、沙箱化表达式的地方使用 [CEL](https://github.com/google/cel-spec)(Common Expression Language):公式字段、校验规则、可见性谓词、共享条件、流程守卫、调度和模板字符串。 - -编写通过从 `@objectstack/spec` 导入的**五个标签模板**完成。它们都生成一个由运行时解析的小型 JSON 对象: - -```ts -{ dialect: 'cel' | 'template' | 'cron', source: string } -``` - -Schema 源码: -[`packages/spec/src/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts)。 - -## 五个标签模板 - -| 模板 | Dialect | 用途 | 示例 | -|:--|:--|:--|:--| -| `` F`...` `` | `cel` | **公式字段** —— 与记录一同存储的派生值 | `` F`record.amount * 0.1` `` | -| `` P`...` `` | `cel` | **谓词** —— 用于校验/共享/可见性/条件的布尔值 | `` P`record.status == "open"` `` | -| `` cel`...` `` | `cel` | **通用 CEL** —— 当 F 或 P 都不合适时(例如参数值) | `` cel`now() + duration("P30D")` `` | -| `` tmpl`...` `` | `template` | 带 `{{var}}` 插值的**字符串模板** | `` tmpl`Order from {{record.customer.name}}` `` | -| `` cron`...` `` | `cron` | **调度** —— 标准 5 字段 cron 语法 | `` cron`0 9 * * 1-5` `` | - -`F`、`P`、`cel` 在求值时没有功能差异 —— 它们都运行 CEL。区分的存在是为了让 schema(和 AI Agent)知道表达式扮演什么角色,并让编辑器能进行类型检查(公式必须返回值,谓词必须返回 bool)。 - -### 导入 - -```ts -import { F, P, cel, tmpl, cron } from '@objectstack/spec' -``` - -## 各自的使用位置 - -| Spec 上的字段 | 标签 | 示例位置 | -|:--|:--|:--| -| `Field.expression`(formula 类型) | `F` | `*.object.ts` 公式字段 | -| `Field.conditionalRequired` | `P` | 对象字段 | -| `Validation.predicate` | `P` | 对象校验 | -| `SharingRule.condition` | `P` | 共享规则 | -| `View.conditionalFormatting[].condition` | `P` | 视图 | -| `Flow.step.when` / `Flow.transition.when` | `P` | 流程 | -| `Action.guard` | `P` | Action | -| 通知主题/消息正文 | `tmpl` | 通知 | -| `Schedule.cron` | `cron` | 调度流程/报表 | -| 任意值参数 | `cel` | 流程步骤输入 | - -## 变量作用域 - -CEL 表达式在带有以下顶级变量的上下文中求值: - -| 变量 | 何时可用 | 内容 | -|:--|:--|:--| -| `record` | 几乎总是 | 当前正在求值的记录 | -| `previous` | 更新 Hook/变更检测 | 记录变更前的状态(或 `null`) | -| `input` | Action、流程步骤 | 用户提交的输入载荷 | -| `os.user` | 总是 | `{ id, roles: string[], permissions: string[] }` | -| `os.org` | 总是 | 组织/租户上下文 | -| `os.env` | 总是 | 暴露给表达式的环境变量 | - -> 旧的 `OLD` / `NEW` 变量已在 M9.5 中移除。请使用 `previous` 和 `record`。 - -## 标准库 - -注册位置: -[`packages/formula/src/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts)。 -最常用的内置函数: - -### 时间 - -| 函数 | 返回 | 说明 | -|:--|:--|:--| -| `now()` | `Timestamp` | 钉在求值上下文 —— 单次查询内稳定 | -| `today()` | `Timestamp` | UTC 当日开始 | -| `daysFromNow(int)` | `Timestamp` | 未来日期 | -| `daysAgo(int)` | `Timestamp` | 过去日期 | - -CEL 还包含原生 `timestamp(...)`、`duration(...)`、`date.getDayOfWeek()` 等 —— 参见 -[CEL spec](https://github.com/google/cel-spec/blob/master/doc/langdef.md#standard-definitions)。 - -### 工具 - -| 函数 | 用途 | -|:--|:--| -| `isBlank(x)` | `null`、`undefined`、`""` 或空列表时为 `true` | -| `coalesce(a, b)` | 第一个非空值 | -| `trim(s)` | 去除空白 | -| `joinNonEmpty(list, sep)` | 拼接非空条目 | - -原生 CEL 字符串辅助函数(`.contains(...)`、`.startsWith(...)`、`.matches(...)`、`.size()`)始终可用。 - -## 示例 - -**公式字段** —— 行项目合计: - -```ts -{ name: 'subtotal', type: 'formula', expression: F`record.quantity * record.unit_price` } -``` - -**校验** —— 关闭日期必须在今日之后: - -```ts -{ message: 'Close date must be in the future', predicate: P`record.close_date > today()` } -``` - -**可见性** —— 仅向经理显示字段: - -```ts -{ visibleIf: P`'manager' in os.user.roles` } -``` - -**流程守卫** —— 金额小时跳过步骤: - -```ts -{ when: P`record.amount >= 1000` } -``` - -**调度** —— 工作日 9 点: - -```ts -{ schedule: cron`0 9 * * 1-5` } -``` - -**模板** —— 通知主题: - -```ts -{ subject: tmpl`[{{record.priority}}] {{record.subject}}` } -``` - -## 错误 - -表达式在加载时编译。失败以带源码位置的 `VALIDATION_ERROR` 呈现: - -```json -{ "code": "VALIDATION_ERROR", "message": "CEL: unknown field 'amout' on Record", "details": { "field": "subtotal", "expression": "record.amout * 0.1" } } -``` - -无效表达式**不会静默失败**。畸形或引用未知字段的表达式会让 `os compile` 失败,并给出上面那条带定位的消息(对拼错的字段还包含 did-you-mean 提示)。运行时遇到坏表达式会**抛出**一个带归属信息的错误,而不是静默求值为 `null` 或 `false`,因此该失败会在日志和审计轨迹中可见,而不会悄悄污染一个公式值或一个守卫判定。 - -## 参见 - -- [字段类型](./field-types) —— 公式和条件必填字段 -- [Build → 数据模型](/docs/build/data) —— 校验和谓词 -- [Build → 流程](/docs/build/automation/flows) —— 守卫和调度 -- [`@objectstack/spec/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts) —— schema -- [`@objectstack/formula/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts) —— 内置函数 diff --git a/content/docs/reference/cel.zh-Hant.mdx b/content/docs/reference/cel.zh-Hant.mdx deleted file mode 100644 index e16cde1..0000000 --- a/content/docs/reference/cel.zh-Hant.mdx +++ /dev/null @@ -1,153 +0,0 @@ ---- -# @generated zh-Hant from zh-Hans -- do not edit. Edit the English source, then regenerate: pnpm --filter @objectos/docs gen:zh-hant -title: CEL 表示式 -description: 用於公式、謂詞、排程和模板字串的表示式語言 —— 通過五個標籤模板暴露。 -translation: - source_sha: 9489636c35a7a16dd066125812e7e218008b3e558ba4c8dd92d6d3126e60f234 - guide_rev: 1 - mode: auto ---- - -ObjectOS 在所有需要小型、安全、沙箱化表示式的地方使用 [CEL](https://github.com/google/cel-spec)(Common Expression Language):公式欄位、校驗規則、可見性謂詞、共享條件、流程守衛、排程和模板字串。 - -編寫通過從 `@objectstack/spec` 匯入的**五個標籤模板**完成。它們都生成一個由執行時解析的小型 JSON 物件: - -```ts -{ dialect: 'cel' | 'template' | 'cron', source: string } -``` - -Schema 原始碼: -[`packages/spec/src/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts)。 - -## 五個標籤模板 - -| 模板 | Dialect | 用途 | 示例 | -|:--|:--|:--|:--| -| `` F`...` `` | `cel` | **公式欄位** —— 與記錄一同儲存的派生值 | `` F`record.amount * 0.1` `` | -| `` P`...` `` | `cel` | **謂詞** —— 用於校驗/共享/可見性/條件的布林值 | `` P`record.status == "open"` `` | -| `` cel`...` `` | `cel` | **通用 CEL** —— 當 F 或 P 都不合適時(例如引數值) | `` cel`now() + duration("P30D")` `` | -| `` tmpl`...` `` | `template` | 帶 `{{var}}` 插值的**字串模板** | `` tmpl`Order from {{record.customer.name}}` `` | -| `` cron`...` `` | `cron` | **排程** —— 標準 5 欄位 cron 語法 | `` cron`0 9 * * 1-5` `` | - -`F`、`P`、`cel` 在求值時沒有功能差異 —— 它們都執行 CEL。區分的存在是為了讓 schema(和 AI Agent)知道表示式扮演什麼角色,並讓編輯器能進行型別檢查(公式必須返回值,謂詞必須返回 bool)。 - -### 匯入 - -```ts -import { F, P, cel, tmpl, cron } from '@objectstack/spec' -``` - -## 各自的使用位置 - -| Spec 上的欄位 | 標籤 | 示例位置 | -|:--|:--|:--| -| `Field.expression`(formula 型別) | `F` | `*.object.ts` 公式欄位 | -| `Field.conditionalRequired` | `P` | 物件欄位 | -| `Validation.predicate` | `P` | 物件校驗 | -| `SharingRule.condition` | `P` | 共享規則 | -| `View.conditionalFormatting[].condition` | `P` | 檢視 | -| `Flow.step.when` / `Flow.transition.when` | `P` | 流程 | -| `Action.guard` | `P` | Action | -| 通知主題/訊息正文 | `tmpl` | 通知 | -| `Schedule.cron` | `cron` | 排程流程/報表 | -| 任意值引數 | `cel` | 流程步驟輸入 | - -## 變數作用域 - -CEL 表示式在帶有以下頂級變數的上下文中求值: - -| 變數 | 何時可用 | 內容 | -|:--|:--|:--| -| `record` | 幾乎總是 | 當前正在求值的記錄 | -| `previous` | 更新 Hook/變更檢測 | 記錄變更前的狀態(或 `null`) | -| `input` | Action、流程步驟 | 使用者提交的輸入載荷 | -| `os.user` | 總是 | `{ id, roles: string[], permissions: string[] }` | -| `os.org` | 總是 | 組織/租戶上下文 | -| `os.env` | 總是 | 暴露給表示式的環境變數 | - -> 舊的 `OLD` / `NEW` 變數已在 M9.5 中移除。請使用 `previous` 和 `record`。 - -## 標準庫 - -註冊位置: -[`packages/formula/src/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts)。 -最常用的內建函式: - -### 時間 - -| 函式 | 返回 | 說明 | -|:--|:--|:--| -| `now()` | `Timestamp` | 釘在求值上下文 —— 單次查詢內穩定 | -| `today()` | `Timestamp` | UTC 當日開始 | -| `daysFromNow(int)` | `Timestamp` | 未來日期 | -| `daysAgo(int)` | `Timestamp` | 過去日期 | - -CEL 還包含原生 `timestamp(...)`、`duration(...)`、`date.getDayOfWeek()` 等 —— 參見 -[CEL spec](https://github.com/google/cel-spec/blob/master/doc/langdef.md#standard-definitions)。 - -### 工具 - -| 函式 | 用途 | -|:--|:--| -| `isBlank(x)` | `null`、`undefined`、`""` 或空列表時為 `true` | -| `coalesce(a, b)` | 第一個非空值 | -| `trim(s)` | 去除空白 | -| `joinNonEmpty(list, sep)` | 拼接非空條目 | - -原生 CEL 字串輔助函式(`.contains(...)`、`.startsWith(...)`、`.matches(...)`、`.size()`)始終可用。 - -## 示例 - -**公式欄位** —— 行專案合計: - -```ts -{ name: 'subtotal', type: 'formula', expression: F`record.quantity * record.unit_price` } -``` - -**校驗** —— 關閉日期必須在今日之後: - -```ts -{ message: 'Close date must be in the future', predicate: P`record.close_date > today()` } -``` - -**可見性** —— 僅向經理顯示欄位: - -```ts -{ visibleIf: P`'manager' in os.user.roles` } -``` - -**流程守衛** —— 金額小時跳過步驟: - -```ts -{ when: P`record.amount >= 1000` } -``` - -**排程** —— 工作日 9 點: - -```ts -{ schedule: cron`0 9 * * 1-5` } -``` - -**模板** —— 通知主題: - -```ts -{ subject: tmpl`[{{record.priority}}] {{record.subject}}` } -``` - -## 錯誤 - -表示式在載入時編譯。失敗以帶原始碼位置的 `VALIDATION_ERROR` 呈現: - -```json -{ "code": "VALIDATION_ERROR", "message": "CEL: unknown field 'amout' on Record", "details": { "field": "subtotal", "expression": "record.amout * 0.1" } } -``` - -無效表示式**不會靜默失敗**。畸形或引用未知欄位的表示式會讓 `os compile` 失敗,並給出上面那條帶定位的訊息(對拼錯的欄位還包含 did-you-mean 提示)。執行時遇到壞表示式會**丟擲**一個帶歸屬資訊的錯誤,而不是靜默求值為 `null` 或 `false`,因此該失敗會在日誌和審計軌跡中可見,而不會悄悄汙染一個公式值或一個守衛判定。 - -## 參見 - -- [欄位型別](./field-types) —— 公式和條件必填欄位 -- [Build → 資料模型](/docs/build/data) —— 校驗和謂詞 -- [Build → 流程](/docs/build/automation/flows) —— 守衛和排程 -- [`@objectstack/spec/shared/expression.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/shared/expression.zod.ts) —— schema -- [`@objectstack/formula/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts) —— 內建函式 diff --git a/content/docs/reference/field-types.mdx b/content/docs/reference/field-types.mdx index a013dfb..26bb947 100644 --- a/content/docs/reference/field-types.mdx +++ b/content/docs/reference/field-types.mdx @@ -24,7 +24,7 @@ description: Every field type you can declare on an object — what it stores, w | `system` | boolean | `false` | Auto-injected (id, created_at, …) | | `externalId` | boolean | `false` | Eligible for upsert via external key | | `inlineHelpText` | string | — | Tooltip / helper text | -| `conditionalRequired` | `P` predicate | — | Required when CEL is true | +| `requiredWhen` | `P` predicate | — | Required when CEL is true | | `trackHistory` | boolean | `false` | Render value changes as human-readable entries on the record activity timeline (ADR-0052) | > Removed in 16.0: `columnName` (the physical column is always the field name; @@ -84,6 +84,7 @@ description: Every field type you can declare on an object — what it stores, w Options shape: ```ts +// Keys of a `select` field; its other keys are omitted. options: [ { value: 'low', label: 'Low' }, { value: 'high', label: 'High', color: '#e02' }, @@ -102,6 +103,7 @@ options: [ Common options: ```ts +// One field. { type: 'lookup', reference: 'account', // target object name @@ -127,6 +129,7 @@ Common options: Formula example: ```ts +// One field. { name: 'profit_margin', type: 'formula', @@ -145,6 +148,7 @@ Formula example: | `audio` | audio | waveform preview | ```ts +// One field. { type: 'file', multiple: true, // store an array of attachments @@ -231,7 +235,7 @@ You don't declare these — opt out per object with ## See also - [Build → Data model](/docs/build/data) — composing fields into objects -- [CEL](./cel) — `expression`, `conditionalRequired`, validations +- [CEL](./cel) — `expression`, `requiredWhen`, validations - [ObjectQL](./objectql) — querying these fields - [REST API](./rest-api) — endpoints that produce / consume them - [`@objectstack/spec/data/field.zod.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/data/field.zod.ts) — authoritative schema diff --git a/content/docs/reference/objectql.mdx b/content/docs/reference/objectql.mdx index c4f6f90..b7de483 100644 --- a/content/docs/reference/objectql.mdx +++ b/content/docs/reference/objectql.mdx @@ -18,6 +18,7 @@ Two shapes: ## Query shape +{/* doc-sample: skip — a type signature, not a value */} ```ts { object: string, // required — target object name diff --git a/content/docs/reference/rest-api.mdx b/content/docs/reference/rest-api.mdx index 82d7c69..9b32e57 100644 --- a/content/docs/reference/rest-api.mdx +++ b/content/docs/reference/rest-api.mdx @@ -49,20 +49,26 @@ any route code: parse, with a warning that names its replacement. ```ts -import { ObjectSchema } from '@objectstack/spec/data'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; // A read-only reference object, never writable over the API: ObjectSchema.create({ name: 'exchange_rate', enable: { apiMethods: ['get', 'list'] }, - // ...fields + fields: { + rate: Field.number({ label: 'Rate', scale: 6 }), + // ...more fields + }, }) // An internal object that the API never exposes: ObjectSchema.create({ name: 'sync_cursor', enable: { apiEnabled: false }, - // ...fields + fields: { + cursor: Field.text({ label: 'Cursor' }), + // ...more fields + }, }) ``` diff --git a/tools/ci-scripts/run-self-tests.mjs b/tools/ci-scripts/run-self-tests.mjs index f753ff2..5f3db33 100644 --- a/tools/ci-scripts/run-self-tests.mjs +++ b/tools/ci-scripts/run-self-tests.mjs @@ -97,6 +97,15 @@ * fixtures are fake handlers. They prove that each rule (a throw, a non-200, * a non-array, no hits, a page its own title cannot find, a negative control * that matches) can still go red. + * + * `check-doc-samples.mjs` is listed for its `--self-test` only (#316). Its gate + * mode reads every English page under `content/docs/`, which this task's inputs + * do not hash, so it is a `ci.yml` step of its own, like the gates above. Its + * fixtures are inline pages parsed against the pinned `@objectstack/spec`, the + * one self-test here with a dependency: it needs + * `npm ci --prefix .github/scripts/doc-samples` first, and says so rather than + * passing when the install is missing. That package's `package-lock.json` sits + * under `.github/scripts/`, so a spec bump moves this task's hash. */ import { readFileSync, readdirSync, existsSync } from 'node:fs'; @@ -120,6 +129,7 @@ const SELF_TESTED = [ 'check-translation-ownership.mjs', 'check-positioning.mjs', 'check-search-locales.mjs', + 'check-doc-samples.mjs', ]; /**