|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// Metadata-driven proof derivation. |
| 4 | +// |
| 5 | +// The platform's apps are 100% declarative metadata, so a baseline runtime |
| 6 | +// contract can be DERIVED from the metadata itself — no hand-written tests. This |
| 7 | +// is the seed of `objectstack verify`: point it at any app (a framework example |
| 8 | +// OR a third-party app like hotcrm) and it auto-generates "author this object → |
| 9 | +// write it → read it back → assert type fidelity" for every object, then runs |
| 10 | +// it against the real in-process stack. |
| 11 | +// |
| 12 | +// v0 derives per-object CRUD round-trip cases. RLS cross-owner denial (the |
| 13 | +// #1994 invariant) is v1 — it needs the multi-user harness + sharing service. |
| 14 | + |
| 15 | +/* eslint-disable @typescript-eslint/no-explicit-any */ |
| 16 | + |
| 17 | +const COMPUTED = new Set(['formula', 'summary', 'autonumber', 'rollup', 'vector']); |
| 18 | +const RELATIONAL = new Set(['lookup', 'master_detail', 'master-detail', 'masterdetail', 'tree']); |
| 19 | +const STRUCTURED = new Set(['composite', 'repeater', 'record', 'location', 'address']); |
| 20 | +const MEDIA = new Set(['image', 'file', 'avatar', 'video', 'audio', 'signature', 'qrcode']); |
| 21 | +const SYSTEM_NAMES = new Set([ |
| 22 | + 'id', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owner', |
| 23 | + 'space', 'instance_state', 'record_id', 'is_deleted', |
| 24 | +]); |
| 25 | + |
| 26 | +export type AssertKind = 'equal' | 'set' | 'none'; |
| 27 | +export interface DerivedAssert { |
| 28 | + field: string; |
| 29 | + type: string; |
| 30 | + value: unknown; |
| 31 | + kind: AssertKind; |
| 32 | +} |
| 33 | +export interface CrudCase { |
| 34 | + object: string; |
| 35 | + blocked?: string; // why this object can't be auto-CRUD'd (e.g. required lookup) |
| 36 | + body?: Record<string, unknown>; |
| 37 | + asserts?: DerivedAssert[]; |
| 38 | + skippedFields?: Array<{ name: string; type: string; reason: string }>; |
| 39 | +} |
| 40 | + |
| 41 | +function clampNum(f: any, fallback: number): number { |
| 42 | + const { min, max, step } = f; |
| 43 | + let v = fallback; |
| 44 | + if (typeof min === 'number' && v < min) v = min; |
| 45 | + if (typeof max === 'number' && v > max) v = max; |
| 46 | + if (typeof step === 'number' && typeof min === 'number') { |
| 47 | + v = min + step * Math.round((v - min) / step); |
| 48 | + } |
| 49 | + return v; |
| 50 | +} |
| 51 | + |
| 52 | +/** Synthesize a valid value for a field type, or null if not synthesizable. */ |
| 53 | +function synth(type: string, f: any): { value: unknown; kind: AssertKind } | null { |
| 54 | + switch (type) { |
| 55 | + case 'text': case 'textarea': case 'string': |
| 56 | + case 'markdown': case 'html': case 'richtext': case 'code': |
| 57 | + return { value: 'verify-sample', kind: 'equal' }; |
| 58 | + case 'email': return { value: 'verify@example.com', kind: 'equal' }; |
| 59 | + case 'url': return { value: 'https://example.com', kind: 'equal' }; |
| 60 | + case 'phone': return { value: '+14155550100', kind: 'equal' }; |
| 61 | + case 'color': return { value: '#3366CC', kind: 'equal' }; |
| 62 | + case 'number': return { value: clampNum(f, 7), kind: 'equal' }; |
| 63 | + case 'currency': return { value: clampNum(f, 100), kind: 'equal' }; |
| 64 | + case 'percent': return { value: clampNum(f, 50), kind: 'equal' }; |
| 65 | + case 'rating': return { value: clampNum(f, Math.min(3, f.max ?? 5)), kind: 'equal' }; |
| 66 | + case 'slider': case 'progress': return { value: clampNum(f, 25), kind: 'equal' }; |
| 67 | + case 'boolean': case 'toggle': return { value: true, kind: 'equal' }; |
| 68 | + case 'date': return { value: '2024-03-15', kind: 'equal' }; |
| 69 | + case 'datetime': return { value: '2024-03-15T08:30:00.000Z', kind: 'equal' }; |
| 70 | + case 'time': return { value: '14:30:00', kind: 'equal' }; |
| 71 | + case 'json': return { value: { sample: true }, kind: 'equal' }; |
| 72 | + case 'select': case 'radio': { |
| 73 | + const opt = f.options?.[0]?.value; |
| 74 | + return opt != null ? { value: opt, kind: 'equal' } : null; |
| 75 | + } |
| 76 | + case 'multiselect': case 'checkboxes': { |
| 77 | + const opt = f.options?.[0]?.value; |
| 78 | + return opt != null ? { value: [opt], kind: 'set' } : null; |
| 79 | + } |
| 80 | + case 'tags': return { value: ['alpha', 'beta'], kind: 'set' }; |
| 81 | + // Opaque-on-read: write a value but don't assert a round-trip (hashed/encrypted). |
| 82 | + case 'password': case 'secret': return { value: 'Sample-Secret-123', kind: 'none' }; |
| 83 | + default: return null; |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +/** |
| 88 | + * Derive one CRUD round-trip case per authorable object in the config. |
| 89 | + * An object whose REQUIRED fields can't be synthesized (e.g. a required lookup |
| 90 | + * needing a target record) is reported `blocked` rather than silently skipped. |
| 91 | + */ |
| 92 | +export function deriveCrudCases(config: any): CrudCase[] { |
| 93 | + const cases: CrudCase[] = []; |
| 94 | + for (const obj of config?.objects ?? []) { |
| 95 | + const fields: Record<string, any> = obj?.fields ?? {}; |
| 96 | + const body: Record<string, unknown> = {}; |
| 97 | + const asserts: DerivedAssert[] = []; |
| 98 | + const skippedFields: Array<{ name: string; type: string; reason: string }> = []; |
| 99 | + let blocked: string | undefined; |
| 100 | + |
| 101 | + for (const [name, f] of Object.entries(fields)) { |
| 102 | + const type = String((f as any)?.type ?? '').toLowerCase(); |
| 103 | + if (SYSTEM_NAMES.has(name) || (f as any)?.system || (f as any)?.readonly) continue; |
| 104 | + if (COMPUTED.has(type)) continue; |
| 105 | + |
| 106 | + if (RELATIONAL.has(type) || STRUCTURED.has(type) || MEDIA.has(type)) { |
| 107 | + if ((f as any)?.required) { blocked = `required ${type} field "${name}" (needs target/structured value)`; break; } |
| 108 | + skippedFields.push({ name, type, reason: 'unsynthesizable-optional' }); |
| 109 | + continue; |
| 110 | + } |
| 111 | + |
| 112 | + const s = synth(type, f); |
| 113 | + if (!s) { |
| 114 | + if ((f as any)?.required) { blocked = `required field "${name}" of type "${type}" is not synthesizable`; break; } |
| 115 | + skippedFields.push({ name, type, reason: 'no-synth' }); |
| 116 | + continue; |
| 117 | + } |
| 118 | + body[name] = s.value; |
| 119 | + if (s.kind !== 'none') asserts.push({ field: name, type, value: s.value, kind: s.kind }); |
| 120 | + } |
| 121 | + |
| 122 | + // Every object needs a name-ish required text; synth already covers `name`. |
| 123 | + if (blocked) cases.push({ object: obj.name, blocked }); |
| 124 | + else cases.push({ object: obj.name, body, asserts, skippedFields }); |
| 125 | + } |
| 126 | + return cases; |
| 127 | +} |
0 commit comments