openvibers/openvibe.sdk Commits

The public surface is pinned: docs/api-surface.json lists every name each entry exports, and a test fails when they disagree (#76)

OP OpenVibers committed 21 days ago · 5b3fba8587 · 6 files, +923 −1

scripts/surface.js writes it (66 entries, 325 names: function, class, a plain object's keys, …; getters
for optional peers as 'lazy', never called) and --check names each change as breaking (a name gone, a
kind changed, a constant's key gone; '[1.0 core]' for core, auth, events, db and media) or added. The
first step of the 1.0 freeze (plan G1). No behaviour changes.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex fe406ce..8ac2124 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -5,6 +5,10 @@ release may change an API and says so here.
## 0.63.0 (2026-10-24)
++- **The public surface is pinned.** `docs/api-surface.json` lists every name each of the 66 entries exports and what it
++ is; `test/surface.test.js` fails when they disagree, and `node scripts/surface.js --check` names each change as
++ breaking (a name gone, a kind changed, a constant's key gone) or added. The first step toward 1.0's freeze of
++ `core`, `auth`, `events`, `db` and `media`. Nothing in the package's behaviour changes.
- **Outbox relays pause on a 429.** `createPgOutbox` (and so `createServiceOutbox` and the usage relay) used to hit
Events' per-actor limit again on every tick after a 429, because the rows it claims next are under the same limit.
Search's re-index bursts were refused 935 times on 2026-10-23, and every event was delivered only on a retry.
ddiff --git a/README.md b/README.md
iindex 94c034f..e59c258 100644
---- a/README.md
++++ b/README.md
@@@ -477,6 +477,7 @@ Install the drivers the service uses: `pg` and `iovalkey` (production), `@electr
## Versioning
- `openvibe-sdk` follows semver. While it is 0.x, a minor release may break an API, and [CHANGELOG.md](CHANGELOG.md) says so. Pin a tag.
++- [docs/api-surface.json](docs/api-surface.json) lists every name each entry exports and what it is (function, class, a plain object's keys, …). `test/surface.test.js` fails when the exports and the file disagree, so a removal or a change of kind is a reviewed line in a diff, never a surprise on a service's next pin round; `node scripts/surface.js --check` names each change as breaking or added, and `node scripts/surface.js` updates the file. 1.0 freezes `core`, `auth`, `events`, `db` and `media` first: a breaking change there is marked `[1.0 core]`.
- Each release states the openvibe-contracts range it was tested against (`CONTRACTS_RANGE`), and `discover()` checks it at runtime. Contract types are copied into `types/contracts.d.ts`. `test/types.test.js` fails if they differ from the pinned Contracts release, the `openvibe-contracts` devDependency (a release tarball pin, v0.86.0), which the tools tests also use to validate requests and answers. It is never a runtime or peer dependency.
- A new public capability ships as a minor release: first the contract, then the wrapper here, then the service's route. Removing a wrapper is a major release, after the capability's deprecation window in Contracts has passed.
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
nnew file mode 100644
iindex 0000000..5271b95
---- /dev/null
++++ b/docs/api-surface.json
@@@ -0,0 +1,761 @@
++{
++ "openvibe-sdk": {
++ "CONTRACTS_RANGE": "string",
++ "DEFAULT_NETWORK": "string",
++ "OpenVibeError": "class",
++ "SDK_VERSION": "string",
++ "actor": {
++ "object": [
++ "createActorClient"
++ ]
++ },
++ "ai": {
++ "object": [
++ "OPS",
++ "TERMINAL",
++ "createAiClient"
++ ]
++ },
++ "auth": {
++ "object": [
++ "TOKEN_VALID_AFTER",
++ "base64url",
++ "buildAuthorizeUrl",
++ "createCodeVerifier",
++ "createJwksClient",
++ "createNetworkKeys",
++ "createPgRevocationStore",
++ "createPkcePair",
++ "createServiceTokenClient",
++ "createState",
++ "decodeUnverified",
++ "exchangeCode",
++ "jwksClient",
++ "jwksStatus",
++ "pkceChallenge",
++ "readCallback",
++ "refreshUserToken",
++ "revocationSchema",
++ "startAuthorization",
++ "unverifiedClaims",
++ "verifyAppToken",
++ "verifyServiceToken",
++ "verifyUserToken"
++ ]
++ },
++ "blog": {
++ "object": [
++ "createBlogClient"
++ ]
++ },
++ "bot": {
++ "object": [
++ "actingHeaders",
++ "createBotClient"
++ ]
++ },
++ "chat": {
++ "object": [
++ "createChatClient"
++ ]
++ },
++ "commerce": {
++ "object": [
++ "CommerceError",
++ "createCommerceClient",
++ "intentKey",
++ "receipt"
++ ]
++ },
++ "community": {
++ "object": [
++ "actingHeaders",
++ "createCommunityClient"
++ ]
++ },
++ "compareVersions": "function",
++ "contextFromHeaders": "function",
++ "coupons": {
++ "object": [
++ "createCouponsClient"
++ ]
++ },
++ "createClient": "function",
++ "deals": {
++ "object": [
++ "createDealsClient"
++ ]
++ },
++ "download": {
++ "object": [
++ "contentTypeOf",
++ "createDownloadClient"
++ ]
++ },
++ "events": {
++ "object": [
++ "appSource",
++ "createAppEvents",
++ "createEventsClient",
++ "createPgInbox",
++ "createPgOutbox",
++ "createServiceOutbox",
++ "inboxSchema",
++ "outboxSchema",
++ "parseDelivery",
++ "projectKey",
++ "signDelivery",
++ "signDeliveryHeaders",
++ "signDeliveryV2",
++ "verifyDelivery",
++ "verifyDeliveryV2"
++ ]
++ },
++ "food": {
++ "object": [
++ "createFoodClient"
++ ]
++ },
++ "geo": {
++ "object": [
++ "createGeoClient"
++ ]
++ },
++ "host": {
++ "object": [
++ "createHostClient"
++ ]
++ },
++ "identity": {
++ "object": [
++ "createIdentityClient"
++ ]
++ },
++ "inventory": {
++ "object": [
++ "createInventoryClient"
++ ]
++ },
++ "isActingSubjectId": "function",
++ "isOpenVibeError": "function",
++ "jobs": {
++ "object": [
++ "TERMINAL_STATES",
++ "createJobsClient",
++ "isTerminal"
++ ]
++ },
++ "live": {
++ "object": [
++ "createLiveClient"
++ ]
++ },
++ "media": {
++ "object": [
++ "DEFAULT_PUBLIC_ORIGIN",
++ "createMediaClient",
++ "createObjectsClient",
++ "mediaUrls"
++ ]
++ },
++ "modules": {
++ "object": [
++ "createModulesClient"
++ ]
++ },
++ "newEventId": "function",
++ "newIdempotencyKey": "function",
++ "news": {
++ "object": [
++ "createNewsClient"
++ ]
++ },
++ "offsetPager": "function",
++ "openre": {
++ "object": [
++ "createOpenReClient"
++ ]
++ },
++ "paginate": "function",
++ "parseTraceparent": "function",
++ "play": {
++ "object": [
++ "createPlayClient"
++ ]
++ },
++ "projects": {
++ "object": [
++ "createProjectsClient"
++ ]
++ },
++ "quest": {
++ "object": [
++ "createQuestClient",
++ "eventType",
++ "projectKey"
++ ]
++ },
++ "realtime": {
++ "object": [
++ "DEFAULT_ORIGIN",
++ "createRealtimeClient",
++ "parseSSE",
++ "subscribe"
++ ]
++ },
++ "registry": {
++ "object": [
++ "createRegistryClient"
++ ]
++ },
++ "resources": {
++ "object": [
++ "RESOURCE_KINDS",
++ "createResourceClient",
++ "createResourceIndex",
++ "parseResourceName",
++ "resourceName",
++ "resourceNameOf"
++ ]
++ },
++ "reviews": {
++ "object": [
++ "createReviewsClient"
++ ]
++ },
++ "run": {
++ "object": [
++ "RUN_JOB_STATES",
++ "createRunClient"
++ ]
++ },
++ "satisfiesRange": "function",
++ "search": {
++ "object": [
++ "createSearchClient",
++ "queryOf"
++ ]
++ },
++ "space": {
++ "object": [
++ "createSpaceClient"
++ ]
++ },
++ "startSpan": "function",
++ "telemetry": {
++ "object": [
++ "createPgTelemetrySink",
++ "createTelemetry",
++ "pruneTelemetry",
++ "startTelemetryRetention",
++ "telemetrySample",
++ "telemetrySchema",
++ "validateTelemetrySample"
++ ]
++ },
++ "tips": {
++ "object": [
++ "createTipsClient"
++ ]
++ },
++ "tools": {
++ "object": [
++ "ToolRunError",
++ "createToolsClient",
++ "isToolRunError"
++ ]
++ },
++ "trade": {
++ "object": [
++ "createTradeClient"
++ ]
++ },
++ "ulid": "function",
++ "usage": {
++ "object": [
++ "createUsageReporter",
++ "usageKey",
++ "usageSample",
++ "validateUsageSample"
++ ]
++ },
++ "video": {
++ "object": [
++ "createVideoClient"
++ ]
++ },
++ "vip": {
++ "object": [
++ "createVipCache",
++ "createVipClient"
++ ]
++ },
++ "wiki": {
++ "object": [
++ "createWikiClient"
++ ]
++ },
++ "work": {
++ "object": [
++ "WORK_APPLICATION_STATUSES",
++ "createWorkClient"
++ ]
++ },
++ "zone": {
++ "object": [
++ "createZoneClient"
++ ]
++ }
++ },
++ "openvibe-sdk/account-data": {
++ "ACCOUNT_DATA_SCHEMA": "string",
++ "TOPICS": "array",
++ "createAccountData": "function",
++ "createNetworkSender": "function",
++ "startSubscriptions": "function"
++ },
++ "openvibe-sdk/actor": {
++ "createActorClient": "function"
++ },
++ "openvibe-sdk/ai": {
++ "OPS": "array",
++ "TERMINAL": "object",
++ "createAiClient": "function"
++ },
++ "openvibe-sdk/auth": {
++ "TOKEN_VALID_AFTER": "string",
++ "base64url": "function",
++ "buildAuthorizeUrl": "function",
++ "createCodeVerifier": "function",
++ "createJwksClient": "function",
++ "createNetworkKeys": "function",
++ "createPgRevocationStore": "function",
++ "createPkcePair": "function",
++ "createServiceTokenClient": "function",
++ "createState": "function",
++ "decodeUnverified": "function",
++ "exchangeCode": "function",
++ "jwksClient": "function",
++ "jwksStatus": "function",
++ "pkceChallenge": "function",
++ "readCallback": "function",
++ "refreshUserToken": "function",
++ "revocationSchema": "function",
++ "startAuthorization": "function",
++ "unverifiedClaims": "function",
++ "verifyAppToken": "function",
++ "verifyServiceToken": "function",
++ "verifyUserToken": "function"
++ },
++ "openvibe-sdk/auth/browser": {
++ "base64url": "function",
++ "buildAuthorizeUrl": "function",
++ "createCodeVerifier": "function",
++ "createPkcePair": "function",
++ "createState": "function",
++ "decodeUnverified": "function",
++ "pkceChallenge": "function",
++ "readCallback": "function",
++ "startAuthorization": "function",
++ "unverifiedClaims": "function"
++ },
++ "openvibe-sdk/blog": {
++ "createBlogClient": "function"
++ },
++ "openvibe-sdk/bot": {
++ "actingHeaders": "function",
++ "createBotClient": "function"
++ },
++ "openvibe-sdk/cache": {
++ "createCache": "function"
++ },
++ "openvibe-sdk/chat": {
++ "createChatClient": "function"
++ },
++ "openvibe-sdk/commerce": {
++ "CommerceError": "class",
++ "createCommerceClient": "function",
++ "intentKey": "function",
++ "receipt": "function"
++ },
++ "openvibe-sdk/community": {
++ "actingHeaders": "function",
++ "createCommunityClient": "function"
++ },
++ "openvibe-sdk/core": {
++ "CONTRACTS_RANGE": "string",
++ "DEFAULT_NETWORK": "string",
++ "OpenVibeError": "class",
++ "SDK_VERSION": "string",
++ "compareVersions": "function",
++ "contextFromHeaders": "function",
++ "createClient": "function",
++ "isActingSubjectId": "function",
++ "isOpenVibeError": "function",
++ "newEventId": "function",
++ "newIdempotencyKey": "function",
++ "offsetPager": "function",
++ "paginate": "function",
++ "parseTraceparent": "function",
++ "satisfiesRange": "function",
++ "startSpan": "function",
++ "ulid": "function"
++ },
++ "openvibe-sdk/cost": {
++ "costSnapshot": "function",
++ "estimate": "function",
++ "rateCard": "function",
++ "validateCostSnapshot": "function",
++ "validateRateCard": "function"
++ },
++ "openvibe-sdk/coupons": {
++ "createCouponsClient": "function"
++ },
++ "openvibe-sdk/db": {
++ "DbError": "class",
++ "ISOLATION": {
++ "object": [
++ "read committed",
++ "repeatable read",
++ "serializable"
++ ]
++ },
++ "PARSERS": {
++ "object": [
++ "1082",
++ "1114",
++ "114",
++ "1184",
++ "17",
++ "1700",
++ "20",
++ "3802"
++ ]
++ },
++ "createDb": "function",
++ "sql": "function"
++ },
++ "openvibe-sdk/deals": {
++ "createDealsClient": "function"
++ },
++ "openvibe-sdk/delivery": {
++ "deliveryOffer": "function",
++ "validateDeliveryOffer": "function"
++ },
++ "openvibe-sdk/download": {
++ "contentTypeOf": "function",
++ "createDownloadClient": "function"
++ },
++ "openvibe-sdk/events": {
++ "appSource": "function",
++ "createAppEvents": "function",
++ "createEventsClient": "function",
++ "createPgInbox": "function",
++ "createPgOutbox": "function",
++ "createServiceOutbox": "function",
++ "inboxSchema": "function",
++ "outboxSchema": "function",
++ "parseDelivery": "function",
++ "projectKey": "function",
++ "signDelivery": "function",
++ "signDeliveryHeaders": "function",
++ "signDeliveryV2": "function",
++ "verifyDelivery": "function",
++ "verifyDeliveryV2": "function"
++ },
++ "openvibe-sdk/food": {
++ "createFoodClient": "function"
++ },
++ "openvibe-sdk/frame": {
++ "DEFAULT_BASE": "string",
++ "frameConfig": "function",
++ "frameTags": "function",
++ "mountFrame": "function",
++ "scriptUrls": "function"
++ },
++ "openvibe-sdk/geo": {
++ "createGeoClient": "function"
++ },
++ "openvibe-sdk/govern": {
++ "UNITS": "array",
++ "WINDOWS": {
++ "object": [
++ "day",
++ "hour",
++ "minute",
++ "month"
++ ]
++ },
++ "createGovernor": "function"
++ },
++ "openvibe-sdk/host": {
++ "createHostClient": "function"
++ },
++ "openvibe-sdk/identity": {
++ "createIdentityClient": "function"
++ },
++ "openvibe-sdk/inventory": {
++ "createInventoryClient": "function"
++ },
++ "openvibe-sdk/jobs": {
++ "TERMINAL_STATES": "array",
++ "createJobsClient": "function",
++ "isTerminal": "function"
++ },
++ "openvibe-sdk/limits": {
++ "WINDOWS": {
++ "object": [
++ "day",
++ "hour",
++ "minute"
++ ]
++ },
++ "createActorLimiter": "function",
++ "createValkeyLimitStore": "function",
++ "defaultActor": "function"
++ },
++ "openvibe-sdk/live": {
++ "createLiveClient": "function"
++ },
++ "openvibe-sdk/media": {
++ "DEFAULT_PUBLIC_ORIGIN": "string",
++ "createMediaClient": "function",
++ "createObjectsClient": "function",
++ "mediaUrls": "function"
++ },
++ "openvibe-sdk/modules": {
++ "createModulesClient": "function"
++ },
++ "openvibe-sdk/news": {
++ "createNewsClient": "function"
++ },
++ "openvibe-sdk/no-looks": {
++ "RULES_VERSION": "string",
++ "hasLooks": "function",
++ "noLooks": "function",
++ "noLooksTags": "function"
++ },
++ "openvibe-sdk/node": {
++ "nodeCapabilities": "function",
++ "nodeOffers": "function",
++ "validateNodeCapabilities": "function"
++ },
++ "openvibe-sdk/notifications": {
++ "createNotificationsClient": "function"
++ },
++ "openvibe-sdk/openre": {
++ "createOpenReClient": "function"
++ },
++ "openvibe-sdk/placement": {
++ "TRUST_CLASSES": "array",
++ "TRUST_ORDER": "array",
++ "canonical": "function",
++ "createPlanHolder": "function",
++ "excluded": "function",
++ "forecast": "function",
++ "marginalCost": "function",
++ "ownedLoadCost": "function",
++ "pickTwo": "function",
++ "plan": "function",
++ "priceOf": "function",
++ "rendezvous": "function",
++ "signPlan": "function",
++ "verifyPlan": "function"
++ },
++ "openvibe-sdk/play": {
++ "createPlayClient": "function"
++ },
++ "openvibe-sdk/projects": {
++ "createProjectsClient": "function"
++ },
++ "openvibe-sdk/pubsub": {
++ "createPubSub": "function"
++ },
++ "openvibe-sdk/quest": {
++ "createQuestClient": "function",
++ "eventType": "function",
++ "projectKey": "function"
++ },
++ "openvibe-sdk/queue": {
++ "backoff": "function",
++ "createQueue": "function"
++ },
++ "openvibe-sdk/realtime": {
++ "DEFAULT_ORIGIN": "string",
++ "createRealtimeClient": "function",
++ "parseSSE": "function",
++ "subscribe": "function"
++ },
++ "openvibe-sdk/registry": {
++ "createRegistryClient": "function"
++ },
++ "openvibe-sdk/resources": {
++ "RESOURCE_KINDS": "array",
++ "createResourceClient": "function",
++ "createResourceIndex": "function",
++ "parseResourceName": "function",
++ "resourceName": "function",
++ "resourceNameOf": "function"
++ },
++ "openvibe-sdk/reviews": {
++ "createReviewsClient": "function"
++ },
++ "openvibe-sdk/run": {
++ "RUN_JOB_STATES": "array",
++ "createRunClient": "function"
++ },
++ "openvibe-sdk/runtime": {
++ "RUNTIME_CLASSES": "array",
++ "runtimeClass": "function",
++ "runtimeOffer": "function",
++ "validateRuntimeClass": "function",
++ "validateRuntimeOffer": "function"
++ },
++ "openvibe-sdk/search": {
++ "createSearchClient": "function",
++ "queryOf": "function"
++ },
++ "openvibe-sdk/service": {
++ "DEADLINE_MS": "number",
++ "DEFAULT_INTERVAL_MS": "number",
++ "DEFAULT_MAX_ROUTE_KEYS": "number",
++ "DEFAULT_SKIP_PREFIXES": "array",
++ "DRAIN_MS": "number",
++ "HTTP_METHODS": "object",
++ "LATENCY_SAMPLE_CAP": "number",
++ "ServiceError": "class",
++ "asServiceError": "function",
++ "createHttpTelemetry": "function",
++ "createReadiness": "lazy",
++ "createRegistry": "lazy",
++ "createRelease": "lazy",
++ "createServiceError": "function",
++ "createTelemetryMiddleware": "function",
++ "defaultRouteLabel": "function",
++ "eventLoopLagMs": "function",
++ "eventLoopMonitorEnabled": "function",
++ "gracefulStop": "function",
++ "instrument": "lazy",
++ "isLoopbackDirect": "lazy",
++ "jsonBody": "function",
++ "jsonErrors": "function",
++ "metricsHandler": "lazy",
++ "privateNoStore": "function",
++ "problem": "lazy",
++ "registerSignals": "function",
++ "releaseInfo": "lazy",
++ "run": "function",
++ "safeReason": "lazy",
++ "sendError": "function",
++ "sendProblem": "lazy",
++ "skip": "lazy",
++ "startEventLoopMonitor": "function",
++ "stopEventLoopMonitor": "function",
++ "telemetry": {
++ "object": [
++ "count",
++ "flush",
++ "gauge",
++ "init",
++ "middleware",
++ "record",
++ "requestFinished",
++ "requestStarted",
++ "stop"
++ ]
++ },
++ "telemetryMiddleware": "function",
++ "telemetrySample": "function",
++ "telemetrySkipped": "function",
++ "validateTelemetrySample": "function",
++ "within": "function",
++ "wrap": "function"
++ },
++ "openvibe-sdk/space": {
++ "createSpaceClient": "function"
++ },
++ "openvibe-sdk/sso": {
++ "COOKIES": {
++ "object": [
++ "access",
++ "hint",
++ "refresh"
++ ]
++ },
++ "claimsToUser": "function",
++ "createSsoClient": "function",
++ "decodeJwtPayload": "function",
++ "fedcmNonceMatches": "function",
++ "parseCookies": "function",
++ "sanitizeNext": "function",
++ "withParam": "function"
++ },
++ "openvibe-sdk/storage": {
++ "storageOffer": "function",
++ "validateStorageOffer": "function"
++ },
++ "openvibe-sdk/telemetry": {
++ "createPgTelemetrySink": "function",
++ "createTelemetry": "function",
++ "pruneTelemetry": "function",
++ "startTelemetryRetention": "function",
++ "telemetrySample": "function",
++ "telemetrySchema": "function",
++ "validateTelemetrySample": "function"
++ },
++ "openvibe-sdk/testing": {
++ "DEFAULT_APP_CATALOG": "array",
++ "DEFAULT_ORIGINS": {
++ "object": [
++ "community",
++ "events",
++ "media",
++ "network",
++ "tools"
++ ]
++ },
++ "DEFAULT_TOOLS_SATELLITES": "array",
++ "createMockPlatform": "function",
++ "createTestDb": "function",
++ "createTestValkey": "function",
++ "pgAvailable": "function",
++ "valkeyAvailable": "function"
++ },
++ "openvibe-sdk/tips": {
++ "createTipsClient": "function"
++ },
++ "openvibe-sdk/tools": {
++ "ToolRunError": "class",
++ "createToolsClient": "function",
++ "isToolRunError": "function"
++ },
++ "openvibe-sdk/trade": {
++ "createTradeClient": "function"
++ },
++ "openvibe-sdk/usage": {
++ "createUsageReporter": "function",
++ "usageKey": "function",
++ "usageSample": "function",
++ "validateUsageSample": "function"
++ },
++ "openvibe-sdk/valkey": {
++ "createValkey": "function"
++ },
++ "openvibe-sdk/video": {
++ "createVideoClient": "function"
++ },
++ "openvibe-sdk/vip": {
++ "createVipCache": "function",
++ "createVipClient": "function"
++ },
++ "openvibe-sdk/wiki": {
++ "createWikiClient": "function"
++ },
++ "openvibe-sdk/work": {
++ "WORK_APPLICATION_STATUSES": "array",
++ "createWorkClient": "function"
++ },
++ "openvibe-sdk/zone": {
++ "createZoneClient": "function"
++ }
++}
ddiff --git a/package.json b/package.json
iindex bea19d4..206110b 100644
---- a/package.json
++++ b/package.json
@@@ -410,7 +410,8 @@
"scripts": {
"test": "node test/run.js",
"esm": "node scripts/esm.js",
-- "browser-bundle": "node scripts/browser-bundle.js"
++ "browser-bundle": "node scripts/browser-bundle.js",
++ "surface": "node scripts/surface.js"
},
"engines": {
"node": ">=20"
ddiff --git a/scripts/surface.js b/scripts/surface.js
nnew file mode 100644
iindex 0000000..58c8b50
---- /dev/null
++++ b/scripts/surface.js
@@@ -0,0 +1,112 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * The public surface: every name each package entry exports, and what it is, in docs/api-surface.json. About 40
++ * services pin this package, so a name that disappears or changes kind breaks them on their next pin round; the file
++ * makes every such change a reviewed line in a diff, and test/surface.test.js fails when the exports and the file
++ * disagree.
++ *
++ * node scripts/surface.js write docs/api-surface.json from the exports
++ * node scripts/surface.js --check exit 1, listing what changed, when they disagree
++ *
++ * For each entry (`openvibe-sdk`, `openvibe-sdk/core`, …): name → "function" | "class" | "array" | "string" |
++ * "number" | "boolean" | "null", { "object": [its keys] } for a plain object (constants, error codes), one level
++ * deep, or "lazy" for a getter that loads an optional peer package (`openvibe-sdk/service`'s openvibe-shared parts):
++ * it is never called here. A name removed, a kind changed or an object key removed is breaking; anything new is an
++ * addition.
++ */
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++const FILE = path.join(ROOT, 'docs', 'api-surface.json');
++// What the 1.0 freeze covers first (README, "Versioning"): a change there is marked on its own.
++const CORE = ['openvibe-sdk/core', 'openvibe-sdk/auth', 'openvibe-sdk/events', 'openvibe-sdk/db', 'openvibe-sdk/media'];
++
++function kindOf(v) {
++ if (typeof v === 'function') return /^class[\s{]/.test(Function.prototype.toString.call(v)) ? 'class' : 'function';
++ if (v === null) return 'null';
++ if (Array.isArray(v)) return 'array';
++ return typeof v;
++}
++
++function isPlainObject(v) {
++ if (!v || typeof v !== 'object' || Array.isArray(v)) return false;
++ const proto = Object.getPrototypeOf(v);
++ return proto === Object.prototype || proto === null;
++}
++
++/** One module's exports as the file records them. */
++function describe(mod) {
++ const out = {};
++ for (const name of Object.keys(mod).sort()) {
++ const d = Object.getOwnPropertyDescriptor(mod, name);
++ if (d && d.get) { out[name] = 'lazy'; continue; }
++ const v = mod[name];
++ out[name] = isPlainObject(v) ? { object: Object.keys(v).sort() } : kindOf(v);
++ }
++ return out;
++}
++
++/** Every entry in package.json "exports" (the browser bundle and package.json itself aside), by its import name. */
++function current() {
++ const pkg = require(path.join(ROOT, 'package.json'));
++ const surface = {};
++ for (const sub of Object.keys(pkg.exports).sort()) {
++ if (sub === './package.json' || sub.endsWith('.mjs')) continue;
++ const spec = sub === '.' ? 'openvibe-sdk' : `openvibe-sdk/${sub.slice(2)}`;
++ surface[spec] = describe(require(spec));
++ }
++ return surface;
++}
++
++const kindName = (k) => (typeof k === 'string' ? k : 'object');
++
++/** → { breaking: [...], added: [...] } between two surfaces. */
++function diff(before, after) {
++ const breaking = [];
++ const added = [];
++ const tag = (spec) => (CORE.includes(spec) ? ' [1.0 core]' : '');
++ for (const spec of Object.keys(before)) {
++ if (!(spec in after)) { breaking.push(`${spec}: the entry is gone${tag(spec)}`); continue; }
++ const was = before[spec];
++ const now = after[spec];
++ for (const name of Object.keys(was)) {
++ const a = was[name];
++ const b = now[name];
++ if (b === undefined) breaking.push(`${spec}: ${name} is gone${tag(spec)}`);
++ else if (a !== 'lazy' && b !== 'lazy' && kindName(a) !== kindName(b)) breaking.push(`${spec}: ${name} was a ${kindName(a)}, now a ${kindName(b)}${tag(spec)}`);
++ else if (typeof a !== 'string') {
++ for (const k of a.object) if (!b.object.includes(k)) breaking.push(`${spec}: ${name}.${k} is gone${tag(spec)}`);
++ for (const k of b.object) if (!a.object.includes(k)) added.push(`${spec}: ${name}.${k}`);
++ }
++ }
++ for (const name of Object.keys(now)) if (!(name in was)) added.push(`${spec}: ${name}`);
++ }
++ for (const spec of Object.keys(after)) if (!(spec in before)) added.push(`${spec} (a new entry)`);
++ return { breaking, added };
++}
++
++function render(surface) {
++ return `${JSON.stringify(surface, null, 2)}\n`;
++}
++
++function main(argv = process.argv.slice(2)) {
++ const now = current();
++ if (!argv.includes('--check')) {
++ fs.writeFileSync(FILE, render(now));
++ console.log(`wrote ${path.relative(ROOT, FILE)}: ${Object.keys(now).length} entries, ${Object.values(now).reduce((n, m) => n + Object.keys(m).length, 0)} names`);
++ return 0;
++ }
++ const recorded = fs.existsSync(FILE) ? JSON.parse(fs.readFileSync(FILE, 'utf8')) : {};
++ const { breaking, added } = diff(recorded, now);
++ if (!breaking.length && !added.length) return 0;
++ if (breaking.length) console.error(`Breaking (say so in CHANGELOG.md; from 1.0 on it needs a major release):\n ${breaking.join('\n ')}`);
++ if (added.length) console.error(`Added:\n ${added.join('\n ')}`);
++ console.error('Then run `node scripts/surface.js` to update docs/api-surface.json.');
++ return 1;
++}
++
++if (require.main === module) process.exitCode = main();
++
++module.exports = { current, describe, diff, render, CORE, FILE };
ddiff --git a/test/surface.test.js b/test/surface.test.js
nnew file mode 100644
iindex 0000000..86b33b3
---- /dev/null
++++ b/test/surface.test.js
@@@ -0,0 +1,43 @@
++'use strict';
++/**
++ * docs/api-surface.json (scripts/surface.js) matches what every entry exports, so a name that disappears or changes
++ * kind is a reviewed line in a diff, never a surprise on a service's next pin round; and the diff calls it breaking.
++ */
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const { run } = require('./helpers');
++const { current, describe, diff, render, CORE, FILE } = require('../scripts/surface');
++
++run([
++ ['docs/api-surface.json lists every export (node scripts/surface.js updates it)', async () => {
++ const recorded = JSON.parse(fs.readFileSync(FILE, 'utf8'));
++ const { breaking, added } = diff(recorded, current());
++ assert.deepEqual({ breaking, added }, { breaking: [], added: [] }, 'run `node scripts/surface.js` and say what changed in CHANGELOG.md');
++ assert.equal(fs.readFileSync(FILE, 'utf8'), render(current()), 'the file is exactly what the script writes');
++ }],
++
++ ['the 1.0 core entries are in it', async () => {
++ const recorded = JSON.parse(fs.readFileSync(FILE, 'utf8'));
++ for (const spec of CORE) assert.ok(Object.keys(recorded[spec] || {}).length > 0, spec);
++ assert.equal(recorded['openvibe-sdk/core'].createClient, 'function');
++ }],
++
++ ['what each kind of export records', async () => {
++ class Thing {}
++ const mod = { f() {}, Thing, list: [1], codes: { B: 1, A: 2 }, name: 'x', n: 1, on: true, none: null, map: new Map() };
++ Object.defineProperty(mod, 'optional', { enumerable: true, get() { throw new Error('never read'); } });
++ assert.deepEqual(describe(mod), {
++ Thing: 'class', codes: { object: ['A', 'B'] }, f: 'function', list: 'array', map: 'object', n: 'number', name: 'string', none: 'null', on: 'boolean', optional: 'lazy',
++ });
++ }],
++
++ ['a removal, a kind change or a removed key is breaking; anything new is an addition', async () => {
++ const before = { 'openvibe-sdk/core': { a: 'function', b: 'function', c: { object: ['X', 'Y'] }, d: 'lazy' }, 'openvibe-sdk/old': { z: 'function' } };
++ const after = { 'openvibe-sdk/core': { a: 'function', b: 'class', c: { object: ['X', 'Z'] }, d: 'function', e: 'function' }, 'openvibe-sdk/new': { y: 'function' } };
++ assert.deepEqual(diff(before, after), {
++ breaking: ['openvibe-sdk/core: b was a function, now a class [1.0 core]', 'openvibe-sdk/core: c.Y is gone [1.0 core]', 'openvibe-sdk/old: the entry is gone'],
++ added: ['openvibe-sdk/core: c.Z', 'openvibe-sdk/core: e', 'openvibe-sdk/new (a new entry)'],
++ });
++ assert.deepEqual(diff(before, before), { breaking: [], added: [] });
++ }],
++]);