@dooer/dooer-test-env 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -79,7 +79,53 @@ function consulNode(name, { address, port }) {
79
79
  // we grant every session and every lock acquire. This is the difference between a static Consul stub and
80
80
  // a working local env (without it, all scheduled/background handlers stay dormant).
81
81
  const sessions = new Set()
82
- const kv = new Map() // key -> { value, session }
82
+ const kv = new Map() // key -> { value, session, index }
83
+ // Consul BLOCKING QUERIES. doode's leader election long-polls `GET /v1/kv/<lock>?index=N&wait=180s` from
84
+ // every service. Consul holds that request open until the key's ModifyIndex exceeds N (or `wait` elapses);
85
+ // our first shim answered instantly with a constant ModifyIndex, so ~90 services re-polled in a tight
86
+ // loop — which produced a 177 GB container log and burned CPU. Track a real index and park waiters.
87
+ let kvIndex = 1
88
+ const kvWaiters = new Map() // key -> Set<{ resolve }>
89
+
90
+ function kvBump(key) {
91
+ kvIndex += 1
92
+ const entry = kv.get(key)
93
+ if (entry) entry.index = kvIndex
94
+ const waiters = kvWaiters.get(key)
95
+ if (waiters) {
96
+ for (const w of waiters) w.resolve()
97
+ waiters.clear()
98
+ }
99
+ }
100
+
101
+ // Wait until `key` changes or `waitMs` elapses. Resolves either way (the caller re-reads current state).
102
+ function kvWait(key, waitMs) {
103
+ return new Promise((resolve) => {
104
+ let done = false
105
+ const finish = () => {
106
+ if (done) return
107
+ done = true
108
+ clearTimeout(timer)
109
+ const set = kvWaiters.get(key)
110
+ if (set) set.delete(waiter)
111
+ resolve()
112
+ }
113
+ const waiter = { resolve: finish }
114
+ const timer = setTimeout(finish, waitMs)
115
+ if (!kvWaiters.has(key)) kvWaiters.set(key, new Set())
116
+ kvWaiters.get(key).add(waiter)
117
+ })
118
+ }
119
+
120
+ // Consul durations: "180s", "5m", "1000ms". Capped so a client cannot park a socket forever.
121
+ function parseWait(raw) {
122
+ if (!raw) return 0
123
+ const m = String(raw).match(/^(\d+)(ms|s|m)?$/)
124
+ if (!m) return 0
125
+ const n = Number(m[1])
126
+ const ms = m[2] === 'ms' ? n : m[2] === 'm' ? n * 60000 : n * 1000
127
+ return Math.min(ms, 600000)
128
+ }
83
129
 
84
130
  function readRaw(req) {
85
131
  return new Promise((resolve, reject) => {
@@ -121,36 +167,52 @@ async function handleConsulLock(req, res, url) {
121
167
  const release = searchParams.get('release')
122
168
  if (release) {
123
169
  const cur = kv.get(key)
124
- if (cur && cur.session === release) kv.set(key, { value: cur.value, session: null })
170
+ if (cur && cur.session === release) kv.set(key, { value: cur.value, session: null, index: kvIndex })
171
+ kvBump(key)
125
172
  sendJson(res, 200, true)
126
173
  } else {
127
174
  // single-node: always grant the lock (or plain write)
128
- kv.set(key, { value, session: acquire || null })
175
+ kv.set(key, { value, session: acquire || null, index: kvIndex })
176
+ kvBump(key)
129
177
  sendJson(res, 200, true)
130
178
  }
131
179
  return true
132
180
  }
133
181
  if (method === 'GET') {
182
+ const askedIndex = Number(searchParams.get('index') || 0)
183
+ const waitMs = parseWait(searchParams.get('wait'))
184
+ // Blocking query: hold the request while the caller's index is current (this is what stops the
185
+ // leader-election long-poll from becoming a tight loop).
186
+ if (askedIndex > 0 && waitMs > 0) {
187
+ const seen = kv.get(key)
188
+ if ((seen ? seen.index : kvIndex) <= askedIndex) await kvWait(key, waitMs)
189
+ }
134
190
  const cur = kv.get(key)
191
+ const idx = cur ? cur.index || kvIndex : kvIndex
135
192
  if (!cur) {
136
- sendJson(res, 404, [])
193
+ res.writeHead(404, { 'content-type': 'application/json', 'x-consul-index': String(idx) })
194
+ res.end('[]')
137
195
  return true
138
196
  }
139
- sendJson(res, 200, [
140
- {
141
- Key: key,
142
- Value: Buffer.from(cur.value || '').toString('base64'),
143
- Session: cur.session || undefined,
144
- CreateIndex: 1,
145
- ModifyIndex: 1,
146
- LockIndex: 1,
147
- Flags: 0,
148
- },
149
- ])
197
+ res.writeHead(200, { 'content-type': 'application/json', 'x-consul-index': String(idx) })
198
+ res.end(
199
+ JSON.stringify([
200
+ {
201
+ Key: key,
202
+ Value: Buffer.from(cur.value || '').toString('base64'),
203
+ Session: cur.session || undefined,
204
+ CreateIndex: 1,
205
+ ModifyIndex: idx,
206
+ LockIndex: 1,
207
+ Flags: 0,
208
+ },
209
+ ])
210
+ )
150
211
  return true
151
212
  }
152
213
  if (method === 'DELETE') {
153
214
  kv.delete(key)
215
+ kvBump(key)
154
216
  sendJson(res, 200, true)
155
217
  return true
156
218
  }
@@ -257,9 +319,14 @@ async function handle(req, res) {
257
319
 
258
320
  const server = http.createServer((req, res) => {
259
321
  const start = Date.now()
260
- res.on('finish', () =>
261
- log('request', { method: req.method, url: req.url, status: res.statusCode, ms: Date.now() - start })
262
- )
322
+ res.on('finish', () => {
323
+ // Skip the leader-election/discovery poll traffic: ~90 services poll continuously, and logging every
324
+ // hit is what grew this container's log to 177 GB. Errors and everything else still log.
325
+ const noisy = /^\/v1\/(kv|session|health)\//.test(req.url || '')
326
+ if (!noisy || res.statusCode >= 400) {
327
+ log('request', { method: req.method, url: req.url, status: res.statusCode, ms: Date.now() - start })
328
+ }
329
+ })
263
330
  handle(req, res).catch((error) => {
264
331
  log('handler-error', { url: req.url, error: error.message })
265
332
  if (!res.headersSent) sendJson(res, 500, { error: 'internal error' })
@@ -63,6 +63,23 @@ module.exports = [
63
63
  if (bankidEnv.SECRET_DOOER_BANK_ID_PFX)
64
64
  console.log(chalk.gray('bankid: injecting production certs from keychain'))
65
65
  const code = rt.dc(['up', '-d'], { profile: argv.profile, env: bankidEnv })
66
+ // `up` starts EVERY service in the profile, including ones currently served by a `service local`
67
+ // host process — the resurrected container then competes with (and for peers, wins over) the local
68
+ // code. Stop those again so local mode survives an `up`.
69
+ const localEntries = Object.entries(rt.readState().local || {}).filter(([, info]) => rt.isAlive(info.pid))
70
+ const localNames = localEntries.map(([name]) => name)
71
+ if (localNames.length) {
72
+ rt.dc(['stop', ...localNames])
73
+ // The router keeps overrides in MEMORY, so a docker (or router) restart loses them and discovery
74
+ // falls back to the container name — which is stopped for a local service, making every call to it
75
+ // fail. Re-apply them here so `up` heals that instead of leaving a silently broken stack.
76
+ for (const [name, info] of localEntries) {
77
+ await discovery
78
+ .setTarget(rt.ROUTER_URL, name, { address: 'host.docker.internal', port: info.port })
79
+ .catch(() => {})
80
+ }
81
+ console.log(chalk.gray(`kept local: ${localNames.join(', ')} (containers stopped, router repointed)`))
82
+ }
66
83
  if (code === 0) {
67
84
  console.log(chalk.green(`\nenv up (profile: ${argv.profile}).`))
68
85
  console.log('Next: `dooer-test-env db pull` to load the customer-free base DB, then `dooer-test-env status`.')
@@ -6,7 +6,8 @@ const chalk = require('chalk')
6
6
  const rt = require('../runtime')
7
7
  const discovery = require('../discovery/client')
8
8
  const bankid = require('../bankid')
9
- const { imageFor } = require('../compose/manifests')
9
+ const { imageFor, listServices } = require('../compose/manifests')
10
+ const { serviceHostPortMap } = require('../compose/generate')
10
11
 
11
12
  // Per-service control in a running env. `local`/`unlocal`/`deploy` update the discovery router so peers
12
13
  // pick up the change with no restarts (see ENVIRONMENT-PLAN.md §4/§6). Local host-process ports are
@@ -52,16 +53,45 @@ function localEnv(name, port) {
52
53
  env.DOOER_SERVICE_NAME = env.DOOER_SERVICE_NAME || name
53
54
  env.DOOER_ENVIRONMENT_NAME = env.DOOER_ENVIRONMENT_NAME || 'local'
54
55
  env.NODE_ENV = env.NODE_ENV || 'development'
56
+ // PEER calls: a host process cannot resolve container names (`service-core-objects:3000`) — nor container
57
+ // IPs, which Docker Desktop doesn't route on macOS. Every service is published on a deterministic host
58
+ // port (see generate.js serviceHostPortMap), so point @dooer/service at those, and at THIS service's own
59
+ // local port for self-calls. Without this a locally-run service can only talk to itself.
60
+ const hostPorts = serviceHostPortMap(listServices(rt.SERVICES_DIR))
61
+ const localPeers = rt.readState().local || {}
62
+ for (const [svcName, hostPort] of Object.entries(hostPorts)) {
63
+ // A peer that is ALSO running locally must be reached on ITS host process port, not the port its
64
+ // (stopped) container would publish — otherwise a local service-graphql calls the container copy of
65
+ // service-accounts and never sees your local changes.
66
+ const peer = localPeers[svcName]
67
+ const target = peer && rt.isAlive(peer.pid) ? peer.port : hostPort
68
+ env[`DOOER_HOST_${svcName.toUpperCase().replace(/-/g, '_')}`] = `localhost:${target}`
69
+ }
55
70
  // SELF-calls: the router advertises this service as host.docker.internal:<port> so CONTAINERS can reach
56
- // the host process but that name does not resolve ON the host, so the process fails calling itself
57
- // (service-accounts → /api/v1/token-invalidation/all → ENOTFOUND, which broke every HQ navigation query).
58
- // @dooer/service checks DOOER_HOST_<NAME> before Consul, so point the service at its own localhost port.
71
+ // the host process, but that name does not resolve ON the host (service-accounts
72
+ // /api/v1/token-invalidation/all → ENOTFOUND, which broke every HQ navigation query).
59
73
  env[`DOOER_HOST_${name.toUpperCase().replace(/-/g, '_')}`] = `localhost:${port}`
74
+ // @dooer/logging: `logMode = DOOER_LOG || (process.stdout.isTTY ? 'pretty' : 'none')`. We redirect the
75
+ // host process to a log FILE, so stdout is not a TTY and it would log NOTHING — the images bake
76
+ // DOOER_LOG=json, which the manifests don't carry, so set it here too or `service local` runs blind.
77
+ env.DOOER_LOG = env.DOOER_LOG || 'json'
60
78
  return env
61
79
  }
62
80
 
81
+ // Where a service's checked-out code lives. Nothing here is machine-specific: an explicit path wins, then
82
+ // the ~/dooer/<service> convention, then the CURRENT directory if you happen to be standing in that
83
+ // service's checkout (so `cd ~/src/service-ledger && … service local service-ledger` works too).
63
84
  function repoPath(name, given) {
64
- return given || path.join(os.homedir(), 'dooer', name)
85
+ if (given) return path.resolve(given)
86
+ const conventional = path.join(os.homedir(), 'dooer', name)
87
+ if (fs.existsSync(path.join(conventional, 'package.json'))) return conventional
88
+ try {
89
+ const pkgName = require(path.join(process.cwd(), 'package.json')).name || ''
90
+ if (pkgName.replace(/^@[^/]+\//, '') === name) return process.cwd()
91
+ } catch (_) {
92
+ /* no readable package.json in cwd */
93
+ }
94
+ return conventional
65
95
  }
66
96
 
67
97
  module.exports = {
@@ -126,6 +126,11 @@ const SERVICE_ENV_OVERRIDES = {
126
126
  },
127
127
  }
128
128
 
129
+ // Cap EVERY container's log. The json-file driver is unbounded by default: the discovery-router alone grew
130
+ // a 177 GB log file and filled the host disk (Jimmy 2026-09-03). 10 MB x 3 files per container bounds the
131
+ // whole stack to a few GB worst case, and `logs`/`docker logs` still have plenty of history.
132
+ const LOGGING = { driver: 'json-file', options: { 'max-size': '10m', 'max-file': '3' } }
133
+
129
134
  // Marker: this secret-backed env var must NOT be emitted at all (see below).
130
135
  const OMIT = Symbol('omit-env')
131
136
 
@@ -249,6 +254,7 @@ function infraServices() {
249
254
  timeout: '5s',
250
255
  retries: 20,
251
256
  },
257
+ logging: LOGGING,
252
258
  restart: 'unless-stopped',
253
259
  },
254
260
  redis: {
@@ -256,6 +262,7 @@ function infraServices() {
256
262
  // password-protected like staging — see DEV.redisPassword for why it matters (GraphQL subscriptions).
257
263
  command: ['redis-server', '--requirepass', DEV.redisPassword],
258
264
  ports: ['6379:6379'],
265
+ logging: LOGGING,
259
266
  restart: 'unless-stopped',
260
267
  },
261
268
  // Single-node Redpanda: Kafka-API compatible, no ZooKeeper needed — much
@@ -284,6 +291,7 @@ function infraServices() {
284
291
  ],
285
292
  ports: ['9092:9092'],
286
293
  volumes: ['kafka_data:/var/lib/redpanda/data'],
294
+ logging: LOGGING,
287
295
  restart: 'unless-stopped',
288
296
  },
289
297
  minio: {
@@ -296,6 +304,7 @@ function infraServices() {
296
304
  },
297
305
  ports: ['9000:9000', '9001:9001'],
298
306
  volumes: ['minio_data:/data'],
307
+ logging: LOGGING,
299
308
  restart: 'unless-stopped',
300
309
  },
301
310
  // One-shot: wait for MinIO, then create the buckets the services expect (idempotent). Runs on every `up`.
@@ -317,6 +326,7 @@ function infraServices() {
317
326
  'discovery-router': {
318
327
  build: path.resolve(__dirname, '..', '..', 'discovery-router'),
319
328
  ports: ['8500:8500'],
329
+ logging: LOGGING,
320
330
  restart: 'unless-stopped',
321
331
  },
322
332
  }
@@ -328,6 +338,25 @@ function infraServices() {
328
338
  // an internal backend it fronts — NOT the facade — so it gets its own port for direct dev access.
329
339
  const FACADE_HOST_PORT = 4000 // public-facade → DOOER_PUBLIC_FACADE_HOST / DOOER_GRAPHQL_HOST
330
340
  const GRAPHQL_HOST_PORT = 4001 // service-graphql (direct access; frontends go through the facade)
341
+ // EVERY backend service is also published on a deterministic host port. Containers reach each other by
342
+ // container name, but a `service local` HOST process cannot resolve those names (nor container IPs, which
343
+ // Docker Desktop doesn't route on macOS) — so without this a locally-run service can call itself but not
344
+ // its peers. `service local` turns this map into DOOER_HOST_<NAME> overrides. (Jimmy 2026-09-03.)
345
+ const SERVICE_HOST_PORT_BASE = 21000
346
+
347
+ // {service name → published host port}, stable because listServices() is sorted by filename.
348
+ function serviceHostPortMap(services) {
349
+ const map = {}
350
+ ;(services || []).forEach((svc, i) => {
351
+ map[svc.name] =
352
+ svc.name === 'public-facade'
353
+ ? FACADE_HOST_PORT
354
+ : svc.name === 'service-graphql'
355
+ ? GRAPHQL_HOST_PORT
356
+ : SERVICE_HOST_PORT_BASE + i
357
+ })
358
+ return map
359
+ }
331
360
  const HQ_HOST_PORT = 8080 // frontend-hq
332
361
 
333
362
  // host port for a frontend: HQ is pinned to 8080; the rest get 8081+ in sorted order.
@@ -382,6 +411,7 @@ const SERVICE_ALIASES = {
382
411
  function buildCompose(services, frontends, { profile, outputValidation = false } = {}) {
383
412
  const wantedSvc = profile ? services.filter((s) => profilesFor(s.name).includes(profile)) : services
384
413
  const portMap = frontendPortMap(frontends)
414
+ const svcPortMap = serviceHostPortMap(services)
385
415
 
386
416
  const composeServices = infraServices()
387
417
  wantedSvc.forEach((svc) => {
@@ -398,13 +428,13 @@ function buildCompose(services, frontends, { profile, outputValidation = false }
398
428
  portMap
399
429
  ),
400
430
  // publish the browser-facing facade (4000) and service-graphql (4001) on the host
401
- ...(svc.name === 'public-facade' ? { ports: [`${FACADE_HOST_PORT}:3000`] } : {}),
402
- ...(svc.name === 'service-graphql' ? { ports: [`${GRAPHQL_HOST_PORT}:3000`] } : {}),
431
+ ports: [`${svcPortMap[svc.name]}:3000`],
403
432
  // legacy-name DNS aliases (e.g. service-periods → service-closing), so callers resolving the old name
404
433
  // reach this container on the compose network.
405
434
  ...(SERVICE_ALIASES[svc.name] ? { networks: { default: { aliases: SERVICE_ALIASES[svc.name] } } } : {}),
406
435
  depends_on: ['postgres', 'redis', 'discovery-router'],
407
436
  profiles: profilesFor(svc.name),
437
+ logging: LOGGING,
408
438
  restart: 'unless-stopped',
409
439
  }
410
440
  })
@@ -433,6 +463,7 @@ function buildCompose(services, frontends, { profile, outputValidation = false }
433
463
  ports: [`${hostPort}:3000`],
434
464
  depends_on: ['discovery-router'],
435
465
  profiles: ['full', 'frontend', ...(fe.name === 'frontend-hq' ? ['hq'] : [])],
466
+ logging: LOGGING,
436
467
  restart: 'unless-stopped',
437
468
  }
438
469
  })
@@ -479,6 +510,7 @@ function generateCompose({ profile, servicesDir, out, outputValidation = false }
479
510
 
480
511
  module.exports = {
481
512
  BOOKING_PROFILE_SERVICES,
513
+ serviceHostPortMap,
482
514
  VALIDATION_ENV,
483
515
  GLOBAL_OVERRIDES,
484
516
  secretDefault,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dooer/dooer-test-env",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Run the whole Dooer backend locally (staging DB minus customers), copy/purge customers between environments, and shred — one CLI.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": "Dooer/cli-dooer-test-env",
package/readme.md CHANGED
@@ -51,19 +51,147 @@ service <start|stop|restart|deploy|local|unlocal|version> <name> per-service c
51
51
 
52
52
  Everything that writes is **dry-run by default**; add `--execute`. Run any command with `--help`.
53
53
 
54
- ### Examples
54
+ ## Use cases
55
+
56
+ Everything below runs from **any directory** (`npx` resolves the published CLI). Nothing is tied to a
57
+ particular machine: checkouts default to `~/dooer/<service>`, state lives in `~/.dooer-test-env`.
58
+
59
+ ### Where things are served
60
+
61
+ | | |
62
+ | --- | --- |
63
+ | `http://localhost:4000` | **public-facade** — the browser-facing entrypoint (GraphQL + REST passthrough) |
64
+ | `http://localhost:4001` | service-graphql (direct; frontends go via the facade) |
65
+ | `http://localhost:8080` | frontend-hq |
66
+ | `http://localhost:8082` | frontend-back-office-neue (GraphiQL at `/tools/graphiql`) |
67
+ | `http://localhost:8089` | frontend-sumify-neue |
68
+ | `localhost:55432` | Postgres (`dooer`/`dooer`) · `localhost:9001` MinIO console |
69
+ | `localhost:21000+` | every backend service, one host port each (so host processes can reach them) |
70
+
71
+ ### Spin up an empty, ready-to-use customer
72
+
73
+ ```bash
74
+ npx @dooer/dooer-test-env@latest customer new "Nordvik Handel AB" \
75
+ --owner <users_pk-of-a-CUSTOMER-user> --execute
76
+ ```
77
+
78
+ Provisions through the product APIs (so `company-created` / `fiscal-year-created` fire and service-closing
79
+ generates the accounting periods): company + Owner + address, the Ghost-Inspector subscription set, partner
80
+ `dooer`, Terms of Service pre-accepted, the current-year fiscal year, and the temporal keys the platform
81
+ reads (`fiscalYear`, `hasVatRegistration`, `hasCompanyTax`, `hasEmployeeRegistration`, `bookkeepingMethod`,
82
+ `vatPeriod`/`vatDue`). `--no-fiscal-year` leaves all of that blank.
83
+
84
+ The owner must be a **`customer`** user — a `hi`/admin owner cannot accept Terms of Service and the command
85
+ warns you. Find one with:
86
+
87
+ ```sql
88
+ SELECT users_pk, email FROM service_accounts.users
89
+ WHERE fk_user_roles_at_dooer_pk = 'customer' AND inactivated_at IS NULL LIMIT 5;
90
+ ```
91
+
92
+ ### Copy a real customer into the local env
93
+
94
+ `--target-namespace local` addresses this stack (Postgres on 55432 + MinIO); source can be any k8s
95
+ namespace. Reading production needs no confirmation — only *writing* to it does. Emails are always
96
+ anonymized. Dry-run by default; add `--execute`.
97
+
98
+ ```bash
99
+ # copy Ghost Inspector out of LIVE into the local env, as a new org owned by a local user
100
+ npx @dooer/dooer-test-env@latest customer copy \
101
+ --source e45a3bc4-1b61-4f55-9bd6-de2705420cc2 \
102
+ --source-namespace dooer-production \
103
+ --target-namespace local \
104
+ --name "Ghost inspector (live)" \
105
+ --owner-user <local-customer-users_pk> \
106
+ --execute
107
+
108
+ # optional: shred the rest of the PII afterwards (localhost only)
109
+ npx @dooer/dooer-test-env@latest shred --execute
110
+
111
+ # undo: delete that org again (rows + its S3 objects), dry-run first
112
+ npx @dooer/dooer-test-env@latest customer purge --org <orgId> --namespace local --execute
113
+ ```
114
+
115
+ It scans all ~336 tables in the source before writing, so expect a few minutes on a production source.
116
+
117
+ ### Run your own code — one service, or several at once
118
+
119
+ `service local` stops that service's container, starts `yarn start` from your checkout, and repoints the
120
+ discovery router so **containers call your process**. Your process reaches the containerized services
121
+ through their published host ports, so traffic flows **both ways**.
122
+
123
+ The checkout is resolved as: explicit path → `~/dooer/<service>` → the current directory if you are standing
124
+ in that service's checkout. So you do **not** need to `cd` anywhere:
55
125
 
56
126
  ```bash
57
- # an empty test account owned by a user, subscriptions like Ghost Inspector, under partner dooer
58
- npx @dooer/dooer-test-env customer new "Test Bolag AB" --owner 203b9010-abb9-4f7c-9a5e-8bbf6f75ca7b --execute
127
+ npx @dooer/dooer-test-env@latest service local service-ledger # uses ~/dooer/service-ledger
128
+ npx @dooer/dooer-test-env@latest service local service-ledger ~/src/ledger # or an explicit path
129
+ npx @dooer/dooer-test-env@latest service version service-ledger # LOCAL code or which image?
130
+ npx @dooer/dooer-test-env@latest service unlocal service-ledger # back to the container image
131
+ ```
59
132
 
60
- # find where a transactionId showed up across the whole stack
61
- npx @dooer/dooer-test-env logs 1374f61b-5b42-48e6-9b28-ac086b55ea53
133
+ Logs go to `~/.dooer-test-env/<service>.local.log`.
62
134
 
63
- # run one service from your checked-out code; peers follow via the discovery router
64
- npx @dooer/dooer-test-env service local service-ledger
135
+ **Several services at once** e.g. test a gateway change together with a backend change, no image builds:
136
+
137
+ ```bash
138
+ npx @dooer/dooer-test-env@latest service local service-accounts # start the backend first…
139
+ npx @dooer/dooer-test-env@latest service local service-graphql # …then the gateway
65
140
  ```
66
141
 
142
+ Order matters: a local process learns its peers' addresses at startup, so start the service **being called**
143
+ before the one calling it (or restart the caller afterwards).
144
+
145
+ Now a single query through the facade spans everything — browser → public-facade (Docker) → service-graphql
146
+ (your code) → service-accounts (your code) **and** service-core-objects (Docker):
147
+
148
+ ```graphql
149
+ query VerifyLocalCode {
150
+ organization(id: "<orgId>") {
151
+ id
152
+ documents(first: 1) { totalCount } # service-core-objects, in Docker
153
+ }
154
+ }
155
+ ```
156
+
157
+ Try it in back-office's GraphiQL at `http://localhost:8082/tools/graphiql`, or:
158
+
159
+ ```bash
160
+ curl -s -X POST http://localhost:4000/graphql -H 'Content-Type: application/json' \
161
+ -H "Authorization: Bearer <token>" \
162
+ --data '{"query":"query { organization(id:\"<orgId>\") { id documents(first:1){ totalCount } } }"}'
163
+ ```
164
+
165
+ ### Test a published build before it ships
166
+
167
+ ```bash
168
+ npx @dooer/dooer-test-env@latest service deploy service-graphql 66.34.2 # pin an image tag
169
+ ```
170
+
171
+ Pins it in `docker-compose.override.yml` and keeps `DOOER_SERVICE_VERSION` in sync so logs report the
172
+ version actually running.
173
+
174
+ ### Find out what broke
175
+
176
+ ```bash
177
+ npx @dooer/dooer-test-env@latest logs <transactionId> # across ALL services at once
178
+ npx @dooer/dooer-test-env@latest logs "error" --service ledger --since 30m -C 2
179
+ ```
180
+
181
+ ### Output-schema validation (off by default)
182
+
183
+ Off means the local env behaves like staging/live, where these checks are silent. Turn it **on** to hunt
184
+ stale service definitions — a consumer whose bundled definition predates a producer's added fields returns
185
+ 500 on perfectly valid data:
186
+
187
+ ```bash
188
+ npx @dooer/dooer-test-env@latest validation on # then reproduce; drift shows up as 500s
189
+ npx @dooer/dooer-test-env@latest validation status
190
+ npx @dooer/dooer-test-env@latest validation off
191
+ ```
192
+
193
+ Inbound *input* validation always runs — this only affects response validation.
194
+
67
195
  ## How it works
68
196
 
69
197
  - **Compose generator** (`lib/compose/`): translates the s-e032 k8s manifests → a docker-compose spec —
@@ -82,6 +210,9 @@ npx @dooer/dooer-test-env service local service-ledger
82
210
  PII-audits, and uploads the artifact to an OBC bucket; devs only `db pull`. The CronJob + OBC manifests
83
211
  live with the other staging yaml in `new-infrastructure/kubernetes/environments/s-e032-onprem/`.
84
212
 
213
+ - **Log caps**: every container is capped at `10m x 3` json-file logs. Unbounded logging once grew the
214
+ discovery-router's log to 179 GB and filled the host disk.
215
+
85
216
  ## Safety
86
217
 
87
218
  - `shred` from a dev machine is **localhost-only**.