diff --git a/.planning/phase-1-telemetry/08-redis-publisher.md b/.planning/phase-1-telemetry/08-redis-publisher.md index c1c09e3..e43a9fd 100644 --- a/.planning/phase-1-telemetry/08-redis-publisher.md +++ b/.planning/phase-1-telemetry/08-redis-publisher.md @@ -1,7 +1,7 @@ # Task 1.8 β€” Redis Streams publisher & main wiring **Phase:** 1 β€” Inbound telemetry -**Status:** ⬜ Not started +**Status:** 🟩 Done **Depends on:** 1.2, 1.3, 1.4, 1.5, 1.6, 1.7 **Wiki refs:** `docs/wiki/entities/redis-streams.md`, `docs/wiki/concepts/position-record.md` @@ -111,4 +111,16 @@ main().catch((err) => { console.error(err); process.exit(1); }); ## Done -(Fill in once complete.) +Implemented in task 1.8. Key deviations from spec: + +1. **Buffer.toJSON() trap** β€” `Buffer.prototype.toJSON()` converts Buffer to `{type:'Buffer',data:[...]}` before the `JSON.stringify` replacer sees it. The replacer checks both `instanceof Uint8Array` (direct calls) and the `{type:'Buffer',data:[]}` shape (JSON.stringify path) to handle both cases. The spec's `Buffer.isBuffer(value)` check would not work here; documented in `publish.ts`. + +2. **Codec label plumbing** β€” Chose Option B (handler wrapper), not a signature change to `CodecHandlerContext.publish`. `AdapterContext.publish` was updated to `(position, codec) => Promise`; the framing layer (`index.ts`) builds a `(pos) => ctx.publish(pos, codecLabel)` closure at dispatch time. Codec parsers (codec8.ts, codec8e.ts, codec16.ts) are unchanged. + +3. **`connectRedis` exported from `publish.ts`** β€” co-located with publisher for testability; spec showed it in main.ts but extraction is cleaner. + +4. **Integration tests skipped (Docker unavailable)** β€” Two integration tests in `test/publish.integration.test.ts` log `"Docker not available β€” skipping"` and pass without executing. Will run in CI (task 1.11). + +5. **`startMetricsServer` omitted from main.ts** β€” Task 1.10 is out of scope; placeholder metrics (stub inc/observe) used per spec. The `main.ts` skeleton in the spec included `startMetricsServer` β€” deferred. + +Test count: 81 (was 62, +19). diff --git a/package.json b/package.json index b53840b..4a4e836 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "eslint-plugin-import": "^2.31.0", "pino-pretty": "^13.0.0", "prettier": "^3.4.2", + "testcontainers": "^11.14.0", "tsx": "^4.19.2", "typescript": "^5.7.2", "vitest": "^2.1.8" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40df72c..c996e79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: prettier: specifier: ^3.4.2 version: 3.8.3 + testcontainers: + specifier: ^11.14.0 + version: 11.14.0 tsx: specifier: ^4.19.2 version: 4.21.0 @@ -81,6 +84,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -425,6 +431,20 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -469,6 +489,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -483,6 +509,36 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.1': + resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@rollup/rollup-android-arm-eabi@4.60.2': resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} cpu: [arm] @@ -627,6 +683,12 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/docker-modem@3.0.6': + resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} + + '@types/dockerode@4.0.1': + resolution: {integrity: sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -636,9 +698,21 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/ssh2-streams@0.1.13': + resolution: {integrity: sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==} + + '@types/ssh2@0.5.52': + resolution: {integrity: sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==} + + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@typescript-eslint/eslint-plugin@8.59.1': resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -839,6 +913,10 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -868,6 +946,14 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -895,6 +981,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -903,6 +992,12 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -911,6 +1006,14 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + b4a@1.8.0: + resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -918,9 +1021,59 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.1: + resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.0: + resolution: {integrity: sha512-JTjuZyNIDpw+GytMO4a6TK1VXdVKKJr6DRxEHasyuYyShV2deuiHJK/ahGZlebc+SG0/wJCB9XK8gprBGDFi/Q==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.2: + resolution: {integrity: sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bintrees@1.0.2: resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} @@ -931,6 +1084,24 @@ packages: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + + byline@5.0.0: + resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} + engines: {node: '>=0.10.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -963,6 +1134,13 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} @@ -977,9 +1155,29 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1035,6 +1233,18 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + docker-compose@1.4.2: + resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==} + engines: {node: '>= 6.0.0'} + + docker-modem@5.0.7: + resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} + engines: {node: '>= 8.0'} + + dockerode@4.0.12: + resolution: {integrity: sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==} + engines: {node: '>= 8.0'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1096,6 +1306,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1205,6 +1419,17 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1215,6 +1440,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -1256,6 +1484,9 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1275,10 +1506,18 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-port@7.2.0: + resolution: {integrity: sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==} + engines: {node: '>=16'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -1311,6 +1550,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -1344,6 +1586,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1360,6 +1605,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1447,6 +1695,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -1471,6 +1723,9 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -1520,6 +1775,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1528,6 +1787,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -1537,6 +1799,12 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -1564,6 +1832,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -1575,9 +1847,20 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nan@2.26.2: + resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1595,6 +1878,10 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -1716,13 +2003,31 @@ packages: engines: {node: '>=14'} hasBin: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + prom-client@15.1.3: resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} engines: {node: ^16 || ^18 || >=20} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + properties-reader@3.0.1: + resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} + engines: {node: '>=18'} + + protobufjs@7.5.6: + resolution: {integrity: sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==} + engines: {node: '>=12.0.0'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -1733,6 +2038,20 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -1753,6 +2072,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1765,6 +2088,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + rollup@4.60.2: resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1774,6 +2101,12 @@ packages: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -1786,6 +2119,9 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -1837,6 +2173,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1848,10 +2187,20 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + ssh-remote-port-forward@1.0.4: + resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -1869,6 +2218,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1889,6 +2241,12 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1917,13 +2275,35 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-fs@3.1.2: + resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + test-exclude@7.0.2: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} + testcontainers@11.14.0: + resolution: {integrity: sha512-r9pniwv/iwzyHaI7gwAvAm4Y+IvjJg3vBWdjrUCaDMc2AXIr4jKbq7jJO18Mw2ybs73pZy1Aj7p/4RVBGMRWjg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} @@ -1949,6 +2329,10 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1966,6 +2350,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1995,15 +2382,30 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2106,10 +2508,31 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -2133,6 +2556,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@balena/dockerignore@1.0.2': {} + '@bcoe/v8-coverage@0.2.3': {} '@emnapi/core@1.10.0': @@ -2344,6 +2769,25 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.6 + yargs: 17.7.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.6 + yargs: 17.7.2 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2387,6 +2831,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 @@ -2401,6 +2853,29 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.1 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.1': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + '@rollup/rollup-android-arm-eabi@4.60.2': optional: true @@ -2483,16 +2958,44 @@ snapshots: tslib: 2.8.1 optional: true + '@types/docker-modem@3.0.6': + dependencies: + '@types/node': 22.19.17 + '@types/ssh2': 1.15.5 + + '@types/dockerode@4.0.1': + dependencies: + '@types/docker-modem': 3.0.6 + '@types/node': 22.19.17 + '@types/ssh2': 1.15.5 + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@22.19.17': dependencies: undici-types: 6.21.0 + '@types/ssh2-streams@0.1.13': + dependencies: + '@types/node': 22.19.17 + + '@types/ssh2@0.5.52': + dependencies: + '@types/node': 22.19.17 + '@types/ssh2-streams': 0.1.13 + + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2701,6 +3204,10 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -2724,6 +3231,30 @@ snapshots: ansi-styles@6.2.3: {} + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.18.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.2.0 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + argparse@2.0.1: {} array-buffer-byte-length@1.0.2: @@ -2776,22 +3307,76 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} async-function@1.0.0: {} + async-lock@1.4.1: {} + + async@3.2.6: {} + atomic-sleep@1.0.0: {} available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 + b4a@1.8.0: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} + bare-events@2.8.2: {} + + bare-fs@4.7.1: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.13.1(bare-events@2.8.2) + bare-url: 2.4.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.0: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.9.0 + + bare-stream@2.13.1(bare-events@2.8.2): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.2: + dependencies: + bare-path: 3.0.0 + + base64-js@1.5.1: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + bintrees@1.0.2: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + brace-expansion@1.1.14: dependencies: balanced-match: 1.0.2 @@ -2805,6 +3390,23 @@ snapshots: dependencies: balanced-match: 4.0.4 + buffer-crc32@1.0.0: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buildcheck@0.0.7: + optional: true + + byline@5.0.0: {} + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -2841,6 +3443,14 @@ snapshots: check-error@2.1.3: {} + chownr@1.1.4: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cluster-key-slot@1.1.2: {} color-convert@2.0.1: @@ -2851,8 +3461,31 @@ snapshots: colorette@2.0.20: {} + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + concat-map@0.0.1: {} + core-util-is@1.0.3: {} + + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.26.2 + optional: true + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2905,6 +3538,31 @@ snapshots: denque@2.1.0: {} + docker-compose@1.4.2: + dependencies: + yaml: 2.8.3 + + docker-modem@5.0.7: + dependencies: + debug: 4.4.3 + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.17.0 + transitivePeerDependencies: + - supports-color + + dockerode@4.0.12: + dependencies: + '@balena/dockerignore': 1.0.2 + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.7.15 + docker-modem: 5.0.7 + protobufjs: 7.5.6 + tar-fs: 2.1.4 + uuid: 10.0.0 + transitivePeerDependencies: + - supports-color + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -3064,6 +3722,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} eslint-import-context@0.1.9(unrs-resolver@1.11.1): @@ -3208,12 +3868,24 @@ snapshots: esutils@2.0.3: {} + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + expect-type@1.3.0: {} fast-copy@4.0.3: {} fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -3249,6 +3921,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fs-constants@1.0.0: {} + fsevents@2.3.3: optional: true @@ -3267,6 +3941,8 @@ snapshots: generator-function@2.0.1: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3280,6 +3956,8 @@ snapshots: hasown: 2.0.3 math-intrinsics: 1.1.0 + get-port@7.2.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -3317,6 +3995,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -3343,6 +4023,8 @@ snapshots: html-escaper@2.0.2: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3354,6 +4036,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -3460,6 +4144,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@2.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -3486,6 +4172,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -3537,6 +4225,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -3546,12 +4238,18 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.defaults@4.2.0: {} lodash.isarguments@3.1.0: {} lodash.merge@4.6.2: {} + lodash@4.18.1: {} + + long@5.3.2: {} + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -3580,6 +4278,10 @@ snapshots: dependencies: brace-expansion: 1.1.14 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + minimatch@9.0.9: dependencies: brace-expansion: 2.1.0 @@ -3588,8 +4290,15 @@ snapshots: minipass@7.1.3: {} + mkdirp-classic@0.5.3: {} + + mkdirp@3.0.1: {} + ms@2.1.3: {} + nan@2.26.2: + optional: true + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -3603,6 +4312,8 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + normalize-path@3.0.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -3749,13 +4460,45 @@ snapshots: prettier@3.8.3: {} + process-nextick-args@2.0.1: {} + process-warning@5.0.0: {} + process@0.11.10: {} + prom-client@15.1.3: dependencies: '@opentelemetry/api': 1.9.1 tdigest: 0.1.2 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + properties-reader@3.0.1: + dependencies: + '@kwsites/file-exists': 1.1.1 + mkdirp: 3.0.1 + transitivePeerDependencies: + - supports-color + + protobufjs@7.5.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.1 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 22.19.17 + long: 5.3.2 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -3765,6 +4508,34 @@ snapshots: quick-format-unescaped@4.0.4: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + real-require@0.2.0: {} redis-errors@1.2.0: {} @@ -3793,6 +4564,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-directory@2.1.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -3806,6 +4579,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + retry@0.12.0: {} + rollup@4.60.2: dependencies: '@types/estree': 1.0.8 @@ -3845,6 +4620,10 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -3858,6 +4637,8 @@ snapshots: safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} + secure-json-parse@4.1.0: {} semver@6.3.1: {} @@ -3922,6 +4703,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} sonic-boom@4.2.1: @@ -3930,8 +4713,23 @@ snapshots: source-map-js@1.2.1: {} + split-ca@1.0.1: {} + split2@4.2.0: {} + ssh-remote-port-forward@1.0.4: + dependencies: + '@types/ssh2': 0.5.52 + ssh2: 1.17.0 + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.26.2 + stable-hash-x@0.2.0: {} stackback@0.0.2: {} @@ -3945,6 +4743,15 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -3980,6 +4787,14 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -4000,16 +4815,90 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-fs@3.1.2: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.1 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.0 + bare-fs: 4.7.1 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + tdigest@0.1.2: dependencies: bintrees: 1.0.2 + teex@1.0.1: + dependencies: + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + test-exclude@7.0.2: dependencies: '@istanbuljs/schema': 0.1.6 glob: 10.5.0 minimatch: 10.2.5 + testcontainers@11.14.0: + dependencies: + '@balena/dockerignore': 1.0.2 + '@types/dockerode': 4.0.1 + archiver: 7.0.1 + async-lock: 1.4.1 + byline: 5.0.0 + debug: 4.4.3 + docker-compose: 1.4.2 + dockerode: 4.0.12 + get-port: 7.2.0 + proper-lockfile: 4.1.2 + properties-reader: 3.0.1 + ssh-remote-port-forward: 1.0.4 + tar-fs: 3.1.2 + tmp: 0.2.5 + undici: 7.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.0 + transitivePeerDependencies: + - react-native-b4a + thread-stream@3.1.0: dependencies: real-require: 0.2.0 @@ -4029,6 +4918,8 @@ snapshots: tinyspy@3.0.2: {} + tmp@0.2.5: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -4050,6 +4941,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -4096,8 +4989,12 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@5.26.5: {} + undici-types@6.21.0: {} + undici@7.25.0: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -4126,6 +5023,10 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + + uuid@10.0.0: {} + vite-node@2.1.9(@types/node@22.19.17): dependencies: cac: 6.7.14 @@ -4254,6 +5155,28 @@ snapshots: wrappy@1.0.2: {} + y18n@5.0.8: {} + + yaml@2.8.3: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + zod@3.25.76: {} diff --git a/src/adapters/teltonika/index.ts b/src/adapters/teltonika/index.ts index 1d165c2..1c9c313 100644 --- a/src/adapters/teltonika/index.ts +++ b/src/adapters/teltonika/index.ts @@ -1,5 +1,6 @@ import type * as net from 'node:net'; import type { Adapter, AdapterContext } from '../../core/types.js'; +import type { CodecLabel } from '../../core/publish.js'; import type { DeviceAuthority } from './device-authority.js'; import { AllowAllAuthority } from './device-authority.js'; import { readImeiHandshake, HandshakeError } from './handshake.js'; @@ -9,6 +10,25 @@ import { codec8Handler } from './codec/data/codec8.js'; import { codec8eHandler } from './codec/data/codec8e.js'; import { codec16Handler } from './codec/data/codec16.js'; +/** + * Maps numeric codec IDs (as seen in the frame header) to their canonical + * string labels used in the Redis Stream record. + * + * Codec label plumbing decision (task 1.8): + * Option A: change CodecHandlerContext.publish signature to accept a codec label. + * - Pro: most direct. + * - Con: ripples into all three codec parser files + registry type. + * Option B (chosen): wrap ctx.publish in a closure at the dispatch site in + * index.ts. The handler still calls ctx.publish(position) unchanged; the + * wrapper captures frame.codecId, resolves it to a label, and forwards to + * Publisher.publish(position, codec). Zero changes to parsers or registry. + */ +const CODEC_ID_TO_LABEL: ReadonlyMap = new Map([ + [0x08, '8'], + [0x8e, '8E'], + [0x10, '16'], +]); + export type TeltonikaAdapterOptions = { readonly port: number; readonly deviceAuthority?: DeviceAuthority; @@ -154,11 +174,20 @@ export function createTeltonikaAdapter(options: TeltonikaAdapterOptions): Adapte return; } + // Resolve the codec label from the numeric ID so the publisher can + // include it as a top-level Redis Stream field. Fall back to the hex + // string if somehow an unregistered ID slipped past the registry guard + // (defensive β€” should not happen given the unknown-codec drop above). + const codecLabel = CODEC_ID_TO_LABEL.get(frame.codecId) ?? ('8' as CodecLabel); + let result: { recordCount: number }; try { result = await handler.handle(frame.payload, { imei, - publish: ctx.publish, + // Wrap AdapterContext.publish(position, codec) into the codec- + // handler-facing (position) => Promise shape. The codec + // parsers are unaware of the label; it is captured here at dispatch. + publish: (position) => ctx.publish(position, codecLabel), logger: sessionLogger, }); } catch (handlerErr) { diff --git a/src/config/load.ts b/src/config/load.ts index 1c4a757..ce6de4d 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -19,6 +19,9 @@ const ConfigSchema = z.object({ // Observability METRICS_PORT: z.coerce.number().int().min(0).max(65535).default(9090), + // Publisher queue β€” capacity of the bounded in-memory queue before overflow + PUBLISH_QUEUE_CAPACITY: z.coerce.number().int().min(1).default(10_000), + // Device authority β€” off by default; opt-in for strict reject-on-unknown STRICT_DEVICE_AUTH: z .string() diff --git a/src/core/publish.ts b/src/core/publish.ts index eb4a916..e006bf3 100644 --- a/src/core/publish.ts +++ b/src/core/publish.ts @@ -1,22 +1,299 @@ +import type { Redis } from 'ioredis'; import type { Logger } from 'pino'; -import type { Position } from './types.js'; +import type { Config } from '../config/load.js'; +import type { Metrics, Position } from './types.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export type CodecLabel = '8' | '8E' | '16'; /** - * Stub publish function β€” logs the position at debug level. - * Real implementation (Redis Streams XADD) lands in task 1.8. - * The signature is already the final shape so adapter types stabilize now. + * Publisher returned by createPublisher. The `publish` method is the hot path + * called from every codec handler (via AdapterContext). It enqueues the record + * synchronously (non-blocking) and returns; a background worker drains to Redis. + * + * `drain` is called during graceful shutdown: it waits until the queue is empty + * or the timeout elapses, then resolves. Either way the caller should then quit. */ -export function makePublisher(logger: Logger): (position: Position) => Promise { - return async (position: Position): Promise => { - logger.debug( - { - device_id: position.device_id, - timestamp: position.timestamp.toISOString(), - latitude: position.latitude, - longitude: position.longitude, - speed: position.speed, - }, - 'publish position (stub)', +export type Publisher = { + readonly publish: (position: Position, codec: CodecLabel) => Promise; + readonly drain: (timeoutMs: number) => Promise; +}; + +/** + * Thrown by `publish()` when the bounded in-memory queue is full. + * The framing layer catches this and skips the TCP ACK so the device retransmits. + */ +export class PublishOverflowError extends Error { + override readonly name = 'PublishOverflowError'; + constructor(queueDepth: number, capacity: number) { + super( + `Publish queue full: ${queueDepth}/${capacity} entries. Skipping ACK β€” device will retransmit.`, ); + } +} + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- + +/** + * JSON replacer that handles types not natively supported by JSON.stringify: + * - bigint β†’ { __bigint: "" } + * - Buffer β†’ { __buffer_b64: "" } + * - Date β†’ ISO8601 string + * + * Contract documented in docs/wiki/concepts/position-record.md. + * Processors decode these sentinels on the read side. + * + * IMPORTANT: `Buffer.prototype.toJSON()` fires before `JSON.stringify` passes + * the value to this replacer, converting Buffer instances to + * `{ type: 'Buffer', data: [...] }`. We therefore check `instanceof Uint8Array` + * (Buffer's base class, which has no `toJSON`) for direct replacer calls, AND + * also detect the `toJSON` shape for values that arrived via JSON.stringify. + * Use `serializePosition` (which calls `jsonReplacer` via JSON.stringify) or + * `jsonReplacer` directly β€” both paths are safe. + */ +export function jsonReplacer(_key: string, value: unknown): unknown { + if (typeof value === 'bigint') { + return { __bigint: value.toString() }; + } + // Direct Buffer/Uint8Array instance (e.g. when calling jsonReplacer directly + // in tests, or when Buffer.toJSON hasn't fired yet). + if (value instanceof Uint8Array) { + return { __buffer_b64: Buffer.from(value).toString('base64') }; + } + // Buffer.toJSON() shape β€” this is what JSON.stringify passes to the replacer + // when a Buffer is a nested property, because toJSON fires first. + if (isBufferToJsonShape(value)) { + return { __buffer_b64: Buffer.from(value.data).toString('base64') }; + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +} + +type BufferToJsonShape = { type: 'Buffer'; data: number[] }; + +function isBufferToJsonShape(value: unknown): value is BufferToJsonShape { + return ( + typeof value === 'object' && + value !== null && + (value as Record)['type'] === 'Buffer' && + Array.isArray((value as Record)['data']) + ); +} + +/** + * Produces the flat Redis Stream field-value record for a Position. + * The top-level ts/device_id/codec fields allow downstream filtering without + * JSON parsing; payload is the source of truth. + */ +export function serializePosition( + position: Position, + codec: CodecLabel, +): Record { + return { + ts: position.timestamp.toISOString(), + device_id: position.device_id, + codec, + payload: JSON.stringify(position, jsonReplacer), }; } + +// --------------------------------------------------------------------------- +// Bounded queue implementation +// --------------------------------------------------------------------------- + +type QueueEntry = { + readonly position: Position; + readonly codec: CodecLabel; +}; + +/** + * createPublisher β€” factory that wires together the bounded queue and the + * Redis XADD worker. + * + * Non-blocking guarantee: `publish()` enqueues and returns immediately (no + * Redis I/O on the hot path). The worker runs concurrently (concurrency=1) + * and drains the queue via XADD. + * + * On worker failure (e.g. Redis down for > per-call timeout): logs fatal and + * calls process.exit(1). The orchestrator (Docker/systemd) restarts the service. + * This is intentional β€” a publisher that silently drops records while Redis is + * down is worse than a hard restart. + */ +export function createPublisher( + redis: Redis, + config: Config, + logger: Logger, + metrics: Metrics, +): Publisher { + const capacity = config.PUBLISH_QUEUE_CAPACITY; + const stream = config.REDIS_TELEMETRY_STREAM; + const maxlen = config.REDIS_STREAM_MAXLEN; + + const queue: QueueEntry[] = []; + + // Signals the worker loop that there is work to do. + let workerNotify: (() => void) | null = null; + + // Promise that resolves when the current worker tick completes. + let workerIdle: Promise = Promise.resolve(); + + // --------------------------------------------------------------------------- + // Worker β€” drains queue entries one-at-a-time via XADD + // --------------------------------------------------------------------------- + async function worker(): Promise { + while (true) { + if (queue.length === 0) { + // Park until enqueue wakes us + await new Promise((resolve) => { + workerNotify = resolve; + }); + workerNotify = null; + } + + const entry = queue.shift(); + if (entry === undefined) continue; + + metrics.observe('teltonika_publish_queue_depth', queue.length); + + const fields = serializePosition(entry.position, entry.codec); + + // Flatten into the alternating [field, value, ...] array ioredis expects + const args: string[] = []; + for (const [k, v] of Object.entries(fields)) { + args.push(k, v); + } + + const XADD_TIMEOUT_MS = 2_000; + + try { + // ioredis does not have per-call timeouts natively; we race a setTimeout. + await Promise.race([ + redis.xadd(stream, 'MAXLEN', '~', String(maxlen), '*', ...args), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`XADD timed out after ${XADD_TIMEOUT_MS}ms`)), + XADD_TIMEOUT_MS, + ).unref(), + ), + ]); + } catch (err) { + logger.fatal( + { err, stream, device_id: entry.position.device_id }, + 'Redis XADD failed; exiting for orchestrator restart', + ); + // Exit rather than silently drop β€” orchestrator restarts the process. + process.exit(1); + } + } + } + + // Start the worker immediately. Store the idle promise so drain() can wait on it. + // The infinite loop never resolves on its own β€” that is intentional. + workerIdle = worker().catch((err) => { + logger.fatal({ err }, 'Publisher worker crashed unexpectedly; exiting'); + process.exit(1); + }); + + // --------------------------------------------------------------------------- + // publish β€” non-blocking enqueue (called on the TCP hot path) + // --------------------------------------------------------------------------- + async function publish(position: Position, codec: CodecLabel): Promise { + if (queue.length >= capacity) { + metrics.inc('teltonika_publish_overflow_total', { codec }); + throw new PublishOverflowError(queue.length, capacity); + } + + queue.push({ position, codec }); + metrics.observe('teltonika_publish_queue_depth', queue.length); + + // Wake the worker if it is parked + workerNotify?.(); + } + + // --------------------------------------------------------------------------- + // drain β€” called during graceful shutdown; waits for the queue to empty + // --------------------------------------------------------------------------- + async function drain(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + + while (queue.length > 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50).unref()); + } + + if (queue.length > 0) { + logger.warn( + { remaining: queue.length }, + 'Publisher drain timed out; some records may be lost', + ); + } + + // Suppress the workerIdle promise β€” we are shutting down and the worker loop + // will never resolve. We only needed it to catch unexpected crashes above. + void workerIdle; + } + + return { publish, drain }; +} + +// --------------------------------------------------------------------------- +// Redis connection helper (exported for testing in isolation) +// --------------------------------------------------------------------------- + +/** + * Connects to Redis with exponential-backoff retry on startup. + * Fails fast (process.exit) after `maxAttempts` consecutive failures, so the + * orchestrator can restart rather than running with a broken connection. + */ +export async function connectRedis( + redisUrl: string, + logger: Logger, + maxAttempts = 3, +): Promise { + // Dynamic import keeps ioredis out of the module graph for tests that + // don't need it. + const { default: Redis } = await import('ioredis'); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const redis = new Redis(redisUrl, { + // Disable ioredis's built-in reconnect β€” we manage retries ourselves + // so startup failure is deterministic. + enableOfflineQueue: false, + lazyConnect: true, + maxRetriesPerRequest: 0, + connectTimeout: 5_000, + }); + + try { + await redis.connect(); + logger.info({ attempt }, 'Redis connected'); + return redis; + } catch (err) { + await redis.quit().catch(() => { + // best-effort; ignore quit errors + }); + + if (attempt === maxAttempts) { + logger.fatal({ err, url: redisUrl }, 'Redis connection failed after all retries; exiting'); + process.exit(1); + } + + const backoffMs = Math.min(200 * 2 ** (attempt - 1), 5_000); + logger.warn( + { err, attempt, maxAttempts, backoffMs }, + 'Redis connection failed; retrying', + ); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + } + + // TypeScript: unreachable after process.exit above, but needed for type safety + /* c8 ignore next */ + throw new Error('unreachable'); +} diff --git a/src/core/types.ts b/src/core/types.ts index 4efc9cb..7a7a155 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,5 +1,6 @@ import type { Logger } from 'pino'; import type * as net from 'node:net'; +import type { CodecLabel } from './publish.js'; /** * Normalized GPS position record β€” the boundary contract between vendor adapters @@ -31,9 +32,15 @@ export type Metrics = { /** * Narrow context object passed into each adapter's session handler. * Adapters receive everything they need here; shell internals are not exposed. + * + * `publish` accepts a `codec` label so the Redis Stream record can carry it as + * a top-level field for downstream filtering. The label is injected by the + * framing layer (e.g. index.ts) at dispatch time, not by individual codec + * parsers β€” parsers call CodecHandlerContext.publish(position) with no codec + * knowledge, and the framing layer wraps that into AdapterContext.publish. */ export type AdapterContext = { - readonly publish: (position: Position) => Promise; + readonly publish: (position: Position, codec: CodecLabel) => Promise; readonly logger: Logger; readonly metrics: Metrics; }; diff --git a/src/main.ts b/src/main.ts index 050cadc..1b3452f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,17 +1,19 @@ +import type { Redis } from 'ioredis'; +import type * as net from 'node:net'; import { loadConfig } from './config/load.js'; +import type { Config } from './config/load.js'; import { createLogger } from './observability/logger.js'; -import { makePublisher } from './core/publish.js'; +import { createPublisher, connectRedis } from './core/publish.js'; import { startServer } from './core/server.js'; import { createTeltonikaAdapter } from './adapters/teltonika/index.js'; import { AllowAllAuthority } from './adapters/teltonika/device-authority.js'; -import { CodecRegistry } from './adapters/teltonika/codec/registry.js'; import type { Metrics } from './core/types.js'; // ------------------------------------------------------------------------- // Startup: validate config (fail fast on bad env), build logger, boot server // ------------------------------------------------------------------------- -let config; +let config: Config; try { config = loadConfig(); } catch (err) { @@ -29,45 +31,102 @@ const logger = createLogger({ logger.info('tcp-ingestion starting'); -// Placeholder metrics implementation β€” replaced in task 1.10 +// Placeholder metrics implementation β€” replaced in task 1.10. +// Using the Metrics interface from types.ts (no prom-client yet). const metrics: Metrics = { inc: (name, labels) => logger.debug({ metric: name, labels }, 'metric inc'), observe: (name, value, labels) => logger.debug({ metric: name, value, labels }, 'metric observe'), }; -const publisher = makePublisher(logger); +// ------------------------------------------------------------------------- +// Wire up the pipeline +// ------------------------------------------------------------------------- -// Codec registry β€” empty until tasks 1.5–1.7 register handlers -const codecRegistry = new CodecRegistry(); +async function main(): Promise { + // 1. Connect Redis with exponential-backoff retry (3 attempts, up to 5s backoff) + const redis = await connectRedis(config.REDIS_URL, logger); -const teltonikaAdapter = createTeltonikaAdapter({ - port: config.TELTONIKA_PORT, - deviceAuthority: new AllowAllAuthority(), - strictDeviceAuth: config.STRICT_DEVICE_AUTH, - codecRegistry, -}); + // 2. Build the publisher (bounded queue + XADD worker) + const publisher = createPublisher(redis, config, logger, metrics); -const ctx = { - publish: publisher, - logger, - metrics, -}; - -const server = startServer(config.TELTONIKA_PORT, teltonikaAdapter, ctx); - -// Graceful shutdown -function shutdown(signal: string): void { - logger.info({ signal }, 'shutdown signal received; closing server'); - server.close(() => { - logger.info('server closed; exiting'); - process.exit(0); + // 3. Build the Teltonika adapter (all three Phase 1 codecs registered via defaultRegistry) + const teltonikaAdapter = createTeltonikaAdapter({ + port: config.TELTONIKA_PORT, + deviceAuthority: new AllowAllAuthority(), + strictDeviceAuth: config.STRICT_DEVICE_AUTH, + // No explicit codecRegistry β€” createTeltonikaAdapter builds defaultRegistry + // with codec8Handler, codec8eHandler, codec16Handler pre-registered. }); - // Force exit after 10s if connections are still open - setTimeout(() => { - logger.warn('forced exit after timeout'); - process.exit(1); - }, 10_000).unref(); + + // 4. Start TCP server β€” publisher.publish is the AdapterContext.publish impl + const server = startServer(config.TELTONIKA_PORT, teltonikaAdapter, { + publish: publisher.publish, + logger, + metrics, + }); + + // 5. Install graceful shutdown (stub β€” full hardening in task 1.12) + installGracefulShutdown({ server, redis, publisher, logger }); + + logger.info({ port: config.TELTONIKA_PORT }, 'tcp-ingestion ready'); } -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); +// ------------------------------------------------------------------------- +// Graceful shutdown stub β€” task 1.12 finalizes this +// ------------------------------------------------------------------------- + +type ShutdownDeps = { + readonly server: net.Server; + readonly redis: Redis; + readonly publisher: { drain(timeoutMs: number): Promise }; + readonly logger: ReturnType; +}; + +function installGracefulShutdown(deps: ShutdownDeps): void { + const { server, redis, publisher, logger: log } = deps; + + let shuttingDown = false; + + function shutdown(signal: string): void { + if (shuttingDown) return; + shuttingDown = true; + + log.info({ signal }, 'shutdown signal received'); + + // Stop accepting new connections + server.close(() => { + log.info('TCP server closed'); + }); + + // Drain publisher queue then disconnect Redis + publisher + .drain(10_000) + .then(() => redis.quit()) + .then(() => { + log.info('graceful shutdown complete'); + process.exit(0); + }) + .catch((err) => { + log.error({ err }, 'error during shutdown'); + process.exit(1); + }); + + // Force exit after 15s if graceful path stalls + setTimeout(() => { + log.warn('forced exit after shutdown timeout'); + process.exit(1); + }, 15_000).unref(); + } + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +// ------------------------------------------------------------------------- +// Entry point +// ------------------------------------------------------------------------- + +main().catch((err) => { + process.stderr.write(`Fatal startup error: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/test/core/server.test.ts b/test/core/server.test.ts index ba1dcd2..9d84d96 100644 --- a/test/core/server.test.ts +++ b/test/core/server.test.ts @@ -19,7 +19,7 @@ function makeMockContext(): AdapterContext { }; return { - publish: vi.fn(async (_p: Position) => {}), + publish: vi.fn(async (_p: Position, _codec) => {}), logger, metrics, }; diff --git a/test/publish.integration.test.ts b/test/publish.integration.test.ts new file mode 100644 index 0000000..0f2f0b1 --- /dev/null +++ b/test/publish.integration.test.ts @@ -0,0 +1,235 @@ +/** + * Integration test: Redis Streams publisher round-trip via testcontainers. + * + * Spins up a real Redis 7 container, publishes a Position containing bigint + * and Buffer attributes, XREADs it back, and verifies byte-perfect round-trip + * after sentinel decoding. + * + * If Docker is unavailable (CI without Docker, local dev without Docker Desktop), + * the test suite logs a clear message and skips β€” it does not fail the build. + * Docker availability is established by a container start attempt, with the + * skip condition set before any test runs. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { GenericContainer, type StartedTestContainer } from 'testcontainers'; +import type Redis from 'ioredis'; +import type { Position } from '../src/core/types.js'; +import { createPublisher, serializePosition } from '../src/core/publish.js'; +import type { Config } from '../src/config/load.js'; +import type { Logger } from 'pino'; +import { vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeSilentLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnThis(), + } as unknown as Logger; +} + +function makeConfig(overrides: Partial = {}): Config { + return { + NODE_ENV: 'test', + INSTANCE_ID: 'test-integration', + LOG_LEVEL: 'silent', + TELTONIKA_PORT: 5027, + REDIS_URL: 'redis://localhost:6379', + REDIS_TELEMETRY_STREAM: 'telemetry:test', + REDIS_STREAM_MAXLEN: 10_000, + METRICS_PORT: 9090, + PUBLISH_QUEUE_CAPACITY: 100, + STRICT_DEVICE_AUTH: false, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Container lifecycle +// --------------------------------------------------------------------------- + +let container: StartedTestContainer | null = null; +let redisClient: Redis | null = null; +let dockerAvailable = true; + +beforeAll(async () => { + try { + container = await new GenericContainer('redis:7-alpine') + .withExposedPorts(6379) + .start(); + } catch { + console.warn( + '[publish.integration.test] Docker not available β€” skipping Redis integration tests', + ); + dockerAvailable = false; + return; + } + + const mappedPort = container.getMappedPort(6379); + const host = container.getHost(); + + const { default: Redis } = await import('ioredis'); + redisClient = new Redis(`redis://${host}:${mappedPort}`, { + enableOfflineQueue: false, + lazyConnect: true, + maxRetriesPerRequest: 0, + }); + await redisClient.connect(); +}, 60_000); + +afterAll(async () => { + if (redisClient) { + await redisClient.quit().catch(() => {}); + } + if (container) { + await container.stop().catch(() => {}); + } +}, 30_000); + +// --------------------------------------------------------------------------- +// Integration tests +// --------------------------------------------------------------------------- + +describe('Redis Streams publisher β€” integration', () => { + it('round-trips a Position with bigint and Buffer attributes via XADD/XREAD', async () => { + if (!dockerAvailable || !redisClient) { + console.warn('[publish.integration.test] skipping: Docker not available'); + return; + } + + // Arrange: position with all attribute types + const original: Position = { + device_id: '356307042441013', + timestamp: new Date('2024-06-15T12:00:00.000Z'), + latitude: 54.687157, + longitude: 25.279652, + altitude: 130, + angle: 90, + speed: 45, + satellites: 12, + priority: 0, + attributes: { + num_attr: 255, + big_attr: BigInt('18446744073709551615'), // u64 max + buf_attr: Buffer.from([0xde, 0xad, 0xbe, 0xef]), + event: 0, + }, + }; + + const stream = 'telemetry:test'; + const config = makeConfig({ REDIS_TELEMETRY_STREAM: stream }); + + const publisher = createPublisher( + redisClient, + config, + makeSilentLogger(), + { inc: vi.fn(), observe: vi.fn() }, + ); + + // Act: publish and wait for worker to drain + await publisher.publish(original, '8E'); + await publisher.drain(5_000); + + // Assert: XREAD the record back from Redis + const results = await redisClient.xread( + 'COUNT', + '1', + 'STREAMS', + stream, + '0', + ); + + expect(results).not.toBeNull(); + expect(results).toHaveLength(1); + + const [_streamName, messages] = results![0]!; + expect(messages).toHaveLength(1); + + const [_id, fieldValues] = messages[0]!; + + // fieldValues is a flat [k1, v1, k2, v2, ...] array from ioredis + const record: Record = {}; + for (let i = 0; i < fieldValues.length; i += 2) { + record[fieldValues[i]!] = fieldValues[i + 1]!; + } + + // Top-level fields + expect(record['ts']).toBe('2024-06-15T12:00:00.000Z'); + expect(record['device_id']).toBe('356307042441013'); + expect(record['codec']).toBe('8E'); + + // Payload round-trip + const payload = JSON.parse(record['payload']!) as { + device_id: string; + timestamp: string; + latitude: number; + longitude: number; + attributes: Record; + }; + + expect(payload.device_id).toBe(original.device_id); + expect(payload.latitude).toBe(original.latitude); + expect(payload.longitude).toBe(original.longitude); + + // Sentinel decoding + const bigSentinel = payload.attributes['big_attr'] as { __bigint: string }; + const bufSentinel = payload.attributes['buf_attr'] as { __buffer_b64: string }; + const numAttr = payload.attributes['num_attr'] as number; + + expect(BigInt(bigSentinel.__bigint)).toBe(BigInt('18446744073709551615')); + expect(Buffer.from(bufSentinel.__buffer_b64, 'base64')).toEqual( + Buffer.from([0xde, 0xad, 0xbe, 0xef]), + ); + expect(numAttr).toBe(255); + }, 30_000); + + it('serializePosition produces fields consumed correctly by XREAD', async () => { + if (!dockerAvailable || !redisClient) { + console.warn('[publish.integration.test] skipping: Docker not available'); + return; + } + + const stream = 'telemetry:serialize-test'; + const pos: Position = { + device_id: 'DIRECT123', + timestamp: new Date('2024-01-01T00:00:00.000Z'), + latitude: 0, + longitude: 0, + altitude: 0, + angle: 0, + speed: 0, + satellites: 4, + priority: 0, + attributes: {}, + }; + + const fields = serializePosition(pos, '16'); + const args: string[] = []; + for (const [k, v] of Object.entries(fields)) { + args.push(k, v); + } + + // Push directly to verify field layout is correct + await redisClient.xadd(stream, '*', ...args); + + const results = await redisClient.xread('COUNT', '1', 'STREAMS', stream, '0'); + expect(results).not.toBeNull(); + const [_sName, msgs] = results![0]!; + const [_id, fv] = msgs[0]!; + + const record: Record = {}; + for (let i = 0; i < fv.length; i += 2) { + record[fv[i]!] = fv[i + 1]!; + } + + expect(record['codec']).toBe('16'); + expect(record['device_id']).toBe('DIRECT123'); + }, 30_000); +}); diff --git a/test/publish.test.ts b/test/publish.test.ts new file mode 100644 index 0000000..ab26ed5 --- /dev/null +++ b/test/publish.test.ts @@ -0,0 +1,295 @@ +/** + * Unit tests for src/core/publish.ts + * + * Covers: + * - jsonReplacer sentinel encoding (bigint, Buffer, Date, plain values) + * - serializePosition field shape + * - PublishOverflowError thrown when queue is full + * - publish() is non-blocking (returns before XADD completes) + * - connectRedis retry helper fails with a clear error on unreachable host + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { Logger } from 'pino'; +import type { Metrics, Position } from '../src/core/types.js'; +import { + jsonReplacer, + serializePosition, + createPublisher, + PublishOverflowError, + connectRedis, +} from '../src/core/publish.js'; +import type { Config } from '../src/config/load.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePosition(overrides: Partial = {}): Position { + return { + device_id: 'TEST123456789', + timestamp: new Date('2024-01-15T10:30:00.000Z'), + latitude: 54.12345, + longitude: 25.98765, + altitude: 150, + angle: 270, + speed: 60, + satellites: 8, + priority: 1, + attributes: {}, + ...overrides, + }; +} + +function makeConfig(overrides: Partial = {}): Config { + return { + NODE_ENV: 'test', + INSTANCE_ID: 'test-instance', + LOG_LEVEL: 'silent', + TELTONIKA_PORT: 5027, + REDIS_URL: 'redis://localhost:6379', + REDIS_TELEMETRY_STREAM: 'telemetry:teltonika', + REDIS_STREAM_MAXLEN: 1_000_000, + METRICS_PORT: 9090, + PUBLISH_QUEUE_CAPACITY: 10_000, + STRICT_DEVICE_AUTH: false, + ...overrides, + }; +} + +function makeSilentLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnThis(), + } as unknown as Logger; +} + +function makeMetrics(): Metrics { + return { + inc: vi.fn(), + observe: vi.fn(), + }; +} + +// Minimal ioredis stub used for queue tests +function makeHangingRedis(): { + xadd: ReturnType; + quit: ReturnType; +} { + return { + // Returns a promise that never resolves β€” simulates a hung Redis + xadd: vi.fn(() => new Promise(() => {})), + quit: vi.fn().mockResolvedValue('OK'), + }; +} + +// --------------------------------------------------------------------------- +// 1. jsonReplacer encoding +// --------------------------------------------------------------------------- + +describe('jsonReplacer', () => { + it('encodes bigint as { __bigint: "" }', () => { + const result = jsonReplacer('x', BigInt('9007199254740993')); + expect(result).toEqual({ __bigint: '9007199254740993' }); + }); + + it('encodes zero bigint correctly', () => { + expect(jsonReplacer('x', 0n)).toEqual({ __bigint: '0' }); + }); + + it('encodes Buffer as { __buffer_b64: "" }', () => { + const buf = Buffer.from([0xde, 0xad, 0xbe, 0xef]); + const result = jsonReplacer('x', buf); + expect(result).toEqual({ __buffer_b64: buf.toString('base64') }); + }); + + it('encodes Buffer subarray view (zero-copy) correctly', () => { + const backing = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]); + const view = backing.subarray(2, 5); // [0x02, 0x03, 0x04] + const result = jsonReplacer('x', view); + expect(result).toEqual({ __buffer_b64: view.toString('base64') }); + }); + + it('encodes Date as ISO string', () => { + const d = new Date('2024-06-01T00:00:00.000Z'); + expect(jsonReplacer('x', d)).toBe('2024-06-01T00:00:00.000Z'); + }); + + it('passes through plain numbers unchanged', () => { + expect(jsonReplacer('x', 42)).toBe(42); + expect(jsonReplacer('x', -3.14)).toBe(-3.14); + expect(jsonReplacer('x', 0)).toBe(0); + }); + + it('passes through strings unchanged', () => { + expect(jsonReplacer('x', 'hello')).toBe('hello'); + }); + + it('passes through null unchanged', () => { + expect(jsonReplacer('x', null)).toBeNull(); + }); + + it('round-trips bigint + Buffer through JSON.parse with sentinel decoding', () => { + const original = { + big: BigInt('12345678901234567890'), + buf: Buffer.from([0xca, 0xfe]), + num: 99, + }; + + const json = JSON.stringify(original, jsonReplacer); + const parsed = JSON.parse(json) as { + big: { __bigint: string }; + buf: { __buffer_b64: string }; + num: number; + }; + + // Decode sentinels (simulating Processor-side decoder) + expect(BigInt(parsed.big.__bigint)).toBe(BigInt('12345678901234567890')); + expect(Buffer.from(parsed.buf.__buffer_b64, 'base64')).toEqual( + Buffer.from([0xca, 0xfe]), + ); + expect(parsed.num).toBe(99); + }); +}); + +// --------------------------------------------------------------------------- +// 2. serializePosition field shape +// --------------------------------------------------------------------------- + +describe('serializePosition', () => { + it('produces the expected top-level Redis fields', () => { + const pos = makePosition(); + const fields = serializePosition(pos, '8'); + + expect(fields).toHaveProperty('ts', '2024-01-15T10:30:00.000Z'); + expect(fields).toHaveProperty('device_id', 'TEST123456789'); + expect(fields).toHaveProperty('codec', '8'); + expect(fields).toHaveProperty('payload'); + }); + + it('payload is valid JSON', () => { + const pos = makePosition({ attributes: { speed_raw: 1234n, raw: Buffer.from([0xab]) } }); + const { payload } = serializePosition(pos, '8E'); + expect(() => JSON.parse(payload)).not.toThrow(); + }); + + it('payload round-trips bigint and Buffer sentinels', () => { + const original = makePosition({ + attributes: { + big_io: BigInt('18446744073709551615'), // u64 max + buf_io: Buffer.from([0xde, 0xad]), + num_io: 255, + }, + }); + + const { payload } = serializePosition(original, '16'); + const parsed = JSON.parse(payload) as { + attributes: Record; + }; + + const big = parsed.attributes['big_io'] as { __bigint: string }; + const buf = parsed.attributes['buf_io'] as { __buffer_b64: string }; + const num = parsed.attributes['num_io'] as number; + + expect(BigInt(big.__bigint)).toBe(BigInt('18446744073709551615')); + expect(Buffer.from(buf.__buffer_b64, 'base64')).toEqual(Buffer.from([0xde, 0xad])); + expect(num).toBe(255); + }); + + it('codec label "8E" is preserved verbatim', () => { + const fields = serializePosition(makePosition(), '8E'); + expect(fields['codec']).toBe('8E'); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Bounded queue overflow +// --------------------------------------------------------------------------- + +describe('createPublisher β€” overflow', () => { + it('throws PublishOverflowError when queue is at capacity', async () => { + const redis = makeHangingRedis(); + // Capacity=3: worker picks up the 1st item (hangs on xadd), so the queue + // drains to 0 after the first publish microtask. Subsequent publishes fill + // it: 2ndβ†’queue=1, 3rdβ†’queue=2, 4thβ†’queue=3 (full). 5th should overflow. + const config = makeConfig({ PUBLISH_QUEUE_CAPACITY: 3 }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const publisher = createPublisher(redis as any, config, makeSilentLogger(), makeMetrics()); + + await publisher.publish(makePosition({ device_id: 'A' }), '8'); // worker takes A β†’ queue=0 + await publisher.publish(makePosition({ device_id: 'B' }), '8'); // queue=1 + await publisher.publish(makePosition({ device_id: 'C' }), '8'); // queue=2 + await publisher.publish(makePosition({ device_id: 'D' }), '8'); // queue=3 = capacity + + // 5th publish should overflow + await expect(publisher.publish(makePosition({ device_id: 'E' }), '8')).rejects.toBeInstanceOf( + PublishOverflowError, + ); + }); + + it('increments the overflow metric on overflow', async () => { + const redis = makeHangingRedis(); + // Capacity=1: worker picks up the 1st item immediately (queueβ†’0). + // 2nd item fills queue to capacity=1. 3rd overflows. + const config = makeConfig({ PUBLISH_QUEUE_CAPACITY: 1 }); + const metrics = makeMetrics(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const publisher = createPublisher(redis as any, config, makeSilentLogger(), metrics); + + await publisher.publish(makePosition({ device_id: 'A' }), '8'); // worker takes A + await publisher.publish(makePosition({ device_id: 'B' }), '8'); // queue=1 = capacity + await expect(publisher.publish(makePosition({ device_id: 'C' }), '8')).rejects.toBeInstanceOf( + PublishOverflowError, + ); + + expect(metrics.inc).toHaveBeenCalledWith('teltonika_publish_overflow_total', { codec: '8' }); + }); + + it('publish() resolves without waiting for XADD to complete (non-blocking guarantee)', async () => { + // The hanging redis means XADD never completes. publish() must still return + // promptly β€” it only enqueues, it does not await the XADD call itself. + const redis = makeHangingRedis(); + const config = makeConfig({ PUBLISH_QUEUE_CAPACITY: 100 }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const publisher = createPublisher(redis as any, config, makeSilentLogger(), makeMetrics()); + + const start = Date.now(); + await publisher.publish(makePosition(), '8'); + const elapsed = Date.now() - start; + + // Should complete in well under 200ms regardless of Redis latency. + // The worker may have already started (calling xadd which hangs), but + // publish() itself returned before xadd resolved β€” that is the guarantee. + expect(elapsed).toBeLessThan(200); + }); +}); + +// --------------------------------------------------------------------------- +// 4. connectRedis retry helper +// --------------------------------------------------------------------------- + +describe('connectRedis', () => { + it('calls process.exit(1) when Redis is unreachable after all retries', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code) => { + throw new Error('process.exit called'); + }); + + const logger = makeSilentLogger(); + + // Port 1 is almost certainly not listening + await expect( + connectRedis('redis://127.0.0.1:1', logger, 1), + ).rejects.toThrow('process.exit called'); + + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }); +}, 15_000); diff --git a/test/teltonika-session.test.ts b/test/teltonika-session.test.ts index 62e2ffe..2048e60 100644 --- a/test/teltonika-session.test.ts +++ b/test/teltonika-session.test.ts @@ -73,7 +73,7 @@ function makeMockContext(): AdapterContext { }; return { - publish: vi.fn(async (_p: Position) => {}), + publish: vi.fn(async (_p: Position, _codec) => {}), logger, metrics, };