Fix Review Issues (#65, #66, #69)

Bug #65: Unify JSONata bindings between validateExpression and foldProjection
- Modified defs.ts validateExpression to use bindings (, , )
- Updated all test expressions to use bindings syntax for consistency
- Both validation and execution now use the same bindings approach

Bug #66: Include sources in projection hash calculation
- Extended calculateProjectionHash to include sources array
- Prevents hash collisions when only expression changes
- Ensures projection_def_sources foreign key constraints work correctly

Bug #69: Export new modules from index.ts
- Added exports for defs.js and projection-engine.js
- External packages can now import definitions and projection engine

All tests pass (225/225) with added test coverage for the fixes.
This commit is contained in:
2026-04-15 04:04:42 +00:00
parent 263803e2e0
commit 27ae4bb17c
4 changed files with 244 additions and 18 deletions
+9 -9
View File
@@ -127,11 +127,11 @@ describe('Definition Layer', () => {
sources: [
{
eventKind: 'UserCreated',
expression: '{ "count": state.count + 1 }',
expression: '{ "count": $state.count + 1 }',
},
{
eventKind: 'UserDeleted',
expression: '{ "count": state.count - 1 }',
expression: '{ "count": $state.count - 1 }',
},
],
codeRev: 'v1.0.0',
@@ -233,7 +233,7 @@ describe('Definition Layer', () => {
it('7. validates JSONata expressions during registration', async () => {
const validResult = await validateExpression({
expression: '{ "count": state.count + 1 }',
expression: '{ "count": $state.count + 1 }',
initialValue: { count: 5 },
mockEvent: { kind: 'UserCreated', key: 'u1', data: {} },
});
@@ -245,9 +245,9 @@ describe('Definition Layer', () => {
// Test more complex expression
const complexResult = await validateExpression({
expression: `
$count(event.kind = 'UserCreated') > 0 ?
{ "count": state.count + 1, "lastUser": event.key } :
state
$count($event.kind = 'UserCreated') > 0 ?
{ "count": $state.count + 1, "lastUser": $event.key } :
$state
`,
initialValue: { count: 0, lastUser: null },
mockEvent: {
@@ -265,7 +265,7 @@ describe('Definition Layer', () => {
it('8. rejects invalid JSONata expressions', async () => {
const invalidResult = await validateExpression({
expression: '{ "count": state.count + }', // Syntax error
expression: '{ "count": $state.count + }', // Syntax error
initialValue: { count: 0 },
mockEvent: { kind: 'test', data: {} },
});
@@ -329,8 +329,8 @@ describe('Definition Layer', () => {
{
eventKind: 'InvoiceCreated',
expression: `{
"totalAmount": state.totalAmount + event.data.amount,
"invoiceCount": state.invoiceCount + 1
"totalAmount": $state.totalAmount + $event.data.amount,
"invoiceCount": $state.invoiceCount + 1
}`,
},
],
+22 -9
View File
@@ -135,13 +135,24 @@ function calculateProjectionHash(
params?: any,
valueSchema?: any,
initialValue?: any,
sources?: Array<{
eventKind: string;
eventKey?: string;
expression: string;
}>,
): string {
const content =
name +
JSON.stringify(params || null) +
JSON.stringify(valueSchema || null) +
JSON.stringify(initialValue);
return createHash('sha256').update(content).digest('hex');
const hashInput = JSON.stringify({
name,
initialValue,
params: params || null,
valueSchema: valueSchema || null,
sources: sources ? sources.map(s => ({
eventKind: s.eventKind,
eventKey: s.eventKey,
expression: s.expression
})) : null
});
return createHash('sha256').update(hashInput).digest('hex');
}
// ── Object Definitions ─────────────────────────────────────────
@@ -343,6 +354,7 @@ export async function registerProjectionDef(opts: {
opts.params,
opts.valueSchema,
opts.initialValue,
opts.sources,
);
const createdAt = Date.now();
@@ -478,14 +490,15 @@ export async function validateExpression(opts: {
try {
const expr = jsonata(opts.expression);
// Test context: { state: initialValue, event: mockEvent, params: {} }
const context = {
// Test bindings: { state: initialValue, event: mockEvent, params: {} }
const testData = {}; // empty data
const testBindings = {
state: opts.initialValue,
event: opts.mockEvent,
params: {},
};
const result = await expr.evaluate(context);
const result = await expr.evaluate(testData, testBindings);
return {
valid: true,
+6
View File
@@ -480,3 +480,9 @@ export * from './rules/index.js';
// ── Executors ─────────────────────────────────────────────────
export * from './executors/index.js';
// ── Definition Layer ────────────────────────────────────────────
export * from './defs.js';
// ── Projection Engine ───────────────────────────────────────────
export * from './projection-engine.js';
+207
View File
@@ -0,0 +1,207 @@
/**
* Tests for Bug Fixes #65, #66, #69
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import {
registerProjectionDef,
validateExpression,
closeDefs,
initDefs,
} from './defs.js';
import { foldProjection } from './projection-engine.js';
import { Database } from 'bun:sqlite';
import { mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
// Helper function to insert test events (copied from projection-engine.test.ts)
function insertTestEvent(
db: Database,
opts: {
kind: string;
key?: string;
payload?: any;
occurredAt?: number;
codeRev?: string;
},
): string {
// Simple ID generation for tests
const id = Date.now().toString() + Math.random().toString(36).substring(2);
const stmt = db.prepare(`
INSERT INTO events (id, occurred_at, kind, key, hash, code_rev, meta)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
id,
opts.occurredAt || Date.now(),
opts.kind,
opts.key || null,
'test-hash',
opts.codeRev || 'test',
JSON.stringify(opts.payload || {}),
);
return id;
}
describe('Review Fixes', () => {
let scopeDb: Database;
const testDir = '/tmp/pulse-review-fixes-test';
const systemDbPath = testDir + '/system.db';
const scopeDbPath = testDir + '/test-scope.db';
beforeEach(() => {
// Clean up any existing test data
try {
rmSync(testDir, { recursive: true, force: true });
} catch {}
// Create test directory
mkdirSync(testDir, { recursive: true });
// Initialize definitions layer
initDefs(systemDbPath);
// Create scope database
scopeDb = new Database(scopeDbPath, { create: true });
scopeDb.exec('PRAGMA journal_mode = WAL');
scopeDb.exec(`
CREATE TABLE IF NOT EXISTS events (
id TEXT NOT NULL PRIMARY KEY,
occurred_at INTEGER NOT NULL,
kind TEXT NOT NULL,
key TEXT,
hash TEXT NOT NULL,
code_rev TEXT NOT NULL,
meta TEXT
)
`);
scopeDb.exec(`
CREATE TABLE IF NOT EXISTS projections (
name TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL,
last_event_id TEXT NOT NULL,
code_rev TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`);
});
afterEach(() => {
scopeDb?.close();
closeDefs();
try {
rmSync(testDir, { recursive: true, force: true });
} catch {}
});
// ── Bug #65: JSONata validate vs fold binding consistency ─────
it('Bug #65: validateExpression and foldProjection both use bindings syntax', async () => {
const expression = '$state.count + $event.payload.increment';
// Test validateExpression with bindings syntax
const validation = await validateExpression({
expression,
initialValue: { count: 5 },
mockEvent: {
kind: 'counter-event',
key: 'test',
payload: { increment: 3 },
},
});
expect(validation.valid).toBe(true);
expect(validation.result).toBe(8); // 5 + 3
// Test that the same expression works in foldProjection
const projDef = await registerProjectionDef({
name: 'test-counter',
initialValue: { count: 5 },
sources: [
{
eventKind: 'counter-event',
expression: '{ "count": ' + expression + ' }',
},
],
codeRev: 'test-v1',
});
// Add a test event to scope
insertTestEvent(scopeDb, {
kind: 'counter-event',
key: 'test-key',
payload: { increment: 3 },
});
// Fold the projection
const result = await foldProjection(scopeDb, 'test-scope', 'test-counter', 'test-v1');
expect(result.value).toEqual({ count: 8 }); // Same result as validation
});
// ── Bug #66: projection hash includes sources ────────────────
it('Bug #66: projection hash includes sources to prevent conflicts', async () => {
const baseDef = {
name: 'hash-test',
initialValue: { value: 0 },
params: { currency: 'USD' },
valueSchema: { type: 'object' },
codeRev: 'hash-v1',
};
// Register projection with first expression
const proj1 = await registerProjectionDef({
...baseDef,
sources: [
{
eventKind: 'test-event',
expression: '$state.value + 1',
},
],
});
// Register projection with same name/params but different expression (different codeRev to avoid conflict)
const proj2 = await registerProjectionDef({
...baseDef,
codeRev: 'hash-v2', // Different codeRev
sources: [
{
eventKind: 'test-event',
expression: '$state.value * 2',
},
],
});
// Should have different hashes because sources are different
expect(proj1.hash).not.toBe(proj2.hash);
// But same name
expect(proj1.name).toBe(proj2.name);
});
it('Bug #66: identical projections have same hash', async () => {
const projectionSpec = {
name: 'identical-test',
initialValue: { count: 0 },
sources: [
{
eventKind: 'increment',
expression: '$state.count + 1',
},
],
codeRev: 'identical-v1',
};
const proj1 = await registerProjectionDef(projectionSpec);
// Re-register the exact same projection should get same hash
const proj2 = await registerProjectionDef({
...projectionSpec,
codeRev: 'identical-v2', // Different codeRev for second registration
});
// Different codeRev but same everything else should produce same hash
expect(proj1.hash).toBe(proj2.hash);
});
});