openvibers/openvibe.sdk Commits

v0.10.0: openvibe-sdk/search (WS-F task 4): OpenVibe.Search's query API — query with owner/type/lang/facet filters and facet counts, iterate across cursor pages, suggest, document by id (null when missing or not visible), actingSubject for services with search.query.delegate; in the browser bundle, ESM and types

OP OpenVibers committed 25 Sept 2026 · 01851f1114 · 17 files, +315 −23


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 895e7e9..3713d3e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,10 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.10.0 (2026-09-25)
++
++**`openvibe-sdk/search`** (roadmap WS-F task 4): OpenVibe.Search's query API. `createSearchClient(client)` gives `query(text, { owner, type, lang, filter, facets, limit, cursor })` (one page: `results`, `next_cursor`, optional facet counts), `iterate(text, opts)` (every result across cursor pages, `max` to stop early), `suggest(text)` and `document(owner, type, id)` (null when missing or not yours to see). Anonymous callers get public documents; a signed-in person also gets restricted documents naming them; a first-party service with `search.query.delegate` passes `actingSubject`. Several values of one facet go as repeated `facet.<key>` parameters. In the browser bundle too. Additive.
++
## 0.9.1 (2026-09-24)
The v0.9.0 tag reports `SDK_VERSION` 0.8.0 and ships a stale browser bundle; 0.9.1 is the same API with both right. Use 0.9.1.
ddiff --git a/README.md b/README.md
iindex f0d2829..69adc06 100644
---- a/README.md
++++ b/README.md
@@@ -164,6 +164,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers; `createObjectsClient({app, baseUrl?, tokenClient \| apiKey \| client})`: object API v2 `upload` (single or multipart by size, resumable), `resume`, `get`, `signedUrl`, `delete`, `list`, `iterate`, and `jobs.create/get/list/approve/cancel/wait`; Media's origin from the platform descriptor when `baseUrl` is omitted |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
++| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
ddiff --git a/browser.js b/browser.js
iindex 3321253..71ce807 100644
---- a/browser.js
++++ b/browser.js
@@@ -13,6 +13,7 @@ module.exports = {
realtime: require('./src/realtime'),
media: require('./src/media'),
community: require('./src/community'),
++ search: require('./src/search'),
jobs: require('./src/jobs'),
tools: require('./src/tools'),
projects: require('./src/projects'),
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 0c3f873..f915006 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,6 +1,6 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.9.1, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.10.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
--// (auth, registry, modules, realtime, media, community, jobs, tools, projects). No server-only code: no service tokens, JWT verification, events, identity.
++// (auth, registry, modules, realtime, media, community, search, jobs, tools, projects). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
@@@ -20,9 +20,10 @@ module.exports = {
realtime: __load(11),
media: __load(12),
community: __load(14),
-- jobs: __load(15),
-- tools: __load(17),
-- projects: __load(18),
++ search: __load(15),
++ jobs: __load(16),
++ tools: __load(18),
++ projects: __load(19),
};
},
// ── 1: src/core/index.js ───────────────────────────────────────────
@@@ -37,7 +38,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.9.1';
++const SDK_VERSION = '0.10.0';
module.exports = {
createClient,
@@@ -1741,7 +1742,94 @@ function createCommunityClient(client, defaults = {}) {
module.exports = { createCommunityClient, actingHeaders };
},
--// ── 15: src/jobs.js ─────────────────────────────────────────────────
++// ── 15: src/search.js ───────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/**
++ * openvibe-sdk/search: OpenVibe.Search's query API (roadmap WS-F task 4). Browser-safe.
++ *
++ * const search = createSearchClient(client);
++ * const { results, next_cursor } = await search.query('forum', { owner: 'community', type: 'thread' });
++ * for await (const doc of search.iterate('minecraft', { owner: 'live' })) { … }
++ * const { suggestions } = await search.suggest('japan');
++ * const doc = await search.document('live', 'channel', '327'); // null when missing or not yours to see
++ *
++ * Who sees what is decided by Search for every hit: anonymous callers get public, published, indexable
++ * documents only; a signed-in person (Bearer user JWT, or the ov_token cookie with credentials) also
++ * gets restricted documents whose ACL names them; a first-party service with search.query.delegate may
++ * say who it asks for (actingSubject → X-OV-Subject). Nothing returns a total count.
++ *
++ * Filters: owner, type, lang, facets ({ key: value | [values] } → facet.<key>=), and `facets` (the
++ * facet keys to count, over what the caller can see).
++ */
++const { isOpenVibeError } = __load(3);
++const { isActingSubjectId } = __load(5);
++
++const enc = encodeURIComponent;
++const FACET_KEY = /^[a-z][a-z0-9_]{0,39}$/;
++
++function queryOf(text, o = {}) {
++ const q = {};
++ if (text !== undefined && text !== null) q.q = String(text);
++ for (const k of ['owner', 'type', 'lang', 'limit', 'cursor']) if (o[k] !== undefined && o[k] !== null) q[k] = o[k];
++ if (o.facets) {
++ const keys = Array.isArray(o.facets) ? o.facets : String(o.facets).split(',');
++ q.facets = keys.map((k) => String(k).trim()).filter(Boolean).join(',');
++ }
++ for (const [k, v] of Object.entries(o.filter || {})) {
++ if (!FACET_KEY.test(k)) throw new TypeError(`facet key ${k} is malformed`);
++ if (v === undefined || v === null) continue;
++ q[`facet.${k}`] = Array.isArray(v) ? v.map(String) : String(v);
++ }
++ return q;
++}
++
++// Repeated keys for several values of one facet (facet.tags=a&facet.tags=b): Search reads each as a
++// value, where the core client's comma join would make one value 'a,b'.
++function pathWith(path, q) {
++ const qs = new URLSearchParams();
++ for (const [k, v] of Object.entries(q)) for (const one of Array.isArray(v) ? v : [v]) qs.append(k, String(one));
++ const s = qs.toString();
++ return s ? `${path}?${s}` : path;
++}
++
++function createSearchClient(client, defaults = {}) {
++ const { baseUrl } = defaults;
++ function call(opts, perCall = {}) {
++ const acting = perCall.actingSubject !== undefined ? perCall.actingSubject : defaults.actingSubject;
++ const headers = { ...opts.headers };
++ if (acting) {
++ if (!isActingSubjectId(acting)) throw new TypeError('actingSubject must be a usr_… or gst_… subject id');
++ headers['X-OV-Subject'] = acting;
++ }
++ return client.json({ service: 'search', baseUrl, audience: 'openvibe.search', ...opts, headers, signal: perCall.signal });
++ }
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++
++ return {
++ /** { results, next_cursor, facets? } — one page. */
++ query: (text, o = {}) => call({ path: pathWith('/api/v1/search', queryOf(text, o)) }, o),
++ /** Every result, page after page (cursor), until Search has no more or `max` is reached. */
++ async *iterate(text, o = {}) {
++ let cursor = o.cursor || null;
++ let seen = 0;
++ const max = Number.isFinite(o.max) ? o.max : Infinity;
++ do {
++ const page = await call({ path: pathWith('/api/v1/search', queryOf(text, { ...o, cursor })) }, o);
++ for (const r of page.results || []) { if (seen++ >= max) return; yield r; }
++ cursor = page.next_cursor || null;
++ } while (cursor);
++ },
++ /** { suggestions: [{ owner, type, id, title, canonical_url }] } — titles starting with what was typed. */
++ suggest: (text, o = {}) => call({ path: pathWith('/api/v1/suggest', queryOf(text, { owner: o.owner, type: o.type, limit: o.limit })) }, o),
++ /** One document by exact id, or null (a document you may not see answers the same as a missing one). */
++ document: (owner, type, id, o = {}) => orNull(call({ path: `/api/v1/documents/${enc(owner)}/${enc(type)}/${enc(id)}` }, o)),
++ };
++}
++
++module.exports = { createSearchClient, queryOf };
++},
++// ── 16: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -1779,7 +1867,7 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { appendFiles } = __load(16);
++const { appendFiles } = __load(17);
const { parseSSE } = __load(11);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@@ -1943,7 +2031,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
--// ── 16: src/core/form.js ────────────────────────────────────────────
++// ── 17: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -1977,7 +2065,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
},
--// ── 17: src/tools.js ────────────────────────────────────────────────
++// ── 18: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2011,8 +2099,8 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { isUpload, appendFiles } = __load(16);
--const { createJobsClient } = __load(15);
++const { isUpload, appendFiles } = __load(17);
++const { createJobsClient } = __load(16);
const enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@@ -2246,7 +2334,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
--// ── 18: src/projects.js ─────────────────────────────────────────────
++// ── 19: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2377,7 +2465,7 @@ export const {
createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
-- registry, modules, realtime, media, community, jobs,
-- tools, projects,
++ registry, modules, realtime, media, community, search,
++ jobs, tools, projects,
} = sdk;
export default sdk;
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex e03c926..d9a1328 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -6,6 +6,6 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
-- community, jobs, tools, projects, vip,
++ community, search, jobs, tools, projects, vip,
} = cjs;
export default cjs;
ddiff --git a/esm/search.mjs b/esm/search.mjs
nnew file mode 100644
iindex 0000000..98a08f6
---- /dev/null
++++ b/esm/search.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/search.js. Do not edit.
++import cjs from '../src/search.js';
++
++export const {
++ createSearchClient, queryOf,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
iindex 4c730b8..ddba334 100644
---- a/index.js
++++ b/index.js
@@@ -16,6 +16,7 @@ module.exports = {
realtime: require('./src/realtime'),
media: require('./src/media'),
community: require('./src/community'),
++ search: require('./src/search'),
jobs: require('./src/jobs'),
tools: require('./src/tools'),
projects: require('./src/projects'),
ddiff --git a/package-lock.json b/package-lock.json
iindex 938ec24..6c8b7fd 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.9.1",
++ "version": "0.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.9.1",
++ "version": "0.10.0",
"license": "MIT",
"devDependencies": {
"better-sqlite3": "^11.10.0",
ddiff --git a/package.json b/package.json
iindex f195a28..eb3d490 100644
---- a/package.json
++++ b/package.json
@@@ -1,7 +1,7 @@
{
"name": "openvibe-sdk",
-- "version": "0.9.1",
-- "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools (registry, run API and jobs), VIP memberships and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
++ "version": "0.10.0",
++ "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, search, Tools (registry, run API and jobs), VIP memberships and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
"browser": "browser.js",
@@@ -65,6 +65,11 @@
"import": "./esm/community.mjs",
"default": "./src/community.js"
},
++ "./search": {
++ "types": "./types/search.d.ts",
++ "import": "./esm/search.mjs",
++ "default": "./src/search.js"
++ },
"./jobs": {
"types": "./types/jobs.d.ts",
"import": "./esm/jobs.mjs",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex e51dd56..f34366d 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -23,6 +23,7 @@ const ENTRIES = {
'realtime.mjs': '../src/realtime.js',
'media.mjs': '../src/media.js',
'community.mjs': '../src/community.js',
++ 'search.mjs': '../src/search.js',
'jobs.mjs': '../src/jobs.js',
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex e839679..a06e7c9 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.9.1';
++const SDK_VERSION = '0.10.0';
module.exports = {
createClient,
ddiff --git a/src/search.js b/src/search.js
nnew file mode 100644
iindex 0000000..0d5ed83
---- /dev/null
++++ b/src/search.js
@@@ -0,0 +1,84 @@
++'use strict';
++/**
++ * openvibe-sdk/search: OpenVibe.Search's query API (roadmap WS-F task 4). Browser-safe.
++ *
++ * const search = createSearchClient(client);
++ * const { results, next_cursor } = await search.query('forum', { owner: 'community', type: 'thread' });
++ * for await (const doc of search.iterate('minecraft', { owner: 'live' })) { … }
++ * const { suggestions } = await search.suggest('japan');
++ * const doc = await search.document('live', 'channel', '327'); // null when missing or not yours to see
++ *
++ * Who sees what is decided by Search for every hit: anonymous callers get public, published, indexable
++ * documents only; a signed-in person (Bearer user JWT, or the ov_token cookie with credentials) also
++ * gets restricted documents whose ACL names them; a first-party service with search.query.delegate may
++ * say who it asks for (actingSubject → X-OV-Subject). Nothing returns a total count.
++ *
++ * Filters: owner, type, lang, facets ({ key: value | [values] } → facet.<key>=), and `facets` (the
++ * facet keys to count, over what the caller can see).
++ */
++const { isOpenVibeError } = require('./core/errors');
++const { isActingSubjectId } = require('./core/ids');
++
++const enc = encodeURIComponent;
++const FACET_KEY = /^[a-z][a-z0-9_]{0,39}$/;
++
++function queryOf(text, o = {}) {
++ const q = {};
++ if (text !== undefined && text !== null) q.q = String(text);
++ for (const k of ['owner', 'type', 'lang', 'limit', 'cursor']) if (o[k] !== undefined && o[k] !== null) q[k] = o[k];
++ if (o.facets) {
++ const keys = Array.isArray(o.facets) ? o.facets : String(o.facets).split(',');
++ q.facets = keys.map((k) => String(k).trim()).filter(Boolean).join(',');
++ }
++ for (const [k, v] of Object.entries(o.filter || {})) {
++ if (!FACET_KEY.test(k)) throw new TypeError(`facet key ${k} is malformed`);
++ if (v === undefined || v === null) continue;
++ q[`facet.${k}`] = Array.isArray(v) ? v.map(String) : String(v);
++ }
++ return q;
++}
++
++// Repeated keys for several values of one facet (facet.tags=a&facet.tags=b): Search reads each as a
++// value, where the core client's comma join would make one value 'a,b'.
++function pathWith(path, q) {
++ const qs = new URLSearchParams();
++ for (const [k, v] of Object.entries(q)) for (const one of Array.isArray(v) ? v : [v]) qs.append(k, String(one));
++ const s = qs.toString();
++ return s ? `${path}?${s}` : path;
++}
++
++function createSearchClient(client, defaults = {}) {
++ const { baseUrl } = defaults;
++ function call(opts, perCall = {}) {
++ const acting = perCall.actingSubject !== undefined ? perCall.actingSubject : defaults.actingSubject;
++ const headers = { ...opts.headers };
++ if (acting) {
++ if (!isActingSubjectId(acting)) throw new TypeError('actingSubject must be a usr_… or gst_… subject id');
++ headers['X-OV-Subject'] = acting;
++ }
++ return client.json({ service: 'search', baseUrl, audience: 'openvibe.search', ...opts, headers, signal: perCall.signal });
++ }
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++
++ return {
++ /** { results, next_cursor, facets? } — one page. */
++ query: (text, o = {}) => call({ path: pathWith('/api/v1/search', queryOf(text, o)) }, o),
++ /** Every result, page after page (cursor), until Search has no more or `max` is reached. */
++ async *iterate(text, o = {}) {
++ let cursor = o.cursor || null;
++ let seen = 0;
++ const max = Number.isFinite(o.max) ? o.max : Infinity;
++ do {
++ const page = await call({ path: pathWith('/api/v1/search', queryOf(text, { ...o, cursor })) }, o);
++ for (const r of page.results || []) { if (seen++ >= max) return; yield r; }
++ cursor = page.next_cursor || null;
++ } while (cursor);
++ },
++ /** { suggestions: [{ owner, type, id, title, canonical_url }] } — titles starting with what was typed. */
++ suggest: (text, o = {}) => call({ path: pathWith('/api/v1/suggest', queryOf(text, { owner: o.owner, type: o.type, limit: o.limit })) }, o),
++ /** One document by exact id, or null (a document you may not see answers the same as a missing one). */
++ document: (owner, type, id, o = {}) => orNull(call({ path: `/api/v1/documents/${enc(owner)}/${enc(type)}/${enc(id)}` }, o)),
++ };
++}
++
++module.exports = { createSearchClient, queryOf };
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex a16257f..e1db771 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 17);
++ assert.equal(subpaths.length, 18);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/search.test.js b/test/search.test.js
nnew file mode 100644
iindex 0000000..219df47
---- /dev/null
++++ b/test/search.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++/** Search: query filters and facets, cursor iteration, suggest, a missing document is null, acting subject. */
++const assert = require('node:assert/strict');
++const { stubServer, send, run } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createSearchClient, queryOf } = require('../src/search');
++
++const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++
++async function search() {
++ return stubServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/api/v1/search') {
++ const cursor = u.searchParams.get('cursor');
++ if (!cursor) return send(res, 200, { results: [{ owner: 'live', type: 'vod', id: '1' }, { owner: 'live', type: 'vod', id: '2' }], next_cursor: 'c2' });
++ return send(res, 200, { results: [{ owner: 'live', type: 'vod', id: '3' }], next_cursor: null });
++ }
++ if (u.pathname === '/api/v1/suggest') return send(res, 200, { suggestions: [{ owner: 'live', type: 'channel', id: '327', title: 'JapaneseOldGuy' }] });
++ if (u.pathname.startsWith('/api/v1/documents/live/channel/404')) return send(res, 404, { type: 'about:blank', code: 'search.not_found', status: 404 });
++ if (u.pathname.startsWith('/api/v1/documents/')) return send(res, 200, { owner: 'live', type: 'channel', id: '327' });
++ return send(res, 404, {});
++ });
++}
++
++run([
++ ['query sends owner, type, facet filters and facet keys', async () => {
++ const srv = await search();
++ const s = createSearchClient(createClient({ baseUrls: { search: srv.url } }));
++ const page = await s.query('forum', { owner: 'community', type: 'thread', filter: { space: 'roadmap', tags: ['a', 'b'] }, facets: ['space', 'kind'], limit: 5 });
++ assert.equal(page.next_cursor, 'c2');
++ const u = new URL(srv.requests[0].url, 'http://x');
++ assert.equal(u.searchParams.get('q'), 'forum');
++ assert.equal(u.searchParams.get('owner'), 'community');
++ assert.equal(u.searchParams.get('type'), 'thread');
++ assert.equal(u.searchParams.get('facet.space'), 'roadmap');
++ assert.deepEqual(u.searchParams.getAll('facet.tags'), ['a', 'b']);
++ assert.equal(u.searchParams.get('facets'), 'space,kind');
++ assert.equal(srv.requests[0].headers.authorization, undefined, 'anonymous unless a token is configured');
++ assert.throws(() => queryOf('x', { filter: { 'Bad Key': 1 } }), /malformed/);
++ await srv.close();
++ }],
++ ['iterate follows the cursor until Search has no more; max stops early', async () => {
++ const srv = await search();
++ const s = createSearchClient(createClient({ baseUrls: { search: srv.url } }));
++ const ids = [];
++ for await (const d of s.iterate('vod', { owner: 'live' })) ids.push(d.id);
++ assert.deepEqual(ids, ['1', '2', '3']);
++ assert.equal(new URL(srv.requests[1].url, 'http://x').searchParams.get('cursor'), 'c2');
++ const two = [];
++ for await (const d of s.iterate('vod', { max: 2 })) two.push(d.id);
++ assert.deepEqual(two, ['1', '2']);
++ await srv.close();
++ }],
++ ['suggest, document, a missing document is null, a service acts for a person', async () => {
++ const srv = await search();
++ const s = createSearchClient(createClient({ baseUrls: { search: srv.url }, getToken: ({ audience }) => `svc-${audience}` }), { actingSubject: USR });
++ assert.equal((await s.suggest('japan', { owner: 'live' })).suggestions[0].title, 'JapaneseOldGuy');
++ assert.equal((await s.document('live', 'channel', '327')).id, '327');
++ assert.equal(await s.document('live', 'channel', '404'), null);
++ const r = srv.requests[0];
++ assert.equal(r.headers.authorization, 'Bearer svc-openvibe.search');
++ assert.equal(r.headers['x-ov-subject'], USR);
++ assert.throws(() => createSearchClient(createClient({ baseUrls: { search: srv.url } })).query('x', { actingSubject: 'nope' }), /actingSubject/);
++ await srv.close();
++ }],
++]);
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex 5f85b8a..62d72c1 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -5,6 +5,7 @@ import * as modules from './modules';
import * as realtime from './realtime';
import * as media from './media';
import * as community from './community';
++import * as search from './search';
import * as jobs from './jobs';
import * as tools from './tools';
import * as projects from './projects';
@@@ -17,12 +18,13 @@ export * as modules from './modules';
export * as realtime from './realtime';
export * as media from './media';
export * as community from './community';
++export * as search from './search';
export * as jobs from './jobs';
export * as tools from './tools';
export * as projects from './projects';
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
-- media: typeof media; community: typeof community; jobs: typeof jobs; tools: typeof tools; projects: typeof projects;
++ media: typeof media; community: typeof community; search: typeof search; jobs: typeof jobs; tools: typeof tools; projects: typeof projects;
};
export default sdk;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex 0d4a323..9adcc39 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -7,6 +7,7 @@ export * as events from './events';
export * as realtime from './realtime';
export * as media from './media';
export * as community from './community';
++export * as search from './search';
export * as jobs from './jobs';
export * as tools from './tools';
export * as projects from './projects';
ddiff --git a/types/search.d.ts b/types/search.d.ts
nnew file mode 100644
iindex 0000000..7de8390
---- /dev/null
++++ b/types/search.d.ts
@@@ -0,0 +1,31 @@
++import type { OpenVibeClient } from './core';
++
++export interface SearchDocument {
++ owner: string; type: string; id: string; revision?: number; visibility?: string;
++ title?: string; summary?: string; canonical_url?: string; language?: string | null;
++ facets?: Record<string, string | number | boolean | string[]>;
++ authorship?: 'human' | 'ai_assisted' | 'ai_generated' | 'imported';
++ published_at?: string | null; updated_at?: string | null; indexable?: boolean;
++ /** Search's escaped snippet with <mark> around matches. */
++ snippet_html?: string | null;
++ [field: string]: unknown;
++}
++export interface SearchOptions {
++ owner?: string; type?: string; lang?: string; limit?: number; cursor?: string | null;
++ /** facet.<key>=<value> filters. */
++ filter?: Record<string, string | number | boolean | Array<string | number> | null | undefined>;
++ /** Facet keys to count over what the caller can see. */
++ facets?: string[] | string;
++ /** usr_… or gst_…; services holding search.query.delegate only. */
++ actingSubject?: string;
++ signal?: AbortSignal;
++}
++export interface SearchPage { results: SearchDocument[]; next_cursor: string | null; facets?: Record<string, Array<{ value: string; count: number }>>; }
++export interface SearchClient {
++ query(text: string, opts?: SearchOptions): Promise<SearchPage>;
++ iterate(text: string, opts?: SearchOptions & { max?: number }): AsyncGenerator<SearchDocument, void, unknown>;
++ suggest(text: string, opts?: Pick<SearchOptions, 'owner' | 'type' | 'limit' | 'actingSubject' | 'signal'>): Promise<{ suggestions: Array<{ owner: string; type: string; id: string; title: string; canonical_url?: string }> }>;
++ document(owner: string, type: string, id: string, opts?: Pick<SearchOptions, 'actingSubject' | 'signal'>): Promise<SearchDocument | null>;
++}
++export function createSearchClient(client: OpenVibeClient, defaults?: { baseUrl?: string; actingSubject?: string }): SearchClient;
++export function queryOf(text: string, opts?: SearchOptions): Record<string, string | string[] | number>;