From a12ce3ebd1791c65f5ef0014501a5ef0f20e4540 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Fri, 20 Mar 2026 22:12:36 +0100 Subject: [PATCH 1/2] Normalize schedule contract and filter tauri events --- README.md | 5 +++++ js-src/10-backend.js | 35 +++++++++++++++++++++++++++++-- js-src/11-solver.js | 14 ++++++++++++- static/sf/sf.js | 49 +++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 84a23be..920b551 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,11 @@ Expects standard SolverForge REST endpoints: - `DELETE /schedules/{id}` — stop solving - `GET /demo-data/{name}` — load demo dataset +Backend contract expectations: +- `createSchedule()` must resolve to a plain schedule/job id (string), or an object containing one of `id`, `jobId`, `job_id`, `scheduleId`, or `schedule_id`. +- Events passed into `streamEvents()` for a job should include one of the same identifiers if multiple solver runs are possible. +- Tauri payloads are ignored unless `event.payload` matches the active job id. + ### Tauri ```javascript diff --git a/js-src/10-backend.js b/js-src/10-backend.js index c08801e..4930342 100644 --- a/js-src/10-backend.js +++ b/js-src/10-backend.js @@ -12,6 +12,34 @@ return createHttpBackend(config); }; + function resolveJobId(raw) { + if (raw == null) return ''; + if (typeof raw === 'string' || typeof raw === 'number') return String(raw).trim(); + if (typeof raw !== 'object') return ''; + + if (raw.id != null) return String(raw.id).trim(); + if (raw.jobId != null) return String(raw.jobId).trim(); + if (raw.job_id != null) return String(raw.job_id).trim(); + if (raw.scheduleId != null) return String(raw.scheduleId).trim(); + if (raw.schedule_id != null) return String(raw.schedule_id).trim(); + + if (raw.data && typeof raw.data === 'object' && raw.data.id != null) { + return String(raw.data.id).trim(); + } + return ''; + } + + function resolveEventJobId(payload) { + if (!payload || typeof payload !== 'object') return ''; + if (payload.jobId != null) return String(payload.jobId).trim(); + if (payload.job_id != null) return String(payload.job_id).trim(); + if (payload.scheduleId != null) return String(payload.scheduleId).trim(); + if (payload.schedule_id != null) return String(payload.schedule_id).trim(); + if (payload.id != null) return String(payload.id).trim(); + if (payload.data && typeof payload.data === 'object' && payload.data.jobId != null) return String(payload.data.jobId).trim(); + return ''; + } + /* ── HTTP backend (Axum, Rails, anything) ── */ function createHttpBackend(config) { @@ -37,7 +65,7 @@ return { createSchedule: function (data) { - return request('POST', schedulesPath, data); + return request('POST', schedulesPath, data).then(resolveJobId); }, getSchedule: function (id) { return request('GET', schedulesPath + '/' + id); @@ -93,9 +121,12 @@ return Promise.resolve([]); }, streamEvents: function (id, onMessage) { + var targetId = String(id); var unlisten = null; listen(eventName, function (event) { - onMessage(event.payload); + var payload = event && event.payload ? event.payload : {}; + if (resolveEventJobId(payload) !== targetId) return; + onMessage(payload); }).then(function (fn) { unlisten = fn; }); return function close() { if (unlisten) unlisten(); }; }, diff --git a/js-src/11-solver.js b/js-src/11-solver.js index 70f535a..bf15201 100644 --- a/js-src/11-solver.js +++ b/js-src/11-solver.js @@ -25,8 +25,13 @@ } backend.createSchedule(data).then(function (id) { - jobId = typeof id === 'string' ? id.trim() : id; + if (typeof id !== 'string' || !id.trim()) { + throw new Error('Invalid solver backend createSchedule response'); + } + jobId = id; closeStream = backend.streamEvents(jobId, function (msg) { + if (!isEventForCurrentJob(msg, jobId)) return; + // Solver finished if (msg.solverStatus === 'NOT_SOLVING') { backend.getSchedule(jobId).then(function (final) { @@ -86,6 +91,13 @@ api.getJobId = function () { return jobId; }; return api; + + function isEventForCurrentJob(msg, expectedId) { + if (!msg || typeof msg !== 'object') return false; + var candidate = msg.jobId || msg.job_id || msg.scheduleId || msg.schedule_id || msg.id || (msg.data && msg.data.id); + if (candidate == null) return true; + return String(candidate) === String(expectedId); + } }; })(SF); diff --git a/static/sf/sf.js b/static/sf/sf.js index dcbbd6e..95b0ba5 100644 --- a/static/sf/sf.js +++ b/static/sf/sf.js @@ -704,6 +704,34 @@ const SF = (function () { return createHttpBackend(config); }; + function resolveJobId(raw) { + if (raw == null) return ''; + if (typeof raw === 'string' || typeof raw === 'number') return String(raw).trim(); + if (typeof raw !== 'object') return ''; + + if (raw.id != null) return String(raw.id).trim(); + if (raw.jobId != null) return String(raw.jobId).trim(); + if (raw.job_id != null) return String(raw.job_id).trim(); + if (raw.scheduleId != null) return String(raw.scheduleId).trim(); + if (raw.schedule_id != null) return String(raw.schedule_id).trim(); + + if (raw.data && typeof raw.data === 'object' && raw.data.id != null) { + return String(raw.data.id).trim(); + } + return ''; + } + + function resolveEventJobId(payload) { + if (!payload || typeof payload !== 'object') return ''; + if (payload.jobId != null) return String(payload.jobId).trim(); + if (payload.job_id != null) return String(payload.job_id).trim(); + if (payload.scheduleId != null) return String(payload.scheduleId).trim(); + if (payload.schedule_id != null) return String(payload.schedule_id).trim(); + if (payload.id != null) return String(payload.id).trim(); + if (payload.data && typeof payload.data === 'object' && payload.data.jobId != null) return String(payload.data.jobId).trim(); + return ''; + } + /* ── HTTP backend (Axum, Rails, anything) ── */ function createHttpBackend(config) { @@ -729,7 +757,7 @@ const SF = (function () { return { createSchedule: function (data) { - return request('POST', schedulesPath, data); + return request('POST', schedulesPath, data).then(resolveJobId); }, getSchedule: function (id) { return request('GET', schedulesPath + '/' + id); @@ -785,9 +813,12 @@ const SF = (function () { return Promise.resolve([]); }, streamEvents: function (id, onMessage) { + var targetId = String(id); var unlisten = null; listen(eventName, function (event) { - onMessage(event.payload); + var payload = event && event.payload ? event.payload : {}; + if (resolveEventJobId(payload) !== targetId) return; + onMessage(payload); }).then(function (fn) { unlisten = fn; }); return function close() { if (unlisten) unlisten(); }; }, @@ -822,8 +853,13 @@ const SF = (function () { } backend.createSchedule(data).then(function (id) { - jobId = typeof id === 'string' ? id.trim() : id; + if (typeof id !== 'string' || !id.trim()) { + throw new Error('Invalid solver backend createSchedule response'); + } + jobId = id; closeStream = backend.streamEvents(jobId, function (msg) { + if (!isEventForCurrentJob(msg, jobId)) return; + // Solver finished if (msg.solverStatus === 'NOT_SOLVING') { backend.getSchedule(jobId).then(function (final) { @@ -883,6 +919,13 @@ const SF = (function () { api.getJobId = function () { return jobId; }; return api; + + function isEventForCurrentJob(msg, expectedId) { + if (!msg || typeof msg !== 'object') return false; + var candidate = msg.jobId || msg.job_id || msg.scheduleId || msg.schedule_id || msg.id || (msg.data && msg.data.id); + if (candidate == null) return true; + return String(candidate) === String(expectedId); + } }; })(SF); From 94d97dc4425eb3ca1aec44efae5adbfe1226a568 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sat, 21 Mar 2026 11:24:31 +0100 Subject: [PATCH 2/2] Normalize tauri backend ids and event filtering --- README.md | 2 +- js-src/10-backend.js | 6 ++- static/sf/sf.js | 6 ++- tests/backend-contract.test.js | 96 ++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 tests/backend-contract.test.js diff --git a/README.md b/README.md index 7163d19..d856891 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ Expects standard SolverForge REST endpoints: Backend contract expectations: - `createSchedule()` must resolve to a plain schedule/job id (string), or an object containing one of `id`, `jobId`, `job_id`, `scheduleId`, or `schedule_id`. - Events passed into `streamEvents()` for a job should include one of the same identifiers if multiple solver runs are possible. -- Tauri payloads are ignored unless `event.payload` matches the active job id. +- Tauri payloads are ignored only when they carry a different job id than the active run; id-less single-run updates still pass through. ### Tauri diff --git a/js-src/10-backend.js b/js-src/10-backend.js index 4930342..0c7a9cb 100644 --- a/js-src/10-backend.js +++ b/js-src/10-backend.js @@ -36,6 +36,7 @@ if (payload.scheduleId != null) return String(payload.scheduleId).trim(); if (payload.schedule_id != null) return String(payload.schedule_id).trim(); if (payload.id != null) return String(payload.id).trim(); + if (payload.data && typeof payload.data === 'object' && payload.data.id != null) return String(payload.data.id).trim(); if (payload.data && typeof payload.data === 'object' && payload.data.jobId != null) return String(payload.data.jobId).trim(); return ''; } @@ -103,7 +104,7 @@ return { createSchedule: function (data) { - return invoke(commands.startSolve || 'create_schedule', { request: data }); + return invoke(commands.startSolve || 'create_schedule', { request: data }).then(resolveJobId); }, getSchedule: function (id) { return invoke(commands.getSchedule || 'get_schedule', { id: id }); @@ -125,7 +126,8 @@ var unlisten = null; listen(eventName, function (event) { var payload = event && event.payload ? event.payload : {}; - if (resolveEventJobId(payload) !== targetId) return; + var payloadId = resolveEventJobId(payload); + if (payloadId && payloadId !== targetId) return; onMessage(payload); }).then(function (fn) { unlisten = fn; }); return function close() { if (unlisten) unlisten(); }; diff --git a/static/sf/sf.js b/static/sf/sf.js index d34e36f..acec9cb 100644 --- a/static/sf/sf.js +++ b/static/sf/sf.js @@ -778,6 +778,7 @@ const SF = (function () { if (payload.scheduleId != null) return String(payload.scheduleId).trim(); if (payload.schedule_id != null) return String(payload.schedule_id).trim(); if (payload.id != null) return String(payload.id).trim(); + if (payload.data && typeof payload.data === 'object' && payload.data.id != null) return String(payload.data.id).trim(); if (payload.data && typeof payload.data === 'object' && payload.data.jobId != null) return String(payload.data.jobId).trim(); return ''; } @@ -845,7 +846,7 @@ const SF = (function () { return { createSchedule: function (data) { - return invoke(commands.startSolve || 'create_schedule', { request: data }); + return invoke(commands.startSolve || 'create_schedule', { request: data }).then(resolveJobId); }, getSchedule: function (id) { return invoke(commands.getSchedule || 'get_schedule', { id: id }); @@ -867,7 +868,8 @@ const SF = (function () { var unlisten = null; listen(eventName, function (event) { var payload = event && event.payload ? event.payload : {}; - if (resolveEventJobId(payload) !== targetId) return; + var payloadId = resolveEventJobId(payload); + if (payloadId && payloadId !== targetId) return; onMessage(payload); }).then(function (fn) { unlisten = fn; }); return function close() { if (unlisten) unlisten(); }; diff --git a/tests/backend-contract.test.js b/tests/backend-contract.test.js new file mode 100644 index 0000000..6bbd605 --- /dev/null +++ b/tests/backend-contract.test.js @@ -0,0 +1,96 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const { createDom } = require('./support/fake-dom'); + +const ROOT = path.resolve(__dirname, '..'); + +function loadSf(files, overrides = {}) { + const { document, window, Node } = createDom(); + const context = vm.createContext({ + console, + document, + window, + Node, + Promise, + setTimeout, + clearTimeout, + ...overrides, + }); + + files.forEach((file) => { + const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); + vm.runInContext(source, context, { filename: file }); + }); + + return { SF: context.window.SF, document }; +} + +test('tauri createSchedule normalizes object and numeric ids to strings', async () => { + const calls = []; + const { SF } = loadSf(['js-src/00-core.js', 'js-src/10-backend.js'], { + fetch() { + throw new Error('unexpected fetch'); + }, + }); + + const backendWithObject = SF.createBackend({ + type: 'tauri', + invoke(command, payload) { + calls.push({ command, payload }); + return Promise.resolve({ jobId: 42 }); + }, + listen() { + return Promise.resolve(function () {}); + }, + }); + + assert.equal(await backendWithObject.createSchedule({ foo: 'bar' }), '42'); + assert.equal(calls[0].command, 'create_schedule'); + + const backendWithNumber = SF.createBackend({ + type: 'tauri', + invoke() { + return Promise.resolve(7); + }, + listen() { + return Promise.resolve(function () {}); + }, + }); + + assert.equal(await backendWithNumber.createSchedule({}), '7'); +}); + +test('tauri streamEvents keeps id-less updates and filters mismatched job ids', async () => { + let handler = null; + const received = []; + const { SF } = loadSf(['js-src/00-core.js', 'js-src/10-backend.js']); + + const backend = SF.createBackend({ + type: 'tauri', + invoke() { + return Promise.resolve('job-1'); + }, + listen(_eventName, onEvent) { + handler = onEvent; + return Promise.resolve(function () {}); + }, + }); + + backend.streamEvents('job-1', function (payload) { + received.push(payload); + }); + + await Promise.resolve(); + + handler({ payload: { score: '0hard/0soft' } }); + handler({ payload: { data: { id: 'job-1' }, solverStatus: 'SOLVING_ACTIVE' } }); + handler({ payload: { jobId: 'job-2', solverStatus: 'NOT_SOLVING' } }); + + assert.equal(received.length, 2); + assert.equal(received[0].score, '0hard/0soft'); + assert.equal(received[1].data.id, 'job-1'); +});