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
Changes
@@@ -3,6 +3,10 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease 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.@@@ -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` |@@@ -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'),@@@ -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 = 'openmodule.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 = 'opemodule.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;@@@ -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;nnew file mode 100644@@@ -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;@@@ -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'),@@@ -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",@@@ -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",@@@ -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',@@@ -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,nnew file mode 100644@@@ -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 };@@@ -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 });nnew file mode 100644@@@ -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();++ }],++]);@@@ -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;@@@ -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';nnew file mode 100644@@@ -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>;
@@ -3,6 +3,10 @@
@@ -3,6 +3,10 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
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.
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)
## 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.
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.
@@ -164,6 +164,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -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/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/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/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/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/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` |
| `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` |
@@ -13,6 +13,7 @@ module.exports = {
@@ -13,6 +13,7 @@ module.exports = {
realtime: require('./src/realtime'),
realtime: require('./src/realtime'),
media: require('./src/media'),
media: require('./src/media'),
community: require('./src/community'),
community: require('./src/community'),
+ search: require('./src/search'),
jobs: require('./src/jobs'),
jobs: require('./src/jobs'),
tools: require('./src/tools'),
tools: require('./src/tools'),
projects: require('./src/projects'),
projects: require('./src/projects'),
@@ -1,6 +1,6 @@
@@ -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
// 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 */
/* eslint-disable */
const __defs = [
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
// ── 0: browser.js ──────────────────────────────────────────────────
@@ -20,9 +20,10 @@ module.exports = {
@@ -20,9 +20,10 @@ module.exports = {
realtime: __load(11),
realtime: __load(11),
media: __load(12),
media: __load(12),
community: __load(14),
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 ───────────────────────────────────────────
// ── 1: src/core/index.js ───────────────────────────────────────────
@@ -37,7 +38,7 @@ const { paginate, offsetPager } = __load(7);
@@ -37,7 +38,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
@@ -1741,7 +1742,94 @@ function createCommunityClient(client, defaults = {}) {
@@ -1741,7 +1742,94 @@ function createCommunityClient(client, defaults = {}) {
module.exports = { createCommunityClient, actingHeaders };
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) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -1779,7 +1867,7 @@ function (module, exports) {
@@ -1779,7 +1867,7 @@ function (module, exports) {
*/
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
const { newIdempotencyKey } = __load(5);
-const { appendFiles } = __load(16);
+const { appendFiles } = __load(17);
const { parseSSE } = __load(11);
const { parseSSE } = __load(11);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@ -1943,7 +2031,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
@@ -1943,7 +2031,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
},
-// ── 16: src/core/form.js ────────────────────────────────────────────
+// ── 17: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -1977,7 +2065,7 @@ function appendFiles(form, list, where) {
@@ -1977,7 +2065,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
module.exports = { isBlob, isUpload, appendFiles };
},
},
-// ── 17: src/tools.js ────────────────────────────────────────────────
+// ── 18: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2011,8 +2099,8 @@ function (module, exports) {
@@ -2011,8 +2099,8 @@ function (module, exports) {
*/
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
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 enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@ -2246,7 +2334,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
@@ -2246,7 +2334,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
},
-// ── 18: src/projects.js ─────────────────────────────────────────────
+// ── 19: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2377,7 +2465,7 @@ export const {
@@ -2377,7 +2465,7 @@ export const {
createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,
createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
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;
} = sdk;
export default sdk;
export default sdk;
@@ -6,6 +6,6 @@ export const {
@@ -6,6 +6,6 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
registry, identity, modules, events, realtime, media,
- community, jobs, tools, projects, vip,
+ community, search, jobs, tools, projects, vip,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -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;
@@ -16,6 +16,7 @@ module.exports = {
@@ -16,6 +16,7 @@ module.exports = {
realtime: require('./src/realtime'),
realtime: require('./src/realtime'),
media: require('./src/media'),
media: require('./src/media'),
community: require('./src/community'),
community: require('./src/community'),
+ search: require('./src/search'),
jobs: require('./src/jobs'),
jobs: require('./src/jobs'),
tools: require('./src/tools'),
tools: require('./src/tools'),
projects: require('./src/projects'),
projects: require('./src/projects'),
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.9.1",
+ "version": "0.10.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.9.1",
+ "version": "0.10.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"name": "openvibe-sdk",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
"browser": "browser.js",
"browser": "browser.js",
@@ -65,6 +65,11 @@
@@ -65,6 +65,11 @@
"import": "./esm/community.mjs",
"import": "./esm/community.mjs",
"default": "./src/community.js"
"default": "./src/community.js"
},
},
+ "./search": {
+ "types": "./types/search.d.ts",
+ "import": "./esm/search.mjs",
+ "default": "./src/search.js"
+ },
"./jobs": {
"./jobs": {
"types": "./types/jobs.d.ts",
"types": "./types/jobs.d.ts",
"import": "./esm/jobs.mjs",
"import": "./esm/jobs.mjs",
@@ -23,6 +23,7 @@ const ENTRIES = {
@@ -23,6 +23,7 @@ const ENTRIES = {
'realtime.mjs': '../src/realtime.js',
'realtime.mjs': '../src/realtime.js',
'media.mjs': '../src/media.js',
'media.mjs': '../src/media.js',
'community.mjs': '../src/community.js',
'community.mjs': '../src/community.js',
+ 'search.mjs': '../src/search.js',
'jobs.mjs': '../src/jobs.js',
'jobs.mjs': '../src/jobs.js',
'tools.mjs': '../src/tools.js',
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
'projects.mjs': '../src/projects.js',
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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 };
@@ -66,7 +66,7 @@ run([
@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
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.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');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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();
+ }],
+]);
@@ -5,6 +5,7 @@ import * as modules from './modules';
@@ -5,6 +5,7 @@ import * as modules from './modules';
import * as realtime from './realtime';
import * as realtime from './realtime';
import * as media from './media';
import * as media from './media';
import * as community from './community';
import * as community from './community';
+import * as search from './search';
import * as jobs from './jobs';
import * as jobs from './jobs';
import * as tools from './tools';
import * as tools from './tools';
import * as projects from './projects';
import * as projects from './projects';
@@ -17,12 +18,13 @@ export * as modules from './modules';
@@ -17,12 +18,13 @@ export * as modules from './modules';
export * as realtime from './realtime';
export * as realtime from './realtime';
export * as media from './media';
export * as media from './media';
export * as community from './community';
export * as community from './community';
+export * as search from './search';
export * as jobs from './jobs';
export * as jobs from './jobs';
export * as tools from './tools';
export * as tools from './tools';
export * as projects from './projects';
export * as projects from './projects';
declare const sdk: typeof core & {
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
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;
export default sdk;
@@ -7,6 +7,7 @@ export * as events from './events';
@@ -7,6 +7,7 @@ export * as events from './events';
export * as realtime from './realtime';
export * as realtime from './realtime';
export * as media from './media';
export * as media from './media';
export * as community from './community';
export * as community from './community';
+export * as search from './search';
export * as jobs from './jobs';
export * as jobs from './jobs';
export * as tools from './tools';
export * as tools from './tools';
export * as projects from './projects';
export * as projects from './projects';
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -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>;