17 · API reference
Purpose: The complete public surface of every @rasd/* package — TypeScript signatures, parameters, return types, events, error codes and stability — expanding the frozen names in 00 §11 without renaming anything.
Audience: Engineers implementing the packages (this is the contract their index.ts must export) and host-app developers wiring Rasd Forms into a web or React Native app.
TL;DR
- Names in 00 §11 are frozen; this document adds parameters, options, return shapes and stability tags. Shapes already fixed by a sibling document (03, 05, 06, 09, 10, 15) are reproduced or linked, never reinvented.
- Every package is ESM-only,
sideEffects: false(CSS/theme files excepted), named exports only,react-nativeexport condition first, TypeScript 6strict+isolatedDeclarations(research/08). - One error class everywhere:
RasdError { code, message, details, cause, retryable, userMessage }; adapters never leak driver errors (§16). - One event convention everywhere:
on(event, handler)/subscribe(listener)return an unsubscribe function; hooks wrap them (§17). - Stability: stable = semver, breaking only in a major; beta = may change in a minor with a changeset note; experimental = may change or disappear in any release.
createFormEngineis the only stateful object in@rasd/core; everything else in core is a pure function of its arguments.@rasd/reactand@rasd/nativeexport the same names with the same types; only JSX and per-part styling props differ (§3.7).
1. Conventions used in this reference
| Convention | Rule |
|---|---|
| Import paths | Root import per package; heavy or platform-specific code lives under documented sub-paths (@rasd/react/elements/matrix, @rasd/pwa/sw, @rasd/media/camera, @rasd/storage-sqlite/node). The exports map is closed — nothing else resolves. |
| Factories | createX(options) returns an object with dispose(); all I/O accepts an optional trailing { signal?: AbortSignal } (omitted from tables) and rejects with RasdError('RASD_ABORTED') on abort. |
| Time / ids | ISO-8601 UTC strings; UUID v7; clock?: () => number injectable for tests. |
FieldPath | String path: consent, hh_members[2].m_age — 0-based in engine APIs, 1-based inside REL (${hh_members[3].m_age}). Core returns LocalizedStrings unresolved unless the API says "resolved". |
| Errors column | RasdError.code values the API throws or rejects with; validation results are returned, not thrown. Types referenced by a stable export are stable. |
Package dependency direction (arrow = "depends on"):
flowchart LR
core["@rasd/core"]
react["@rasd/react"] --> core
native["@rasd/native"] --> core
builder["@rasd/builder"] --> react
element["@rasd/element"] --> react
storage["@rasd/storage"] --> core
dexie["@rasd/storage-dexie"] --> storage
sqlite["@rasd/storage-sqlite"] --> storage
sync["@rasd/sync"] --> storage
pwa["@rasd/pwa"] --> sync
media["@rasd/media"] --> core
themes["@rasd/themes"] --> core
xlsform["@rasd/xlsform"] --> core
cli["@rasd/cli"] --> xlsform
cli --> core
testing["@rasd/testing"] --> core
license["@rasd/license"]
server["@rasd/server"]
2. @rasd/core (Apache-2.0)
2.1 Types — RFD v1 (stable)
The zod schemas in @rasd/core/schema are the executable source; these types are inferred from them (04 §1.2). Per-type props are specified property-by-property in 04 §10.
export type LocalizedString = string | { [bcp47Locale: string]: string };
export type Expr = string; // REL v1 source
export type Ext = { [vendorKey: string]: unknown };
export type Severity = 'error' | 'warning' | 'info';
export interface Geo { lat: number; lng: number; alt?: number; accuracy?: number; capturedAt: string }
export interface AttachmentRef { attachmentId: string; name?: string; mime?: string; bytes?: number; sha256?: string; capturedAt?: string; geo?: Geo }
export interface Media { image?: LocalizedString; audio?: LocalizedString; video?: LocalizedString }
export interface FormDefinition {
$schema?: string; rasd: string /* "1.0" */; id: string; version: string;
meta: { title: LocalizedString; description?: LocalizedString; tags?: string[]; author?: string; createdAt?: string; updatedAt?: string; changelog?: LocalizedString; ext?: Ext };
settings: FormSettings; choiceLists?: Record<string, ChoiceList>; datasets?: Dataset[];
pages: Page[]; logic?: { calculated?: CalculatedValue[]; triggers?: Trigger[] };
requires?: { rasd?: string; features?: string[] }; ext?: Ext;
}
export interface FormSettings {
defaultLocale: string; locales: string[]; navigation?: 'paged' | 'scroll'; showProgress?: boolean; allowDrafts?: boolean; autosaveMs?: number;
instanceName?: Expr; submissionIdPrefix?: string;
audit?: { enabled: boolean; trackChanges?: boolean; location?: { enabled: boolean; priority?: 'high' | 'balanced' | 'low'; minSeconds?: number; minMeters?: number } };
encryption?: { mode: 'none' | 'field' | 'submission'; publicKeyId?: string };
theme?: { themeId?: string; overrides?: Partial<RasdTheme> };
numbering?: 'latn' | 'native'; calendar?: 'gregorian' | 'islamic-umalqura';
localeMeta?: Record<string, { dir: 'rtl' | 'ltr'; numbering?: 'latn' | 'native'; calendar?: 'gregorian' | 'islamic-umalqura' }>;
ext?: Ext;
}
export interface Page { id: string; title?: LocalizedString; description?: LocalizedString; relevant?: Expr; elements: Element[]; ext?: Ext }
export interface Choice { value: string; label: LocalizedString; media?: Media; attrs?: Record<string, string | number | boolean>; ext?: Ext }
export interface ChoiceList { choices?: Choice[]; source?: { type: 'dataset'; dataset: string }; valueKey?: string; labelKey?: string; filterKeys?: string[]; ext?: Ext }
export interface Dataset { name: string; source: 'server' | 'inline' | 'url'; keyField: string; inline?: Record<string, unknown>[]; url?: string; columns?: { name: string; type?: string; label?: LocalizedString }[]; minVersion?: string; ext?: Ext }
export interface CalculatedValue { name: string; calculate: Expr; includeInData?: boolean; ext?: Ext }
export interface Trigger { id: string; when: Expr; on?: 'change' | 'pageLeave' | 'finalize'; once?: boolean; actions: TriggerAction[]; ext?: Ext }
export type TriggerAction =
| { type: 'setValue'; target: string; value?: unknown; expr?: Expr } | { type: 'clearValue'; target: string }
| { type: 'complete'; message?: LocalizedString } | { type: 'skipTo'; page: string }
| { type: 'showMessage'; message: LocalizedString; severity?: Severity; blocking?: boolean }
| { type: 'custom'; id: string; payload?: unknown };
export type Validator = { message?: LocalizedString; severity?: Severity } & (
| { type: 'regex'; pattern: string } | { type: 'range'; min?: number | string; max?: number | string }
| { type: 'length'; min?: number; max?: number } | { type: 'expr'; expr: Expr } | { type: 'custom'; id: string });
export interface ElementBase<T extends string, P> {
type: T; name: string; label?: LocalizedString; hint?: LocalizedString; guidance?: LocalizedString; media?: Media;
required?: boolean | Expr; requiredMessage?: LocalizedString; relevant?: Expr; readonly?: boolean | Expr;
default?: { value: unknown } | { expr: Expr }; calculate?: Expr; constraint?: Expr; constraintMessage?: LocalizedString;
validators?: Validator[]; appearance?: { variant?: string; columns?: number; size?: 'sm' | 'md' | 'lg'; ext?: Ext };
bind?: { sensitive?: boolean; saveIncomplete?: boolean; trackChanges?: boolean; index?: boolean; ext?: Ext };
props?: P; ext?: Ext;
}
type SelectProps = { list?: string; choices?: Choice[]; choiceFilter?: Expr; search?: boolean; randomize?: boolean };
export type Element =
| ElementBase<'text', { multiline?: boolean; format?: 'email' | 'phone' | 'url' | 'none'; maxLength?: number; mask?: string; placeholder?: LocalizedString }>
| ElementBase<'number', { kind?: 'integer' | 'decimal'; min?: number; max?: number; step?: number; unit?: LocalizedString; thousandsSeparator?: boolean }>
| ElementBase<'date' | 'time' | 'datetime', { min?: string; max?: string; calendar?: 'gregorian' | 'hijri' }>
| ElementBase<'select_one', SelectProps & { other?: { enabled: boolean; label?: LocalizedString; value?: string } }>
| ElementBase<'select_multiple', SelectProps & { minSelected?: number; maxSelected?: number; exclusive?: string[] }>
| ElementBase<'rank', { list?: string; choices?: Choice[] }>
| ElementBase<'rating', { max?: number; icon?: 'star' | 'number' | 'smiley'; labels?: { min?: LocalizedString; max?: LocalizedString } }>
| ElementBase<'range', { min: number; max: number; step?: number; showValue?: boolean }>
| ElementBase<'checkbox', object>
| ElementBase<'consent', { text: LocalizedString; textVersion: string; method?: 'tap' | 'signature' | 'verbal'; allowWithdraw?: boolean; onWithdraw?: 'clearSensitive' | 'keep' }>
| ElementBase<'matrix', { rows: { value: string; label: LocalizedString; relevant?: Expr }[]; columns: { type: 'select_one'; list?: string; choices?: Choice[] } | { type: 'number'; min?: number; max?: number; kind?: 'integer' | 'decimal' } | { type: 'text'; maxLength?: number }; requiredRows?: 'all' | 'any' | 'none' }>
| ElementBase<'geopoint', { accuracyThreshold?: number; warningThreshold?: number; autoCapture?: boolean; allowManual?: boolean; map?: boolean }>
| ElementBase<'geotrace' | 'geoshape', { mode?: 'manual' | 'auto'; intervalSeconds?: number; minPoints?: number; accuracyThreshold?: number }>
| ElementBase<'image', { source?: 'camera' | 'gallery' | 'both'; maxPixels?: number; quality?: number; annotate?: boolean; geotag?: boolean; multiple?: boolean; maxCount?: number; maxBytes?: number }>
| ElementBase<'audio' | 'video' | 'file', { maxDurationSeconds?: number; accept?: string[]; maxBytes?: number; multiple?: boolean }>
| ElementBase<'barcode', { formats?: string[]; allowManual?: boolean }>
| ElementBase<'signature', { penColor?: string }>
| ElementBase<'note', { style?: 'info' | 'warning' | 'success'; collapsible?: boolean }>
| ElementBase<'hidden', object> | ElementBase<'calculate', object>
| (ElementBase<'group', { nestData?: boolean }> & { elements: Element[] })
| (ElementBase<'repeat', { min?: number; max?: number; count?: Expr; addLabel?: LocalizedString; removeLabel?: LocalizedString; itemLabel?: Expr; keyField?: string; confirmDelete?: boolean; allowReorder?: boolean }> & { elements: Element[] })
| ElementBase<`x:${string}`, Record<string, unknown>>;
export type ElementType = Element['type'];
rasd types <form.json> (§13) generates a per-form Data interface from these types ({ consent: 'yes' | 'no'; hh_members: { m_name: string; m_age: number }[]; photo: AttachmentRef }).
2.2 Types — Submission (stable)
export type SubmissionStatus = 'draft' | 'finalized' | 'queued' | 'sending' | 'synced' | 'rejected' | 'conflict';
export type AttachmentStatus = 'pending' | 'uploading' | 'uploaded' | 'failed';
export interface Submission {
id: string; formId: string; formVersion: string; definitionHash: string; status: SubmissionStatus;
data: Record<string, unknown>;
meta: { startedAt: string; finalizedAt?: string; deviceId: string; userId?: string; username?: string; locale: string; appVersion?: string; platform: 'web' | 'android' | 'ios' | (string & {}); instanceName?: string; geo?: Geo | null; ext?: Ext };
attachments: Attachment[]; audit: AuditEvent[];
clientRev: number; serverRev: number | null; syncedAt: string | null; createdAt: string; updatedAt: string; checksum: string /* "sha256:…" */; ext?: Ext;
}
export interface Attachment { id: string; field: string; mime: string; bytes: number; sha256: string; localUri?: string; remoteId: string | null; status: AttachmentStatus }
export interface AuditEvent { t: string; event: 'value' | 'calculate' | 'repeat_add' | 'repeat_remove' | 'reorder' | 'page' | 'locale' | 'constraint warning' | 'finalize' | 'migrate' | (string & {}); field?: string; old?: unknown; new?: unknown; source?: string; ext?: Ext }
2.3 createFormEngine(def, opts?) → FormEngine (stable)
export function createFormEngine(def: FormDefinition, opts?: EngineOptions): FormEngine;
export interface EngineOptions {
submissionId?: string; // default uuidv7()
initialData?: Record<string, unknown>; // applied before default.expr; unknown keys preserved
meta?: Partial<Submission['meta']> & { custom?: Record<string, unknown> }; // ${meta.*}, ${meta.custom.*}
locale?: string; // default settings.defaultLocale
coercion?: 'rel' | 'xpath'; // default 'rel' (05 §5)
host?: unknown; // ctx.host for host functions
functions?: Record<string, RelFunction>; // per-engine additions to the default registry
validators?: Record<string, CustomValidator>; // targets of { type:'custom', id }
datasets?: Record<string, DatasetIndex>; // preloaded indexes; add later with invalidateDataset()
clock?: () => number; // now(), today(), meta.now
validateDefinition?: boolean; // default true in dev, false in production (zod not loaded)
budget?: { steps?: number /* 100_000 */; timeMs?: number /* 10 */ };
onDiagnostic?: (d: Diagnostic) => void;
}
Throws: RASD_SCHEMA_INVALID (details.issues), RASD_EXPR_PARSE, RASD_EXPR_UNKNOWN_REF, RASD_EXPR_UNKNOWN_FUNCTION, RASD_EXPR_CYCLE, RASD_UNSUPPORTED_SPEC (newer rasd major or unmet requires). The compiled program is cached process-wide per definitionHash (LRU 32). The engine never throws after construction; runtime expression failures become diagnostics (03 §10).
export interface FormEngine {
readonly definition: FormDefinition; readonly submissionId: string; readonly diagnostics: Diagnostic[];
getState(): EngineState;
setValue(path: FieldPath, value: unknown, opts?: { source?: 'user' | 'calc' | 'migrate' | `trigger:${string}` }): void;
addRepeat(path: FieldPath, at?: number, initial?: Record<string, unknown>): void;
removeRepeat(path: FieldPath, index: number): void;
moveRepeat(path: FieldPath, from: number, to: number): void;
setLocale(locale: string): void;
validate(scope?: { page?: string; path?: FieldPath }): ValidationResult;
finalize(): Result<Submission, ValidationResult>;
toSubmission(): Submission;
recompute(opts?: { volatile?: boolean }): void;
flushSync(): void;
invalidateDataset(name: string, index?: DatasetIndex): void;
subscribe(listener: (patch: EnginePatch) => void): () => void;
subscribeField(path: FieldPath, listener: (f: FieldState) => void): () => void;
select<T>(selector: (s: EngineState) => T, equals?: (a: T, b: T) => boolean): { get(): T; subscribe(cb: () => void): () => void };
on<E extends keyof EngineEvents>(event: E, handler: (e: EngineEvents[E]) => void): () => void;
dispose(): void;
}
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export interface EngineState { rev: number; data: Readonly<Record<string, unknown>>; fields: ReadonlyMap<FieldPath, FieldState>; pages: { id: string; relevant: boolean; errorCount: number }[]; locale: string; status: SubmissionStatus; dirty: boolean }
export interface EnginePatch { rev: number; changedPaths: FieldPath[]; structural: boolean }
export interface FieldIssue { path: FieldPath; code: string; severity: Severity; message: string /* resolved */; source: 'required' | 'constraint' | 'validator' | 'custom' | 'valueSchema' | 'repeat'; validatorIndex?: number }
export interface ValidationResult { ok: boolean; errors: FieldIssue[]; warnings: FieldIssue[]; infos: FieldIssue[]; firstInvalidPath?: FieldPath }
export interface EngineEvents { patch: EnginePatch; diagnostic: Diagnostic; trigger: { id: string; actions: TriggerAction[] }; complete: { trigger: string; message?: LocalizedString }; message: { trigger: string; message: LocalizedString; severity: Severity; blocking: boolean }; customAction: { trigger: string; id: string; payload?: unknown }; locale: { old: string; new: string }; finalized: Submission }
FieldState (path, name, type, value, relevant, required, readonly, calculated, label, hint, guidance, choices, errors, warnings, infos, touched, dirty, validating, repeat, a11y, ext) is normative in 03 §7.
| Method | Semantics |
|---|---|
setValue(path, v, opts?) | Coalesced per microtask; dependents recomputed in topological order; audited when bind.trackChanges. Unknown path or write to a calculate field ⇒ ignored + diagnostic (reason: 'READONLY_TARGET'). |
addRepeat / removeRepeat / moveRepeat | Respect min/max/count; initial runs default for the new instance; instance ids are stable so field subscriptions survive moves. Out of range ⇒ no-op + diagnostic. |
validate(scope?) | Required/constraint/validators/custom/valueSchema for relevant fields in scope; async custom validators set field.validating and resolve into a later patch. |
finalize() | recompute({ volatile: true }) → full validate → on: 'finalize' triggers → strip irrelevant values → status finalized, meta.finalizedAt, checksum. { ok: false, error } when any error-severity issue exists; never throws. toSubmission() is the unvalidated draft with irrelevant values retained. |
recompute({ volatile }) / flushSync() | Re-evaluate volatile nodes (now(), random(), impure host functions) / force the pending flush (tests, before finalize). |
invalidateDataset(name, index?) | Replace the index; recompute every node depending on dataset:<name>. |
select() (beta) / dispose() | Selector store for useSyncExternalStore (equals defaults to Object.is) / unsubscribe everything; later calls throw RASD_ENGINE_DISPOSED. |
const engine = createFormEngine(def, { meta: { userId: 'u-17', custom: { office: 'AMM' } }, locale: 'ar' });
const off = engine.subscribeField('hh_size', (f) => console.log(f.value, f.errors));
engine.setValue('hh_size', 5);
engine.addRepeat('hh_members'); engine.setValue('hh_members[0].m_name', 'ليلى');
const r = engine.finalize();
if (r.ok) await storage.submissions.put(r.value); else focus(r.error.firstInvalidPath);
off(); engine.dispose();
stateDiagram-v2
[*] --> compiled: createFormEngine (validate · parse · DAG)
compiled --> draft: initialData + default.expr + once()
draft --> draft: setValue / addRepeat / … → microtask flush → patch
draft --> validating: validate() / finalize()
validating --> draft: errors (Result.ok = false)
validating --> finalized: finalize() ok → event finalized
draft --> disposed: dispose()
finalized --> disposed: dispose()
2.4 Expressions (stable unless tagged)
export function parseExpression(src: string, opts?: { cache?: boolean }): Ast; // throws RASD_EXPR_PARSE { details: { source, offset, expected } }
export function evaluate(ast: Ast, ctx: EvalContext): unknown; // never throws; ctx.onDiagnostic receives RASD_EXPR_RUNTIME / RASD_EXPR_BUDGET
export function printExpression(ast: Ast): string; // canonical REL
export function dependenciesOf(ast: Ast): { refs: RefPattern[]; meta: string[]; datasets: string[]; volatile: boolean; usesPosition: boolean; usesOwner: boolean; functions: string[] };
export function registerFunction(name: string, fn: RelFunction, meta: { pure: boolean; minArgs?: number; maxArgs?: number; returns?: 'string' | 'number' | 'boolean' | 'any' }): void; // default registry; throws RASD_EXPR_FUNCTION_EXISTS for core names
export function createDatasetIndex(rows: Record<string, unknown>[], opts: { keyField: string; indexColumns?: string[] }): DatasetIndex;
export function createEvalContext(partial: Partial<EvalContext> & { data: Record<string, unknown> }): EvalContext; // defaults for tests/servers
export function isVisualisable(ast: Ast): boolean; // beta — builder helper (05 §18)
export type RelFunction = (...args: [...unknown[], FunctionContext]) => unknown; // synchronous; ctx is the last argument
export interface FunctionContext { locale: string; meta: Readonly<Record<string, unknown>>; host: unknown }
export interface Diagnostic { code: 'RASD_EXPR_RUNTIME' | 'RASD_EXPR_BUDGET' | 'RASD_EXPR_TRIGGER_LOOP'; path: string; property: string; reason?: string; message: string }
EvalContext (data, scope, owner, meta, locale, datasets, choiceLists, functions, coercion, isRelevant, budget, onDiagnostic) is normative in 05 §4.1.
| Parameter | Notes |
|---|---|
registerFunction.name | ^[a-z][A-Za-z0-9_]*$, vendor-prefixed (wfp_…); call before creating engines; register on @rasd/server too when used in instanceName. |
meta.pure | true ⇒ memoised per argument tuple; false ⇒ the expression is volatile (re-evaluated on every recompute). |
createDatasetIndex | Synchronous; ≤ 100 k rows; keys compared as string(); one lazy Map per queried column. |
registerFunction('wfp_ration', (hh, ctx) => Number(hh ?? 0) * 12.5, { pure: true, minArgs: 1, maxArgs: 1, returns: 'number' });
evaluate(parseExpression("if(${hh_size} > 5, wfp_ration(${hh_size}), 0)"), createEvalContext({ data: { hh_size: 7 } })); // 87.5
2.5 Definition tooling (stable)
export function validateFormDefinition(def: unknown, opts?: { previous?: FormDefinition; functions?: string[]; translations?: 'warn' | 'error'; features?: string[]; limits?: Partial<ValidationLimits>; retiredNames?: Record<string, string> }): { ok: boolean; errors: Issue[]; warnings: Issue[] };
export interface Issue { code: string; path: string /* JSON pointer */; message: string; hint?: string; ref?: string }
export function definitionHash(def: FormDefinition): string; // "sha256:" + hex(SHA-256(JCS(def minus meta.updatedAt)))
export function canonicalJson(value: unknown): string; // RFC 8785
export function diffDefinitions(from: FormDefinition, to: FormDefinition): { changes: SemanticChange[]; plan: MigrationPlan; loss: 'none' | 'possible' | 'certain' };
export interface SemanticChange { kind: string; compat: 'C' | 'T' | 'B'; target?: string; from?: unknown; to?: unknown; message: LocalizedString; suggestedStep?: MigrationStep }
export interface MigrationPlan { rasd: string; formId: string; from: string; to: string; fromHash: string; toHash: string; mode: 'auto' | 'assisted' | 'manual'; loss: 'none' | 'possible' | 'certain'; steps: MigrationStep[]; review?: { requiresUser: boolean; message?: LocalizedString } }
export type MigrationStep =
| { op: 'test'; path: string; type: string } | { op: 'rename'; from: string; to: string }
| { op: 'transform'; target: string; expr: Expr; onError?: 'null' | 'keep' | 'fail' }
| { op: 'default'; target: string; value: unknown; when?: Expr }
| { op: 'remapChoices'; target: string; map: Record<string, string>; unmapped?: 'keep' | 'drop' | 'fail' }
| { op: 'drop'; target: string; keep?: 'orphan' | 'discard' }
| { op: 'wrapRepeat'; target: string; into: string; as: string } | { op: 'unwrapRepeat'; target: string; pick: 'first'; as: string }
| { op: 'recalculate'; targets: string[] };
export function migrateSubmission(sub: Submission, fromDef: FormDefinition, toDef: FormDefinition, plan?: MigrationPlan, opts?: { force?: boolean; clock?: () => number }): Submission;
export function planMigration(sub: Submission, fromDef: FormDefinition, toDef: FormDefinition, plan?: MigrationPlan): { ok: boolean; loss: 'none' | 'possible' | 'certain'; warnings: Issue[]; orphaned: Record<string, unknown>; changedPaths: string[] }; // dry run
| Function | Behaviour | Errors |
|---|---|---|
validateFormDefinition | Structural pass (zod, ~60 kB gz, lazy — dev/CLI/builder only) then semantic pass (names, references, a full REL parse of every expression site — a lexical scan alone missed 1 + and 1 2 3 — cycles, publish rules when previous is given). Codes: E_* / W_* catalogue in 04 §15; every one of them has a fixture in examples/invalid/. | never throws |
diffDefinitions | Classifies per research/12 §3; auto-seeds plan.steps for T changes; loss derives from B changes lacking a step. | never throws |
migrateSubmission | Runs plan.steps in order, atomically (a fail leaves sub untouched); plan omitted ⇒ derived from diffDefinitions; appends audit event migrate, sets formVersion/definitionHash to toDef. Refuses unless sub.status === 'draft' or force. | RASD_MIGRATION_REFUSED (details.reason: 'NOT_DRAFT' | 'BREAKING_WITHOUT_STEP' | 'TEST_FAILED' | 'HASH_MISMATCH') |
2.6 i18n helpers (stable; 13 §1)
export function negotiateLocale(requested: string[], available: string[], defaultLocale: string): string;
export function resolveLocalized(ls: LocalizedString | undefined, cfg: LocaleConfig): { text: string; locale: string; fallback: boolean };
export function formatMessage(text: string, vars: Record<string, unknown>, cfg: LocaleConfig): string; // Mini-Message; throws RASD_I18N_SYNTAX in dev, returns source in prod
export function pluralCategory(locale: string, n: number): 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
export function normalizeDigits(input: string): string;
export function createLocaleConfig(locale: string, settings: FormSettings): LocaleConfig;
export function translationReport(def: FormDefinition): { perLocale: Record<string, { total: number; translated: number; missing: number; stale: number; mt: number; percent: number }>; issues: Issue[] };
export interface LocaleConfig { locale: string; fallbacks: string[]; dir: 'ltr' | 'rtl'; numbering: 'latn' | 'native'; numberingSystem: string; calendar: 'gregorian' | 'islamic-umalqura'; hourCycle?: 'h12' | 'h23'; timeZone?: string }
2.7 RasdError and misc (stable)
export class RasdError extends Error {
constructor(code: RasdErrorCode, message: string, opts?: { details?: Record<string, unknown>; cause?: unknown; retryable?: boolean; userMessage?: LocalizedString });
readonly code: RasdErrorCode; readonly details?: Record<string, unknown>; readonly cause?: unknown; readonly retryable: boolean; readonly userMessage?: LocalizedString;
toJSON(): { code: string; message: string; details?: unknown; retryable: boolean }; // never includes answers or tokens
}
export function isRasdError(e: unknown, code?: RasdErrorCode): e is RasdError;
export const RASD_ERROR_CODES: { RASD_SCHEMA_INVALID: { retryable: false }; RASD_EXPR_PARSE: { retryable: false }; /* … one literal-keyed entry per code in §16 */ };
export type RasdErrorCode = keyof typeof RASD_ERROR_CODES; // §16
export function uuidv7(): string; export function sha256(input: Uint8Array | string): Promise<string>;
export const RASD_SPEC_VERSION: '1.0'; export function supportsSpec(rasd: string): boolean;
// '@rasd/core/schema' (lazy): formSchema, themeSchema (JSON Schema 2020-12 documents), zod schemas
3. @rasd/react and @rasd/native (FSL-1.1)
Identical export list and types; "native" notes mark the only differences (07 §2).
3.1 <RasdProvider> (stable)
export function RasdProvider(props: RasdProviderProps): JSX.Element;
export interface RasdProviderProps {
storage?: StorageAdapter | (() => Promise<StorageAdapter>); // default MemoryStorage + dev warning; factory form opens in an effect (SSR-safe)
license?: LicenseHandle; // createLicense()
theme?: RasdTheme | string; // theme object or built-in id; default 'rasd-light'
locale?: string; // chrome locale; default navigator.language / expo-localization
messages?: Record<string, Record<string, string>>; // chrome catalog overrides, deep-merged (region > language > built-in)
loadLocale?: (lc: string) => Promise<Record<string, string>>; onMissingKey?: (key: string, locale: string) => void; pluralRules?: Record<string, (n: number) => string>;
registry?: Registry; // createRegistry()
sync?: SyncEngine; // createSyncEngine()
media?: MediaAdapters; // @rasd/media web | native
mode?: { colorScheme?: 'light' | 'dark'; contrast?: 'normal' | 'high'; density?: 'comfortable' | 'compact' | 'spacious'; reducedMotion?: boolean };
portalContainer?: HTMLElement; cssNonce?: string; unstyled?: boolean; // web only
as?: keyof JSX.IntrinsicElements; className?: string; style?: React.CSSProperties; // web; native: style?: ViewStyle
security?: SecurityPolicy; // native ([16](16-security-and-data-protection.md))
logger?: Logger; onError?: (e: RasdError) => void; onAudit?: (e: AuditEvent & { submissionId: string }) => void;
onPolicyViolation?: (e: RasdError) => void; onStorageBlocked?: (info: { reason: string; unsyncedCount: number }) => void;
children: React.ReactNode;
}
Renders <div class="rasd-root" data-theme data-color-scheme data-contrast data-density dir> (web) or a direction-aware root View (native). Nested providers inherit unset props. Nothing is thrown; onError receives adapter failures (RASD_STORAGE_*, RASD_LICENSE_*).
3.2 <FormRenderer> (stable)
export function FormRenderer(props: FormRendererProps): JSX.Element;
export interface FormRendererProps {
definition: FormDefinition | { id: string; version?: string }; // reference form → storage.forms.get()
submissionId?: string; initialData?: Record<string, unknown>; meta?: Record<string, unknown>;
locale?: string; readOnly?: boolean; mode?: 'fill' | 'review' | 'summary';
validateOn?: 'change' | 'blur' | 'page' | 'finalize'; // default 'change'
page?: number; onPageChange?: (index: number, reason: 'next' | 'prev' | 'jump' | 'error' | 'trigger') => void;
persist?: boolean; engine?: FormEngine; // never both engine and submissionId
autosave?: boolean | { debounceMs: number; maxWaitMs?: number }; // min 250 ms; default settings.autosaveMs (2000)
renderers?: Partial<Record<ElementType, ElementComponent>>; components?: Partial<Components>;
classNames?: PartClassNames; styles?: PartStyles; // web: both; native: styles only
reviewBeforeFinalize?: boolean; fallback?: React.ReactNode;
slots?: { header?: SlotRenderer; footer?: SlotRenderer; nav?: SlotRenderer; watermark?: SlotRenderer };
keyboardVerticalOffset?: number; // native only
onChange?: (e: { type: 'value' | 'repeat_add' | 'repeat_remove' | 'repeat_move'; path: FieldPath; value: unknown; previous: unknown; submissionId: string; readonly data: Record<string, unknown> }) => void;
onSave?: (s: Submission) => void;
onFinalize?: (s: Submission) => void | boolean | Promise<void | boolean>; // return false or throw RasdError to veto
onInvalid?: (e: { errors: FieldIssue[]; page: number }) => void;
onComplete?: (e: { trigger: string; message?: LocalizedString }) => void;
onTriggerAction?: (id: string, payload: unknown, ctx: { submissionId: string; engine: FormEngine }) => void;
onError?: (e: RasdError) => void;
}
Event semantics: onChange fires after each engine flush (data is a lazy getter, materialised once); onSave after each autosave commit including the flush at pagehide / AppState background; onFinalize after validation passes and the submission is written — veto by false/throw keeps status draft, and the renderer enqueues to storage.outbox regardless of the sync prop; onInvalid when Next/Finalize is blocked (focus already on the first invalid field); onError on boundary catch, storage failure or RASD_LICENSE_EXPIRED (hard), bubbling to the provider's onError when unset — codes RASD_SCHEMA_INVALID / RASD_EXPR_* (non-recoverable card), RASD_STORAGE_QUOTA (banner + retry), RASD_STORAGE_*, RASD_ELEMENT_RENDER, RASD_MEDIA_*.
3.3 Hooks (stable unless tagged)
export function useRasdForm<T = FormApi>(selector?: (api: FormApi) => T, isEqual?: (a: T, b: T) => boolean): T;
export function useField<V = unknown>(path: FieldPath): FieldApi<V>;
export function useSubmission(id: string): { submission?: Submission; status?: SubmissionStatus; attachments: Attachment[]; patch(p: Partial<Submission>): Promise<void>; remove(): Promise<void>; loading: boolean };
export function useSync(): SyncView;
export function useLicense(): LicenseView; // { state, plan, features, expiresAt, graceUntil, enforcement, reason, clockSuspect, seats, can(), refresh() } — [15 §10.3]
export function useTheme(): ThemeView;
export function useLocale(): LocaleView; // { locale, dir, t, tf, formatNumber, formatDate, formatList, collator, setLocale, config } — [13 §1]
export function useDirection(): 'ltr' | 'rtl';
export function useRasdBusy(): boolean; // dirty || save in flight || finalize in flight || locale loading || media processing
export interface FormApi { engine: FormEngine; definition: FormDefinition; submissionId: string; status: SubmissionStatus; page: number; pages: { id: string; title: string; relevant: boolean; errorCount: number }[]; goTo(pageOrId: number | string): Promise<boolean>; next(): Promise<boolean>; prev(): Promise<void>; canNext: boolean; canFinish: boolean; validate(): ValidationResult; finalize(): Promise<Result<Submission, ValidationResult>>; save(): Promise<void>; dirty: boolean; errorCount: number; locale: string; setLocale(lc: string): void; busy: boolean }
export interface FieldApi<V> { value: V | undefined; setValue(v: V | undefined): void; blur(): void; touched: boolean; dirty: boolean; errors: FieldIssue[]; warnings: FieldIssue[]; infos: FieldIssue[]; relevant: boolean; required: boolean; readonly: boolean; calculated: boolean; element: Element; path: FieldPath; ids: { input: string; label: string; hint: string; error: string }; inputProps: InputA11yProps; state: FieldState }
export interface SyncView { status: SyncStatus; state: 'idle' | 'syncing' | 'paused' | 'offline' | 'error'; pending: { submissions: number; attachments: number }; lastSyncAt?: string; lastError?: RasdError; progress?: SyncProgress; conflicts: Conflict[]; syncNow(reason?: SyncReason): Promise<SyncRunReport>; pause(): void; resume(opts?: { allowMetered?: boolean }): void }
export interface ThemeView { theme: RasdTheme; tokens: ResolvedTokens; resolved: { colorScheme: 'light' | 'dark'; contrast: 'normal' | 'high'; density: 'comfortable' | 'compact' | 'spacious'; reducedMotion: boolean }; setMode(partial: RasdProviderProps['mode']): void; cssVar?(path: string): string /* web only: cssVar('color.primary') → 'var(--rasd-color-primary)' — [12 §4.2](12-theming.md) */; styles?<T>(factory: (t: ResolvedTokens) => T): T /* native only */ }
| Hook | Re-renders when | Outside its context |
|---|---|---|
useRasdForm() | structure revision (page, status, dirty, errorCount, repeat count) — not on every value | throws RASD_HOOK_CONTEXT |
useField(path) | that path's FieldState identity changes | throws RASD_HOOK_CONTEXT |
useSubmission(id) | storage change events for id | loading: true forever without storage |
useSync() | sync stateChange / progress / error / conflict | inert (state: 'idle', zero pending) |
useLicense() | license state transitions only | evaluating on dev hosts, else limited |
useTheme() / useLocale() / useDirection() | provider prop change or OS signal | provider defaults |
useRasdBusy() | busy edges only | false |
SyncView.state projects SyncStatus.state (10 §4.9): running|acquiring|probing → 'syncing', backoff|authRequired|revoked → 'error', offline → 'offline', paused|wiping → 'paused', else 'idle'.
3.4 Registry — defineElement, createRegistry (stable)
export function defineElement<V = unknown>(d: { type: `x:${string}` | ElementType; component: ElementComponent<V>; valueSchema?: StandardSchema<V>; builder?: BuilderElementMeta; chunk?: () => Promise<{ default: ElementComponent<V> }> }): ElementDefinition<V>;
export function createRegistry(opts?: { elements?: ElementDefinition[]; components?: Partial<Components>; renderers?: Partial<Record<ElementType, ElementComponent>>; testers?: { rank: number; test: (el: Element, ctx: { def: FormDefinition; path: FieldPath }) => boolean; component: ElementComponent }[]; validators?: Record<string, CustomValidator>; functions?: Record<string, RelFunction>; unknownElement?: ElementComponent; extend?: Registry }): Registry;
export type ElementComponent<V = unknown> = React.ComponentType<{ field: FieldApi<V>; element: Element; path: FieldPath; variant?: string; mode: 'fill' | 'review' | 'summary'; readOnly: boolean; locale: string; dir: 'ltr' | 'rtl' }>;
export type CustomValidator = (input: { value: unknown; element: Element; path: FieldPath; data: Record<string, unknown>; locale: string }) => ValidatorOutcome | Promise<ValidatorOutcome>; // 5 s timeout ⇒ warning
export type ValidatorOutcome = { ok: true } | { ok: false; severity?: Severity; message: LocalizedString };
export function FieldWrapper(props: { field: FieldApi; children: React.ReactNode; hideLabel?: boolean }): JSX.Element;
export function preloadElements(types: ElementType[]): Promise<void>; // warms lazy chunks (web)
Resolution order for element type T: FormRenderer.renderers[T] → registry testers (rank desc) → registry renderers[T] → defineElement entries → built-in → unknownElement (06 §5) — the RJSF-style registry plus JSON Forms-style tester ranking recommended in research/02 §3–4. defineElement throws RASD_REGISTRY_TYPE for a type that is neither built-in nor ^x:[a-z][a-z0-9-]*$.
3.5 Default components and theming props (stable names, beta internals)
components keys — fields: TextField, NumberField, RangeSlider, Rating, DateField, TimeField, DateTimeField, SelectOne, SearchSelect, SelectMultiple, Checkbox, ConsentBlock, Note, Group, Repeat, RankList, MatrixGrid, GeoPointField, GeoPathField, ImageField, AudioField, VideoField, FileField, SignatureField, BarcodeField, UnknownElement (heavy ones lazy under @rasd/react/elements/{matrix,rank,geo,media,barcode,signature,search-select}); layout: FieldWrapper, Page, PageNav, ProgressBar, ErrorSummary, RepeatItem, Button, Watermark, SummaryRow, LicenseBanner, plus native FormScroll, PageTransition, Sheet, DatePicker, Slider. Utility exports: Watermark, LicenseBanner, ErrorBoundary, RasdOfflineFormList (beta).
Theming props (web): classNames / styles / render per part keyed <Component>.<part> (value or (state) => value); every part carries class="rasd-<Component>__<part>" and data-scope="rasd" data-part. Native: styles per part only. Entries: @rasd/react/styles.css, @rasd/react/locales/<lc>, @rasd/native/locales/<lc>.
3.6 Example — custom element and custom validator
import { defineElement, createRegistry, FieldWrapper } from '@rasd/react'; // or '@rasd/native'
export const registry = createRegistry({
elements: [defineElement<string>({
type: 'x:beneficiary-lookup',
component: ({ field }) => (
<FieldWrapper field={field}>
<input {...field.inputProps} value={field.value ?? ''} onChange={(e) => field.setValue(e.target.value)} />
</FieldWrapper>),
builder: { label: { en: 'Beneficiary lookup', ar: 'بحث عن مستفيد' }, icon: 'id-card', group: 'advanced', inspector: [] },
})],
validators: { hhIdChecksum: ({ value }) => (luhn(String(value)) ? { ok: true } : { ok: false, message: { en: 'Invalid household ID' } }) },
});
3.7 Native-only additions (stable)
@rasd/native/keyboard-controller (KeyboardAwareFormScroll), @rasd/native/pickers/community (CommunityDatePicker), @rasd/native/app.plugin.js (Expo config plugin: backupExclusion, backgroundSync, permissions, usageDescriptions), securityReport(): Promise<SecurityReport>, preventScreenCapture(on: boolean). Peers: react-native >= 0.81 (New Architecture), optional expo-sqlite, @op-engineering/op-sqlite, RNGH 3, Reanimated 4, @shopify/flash-list.
4. @rasd/element — <rasd-form> (FSL-1.1, beta)
| Surface | Detail |
|---|---|
| Attributes | definition-url, definition-id, submission-id, locale, dir, theme (id or JSON URL), license, api (RSP base URL), namespace, read-only, validate-on, css-nonce |
| Properties | definition, theme, initialData, registry, storage, sync, license (objects; React 19 maps object props to properties) |
| Methods | validate(): ValidationResult, finalize(): Promise<Result<Submission, ValidationResult>>, getData(), setValue(path, v), getSubmission() |
Events (bubbles, composed) | rasd:ready, rasd:change { path, value, data }, rasd:save { submission }, rasd:finalize { submission }, rasd:error { error: RasdError }, rasd:sync { pending, lastSyncedAt } |
| Programmatic | defineRasdElement(tag = 'rasd-form'), Rasd.render(target, options); IIFE window.Rasd = { version, ready, render, defineElement, createTheme, registerFunction, createDexieStorage, createSyncEngine, createLicense } |
| Errors | rasd:error only; lifecycle callbacks never throw. Form-associated (ElementInternals.setFormValue). |
<script src="/vendor/rasd/rasd-forms.iife.js" defer></script>
<rasd-form definition-url="/forms/pdm-gfd-2026.form.json" locale="ar" dir="rtl" api="https://forms.example.org"></rasd-form>
<script>Rasd.ready.then(() => document.querySelector('rasd-form').addEventListener('rasd:finalize', e => console.log(e.detail.submission.id)));</script>
5. @rasd/builder (FSL-1.1, beta)
export function FormBuilder(props: FormBuilderProps): JSX.Element; // must render inside <RasdProvider>; lazy-load it
export interface FormBuilderProps {
definition?: FormDefinition; // undefined → "new form" wizard
onChange?: (def: FormDefinition, meta: { dirty: boolean; command: string; definitionHash: string; baseHash?: string }) => void; // debounced 250 ms
onPublish?: (req: PublishRequest) => Promise<PublishResult>;
plugins?: BuilderPlugin[]; locale?: string; theme?: RasdTheme | string;
features?: { logic?: boolean; translations?: boolean; json?: boolean; versions?: boolean; preview?: boolean; library?: boolean }; // all true by default
readOnly?: boolean; // forced true when license state is 'limited'
versions?: { list(formId: string): Promise<VersionSummary[]>; get(formId: string, version: string): Promise<FormDefinition> };
extSchemas?: Record<string, object>; // JSON Schema per vendor key
onMachineTranslate?: (req: { texts: string[]; from: string; to: string }) => Promise<string[]>;
onCommand?: (e: { id: string; args?: unknown }) => void;
}
export interface PublishRequest { definition: FormDefinition; previous?: { version: string; definitionHash: string }; definitionHash: string; diff: ReturnType<typeof diffDefinitions> | null; changelog: SemanticChange[]; force: boolean }
export type PublishResult = { ok: true; version: string; publishedAt: string } | { ok: false; code: 'RASD_VERSION_EXISTS' | 'RASD_SCHEMA_INVALID' | 'RASD_SYNC_REJECTED' | (string & {}); message: string; details?: unknown };
export interface BuilderPlugin { id: string; elements?: ElementDefinition[]; paletteGroups?: PaletteGroup[]; inspectorPanels?: InspectorPanel[]; commands?: BuilderCommand[]; extSchemas?: Record<string, object> }
BuilderPlugin members, BuilderElementMeta (label, description, icon, group, defaultProps, inspector, container, valueSchema, validate), InspectorField and BuilderApi (getDefinition, dispatch, select, announce, t) are specified in 08 §5 and §11.
| Callback | When | Errors |
|---|---|---|
onChange | after any command (debounced); immediately before publish and on visibilitychange: hidden | — |
onPublish | after validateFormDefinition passes and no B-class change remains (unless force by an admin) | host returns PublishResult; RASD_VERSION_EXISTS becomes an inline error |
| Builder-side refusals | E_PUBLISH_UNCHANGED, E_VERSION_NOT_MONOTONIC, E_VERSION_REUSED, E_BREAKING_CHANGE from validateFormDefinition(def, { previous }) | publish button disabled, Versions tab explains |
Sub-paths: @rasd/builder/locales/<lc>, @rasd/builder/dnd-adapter (experimental, the swap point for @dnd-kit/react).
6. @rasd/storage, @rasd/storage-dexie, @rasd/storage-sqlite (FSL-1.1)
The StorageAdapter contract is normative in 00 §7 and fully typed in 09 §3; this section lists the factories and helpers around it.
| Export | Signature | Stability | Errors |
|---|---|---|---|
createMemoryStorage | (opts?: { quotaBytes?: number; latencyMs?: number; faults?: FaultSpec }) => StorageAdapter | stable | RASD_STORAGE_QUOTA when simulated |
createDexieStorage | (opts: { namespace: string; encryptionKey?: CryptoKeyLike; blobs?: 'rows' | 'opfs'; worker?: boolean; persist?: boolean; migrations?: Migration[] }) => StorageAdapter | stable | from open(): RASD_STORAGE_UNAVAILABLE, RASD_STORAGE_KEY_UNAVAILABLE, RASD_STORAGE_MIGRATION, RASD_STORAGE_DOWNGRADE, RASD_STORAGE_CORRUPT |
createSqliteStorage | (opts: { driver: 'expo' | 'op'; namespace: string; encryptionKey?: CryptoKeyLike; directory?: string; enableFTS?: boolean; fileSystem?: FileSystemAdapter; migrations?: Migration[] }) => StorageAdapter | stable | as above; RASD_STORAGE_UNAVAILABLE when the driver peer is missing |
secureStoreKeyProvider (@rasd/storage-sqlite) | (opts?: { key?: string /* default 'rasd.dbkey.<namespace>' */ }) => { unlock(): Promise<string> } — 256-bit key created in expo-secure-store (WHEN_UNLOCKED_THIS_DEVICE_ONLY) on first use | beta | RASD_STORAGE_KEY_UNAVAILABLE |
deriveKeyFromPassphrase | (passphrase: string, salt: Uint8Array, opts?: { iterations?: number /* 600_000 */ }) => Promise<CryptoKey> (PBKDF2-HMAC-SHA-256 wrapping key) | stable | — |
createCipher, wrapKey, unwrapKey | AES-256-GCM envelope helpers used by adapters and packExport | beta | RASD_STORAGE_INTEGRITY on tag failure |
runMigrations | (ctx: { engine; tx; applied: AppliedRow[] }, steps: Migration[], opts?: { signal?; onProgress? }) => Promise<void> | stable | RASD_STORAGE_MIGRATION |
packExport / unpackExport | (chunks: AsyncIterable<ExportChunk>, sink: WritableStream | string, opts?: { encryptTo?; allowPlaintext?: boolean }) => Promise<ExportReport> and the inverse | stable | RASD_STORAGE_INTEGRITY |
listNamespaces (@rasd/storage-dexie) | () => Promise<string[]> | stable | — |
runConformanceSuite (@rasd/storage/conformance) | (factory: () => Promise<StorageAdapter>, opts?) => void — 180+ Vitest cases every adapter must pass | stable | — |
@rasd/storage-sqlite/node | createSqliteStorage({ driver: 'better-sqlite3', … }) for Node tests | beta | — |
Factory options are the defaults for open(); open(opts?) merges its argument over them (namespace must come from one of the two). Beyond the repos, every adapter carries state: 'closed' | 'opening' | 'open' | 'blocked' | 'error', import(chunks, opts?): Promise<ImportReport> (the inverse of export()), on('change' | 'blocked' | 'quota' | 'error' | 'migration', h): Unsubscribe and securityReport(), which returns { encryption: 'sqlcipher' | 'aes-gcm' | 'field' | 'none'; keyStore: string; persisted: boolean | null; backupExcluded: boolean | null; ephemeral: boolean; notices: string[] } (00 §7, 09 §3).
const storage = createDexieStorage({ namespace: 'wfp-jo', encryptionKey: await deriveKeyFromPassphrase(pass, salt) });
await storage.open();
const { items } = await storage.submissions.list({ formId: 'pdm-gfd-2026', status: ['draft'], limit: 20 });
7. @rasd/sync (FSL-1.1)
export function createSyncEngine(opts: SyncEngineOptions): SyncEngine; // stable
export interface SyncEngineOptions { storage: StorageAdapter; baseUrl?: string; getAuthToken: (ctx: { forceRefresh: boolean }) => Promise<string>; transport?: SyncTransport; policy?: DeepPartial<SyncPolicy>; deviceInfo?: Partial<DeviceInfo>; fetch?: typeof fetch; license?: LicenseHandle /* receives X-Rasd-License */; clock?: () => number; logger?: Logger }
export interface SyncEngine {
syncNow(reason?: SyncReason): Promise<SyncRunReport>; // single-flight; SyncReason = 'manual' | 'online' | 'foreground' | 'finalize' | 'timer' | 'event' | 'background'
pause(): void; resume(opts?: { allowMetered?: boolean }): void;
status(): SyncStatus; getLog(opts?: { since?: string; level?: 'debug' | 'info' | 'warn' | 'error' }): SyncLogEntry[]; getMetrics(): SyncMetrics;
retry(ids: string[]): Promise<void>; // dead-lettered outbox ops → pending
resolveConflict(id: string, resolution: 'keep-local' | 'keep-server' | { merged: Record<string, unknown> }): Promise<void>; // experimental (records)
on<E extends keyof SyncEvents>(event: E, h: (e: SyncEvents[E]) => void): () => void;
dispose(): Promise<void>;
}
export interface SyncEvents { progress: SyncProgress; error: RasdError; conflict: Conflict; formUpdated: { id: string; version: string; definitionHash: string }; datasetUpdated: { name: string; rows: number }; licenseRefreshed: { exp: string }; stateChange: SyncStatus; remoteWipe: { reason: string; unsyncedCount: number }; policyUpdated: DevicePolicy }
export interface SyncRunReport { reason: SyncReason; startedAt: string; endedAt: string; phases: Partial<Record<'submissions' | 'attachments' | 'forms' | 'datasets' | 'records' | 'purge', { done: number; failed: number; bytes?: number }>>; error?: RasdError }
export function createRspTransport(opts: { baseUrl: string; fetch?: typeof fetch; getAuthToken: SyncEngineOptions['getAuthToken']; clientHeader?: string }): SyncTransport; // stable (default when transport omitted)
export function createOpenRosaTransport(opts: { baseUrl: string; profile: 'central' | 'kobo' | 'ona'; getAuthToken: SyncEngineOptions['getAuthToken']; fetch?: typeof fetch }): SyncTransport; // experimental (phase 3)
SyncPolicy groups and defaults — batch (50 items / 5 MiB), attachments (5 MiB adaptive chunks 256 KiB–8 MiB, parallel 2, onMetered 'wifiOnly' | 'always' | 'ask'), backoff (1 s → 300 s, deadAfterAttempts 5), timers (pullIntervalMs 900_000, probeIntervalMs 60_000, requestTimeoutMs 30_000, chunkTimeoutMs 120_000, leaseMs 120_000), triggers, retention (purgeSyncedAfterDays 7, keepSentMetadataDays 90), background, records, requireHttps, log — are listed with every default in 10 §4.1; server DevicePolicy is merged over them at registration.
syncNow() resolves with report.error for run-level failures and emits error; it rejects only with RASD_SYNC_DISPOSED. Item-level outcomes: RASD_SYNC_REJECTED (submission → rejected), RASD_SYNC_CONFLICT, RASD_ATTACHMENT_FAILED, RASD_SYNC_AUTH (state authRequired), RASD_SYNC_NETWORK (retryable, backoff), RASD_SYNC_REVOKED. SyncTransport, SyncStatus, SyncProgress are in 03 §8 and 10 §4.9.
8. @rasd/pwa (FSL-1.1, beta)
| Export (entry) | Signature | Notes |
|---|---|---|
useServiceWorkerUpdate (@rasd/pwa) | (opts?: { registration?; workbox?; vitePwa?; mode?: 'prompt' | 'auto'; checkIntervalMs?: number /* 3_600_000 */; deferWhileBusy?: boolean }) => { updateAvailable; offlineReady; busy; deferred: boolean; applyUpdate(opts?: { force?: boolean }): Promise<'applied' | 'deferred'>; dismiss(): void } | Never reloads on its own; force while busy logs RASD_SW_UPDATE_WHILE_BUSY (11 §5.1) |
useInstallPrompt | () => { canPrompt: boolean; platform: 'chromium' | 'ios-safari' | 'other'; installed: boolean; prompt(): Promise<'accepted' | 'dismissed' | 'unavailable'> } | |
useStoragePersistence, requestPersistence | () => { persisted: boolean | null; usageBytes: number; quotaBytes: number | null; headroomBytes: number; low: boolean; request(): Promise<boolean> } / () => Promise<boolean> | |
precacheForms | (opts: { definitions: FormDefinition[]; concurrency?: number /* 4 */; maxBytes?: number; onProgress?; signal? }) => Promise<{ cached: number; skipped: number; failed: { url: string; status?: number }[]; bytes: number }> | Never throws on per-URL failure |
connectBackgroundSync | (opts: { sync: SyncEngine; registration: ServiceWorkerRegistration | Promise<ServiceWorkerRegistration> }) => () => void | Chromium only; the outbox stays the single queue |
isStandalone, InstallHint | () => boolean / React.FC<{ locale?: string; onDismiss?(): void }> | |
registerRasdRoutes, rasdSerwistRoutes, RasdOutboxPlugin (@rasd/pwa/sw) | (opts: RasdRouteOptions) => void / (opts) => SerwistRuntimeCaching[] / new RasdOutboxPlugin({ tag?: 'rasd-outbox' }) | Worker context only |
rasdRuntimeCaching, rasdWorkboxConfig (@rasd/pwa/workbox-config) | (opts: RasdRouteOptions) => RuntimeCaching[] / Partial<GenerateSWOptions> | Data only; safe in Node config files |
RasdRouteOptions (apiOrigin, definitionUrls, mediaOrigins, fontOrigins, cacheVersion, limits) is in 11 §2; Workbox 7.4.x is the peer (research/05).
9. @rasd/media (FSL-1.1, beta)
export const web: MediaAdapters; export const native: MediaAdapters; // lazy proxies; resolved by the react-native export condition
export function createMediaAdapters(overrides?: Partial<MediaAdapters> & { policy?: Partial<MediaPolicy> }): MediaAdapters;
export function permissionFor(kind: 'camera' | 'location' | 'microphone' | 'photos'): Promise<PermissionState>;
export function compressImage(input: Blob | string, opts: { maxPixels: number; quality: number; keepExif?: boolean }): Promise<CapturedFile>; // web worker / expo-image-manipulator
Adapter interfaces (GeolocationAdapter, CameraAdapter, BarcodeAdapter, SignatureAdapter, AudioAdapter, VideoAdapter, FilePickerAdapter, MapAdapter, MediaPolicy, CapturedFile, GeoFix, PermissionState) are normative in 14 §2. One factory pair per @rasd/media/<capability> entry: createWebCamera / createExpoCamera, createWebGeolocation / createExpoLocation, createWebBarcode({ wasmUrl }) / createExpoBarcode, createCanvasSignature / createSvgSignature, createWebAudio / createExpoAudio, createWebFilePicker / createExpoDocumentPicker, createMaplibreMap. Errors: RASD_MEDIA_PERMISSION, RASD_MEDIA_UNAVAILABLE, RASD_MEDIA_LIMIT, RASD_MEDIA_TYPE; cancellation resolves null/[].
import { web, createMediaAdapters } from '@rasd/media';
import { createExpoCamera } from '@rasd/media/camera';
const media = createMediaAdapters({ ...web, camera: createExpoCamera(), policy: { image: { maxPixels: 1600 } } });
10. @rasd/license (FSL-1.1, stable)
export function createLicense(opts: { token?: string; tokenEndpoint?: string; siteKey?: string; fetcher?: TokenFetcher; storage: StorageAdapter; appId?: string; clock?: () => number; freeFeatures?: string[] }): LicenseHandle;
export type TokenFetcher = (current: string | null) => Promise<{ token: string; serverTime?: string } | null>;
export type LicenseState = 'evaluating' | 'trial' | 'active' | 'grace' | 'limited' | 'invalid';
export interface LicenseHandle {
readonly state$: { subscribe(listener: (s: LicenseSnapshot) => void): () => void; get(): LicenseSnapshot };
getState(): LicenseState; getSnapshot(): LicenseSnapshot;
refresh(): Promise<void>; // coalesced; one call per 30 s per namespace
setToken(token: string, opts?: { serverTime?: string }): Promise<void>; // used by the sync engine for X-Rasd-License
can(feature: string): boolean;
on(event: 'stateChange' | 'licenseRefreshed' | 'error', h: (e: LicenseSnapshot | RasdError) => void): () => void;
dispose(): void;
}
export interface LicenseSnapshot { state: LicenseState; plan?: 'trial' | 'starter' | 'team' | 'enterprise'; features: string[]; apps: string[]; enforcement: 'soft' | 'hard'; expiresAt?: string; graceUntil?: string; seats?: number; reason?: InvalidReason; clockSuspect: boolean; kid?: string }
export type InvalidReason = 'MALFORMED' | 'WRONG_TYP' | 'UNKNOWN_KID' | 'REVOKED' | 'BAD_SIGNATURE' | 'NOT_YET_VALID' | 'APP_MISMATCH' | 'SCHEMA' | 'ISSUER'; // nine values, ratified in [00 §11]: SCHEMA = claims failed the zod schema, ISSUER = iss !== 'rasd'; a bad or absent alg folds into WRONG_TYP
// Seven SCREAMING_SNAKE values covering the verification steps in [15 §4.1](15-licensing-and-billing.md).
// Claim-shape failures (payload rejected by the zod schema) and a wrong issuer (`iss` ≠ "rasd") collapse into MALFORMED;
// a bad or absent `alg` collapses into WRONG_TYP.
export function verifyToken(token: string, opts?: { keys?: Record<string, string>; now?: number; app?: string }): { ok: true; claims: RltClaims } | { ok: false; reason: InvalidReason }; // pure; used by CLI and server
| Parameter | Notes |
|---|---|
tokenEndpoint | Recommended production channel; built-in fetcher POSTs { current, sdk, app }. |
siteKey | Prototyping only; console warning on any non-dev host. |
freeFeatures | Features always granted (the Option B switch, 15 §4.4). |
storage | kv keys license.token, license.lastServerTime, license.lastSeenLocal, license.trial.startedAt. |
Errors are events, never thrown in the render path: RASD_LICENSE_INVALID, RASD_LICENSE_EXPIRED (hard enforcement, via renderer onError), RASD_LICENSE_REFRESH_FAILED. Verification uses WebCrypto Ed25519 where available and @noble/ed25519 otherwise (research/06).
11. @rasd/themes (Apache-2.0, stable)
export const rasdLight: RasdTheme; export const rasdDark: RasdTheme; export const rasdHighContrast: RasdTheme; export const rasdField: RasdTheme;
export const themes: Record<'rasd-light' | 'rasd-dark' | 'rasd-high-contrast' | 'rasd-field', RasdTheme>;
export function createTheme(partial: DeepPartial<RasdTheme> & { id: string }, opts?: { extends?: string | RasdTheme }): RasdTheme; // throws RASD_THEME_INVALID
export function resolveTheme(theme: RasdTheme, mode?: { colorScheme?; contrast?; density?; reducedMotion?; direction?: 'ltr' | 'rtl' }): ResolvedTokens;
export function toCss(theme: RasdTheme, opts?: { selector?: string /* '.rasd-root[data-theme="<id>"]' */; layer?: string /* 'rasd' */ }): string;
export function fromDtcg(tokens: object, opts?: { id: string; theme?: string; contrast?: string; resolver?: object }): RasdTheme; // DTCG 2025.10
export function toDtcg(theme: RasdTheme): object;
export function validateTheme(theme: unknown): { ok: boolean; errors: Issue[]; warnings: Issue[] };
export function checkContrast(theme: RasdTheme): { pairs: { fg: string; bg: string; ratio: number; required: number; ok: boolean }[]; ok: boolean }; // WCAG 2.2 AA
export const fonts: { notoSansArabic: FontAsset; notoNaskhArabic: FontAsset }; // OFL; WOFF2 (web) / TTF (native)
RasdTheme is the JSON in 00 §10 (id, name, extends, mode, tokens.{color,typography,spacing,radius,elevation,motion,control}, components, ext), validated by docs/schema/rasd-theme.schema.json. createTheme resolves extends chains to depth 8 and rejects cycles.
12. @rasd/xlsform (Apache-2.0, beta)
export function importXlsform(input: ArrayBuffer | Uint8Array | Blob | XlsformWorkbook, opts?: { locales?: 'header' | 'code'; nestGroups?: boolean; coercion?: 'rel' | 'xpath'; kobo?: boolean; strict?: boolean }): Promise<{ definition: FormDefinition; warnings: ImportIssue[]; report: ImportReport }>;
export function importXform(xml: string, opts?: { coercion?: 'rel' | 'xpath' }): Promise<{ definition: FormDefinition; warnings: ImportIssue[]; xform: XformInfo }>; // XForm XML → RFD (forms pulled from servers)
export function importSurveyJs(json: unknown, opts?: { defaultLocale?: string }): Promise<{ definition: FormDefinition; warnings: ImportIssue[] }>; // experimental
export function exportXlsform(def: FormDefinition, opts?: { target?: 'generic' | 'kobo' | 'central' /* 'generic' */; locales?: 'header' | 'code'; format?: 'xlsx' | 'csv-zip' }): Promise<{ bytes: Uint8Array; warnings: ImportIssue[] }>;
export function xpathToRel(xpath: string, ctx?: { scopePath?: string }): { rel: string; lossless: boolean }; // throws RASD_XLSFORM_UNSUPPORTED
export function relToXpath(rel: string): { xpath: string; lossless: boolean };
export function serializeXformInstance(sub: Submission, def: FormDefinition, xform: XformInfo, opts?: { emitEmpty?: boolean; deprecatedId?: string }): { xml: string; media: MediaPart[] }; // used by createOpenRosaTransport
export function parseXformInstance(xml: string, def: FormDefinition, xform: XformInfo): { data: Record<string, unknown>; attachments: Partial<Submission['attachments']>; warnings: ImportIssue[] };
export type XlsformWorkbook = { survey: Record<string, unknown>[]; choices?: Record<string, unknown>[]; settings?: Record<string, unknown>[]; entities?: Record<string, unknown>[] };
export interface ImportIssue { code: string; severity: Severity; sheet?: string; row?: number; column?: string; path?: string; message: string; original?: string }
// Warning codes: W_XPATH_UNMAPPED, W_FUNCTION_UNKNOWN, W_REL_UNEXPORTABLE, W_EXPORT_DOWNGRADED (catalogue in [20 §1–2](20-interoperability.md)),
// plus W_UNLICENSED — appended to `warnings` when the `xlsform` feature is absent; import/export never throw for licensing ([15 §4.4](15-licensing-and-billing.md)).
// ImportReport ({ summary, issues, expectedMedia, languages, pyxform? }), XformInfo ({ rootName, id, version,
// formhubUuid?, nodeOrder, paths, metaNs? }) and MediaPart are normative in [20 §1–2](20-interoperability.md).
| Function | Behaviour | Errors |
|---|---|---|
importXlsform | Maps survey/choices/settings/entities, label::Lang (code) columns, Kobo begin_score/rank/kobomatrix; unmappable XPath kept under ext["org.getodk.xpath"] with W_XPATH_UNMAPPED in report.issues; strict turns unmapped into failure. | RASD_XLSFORM_IMPORT (details.sheet, row, column) |
exportXlsform | target: 'generic' emits a workbook pyxform 4.5.0 compiles (research/14); 'kobo'/'central' add platform columns; REL without an XPath equivalent becomes W_REL_UNEXPORTABLE + a calculation placeholder. | RASD_XLSFORM_EXPORT |
const { definition, warnings, report } = await importXlsform(await file.arrayBuffer(), { kobo: true });
if (report.summary.errors === 0) { const { bytes } = await exportXlsform(definition, { target: 'kobo' }); }
Peer: xlsx (SheetJS) for .xlsx I/O; the CSV path has no dependency.
13. @rasd/cli (Apache-2.0)
Binary rasd. Global flags: --json (machine output on stdout, human text on stderr), --quiet, --no-color, --cwd <dir>. Exit codes: 0 ok, 1 errors, 2 warnings under --strict or license limited, 3 invalid license/token, 64 usage error.
| Command | Flags | Stability | Behaviour |
|---|---|---|---|
rasd validate <form.json…> | --previous <file>, --functions a,b, --features a,b, --limit key=n (repeatable, tightens a 04 §16 limit; looser values are ignored), --translations warn|error, --strict | stable | validateFormDefinition; every flag maps onto an option the function already takes; prints code path message; JSON { ok, errors, warnings } |
rasd convert xlsform <in.xlsx> | -o <out.form.json>, --id, --version, --locale, --kobo, --strict | stable | importXlsform; exit 1 on RASD_XLSFORM_IMPORT |
rasd convert rfd <in.form.json> --to xlsform | -o <out.xlsx>, --target generic|kobo|central, --format xlsx|csv-zip | beta | exportXlsform |
rasd types <form.json> | -o <types.d.ts>, --name <Interface>, --zod | stable | Emits interface <Name>Data (+ optional zod schema) matching toSubmission().data |
rasd theme check <theme.json…> | --strict, --min-touch 24 | stable | validateTheme + checkContrast; fails below 4.5:1 text / 3:1 UI |
rasd i18n check <form.json> / --catalogs | --locales ar,fr, --strict | stable | translationReport; --catalogs lints chrome catalogs |
rasd i18n export / rasd i18n import | --format xliff|csv, --locales, -o | beta | Round-trips translation cells |
rasd diff <from.json> <to.json> | --plan-out <plan.json> | stable | diffDefinitions; exit 1 when loss !== 'none' under --strict |
rasd hash <form.json> | — | stable | Prints definitionHash |
rasd license check | --token, --env RASD_LICENSE, --file rasd-license.rlt, --app <origin|bundleId> | stable | Exit 0 ok / 2 limited / 3 invalid |
rasd license trial / rasd license refresh | --email, --out .rasdrc|- / --org-secret-env RASD_ORG_SECRET | beta | Signup / CI refresh |
rasd doctor | --url <origin>, --namespace | beta | Storage estimate, persistence, migration state, license state, outbox age |
rasd sign <form.json> | --key <ed25519.pem> | experimental | JWS-signs a definition for servers publishing signed RFDs |
13.1 What 0.1 actually ships
@rasd/cli 0.1 implements the E0.8 slice plus the commands whose backing
functions already exist: validate, hash, convert xlsform, convert rfd,
theme check, license check, version. Each wraps a library function
unchanged, so the CLI cannot develop its own idea of what "valid" means.
types, diff, i18n, doctor, sign, license trial and
license refresh need engines that do not exist yet (diffDefinitions,
translationReport, a storage probe, a signer, the issuer API). Running one
exits 64 and names this section, rather than failing obscurely or — worse —
succeeding silently.
Two deliberate departures from the table above, both recorded in 00 §14:
--versionis not global. The table gives--versiontoconvert xlsform(the form's version). One flag cannot mean two things, so the CLI's own version is therasd versioncommand.license checkgains--key <kid>=<base64url>. The embedded key set holds Rasd's own signing keys, so a team running the reference issuer from@rasd/server— which signs with its ownkid— could not check its tokens at all. Verification stays entirely offline: the flag supplies a key, it does not fetch one.
A third difference is behavioural rather than surface: a token that fails to
verify reports invalid, where deriveState would answer evaluating for
a node host. evaluating is the developer-machine leniency that keeps a dev
build running without a token; handing that word to a release pipeline would
tell it the opposite of the truth.
14. @rasd/testing (Apache-2.0, beta)
export function renderForm(def: FormDefinition, opts?: { platform?: 'web' | 'native'; initialData?; submissionId?; locale?; storage?: StorageAdapter; clock?: FakeClock; network?: FaultyNetwork; registry?: Registry; license?: LicenseHandle; media?: MediaAdapters; sync?: boolean | SyncEngine; validateOn? }): Promise<RenderedForm>;
export interface RenderedForm extends RenderResult { engine: FormEngine; storage: StorageAdapter; clock: FakeClock; sync?: SyncEngine; field(name: string): HTMLElement | ReactTestInstance; fill(name: string, value: unknown): Promise<void>; next(): Promise<void>; prev(): Promise<void>; finalize(): Promise<Result<Submission, ValidationResult>>; expectError(name: string, matcher?: string | RegExp): void; submission(): Promise<Submission>; kill(): Promise<void>; restart(): Promise<RenderedForm> }
export function fakeStorage(opts?: { kind?: 'memory' | 'dexie' | 'sqlite'; quotaBytes?: number; latencyMs?: number }): StorageAdapter & { failNext(method: string, err: RasdError): void; delay(method: string, ms: number): void; disconnect(): void; reconnect(): void };
export function createFakeClock(start?: string | number): FakeClock; // spine §11 name (alias: fakeClock); { now(); advance(ms); set(iso); tick() }
export function faultyNetwork(opts?: { dropResponses?: number; latencyMs?: [number, number]; offlineFor?: number; status?: Record<string, number> }): FaultyNetwork; // fetch shim + navigator.onLine control
export function createFaultyTransport(opts?: { faults?: FaultSpec; forms?: FormDefinition[]; datasets?: Record<string, DatasetRow[]>; verdict?: (s: Submission) => 'accepted' | 'duplicate' | 'rejected' | 'conflict' }): SyncTransport & { received: Submission[] }; // spine §11 name (alias: fakeTransport)
export function fakeMedia(opts?: { geolocation?: { fixes: GeoFix[]; permission?: PermissionState }; camera?: { files: CapturedFile[] }; barcode?: { results: string[] }; audio?: { durationMs: number; bytes: number }; permissions?: Partial<Record<string, PermissionState>> }): MediaAdapters;
export function fakeLicense(state: LicenseState, opts?: { features?: string[]; enforcement?: 'soft' | 'hard' }): LicenseHandle;
export function assertAccessibleContract(Component: ElementComponent | Components[keyof Components], opts?: { platform?: 'web' | 'native'; element?: Element; locale?: string }): void; // fails when a replacement drops the accessible-props contract (ids, aria-labelledby / aria-describedby, aria-invalid, aria-required, dir; native equivalents) — [12 §6](12-theming.md)
export const fixtures: { minimal: FormDefinition; pdmGfd2026: FormDefinition; kitchenSink: FormDefinition; perf500: FormDefinition; repeats200: FormDefinition };
renderForm wires <RasdProvider><FormRenderer/></RasdProvider> with @testing-library/react (web) or RNTL 14 (platform: 'native'); kill()/restart() simulate process death and reopen the same storage. Every fake throws RasdErrors with the same codes as production adapters.
14.1 What 0.1 actually ships
The signatures above are the target surface. @rasd/testing 0.1 implements the
E0.6 slice (19 §epics) — the five names
frozen in 00 §11, all headless:
| Shipped in 0.1 | Notes against the target above |
|---|---|
renderForm(def, opts?) | Headless: drives @rasd/core directly, returns no RTL queries and takes no platform, registry, license, media or sync. fill/next/prev/goTo/expectError/kill/restart are present; fill and the navigation helpers are synchronous because there is no render to await. Paging follows the renderer's rule (skip irrelevant pages, refuse to leave a page with blocking errors). |
createFakeClock(opts?) | Takes { start, autoAdvanceMs } rather than a bare start. Adds iso(), fn, runAll(), pending(), nextDue(), timer functions and install(). |
createFaultyTransport(opts) | Wraps a SyncTransport ({ target, faults, dropResponses, seed, … }) instead of standing one up. It is a decorator, not an in-memory server — the reference server in @rasd/server already is one, and duplicating its verdict logic would create a second source of truth about what the protocol accepts. dropResponse calls through before throwing, which is what makes it test idempotency rather than just retries. |
assertAccessibleContract(contract, rendered, opts?) | Takes the props object and the rendered node, not a component (see the amendment in 00 §14). Reads DOM attributes or React/RNTL host props, so it works under both runners without depending on either. |
fixtures | Builders, not constants: minimal(), consentGated(), repeats(), validation(), calculated(), multiPage(), large({ questions }), fromJson(). large() covers what perf500/repeats200 were for; the agency forms stay in docs/examples/ and load via fromJson(). |
@rasd/testing/conformance | runConformanceSuite(), re-exported from @rasd/storage behind a separate entry point so the default entry needs no storage peer. |
Since added: fakeStorage() (a createMemoryStorage wrapper with
failNext / delay / disconnect / reconnect and a call log — a Proxy
rather than a hand-listed wrapper, so it keeps covering the contract as it
grows), fakeLicense(state, opts?) (every licence state in one line, with
a stable snapshot — an unstable one spins useSyncExternalStore until React
gives up), and @rasd/testing/react, the RTL variant of renderForm from
06 §18. It lives behind its own entry point because
@rasd/react, react, react-dom and @testing-library/react are all
needed for it and none of them is needed to test form logic.
Its errors(name) reads the DOM, not the engine, and returns the text the
renderer is actually showing. The engine knows a required field is empty the
moment the form mounts; the renderer deliberately does not reveal it until the
field is touched or the page is validated (06 §7), so a
helper reporting the engine's view would pass a test asserting an error the
enumerator cannot see.
Also added: rspConformance({ baseUrl, getAuthToken, supports?, runId? })
— the RSP v1 suite of 10 §11, the server-side twin of
runConformanceSuite(). It speaks only HTTP against a baseUrl, so an agency
that writes its own RSP server in Go runs exactly what the reference server
runs, and a green result means their devices will sync. baseUrl may be a
function, which is usually what you want: the suite registers its tests at
module load, before any beforeAll has bound an ephemeral port. Optional
surfaces (publish, datasetImport, attachments, quarantine, records,
sse, remoteWipe, definitionSignature) are declared through supports,
because a server is conformant without them (10 §10) and
a suite that failed on their absence would be untrue. errorBodyProblem(body)
and isUtcIso(value) are exported separately for a server's own tests. It
covers checks 1–10, 13 and 17; records, SSE, remote wipe, definition signatures
and device revocation (11, 12, 14, 15, 16) still need a server-side admin hook
the suite does not yet take.
Still not built: the RNTL variant (platform: 'native'), faultyNetwork()
(the lower-level fetch shim — createFaultyTransport() covers the transport
level), fakeMedia(), propertyEngine() and @rasd/testing/axe.
15. @rasd/server (FSL-1.1, beta)
export function createRasdServer(opts: { db: DbAdapter; blobs: BlobStoreAdapter; auth: AuthAdapter; hooks?: ServerHooks; license?: LicenseServiceOptions | false; settings?: ServerSettings }): { app: Hono; rsp: Hono; license?: Hono; start(port?: number): Promise<{ close(): Promise<void> }>; migrate(): Promise<void>; healthz(): Promise<{ ok: boolean; db: boolean; blobs: boolean }> };
export function createRspApp(opts: { db: DbAdapter; blobs: BlobStoreAdapter; auth: AuthAdapter; hooks?: ServerHooks }): Hono; // RSP routes only, mountable under any prefix
export function createLicenseService(opts: { db: DbAdapter; signingKeys: { kid: string; privateKey: Uint8Array }[]; billing?: BillingAdapter; issuerUrl: string }): Hono; // POST /v1/trials, /v1/tokens/refresh, /v1/tokens/site, GET /.well-known/jwks.json
export function createPostgresDb(opts: { connectionString: string; schema?: string; pool?: { max?: number } }): DbAdapter;
export function createS3BlobStore(opts: { bucket: string; endpoint?: string; region?: string; credentials?: unknown; tus?: { maxSize?: number /* 100 MiB */; expirationMs?: number /* 7 d */ } }): BlobStoreAdapter;
export function jwtAuth(opts: { jwksUrl?: string; secret?: string; issuer: string; audience?: string; claims?: { org: string; user: string; roles?: string } }): AuthAdapter;
export function staticApiKeyAuth(keys: Record<string, { orgId: string; userId: string; roles: string[] }>): AuthAdapter; // dev only
export function centralAppUserAuth(opts: { lookup(token: string): Promise<{ orgId: string; userId: string; roles: string[] } | null> }): AuthAdapter;
export interface ServerHooks { validateSubmission?(s: Submission, def: FormDefinition): Promise<{ verdict: 'accepted' | 'rejected'; reasons?: { code: string; message: string; field?: string }[] } | void>; onEvent?(e: WebhookEvent): Promise<void> }
export function tokenEndpointHandler(opts: { orgSecret: string; licenseUrl?: string; cacheMs?: number /* 24 h */ }): (c: HonoContext) => Promise<Response>; // '@rasd/server/license' proxy recipe
export function xRasdLicenseMiddleware(opts: { issue(orgId: string): Promise<string | null> }): MiddlewareHandler;
AuthAdapter (verify, issueLicense?, onDeviceRegistered?), routes, Postgres DDL, tus handling, quarantine, exports and webhooks are specified in 10 §9. Node ≥ 20 (Node 22 LTS recommended, research/08); Postgres 15+; any S3-compatible store.
const server = createRasdServer({
db: createPostgresDb({ connectionString: process.env.DATABASE_URL! }),
blobs: createS3BlobStore({ bucket: 'rasd-attachments', endpoint: 'https://minio.internal:9000' }),
auth: jwtAuth({ jwksUrl: 'https://id.example.org/.well-known/jwks.json', issuer: 'https://id.example.org' }),
});
await server.migrate();
const { close } = await server.start(8080);
16. Consolidated RasdError code table
| Code | Package | Surfaces as | retryable | details |
|---|---|---|---|---|
RASD_SCHEMA_INVALID | core | thrown by createFormEngine; renderer onError | no | { issues: Issue[] } |
RASD_EXPR_PARSE / RASD_EXPR_UNKNOWN_REF / RASD_EXPR_UNKNOWN_FUNCTION / RASD_EXPR_CYCLE | core | thrown at load (parseExpression, createFormEngine) | no | { source, offset, expected } / { path, property, name? | cycle[] } |
RASD_EXPR_FUNCTION_EXISTS / RASD_ENGINE_DISPOSED / RASD_I18N_SYNTAX / RASD_UNSUPPORTED_SPEC / RASD_MIGRATION_REFUSED / RASD_THEME_INVALID | core, themes | thrown | no | { name } / — / { text, offset } / { rasd, supports, requires? } / { reason, step? } / { issues } |
RASD_EXPR_RUNTIME / RASD_EXPR_BUDGET / RASD_EXPR_TRIGGER_LOOP | core | diagnostic event only | — | { path, property, reason } |
RASD_ABORTED | all | rejected when signal aborts | yes | — |
RASD_HOOK_CONTEXT / RASD_REGISTRY_TYPE / RASD_ELEMENT_RENDER | react, native | thrown (hooks, registry) / onError (boundary) | no | { hook | type | path, componentStack } |
RASD_STORAGE_QUOTA / RASD_STORAGE_LOCKED | storage | rejected; quota event | yes | { usageBytes, quotaBytes } / { expectedRev?, actualRev? } |
RASD_STORAGE_MIGRATION / _DOWNGRADE / _KEY_UNAVAILABLE / _CORRUPT / _INTEGRITY / _NOT_OPEN / _UNAVAILABLE | storage | rejected (mostly from open()) | no | { migrationId?, storedVersion?, table?, id? } |
RASD_SYNC_REJECTED | sync | error event; submission → rejected | no | { submissionId, reasons: { code, message, field? }[] } |
RASD_SYNC_NETWORK | sync | error event; state backoff | yes | { httpStatus?, url } |
RASD_SYNC_AUTH / RASD_SYNC_REVOKED / RASD_SYNC_CONFLICT / RASD_SYNC_DISPOSED / RASD_ATTACHMENT_FAILED | sync (media) | error / conflict events; syncNow rejects only when disposed | no | { httpStatus } / { recordId, local, server } / { attachmentId, reason } |
RASD_LICENSE_INVALID / RASD_LICENSE_EXPIRED / RASD_LICENSE_REFRESH_FAILED | license | events (RASD_LICENSE_EXPIRED also via renderer onError under hard) | refresh: yes | { reason?, apps?, host?, exp?, graceUntil?, status? } |
RASD_MEDIA_PERMISSION / RASD_MEDIA_UNAVAILABLE / RASD_MEDIA_LIMIT / RASD_MEDIA_TYPE | media | rejected by adapters | permission: when askable | { reason | limit, actual, kind | mime } |
RASD_POLICY_VIOLATION | react, native, storage | onPolicyViolation | no | { policy, path? } |
RASD_XLSFORM_IMPORT / RASD_XLSFORM_EXPORT / RASD_XLSFORM_UNSUPPORTED | xlsform | rejected / thrown | no | { sheet?, row?, column?, xpath? } |
RASD_VERSION_EXISTS | builder, server | PublishResult.code; server 409 | no | { id, version } |
RASD_SW_UPDATE_WHILE_BUSY | pwa | console warning only | — | — |
RSP wire error codes (batch_too_large, cursor_expired, checksum_mismatch, device_revoked, …) are lower-case strings in the HTTP body (10 §1.4) and are mapped to the codes above by the sync engine.
17. Event catalogue
| Source | Subscribe via | Event | Payload |
|---|---|---|---|
FormEngine | subscribe(l) / on('patch') | patch | EnginePatch { rev, changedPaths, structural } |
FormEngine | subscribeField(path) | — | FieldState |
FormEngine | on() | diagnostic, trigger, complete, message, customAction, locale, finalized | §2.3 |
<FormRenderer> | props | onChange, onSave, onFinalize, onInvalid, onPageChange, onComplete, onTriggerAction, onError | §3.2 |
<RasdProvider> | props | onError, onAudit, onPolicyViolation, onStorageBlocked | RasdError / AuditEvent / info |
StorageAdapter | on() | change, blocked, quota, error, migration | { table, ids, source: 'local' | 'other-tab' | 'sync' }, { reason, otherTabs }, { usageBytes, quotaBytes, level: 'warning' | 'critical' } (09 §3), RasdError, { id, phase, done, total } |
SyncEngine | on() | progress, error, conflict, formUpdated, datasetUpdated, licenseRefreshed, stateChange, remoteWipe, policyUpdated | §7 |
LicenseHandle | state$.subscribe / on() | stateChange, licenseRefreshed, error | LicenseSnapshot / RasdError |
<FormBuilder> | props | onChange, onPublish, onMachineTranslate, onCommand | §5 |
<rasd-form> | DOM addEventListener | rasd:ready, rasd:change, rasd:save, rasd:finalize, rasd:error, rasd:sync | CustomEvent.detail per §4 |
| Service worker | postMessage | rasd:sync-wake | { type: 'rasd:sync-wake' } |
| RSP server (SSE) | GET /v1/events | formPublished, datasetUpdated, recordChanged, policyUpdated | trigger-only |
| RSP server (webhooks) | HTTP POST | submission.accepted, submission.completed, submission.quarantined, record.updated, record.conflict, form.published, device.registered | entity + { event, orgId, occurredAt } |
18. Acceptance criteria
- Every export in this document exists with the stated name, signature and JSDoc
@stabilitytag; theapi-extractorreport is committed per package and a PR that changes a stable signature fails CI unless amajorchangeset is present. -
publintand@arethetypeswrong/clipass on everynpm packoutput; no import outside the documentedexportsmap resolves. -
RASD_ERROR_CODESin@rasd/coreequals the union of §16; each adapter package has a test asserting it never rejects with a non-RasdError; every event in §17 has a contract test. -
@rasd/reactand@rasd/nativeexport lists are diffed in CI and are identical except the documented native-only additions; every hook has an "outside provider" behaviour test. - Type-level tests (
expect-type) coverElementnarrowing bytype,FieldApi<V>inference fromdefineElement<V>, andResultnarrowing onfinalize(); TypeDoc builds without warnings.
Open questions
- Should
migrateSubmissionreturn a report alongside theSubmission(breaking the spine'sSubmissionreturn type), or is the separateplanMigration()dry-run introduced here sufficient? registerFunctionis process-global while 05 §8 treats functions as per-engine; confirm the "default registry + per-enginefunctions" model or move to registry objects only.useSync().state— keep the 5-value projection for hosts, or expose only the 10-valueSyncStatus.state?- Ship
createRasdServer(all-in-one) in addition tocreateRspApp/createLicenseService, or keep only the two composable factories? - Name and home of
secureStoreKeyProvider(@rasd/storage-sqlitevs@rasd/native). - Fold
RASD_TRIGGER_LOOP(04 §8.2) intoRASD_EXPR_TRIGGER_LOOP(05 §12)? This document lists only the latter. xpathToRel/relToXpathas public exports of@rasd/xlsformversus internal helpers — decide before the 1.0 API freeze.
Related documents
00 · Decisions & conventions · 03 · Architecture · 04 · Form schema spec · 05 · Logic & expressions · 06 · Renderer (React) · 07 · Renderer (native) · 08 · Builder · 09 · Offline storage · 10 · Sync protocol · 11 · PWA & embedding · 12 · Theming · 13 · i18n, RTL & accessibility · 14 · Media & field capture · 15 · Licensing & billing · 16 · Security & data protection · 18 · Engineering practices · 20 · Interoperability · 21 · Getting started · Research: 02 · 08 · 12