feat(bridge/adapters): bloc 1 propre — BaserowClient + DocmostClient + RedisCache
- BaserowClient : CRUD rows, listRows pagination/filter/search, resolveTableIds, healthCheck - DocmostClient : auth session cookie auto-relogin, spaces/pages/shares CRUD, healthCheck - RedisCache : cache-aside, invalidation pattern SCAN, idempotence webhooks, rate limit sliding window - errors.ts : BridgeError typee + 11 ErrorCode (AUTH/RG_VIOLATION/BASEROW_UNAVAILABLE...) - bumps mineurs deps (hono, ofetch, ioredis, zod, pino) + ajout pino-pretty dev tsc strict mode clean, biome ci clean. Tests unit a venir (Bloc 6 via bridge-tester). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
460f7effe0
commit
5b2abbc23c
6 changed files with 679 additions and 20 deletions
133
bridge/package-lock.json
generated
133
bridge/package-lock.json
generated
|
|
@ -9,14 +9,15 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.19.14",
|
||||||
"decimal.js": "^10.4.3",
|
"decimal.js": "^10.6.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.6.1",
|
||||||
"hono": "^4.6.0",
|
"hono": "^4.12.18",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.10.1",
|
||||||
"ofetch": "^1.4.0",
|
"ofetch": "^1.5.1",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.14.0",
|
||||||
"zod": "^3.23.8"
|
"pino-pretty": "^13.1.3",
|
||||||
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.0",
|
"@biomejs/biome": "^1.9.0",
|
||||||
|
|
@ -2148,6 +2149,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/colorette": {
|
||||||
|
"version": "2.0.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||||
|
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/compress-commons": {
|
"node_modules/compress-commons": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
|
||||||
|
|
@ -2229,6 +2236,15 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dateformat": {
|
||||||
|
"version": "4.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||||
|
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
@ -2415,7 +2431,6 @@
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
|
|
@ -2530,6 +2545,12 @@
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-copy": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-fifo": {
|
"node_modules/fast-fifo": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||||
|
|
@ -2537,6 +2558,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-safe-stringify": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
|
|
@ -2684,6 +2711,12 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/help-me": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.18",
|
"version": "4.12.18",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||||
|
|
@ -2859,6 +2892,15 @@
|
||||||
"@pkgjs/parseargs": "^0.11.0"
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/joycon": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lazystream": {
|
"node_modules/lazystream": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||||
|
|
@ -3006,6 +3048,15 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minipass": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
|
|
@ -3112,7 +3163,6 @@
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
|
|
@ -3207,6 +3257,39 @@
|
||||||
"split2": "^4.0.0"
|
"split2": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pino-pretty": {
|
||||||
|
"version": "13.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
|
||||||
|
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"colorette": "^2.0.7",
|
||||||
|
"dateformat": "^4.6.3",
|
||||||
|
"fast-copy": "^4.0.0",
|
||||||
|
"fast-safe-stringify": "^2.1.1",
|
||||||
|
"help-me": "^5.0.0",
|
||||||
|
"joycon": "^3.1.1",
|
||||||
|
"minimist": "^1.2.6",
|
||||||
|
"on-exit-leak-free": "^2.1.0",
|
||||||
|
"pino-abstract-transport": "^3.0.0",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"secure-json-parse": "^4.0.0",
|
||||||
|
"sonic-boom": "^4.0.1",
|
||||||
|
"strip-json-comments": "^5.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pino-pretty": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-pretty/node_modules/pino-abstract-transport": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pino-std-serializers": {
|
"node_modules/pino-std-serializers": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||||
|
|
@ -3341,7 +3424,6 @@
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"end-of-stream": "^1.1.0",
|
"end-of-stream": "^1.1.0",
|
||||||
|
|
@ -3560,6 +3642,22 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/secure-json-parse": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
|
@ -3837,6 +3935,18 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-json-comments": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
|
@ -4800,7 +4910,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,15 @@
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.19.14",
|
||||||
"decimal.js": "^10.4.3",
|
"decimal.js": "^10.6.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.6.1",
|
||||||
"hono": "^4.6.0",
|
"hono": "^4.12.18",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.10.1",
|
||||||
"ofetch": "^1.4.0",
|
"ofetch": "^1.5.1",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.14.0",
|
||||||
"zod": "^3.23.8"
|
"pino-pretty": "^13.1.3",
|
||||||
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.0",
|
"@biomejs/biome": "^1.9.0",
|
||||||
|
|
|
||||||
151
bridge/src/adapters/baserow-client.ts
Normal file
151
bridge/src/adapters/baserow-client.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* BaserowClient — wrapper typé de l'API Baserow.
|
||||||
|
* Auth via API token (BASEROW_API_TOKEN), pagination, retry sur erreurs reseau.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ofetch } from 'ofetch';
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
import { errors } from '../lib/errors.js';
|
||||||
|
|
||||||
|
export interface BaserowRow {
|
||||||
|
id: number;
|
||||||
|
order: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaserowPaginatedResponse<T = BaserowRow> {
|
||||||
|
count: number;
|
||||||
|
next: string | null;
|
||||||
|
previous: string | null;
|
||||||
|
results: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaserowListOptions {
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
search?: string;
|
||||||
|
filter?: Record<string, string | number | boolean>;
|
||||||
|
orderBy?: string;
|
||||||
|
userFieldNames?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BaserowClient {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly token: string;
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
|
constructor(opts: { baseUrl: string; token: string; logger: Logger }) {
|
||||||
|
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
||||||
|
this.token = opts.token;
|
||||||
|
this.logger = opts.logger.child({ adapter: 'baserow' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetch<T>(
|
||||||
|
path: string,
|
||||||
|
init?: { method?: string; body?: string; headers?: Record<string, string> },
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
return ofetch<T>(url, {
|
||||||
|
method: init?.method,
|
||||||
|
body: init?.body,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Token ${this.token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
retry: 2,
|
||||||
|
retryDelay: 200,
|
||||||
|
timeout: 10_000,
|
||||||
|
onResponseError: ({ response }) => {
|
||||||
|
this.logger.error({ status: response.status, url, body: response._data }, 'baserow error');
|
||||||
|
},
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
const error = err as { response?: { status?: number } };
|
||||||
|
if (error.response?.status === 401) throw errors.authInvalid();
|
||||||
|
if (error.response?.status === 404) throw errors.notFound('Baserow row', path);
|
||||||
|
if (!error.response) throw errors.baserowDown();
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste les rows d'une table avec pagination.
|
||||||
|
* Retourne automatiquement les noms de fields (`user_field_names=true`) pour eviter le mapping field_id.
|
||||||
|
*/
|
||||||
|
async listRows(
|
||||||
|
tableId: number,
|
||||||
|
opts: BaserowListOptions = {},
|
||||||
|
): Promise<BaserowPaginatedResponse> {
|
||||||
|
const params: Record<string, string | number | boolean> = {
|
||||||
|
user_field_names: opts.userFieldNames ?? true,
|
||||||
|
size: opts.size ?? 100,
|
||||||
|
page: opts.page ?? 1,
|
||||||
|
};
|
||||||
|
if (opts.search) params.search = opts.search;
|
||||||
|
if (opts.orderBy) params.order_by = opts.orderBy;
|
||||||
|
if (opts.filter) {
|
||||||
|
for (const [key, val] of Object.entries(opts.filter)) {
|
||||||
|
params[`filter__${key}__contains`] = String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const queryRecord: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
queryRecord[k] = String(v);
|
||||||
|
}
|
||||||
|
const query = new URLSearchParams(queryRecord).toString();
|
||||||
|
return this.fetch<BaserowPaginatedResponse>(`/api/database/rows/table/${tableId}/?${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRow(tableId: number, rowId: number, userFieldNames = true): Promise<BaserowRow> {
|
||||||
|
return this.fetch<BaserowRow>(
|
||||||
|
`/api/database/rows/table/${tableId}/${rowId}/?user_field_names=${userFieldNames}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRow(
|
||||||
|
tableId: number,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
userFieldNames = true,
|
||||||
|
): Promise<BaserowRow> {
|
||||||
|
return this.fetch<BaserowRow>(
|
||||||
|
`/api/database/rows/table/${tableId}/?user_field_names=${userFieldNames}`,
|
||||||
|
{ method: 'POST', body: JSON.stringify(data) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRow(
|
||||||
|
tableId: number,
|
||||||
|
rowId: number,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
userFieldNames = true,
|
||||||
|
): Promise<BaserowRow> {
|
||||||
|
return this.fetch<BaserowRow>(
|
||||||
|
`/api/database/rows/table/${tableId}/${rowId}/?user_field_names=${userFieldNames}`,
|
||||||
|
{ method: 'PATCH', body: JSON.stringify(data) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRow(tableId: number, rowId: number): Promise<void> {
|
||||||
|
await this.fetch<void>(`/api/database/rows/table/${tableId}/${rowId}/`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resoud le mapping table_name → table_id pour la database.
|
||||||
|
* Utilise par le bridge au boot pour eviter de coder les ids en dur.
|
||||||
|
*/
|
||||||
|
async resolveTableIds(databaseId: number): Promise<Record<string, number>> {
|
||||||
|
const tables = await this.fetch<Array<{ id: number; name: string }>>(
|
||||||
|
`/api/database/tables/database/${databaseId}/`,
|
||||||
|
);
|
||||||
|
return Object.fromEntries(tables.map((t) => [t.name, t.id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ofetch(`${this.baseUrl}/api/_health/`, { timeout: 3000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
bridge/src/adapters/docmost-client.ts
Normal file
220
bridge/src/adapters/docmost-client.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
/**
|
||||||
|
* DocmostClient — wrapper de l'API interne Docmost (endpoints privés AGPL-legal).
|
||||||
|
* Auth par session cookie (POST /api/auth/login → cookie Set-Cookie).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ofetch } from 'ofetch';
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
import { errors } from '../lib/errors.js';
|
||||||
|
|
||||||
|
export interface DocmostUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocmostWorkspace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
defaultSpaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocmostSpace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
visibility: 'open' | 'private';
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocmostPage {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
spaceId: string;
|
||||||
|
parentPageId: string | null;
|
||||||
|
position: number | null;
|
||||||
|
content?: unknown;
|
||||||
|
slugId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocmostShare {
|
||||||
|
id: string;
|
||||||
|
pageId: string;
|
||||||
|
url?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
password?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocmostEnvelope<T> {
|
||||||
|
data: T;
|
||||||
|
success?: boolean;
|
||||||
|
status?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocmostClient {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly logger: Logger;
|
||||||
|
private cookie: string | null = null;
|
||||||
|
private readonly email: string;
|
||||||
|
private readonly password: string;
|
||||||
|
|
||||||
|
constructor(opts: { baseUrl: string; email: string; password: string; logger: Logger }) {
|
||||||
|
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
||||||
|
this.logger = opts.logger.child({ adapter: 'docmost' });
|
||||||
|
this.email = opts.email;
|
||||||
|
this.password = opts.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-login + persist session cookie. Re-auth si 401.
|
||||||
|
*/
|
||||||
|
private async ensureAuth(): Promise<void> {
|
||||||
|
if (this.cookie) return;
|
||||||
|
await this.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(): Promise<void> {
|
||||||
|
const url = `${this.baseUrl}/api/auth/login`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: this.email, password: this.password }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
this.logger.error({ status: response.status }, 'docmost login failed');
|
||||||
|
throw errors.authInvalid();
|
||||||
|
}
|
||||||
|
const setCookie = response.headers.get('set-cookie');
|
||||||
|
if (setCookie) {
|
||||||
|
this.cookie = setCookie.split(';')[0] ?? null;
|
||||||
|
}
|
||||||
|
this.logger.debug('docmost login ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post<T>(path: string, body: Record<string, unknown> = {}): Promise<T> {
|
||||||
|
await this.ensureAuth();
|
||||||
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
try {
|
||||||
|
const response = await ofetch<DocmostEnvelope<T> | T>(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(this.cookie ? { Cookie: this.cookie } : {}),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
retry: 1,
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
if (response && typeof response === 'object' && 'data' in response) {
|
||||||
|
return (response as DocmostEnvelope<T>).data;
|
||||||
|
}
|
||||||
|
return response as T;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { status?: number } };
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Re-auth + retry once
|
||||||
|
this.cookie = null;
|
||||||
|
await this.ensureAuth();
|
||||||
|
return this.post<T>(path, body);
|
||||||
|
}
|
||||||
|
if (error.response?.status === 404) throw errors.notFound('Docmost resource', path);
|
||||||
|
if (!error.response) throw errors.docmostDown();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Workspace ---
|
||||||
|
async getWorkspaceInfo(): Promise<DocmostWorkspace> {
|
||||||
|
return this.post<DocmostWorkspace>('/api/workspace/info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Spaces ---
|
||||||
|
async listSpaces(page = 1, limit = 100): Promise<DocmostSpace[]> {
|
||||||
|
const result = await this.post<{ items: DocmostSpace[] }>('/api/spaces/', {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
return result.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSpace(input: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
slug: string;
|
||||||
|
visibility?: 'open' | 'private';
|
||||||
|
}): Promise<DocmostSpace> {
|
||||||
|
return this.post<DocmostSpace>('/api/spaces/create', {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? '',
|
||||||
|
slug: input.slug,
|
||||||
|
visibility: input.visibility ?? 'open',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSpaceMember(spaceId: string, userEmails: string[], role = 'writer'): Promise<void> {
|
||||||
|
await this.post('/api/spaces/members/add', { spaceId, userEmails, role });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pages ---
|
||||||
|
async createPage(input: {
|
||||||
|
spaceId: string;
|
||||||
|
title: string;
|
||||||
|
content?: string;
|
||||||
|
format?: 'markdown' | 'json' | 'html';
|
||||||
|
parentPageId?: string;
|
||||||
|
}): Promise<DocmostPage> {
|
||||||
|
return this.post<DocmostPage>('/api/pages/create', {
|
||||||
|
spaceId: input.spaceId,
|
||||||
|
title: input.title,
|
||||||
|
...(input.content ? { content: input.content } : {}),
|
||||||
|
format: input.format ?? 'markdown',
|
||||||
|
...(input.parentPageId ? { parentPageId: input.parentPageId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPageInfo(pageId: string): Promise<DocmostPage> {
|
||||||
|
return this.post<DocmostPage>('/api/pages/info', { pageId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePage(input: {
|
||||||
|
pageId: string;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
}): Promise<DocmostPage> {
|
||||||
|
return this.post<DocmostPage>('/api/pages/update', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePage(pageId: string): Promise<void> {
|
||||||
|
await this.post('/api/pages/delete', { pageId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shares ---
|
||||||
|
async createShare(input: {
|
||||||
|
pageId: string;
|
||||||
|
includeSubPages?: boolean;
|
||||||
|
expiresAt?: string;
|
||||||
|
password?: string;
|
||||||
|
}): Promise<DocmostShare> {
|
||||||
|
return this.post<DocmostShare>('/api/shares/create', {
|
||||||
|
pageId: input.pageId,
|
||||||
|
includeSubPages: input.includeSubPages ?? false,
|
||||||
|
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
||||||
|
...(input.password ? { password: input.password } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteShare(shareId: string): Promise<void> {
|
||||||
|
await this.post('/api/shares/delete', { shareId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await ofetch<{ status: string }>(`${this.baseUrl}/api/health`, {
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
return response.status === 'ok';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
bridge/src/adapters/redis-cache.ts
Normal file
119
bridge/src/adapters/redis-cache.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
/**
|
||||||
|
* RedisCache — cache-aside pattern.
|
||||||
|
* Utilise pour : cache rows Baserow, idempotence webhooks, rate limiting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import IORedis from 'ioredis';
|
||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
|
||||||
|
export class RedisCache {
|
||||||
|
private readonly client: Redis;
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
|
constructor(opts: { url: string; logger: Logger }) {
|
||||||
|
this.logger = opts.logger.child({ adapter: 'redis' });
|
||||||
|
this.client = new IORedis(opts.url, {
|
||||||
|
lazyConnect: false,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
enableOfflineQueue: false,
|
||||||
|
});
|
||||||
|
this.client.on('error', (err: Error) => {
|
||||||
|
this.logger.error({ err: err.message }, 'redis error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get + JSON parse.
|
||||||
|
*/
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
const raw = await this.client.get(key);
|
||||||
|
if (raw === null) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
this.logger.warn({ key }, 'cache value not JSON, deleting');
|
||||||
|
await this.client.del(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set with TTL (seconds).
|
||||||
|
*/
|
||||||
|
async set<T>(key: string, value: T, ttlSeconds = 300): Promise<void> {
|
||||||
|
await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string | string[]): Promise<void> {
|
||||||
|
const keys = Array.isArray(key) ? key : [key];
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await this.client.del(...keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidation par pattern (ex: 'bridge:personne:*').
|
||||||
|
* Utilise SCAN pour ne pas bloquer Redis.
|
||||||
|
*/
|
||||||
|
async invalidatePattern(pattern: string): Promise<number> {
|
||||||
|
let cursor = '0';
|
||||||
|
let count = 0;
|
||||||
|
do {
|
||||||
|
const [next, keys] = await this.client.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
||||||
|
cursor = next;
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await this.client.del(...keys);
|
||||||
|
count += keys.length;
|
||||||
|
}
|
||||||
|
} while (cursor !== '0');
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotence webhook : retourne true si event_id deja vu, false sinon.
|
||||||
|
*/
|
||||||
|
async checkAndStoreEventId(eventId: string, ttlSeconds = 86400): Promise<boolean> {
|
||||||
|
const result = await this.client.set(
|
||||||
|
`bridge:webhook:event:${eventId}`,
|
||||||
|
'1',
|
||||||
|
'EX',
|
||||||
|
ttlSeconds,
|
||||||
|
'NX',
|
||||||
|
);
|
||||||
|
// result === 'OK' → key was set (event nouveau)
|
||||||
|
// result === null → key existait deja (duplicate)
|
||||||
|
return result === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit sliding window basique.
|
||||||
|
* Retourne true si la requete passe, false si limit atteint.
|
||||||
|
*/
|
||||||
|
async checkRateLimit(key: string, maxRequests: number, windowSeconds: number): Promise<boolean> {
|
||||||
|
const now = Date.now();
|
||||||
|
const windowKey = `bridge:rate:${key}`;
|
||||||
|
const multi = this.client.multi();
|
||||||
|
multi.zadd(windowKey, now, `${now}`);
|
||||||
|
multi.zremrangebyscore(windowKey, 0, now - windowSeconds * 1000);
|
||||||
|
multi.zcard(windowKey);
|
||||||
|
multi.expire(windowKey, windowSeconds);
|
||||||
|
const results = await multi.exec();
|
||||||
|
if (!results) return true;
|
||||||
|
const count = results[2]?.[1] as number;
|
||||||
|
return count <= maxRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const pong = await this.client.ping();
|
||||||
|
return pong === 'PONG';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.client.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
59
bridge/src/lib/errors.ts
Normal file
59
bridge/src/lib/errors.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/**
|
||||||
|
* Erreurs metier typees pour le bridge.
|
||||||
|
* Chaque erreur a un code stable + statut HTTP + details serializables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ErrorCode =
|
||||||
|
| 'AUTH_REQUIRED'
|
||||||
|
| 'AUTH_INVALID'
|
||||||
|
| 'FORBIDDEN_SCOPE'
|
||||||
|
| 'NOT_FOUND'
|
||||||
|
| 'VALIDATION_ERROR'
|
||||||
|
| 'RG_VIOLATION'
|
||||||
|
| 'CONFLICT'
|
||||||
|
| 'RATE_LIMITED'
|
||||||
|
| 'BASEROW_UNAVAILABLE'
|
||||||
|
| 'DOCMOST_UNAVAILABLE'
|
||||||
|
| 'INTERNAL';
|
||||||
|
|
||||||
|
export class BridgeError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: ErrorCode,
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
public readonly details?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'BridgeError';
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: this.code,
|
||||||
|
message: this.message,
|
||||||
|
...(this.details ? { details: this.details } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errors = {
|
||||||
|
authRequired: () => new BridgeError('AUTH_REQUIRED', 401, 'Token absent'),
|
||||||
|
authInvalid: () => new BridgeError('AUTH_INVALID', 401, 'Token invalide'),
|
||||||
|
forbidden: (scope: string) =>
|
||||||
|
new BridgeError('FORBIDDEN_SCOPE', 403, `Scope requis : ${scope}`, { scope }),
|
||||||
|
notFound: (entity: string, id: string | number) =>
|
||||||
|
new BridgeError('NOT_FOUND', 404, `${entity} introuvable`, { entity, id }),
|
||||||
|
validation: (issues: unknown) =>
|
||||||
|
new BridgeError('VALIDATION_ERROR', 400, 'Body invalide', { issues }),
|
||||||
|
rgViolation: (rule: string, message: string, details?: Record<string, unknown>) =>
|
||||||
|
new BridgeError('RG_VIOLATION', 422, message, { rule, ...details }),
|
||||||
|
conflict: (message: string, details?: Record<string, unknown>) =>
|
||||||
|
new BridgeError('CONFLICT', 409, message, details),
|
||||||
|
rateLimited: (retryAfter: number) =>
|
||||||
|
new BridgeError('RATE_LIMITED', 429, 'Too many requests', { retry_after: retryAfter }),
|
||||||
|
baserowDown: () => new BridgeError('BASEROW_UNAVAILABLE', 502, 'Baserow API unreachable'),
|
||||||
|
docmostDown: () => new BridgeError('DOCMOST_UNAVAILABLE', 502, 'Docmost API unreachable'),
|
||||||
|
internal: (message: string) => new BridgeError('INTERNAL', 500, message),
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue