refactor(ts): put getState/loadState on the Resource contract, mirroring Python's base (A3) - #800
Merged
Conversation
…ing Python's base
`snapshot/state.ts` cast every mount's resource into `{ getState }` and
called it unconditionally, so a resource without one was a runtime crash
at save time rather than a compile error. postgres and mongodb — both
registry-mountable, in node and browser — had none, and snapshotting such
a mount died with `resource.getState is not a function`.
Structure follows Python's, which already had all of this:
- `BaseResource` gains `getState()` returning `{type: this.kind}` and a
no-op `loadState()`, the twins of `BaseResource.get_state` /
`load_state`. `kind` moves onto the base as abstract so the default can
spell itself, mirroring Python's `name`.
- `Resource` declares both non-optionally, so the two casts in
`snapshot/state.ts` go away.
- The eight config-backed resources that had no state — chroma, dify,
qdrant (core), postgres, mongodb (node + browser), lancedb — now carry
their config, redacted. Inheriting the bare default would be worse than
crashing: with no `<REDACTED>` marker,`resourceStateRequiresOverride`
returns false and `buildMountArgs` substitutes an empty RAMResource,
turning a live database mount into an empty directory on load.
- Redacted fields were taken from Python's own `secret_field_names`, not
guessed: qdrant/lancedb `apiKey`, postgres `dsn`, mongodb `uri`, dify
`apiKey` (Python masks it in `get_state` rather than on the model),
chroma none.
- `HfResource` re-narrows `getState` to abstract so the bare default
cannot reach a Hub resource. Python has no shared Hub base; its four
resources each spell `get_state`.
- Test fakes that stood up a `Resource` by hand now extend `BaseResource`
like real resources do, so they inherit the default instead of carrying
a stub. The three that cannot (two object literals, and the bare class
that exists to have no `storageId`) spell the pair.
- mem0/onedrive/sharepoint swap `Record<string, unknown>` for named state
types, joining the ~35 resources that already had them.
Tests: a registry-derived sweep over the six database backends asserts
getState/loadState exist and the credential is masked, plus an end-to-end
`toStateDict` over a postgres mount. Python gains the twin sweep in
`tests/resource/test_state_round_trip.py` — it passes as-is, which is the
point: this closes a TypeScript-only gap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The interface I added to `resource/base.ts` was a byte-identical copy of a private one that already sat in `workspace/snapshot/types.ts` feeding `ResourceState`. Snapshot types now import the exported one, so the Resource contract and the snapshot format name one shape. The doc comment now says what the two keys are for rather than paraphrasing: `type` is the registry name Python's `_resource_class_for` looks up before falling back to the mount's `resource_class` import path, and `config` is what `resourceStateRequiresOverride` scans. It also records why the literal twin of Python's `dict[str, Any]` does not work here — a TS interface has no implicit index signature, so `Record<string, unknown>` would reject every named `XResourceState`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d69fae4dd1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… load as RAM Both from the codex review of strukto-ai#800, both verified against Python first. P2 — a null secret was being masked. `redactValueWithSchema` returns null *before* it asks whether a field is secret, mirroring Python's `if value is None: continue` in `_walk_config_dump`. The hand-written qdrant and lancedb redactors masked unconditionally, and both keys are `string | null`, so a keyless local Qdrant or an on-disk LanceDB got a `<REDACTED>` marker for a credential it never had — making `Workspace.load` demand a fresh config for a self-contained snapshot. `RedactedConfig<T, K>` now keeps null in the redacted twin when the source allows it, so the type says this too. P1 — chroma has no credential, so its state carried no marker, so `buildMountArgs` handed back a `RAMResource` and a /chroma mount loaded as an empty local directory. The cause is broader than chroma: TypeScript rebuilds *nothing* from state, because core cannot import `buildResource` (it lives in node/browser), while Python reconstructs the class from `resource_state["type"]` via its registry (`_resource_class_for`). Until core gets a resource factory, `resourceStateRequiresOverride` also honors an explicit `needs_override`, and the six config-backed resources set it — so the mount refuses to load rather than coming back empty. Python already writes that field on four resources and reads it nowhere; TypeScript now reads it, which also protects Python-written snapshots loaded in TS. Tests: keyless lancedb/qdrant cases on both sides (Python passes as-is, which is the proof the null rule was already its behavior), and the registry sweep now asserts every config-backed backend demands a resource at load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cleanup-plan item A3.
The bug
workspace/snapshot/state.tscast every mount's resource into{ getState }and called it unconditionally:Nothing on the
Resourceinterface saidgetStateexisted, so a resource without one was a runtime crash at save time, not a compile error. postgres and mongodb — both registry-mountable, innodeandbrowser— had none. Snapshotting such a mount died withresource.getState is not a function.The fix, following Python's structure
Python already had all of this; this closes a TypeScript-only gap.
getState() → {type: this.kind},loadState()no-opBaseResource.get_state/load_stategetState/loadStatekindon baseabstract readonly kindname: str = "base"Concretely:
BaseResourcegains both methods, the twins of Python's.kindmoves onto the base as abstract so the default can spell itself.Resourcedeclares both non-optionally, so both casts insnapshot/state.tsgo. What remains there narrows the returned state to the snapshot format's union — the resource itself is no longer cast into a shape that promises a method it may not have.<REDACTED>marker,resourceStateRequiresOverridereturns false andbuildMountArgssubstitutes an emptyRAMResource— a live database mount silently becomes an empty directory on load.HfResourcere-narrowsgetStateback to abstract, so the bare default cannot reach a Hub resource. Python has no shared Hub base; its four resources each spellget_state, so this only pins the habit down.Which fields get masked
Not guessed — read off Python's
secret_field_namesfor each config:apiKeydsnuriapiKeyget_state, not as aSecretStron the modelThese six configs are hand-written interfaces with no zod schema, so they cannot go through the shared
redactConfigWithSchemathe ~50 schema-backed configs use; each spells its own mask, typed byRedactedConfig<T, K>.Test fakes
Fakes that stood up a
Resourceby hand nowextends BaseResourcelike real resources do, and inherit the default rather than carrying a stub. The three that can't — two object literals in_test_util.ts, and the bare class that exists precisely to have nostorageId— spell the pair.mem0/onedrive/sharepointswapRecord<string, unknown>for named state types, joining the ~35 resources that already had them (Record<string, unknown>has notype: stringand so cannot satisfy the contract).Tests
packages/node/src/resource/state_round_trip.test.ts— a registry-derived sweep (not a hand-listed table) over the six database backends:getState/loadStateexist, the credential is masked, andresourceStateRequiresOverrideanswers correctly. Plus an end-to-endtoStateDictover a postgres mount — the exact call that used to throw.packages/core/src/resource/base.test.ts— the base default's shape, and that it asks for no override.python/tests/resource/test_state_round_trip.py— the twin sweep. It passes without touching any Python source, which is the point.Verification
pnpm -r typecheck— 0pnpm -r build— 0pnpm -r test— 11,256 passinguv run pytest(excl.tests/fuse, no macFUSE locally) — greenpre-commit run --all-files— passcheck_spec_parity.py(93 commands),check_layout_parity.py --strict(300, at baseline),gen_width_table.py— all clean🤖 Generated with Claude Code