Repository navigation
fix(service-datasource)!: a datasource update whose new configuration fails to connect stores none of it - #29981
Conversation
… previous pool stores none of the new configuration A PATCH whose new configuration failed to connect answered that the previous pool was kept and the new configuration was NOT in force, but had already stored the merged record, repointed external.credentialsRef at the new secret and removed the working one. The next restart connected the configuration that failed. The update now rebuilds the pool before it stores the record. When the reregisterPool seam reports previousPoolKept, nothing is stored and the secret sealed for the write is taken back out; the working secret is retired only after the merged record is stored. The plugin's seam reports the outcome from the state the rebuild left. The blank-secret guard also refuses a whitespace-only secret, with the same message. Claude-Session: https://claude.ai/code/session_01WGufzfDo764sexAMz4Feui Co-authored-by: Claude <noreply@anthropic.com>
…and blank secrets at the door Claude-Session: https://claude.ai/code/session_01WGufzfDo764sexAMz4Feui Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfDo764sexAMz4Feui Co-authored-by: Claude <noreply@anthropic.com>
… ledger Claude-Session: https://claude.ai/code/session_01WGufzfDo764sexAMz4Feui Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6440384440b929c889a0aef080fb0f4537d3c4bc && git checkout 6440384440b929c889a0aef080fb0f4537d3c4bc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6f7df530cd1432d0d34e0ee5cbee6b4d669e3f77 2e46f1637f33ef14359ec783959fea38419b2016 && git checkout -B drift-repro 6f7df530cd1432d0d34e0ee5cbee6b4d669e3f77 && git merge --no-ff 2e46f1637f33ef14359ec783959fea38419b2016
node scripts/docs-audit/affected-docs.mjs --json 6f7df530cd1432d0d34e0ee5cbee6b4d669e3f77
|
|
domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5 Contract reviewServed-tier: Face: narrowing ( ① Derived judgments
② Semver level
③ Boundary flags
④ Narrowing face
Implemented-by: VERDICT: PASS |
|
domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5 席位核对Verdict: ACCEPT at head
|
Requested by Yibo Wang · project thread
Fixes #29879
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under
skills/**orcontent/docs/**teaches what a datasource update stores when its rebuild fails, or what a blanksecretdoes at the admin doors; the fail-closed paragraph inexternal-datasources.mdx(§4 Credentials) stays true.BREAKING accept-set narrowing at the datasource admin doors (
@objectstack/service-datasource): a whitespace-onlysecretis refused.维护者速读
PATCH /api/v1/datasources/NAME在新配置连不上、旧连接池被保留时,现在什么都不落盘:数据源记录、external.credentialsRef、同一次 PATCH 里的其它键都保持原样,为这次 PATCH 新建的 sys_secret 行会被删掉。接口回答不变(200、status: "error"、「新配置未生效」),现在这句话是真的。另外,全空白的secret(" "、制表符加换行)和空串一样在入口回 400「Nothing was written」,用的是同一个守卫和同一句话。reregisterPool的宿主不回报结果时行为与以前相同。回滚即 revert。packages/services/service-datasource/src/datasource-admin-service.ts的updateDatasource尾部(previousPoolKept分支、putDatasourceRecord之后才删旧凭据)与assertSecretNotEmpty;datasource-admin-plugin.ts的reregisterPool如何判断旧池被保留。pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-update-kept-pool-stores-nothing.test.ts src/__tests__/datasource-empty-secret-refusal.test.ts,预期Tests 17 passed (17)(main 上 7 条红:3 条「重启后仍连得上」、4 条空白 secret;对照两边都绿)。What changes
updateDatasourceused to seal the new secret, remove the working one, store the merged record, and only then rebuild the pool. It now seals the new secret (the rebuild dereferences the merged record'scredentialsRef), rebuilds, and only then decides:reregisterPoolseam reportspreviousPoolKept: true→ nothing is stored, thesys_secretrow sealed for this patch is removed, no cluster signal is published (there is nothing for a peer to converge on), and the answer istoSummary(existing):200,status: "error", the reason ending "the new configuration is NOT in force";putDatasourceRecordthat throws now takes the newly sealed secret back out instead of leaving the record pointing at a removed one.putDatasourceRecord, after the pool settles). The restore route would have written the record twice on failure, and its second write can fail too.active: falseand the no-connectivity-change register keep their order (store, then pool), unchanged.DatasourceAdminServiceConfig.reregisterPoolmay now answer{ previousPoolKept: boolean }(newReregisterPoolOutcomeindatasource-admin-service.ts, not exported from the index). A seam that answers nothing reads as before: the record is stored whatever the rebuild did. This is the widening half ofClause-②: yes (narrowing).DatasourceAdminServicePlugin'sreregisterPoolreads: the new configuration is notavailableAND a driver still answers the name. That driver is the previous poolreconnectput back, the only driver a failed rebuild leaves registered (a rebuild with no live pool to keep is a plainconnect, which registers nothing on failure). It reads the state, not the verdict the rebuild started from: after a first failed PATCH the kept pool serves under a FAILED verdict, and a second failing PATCH must store nothing either (pinned). On a kept pool the plugin'slivePoolRecordsentry goes back toprevious, so a later cluster convergence compares the shared row against the configuration actually serving. Areconnectthat throws after restoring the pool is reported as kept (logged atwarn, as the admin service logged it before); one that throws with nothing kept rethrows.sys_secretbinder mints a fresh handle per write, so removing the new row restores the binding exactly. For a host binder that reseals under the SAME reference, the working credential is already overwritten: the row is never removed (it is the binding the stored record names) and the loss is logged once aterrorwith the remedy.assertSecretNotEmptynow refuses asecretwhose value is whitespace only, on create and on update, with the same message (The \secret` is empty. … Nothing was written.) and the same400 DATASOURCE_ADMIN_ERROR`. No second guard. A value with whitespace around other characters is still sealed as sent.datasource-connection-service.tsis unchanged:reconnectalready kept the previous pool; the defect was the store write before it..changeset/29879-datasource-update-kept-pool-stores-nothing.md:patchon@objectstack/service-datasource, both declaration lines, BREAKING banner, the remedy, ADR-0087 markernot-required (no-migration-prescription)(as Datasources: PATCH with an emptysecretreplaces the stored credential with an empty one, and the status reason then blames a missing secret row #28928).Why
Clause-②: yes (narrowing)and not the claim'sno. The narrowing is the blank-secret refusal, declared as #28928 declared the empty string (no (narrowing)there). This diff also widens an exported type (DatasourceAdminServiceConfig.reregisterPool's answer), so the value isyes;yes (narrowing)is the spelling for a diff that widens one surface and narrows another (scripts/pm/clause2-line.mjs). Metadata-protocol staysnone:secretis the doors' credential channel, not an authorable key, so the level ispatch.Tests
src/__tests__/datasource-update-kept-pool-stores-nothing.test.ts: the REALDatasourceAdminServicePluginandExternalDatasourceServicePluginbehind the REAL admin routes, with the REALsys_secretbinder, over one in-memory store (sys_metadata+sys_secret) that outlives a boot. A restart is a second boot over the same store. The pool connects only to databaseqawith the real password.PATCH {secret: wrong, label}→200,status: "error", "NOT in force" → the stored record and thesys_secretids equal what they were before the PATCH →/testok on the kept pool → restart →/testok, and the restart built its pool with the real password;configchange: the same, the stored config is the one serving;sys_secretrow is gone, and the restart connects.src/__tests__/datasource-empty-secret-refusal.test.ts(the Datasources: PATCH with an emptysecretreplaces the stored credential with an empty one, and the status reason then blames a missing secret row #28928 harness, real routes over the real service, every seam call recorded):PATCHwith" "and with{ value: "\t\n" },POSTwith" "and"\t\n"→400 DATASOURCE_ADMIN_ERROR, message containssecretand "Nothing was written.", no seam call at all. New control: a secret with whitespace around other characters is sealed as sent.Red on main, green on head, two ablations
18112bfa, the tests at head):Tests 7 failed | 10 passed (17). The three kept-pool pins received the card's state, for exampleexternal.credentialsRefsys_secret:sec_2forsec_1,label: "Renamed in the same patch"stored,secretIds[sec_2]for[sec_1]; the four blank-secret pins received{ status: 200 | 201, success: true }. Every control green. Restored:git diff HEADempty.Tests 17 passed (17).scripts/ablation-replace.mjs,if (previousPoolKept) {→if (previousPoolKept && 'ABLATED_29879' === '') {):Tests 3 failed | 1 passed (4), exactly the three kept-pool pins; the good-secret control green. Restore: blobcea697a4equals HEAD's.value.trim() !== ''→value !== '', the Datasources: PATCH with an emptysecretreplaces the stored credential with an empty one, and the status reason then blames a missing secret row #28928 guard):Tests 4 failed | 9 passed (13), exactly the four blank-secret pins. Restore: blobcea697a4equals HEAD's.Acceptance notes
convergePool(a peer's rebuild after a cluster signal) ignores the answer, as it should: it writes no record. It benefits from thelivePoolRecordsfix only.already-registeredshort-circuits the reconfigure path, so the OLD pool stays live and the admin UI reports success #13804 fallback. A host without thereregisterPoolseam keeps the incumbent pool through the idempotent register and stores the new record, as before; nothing in that path says "NOT in force".secretnow get the 400; the remedy is in the message and the changeset.Verification
All readings at head
2e46f163(four commits onorigin/main18112bfa; main had not moved when the gates and the dogfood ran).Package suite, typecheck, lint
pnpm --filter @objectstack/service-datasource test:Test Files 71 passed (71),Tests 1169 passed (1169), exit 0 (taken before the ledger commit, which touches onlyscripts/engine-double-contract.pinned.json).pnpm --filter @objectstack/service-datasource typecheck(tsc oversrc, tests included): exit 0.pnpm lintcommand scoped topackages/services/service-datasource: exit 0, no findings.Derived gates (`dispatch-gates.mjs --commands`, 69 families)
check-adr-0087-registration --base origin/mainandcheck-changeset-no-major --base origin/mainincluded.check:engine-double-contractfirst answered 1 for the new restart rig's doubles (RETAINED [delete] [findOne] [update]), then 0 after--writeand the ledger commit; the 8 families that commit added all exit 0.check-plugin-teardown-shape --self-testexit 0 after fetching its pinned fixture commit.check:dual-build-cjs-loads: exit 3, PREREQUISITE NOT MET (it needs dists of packages outside this closure). NOT MEASURED locally; CI's.dispatch-gates --ran:69 derived, 68 run, 1 NOT-MEASURED, 0 UNRUN.pnpm lintand the CI-only families are CI's; see the PR's check-runs.Dogfood battery (owed:
!title and the(narrowing)arm), once at2e46f163afterpnpm turbo run build --filter='@objectstack/dogfood...'(69 tasks, exit 0):pnpm --filter @objectstack/dogfood test,Test Files 415 passed | 3 skipped (418),Tests 3432 passed | 28 skipped (3460), lock VERDICT command-exit 0.Generated by Claude Code