@enc-protocol/flow -- SDK API Reference
Enclave resolution engine. Maps app schemas to protocol enclaves, generates deployment artifacts, and produces runtime SDKs.
Install
npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/
npm install @enc-protocol/flowimport {
extractSchema,
deriveManifest,
deriveManifestFromSchema, // schema-first entry point (no UI/flow needed)
match,
generate,
profileEnclave,
deriveFlow,
createSDK,
FLOW_CONFIG, // inline fallback (browser-safe)
loadFlowConfig, // async Node-only loader for per-app flow.json
} from '@enc-protocol/flow'Pipeline (schema-first)
ui.json + flow.json ─► schema.json ─► app.json ─► infra.json
enclaves/*.json ─┘extractSchema(ui, flow)→ schema.json (absorbs tableMap, derived, enclaves, sources so downstream never re-opens flow)deriveManifestFromSchema(schema, enclaves)→ app.json (the new schema-first entry; takes no UI)deriveManifest(app, enclaves)→ app.json (legacy wrapper kept for callers without a persisted schema)
Table of Contents
- schema.mjs --
extractSchema() - resolver.mjs --
match(),generate(),profileEnclave() - manifest.mjs --
deriveManifest() - sdk-gen.mjs --
createSDK(),sdkMethods() - flow-config.js --
FLOW_CONFIG - derive-flow.mjs --
deriveFlow() - ui-analysis.mjs --
deriveUi() - engine.mjs --
profileEnclave(),analyzeApp(),resolve() - resolve-enclaves.mjs --
resolveEnclaves() - index.mjs -- Re-exports
- lib/generic-dataview.js --
GenericDataViewclass - lib/dataview-server.js -- DataView HTTP server
- lib/workflow-executor.mjs --
executeWorkflow(),executeMockAsCommits()
1. schema.mjs -- Schema Extraction
Source: sdk-flow/schema.mjs
extractSchema(app, flow?) -> Schema
Extract a protocol-level data schema from a UI app definition + its flow
config. Derives read/write tables from action expressions and data
bindings, and absorbs the flow's routing fields (tableMap, derived,
enclaves, sources) into the schema output so downstream stages never
re-open the flow config.
| Param | Type | Default | Description |
|---|---|---|---|
app | object | required | App definition (from ui.json or ENC_APPS[]). Must have id, name, pages. |
flow | object | FLOW_CONFIG[app.id] ?? {} | Flow config entry. When omitted, falls back to the inline FLOW_CONFIG. |
Returns: Schema
interface Schema {
id: string // App identifier
name: string // Display name
data_types: Record<string, JSONSchema> // Write tables with JSON Schema
actions: Record<string, ActionDef> // Named write triggers
reads: Record<string, ReadSpec> // Read subscriptions
encrypt: string[] // Encrypted table names
// Absorbed from flow (only present if flow set them):
tableMap?: Record<string, string> // UI table -> enclave event
derived?: string[] // Client-side view tables
enclaves?: string | string[] // Enclave override ('*' or list)
sources?: { protocol?: string[]; external?: string[] }
external?: { // Only if app has external data
reads: Record<string, { fields: string[] }>
writes: Record<string, JSONSchema>
}
}
interface ActionDef {
write?: string // Target table (for insert)
delete?: string // Target table (for delete)
page: string // Source page path
trigger: string // UI trigger (e.g., '#bar.submit')
}
interface ReadSpec {
fields: string[] // Column names referenced
cross_enclave?: boolean // True if table is read-only (not written)
}- Loads the app's flow config from
FLOW_CONFIG[app.id]. - Iterates all pages (skipping
gatepages). - Scans action expressions for
push(),delete(),insert()patterns. - Scans data bindings for
#node<tableand@table.fieldpatterns. - Marks tables in
flow.derivedas derived (skipped). - Marks tables in
flow.sources.externalas external (separate output). - Cross-enclave marks read-only tables (read but never written).
import { extractSchema } from '@enc-protocol/flow'
const schema = extractSchema(helloApp)
// {
// id: 'hello',
// name: 'Hello',
// data_types: {
// messages: {
// type: 'object',
// properties: { draft: { type: 'string' } },
// required: ['draft']
// }
// },
// actions: {
// feed_bar_submit: { write: 'messages', page: '/feed', trigger: '#bar.submit' },
// my_posts_bar_submit: { write: 'messages', page: '/my_posts', trigger: '#bar.submit' }
// },
// reads: {
// messages: { fields: ['from', 'body', 'trailing'] }
// },
// encrypt: []
// }- If
FLOW_CONFIG[app.id]does not exist, uses empty defaults (encrypt: [], no derived, no external`). - Tables in
derivedare completely skipped -- they do not appear indata_types,actions, orreads. - Tables in
sources.externalare classified underschema.externalinstead ofschema.data_types/schema.reads. - If the same table appears in multiple
push()actions across pages, thedata_typesentry is created once and extended with new columns. - The
externalkey is only present in the output if external tables exist. - Action key naming:
pagePath_triggerwith leading/stripped and#/.replaced by_. Duplicate keys are not overwritten.
2. resolver.mjs -- Matching and Generation
Source: sdk-flow/resolver.mjs
profileEnclave(name, manifest) -> EnclaveProfile | null
Profile an enclave's access model from its RBAC manifest for matching.
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
name | string | required | Enclave name (e.g., 'Hello') |
manifest | object | required | Enclave manifest object (the .manifest property from the JSON file) |
Returns: EnclaveProfile | null (null if manifest is falsy)
interface EnclaveProfile {
name: string // Enclave name
parties: '1' | '2' | 'N' // Participant model
readerModel: 'public' | 'member' | 'owner'
events: Record<string, EventProfile>
stateCount: number // Number of states in the enclave
}
interface EventProfile {
write: boolean // Has Create permission (non-dataview)
dataviewP: boolean // Has Poll permission
operators: string[] // All operators for this event
}- Extracts states, readers, customs, and moves from the manifest.
- Determines participant model:
- MEMBER state + (complex moves or PENDING state) =
'N' - MEMBER state (simple) =
'2' - No MEMBER state =
'1'
- MEMBER state + (complex moves or PENDING state) =
- Determines reader model:
- Public reader or non-OWNER with
reads: '*'='public' - MEMBER reader =
'member' - Otherwise =
'owner'
- Public reader or non-OWNER with
- Builds per-event profiles from customs:
write: trueif ops include'C'and operator is not'dataview'dataviewP: trueif ops include'P'
import { profileEnclave } from '@enc-protocol/flow'
const helloManifest = {
states: [],
traits: ['owner(0)', 'dataview(1)'],
readers: [{ type: 'Public', reads: '*' }],
moves: [],
customs: [
{ event: 'post', operator: 'owner', ops: ['C', 'U', 'D'] },
{ event: 'post', operator: 'dataview', ops: ['P'] }
],
// ...
}
const profile = profileEnclave('Hello', helloManifest)
// {
// name: 'Hello',
// parties: '1',
// readerModel: 'public',
// events: {
// post: { write: true, dataviewP: true, operators: ['owner', 'dataview'] }
// },
// stateCount: 0
// }- Returns
nullifmanifestisnull,undefined, or falsy. - Events with
operator: 'dataview'and ops['C']are NOT counted as writable (dataview only polls, it never creates). - An event can have both
write: trueanddataviewP: trueif different operators provide C and P respectively.
match(schema, enclaveRegistry) -> MatchResult
Match an app schema against existing enclaves. Returns the best-fit primary enclave, cross-enclave reads, and scores.
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
schema | Schema | required | App schema from extractSchema() |
enclaveRegistry | Record<string, EnclaveData> | required | { Name: { manifest: {...} } } |
Returns: MatchResult
interface MatchResult {
primary: { name: string, score: number, profile: EnclaveProfile } | null
crossEnclave: CrossEnclaveRead[]
enclaves: string[] // All enclaves needed
scores: { name: string, score: number, profile: EnclaveProfile }[] // Top 6
profiles: Record<string, EnclaveProfile>
}
interface CrossEnclaveRead {
table: string // Read-only table name
enclave: string // Source enclave name
event: string // Source event name
reason: string // Human-readable reason
}- Profiles all enclaves in the registry via
profileEnclave(). - Scores each profile against the schema (see scoring below).
- Filters out scores of
-Infinity(disqualified). - Sorts by score descending. Primary = highest.
- Resolves cross-enclave reads for external tables:
profilesalways maps toRegistry.reg_identity.- Other external tables: search scored enclaves (excluding primary) for one with DataView P capability.
- Returns union of primary + cross-enclave as
enclaves.
Score = 0
// Write coverage: min(writableEvents, writeTableCount) * 10
// Read coverage: min(totalEvents, totalTableCount) * 3
// Excess events penalty: -(totalEvents - totalTableCount) * 2 (if > 2x)
// Insufficient events: -(writeTableCount - writableEvents) * 5
// Encrypted writes need owner/member reader: +10
// Encrypted writes with public reader: -10
// Plaintext writes with DataView P or public: +5
// Disqualified: writeTableCount > 0 but writableEvents == 0import { match, extractSchema } from '@enc-protocol/flow'
const schema = extractSchema(helloApp)
const result = match(schema, {
Hello: { manifest: helloEnclave.manifest },
DM: { manifest: dmEnclave.manifest },
Timeline: { manifest: timelineEnclave.manifest },
})
// {
// primary: { name: 'Hello', score: 23, profile: { ... } },
// crossEnclave: [],
// enclaves: ['Hello'],
// scores: [
// { name: 'Hello', score: 23 },
// { name: 'Timeline', score: 15 },
// ...
// ],
// profiles: { Hello: { ... }, DM: { ... }, ... }
// }- If no enclave scores above
-Infinity,primaryisnull. - If
schema.readshas no entries withexternal: true,crossEnclaveis empty. - Enclave registry entries with missing or falsy manifest are profiled as
nulland scored as-Infinity.
generate(schema) -> GeneratedEnclave
Generate a minimum viable enclave manifest from an app schema. Used when no existing enclave fits the app's needs.
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
schema | Schema | required | App schema from extractSchema() |
Returns: GeneratedEnclave
interface GeneratedEnclave {
name: string // schema.name or schema.id
description: string // Auto-generated description
manifest: {
states: [] // Always empty (1-party)
traits: string[] // ['owner(0)'] + optionally ['dataview(1)']
readers: ReaderRule[] // Owner-only or Public
moves: [] // Always empty
grants: GrantRule[] // DataView grants if needed
transfers: [] // Always empty
slots: [] // Always empty
lifecycle: LifecycleRule[] // Always includes Terminate
customs: CustomRule[] // One per data type
init: InitRule[] // Owner initialization
}
encryption: Record<string, { algorithm: string }> // Per encrypted event
data_types: Record<string, JSONSchema> // Passthrough from schema
}- Determines if app needs plaintext or encrypted writes.
- Creates one custom event per data type:
- Table name singularized (
messages->message). - OWNER gets CUD ops.
- If plaintext,
dataviewgets P ops.
- Table name singularized (
- Sets reader model:
- Encrypted-only:
[{ type: 'OWNER', reads: '*' }] - Plaintext-only:
[{ type: 'Public', reads: '*' }] - Mixed:
[{ type: 'OWNER', reads: '*' }](owner reads all, dataview polls plaintext)
- Encrypted-only:
- Adds traits:
['owner(0)']+['dataview(1)']if DataView needed. - Adds DataView grants if needed.
- Determines encryption algorithm:
XChaCha20-Poly1305for each encrypted table.
import { generate, extractSchema } from '@enc-protocol/flow'
const schema = extractSchema(helloApp)
const enclave = generate(schema)
// {
// name: 'Hello',
// description: 'Auto-generated enclave for Hello.',
// manifest: {
// states: [],
// traits: ['owner(0)', 'dataview(1)'],
// readers: [{ type: 'Public', reads: '*' }],
// customs: [
// { event: 'message', operator: 'OWNER', ops: ['C', 'U', 'D'] },
// { event: 'message', operator: 'dataview', ops: ['P'] }
// ],
// ...
// },
// encryption: {},
// data_types: { messages: { ... } }
// }- If schema has no
data_types, produces an enclave with zero customs. - Table name singularization is naive (
replace(/s$/, '')) --addressesbecomesaddresse, notaddress. - Encryption key uses the singularized event name, not the table name.
3. manifest.mjs -- Manifest Derivation
Source: sdk-flow/manifest.mjs
deriveManifestFromSchema(schema, enclaveRegistry?) -> AppManifest
Primary entry point (schema-first). Derives an app manifest from an
already-extracted schema + the available enclave registry. Never touches
the UI or flow sources directly — the schema is expected to have absorbed
the flow's routing fields (via extractSchema(app, flow)).
Use this when you have a persisted schema.json on hand, or when you want
to hand-author a schema and skip the UI/flow pipeline entirely.
| Param | Type | Default | Description |
|---|---|---|---|
schema | Schema | required | Output of extractSchema(app, flow) or a hand-authored equivalent |
enclaveRegistry | Record<string, EnclaveData> | {} | { Name: { manifest: {...} } } |
deriveManifest(app, enclaveRegistry?) -> AppManifest
Legacy wrapper: runs extractSchema(app) internally, then forwards to
deriveManifestFromSchema. Kept for callers that don't have a persisted
schema on hand (in-browser usage, published-SDK consumers).
| Param | Type | Default | Description |
|---|---|---|---|
app | object | required | App definition (from ui.json or ENC_APPS[]) |
enclaveRegistry | Record<string, EnclaveData> | {} | { Name: { manifest: {...} } } |
Returns: AppManifest
interface AppManifest {
id: string
name: string
version: '1.0.0'
enclaves: string[]
data_types: Record<string, Record<string, string | ColSpec>>
cross_enclave_reads?: Record<string, CrossRead>
}
interface ColSpec {
type: string
encrypted?: boolean
optional?: boolean
}
interface CrossRead {
enclave: string
event: string
access: 'read'
}- Resolves enclaves:
- Check
ENCLAVE_OVERRIDES[app.id]first. - Default:
[PascalCase(app.name)]. - Wildcard
'*'expands to all keys inenclaveRegistry.
- Check
- Calls
extractSchema(app)to derive the schema. - Converts JSON Schema
data_typesto flat field maps:{ col: typeString }. - Detects cross-enclave reads: tables read but not written.
profiles->Registry.reg_identity(if Registry in registry).- Others: search enclave customs for matching event name.
- Adds cross-enclave enclaves to the enclaves list.
{
wallet: ['DM'],
appstore: ['*'],
super: ['DM', 'Group', 'Personal'],
node: ['*'],
mini_hello: ['Hello'],
}| Column Name | Inferred Type |
|---|---|
from, to, pub, identity, owner, media, key, *_pub | id_pub |
outgoing, read, pinned, starred, blocked, encrypted, public, private | bool |
likes, replies, count, views, amount, balance | int |
created_at, updated_at, expires_at, timestamp, time | timestamp |
| Everything else | string |
import { deriveManifest } from '@enc-protocol/flow'
const manifest = deriveManifest(helloApp, enclaveRegistry)
// {
// id: 'hello',
// name: 'Hello',
// version: '1.0.0',
// enclaves: ['Hello'],
// data_types: { messages: { draft: 'string' } }
// }
const dmManifest = deriveManifest(dmApp, enclaveRegistry)
// {
// id: 'dm',
// name: 'DM',
// version: '1.0.0',
// enclaves: ['DM', 'Registry'],
// data_types: { invites: {}, contacts: {}, messages: { message_draft: 'string' } },
// cross_enclave_reads: {
// profiles: { enclave: 'Registry', event: 'reg_identity', access: 'read' }
// }
// }- If
extractSchema()throws, falls back to empty schema:{ data_types: {}, actions: {}, reads: {}, encrypt: [] }. cross_enclave_readskey is only present in output when there are cross-enclave reads.- If the enclave registry is empty and the app has wildcard enclaves,
enclavesremains['*'].
4. sdk-gen.mjs -- SDK Generation
Source: sdk-flow/sdk-gen.mjs
createSDK(appId, infra, adapter, opts?) -> SDK
Generate a runtime SDK instance for an app. Wraps a NetworkAdapter or PureAdapter with typed methods for every protocol operation.
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
appId | string | required | App identifier (e.g., 'hello') |
infra | object | required | Parsed infra.json content |
adapter | object | required | Core adapter (NetworkAdapter or PureAdapter) |
opts | object | {} | Optional configuration |
opts.crypto | object | null | Crypto hooks for encrypted tables |
opts.crypto.encrypt | function | -- | async (eventName, content, opts) => encryptedContent |
opts.crypto.decrypt | function | -- | async (eventName, encContent, senderPub) => content |
Returns: SDK
interface SDK {
// Dynamically generated per infra.json commands:
submit_<eventName>: (content: any, cryptoOpts?: any) => Promise<any>
// Dynamically generated per infra.json endpoints:
query_<endpointName>: (opts?: any) => Promise<any[]>
// Fixed methods:
subscribe: (callback: (event: any) => void) => () => void
query_events: (eventType: string, opts?: any) => Promise<any[]>
create_enclave: (rbac: any) => Promise<any>
// Cross-enclave:
registry: {
setAdapter: (adapter: any) => void
publish: (identity: any) => Promise<any>
lookup: (pubKey: string) => Promise<any>
nameFor: (pubKey: string) => string
_adapter: any
_cache: Map<string, any>
}
// External data:
external: {
register: (name: string, fetchFn: Function) => void
fetch: (name: string, opts?: any) => Promise<any>
_handlers: Map<string, Function>
}
// Metadata:
_adapter: any
_appId: string
_infra: object
_events: string[] // Commit event names
_queries: string[] // Query endpoint names
_encrypted: string[] // Encrypted table names
_externalTables: string[] // External table names
crypto: object | null // Injected crypto hooks
}- Loads flow config from
FLOW_CONFIG[appId]. - Builds reverse mapping:
eventToTablesfromtableMap. - For each
commitcommand ininfra.commands:- Creates
submit_<eventName>method. - If the event's tables include an encrypted table AND
crypto.encryptexists, encrypts content before submitting. - Serializes payload to JSON string if not already a string.
- Creates
- For each endpoint in
infra.endpoints(excluding_-prefixed):- Creates
query_<endpointName>method. - Uses
adapter.queryEndpoint()if available, else falls back toadapter.query().
- Creates
- Creates
subscribe()method:- If event is for an encrypted table and
crypto.decryptexists, decrypts content before passing to callback. - Adds
_encrypted: trueflag to decrypted events. - Falls through to raw event if decryption fails.
- If event is for an encrypted table and
- Creates
registrysub-object for cross-enclave identity operations:publish(identity)-- submitsreg_identityevent.lookup(pubKey)-- queries allreg_identityevents, caches results by normalized pubkey.nameFor(pubKey)-- synchronous cache lookup, returnsdisplay_nameor truncated pubkey.
- Creates
externalsub-object for external data sources.
import { createSDK } from '@enc-protocol/flow'
// Basic plaintext app
const sdk = createSDK('hello', infraJson, pureAdapter)
await sdk.submit_post({ body: 'Hello World', from: '79be...' })
const rows = await sdk.query_messages({ limit: 50 })
const unsub = sdk.subscribe(event => console.log(event.content))
// Encrypted app with crypto
const dmSdk = createSDK('dm', dmInfra, adapter, {
crypto: {
encrypt: async (eventName, content) => {
return ecdhEncrypt(content, recipientPub)
},
decrypt: async (eventName, encContent, senderPub) => {
return ecdhDecrypt(encContent, senderPub)
},
}
})
await dmSdk.submit_message({ body: 'secret' }) // auto-encrypts
// Cross-enclave identity
sdk.registry.setAdapter(registryAdapter)
await sdk.registry.publish({ id_pub: '79be...', display_name: 'Alice' })
const identity = await sdk.registry.lookup('79be...')
const name = sdk.registry.nameFor('79be...') // 'Alice' (from cache)- If
adapter.subscribeis falsy,sdk.subscribe()returns a no-op unsubscribe function. - If
adapter.queryEndpointdoes not exist,query_*methods fall back toadapter.query()with the first commit command's event name. registry.lookup()queries up to 500 events and caches all of them. Subsequent lookups hit the cache.registry.nameFor()returns truncated pubkey (slice(0, 8)) if no cached identity exists.- If
opts.cryptois not provided,sdk.cryptoisnulland no encryption/decryption is performed. - Endpoints prefixed with
_are internal and excluded from query methods.
sdkMethods(appId, infra) -> MethodDef[]
List all SDK methods that would be generated for an app. Useful for documentation and introspection.
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
appId | string | required | App identifier |
infra | object | required | Parsed infra.json |
Returns: MethodDef[]
interface MethodDef {
name: string // e.g., 'submit_post', 'query_messages'
type: 'write' | 'read' | 'subscribe' | 'lifecycle'
event?: string // For write methods: event name
encrypted?: boolean // For write methods: whether event is encrypted
method?: string // For read methods: HTTP method
path?: string // For read methods: endpoint path
}- For each
commitcommand ininfra.commands: addsubmit_<name>with type'write'and encryption status. - For each endpoint (excluding
_-prefixed): addquery_<name>with type'read'. - Always adds
subscribe(type'subscribe'),query_events(type'read'), andcreate_enclave(type'lifecycle').
import { sdkMethods } from '@enc-protocol/flow/sdk-gen'
const methods = sdkMethods('hello', helloInfra)
// [
// { name: 'submit_post', type: 'write', event: 'post', encrypted: false },
// { name: 'query_messages', type: 'read', method: 'GET', path: '/messages' },
// { name: 'subscribe', type: 'subscribe' },
// { name: 'query_events', type: 'read' },
// { name: 'create_enclave', type: 'lifecycle' },
// ]- Encryption detection: checks if any table in
tableMapthat maps to the event name is inflow.encrypt. Uses the mapped event name, not the table name. - If
infra.commandsorinfra.endpointsis missing/empty, only the fixed methods are returned.
5. flow-config.js -- FLOW_CONFIG + loadFlowConfig
Source: sdk-flow/flow-config.js.
Canonical data lives at config/apps/<id>/flow.json (one file per app).
The SDK ships an inline FLOW_CONFIG object mirroring those files, so
browser/published-SDK consumers can read the same data without the
filesystem. For live on-disk reads in Node, use the async
loadFlowConfig(appsDir) helper below.
FLOW_CONFIG
Exported constant object. Per-app protocol decisions, keyed by app ID.
Browser-safe (no fs at module scope).
Type: Record<string, FlowConfigEntry>
interface FlowConfigEntry {
tableMap?: Record<string, string> // UI table -> enclave event
encrypt?: string[] // Encrypted tables
derived?: string[] // Client-side view tables
sources?: {
protocol?: string[] // Protocol-backed tables
external?: string[] // External API-backed tables
}
enclaves?: string | string[] // Explicit enclave(s) or '*'
}| App | tableMap | encrypt | derived | sources | enclaves |
|---|---|---|---|---|---|
hello | messages->post, my_messages->post | -- | my_messages | -- | -- |
dm | messages->message, invites->invite | messages | contacts | -- | -- |
group | messages->message | messages | members, pending, group_info | -- | -- |
personal | public->public, private->private | private | -- | -- | -- |
timeline | posts->post, my_posts->post, likes->like, comments->comment, follows->follow | -- | 10 tables | -- | -- |
registry | reg_identities->reg_identity, reg_enclaves->reg_enclave, reg_nodes->reg_node | -- | 6 tables | -- | -- |
node | -- | -- | 5 tables | -- | '*' |
wallet | app_messages->message, app_threads->message, app_pending->invite | app_messages | 4 tables | protocol: 4, external: 4 | -- |
super | messages->message, moments->public | -- | 4 tables | -- | -- |
appstore | -- | -- | catalog, macros | -- | '*' |
import { FLOW_CONFIG } from '@enc-protocol/flow'
// Access a specific app's config
const helloConfig = FLOW_CONFIG.hello
// { tableMap: { messages: 'post', my_messages: 'post' }, derived: ['my_messages'] }
// Check if a table is encrypted
const isEncrypted = (FLOW_CONFIG.dm.encrypt || []).includes('messages') // true
// Check if a table is derived
const isDerived = (FLOW_CONFIG.timeline.derived || []).includes('my_posts') // true
// Resolve event name for a table
const eventName = FLOW_CONFIG.hello.tableMap.messages // 'post'loadFlowConfig(appsDir)
Async. Node-only. Reads <appsDir>/<id>/flow.json for every subdirectory
and returns the combined config object. node:fs is imported lazily so
browsers that never call this function don't trip Vite's fs externalization.
Signature: (appsDir: string) => Promise<Record<string, FlowConfigEntry>>
import { loadFlowConfig } from '@enc-protocol/flow'
import { join } from 'node:path'
const flows = await loadFlowConfig(join(process.cwd(), 'config/apps'))
// { hello: { tableMap: ..., derived: ... }, dm: { ... }, ... }6. derive-flow.mjs -- Flow Derivation
Source: sdk-flow/derive-flow.mjs
deriveFlow(app, v1, enclaves?) -> FlowDescriptor
Derive a protocol flow description from an app definition, its derived manifest (v1), and available enclave data.
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
app | object | required | App definition (from ui.json or ENC_APPS[]) |
v1 | AppManifest | required | Derived manifest from deriveManifest() |
enclaves | Record<string, EnclaveData> | {} | { Name: { manifest: {...} } } |
Returns: FlowDescriptor
interface FlowDescriptor {
$schema: 'https://enc-protocol.dev/schemas/app-flow-v0.json'
name: string
description: string
identity: { key: 'secp256k1' }
events: Record<string, { fields: Record<string, { type: string }> }>
encryption: Record<string, { _all: boolean, algorithm: string }>
flows: Record<string, FlowDef>
ingest: Record<string, IngestRule[]>
}
interface FlowDef {
params: string[]
steps: FlowStep[]
}
interface FlowStep {
if?: string
fail?: string
return?: boolean
submit?: { to_enclave: string, type: string, content: Record<string, string> }
on_error?: { fail: string }
http_get?: { url: string }
set?: string
then?: FlowStep[]
merge_feed?: string
}- Iterates
v1.data_types, skipping derived tables. - Resolves event name for each table via
tableMapor enclave customs. - Builds
eventsmap with field types. - For encrypted tables: determines encryption algorithm based on enclave participant model (1-party: XChaCha20-Poly1305, 2-party: Ratchet_DM, N-party: MLS-lite).
- Generates
submit_<event>flow for each event with validation steps. - Generates ingest rules for non-encrypted events: maps event fields to local state table rows.
- Generates
load_<readKey>flows for each SQL read inv1.sql.read.
- Check
tableMap[table]in flow config. - Search enclave customs for exact match (
event === table). - Search enclave customs for singular match (
event === table.replace(/s$/, '')). - Single-event heuristic: if enclave has exactly 1 non-lifecycle event, use it.
- Fallback:
table.replace(/s$/, '').
| Enclave Pattern | Algorithm |
|---|---|
| No MEMBER state, no moves | XChaCha20-Poly1305 |
| MEMBER state, few moves | Ratchet_DM |
| MEMBER state + many moves | MLS-lite |
import { deriveFlow, deriveManifest } from '@enc-protocol/flow'
const v1 = deriveManifest(helloApp, enclaveRegistry)
const flow = deriveFlow(helloApp, v1, enclaveRegistry)
// {
// $schema: 'https://enc-protocol.dev/schemas/app-flow-v0.json',
// name: 'Hello',
// identity: { key: 'secp256k1' },
// events: {
// post: { fields: { draft: { type: 'string' } } }
// },
// encryption: {},
// flows: {
// submit_post: {
// params: ['draft'],
// steps: [
// { if: '!$draft', fail: 'empty draft' },
// { submit: { to_enclave: '$myEnclave', type: 'post', content: { draft: '$draft' } } }
// ]
// }
// },
// ingest: {
// post: [{ add_post: { id: '$ev.id', timestamp: '$ev.timestamp', draft: '$content.draft', outgoing: '$ev.from === $me' } }]
// }
// }- Encrypted events skip ingest rule generation (can't index encrypted content).
- If multiple writes touch the same ingest table, the richest (most fields) ingest rule wins.
load_*flows are only generated ifv1.sql?.readexists.- Description falls back to
"${name} app."ifapp.descriptionis missing.
7. ui-analysis.mjs -- UI Derivation
Source: sdk-flow/ui-analysis.mjs
deriveUi(app, v1, enclaves?) -> UiDescriptor
Derive a structured UI tree from an algebraic app definition. Each page type is translated by a dedicated translator function.
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
app | object | required | App definition (from ui.json or ENC_APPS[]) |
v1 | AppManifest | required | Derived manifest |
enclaves | Record<string, EnclaveData> | {} | Enclave registry |
Returns: UiDescriptor
interface UiDescriptor {
$schema: 'https://enc-protocol.dev/schemas/app-ui-v0.json'
kit_version: '0.1.0'
name: string
description: string
state: Record<string, { type: string, default: string }>
pages: Record<string, PageDef>
}
interface PageDef {
header?: { title: string, subtitle?: string, back?: string | object[] }
body: ComponentNode[]
background?: string
}- Determines primary submit flow name from enclave customs (e.g.,
send_post). - Iterates app pages. Each page type dispatches to its translator:
gate->null(skipped)feed-> list + compose barchat-> message bubble list + chat inputcontacts-> contact row list + lookup barform-> form fields + submit buttondetail-> hero stack + comments + inputprofile-> avatar + bio + post listbuilder-> form + pending list + submitsearch-> search bar + filter tabs + results
- Collects state variables from
#bar.valueand#fields[].key. - Builds state schema with defaults.
Throws: If a page has an unsupported page type.
Example:import { deriveUi, deriveManifest } from '@enc-protocol/flow'
const v1 = deriveManifest(helloApp, enclaveRegistry)
const ui = deriveUi(helloApp, v1, enclaveRegistry)
// {
// $schema: 'https://enc-protocol.dev/schemas/app-ui-v0.json',
// kit_version: '0.1.0',
// name: 'Hello',
// state: {
// page: { type: 'string', default: 'feed' },
// draft: { type: 'string', default: '' }
// },
// pages: {
// feed: {
// header: { title: 'Feed', subtitle: '{{$runtime.messages.length}} posts' },
// body: [
// { type: 'list', from: '$runtime.messages', row: { type: 'PostCard', ... } },
// { type: 'ComposeBar', draft: '$state.draft', ... }
// ]
// },
// my_posts: { ... }
// }
// }- Feed pages with
/my_*paths setoutgoing: trueon all rows and add afilter: '$item.outgoing'to the list. - For owner-only pages, the runtime state key is un-prefixed:
my_messagesbecomesmessages(the canonical array). - Chat pages filter messages by
$state.chatWith.pub. - The
backaction on chat pages clearschatWithand navigates to contacts. - Tabs and filter nodes are only generated if the page data contains
#tabs.itemsor#filter.itemsarrays.
8. engine.mjs -- Enclave Resolution Engine
Source: sdk-flow/engine.mjs
The engine module is an alternative enclave resolver with a slightly
different API and scoring model. It provides profileEnclave,
analyzeApp, and resolve as a cohesive pipeline.
profileEnclave(name, manifest) -> EnclaveProfileV2 | null
Profile an enclave's access model. Similar to resolver.mjs:profileEnclave
but with additional computed fields.
| Param | Type | Default | Description |
|---|---|---|---|
name | string | required | Enclave name |
manifest | object | required | Enclave RBAC manifest |
Returns: EnclaveProfileV2 | null
interface EnclaveProfileV2 {
name: string
parties: '1' | '2' | 'N'
readerModel: 'public' | 'member' | 'owner'
ownerOnly: boolean // True if no public/member readers
events: Record<string, {
write: string[] // Operators that can write (non-dataview)
read: string[] // (always empty in current impl)
dataviewP: boolean
}>
writeEventCount: number // Events with at least one write operator
plaintextEventCount: number // Events with dataviewP or public reader
encryptedEventCount: number // writeEventCount - plaintextEventCount
stateCount: number
traitCount: number
}events[name].writeis an array of operator names (not boolean).- Includes
writeEventCount,plaintextEventCount,encryptedEventCount. - Includes
ownerOnlyboolean. - Includes
traitCount.
import { profileEnclave } from '@enc-protocol/flow/engine'
const profile = profileEnclave('DM', dmManifest)
// {
// name: 'DM',
// parties: '2',
// readerModel: 'owner',
// ownerOnly: true,
// events: {
// invite: { write: ['OUTSIDER'], read: [], dataviewP: false },
// message: { write: ['FRIEND'], read: [], dataviewP: false },
// sent: { write: ['OWNER'], read: [], dataviewP: false },
// rotate: { write: ['OWNER'], read: [], dataviewP: false }
// },
// writeEventCount: 4,
// plaintextEventCount: 0,
// encryptedEventCount: 4,
// stateCount: 3,
// traitCount: 0
// }analyzeApp(app) -> AppNeeds
Extract what the app needs from its UI config and encrypt declarations.
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
app | object | required | App definition (from ui.json or ENC_APPS[]) |
Returns: AppNeeds
interface AppNeeds {
id: string
writes: { table: string, encrypted: boolean }[]
readOnly: string[] // Tables read but never written
needsEncryptedWrite: boolean
needsPlaintextWrite: boolean
encryptedCount: number
plaintextCount: number
totalWrites: number
}- Collects encrypted table names from
app.encrypt. - Scans page
writeblocks for INSERT/UPDATE/DELETE operations. - Extracts tables (ignoring
_state). - Scans page
databindings for read tables (#node<table,@table). - Computes read-only tables (referenced but never written).
import { analyzeApp } from '@enc-protocol/flow/engine'
const needs = analyzeApp(dmApp)
// {
// id: 'dm',
// writes: [
// { table: 'invites', encrypted: false },
// { table: 'messages', encrypted: true }
// ],
// readOnly: ['profiles'],
// needsEncryptedWrite: true,
// needsPlaintextWrite: true,
// encryptedCount: 1,
// plaintextCount: 1,
// totalWrites: 2
// }- Pages with
page: 'gate'are skipped. - If
page.writecontains SQL strings (not arrays), parses with regexINSERT INTO (\w+). - Tables with name
_stateare always excluded.
resolve(app, enclaveRegistry) -> Resolution
Resolve enclaves for an app. Complete pipeline: analyze needs, profile enclaves, score, match, resolve cross-enclave reads.
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
app | object | required | App definition |
enclaveRegistry | Record<string, EnclaveData> | required | { Name: { manifest: {...} } } |
Returns: Resolution
interface Resolution {
appNeeds?: AppNeeds // Undefined if wildcard
primary: { name: string, score: number, profile: EnclaveProfileV2 } | null
crossEnclave: CrossEnclaveRead[]
enclaves: string[]
wildcard: boolean
scores: { name: string, score: number, profile: EnclaveProfileV2 }[] // Top 5
}- If
app.enclaves === '*', returns all enclaves withwildcard: true. - Calls
analyzeApp(app)for needs. - Profiles all enclaves.
- Scores each enclave (see section 6 of flow.md for scoring rules).
- Filters scores >
-Infinity, sorts descending. - Primary = highest score.
- Cross-enclave:
profiles->Registry.reg_identity. Other read-only tables: find best readable enclave with DataView P.
import { resolve } from '@enc-protocol/flow/engine'
const result = resolve(helloApp, enclaveRegistry)
// {
// appNeeds: { id: 'hello', writes: [{ table: 'messages', encrypted: false }], ... },
// primary: { name: 'Hello', score: 35, profile: { ... } },
// crossEnclave: [],
// enclaves: ['Hello'],
// wildcard: false,
// scores: [{ name: 'Hello', score: 35 }, ...]
// }9. resolve-enclaves.mjs -- Access Model Resolver
Source: sdk-flow/resolve-enclaves.mjs
A third resolver implementation focused purely on RBAC access model compatibility. No name matching, no field matching.
resolveEnclaves(app, enclaveRegistry?) -> EnclaveResolution
Resolve which enclaves an app should use based on access patterns.
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
app | object | required | App definition (from ui.json or ENC_APPS[]) |
enclaveRegistry | Record<string, EnclaveData> | {} | Enclave registry |
Returns: EnclaveResolution
interface EnclaveResolution {
primary: Map<string, { enclave: string, event: string }> // table -> assignment
crossEnclave: Map<string, { enclave: string, event: string }>
enclaves: string[]
wildcard: boolean
}- If
app.enclaves === '*', returns all enclaves withwildcard: true. - Analyzes write needs: scans page
writeblocks for tables. - Analyzes read needs: scans page
databindings for referenced tables. - Classifies each enclave: participant model, reader type, per-event access (canWrite, hasDataviewP).
- Scores each enclave:
- +10 per encrypted write need if enclave has owner/member reader.
- -5 per encrypted write need if enclave has public reader.
- +10 per plaintext write need if enclave has DataView P or public reader.
- +3 bonus if event count matches write count.
- +1 bonus if event count is within 1 of write count.
- Assigns best enclave to all write tables (round-robin event mapping).
- Resolves cross-enclave reads:
profiles->Registry.reg_identity.- Others: first non-primary enclave with DataView P.
import { resolveEnclaves } from '@enc-protocol/flow/resolve-enclaves'
const result = resolveEnclaves(helloApp, enclaveRegistry)
// {
// primary: Map { 'messages' => { enclave: 'Hello', event: 'post' } },
// crossEnclave: Map {},
// enclaves: ['Hello'],
// wildcard: false
// }
const dmResult = resolveEnclaves(dmApp, enclaveRegistry)
// {
// primary: Map {
// 'invites' => { enclave: 'DM', event: 'invite' },
// 'messages' => { enclave: 'DM', event: 'message' }
// },
// crossEnclave: Map { 'profiles' => { enclave: 'Registry', event: 'reg_identity' } },
// enclaves: ['DM', 'Registry'],
// wildcard: false
// }- Returns
Mapobjects (not plain objects) forprimaryandcrossEnclave. - Round-robin event mapping: if there are more write tables than writable events, later tables reuse events from the beginning.
- If no enclave scores above 0,
primaryis an empty Map.
10. index.mjs -- Re-exports
Source: sdk-flow/index.mjs
The package entry point re-exports all public APIs.
export { extractSchema } from './schema.mjs'
export { deriveManifest } from './manifest.mjs'
export { match, generate, profileEnclave } from './resolver.mjs'
export { deriveFlow } from './derive-flow.mjs'
export { createSDK } from './sdk-gen.mjs'
export { FLOW_CONFIG } from './flow-config.js'Not re-exported (import directly):
engine.mjs--profileEnclave,analyzeApp,resolveresolve-enclaves.mjs--resolveEnclavesui-analysis.mjs--deriveUisdk-gen.mjs--sdkMethods
11. lib/generic-dataview.js -- GenericDataView Class
Source: lib/generic-dataview.js
new GenericDataView(sql, infra)
Create a DataView instance from an infra.json configuration.
Constructor Parameters:| Param | Type | Description |
|---|---|---|
sql | object | SQL executor with exec(sql, ...params) method. Compatible with D1, sql.js, or the in-memory implementation. |
infra | object | Parsed infra.json content. Must have schema.reads, flow_config.tableMap. |
| Property | Type | Description |
|---|---|---|
sql | object | The SQL executor |
infra | object | The infra.json object |
appId | string | App identifier from infra.manifest.id or infra.app |
tableMap | Record<string, string> | UI table -> event type |
eventToTable | Record<string, string[]> | Reverse: event type -> UI tables |
tables | string[] | All table names from infra.schema.reads |
- Stores references to
sqlandinfra. - Extracts
appIdfrominfra.manifest.idor lowercaseinfra.app. - Copies
tableMapfrominfra.flow_config.tableMap. - Builds reverse mapping
eventToTable. - Calls
_initTables()to create SQL tables.
For each table in infra.schema.reads:
- Creates a SQL table with columns from
reads[table].fields(all TEXT). - Adds
_rowid INTEGER PRIMARY KEY AUTOINCREMENT. - Adds
_event_id TEXTand_timestamp INTEGER.
Also creates stub tables for tableMap keys not in reads.
push(event) -> void
Process a push event from the protocol node.
Parameters:| Param | Type | Description |
|---|---|---|
event | object | Push event: { type, from, content, timestamp, id, enclave, seq } |
| Field | Type | Description |
|---|---|---|
type | string | Event type (e.g., 'post'). Maps via eventToTable. |
from | string | Author public key |
content | string | object | JSON content string or parsed object |
timestamp | number | Unix timestamp |
id | string | Event ID |
enclave | string | Source enclave name |
seq | number | Sequence number |
- Returns immediately if event is falsy or has no
type. - Looks up target tables:
eventToTable[event.type]or[event.type]. - Parses
content(JSON string to object if needed). - For each target table:
a. Queries
PRAGMA table_infofor existing columns. b. Merges content fields withfromfield. c. Adds missing columns viaALTER TABLE. d. Inserts row with_event_idand_timestamp. - Silently catches errors (e.g., missing tables, schema mismatches).
const dv = new GenericDataView(sql, helloInfra)
dv.push({
type: 'post',
from: '79be667ef9dcbbac',
content: '{"body":"Hello World","from":"79be667ef9dcbbac"}',
timestamp: 1713800000,
id: 'evt_001',
enclave: 'Hello',
seq: 1,
})
// Row inserted into 'messages' table (and 'my_messages' if mapped)- If
event.contentis already an object, uses it directly. - If
event.contentis an unparseable string, uses{}. - Columns not in the original schema are added dynamically via ALTER TABLE.
- If ALTER TABLE fails (column already exists), error is silently caught.
- If INSERT fails (schema mismatch), error is silently caught.
query(table, opts?) -> object[]
Query a table for rows.
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
table | string | required | Table name to query |
opts | object | {} | Query options |
opts.limit | number | 100 | Maximum rows to return |
opts.orderBy | string | '_rowid DESC' | ORDER BY clause |
Returns: Array of row objects. Empty array on error.
Example:const rows = dv.query('messages')
// [{ _rowid: 2, body: 'World', from: '79be...', _event_id: 'evt_002', _timestamp: 1713800001 },
// { _rowid: 1, body: 'Hello', from: '79be...', _event_id: 'evt_001', _timestamp: 1713800000 }]
const recent = dv.query('messages', { limit: 10, orderBy: '_timestamp DESC' })- Returns
[]if the table does not exist. - Returns
[]on any SQL error.
getTables() -> string[]
Get all user-facing table names.
Returns: Array of table names (excluding those starting with _).
dv.getTables() // ['messages']handleRequest(path, method) -> Response
Handle an HTTP request. Used by the DataView server and CF Worker.
Parameters:| Param | Type | Description |
|---|---|---|
path | string | URL path (e.g., '/messages', '/tables') |
method | string | HTTP method ('GET', 'POST', 'OPTIONS') |
Returns: Response object:
interface Response {
status: number
headers: Record<string, string> // Includes CORS headers
body: string | null // JSON string
}| Method | Path | Status | Body |
|---|---|---|---|
| OPTIONS | * | 204 | null |
| GET | /tables | 200 | { "tables": [...] } |
| GET | /<table> | 200 | { "<table>": [rows...] } |
| POST | /push | -- | Returns string 'push' (caller handles body) |
| * | * | 404 | { "error": "not_found" } |
const res = dv.handleRequest('/messages', 'GET')
// { status: 200, headers: { 'Access-Control-Allow-Origin': '*', ... },
// body: '{"messages":[...]}' }
const cors = dv.handleRequest('/anything', 'OPTIONS')
// { status: 204, headers: { ... }, body: null }createDataViewHandler(infra) -> CFWorkerHandler
Create a Cloudflare Worker-compatible handler from an infra.json.
Parameters:| Param | Type | Description |
|---|---|---|
infra | object | Parsed infra.json |
Returns: Object with async fetch(request, env) method.
- Lazily initializes
GenericDataViewon first request (stored onenv._dv). - POST
/push: parses JSON body, supports single event, array, or{ events: [...] }wrapper. Returns{ ok: true, applied: N }. - All other paths: delegates to
handleRequest().
import { createDataViewHandler } from './lib/generic-dataview.js'
const handler = createDataViewHandler(helloInfra)
// Use as CF Worker:
// export default handler12. lib/dataview-server.js -- DataView HTTP Server
Source: lib/dataview-server.js
Standalone Node.js HTTP server for local development. Wraps
GenericDataView with an in-memory SQL executor.
Usage
node lib/dataview-server.js <app> <port>
node lib/dataview-server.js hello 8801In-Memory SQL Executor
The server creates createMemSQL() which implements a minimal SQL subset:
| SQL Pattern | Support |
|---|---|
CREATE TABLE IF NOT EXISTS ... | Creates table with columns |
ALTER TABLE ... ADD COLUMN ... | Adds column dynamically |
INSERT INTO ... VALUES ... | Appends row with auto-increment _rowid |
SELECT * FROM ... ORDER BY ... LIMIT ... | Queries with DESC support |
PRAGMA table_info(...) | Returns column metadata |
SELECT ... FROM sqlite_master ... | Returns table names |
Endpoints
| Method | Path | Response |
|---|---|---|
| GET | / or /health | { "ok": true, "app": "<appId>", "tables": [...] } |
| GET | /tables | { "tables": [...] } |
| GET | /<table> | { "<table>": [rows...] } |
| POST | /push | Accepts JSON, returns { "ok": true, "applied": N } |
| OPTIONS | * | CORS preflight (204) |
| * | * | 404 { "error": "not_found" } |
Push Format
POST /push accepts:
- Single event:
{ type: 'post', from: '...', content: '...' } - Array:
[event1, event2, ...] - Wrapper:
{ events: [event1, event2, ...] }
13. lib/workflow-executor.mjs -- Workflow Execution
Source: lib/workflow-executor.mjs
executeWorkflow(opts) -> WorkflowResult
Execute a UI-Kit workflow against a real protocol node.
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
opts.app | object | required | App definition (from ui.json or ENC_APPS[]) |
opts.steps | object[] | required | Workflow steps [{ do, see }] |
opts.flowConfig | object | required | Flow config for this app |
opts.runAlgebraStep | function | required | (app, state, doAction) => newState |
opts.submitCommit | function | required | (eventType, content) => receipt |
opts.executeMock | function | undefined | (mockName, mock, state) => commitCount |
Returns: WorkflowResult
interface WorkflowResult {
commits: number // Total commits submitted
steps: { step: object, state: object }[] // Per-step results
finalState: object // Final state after all steps
}For each step:
- If
step.donames a mock inapp.mocks:- Calls
executeMock(doAction, mock, state)if provided. - Adds returned count to
commits.
- Calls
- Calls
runAlgebraStep(app, state, doAction)to compute new state. - Checks if the current page's
writeblock matches the action trigger:- Extracts table from SQL (
INSERT INTO <table>). - Skips derived tables.
- Maps table to event type via
tableMap. - Builds content from current state scalars.
- Calls
submitCommit(eventType, content). - Increments
commitson success.
- Extracts table from SQL (
- Records
{ step, state }in results.
import { executeWorkflow } from './lib/workflow-executor.mjs'
const result = executeWorkflow({
app: helloApp,
steps: helloApp.tests[0],
flowConfig: FLOW_CONFIG.hello,
runAlgebraStep: (app, state, action) => runStep(app, state, { do: action }),
submitCommit: (eventType, content) => {
return node.processCommit(mkCommit(enclaveId, pub, eventType, content))
},
executeMock: (name, mock, state) => {
return executeMockAsCommits(mock, FLOW_CONFIG.hello.tableMap, submitFn)
},
})
// { commits: 3, steps: [...], finalState: { ... } }- Errors in
executeMockandsubmitCommitare silently caught (try/catch). - If
runAlgebraStepreturns falsy, state is unchanged. - Write detection uses regex on SQL strings, which may miss complex SQL.
- Content is built from all scalar values in state, which may include irrelevant fields.
executeMockAsCommits(mock, tableMap, submitFn) -> number
Convert a mock operation to real protocol commit(s).
Parameters:| Param | Type | Default | Description |
|---|---|---|---|
mock | object | required | Mock definition (see below) |
tableMap | Record<string, string> | required | UI table -> event type mapping |
submitFn | function | required | (eventType, content) => receipt |
Returns: Number of commits submitted.
Mock Formats:// Single insert
{ op: 'insert', table: 'messages', values: { body: 'hello', from: '79be...' } }
// Compound operations
{ ops: [
{ op: 'insert', table: 'messages', values: { body: 'hi' } },
{ op: 'set', values: { path: '/feed' } },
] }
// State-only operations (produce zero commits)
{ op: 'set', values: { path: '/feed' } }
{ op: 'delete', table: 'contacts', where: { pub: 'abc' } }
{ op: 'update', table: 'users', where: { pub: 'abc' }, set: { role: 'admin' } }- If
mock.opsexists, recursively process each operation. - If
mock.op === 'insert'andmock.tableis set:- Map table to event type:
tableMap[mock.table]ormock.table.replace(/s$/, ''). - Call
submitFn(eventType, mock.values). - Increment count on success.
- Map table to event type:
- For
set,delete,updateops: return 0 (state-only, no commits).
import { executeMockAsCommits } from './lib/workflow-executor.mjs'
const count = executeMockAsCommits(
{ op: 'insert', table: 'messages', values: { body: 'hello' } },
{ messages: 'post' },
(eventType, content) => node.processCommit(mkCommit(eid, pub, eventType, content))
)
// count: 1 (submitted 'post' event with { body: 'hello' })
const count2 = executeMockAsCommits(
{ ops: [
{ op: 'insert', table: 'messages', values: { body: 'msg1' } },
{ op: 'insert', table: 'messages', values: { body: 'msg2' } },
{ op: 'set', values: { path: '/feed' } },
] },
{ messages: 'post' },
submitFn
)
// count2: 2 (two inserts, set is state-only)- If
submitFnthrows, the error is caught and the count is not incremented. - If
submitFnreturns an object with anerrorproperty, the count is not incremented. - Table name singularization fallback:
messages->message. - Compound ops are processed sequentially; errors in one do not stop others.