openvibers/openvibe.sdk Commits

0.2.0: transactional outbox and exactly-once inbox in openvibe-sdk/events (no runtime dependency)

OP OpenVibers committed 23 Sept 2026 · c64e3e154d · 8 files, +890 −5


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex fc0cfa8..cb8c1d8 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,14 @@
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.2.0 (2026-09-23)
++
++- `openvibe-sdk/events`: `createOutbox(db, { events })` and `createInbox(db)` — the transactional
++ outbox and exactly-once inbox on the service's own better-sqlite3 handle (ADR-004), so producers
++ no longer need the `openvibe-events` server package. `enqueue()` refuses to run outside a
++ transaction; the relay batches, backs off on transient failures and rejects only the row a
++ permanent 4xx refused. Still no runtime dependencies (`better-sqlite3` is a dev dependency).
++
## 0.1.0 (2026-09-22)
First release (roadmap Wave 2, implementation plan §3.2). Built and tested against
ddiff --git a/esm/events.mjs b/esm/events.mjs
iindex a02d50f..0a433d7 100644
---- a/esm/events.mjs
++++ b/esm/events.mjs
@@@ -2,6 +2,6 @@
import cjs from '../src/events.js';
export const {
-- createEventsClient, verifyDelivery, signDelivery, parseDelivery,
++ createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..f103e3f
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,514 @@
++{
++ "name": "openvibe-sdk",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-sdk",
++ "version": "0.1.0",
++ "license": "MIT",
++ "devDependencies": {
++ "better-sqlite3": "^11.10.0"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.6.0"
++ },
++ "peerDependenciesMeta": {
++ "openvibe-contracts": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/base64-js": {
++ "version": "1.5.1",
++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/better-sqlite3": {
++ "version": "11.10.0",
++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
++ "dev": true,
++ "hasInstallScript": true,
++ "license": "MIT",
++ "dependencies": {
++ "bindings": "^1.5.0",
++ "prebuild-install": "^7.1.1"
++ }
++ },
++ "node_modules/bindings": {
++ "version": "1.5.0",
++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "file-uri-to-path": "1.0.0"
++ }
++ },
++ "node_modules/bl": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
++ "node_modules/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.3.1",
++ "ieee754": "^1.1.13"
++ }
++ },
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "dev": true,
++ "license": "ISC"
++ },
++ "node_modules/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "dev": true,
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/end-of-stream": {
++ "version": "1.4.5",
++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "once": "^1.4.0"
++ }
++ },
++ "node_modules/expand-template": {
++ "version": "2.0.3",
++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
++ "dev": true,
++ "license": "(MIT OR WTFPL)",
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/file-uri-to-path": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/fs-constants": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/github-from-package": {
++ "version": "0.0.0",
++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/ieee754": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "dev": true,
++ "license": "ISC"
++ },
++ "node_modules/ini": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
++ "dev": true,
++ "license": "ISC"
++ },
++ "node_modules/mimic-response": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "dev": true,
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/mkdirp-classic": {
++ "version": "0.5.3",
++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/napi-build-utils": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/node-abi": {
++ "version": "3.96.0",
++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/once": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
++ "dev": true,
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/prebuild-install": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "detect-libc": "^2.0.0",
++ "expand-template": "^2.0.3",
++ "github-from-package": "0.0.0",
++ "minimist": "^1.2.3",
++ "mkdirp-classic": "^0.5.3",
++ "napi-build-utils": "^2.0.0",
++ "node-abi": "^3.3.0",
++ "pump": "^3.0.0",
++ "rc": "^1.2.7",
++ "simple-get": "^4.0.0",
++ "tar-fs": "^2.0.0",
++ "tunnel-agent": "^0.6.0"
++ },
++ "bin": {
++ "prebuild-install": "bin.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/pump": {
++ "version": "3.0.4",
++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "end-of-stream": "^1.1.0",
++ "once": "^1.3.1"
++ }
++ },
++ "node_modules/rc": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "dev": true,
++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "dependencies": {
++ "deep-extend": "^0.6.0",
++ "ini": "~1.3.0",
++ "minimist": "^1.2.0",
++ "strip-json-comments": "~2.0.1"
++ },
++ "bin": {
++ "rc": "cli.js"
++ }
++ },
++ "node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "dev": true,
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/simple-concat": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/simple-get": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "decompress-response": "^6.0.0",
++ "once": "^1.3.1",
++ "simple-concat": "^1.0.0"
++ }
++ },
++ "node_modules/string_decoder": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "~5.2.0"
++ }
++ },
++ "node_modules/strip-json-comments": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/tar-fs": {
++ "version": "2.1.5",
++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "chownr": "^1.1.1",
++ "mkdirp-classic": "^0.5.2",
++ "pump": "^3.0.0",
++ "tar-stream": "^2.1.4"
++ }
++ },
++ "node_modules/tar-stream": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "bl": "^4.0.3",
++ "end-of-stream": "^1.4.1",
++ "fs-constants": "^1.0.0",
++ "inherits": "^2.0.3",
++ "readable-stream": "^3.1.1"
++ },
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/tunnel-agent": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
++ "dev": true,
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
++ "node_modules/util-deprecate": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "dev": true,
++ "license": "ISC"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
iindex c148cce..ba354d9 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.1.0",
++ "version": "0.2.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth, registry discovery, identity, user modules, events, realtime, media and community, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -99,7 +99,18 @@
}
},
"sideEffects": false,
-- "keywords": ["openvibe", "sdk", "oauth", "pkce", "events", "sse", "media"],
++ "keywords": [
++ "openvibe",
++ "sdk",
++ "oauth",
++ "pkce",
++ "events",
++ "sse",
++ "media"
++ ],
"repository": "github:OpenVibers/OpenVibe.SDK",
-- "license": "MIT"
++ "license": "MIT",
++ "devDependencies": {
++ "better-sqlite3": "^11.10.0"
++ }
}
ddiff --git a/src/events.js b/src/events.js
iindex 37c2830..51b6cb3 100644
---- a/src/events.js
++++ b/src/events.js
@@@ -146,4 +146,6 @@ function createEventsClient(client, { source, baseUrl } = {}) {
};
}
--module.exports = { createEventsClient, verifyDelivery, signDelivery, parseDelivery };
++const { createOutbox, createInbox } = require('./outbox');
++
++module.exports = { createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox };
ddiff --git a/src/outbox.js b/src/outbox.js
nnew file mode 100644
iindex 0000000..a262f67
---- /dev/null
++++ b/src/outbox.js
@@@ -0,0 +1,207 @@
++'use strict';
++/**
++ * openvibe-sdk/events (server): transactional outbox and inbox on the service's own better-sqlite3
++ * database (ADR-004). No dependency: the caller passes its database handle.
++ *
++ * const events = createEventsClient(client, { source: 'live' });
++ * const outbox = createOutbox(db, { events });
++ * outbox.ensureSchema();
++ * db.transaction(() => {
++ * db.prepare('UPDATE streams SET is_live = 1 WHERE id = ?').run(id);
++ * outbox.enqueue({ event_type: 'live.stream.started', actor, subject, payload });
++ * })(); // the event exists if and only if the change committed
++ * outbox.start(); // relay: publishes due rows, marks them sent, backs off on failure
++ *
++ * const inbox = createInbox(db);
++ * inbox.ensureSchema();
++ * inbox.once('network', event.event_id, () => { ...synchronous db writes... });
++ *
++ * Delivery is at least once: a crash between publish and mark-sent republishes the same event_id,
++ * which OpenVibe.Events answers as a duplicate. A 4xx other than 401/408/409/425/429 is permanent
++ * (the row is marked rejected and never retried); anything else is retried with backoff.
++ */
++
++const TRACEPARENT_RE = /^00-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/;
++const TABLE_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
++const RETRYABLE_4XX = new Set([401, 408, 409, 425, 429]);
++
++function isPermanent(err) {
++ const s = err && err.status;
++ return Number.isInteger(s) && s >= 400 && s < 500 && !RETRYABLE_4XX.has(s);
++}
++
++function createOutbox(db, {
++ events, table = 'event_outbox', batchSize = 50, intervalMs = 1000,
++ backoffMs = [1000, 5000, 30000, 120000, 600000], now = () => Date.now(), onError = null, allowOutsideTransaction = false,
++} = {}) {
++ if (!db || typeof db.prepare !== 'function') throw new TypeError('a better-sqlite3 database is required');
++ if (!events || typeof events.publish !== 'function' || typeof events.prepare !== 'function') {
++ throw new TypeError('events must be an openvibe-sdk events client (createEventsClient)');
++ }
++ if (!TABLE_RE.test(table)) throw new TypeError('bad outbox table name');
++ let stmts = null;
++ let timer = null;
++ let running = false;
++ let flushing = null;
++
++ function ensureSchema() {
++ db.exec(`CREATE TABLE IF NOT EXISTS ${table} (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ event_id TEXT NOT NULL UNIQUE,
++ envelope TEXT NOT NULL,
++ traceparent TEXT,
++ created_at INTEGER NOT NULL,
++ attempts INTEGER NOT NULL DEFAULT 0,
++ next_attempt_at INTEGER NOT NULL DEFAULT 0,
++ sent_at INTEGER,
++ seq INTEGER,
++ rejected_at INTEGER,
++ last_error TEXT
++ );
++ CREATE INDEX IF NOT EXISTS idx_${table}_due ON ${table}(sent_at, rejected_at, next_attempt_at);`);
++ stmts = null;
++ }
++
++ function q() {
++ if (!stmts) {
++ stmts = {
++ insert: db.prepare(`INSERT INTO ${table} (event_id, envelope, traceparent, created_at, next_attempt_at) VALUES (?, ?, ?, ?, 0)`),
++ due: db.prepare(`SELECT * FROM ${table} WHERE sent_at IS NULL AND rejected_at IS NULL AND next_attempt_at <= ? ORDER BY id LIMIT ?`),
++ sent: db.prepare(`UPDATE ${table} SET sent_at = ?, seq = ?, attempts = attempts + 1, last_error = NULL WHERE id = ?`),
++ failed: db.prepare(`UPDATE ${table} SET attempts = attempts + 1, next_attempt_at = ?, last_error = ? WHERE id = ?`),
++ rejected: db.prepare(`UPDATE ${table} SET rejected_at = ?, attempts = attempts + 1, last_error = ? WHERE id = ?`),
++ pending: db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE sent_at IS NULL AND rejected_at IS NULL`),
++ rejectedCount: db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE rejected_at IS NOT NULL`),
++ prune: db.prepare(`DELETE FROM ${table} WHERE sent_at IS NOT NULL AND sent_at < ?`),
++ };
++ }
++ return stmts;
++ }
++
++ /** INSIDE the caller's transaction. Returns the complete envelope (event_id, timestamp, ...). */
++ function enqueue(envelope, { traceparent } = {}) {
++ if (!allowOutsideTransaction && !db.inTransaction) {
++ throw new Error('outbox.enqueue() must run inside the transaction that makes the change');
++ }
++ const m = traceparent && TRACEPARENT_RE.exec(traceparent);
++ const env = events.prepare(envelope, { traceId: m ? m[1] : undefined, now: now() });
++ q().insert.run(env.event_id, JSON.stringify(env), m ? traceparent : null, now());
++ return env;
++ }
++
++ const backoff = (attempts) => backoffMs[Math.min(attempts, backoffMs.length - 1)];
++
++ function markFailure(row, err) {
++ const message = String((err && err.message) || err).slice(0, 500);
++ if (isPermanent(err)) q().rejected.run(now(), message, row.id);
++ else q().failed.run(now() + backoff(row.attempts), message, row.id);
++ if (onError) { try { onError(err, row); } catch { /* reporting must not break the relay */ } }
++ }
++
++ async function publishRows(rows) {
++ const stats = { sent: 0, failed: 0, rejected: 0 };
++ const envelopes = rows.map((r) => JSON.parse(r.envelope));
++ const traceparent = rows[0].traceparent || undefined;
++ try {
++ const out = rows.length === 1
++ ? { results: [await events.publish(envelopes[0], { traceparent })] }
++ : await events.publish(envelopes, { traceparent });
++ const byId = new Map(((out && out.results) || []).map((r) => [r.event_id, r]));
++ db.transaction(() => { for (const row of rows) q().sent.run(now(), byId.get(row.event_id)?.seq ?? null, row.id); })();
++ stats.sent += rows.length;
++ } catch (err) {
++ if (rows.length > 1 && isPermanent(err)) {
++ // One bad envelope rejects the whole batch: isolate it.
++ for (const row of rows) {
++ const s = await publishRows([row]);
++ stats.sent += s.sent; stats.failed += s.failed; stats.rejected += s.rejected;
++ }
++ } else {
++ for (const row of rows) markFailure(row, err);
++ if (isPermanent(err)) stats.rejected += rows.length; else stats.failed += rows.length;
++ }
++ }
++ return stats;
++ }
++
++ async function doFlush() {
++ const total = { sent: 0, failed: 0, rejected: 0 };
++ for (;;) {
++ const rows = q().due.all(now(), batchSize);
++ if (!rows.length) break;
++ const s = await publishRows(rows);
++ total.sent += s.sent; total.failed += s.failed; total.rejected += s.rejected;
++ if (s.failed || rows.length < batchSize) break;
++ }
++ return total;
++ }
++
++ /** Publish due rows once. Concurrent callers share the flush in progress. */
++ function flush() {
++ if (!flushing) flushing = doFlush().finally(() => { flushing = null; });
++ return flushing;
++ }
++
++ function schedule(ms) {
++ if (!running) return;
++ clearTimeout(timer);
++ timer = setTimeout(async () => {
++ timer = null;
++ try { await flush(); } catch (err) { if (onError) { try { onError(err); } catch { /* ignore */ } } }
++ schedule(intervalMs);
++ }, ms);
++ if (timer.unref) timer.unref();
++ }
++
++ return {
++ ensureSchema,
++ enqueue,
++ flush,
++ start() { if (!running) { running = true; schedule(0); } },
++ stop() { running = false; clearTimeout(timer); timer = null; return flushing || Promise.resolve(); },
++ /** Wake the relay now (e.g. right after the transaction commits). */
++ kick() { schedule(0); },
++ pending: () => q().pending.get().n,
++ rejected: () => q().rejectedCount.get().n,
++ /** Delete sent rows older than `olderThanMs` (default 7 days). */
++ prune(olderThanMs = 7 * 24 * 60 * 60 * 1000) { return q().prune.run(now() - olderThanMs).changes; },
++ };
++}
++
++function createInbox(db, { table = 'idempotency_receipts', now = () => Date.now() } = {}) {
++ if (!db || typeof db.prepare !== 'function') throw new TypeError('a better-sqlite3 database is required');
++ if (!TABLE_RE.test(table)) throw new TypeError('bad inbox table name');
++ let claim = null;
++
++ function ensureSchema() {
++ db.exec(`CREATE TABLE IF NOT EXISTS ${table} (
++ consumer TEXT NOT NULL,
++ event_id TEXT NOT NULL,
++ processed_at INTEGER NOT NULL,
++ PRIMARY KEY (consumer, event_id)
++ )`);
++ claim = null;
++ }
++
++ /** Run fn exactly once per (consumer, eventId), in one SQLite transaction with the receipt. */
++ function once(consumer, eventId, fn) {
++ if (!consumer || !eventId) throw new TypeError('consumer and eventId are required');
++ if (!claim) claim = db.prepare(`INSERT OR IGNORE INTO ${table} (consumer, event_id, processed_at) VALUES (?, ?, ?)`);
++ return db.transaction(() => {
++ if (claim.run(String(consumer), String(eventId), now()).changes === 0) return { duplicate: true };
++ const result = fn();
++ if (result && typeof result.then === 'function') {
++ throw new TypeError('inbox.once(): fn must be synchronous (it runs inside a SQLite transaction)');
++ }
++ return { duplicate: false, result };
++ })();
++ }
++
++ function seen(consumer, eventId) {
++ return Boolean(db.prepare(`SELECT 1 FROM ${table} WHERE consumer = ? AND event_id = ?`).get(String(consumer), String(eventId)));
++ }
++
++ return { ensureSchema, once, seen };
++}
++
++module.exports = { createOutbox, createInbox, isPermanent };
ddiff --git a/test/outbox.test.js b/test/outbox.test.js
nnew file mode 100644
iindex 0000000..948fe6c
---- /dev/null
++++ b/test/outbox.test.js
@@@ -0,0 +1,111 @@
++'use strict';
++/** Outbox/inbox: enqueue only inside a transaction, relay publishes once, retries, rejects, dedupes. */
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const Database = require('better-sqlite3');
++const { run } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createServiceTokenClient } = require('../src/auth');
++const { createEventsClient, createOutbox, createInbox } = require('../src/events');
++const { createMockPlatform } = require('../src/testing');
++
++const actor = { type: 'service', id: 'live' };
++const subject = { type: 'stream', id: '12', revision: 1 };
++
++function setup() {
++ const platform = createMockPlatform({
++ clients: { live: { secret: 'live-secret', grants: [['events.event.publish', 'openvibe.events']] } },
++ });
++ const tokens = createServiceTokenClient({ clientId: 'live', clientSecret: 'live-secret', fetch: platform.fetch });
++ const client = createClient({ fetch: platform.fetch, tokenProvider: tokens, retries: 0 });
++ const events = createEventsClient(client, { source: 'live' });
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-outbox-'));
++ const db = new Database(path.join(dir, 'svc.db'));
++ db.exec('CREATE TABLE streams (id INTEGER PRIMARY KEY, is_live INTEGER)');
++ return { platform, events, db };
++}
++
++run([
++ ['enqueue refuses to run outside a transaction', async () => {
++ const { events, db } = setup();
++ const outbox = createOutbox(db, { events });
++ outbox.ensureSchema();
++ assert.throws(() => outbox.enqueue({ event_type: 'live.stream.started', actor, subject }), /inside the transaction/);
++ }],
++ ['a rolled-back change leaves no event; a committed one is published once', async () => {
++ const { platform, events, db } = setup();
++ const outbox = createOutbox(db, { events });
++ outbox.ensureSchema();
++ assert.throws(() => db.transaction(() => {
++ db.prepare('INSERT INTO streams (id, is_live) VALUES (1, 1)').run();
++ outbox.enqueue({ event_type: 'live.stream.started', actor, subject });
++ throw new Error('boom');
++ })());
++ assert.equal(outbox.pending(), 0);
++ let env;
++ db.transaction(() => {
++ db.prepare('INSERT INTO streams (id, is_live) VALUES (2, 1)').run();
++ env = outbox.enqueue({ event_type: 'live.stream.started', actor, subject, payload: { stream_id: 2 } }, { traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' });
++ })();
++ assert.match(env.event_id, /^evt_/);
++ assert.equal(env.trace_id, '0af7651916cd43dd8448eb211c80319c');
++ assert.equal(outbox.pending(), 1);
++ const s = await outbox.flush();
++ assert.deepEqual(s, { sent: 1, failed: 0, rejected: 0 });
++ assert.equal(outbox.pending(), 0);
++ assert.equal(platform.state.events.length, 1);
++ assert.equal(platform.state.events[0].event.event_id, env.event_id);
++ await outbox.flush();
++ assert.equal(platform.state.events.length, 1, 'nothing republished');
++ }],
++ ['a transient failure backs off and retries; a batch publishes together', async () => {
++ const { platform, events, db } = setup();
++ let t = 1_000_000;
++ let fail = true;
++ const flaky = { prepare: events.prepare, publish: (...a) => (fail ? Promise.reject(Object.assign(new Error('down'), { status: 503 })) : events.publish(...a)) };
++ const outbox = createOutbox(db, { events: flaky, now: () => t });
++ outbox.ensureSchema();
++ db.transaction(() => { for (let i = 0; i < 3; i++) outbox.enqueue({ event_type: 'live.stream.started', actor, subject: { type: 'stream', id: String(i) } }); })();
++ assert.deepEqual(await outbox.flush(), { sent: 0, failed: 3, rejected: 0 });
++ fail = false;
++ assert.deepEqual(await outbox.flush(), { sent: 0, failed: 0, rejected: 0 }, 'not due yet');
++ t += 1000;
++ assert.deepEqual(await outbox.flush(), { sent: 3, failed: 0, rejected: 0 });
++ assert.equal(platform.state.events.length, 3);
++ }],
++ ['a permanent 4xx rejects only the bad row', async () => {
++ const { platform, events, db } = setup();
++ const picky = {
++ prepare: events.prepare,
++ publish: (input, o) => {
++ const list = Array.isArray(input) ? input : [input];
++ if (list.some((e) => e.subject.id === 'bad')) return Promise.reject(Object.assign(new Error('events.type_not_allowed'), { status: 403 }));
++ return events.publish(input, o);
++ },
++ };
++ const outbox = createOutbox(db, { events: picky });
++ outbox.ensureSchema();
++ db.transaction(() => {
++ outbox.enqueue({ event_type: 'live.stream.started', actor, subject: { type: 'stream', id: 'ok1' } });
++ outbox.enqueue({ event_type: 'live.stream.started', actor, subject: { type: 'stream', id: 'bad' } });
++ outbox.enqueue({ event_type: 'live.stream.started', actor, subject: { type: 'stream', id: 'ok2' } });
++ })();
++ assert.deepEqual(await outbox.flush(), { sent: 2, failed: 0, rejected: 1 });
++ assert.equal(outbox.pending(), 0);
++ assert.equal(outbox.rejected(), 1);
++ assert.equal(platform.state.events.length, 2);
++ }],
++ ['inbox runs a handler exactly once and rolls back with it', async () => {
++ const { db } = setup();
++ const inbox = createInbox(db);
++ inbox.ensureSchema();
++ assert.throws(() => inbox.once('network', 'evt_1', () => { db.prepare('INSERT INTO streams (id, is_live) VALUES (9, 1)').run(); throw new Error('crash'); }));
++ assert.equal(inbox.seen('network', 'evt_1'), false);
++ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM streams').get().n, 0);
++ assert.deepEqual(inbox.once('network', 'evt_1', () => { db.prepare('INSERT INTO streams (id, is_live) VALUES (9, 1)').run(); return 'done'; }), { duplicate: false, result: 'done' });
++ assert.deepEqual(inbox.once('network', 'evt_1', () => { throw new Error('must not run'); }), { duplicate: true });
++ assert.throws(() => inbox.once('network', 'evt_2', async () => {}), /synchronous/);
++ }],
++]);
ddiff --git a/types/events.d.ts b/types/events.d.ts
iindex 8b8a149..24c3f6b 100644
---- a/types/events.d.ts
++++ b/types/events.d.ts
@@@ -45,3 +45,35 @@ type RawBody = string | Uint8Array | ArrayBufferView;
export declare function signDelivery(rawBody: RawBody, secret: string): string;
export declare function verifyDelivery(rawBody: RawBody, signatureHeader: string | undefined | null, secret: string): boolean;
export declare function parseDelivery(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string): { event: EventEnvelope; seq: number; subscriptionId: string | null; attempt: number } | null;
++
++/** The subset of a better-sqlite3 Database the outbox and inbox use. */
++export interface SqliteDatabase {
++ prepare(sql: string): any;
++ exec(sql: string): any;
++ transaction<F extends (...args: any[]) => any>(fn: F): F;
++ readonly inTransaction: boolean;
++}
++export interface FlushStats { sent: number; failed: number; rejected: number; }
++export interface Outbox {
++ ensureSchema(): void;
++ /** Inside the caller's transaction; returns the complete envelope. */
++ enqueue(envelope: EventInput, opts?: { traceparent?: string }): EventEnvelope;
++ flush(): Promise<FlushStats>;
++ start(): void;
++ stop(): Promise<unknown>;
++ kick(): void;
++ pending(): number;
++ rejected(): number;
++ prune(olderThanMs?: number): number;
++}
++export declare function createOutbox(db: SqliteDatabase, opts: {
++ events: Pick<EventsClient, 'publish' | 'prepare'>;
++ table?: string; batchSize?: number; intervalMs?: number; backoffMs?: number[];
++ now?: () => number; onError?: (err: unknown, row?: unknown) => void; allowOutsideTransaction?: boolean;
++}): Outbox;
++export interface Inbox {
++ ensureSchema(): void;
++ once<T>(consumer: string, eventId: string, fn: () => T): { duplicate: true } | { duplicate: false; result: T };
++ seen(consumer: string, eventId: string): boolean;
++}
++export declare function createInbox(db: SqliteDatabase, opts?: { table?: string; now?: () => number }): Inbox;