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:
@@ -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
|
||||
}`,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user