docs: fix plan — core devDep, docker prod deps, byte budget, barrel export
This commit is contained in:
1 parent
f4b22ab1f7
commit
0771fa21c7
1 file changed
+99
-13
@@ -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 = <T>(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:
|
||||
|
||||
|
||||
Reference in new issue
Block a user