Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/guides/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ node packages/wesley-host-node/bin/wesley.mjs cert-verify --in .wesley/SHIPME.md

Evidence bundle lives under `.wesley/` and is validated against JSON Schemas in `schemas/`.

### Experimental: Operation Documents (QIR)

You can start placing GraphQL operation documents (queries) in an `ops/` folder and pass `--ops ops/` to `generate`. In this MVP, the flag is a no‑op validator; future versions compile these operations to SQL via the QIR pipeline proposed in docs/drafts/2025-10-03-rfc-query-ops-to-sql-qir.md.

Example:

```bash
node packages/wesley-host-node/bin/wesley.mjs generate \
--schema schema.graphql \
--ops ops/ \
--emit-bundle
```

## HOLMES (investigate/verify/predict)

From the repo root:
Expand All @@ -64,4 +77,3 @@ bash demo/blade/run.sh
- Use canonical directives (`@wes_table`, `@wes_pk`, `@wes_fk`, `@wes_index`, `@wes_default`, `@wes_tenant`).
- Aliases (e.g., `@table`, `@pk`) are accepted but deprecated.
- In CI, use the same entrypoint: `node packages/wesley-host-node/bin/wesley.mjs`.

30 changes: 30 additions & 0 deletions packages/wesley-cli/src/commands/generate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class GeneratePipelineCommand extends WesleyCommand {
return cmd
.option('-s, --schema <path>', 'GraphQL schema file. Use "-" for stdin', 'schema.graphql')
.option('--stdin', 'Read schema from stdin (alias for --schema -)')
.option('--ops <dir>', 'Experimental: directory with GraphQL operation documents (queries) to validate', 'ops')
.option('--emit-bundle', 'Emit .wesley/ evidence bundle')
.option('--supabase', 'Enable Supabase features (RLS tests)')
.option('--out-dir <dir>', 'Output directory', 'out')
Expand Down Expand Up @@ -47,6 +48,35 @@ export class GeneratePipelineCommand extends WesleyCommand {

logger.info({ schema: schemaPath }, 'Parsing schema...');

// Experimental ops: validate presence of operation documents if --ops provided
if (options.ops) {
try {
const opsDir = options.ops;
const fs = this.ctx.fs;
const exists = await fs.exists(opsDir);
if (!exists) {
logger.warn({ opsDir }, 'Experimental --ops: directory not found; skipping ops validation');
} else {
// naive scan for .graphql files
// We avoid Node-specific APIs: rely on adapter for a minimal read attempt
// Try common filenames
const candidates = ['queries.graphql', 'operations.graphql'];
let found = false;
for (const c of candidates) {
const p = await fs.join(opsDir, c);
if (await fs.exists(p)) { found = true; break; }
}
if (!found) {
logger.info({ opsDir }, 'Experimental --ops: no known op files found; continue (no-op)');
} else {
logger.info({ opsDir }, 'Experimental --ops detected; future versions will compile operations to SQL (QIR).');
}
}
} catch (e) {
logger.warn('Experimental --ops validation failed: ' + (e?.message || e));
}
}

// Use injected generators and writer
const { generators, writer, planner, runner } = this.ctx;

Expand Down
92 changes: 92 additions & 0 deletions packages/wesley-core/src/domain/qir/Nodes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* QIR (Query IR) Nodes — Minimal skeleton for operation→SQL compilation
* Pure data structures (no Node built-ins). Kept intentionally small for MVP.
*/

export class QueryPlan {
constructor(root, projection, { orderBy = [], limit = null, offset = null } = {}) {
this.root = root; // RelationNode
this.projection = projection; // Projection
this.orderBy = orderBy; // OrderBy[]
this.limit = limit; // number|null
this.offset = offset; // number|null
}
}

export class TableNode {
constructor(table, alias) {
this.kind = 'Table';
this.table = table;
this.alias = alias;
}
}

export class JoinNode {
constructor(left, right, joinType, onPredicate) {
this.kind = 'Join';
this.left = left; // RelationNode
this.right = right; // RelationNode
this.joinType = joinType || 'INNER'; // INNER|LEFT
this.on = onPredicate; // Predicate
this.alias = (right && right.alias) || undefined;
}
}

export class LateralNode {
constructor(plan, alias) {
this.kind = 'Lateral';
this.plan = plan; // QueryPlan
this.alias = alias;
}
}

export class SubqueryNode {
constructor(plan, alias) {
this.kind = 'Subquery';
this.plan = plan; // QueryPlan
this.alias = alias;
}
}

export class Projection {
constructor(items = []) {
this.items = items; // ProjectionItem[]
}
add(item) { this.items.push(item); return this; }
}

export class ProjectionItem {
constructor(alias, expr) {
this.alias = alias; // string
this.expr = expr; // Expr
}
}

// Exprs
export class ColumnRef { constructor(table, column) { this.kind = 'ColumnRef'; this.table = table; this.column = column; } }
export class ParamRef { constructor(name, special) { this.kind = 'ParamRef'; this.name = name; this.special = special || null; } }
export class Literal { constructor(value, type) { this.kind = 'Literal'; this.value = value; this.type = type || null; } }
export class FuncCall { constructor(name, args = []) { this.kind = 'FuncCall'; this.name = name; this.args = args; } }
export class ScalarSubquery { constructor(plan) { this.kind = 'ScalarSubquery'; this.plan = plan; } }
export class JsonBuildObject { constructor(fields) { this.kind = 'JsonBuildObject'; this.fields = fields; } }
export class JsonAgg { constructor(value, orderBy = []) { this.kind = 'JsonAgg'; this.value = value; this.orderBy = orderBy; } }

// Predicates
export class Predicate {
static compare(left, op, right) { return { kind: 'Compare', left, op, right }; }
static and(left, right) { return { kind: 'And', left, right }; }
static or(left, right) { return { kind: 'Or', left, right }; }
static not(inner) { return { kind: 'Not', left: inner }; }
static exists(subqueryPlan) { return { kind: 'Exists', subquery: subqueryPlan }; }
static isNull(expr) { return { kind: 'IsNull', left: expr }; }
static isNotNull(expr) { return { kind: 'IsNotNull', left: expr }; }
}

export class OrderBy { constructor(expr, direction = 'asc', nulls = null) { this.expr = expr; this.direction = direction; this.nulls = nulls; } }

// Simple alias generator for determinism in tests (not used by runtime yet)
export class AliasAllocator {
constructor(prefix = 't') { this.prefix = prefix; this.count = 0; }
next() { return `${this.prefix}${this.count++}`; }
}

2 changes: 2 additions & 0 deletions packages/wesley-core/src/domain/qir/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './Nodes.mjs';

41 changes: 41 additions & 0 deletions packages/wesley-core/test/unit/qir-nodes.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';

import {
QueryPlan,
TableNode,
Projection,
ProjectionItem,
ColumnRef,
AliasAllocator,
Predicate,
OrderBy
} from '../../src/domain/qir/Nodes.mjs';

test('QIR: builds a minimal plan with deterministic aliases', () => {
const aa = new AliasAllocator('t');
const root = new TableNode('organization', aa.next());
const proj = new Projection([
new ProjectionItem('id', new ColumnRef(root.alias, 'id')),
new ProjectionItem('name', new ColumnRef(root.alias, 'name'))
]);
const plan = new QueryPlan(root, proj, {
orderBy: [new OrderBy(new ColumnRef(root.alias, 'name'), 'asc')],
limit: 10,
offset: 0
});

assert.equal(root.alias, 't0');
assert.equal(plan.projection.items.length, 2);
assert.equal(plan.orderBy.length, 1);
assert.equal(plan.limit, 10);
assert.equal(plan.offset, 0);
});

test('QIR: simple EXISTS predicate shape', () => {
const childPlan = new QueryPlan(new TableNode('membership', 'm0'), new Projection());
const exists = Predicate.exists(childPlan);
assert.equal(exists.kind, 'Exists');
assert.ok(exists.subquery instanceof QueryPlan);
});

26 changes: 25 additions & 1 deletion packages/wesley-host-node/src/adapters/ConfigLoader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ export class ConfigLoader {
securityDefiner: true, volatility: 'VOLATILE'
}
},
// Experimental operation documents (QIR) settings
ops: {
folder: 'ops', // location of .graphql operation docs
target: 'function', // 'function' | 'view'
nestedList: { requireLimit: true, requireOrderBy: true },
rlsReliance: 'prefer', // 'prefer' (RLS-first) | 'derive' (inject from directives if absent)
},
naming: {
strategy: 'snake_case', tablePrefix: '',
indexPrefix: 'idx_', constraintPrefix: '', policyPrefix: 'policy_'
Expand Down Expand Up @@ -169,6 +176,23 @@ export class ConfigLoader {
if (!validParamStrategies.includes(config.generation.rpc.paramStrategy)) {
throw new Error(`Invalid RPC param strategy: ${config.generation.rpc.paramStrategy}`);
}

// Validate ops section
const ops = config.ops || {};
const validTargets = ['function', 'view'];
if (ops.target && !validTargets.includes(ops.target)) {
throw new Error(`Invalid ops.target: ${ops.target}. Expected 'function' or 'view'`);
}
if (ops.nestedList) {
const { requireLimit, requireOrderBy } = ops.nestedList;
if (typeof requireLimit !== 'boolean' || typeof requireOrderBy !== 'boolean') {
throw new Error('ops.nestedList.{requireLimit,requireOrderBy} must be boolean');
}
}
const validRls = ['prefer', 'derive'];
if (ops.rlsReliance && !validRls.includes(ops.rlsReliance)) {
throw new Error(`Invalid ops.rlsReliance: ${ops.rlsReliance}. Expected 'prefer' or 'derive'`);
}
}

/**
Expand Down Expand Up @@ -247,4 +271,4 @@ export class ConfigLoader {
}

// Export singleton
export const configLoader = new ConfigLoader();
export const configLoader = new ConfigLoader();
Loading