openvibers/openvibe.wiki Commits
wiki.projects on Network (Contracts 0.41.0 user module, WS-B task 9): the spaces a person owns or edits (public ones named, others counted); a space or role change marks the people concerned in the same transaction (a removed role too) and a one-minute drain writes their record as the owning service, only when it changed
Changes
@@@ -14,7 +14,7 @@"dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"@@@ -999,9 +999,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.38.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",-- "integrity": "sha512-nHYCcKM9N4TJS+BlHOax9xwt4jU87qhXsLZ2gnuK4nOHpGlkLk2OI49PCQVvfHPe+V7bzBK7L4MKjFcTNv8ORA==",++ "version": "0.41.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",++ "integrity": "sha512-uT+Prd6NOO7iT2zn2cstxpPa6qmybc/O5KH0AQ8R5alzN/+WyUufd+h/yE0YxAw1+H/mJ4I2Q4xT+Go+CiPW2w==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -20,7 +20,7 @@"dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"@@@ -106,6 +106,18 @@ CREATE TABLE IF NOT EXISTS wiki_permissions ();CREATE INDEX IF NOT EXISTS wiki_permissions_subject ON wiki_permissions (subject);++-- People whose wiki.projects user module (Network) needs writing again: marked in the same transaction as++-- the space or role change, drained by server/integrations/projects-module.js.++CREATE TABLE IF NOT EXISTS wiki_module_dirty (++ subject TEXT PRIMARY KEY,++ marked_at INTEGER NOT NULL++);++CREATE TABLE IF NOT EXISTS wiki_module_pushes (++ subject TEXT PRIMARY KEY,++ hash TEXT NOT NULL,++ pushed_at INTEGER NOT NULL++);++CREATE TABLE IF NOT EXISTS wiki_watchers (page_id TEXT NOT NULL REFERENCES wiki_pages(id),subject TEXT NOT NULL,@@@ -44,6 +44,9 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetimers.push(setInterval(() => svc.verifyAllMedia(platform.media.resolve).catch((err) => log.warn(`[Wiki] media check: ${err.message}`)), config.mediaVerifyIntervalMs));}if (platform.eventsConfigured) platform.outbox.start();++ // wiki.projects on Network (Contracts 0.41.0): people whose spaces or roles changed, every minute.++ const projects = require('./integrations/projects-module').createProjectsModule({ db, config, tokens: platform.tokenClient, fetchImpl, now, log });++ if (projects.enabled) timers.push(setInterval(() => projects.drain().catch((err) => log.warn(`[Wiki] wiki.projects: ${err.message}`)), 60 * 1000));for (const t of timers) if (t.unref) t.unref();}nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++/**++ * wiki.projects on OpenVibe.Network (openvibe-contracts 0.41.0 user module, roadmap WS-B task 9): the++ * spaces a person owns or edits, for other sites. Only public spaces are named; the others are counted++ * (private_count). Wiki's spaces and roles stay the truth.++ *++ * A space or role change marks the people concerned in wiki_module_dirty inside its own transaction++ * (server/wiki/service.js); drain() writes each marked person's record as the owning service (grant wiki++ * network.modules.write on wiki.projects) and clears the mark. Written only when it changed++ * (wiki_module_pushes keeps a hash); someone who never had a space gets no record. Off without the++ * service principal; a failed write keeps the mark for the next drain.++ */++const crypto = require('crypto');++++const NS = 'wiki.projects';++const MAX_SPACES = 50;++++/** The record for one person: { spaces: [{ slug, name, role }], private_count }. */++function summarize(db, subject) {++ const rows = db.prepare(`SELECT s.slug, s.name, s.visibility,++ CASE WHEN p.role = 'owner' OR s.owner = @subject THEN 'owner' ELSE p.role END AS role++ FROM wiki_spaces s LEFT JOIN wiki_permissions p ON p.space_id = s.id AND p.subject = @subject++ WHERE s.deleted_at IS NULL AND (s.owner = @subject OR p.role IN ('owner', 'editor'))++ ORDER BY role = 'owner' DESC, s.name COLLATE NOCASE`).all({ subject });++ const pub = rows.filter((r) => r.visibility === 'public');++ return {++ spaces: pub.slice(0, MAX_SPACES).map((r) => ({ slug: r.slug, name: String(r.name).slice(0, 120), role: r.role })),++ private_count: rows.length - pub.length,++ };++}++++function createProjectsModule({ db, config, tokens, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {++ const enabled = !!tokens;++ const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');++ const stats = { written: 0, unchanged: 0, failed: 0, lastError: null };++ let draining = false;++++ async function put(subject, data) {++ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders({ audience: 'openvibe.network' })) };++ const res = await fetchImpl(`${base}/internal/modules/${NS}/${encodeURIComponent(subject)}`, { method: 'PUT', headers, body: JSON.stringify({ data }), signal: AbortSignal.timeout(8000) });++ if (res.status === 401) tokens.invalidate({ audience: 'openvibe.network' });++ if (!res.ok) throw new Error(`Network answered ${res.status}`);++ }++++ /** Write every marked person's record. → how many were written */++ async function drain({ limit = 200 } = {}) {++ if (!enabled || draining) return 0;++ draining = true;++ let written = 0;++ try {++ for (const { subject, marked_at: markedAt } of db.prepare('SELECT subject, marked_at FROM wiki_module_dirty ORDER BY marked_at LIMIT ?').all(limit)) {++ const clear = () => db.prepare('DELETE FROM wiki_module_dirty WHERE subject = ? AND marked_at = ?').run(subject, markedAt);++ const data = summarize(db, subject);++ const hash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);++ const last = db.prepare('SELECT hash FROM wiki_module_pushes WHERE subject = ?').get(subject);++ if ((last && last.hash === hash) || (!last && !data.spaces.length && !data.private_count)) { stats.unchanged++; clear(); continue; }++ try { await put(subject, data); } catch (err) { stats.failed++; stats.lastError = err.message; continue; }++ db.prepare(`INSERT INTO wiki_module_pushes (subject, hash, pushed_at) VALUES (?, ?, ?)++ ON CONFLICT (subject) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(subject, hash, now());++ clear();++ stats.written++; written++;++ }++ } finally { draining = false; }++ return written;++ }++++ return { enabled, drain, stats: () => ({ enabled, ...stats }) };++}++++module.exports = { NS, summarize, createProjectsModule };@@@ -99,6 +99,12 @@ function createWikiService({ db, stores, outbox, config, community = null, now =putRole: db.prepare(`INSERT INTO wiki_permissions (space_id, subject, role, granted_by, granted_at) VALUES (?, ?, ?, ?, ?)ON CONFLICT (space_id, subject) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, granted_at = excluded.granted_at`),delRole: db.prepare('DELETE FROM wiki_permissions WHERE space_id = ? AND subject = ?'),++ // wiki.projects (Network user module): who needs their record written again.++ dirtySubject: db.prepare("INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, ?) ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at"),++ dirtySpace: db.prepare(`INSERT INTO wiki_module_dirty (subject, marked_at)++ SELECT subject, @t FROM wiki_permissions WHERE space_id = @id AND substr(subject, 1, 4) = 'usr_'++ UNION SELECT owner, @t FROM wiki_spaces WHERE id = @id AND substr(owner, 1, 4) = 'usr_'++ ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at`),owners: db.prepare("SELECT COUNT(*) AS n FROM wiki_permissions WHERE space_id = ? AND role = 'owner'"),watchers: db.prepare('SELECT subject FROM wiki_watchers WHERE page_id = ? ORDER BY subject'),watch: db.prepare('INSERT OR IGNORE INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)'),@@@ -249,6 +255,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =function emit(envelope) { return outbox.enqueue(envelope); }function spaceEvent(space, actor, change) {++ q.dirtySpace.run({ id: space.id, t: now() });emit({event_type: 'wiki.space.updated',actor: hooks.subjectRef(actorId(actor)),@@@ -572,6 +579,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =if (losingOwner && space.kind === 'user' && q.owners.get(space.id).n <= 1) fail(409, 'space.last_owner', 'A user space keeps at least one owner');if (role == null) q.delRole.run(space.id, subject);else q.putRole.run(space.id, subject, role, actorId(actor), now());++ q.dirtySubject.run(subject, now()); // a removed role is no longer in the space's listspaceEvent(space, actor, 'roles');return svc.roles(space.id, actor);});nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++/**++ * wiki.projects on Network (server/integrations/projects-module.js, Contracts 0.41.0, WS-B task 9):++ * creating a space, granting or removing a role and changing a space mark the people concerned in the++ * same transaction; drain() writes their record (public spaces named, others counted) as the owning++ * service, only when it changed, and clears the mark; viewers and people who never had a space get none.++ */++const assert = require('assert');++const { modules } = require('openvibe-contracts');++const H = require('./helpers');++const { summarize, createProjectsModule } = require('../server/integrations/projects-module');++++(async () => {++ const h = await H.boot();++ const ann = H.subject(), bob = H.subject(), cat = H.subject();++ const tok = (sub) => H.userToken({ subject: sub });++ const puts = [];++ const fetchImpl = async (url, opts) => {++ const m = String(url).match(/\/internal\/modules\/wiki\.projects\/(usr_[0-9A-Z]+)$/);++ assert.ok(m && opts.method === 'PUT' && opts.headers.Authorization === 'Bearer stub-token', url);++ puts.push({ subject: m[1], data: JSON.parse(opts.body).data });++ return { ok: true, status: 201, json: async () => ({}) };++ };++ const projects = createProjectsModule({ db: h.db, config: { networkInternalUrl: 'http://network.test' }, tokens: h.platform.tokenClient, fetchImpl, log: H.quiet });++ const dirty = () => h.db.prepare('SELECT subject FROM wiki_module_dirty ORDER BY subject').all().map((r) => r.subject);++ try {++ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Garden', slug: 'garden', visibility: 'public' } });++ assert.strictEqual(r.status, 201, r.text);++ r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Diary', slug: 'diary', visibility: 'private' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual(dirty(), [ann], 'creating a space marks its owner');++ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: 'editor' } });++ assert.strictEqual(r.status, 200, r.text);++ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${cat}`, { token: tok(ann), body: { role: 'viewer' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(summarize(h.db, ann), { spaces: [{ slug: 'garden', name: 'Garden', role: 'owner' }], private_count: 1 });++ assert.deepStrictEqual(summarize(h.db, bob), { spaces: [{ slug: 'garden', name: 'Garden', role: 'editor' }], private_count: 0 });++ assert.ok(modules.validateData('wiki.projects', summarize(h.db, ann)).valid, 'matches the namespace schema');++++ assert.strictEqual(await projects.drain(), 2, 'ann and bob; cat is only a viewer');++ assert.deepStrictEqual(puts.map((p) => p.subject).sort(), [ann, bob].sort());++ assert.deepStrictEqual(dirty(), [], 'every mark cleared');++++ // Bob loses his role: he is marked although he is no longer in the space's list.++ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: null } });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(dirty().includes(bob));++ await projects.drain();++ assert.deepStrictEqual(puts.at(-1), { subject: bob, data: { spaces: [], private_count: 0 } }, 'his record empties');++++ // Renaming the space rewrites its people; a drain with nothing new writes nothing.++ r = await H.req(h, 'PATCH', '/api/v1/spaces/garden', { token: tok(ann), body: { name: 'Big Garden' } });++ assert.ok(r.status === 200, r.text);++ const before = puts.length;++ await projects.drain();++ assert.strictEqual(puts.length, before + 1);++ assert.strictEqual(puts.at(-1).data.spaces[0].name, 'Big Garden');++ h.db.prepare('INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, 1)').run(ann);++ await projects.drain();++ assert.strictEqual(puts.length, before + 1, 'unchanged: not written again');++ assert.strictEqual(projects.stats().unchanged >= 2, true);++ console.log('wiki.projects: all checks passed');++ } finally { await h.stop(); }++})().catch((err) => { console.error(err); process.exit(1); });
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
@@ -999,9 +999,9 @@
@@ -999,9 +999,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.38.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
- "integrity": "sha512-nHYCcKM9N4TJS+BlHOax9xwt4jU87qhXsLZ2gnuK4nOHpGlkLk2OI49PCQVvfHPe+V7bzBK7L4MKjFcTNv8ORA==",
+ "version": "0.41.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
+ "integrity": "sha512-uT+Prd6NOO7iT2zn2cstxpPa6qmybc/O5KH0AQ8R5alzN/+WyUufd+h/yE0YxAw1+H/mJ4I2Q4xT+Go+CiPW2w==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.41.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
@@ -106,6 +106,18 @@ CREATE TABLE IF NOT EXISTS wiki_permissions (
@@ -106,6 +106,18 @@ CREATE TABLE IF NOT EXISTS wiki_permissions (
);
);
CREATE INDEX IF NOT EXISTS wiki_permissions_subject ON wiki_permissions (subject);
CREATE INDEX IF NOT EXISTS wiki_permissions_subject ON wiki_permissions (subject);
+-- People whose wiki.projects user module (Network) needs writing again: marked in the same transaction as
+-- the space or role change, drained by server/integrations/projects-module.js.
+CREATE TABLE IF NOT EXISTS wiki_module_dirty (
+ subject TEXT PRIMARY KEY,
+ marked_at INTEGER NOT NULL
+);
+CREATE TABLE IF NOT EXISTS wiki_module_pushes (
+ subject TEXT PRIMARY KEY,
+ hash TEXT NOT NULL,
+ pushed_at INTEGER NOT NULL
+);
+
CREATE TABLE IF NOT EXISTS wiki_watchers (
CREATE TABLE IF NOT EXISTS wiki_watchers (
page_id TEXT NOT NULL REFERENCES wiki_pages(id),
page_id TEXT NOT NULL REFERENCES wiki_pages(id),
subject TEXT NOT NULL,
subject TEXT NOT NULL,
@@ -44,6 +44,9 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
@@ -44,6 +44,9 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
timers.push(setInterval(() => svc.verifyAllMedia(platform.media.resolve).catch((err) => log.warn(`[Wiki] media check: ${err.message}`)), config.mediaVerifyIntervalMs));
timers.push(setInterval(() => svc.verifyAllMedia(platform.media.resolve).catch((err) => log.warn(`[Wiki] media check: ${err.message}`)), config.mediaVerifyIntervalMs));
}
}
if (platform.eventsConfigured) platform.outbox.start();
if (platform.eventsConfigured) platform.outbox.start();
+ // wiki.projects on Network (Contracts 0.41.0): people whose spaces or roles changed, every minute.
+ const projects = require('./integrations/projects-module').createProjectsModule({ db, config, tokens: platform.tokenClient, fetchImpl, now, log });
+ if (projects.enabled) timers.push(setInterval(() => projects.drain().catch((err) => log.warn(`[Wiki] wiki.projects: ${err.message}`)), 60 * 1000));
for (const t of timers) if (t.unref) t.unref();
for (const t of timers) if (t.unref) t.unref();
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * wiki.projects on OpenVibe.Network (openvibe-contracts 0.41.0 user module, roadmap WS-B task 9): the
+ * spaces a person owns or edits, for other sites. Only public spaces are named; the others are counted
+ * (private_count). Wiki's spaces and roles stay the truth.
+ *
+ * A space or role change marks the people concerned in wiki_module_dirty inside its own transaction
+ * (server/wiki/service.js); drain() writes each marked person's record as the owning service (grant wiki
+ * network.modules.write on wiki.projects) and clears the mark. Written only when it changed
+ * (wiki_module_pushes keeps a hash); someone who never had a space gets no record. Off without the
+ * service principal; a failed write keeps the mark for the next drain.
+ */
+const crypto = require('crypto');
+
+const NS = 'wiki.projects';
+const MAX_SPACES = 50;
+
+/** The record for one person: { spaces: [{ slug, name, role }], private_count }. */
+function summarize(db, subject) {
+ const rows = db.prepare(`SELECT s.slug, s.name, s.visibility,
+ CASE WHEN p.role = 'owner' OR s.owner = @subject THEN 'owner' ELSE p.role END AS role
+ FROM wiki_spaces s LEFT JOIN wiki_permissions p ON p.space_id = s.id AND p.subject = @subject
+ WHERE s.deleted_at IS NULL AND (s.owner = @subject OR p.role IN ('owner', 'editor'))
+ ORDER BY role = 'owner' DESC, s.name COLLATE NOCASE`).all({ subject });
+ const pub = rows.filter((r) => r.visibility === 'public');
+ return {
+ spaces: pub.slice(0, MAX_SPACES).map((r) => ({ slug: r.slug, name: String(r.name).slice(0, 120), role: r.role })),
+ private_count: rows.length - pub.length,
+ };
+}
+
+function createProjectsModule({ db, config, tokens, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
+ const enabled = !!tokens;
+ const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');
+ const stats = { written: 0, unchanged: 0, failed: 0, lastError: null };
+ let draining = false;
+
+ async function put(subject, data) {
+ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders({ audience: 'openvibe.network' })) };
+ const res = await fetchImpl(`${base}/internal/modules/${NS}/${encodeURIComponent(subject)}`, { method: 'PUT', headers, body: JSON.stringify({ data }), signal: AbortSignal.timeout(8000) });
+ if (res.status === 401) tokens.invalidate({ audience: 'openvibe.network' });
+ if (!res.ok) throw new Error(`Network answered ${res.status}`);
+ }
+
+ /** Write every marked person's record. → how many were written */
+ async function drain({ limit = 200 } = {}) {
+ if (!enabled || draining) return 0;
+ draining = true;
+ let written = 0;
+ try {
+ for (const { subject, marked_at: markedAt } of db.prepare('SELECT subject, marked_at FROM wiki_module_dirty ORDER BY marked_at LIMIT ?').all(limit)) {
+ const clear = () => db.prepare('DELETE FROM wiki_module_dirty WHERE subject = ? AND marked_at = ?').run(subject, markedAt);
+ const data = summarize(db, subject);
+ const hash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);
+ const last = db.prepare('SELECT hash FROM wiki_module_pushes WHERE subject = ?').get(subject);
+ if ((last && last.hash === hash) || (!last && !data.spaces.length && !data.private_count)) { stats.unchanged++; clear(); continue; }
+ try { await put(subject, data); } catch (err) { stats.failed++; stats.lastError = err.message; continue; }
+ db.prepare(`INSERT INTO wiki_module_pushes (subject, hash, pushed_at) VALUES (?, ?, ?)
+ ON CONFLICT (subject) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(subject, hash, now());
+ clear();
+ stats.written++; written++;
+ }
+ } finally { draining = false; }
+ return written;
+ }
+
+ return { enabled, drain, stats: () => ({ enabled, ...stats }) };
+}
+
+module.exports = { NS, summarize, createProjectsModule };
@@ -99,6 +99,12 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -99,6 +99,12 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
putRole: db.prepare(`INSERT INTO wiki_permissions (space_id, subject, role, granted_by, granted_at) VALUES (?, ?, ?, ?, ?)
putRole: db.prepare(`INSERT INTO wiki_permissions (space_id, subject, role, granted_by, granted_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (space_id, subject) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, granted_at = excluded.granted_at`),
ON CONFLICT (space_id, subject) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, granted_at = excluded.granted_at`),
delRole: db.prepare('DELETE FROM wiki_permissions WHERE space_id = ? AND subject = ?'),
delRole: db.prepare('DELETE FROM wiki_permissions WHERE space_id = ? AND subject = ?'),
+ // wiki.projects (Network user module): who needs their record written again.
+ dirtySubject: db.prepare("INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, ?) ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at"),
+ dirtySpace: db.prepare(`INSERT INTO wiki_module_dirty (subject, marked_at)
+ SELECT subject, @t FROM wiki_permissions WHERE space_id = @id AND substr(subject, 1, 4) = 'usr_'
+ UNION SELECT owner, @t FROM wiki_spaces WHERE id = @id AND substr(owner, 1, 4) = 'usr_'
+ ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at`),
owners: db.prepare("SELECT COUNT(*) AS n FROM wiki_permissions WHERE space_id = ? AND role = 'owner'"),
owners: db.prepare("SELECT COUNT(*) AS n FROM wiki_permissions WHERE space_id = ? AND role = 'owner'"),
watchers: db.prepare('SELECT subject FROM wiki_watchers WHERE page_id = ? ORDER BY subject'),
watchers: db.prepare('SELECT subject FROM wiki_watchers WHERE page_id = ? ORDER BY subject'),
watch: db.prepare('INSERT OR IGNORE INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)'),
watch: db.prepare('INSERT OR IGNORE INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)'),
@@ -249,6 +255,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -249,6 +255,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
function emit(envelope) { return outbox.enqueue(envelope); }
function emit(envelope) { return outbox.enqueue(envelope); }
function spaceEvent(space, actor, change) {
function spaceEvent(space, actor, change) {
+ q.dirtySpace.run({ id: space.id, t: now() });
emit({
emit({
event_type: 'wiki.space.updated',
event_type: 'wiki.space.updated',
actor: hooks.subjectRef(actorId(actor)),
actor: hooks.subjectRef(actorId(actor)),
@@ -572,6 +579,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -572,6 +579,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
if (losingOwner && space.kind === 'user' && q.owners.get(space.id).n <= 1) fail(409, 'space.last_owner', 'A user space keeps at least one owner');
if (losingOwner && space.kind === 'user' && q.owners.get(space.id).n <= 1) fail(409, 'space.last_owner', 'A user space keeps at least one owner');
if (role == null) q.delRole.run(space.id, subject);
if (role == null) q.delRole.run(space.id, subject);
else q.putRole.run(space.id, subject, role, actorId(actor), now());
else q.putRole.run(space.id, subject, role, actorId(actor), now());
+ q.dirtySubject.run(subject, now()); // a removed role is no longer in the space's list
spaceEvent(space, actor, 'roles');
spaceEvent(space, actor, 'roles');
return svc.roles(space.id, actor);
return svc.roles(space.id, actor);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+/**
+ * wiki.projects on Network (server/integrations/projects-module.js, Contracts 0.41.0, WS-B task 9):
+ * creating a space, granting or removing a role and changing a space mark the people concerned in the
+ * same transaction; drain() writes their record (public spaces named, others counted) as the owning
+ * service, only when it changed, and clears the mark; viewers and people who never had a space get none.
+ */
+const assert = require('assert');
+const { modules } = require('openvibe-contracts');
+const H = require('./helpers');
+const { summarize, createProjectsModule } = require('../server/integrations/projects-module');
+
+(async () => {
+ const h = await H.boot();
+ const ann = H.subject(), bob = H.subject(), cat = H.subject();
+ const tok = (sub) => H.userToken({ subject: sub });
+ const puts = [];
+ const fetchImpl = async (url, opts) => {
+ const m = String(url).match(/\/internal\/modules\/wiki\.projects\/(usr_[0-9A-Z]+)$/);
+ assert.ok(m && opts.method === 'PUT' && opts.headers.Authorization === 'Bearer stub-token', url);
+ puts.push({ subject: m[1], data: JSON.parse(opts.body).data });
+ return { ok: true, status: 201, json: async () => ({}) };
+ };
+ const projects = createProjectsModule({ db: h.db, config: { networkInternalUrl: 'http://network.test' }, tokens: h.platform.tokenClient, fetchImpl, log: H.quiet });
+ const dirty = () => h.db.prepare('SELECT subject FROM wiki_module_dirty ORDER BY subject').all().map((r) => r.subject);
+ try {
+ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Garden', slug: 'garden', visibility: 'public' } });
+ assert.strictEqual(r.status, 201, r.text);
+ r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Diary', slug: 'diary', visibility: 'private' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual(dirty(), [ann], 'creating a space marks its owner');
+ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: 'editor' } });
+ assert.strictEqual(r.status, 200, r.text);
+ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${cat}`, { token: tok(ann), body: { role: 'viewer' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(summarize(h.db, ann), { spaces: [{ slug: 'garden', name: 'Garden', role: 'owner' }], private_count: 1 });
+ assert.deepStrictEqual(summarize(h.db, bob), { spaces: [{ slug: 'garden', name: 'Garden', role: 'editor' }], private_count: 0 });
+ assert.ok(modules.validateData('wiki.projects', summarize(h.db, ann)).valid, 'matches the namespace schema');
+
+ assert.strictEqual(await projects.drain(), 2, 'ann and bob; cat is only a viewer');
+ assert.deepStrictEqual(puts.map((p) => p.subject).sort(), [ann, bob].sort());
+ assert.deepStrictEqual(dirty(), [], 'every mark cleared');
+
+ // Bob loses his role: he is marked although he is no longer in the space's list.
+ r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: null } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(dirty().includes(bob));
+ await projects.drain();
+ assert.deepStrictEqual(puts.at(-1), { subject: bob, data: { spaces: [], private_count: 0 } }, 'his record empties');
+
+ // Renaming the space rewrites its people; a drain with nothing new writes nothing.
+ r = await H.req(h, 'PATCH', '/api/v1/spaces/garden', { token: tok(ann), body: { name: 'Big Garden' } });
+ assert.ok(r.status === 200, r.text);
+ const before = puts.length;
+ await projects.drain();
+ assert.strictEqual(puts.length, before + 1);
+ assert.strictEqual(puts.at(-1).data.spaces[0].name, 'Big Garden');
+ h.db.prepare('INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, 1)').run(ann);
+ await projects.drain();
+ assert.strictEqual(puts.length, before + 1, 'unchanged: not written again');
+ assert.strictEqual(projects.stats().unchanged >= 2, true);
+ console.log('wiki.projects: all checks passed');
+ } finally { await h.stop(); }
+})().catch((err) => { console.error(err); process.exit(1); });