openvibers/openvibe.sdk Commits
0.2.0: transactional outbox and exactly-once inbox in openvibe-sdk/events (no runtime dependency)
Changes
@@@ -3,6 +3,14 @@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.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@@@ -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;nnew file mode 100644@@@ -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"++ }++ }++}@@@ -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"++ }}@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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/);++ }],++]);@@@ -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;
@@ -3,6 +3,14 @@
@@ -3,6 +3,14 @@
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.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)
## 0.1.0 (2026-09-22)
First release (roadmap Wave 2, implementation plan §3.2). Built and tested against
First release (roadmap Wave 2, implementation plan §3.2). Built and tested against
@@ -2,6 +2,6 @@
@@ -2,6 +2,6 @@
import cjs from '../src/events.js';
import cjs from '../src/events.js';
export const {
export const {
- createEventsClient, verifyDelivery, signDelivery, parseDelivery,
+ createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,514 @@
@@ -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"
+ }
+ }
+}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -99,7 +99,18 @@
@@ -99,7 +99,18 @@
}
}
},
},
"sideEffects": false,
"sideEffects": false,
- "keywords": ["openvibe", "sdk", "oauth", "pkce", "events", "sse", "media"],
+ "keywords": [
+ "openvibe",
+ "sdk",
+ "oauth",
+ "pkce",
+ "events",
+ "sse",
+ "media"
+ ],
"repository": "github:OpenVibers/OpenVibe.SDK",
"repository": "github:OpenVibers/OpenVibe.SDK",
- "license": "MIT"
+ "license": "MIT",
+ "devDependencies": {
+ "better-sqlite3": "^11.10.0"
+ }
}
}
@@ -146,4 +146,6 @@ function createEventsClient(client, { source, baseUrl } = {}) {
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -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/);
+ }],
+]);
@@ -45,3 +45,35 @@ type RawBody = string | Uint8Array | ArrayBufferView;
@@ -45,3 +45,35 @@ type RawBody = string | Uint8Array | ArrayBufferView;
export declare function signDelivery(rawBody: RawBody, secret: string): string;
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 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;
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;