Skip to content

refactor(ts): put getState/loadState on the Resource contract, mirroring Python's base (A3) - #800

Merged
bytecii merged 3 commits into
strukto-ai:mainfrom
bytecii:feat/resource-state-contract
Aug 15, 2026
Merged

bytecii merged 3 commits into
strukto-ai:mainfrom
bytecii:feat/resource-state-contract

Conversation

@bytecii

@bytecii bytecii commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Cleanup-plan item A3.

The bug

workspace/snapshot/state.ts cast every mount's resource into { getState } and called it unconditionally:

const resource = m.resource as unknown as { kind: string; getState: () => ... }
const state = await Promise.resolve(resource.getState())

Nothing on the Resource interface said getState existed, so a resource without one was a runtime crash at save time, not a compile error. postgres and mongodb — both registry-mountable, in node and browser — had none. Snapshotting such a mount died with resource.getState is not a function.

The fix, following Python's structure

Python already had all of this; this closes a TypeScript-only gap.

before (TS) after (TS) Python
base default getState() → {type: this.kind}, loadState() no-op BaseResource.get_state / load_state
interface absent non-optional getState / loadState (duck-typed)
kind on base interface only abstract readonly kind name: str = "base"
config-backed resources with no state 8 0 0

Concretely:

  • BaseResource gains both methods, the twins of Python's. kind moves onto the base as abstract so the default can spell itself.
  • Resource declares both non-optionally, so both casts in snapshot/state.ts go. 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.
  • The eight config-backed resources that had no state now carry their config, redacted: chroma, dify, qdrant (core), postgres, mongodb (node + browser), lancedb. Inheriting the bare default would be worse than crashing: with no <REDACTED> marker, resourceStateRequiresOverride returns false and buildMountArgs substitutes an empty RAMResource — a live database mount silently becomes an empty directory on load.
  • HfResource re-narrows getState back to abstract, so the bare default cannot reach a Hub resource. Python has no shared Hub base; its four resources each spell get_state, so this only pins the habit down.

Which fields get masked

Not guessed — read off Python's secret_field_names for each config:

backend masked note
qdrant, lancedb apiKey
postgres dsn password is inside the DSN
mongodb uri password is inside the URI
dify apiKey Python masks it in get_state, not as a SecretStr on the model
chroma genuinely no credential; the one backend that needs no override on load

These six configs are hand-written interfaces with no zod schema, so they cannot go through the shared redactConfigWithSchema the ~50 schema-backed configs use; each spells its own mask, typed by RedactedConfig<T, K>.

Test fakes

Fakes that stood up a Resource by hand now extends BaseResource like 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 no storageId — spell the pair.

mem0/onedrive/sharepoint swap Record<string, unknown> for named state types, joining the ~35 resources that already had them (Record<string, unknown> has no type: string and 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/loadState exist, the credential is masked, and resourceStateRequiresOverride answers correctly. Plus an end-to-end toStateDict over 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 — 0
  • pnpm -r build — 0
  • pnpm -r test — 11,256 passing
  • uv run pytest (excl. tests/fuse, no macFUSE locally) — green
  • pre-commit run --all-files — pass
  • spec drift (both generators), check_spec_parity.py (93 commands), check_layout_parity.py --strict (300, at baseline), gen_width_table.py — all clean

🤖 Generated with Claude Code

…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>
@bytecii
bytecii requested a review from zechengz as a code owner August 15, 2026 01:30

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

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>
@zechengz

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread typescript/packages/core/src/resource/chroma/chroma.ts Outdated
Comment thread typescript/packages/core/src/resource/qdrant/config.ts Outdated
… 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>
@bytecii
bytecii merged commit 7a6ca4f into strukto-ai:main Aug 15, 2026
41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants