openvibers/openvibe.chat Commits

Chat: archiving a room works before migration 0010 runs (it waits behind 0009's contract window) (#86)

OP OpenVibers committed 15 Oct 2026 · d9a1969746 · 2 files, +19 −2

Production proof of Services#61: archiving a room through Chat's control API answered HTTP 500 (`relation "chat_control_log" does not exist`).

**Cause:** migration 0010 waits behind 0009, a contract that runs only once 0006 is 7 days old (about 2026-10-16 17:39 UTC), and the migrator applies migrations in order.

**Fix:** until the table exists, the control route keeps no answers. It catches only that missing-table error on the log read and the log write; any other error still propagates. Archive is idempotent by itself (a second one answers `changed: false`), so a retry stays safe. Once 0010 runs, idempotency works as designed.

**Test:** with the table renamed away, an archive still answers `done` and archives the room. `resource-control.test.js` passes 5/5.

Changes

ddiff --git a/server/registry/resource-control.js b/server/registry/resource-control.js
iindex c3dcda3..1f130fa 100644
---- a/server/registry/resource-control.js
++++ b/server/registry/resource-control.js
@@@ -67,14 +67,19 @@ function router({ now = () => Date.now() } = {}) {
if (!check.valid) return http.sendProblem(res, 400, 'control.bad_request', { detail: JSON.stringify(check.errors).slice(0, 500), ctx });
const caller = req.principal.sub;
const digest = digestOf(request);
-- const seen = await db.get('SELECT digest, response FROM chat_control_log WHERE caller = ? AND idempotency_key = ?', [caller, request.idempotency_key]);
++ // chat_control_log (migration 0010) waits behind 0009's contract window (until 0006 is 7 days old). Until it
++ // exists, answers are not kept: archive is idempotent by itself (a second one answers changed: false).
++ const missing = (err) => /chat_control_log/.test(String(err && err.message)) && /does not exist|no such table/.test(String(err && err.message));
++ const seen = await db.get('SELECT digest, response FROM chat_control_log WHERE caller = ? AND idempotency_key = ?', [caller, request.idempotency_key])
++ .catch((err) => { if (missing(err)) return null; throw err; });
if (seen) {
if (seen.digest !== digest) return http.sendProblem(res, 409, 'control.idempotency_conflict', { detail: 'this idempotency_key was used for another request', ctx });
return res.json(typeof seen.response === 'string' ? JSON.parse(seen.response) : seen.response);
}
const answer = await decide(request);
await db.run(`INSERT INTO chat_control_log (caller, idempotency_key, owner_subject, digest, response, created_at)
-- VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`, [caller, request.idempotency_key, request.owner.id, digest, JSON.stringify(answer), now()]);
++ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`, [caller, request.idempotency_key, request.owner.id, digest, JSON.stringify(answer), now()])
++ .catch((err) => { if (!missing(err)) throw err; });
return res.json(answer);
} catch (err) { return next(err); }
});
ddiff --git a/test/resource-control.test.js b/test/resource-control.test.js
iindex 40b5217..d439964 100644
---- a/test/resource-control.test.js
++++ b/test/resource-control.test.js
@@@ -56,6 +56,18 @@ t('only the owner Chat holds; other actions and kinds are refused', async () =>
assert.deepStrictEqual([r.status, r.body.code], [400, 'control.bad_request'], 'Chat holds no project resources');
});
++t('before migration 0010 has run, archive still works (its answers are just not kept)', async () => {
++ await h.db.run('INSERT INTO rooms (slug, name, visibility, owner_id, owner_subject) VALUES (?, ?, ?, ?, ?)', ['control-early', 'Early', 'public', owner.id, owner.subject_id]);
++ await h.db.run('ALTER TABLE chat_control_log RENAME TO chat_control_log_hidden');
++ try {
++ const r = await send(req('archive', { owned: { kind: 'chat.room', id: 'control-early' } }));
++ assert.deepStrictEqual([r.status, r.body.state, r.body.result], [200, 'done', { archived: true, changed: true }], r.text);
++ assert.ok(await archived('control-early'));
++ } finally {
++ await h.db.run('ALTER TABLE chat_control_log_hidden RENAME TO chat_control_log');
++ }
++});
++
t('first-party only, with chat.resource.control', async () => {
let r = await send(req('archive', { owned: { kind: 'chat.room', id: 'control-theirs' } }), { token: h.serviceToken(['chat.resource.read'], { sub: 'svc:services' }) });
assert.strictEqual(r.status, 403);