From 0771fa21c797a2253afa7562f7fe02866702ea7b Mon Sep 17 00:00:00 2001 From: Evgeny Gamov Date: Tue, 16 Jun 2026 19:30:03 +0500 Subject: [PATCH] =?UTF-8?q?docs:=20fix=20plan=20=E2=80=94=20core=20devDep,?= =?UTF-8?q?=20docker=20prod=20deps,=20byte=20budget,=20barrel=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-16-esmole-monorepo.md | 112 ++++++++++++++++-- 1 file changed, 99 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/plans/2026-06-16-esmole-monorepo.md b/docs/superpowers/plans/2026-06-16-esmole-monorepo.md index 5e11baf..e72ad9d 100644 --- a/docs/superpowers/plans/2026-06-16-esmole-monorepo.md +++ b/docs/superpowers/plans/2026-06-16-esmole-monorepo.md @@ -439,12 +439,14 @@ export default defineConfig({ export {} ``` -- [ ] **Step 5: Add core as a dependency of dbmole-mcp.** In `packages/dbmole-mcp/package.json` add to `dependencies`: +- [ ] **Step 5: Add core as a dev dependency of dbmole-mcp.** In `packages/dbmole-mcp/package.json` add to `devDependencies` (NOT `dependencies`): ```json "@dbmole/core": "*" ``` +It must be a **devDependency**: tsup bundles core into `dist` (`noExternal`), so it is a build-time dependency only. If it were a runtime `dependency`, a consumer's `npm install dbmole-mcp` would try to resolve the unpublished `@dbmole/core` from the registry and fail. The workspace still symlinks it for building regardless of dependency type. + - [ ] **Step 6: Reinstall + commit** ```bash @@ -534,8 +536,25 @@ export const clampLimit = (requested: number | undefined, fallback = DEFAULT_ROW export const truncateRows = (rows: T[], limit: number): { rows: T[]; truncated: boolean } => rows.length > limit ? { rows: rows.slice(0, limit), truncated: true } : { rows, truncated: false } +// Slice a string to at most `maxBytes` UTF-8 bytes without splitting a +// multi-byte sequence (back off to a code-point boundary). +export const sliceUtf8 = (s: string, maxBytes: number): string => { + const buf = Buffer.from(s, 'utf8') + if (buf.length <= maxBytes) { + return s + } + let end = maxBytes + while (end > 0 && (buf[end] & 0xc0) === 0x80) { + end -= 1 + } + return buf.subarray(0, end).toString('utf8') +} + // Serialize `value` to JSON and, if it exceeds `maxBytes` (UTF-8), return a -// truncated marker object instead. Used by esmole to cap huge ES responses. +// truncated marker object whose own serialization stays within `maxBytes`. +// `preview` is byte-bounded (not UTF-16 `.slice`), and a fixed reserve covers +// the marker's metadata fields so the returned object does not blow the budget. +// Used by esmole to cap huge ES responses. export const truncateJsonBudget = ( value: unknown, maxBytes: number @@ -548,13 +567,28 @@ export const truncateJsonBudget = ( if (bytes <= maxBytes) { return { body: value, truncated: false } } + const RESERVE = 256 + const preview = sliceUtf8(text, Math.max(0, maxBytes - RESERVE)) return { - body: { _truncated: true, _bytes: bytes, _maxBytes: maxBytes, preview: text.slice(0, maxBytes) }, + body: { _truncated: true, _originalBytes: bytes, _maxBytes: maxBytes, preview }, truncated: true } } ``` +- [ ] **Step 1b: Export the format helpers from the core barrel** (required before any dbmole file imports them from `@dbmole/core`, or typecheck/build fails). Add to `packages/core/src/index.ts`: + +```ts +export { + DEFAULT_ROW_LIMIT, + MAX_ROW_LIMIT, + clampLimit, + truncateRows, + sliceUtf8, + truncateJsonBudget +} from './format.js' +``` + - [ ] **Step 2: Trim `packages/dbmole-mcp/src/format.ts`** to only `normalizeCell` and `formatDbError`, importing the row helpers it still uses from core: ```ts @@ -599,7 +633,7 @@ Re-exporting `clampRowLimit`/`truncateRows` keeps `MAX_ROW_LIMIT` consumers (`to ```ts import { describe, expect, it } from 'vitest' -import { clampLimit, truncateJsonBudget, truncateRows } from '../../src/format.js' +import { clampLimit, sliceUtf8, truncateJsonBudget, truncateRows } from '../../src/format.js' describe('clampLimit', () => { it('returns fallback when undefined', () => { @@ -625,6 +659,24 @@ describe('truncateJsonBudget', () => { expect((out.body as { _truncated: boolean })._truncated).toBe(true) }) }) + +describe('sliceUtf8', () => { + it('returns the string unchanged when it fits', () => { + expect(sliceUtf8('hello', 100)).toBe('hello') + }) + it('caps to a real UTF-8 byte budget', () => { + const out = sliceUtf8('abcdef', 3) + expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(3) + expect(out).toBe('abc') + }) + it('does not split a multi-byte code point at the boundary', () => { + // 'é' is 2 bytes (U+00E9). Budget 3 bytes over 'aéé' (5 bytes) must back + // off rather than emit half of the second 'é'. + const out = sliceUtf8('aéé', 3) + expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(3) + expect(out).toBe('aé') + }) +}) ``` - [ ] **Step 5: Run + commit** @@ -1682,18 +1734,18 @@ New Elasticsearch leaf package. TDD throughout. Reuses all of core (store/regist "prepublishOnly": "tsc --noEmit && tsup" }, "dependencies": { - "@dbmole/core": "*", "@modelcontextprotocol/sdk": "^1.29.0", "undici": "^6.21.0", "zod": "^3.25.76" }, "devDependencies": { + "@dbmole/core": "*", "@testcontainers/elasticsearch": "^11.14.0" } } ``` -Note: undici ships with Node 20+ as the global `fetch`, but the standalone package gives us `Agent`/`Pool` with explicit `dispose()` and a TLS `connect` option for `servername` + `rejectUnauthorized`. +Note: `@dbmole/core` is a **devDependency** (bundled into `dist` by tsup, not a published runtime dep — see Task 2.1 Step 5). undici ships with Node 20+ as the global `fetch`, but the standalone package gives us `Agent`/`Pool` with explicit `dispose()` and a TLS `connect` option for `servername` + `rejectUnauthorized`. - [ ] **Step 2: Create `tsconfig.json`** (`extends ../../tsconfig.base.json`, `include ["src","test"]`). @@ -2878,27 +2930,35 @@ git commit -m "test(esmole): integration against ES 7 and 8" - [ ] **Step 1: Create `packages/esmole-mcp/Dockerfile`** following the dbmole Dockerfile pattern (read `packages/dbmole-mcp/Dockerfile` and mirror it). Because core is bundled by tsup into `dist/index.js`, the runtime image needs only `dist` + the leaf package's prod deps (undici, MCP SDK, zod). The build stage must install from the workspace root so `@dbmole/core` resolves, then run `npm run build --workspace esmole-mcp`. Concretely: ```dockerfile -# build stage — install the whole workspace so @dbmole/core resolves, bundle esmole -FROM node:20-slim AS build +# build stage — install the whole workspace so @dbmole/core resolves, build esmole +FROM node:22-alpine AS build WORKDIR /repo COPY package.json package-lock.json tsconfig.base.json ./ COPY packages/core/package.json packages/core/ +COPY packages/dbmole-mcp/package.json packages/dbmole-mcp/ COPY packages/esmole-mcp/package.json packages/esmole-mcp/ RUN npm ci COPY packages/core packages/core COPY packages/esmole-mcp packages/esmole-mcp -RUN npm run build --workspace esmole-mcp +# tsup bundles @dbmole/core into dist (noExternal); the remaining runtime deps +# (undici, MCP SDK, zod) stay external and are served from node_modules. +RUN npm run build --workspace esmole-mcp && npm prune --omit=dev -# runtime stage — dist is self-contained (core bundled in) -FROM node:20-slim AS runtime +# runtime stage — dist (core bundled in) + pruned production node_modules +FROM node:22-alpine AS runtime WORKDIR /app ENV NODE_ENV=production +COPY --from=build /repo/node_modules ./node_modules COPY --from=build /repo/packages/esmole-mcp/dist ./dist +RUN mkdir -p /home/node/.config/esmole && chown -R node:node /home/node/.config USER node ENTRYPOINT ["node", "dist/index.js"] ``` -(If the dbmole Dockerfile pre-creates the store dir for non-root volume mounts — per commit `783e5bc` — replicate that `mkdir -p /home/node/.config/esmole && chown` step before `USER node`.) +Notes: +- All three workspace `package.json` files are copied before `npm ci` because the root lockfile references every workspace package; omitting one makes `npm ci` fail lockfile validation. +- `npm prune --omit=dev` removes dev deps (tsup, typescript, vitest, the bundled-in `@dbmole/core`, ...). It keeps every workspace package's **production** deps, so the hoisted `node_modules` also carries dbmole's pg/mysql2/ssh2 — a minor size cost, acceptable here; a per-package isolated install could trim it later. +- The `mkdir -p /home/node/.config/esmole && chown` mirrors dbmole's store-dir pre-creation for non-root volume mounts (commit `783e5bc`). - [ ] **Step 2: Create `.dockerignore`** (mirror dbmole's; exclude node_modules, test, dist from context where appropriate). @@ -2921,7 +2981,33 @@ git commit -m "build: esmole Dockerfile" - Modify: root `README.md` (if present) to document both servers and the npx/docker selection - Modify: `packages/dbmole-mcp/Dockerfile` build stage to the workspace-root pattern (it can no longer build from its own dir alone, since it now depends on `@dbmole/core`) -- [ ] **Step 1: Update `packages/dbmole-mcp/Dockerfile`** to the same workspace-root build pattern as esmole (build from `/repo`, `npm run build --workspace dbmole-mcp`). This is REQUIRED — Phase 2 made dbmole depend on `@dbmole/core`, so the old single-package Docker build breaks. Verify: `docker build -f packages/dbmole-mcp/Dockerfile -t dbmole-mcp:dev .` succeeds. +- [ ] **Step 1: Rewrite `packages/dbmole-mcp/Dockerfile`** to the workspace-root build pattern. This is REQUIRED — Phase 2 made dbmole depend on `@dbmole/core`, so the old single-package Docker build (which ran `npm ci` from the package dir) can no longer resolve core. Build from `/repo` and ship pruned production `node_modules` (matching the existing dbmole image's externalize-and-ship-node_modules approach; pg/mysql2/ssh2 stay external — ssh2 has optional native bits that should not be bundled): + +```dockerfile +# build stage — install the whole workspace so @dbmole/core resolves, build dbmole +FROM node:22-alpine AS build +WORKDIR /repo +COPY package.json package-lock.json tsconfig.base.json ./ +COPY packages/core/package.json packages/core/ +COPY packages/dbmole-mcp/package.json packages/dbmole-mcp/ +COPY packages/esmole-mcp/package.json packages/esmole-mcp/ +RUN npm ci +COPY packages/core packages/core +COPY packages/dbmole-mcp packages/dbmole-mcp +RUN npm run build --workspace dbmole-mcp && npm prune --omit=dev + +# runtime stage — dist (core bundled in) + pruned production node_modules +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY --from=build /repo/node_modules ./node_modules +COPY --from=build /repo/packages/dbmole-mcp/dist ./dist +RUN mkdir -p /home/node/.config/dbmole && chown -R node:node /home/node/.config +USER node +ENTRYPOINT ["node", "dist/index.js"] +``` + +Note: this requires `packages/esmole-mcp/package.json` to exist (for `npm ci` lockfile validation) — true once Phase 3 has run. The `.dockerignore` must NOT exclude it. Verify: `docker build -f packages/dbmole-mcp/Dockerfile -t dbmole-mcp:dev .` succeeds and `docker run --rm -i dbmole-mcp:dev` logs `dbmole: MCP server listening on stdio`. - [ ] **Step 2: Write `packages/esmole-mcp/README.md`** documenting tools, config (env names `ESMOLE_STORE`/`ESMOLE_CONNECTIONS`/`ESMOLE_CONFIG`), auth (user/pass or apiKey), scheme/verifyTls, ssh tunnel, readonly guard, and the MCP client config snippet: