Skip to content

refactor(crm): static schemas + raw_data for salesforce/hubspot - #2212

Merged
aleksdotbar merged 1 commit into
mainfrom
refactor/crm-static-schema-raw-data
Aug 5, 2026
Merged

refactor(crm): static schemas + raw_data for salesforce/hubspot#2212
aleksdotbar merged 1 commit into
mainfrom
refactor/crm-static-schema-raw-data

Conversation

@aleksdotbar

@aleksdotbar aleksdotbar commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Problem

Salesforce and HubSpot built their advertised schemas from a live describe. Two costs: bronze DDL could not be generated without credentials, and column sets varied per org/portal. HubSpot also dropped data — standard properties outside the allowlist were never requested.

Fix

Schemas are repository artifacts. discover issues no API call for either connector.

  • Salesforce — per-stream schema files in source_salesforce/stream_schemas/, frozen from the committed DDL so dbt reads nothing new. Auth happens on demand, not at streams(). Stream availability is resolved per read, off the describe the stream already makes for its SOQL field list; a sobject the org lacks syncs empty and check reports it.
  • HubSpot — allowlist columns emitted unconditionally; every portal property fetched, so non-allowlisted standard properties reach bronze instead of vanishing.
  • raw_data carries the whole record (per-value 2 KB cap). custom_fields removed as a strict subset of it.
  • Pipelinebootstrap-db and the connectors-ddl gate need no credentials; the fork skip is gone from the gate and now guards only regen-pr.

Verify

  • Real CDK discover on fake config with the network blocked: SF 10 streams, HubSpot 19.
  • Full bootstrap-db on a throwaway ClickHouse: 218/218 dbt models, field parity 0 failures. Snapshot regenerated from that run.
  • SF bronze column set is unchanged apart from custom_fieldsraw_data, so the frozen schemas reproduce the describe-derived ones.
  • Unit: salesforce 174, hubspot 180.

Notes

  • Merge order. The gate runs the image pinned in descriptor.yaml, and bump-descriptors only runs on push to main. Until the new images publish, this PR's connectors-ddl run uses the old describe-driven images and will fail on auth. Expected; it resolves on main once images land.
  • custom_fields leaves the class contract: migration drops it from silver.class_crm_*, apply-ch-migrations.sh from staging. Nothing read its content.
  • Unrelated, pre-existing: regenerating bitbucket-cloud.sql yields 10 tables against the committed 20 (pinned image exposes fewer streams, no config gates them). Left untouched here; worth checking on main.

Refs #756 #757

Summary by CodeRabbit

  • New Features

    • CRM connector records now preserve complete source payloads in a raw_data field while exposing only declared fields as columns.
    • Added static, repository-defined schemas for Salesforce streams.
    • Connector discovery can run with placeholder configuration values, including from forked pull requests.
  • Bug Fixes

    • Removed obsolete custom_fields columns from CRM data models and added migration support for existing tables.
  • Documentation

    • Updated connector and ingestion documentation to describe static schemas and raw_data handling.

Discover built schemas from a live describe: bronze DDL needed
credentials and column sets varied per org/portal. Schemas are now
repository artifacts and discover issues no API call.

- salesforce: per-stream schema files, auth on demand, stream
  availability resolved per read
- hubspot: allowlist columns emitted unconditionally, all portal
  properties fetched
- raw_data carries the whole record; custom_fields removed as a subset
- bootstrap-db and the connectors-ddl gate run without credentials

BREAKING CHANGE: silver.class_crm_* lose custom_fields (migration
20260805000000_crm-drop-custom-fields.sql; staging healed in
apply-ch-migrations.sh).

Refs #756 #757

Signed-off-by: Aleksandr Barkhatov <pm@aleks.bar>
@aleksdotbar
aleksdotbar requested a review from a team as a code owner August 5, 2026 05:38
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

HubSpot and Salesforce now use static stream schemas and preserve undeclared source fields in raw_data. CRM DDL, dbt models, migrations, tests, bootstrap configuration, and connector workflows were updated to remove custom_fields and support credential-free discovery.

Changes

CRM generation and schema contract

Layer / File(s) Summary
Credential-free generation and schema contract
.claude/skills/connector/..., .github/workflows/..., docs/domain/..., src/ingestion/scripts/bootstrap-db/...
Connector discovery uses fake values. Fork pull requests can run validation without CRM secrets. Static schema and raw_data behavior are documented.
HubSpot allowlisted projection and raw_data envelope
src/ingestion/connectors/crm/hubspot/...
HubSpot exposes allowlisted properties as columns and stores the complete truncated record in raw_data. Dynamic custom-property classification and custom_fields handling were removed.
Salesforce static schemas and stream pipeline
src/ingestion/connectors/crm/salesforce/source_salesforce/...
Salesforce loads repository schemas, separates stream availability from schema generation, authenticates lazily, and uses tuple-based field chunks.
Salesforce pipeline validation
src/ingestion/connectors/crm/salesforce/tests/...
Tests cover static schemas, lazy authentication, unavailable streams, raw record retention, chunking, and removed parent substreams.
CRM storage contract migration
src/ingestion/scripts/connectors-ddl/*, src/ingestion/scripts/migrations/*, src/ingestion/scripts/apply-ch-migrations.sh
Bronze schemas replace custom_fields with raw_data. Silver models and existing CRM tables no longer retain custom_fields.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers: cyberantonz, mitasovr

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the CRM refactor to static schemas and raw_data for both Salesforce and HubSpot.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/crm-static-schema-raw-data

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 9

🧹 Nitpick comments (5)
src/ingestion/connectors/crm/salesforce/source_salesforce/source.py (2)

181-186: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

_get_stream_type ignores stream_name.

The method returns the same pair for every stream. The stream_name parameter is now dead. Keep it only if a caller or test depends on the signature; otherwise inline the constant pair in prepare_stream.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/connectors/crm/salesforce/source_salesforce/source.py` around
lines 181 - 186, Update _get_stream_type so it no longer accepts the unused
stream_name parameter unless callers or tests require that signature; otherwise
remove the method and inline the constant RestSalesforceStream,
IncrementalRestSalesforceStream pair directly in prepare_stream, updating all
call sites accordingly.

180-201: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the missing type hints on the changed signatures.

_get_stream_type has no return annotation. prepare_stream has no parameter or return annotations. The repository guideline requires type hints on every function and method signature under src/ingestion/.

♻️ Proposed annotations
     `@classmethod`
-    def _get_stream_type(cls, stream_name: str):
+    def _get_stream_type(cls, stream_name: str) -> Tuple[Type[Stream], Type[Stream]]:
         """Get proper stream class: full_refresh or incremental.
 
         Every stream uses the REST ``/queryAll`` API.
         """
         return RestSalesforceStream, IncrementalRestSalesforceStream
 
-    def prepare_stream(self, stream_name, json_schema, sobject_options, sf_object, authenticator, config):
+    def prepare_stream(
+        self,
+        stream_name: str,
+        json_schema: Mapping[str, Any],
+        sobject_options: Mapping[str, Any],
+        sf_object: Salesforce,
+        authenticator: SalesforceAuthenticator,
+        config: Mapping[str, Any],
+    ) -> Tuple[Type[Stream], MutableMapping[str, Any]]:

Type must be added to the typing import list.

As per coding guidelines: "Put type hints on every function and method signature; do not allow bare Any to escape."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/connectors/crm/salesforce/source_salesforce/source.py` around
lines 180 - 201, Add complete type annotations to the changed _get_stream_type
and prepare_stream signatures, including the return type for _get_stream_type
and parameter plus return types for prepare_stream. Add Type to the typing
imports as required, and use concrete existing stream/config/schema types rather
than allowing untyped or bare Any values to escape.

Source: Coding guidelines

src/ingestion/connectors/crm/salesforce/tests/conftest.py (1)

99-106: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Annotate make_stream to satisfy the signature requirement.

sf: Salesforce | None is safe because the module enables from __future__ import annotations and the connector requires Python >=3.10, but make_stream still needs a return annotation and all parameters should avoid bare Any as required under src/ingestion/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/connectors/crm/salesforce/tests/conftest.py` around lines 99 -
106, Update the make_stream signature with an explicit return annotation and
replace bare Any annotations on sf_fields and extra with precise types
consistent with the surrounding src/ingestion conventions. Preserve the existing
defaults and parameter behavior.

Source: Coding guidelines

src/ingestion/connectors/crm/salesforce/tests/test_envelope.py (1)

103-117: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add tests for two non-obvious truncation rules.

The truncation tests use Description, which is outside DECLARED. Two semantics stay uncovered:

  • A declared field keeps its full value in the top-level column while raw_data holds the truncated copy. The envelope truncates only the raw accumulator, so the same field has two different lengths in one output row.
  • _truncate slices on bytes and decodes with errors="ignore". A multi-byte character that straddles the 2034-byte boundary is dropped so the result stays valid UTF-8.
💚 Proposed tests
    def test_declared_column_keeps_full_value_while_raw_data_truncates(self):
        long_name = "x" * 5000
        out = _wrap({"Id": "001", "Name": long_name})
        assert out["Name"] == long_name
        assert json.loads(out["raw_data"])["Name"].endswith("…[truncated]")

    def test_multibyte_character_split_at_the_cap_is_dropped(self):
        out = _wrap({"Id": "001", "Description": "é" * 2000})
        truncated = json.loads(out["raw_data"])["Description"]
        assert truncated.endswith("…[truncated]")
        assert len(truncated.encode("utf-8")) <= 2048

As per path instructions: "Test non-obvious semantics with a test whose name states the rule rather than adding a comment."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/connectors/crm/salesforce/tests/test_envelope.py` around lines
103 - 117, Extend the truncation tests in test_envelope.py with cases named for
both rules: verify a declared Name field remains full-length in out["Name"]
while raw_data contains its truncated value, and verify truncating repeated
multi-byte "é" characters drops any character split at the byte cap while
preserving the suffix and the 2048-byte limit. Use _wrap and JSON decoding
consistently with the existing tests.

Source: Path instructions

src/ingestion/connectors/crm/salesforce/source_salesforce/schema_loader.py (1)

25-37: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Raise a typed error for a malformed schema file.

_load raises UnknownStreamSchemaError only when the file is absent. Two other failure kinds escape untyped:

  • Invalid JSON raises json.JSONDecodeError.
  • A schema file without a properties key makes declared_field_names raise KeyError.

Both failures reach the caller during discovery and produce a message that does not name the offending file. Convert them at the failure site.

The coding guidelines require one typed exception per failure kind, with detailed context logged at the failure site.

♻️ Proposed refactor
 class UnknownStreamSchemaError(Exception):
     pass
 
 
+class InvalidStreamSchemaError(Exception):
+    pass
+
+
 `@lru_cache`(maxsize=None)
 def available_stream_names() -> FrozenSet[str]:
     return frozenset(p.name[: -len(_SCHEMA_SUFFIX)] for p in _SCHEMA_DIR.glob(f"*{_SCHEMA_SUFFIX}"))
 
 
 `@lru_cache`(maxsize=None)
 def _load(stream_name: str) -> Mapping[str, Any]:
     path = _SCHEMA_DIR / f"{stream_name}{_SCHEMA_SUFFIX}"
     if not path.is_file():
         raise UnknownStreamSchemaError(
             f"No static schema for stream '{stream_name}'; expected {path.name}"
         )
-    schema: Mapping[str, Any] = json.loads(path.read_text())
+
+    try:
+        schema: Mapping[str, Any] = json.loads(path.read_text())
+    except json.JSONDecodeError as exc:
+        raise InvalidStreamSchemaError(f"Static schema {path.name} is not valid JSON") from exc
+
+    if "properties" not in schema:
+        raise InvalidStreamSchemaError(f"Static schema {path.name} has no 'properties' object")
+
     return schema

As per coding guidelines: "Use named helpers for repetition, log detailed context at the failure site, and raise one typed exception per failure kind."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/connectors/crm/salesforce/source_salesforce/schema_loader.py`
around lines 25 - 37, Update _load to catch JSON decoding failures, log detailed
context including the offending schema filename, and raise a dedicated typed
exception for invalid JSON. Update declared_field_names to validate the loaded
schema contains properties, logging the filename and raising a separate typed
exception when it is absent instead of allowing KeyError to escape. Preserve
UnknownStreamSchemaError for missing files and use named helpers if needed to
avoid repeating failure-context handling.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/domain/connector/specs/ADR/0004-static-stream-schemas-with-raw-data.md`:
- Around line 79-80: Update the raw_data string-value rule in the static stream
schemas ADR to specify the 2 KB maximum and clarify whether the limit is
measured in UTF-8 bytes or characters. Add a boundary test covering values at
the limit and just beyond it, preserving the guarantee that the serialized blob
is never truncated and remains parseable.

In `@src/ingestion/connectors/crm/hubspot/source_hubspot/envelope.py`:
- Around line 60-66: Complete the type annotations for the affected callables:
define and use a recursive JSON-value type in _truncate_deep instead of bare
Any; parameterize allowed_property_names as an immutable string set in
envelope.py; annotate the helper input and return in test_envelope.py; and
annotate the batch input and iterable return in test_base_stream.py. Update
every listed signature while preserving existing behavior.

In `@src/ingestion/connectors/crm/hubspot/tests/test_api.py`:
- Around line 126-156: Add return type annotations of None to every changed test
method in test_api.py, including the parameterized test’s object_type parameter
annotated as str. Also annotate FakeHubspot.__init__ in conftest.py with ->
None; make no other changes.

In
`@src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/Account.schema.json`:
- Around line 105-110: The Salesforce compound address fields are declared as
strings but are returned as nested objects. Update Account.schema.json lines
105-110, Contact.schema.json lines 117-122, and Lead.schema.json lines 123-128
for BillingAddress, ShippingAddress, Address, MailingAddress, and OtherAddress
to use the shared compound address type consistently, or remove these declared
columns if they are exposed only through raw_data.

In
`@src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/User.schema.json`:
- Around line 123-128: Update the User schema’s Address property to match
Salesforce’s compound-object shape by changing its type union from string/null
to object/null, or remove the Address property if it should not be exposed as a
column; do not leave it declared as a string.

In `@src/ingestion/connectors/crm/salesforce/source_salesforce/streams.py`:
- Around line 113-129: Cache the declared field set before the record loop in
read_records and reuse it when calling envelope, rather than resolving
self.declared_fields for each record. Update declared_field_names in
schema_loader.py to cache the frozenset derived from
_load(stream_name)["properties"], while leaving get_json_schema’s existing
single _load call unchanged.

In `@src/ingestion/connectors/crm/salesforce/tests/test_api.py`:
- Around line 35-44: Update every changed test method in
src/ingestion/connectors/crm/salesforce/tests/test_api.py ranges 35-44, 184-190,
213-238, and 261-277, and
src/ingestion/connectors/crm/salesforce/tests/test_source.py ranges 201-208,
233-255, 270-274, and 332-340, adding appropriate fixture parameter types and ->
None return annotations. In test_source.py range 233-255, also annotate _sf_stub
with -> Salesforce; ensure all affected function and method signatures are fully
typed without bare Any escaping.

In `@src/ingestion/scripts/bootstrap-db/README.md`:
- Around line 86-90: Update the fork pull request documentation near the
workflow description to state that fork PRs run validation and receive the
regenerated snapshot artifact without CRM secrets, while limiting regen-pr to
same-repository pull requests.

In `@src/ingestion/scripts/migrations/20260805000000_crm-drop-custom-fields.sql`:
- Around line 12-20: Migrate existing bronze tables from custom_fields to
raw_data while preserving raw_data immediately after collected_at. In
src/ingestion/scripts/migrations/20260805000000_crm-drop-custom-fields.sql
(lines 12-20), add ALTER statements for every bronze_hubspot and
bronze_salesforce relation; in src/ingestion/scripts/apply-ch-migrations.sh
(lines 102-114), include those relations in contract healing. Keep the target
declarations in src/ingestion/scripts/connectors-ddl/hubspot.sql (lines 31-619)
and src/ingestion/scripts/connectors-ddl/salesforce.sql (lines 77-736), adding
matching existing-table ALTER statements there.

---

Nitpick comments:
In `@src/ingestion/connectors/crm/salesforce/source_salesforce/schema_loader.py`:
- Around line 25-37: Update _load to catch JSON decoding failures, log detailed
context including the offending schema filename, and raise a dedicated typed
exception for invalid JSON. Update declared_field_names to validate the loaded
schema contains properties, logging the filename and raising a separate typed
exception when it is absent instead of allowing KeyError to escape. Preserve
UnknownStreamSchemaError for missing files and use named helpers if needed to
avoid repeating failure-context handling.

In `@src/ingestion/connectors/crm/salesforce/source_salesforce/source.py`:
- Around line 181-186: Update _get_stream_type so it no longer accepts the
unused stream_name parameter unless callers or tests require that signature;
otherwise remove the method and inline the constant RestSalesforceStream,
IncrementalRestSalesforceStream pair directly in prepare_stream, updating all
call sites accordingly.
- Around line 180-201: Add complete type annotations to the changed
_get_stream_type and prepare_stream signatures, including the return type for
_get_stream_type and parameter plus return types for prepare_stream. Add Type to
the typing imports as required, and use concrete existing stream/config/schema
types rather than allowing untyped or bare Any values to escape.

In `@src/ingestion/connectors/crm/salesforce/tests/conftest.py`:
- Around line 99-106: Update the make_stream signature with an explicit return
annotation and replace bare Any annotations on sf_fields and extra with precise
types consistent with the surrounding src/ingestion conventions. Preserve the
existing defaults and parameter behavior.

In `@src/ingestion/connectors/crm/salesforce/tests/test_envelope.py`:
- Around line 103-117: Extend the truncation tests in test_envelope.py with
cases named for both rules: verify a declared Name field remains full-length in
out["Name"] while raw_data contains its truncated value, and verify truncating
repeated multi-byte "é" characters drops any character split at the byte cap
while preserving the suffix and the 2048-byte limit. Use _wrap and JSON decoding
consistently with the existing tests.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 957d53d4-632b-40f7-9bd1-56001b77d4cc

📥 Commits

Reviewing files that changed from the base of the PR and between 69116ff and 5355791.

📒 Files selected for processing (63)
  • .claude/skills/connector/workflows/create.md
  • .github/workflows/connectors-ddl.yml
  • docs/domain/connector/specs/ADR/0004-static-stream-schemas-with-raw-data.md
  • docs/domain/ingestion-data-flow/specs/DESIGN.md
  • src/ingestion/connectors/crm/hubspot/README.md
  • src/ingestion/connectors/crm/hubspot/dbt/hubspot__crm_accounts.sql
  • src/ingestion/connectors/crm/hubspot/dbt/hubspot__crm_activities.sql
  • src/ingestion/connectors/crm/hubspot/dbt/hubspot__crm_contacts.sql
  • src/ingestion/connectors/crm/hubspot/dbt/hubspot__crm_deals.sql
  • src/ingestion/connectors/crm/hubspot/dbt/hubspot__crm_users.sql
  • src/ingestion/connectors/crm/hubspot/dbt/schema.yml
  • src/ingestion/connectors/crm/hubspot/descriptor.yaml
  • src/ingestion/connectors/crm/hubspot/source_hubspot/api.py
  • src/ingestion/connectors/crm/hubspot/source_hubspot/constants.py
  • src/ingestion/connectors/crm/hubspot/source_hubspot/envelope.py
  • src/ingestion/connectors/crm/hubspot/source_hubspot/streams.py
  • src/ingestion/connectors/crm/hubspot/tests/conftest.py
  • src/ingestion/connectors/crm/hubspot/tests/test_api.py
  • src/ingestion/connectors/crm/hubspot/tests/test_archived_stream.py
  • src/ingestion/connectors/crm/hubspot/tests/test_base_stream.py
  • src/ingestion/connectors/crm/hubspot/tests/test_envelope.py
  • src/ingestion/connectors/crm/hubspot/tests/test_owners_streams.py
  • src/ingestion/connectors/crm/hubspot/tests/test_search_stream.py
  • src/ingestion/connectors/crm/hubspot/tests/test_source.py
  • src/ingestion/connectors/crm/salesforce/README.md
  • src/ingestion/connectors/crm/salesforce/dbt/salesforce__crm_accounts.sql
  • src/ingestion/connectors/crm/salesforce/dbt/salesforce__crm_activities.sql
  • src/ingestion/connectors/crm/salesforce/dbt/salesforce__crm_contacts.sql
  • src/ingestion/connectors/crm/salesforce/dbt/salesforce__crm_deals.sql
  • src/ingestion/connectors/crm/salesforce/dbt/salesforce__crm_users.sql
  • src/ingestion/connectors/crm/salesforce/dbt/schema.yml
  • src/ingestion/connectors/crm/salesforce/descriptor.yaml
  • src/ingestion/connectors/crm/salesforce/pyproject.toml
  • src/ingestion/connectors/crm/salesforce/source_salesforce/api.py
  • src/ingestion/connectors/crm/salesforce/source_salesforce/constants.py
  • src/ingestion/connectors/crm/salesforce/source_salesforce/envelope.py
  • src/ingestion/connectors/crm/salesforce/source_salesforce/exceptions.py
  • src/ingestion/connectors/crm/salesforce/source_salesforce/schema_loader.py
  • src/ingestion/connectors/crm/salesforce/source_salesforce/source.py
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/Account.schema.json
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/Case.schema.json
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/Contact.schema.json
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/Event.schema.json
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/Lead.schema.json
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/Opportunity.schema.json
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/OpportunityContactRole.schema.json
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/OpportunityHistory.schema.json
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/Task.schema.json
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/User.schema.json
  • src/ingestion/connectors/crm/salesforce/source_salesforce/streams.py
  • src/ingestion/connectors/crm/salesforce/tests/conftest.py
  • src/ingestion/connectors/crm/salesforce/tests/test_api.py
  • src/ingestion/connectors/crm/salesforce/tests/test_envelope.py
  • src/ingestion/connectors/crm/salesforce/tests/test_source.py
  • src/ingestion/connectors/crm/salesforce/tests/test_streams.py
  • src/ingestion/scripts/apply-ch-migrations.sh
  • src/ingestion/scripts/bootstrap-db/.env.bootstrap.example
  • src/ingestion/scripts/bootstrap-db/README.md
  • src/ingestion/scripts/bootstrap-db/connectors-config.yaml
  • src/ingestion/scripts/connectors-ddl/hubspot.sql
  • src/ingestion/scripts/connectors-ddl/salesforce.sql
  • src/ingestion/scripts/connectors-ddl/silver.sql
  • src/ingestion/scripts/migrations/20260805000000_crm-drop-custom-fields.sql
💤 Files with no reviewable changes (14)
  • src/ingestion/connectors/crm/hubspot/tests/test_source.py
  • src/ingestion/connectors/crm/salesforce/dbt/salesforce__crm_accounts.sql
  • src/ingestion/connectors/crm/salesforce/dbt/salesforce__crm_contacts.sql
  • src/ingestion/connectors/crm/hubspot/dbt/hubspot__crm_accounts.sql
  • src/ingestion/connectors/crm/hubspot/dbt/hubspot__crm_users.sql
  • src/ingestion/connectors/crm/hubspot/dbt/hubspot__crm_deals.sql
  • src/ingestion/connectors/crm/salesforce/source_salesforce/exceptions.py
  • src/ingestion/connectors/crm/hubspot/dbt/hubspot__crm_contacts.sql
  • src/ingestion/connectors/crm/hubspot/dbt/hubspot__crm_activities.sql
  • src/ingestion/scripts/bootstrap-db/.env.bootstrap.example
  • src/ingestion/connectors/crm/salesforce/dbt/salesforce__crm_deals.sql
  • src/ingestion/connectors/crm/salesforce/dbt/salesforce__crm_activities.sql
  • src/ingestion/connectors/crm/salesforce/dbt/salesforce__crm_users.sql
  • src/ingestion/scripts/connectors-ddl/silver.sql

Comment on lines +79 to +80
4. String values inside `raw_data` are capped per value. The serialized blob is
never truncated, so it always parses.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Document the raw_data value limit.

The rule says that string values are capped, but it does not define the limit. The PR contract specifies a 2 KB cap per value. State the numeric limit and the unit, such as UTF-8 bytes or characters. Add a boundary test for this contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/domain/connector/specs/ADR/0004-static-stream-schemas-with-raw-data.md`
around lines 79 - 80, Update the raw_data string-value rule in the static stream
schemas ADR to specify the 2 KB maximum and clarify whether the limit is
measured in UTF-8 bytes or characters. Add a boundary test covering values at
the limit and just beyond it, preserving the guarantee that the serialized blob
is never truncated and remains parseable.

Comment on lines +60 to +66
def _truncate_deep(value: Any) -> Any:
"""Copy ``value``, truncating every string it contains."""
if isinstance(value, Mapping):
return {k: _truncate_deep(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_truncate_deep(v) for v in value]
return _truncate(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add complete types to the changed callable interfaces.

Replace bare Any in _truncate_deep() with a recursive JSON-value type. Parameterize allowed_property_names as a string set. Add parameter and return types to wrap() and spy().

  • src/ingestion/connectors/crm/hubspot/source_hubspot/envelope.py#L60-L66: use a recursive JSON-value type instead of Any.
  • src/ingestion/connectors/crm/hubspot/source_hubspot/envelope.py#L73-L80: use a parameterized immutable string set type.
  • src/ingestion/connectors/crm/hubspot/tests/test_envelope.py#L14-L15: annotate the helper input and return value.
  • src/ingestion/connectors/crm/hubspot/tests/test_base_stream.py#L164-L166: annotate the batch input and iterable return value.

As per coding guidelines, “Put type hints on every function and method signature; do not allow bare Any to escape.”

📍 Affects 3 files
  • src/ingestion/connectors/crm/hubspot/source_hubspot/envelope.py#L60-L66 (this comment)
  • src/ingestion/connectors/crm/hubspot/source_hubspot/envelope.py#L73-L80
  • src/ingestion/connectors/crm/hubspot/tests/test_envelope.py#L14-L15
  • src/ingestion/connectors/crm/hubspot/tests/test_base_stream.py#L164-L166
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/connectors/crm/hubspot/source_hubspot/envelope.py` around lines
60 - 66, Complete the type annotations for the affected callables: define and
use a recursive JSON-value type in _truncate_deep instead of bare Any;
parameterize allowed_property_names as an immutable string set in envelope.py;
annotate the helper input and return in test_envelope.py; and annotate the batch
input and iterable return in test_base_stream.py. Update every listed signature
while preserving existing behavior.

Source: Coding guidelines

Comment on lines +126 to +156
def test_every_named_portal_property_is_requested(self):
hs = make_client([FakeResponse({"results": self.DESCRIPTORS})])
assert hs.property_names("deals") == ("amount", "my_custom")
assert hs.property_names("deals") == ("amount", "uncurated_std", "my_custom")

def test_custom_property_names(self):
hs = make_client([FakeResponse({"results": self.DESCRIPTORS})])
assert hs.custom_property_names("deals") == frozenset({"my_custom"})

def test_unknown_object_has_no_curated_names(self):
def test_property_names_independent_of_allowlist(self):
hs = make_client([FakeResponse({"results": [prop("anything")]})])
assert hs.property_names("unknown_object") == ()
assert hs.property_names("unknown_object") == ("anything",)


class TestGenerateSchema:
def test_curated_props_added_with_string_type(self):
hs = make_client(
[
FakeResponse(
{
"results": [
prop("amount", type_="number"),
prop("uncurated_std"),
prop("my_custom", hubspot_defined=False),
]
}
)
]
)
schema = hs.generate_schema("deals")
props = schema["properties"]
# Base record fields always present.
def test_allowlist_is_the_whole_property_column_set(self):
props = make_client().generate_schema("deals")["properties"]
expected = {f"properties_{name}" for name in ALLOWED_PROPERTIES_BY_OBJECT["deals"]}
assert {k for k in props if k.startswith("properties_")} == expected
assert all(props[k] == {"type": ["string", "null"]} for k in expected)

def test_schema_needs_no_portal_describe(self):
hs = make_client() # no queued responses: any HTTP call raises
hs.generate_schema("deals")
assert hs._http_client.calls == []

def test_base_record_fields_always_present(self):
props = make_client().generate_schema("leads")["properties"]
assert props["id"] == {"type": ["string", "null"]}
assert props["archived"] == {"type": ["boolean", "null"]}
assert props["archivedAt"]["format"] == "date-time"
# number maps to string on purpose (Bronze stays Nullable(String)).
assert props["properties_amount"] == {"type": ["string", "null"]}
assert "properties_uncurated_std" not in props
assert "properties_my_custom" not in props # customs ride in custom_fields

def test_unknown_type_warns_once(self, caplog):
hs = make_client(
[
FakeResponse(
{
"results": [
{"name": "amount", "hubspotDefined": True, "type": "alien"},
{"name": "closedate", "hubspotDefined": True, "type": "alien"},
]
}
)
]
)
with caplog.at_level(logging.WARNING, logger="airbyte"):
schema = hs.generate_schema("deals")
assert schema["properties"]["properties_amount"] == {"type": ["string", "null"]}
assert caplog.text.count("Unknown HubSpot property type") == 1

def test_prop_to_json_schema_format_passthrough(self, monkeypatch):
# No current mapping carries a format — patch one in to cover the
# format branch.
monkeypatch.setitem(api_mod.HUBSPOT_TYPE_TO_JSON_SCHEMA, "datetime", ("string", "date-time"))
out = _prop_to_json_schema({"name": "x", "type": "datetime"}, set())
assert out == {"type": ["string", "null"], "format": "date-time"}

def test_prop_to_json_schema_defaults_missing_type_to_string(self):
assert _prop_to_json_schema({"name": "x"}, set()) == {"type": ["string", "null"]}

@pytest.mark.parametrize("object_type", ["leads", "unknown_object"])
def test_empty_allowlist_yields_base_fields_only(self, object_type):
props = make_client().generate_schema(object_type)["properties"]
assert not [k for k in props if k.startswith("properties_")], f"object: {object_type}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Apply the signature-typing rule to both changed Python files.

  • src/ingestion/connectors/crm/hubspot/tests/test_api.py#L126-L156: add -> None to each changed test method and annotate object_type: str.
  • src/ingestion/connectors/crm/hubspot/tests/conftest.py#L98-L99: add -> None to FakeHubspot.__init__.

As per coding guidelines, src/ingestion/**/*.{py,pyi} requires type hints on every function and method signature.

📍 Affects 2 files
  • src/ingestion/connectors/crm/hubspot/tests/test_api.py#L126-L156 (this comment)
  • src/ingestion/connectors/crm/hubspot/tests/conftest.py#L98-L99
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/connectors/crm/hubspot/tests/test_api.py` around lines 126 -
156, Add return type annotations of None to every changed test method in
test_api.py, including the parameterized test’s object_type parameter annotated
as str. Also annotate FakeHubspot.__init__ in conftest.py with -> None; make no
other changes.

Source: Coding guidelines

Comment on lines +105 to +110
"BillingAddress": {
"type": [
"string",
"null"
]
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check compound-value coercion and find every address-typed property in the static schemas.
set -euo pipefail

fd -t f 'envelope.py' src/ingestion/connectors/crm/salesforce --exec cat -n {}

# Every property whose name ends in Address across the static schemas.
fd -t f -e json . src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas \
  --exec rg -n -A4 '"[A-Za-z]*Address":' {}

Repository: constructorfabric/insight

Length of output: 13548


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Candidate files:\n'
fd -t f -i 'schema|describe|salesforce|source.py' src ing* 2>/dev/null | sed -n '1,120p'

printf '\nKey symbol search:\n'
rg -n --glob '!**/stream_schemas/*.json' 'schema|schema_generator|describe|address|Address|queryAll|connector|stream' src/ingestion/connectors/crm/salesforce source* 2>/dev/null | sed -n '1,240p'

Repository: constructorfabric/insight

Length of output: 38401


🏁 Script executed:

#!/bin/bash
set -euo pipefail

source_dir="src/ingestion/connectors/crm/salesforce"

printf 'Files under Salesforce connector:\n'
git ls-files "$source_dir" | sed -n '1,200p'

printf '\nPackage metadata:\n'
for f in pyproject.toml setup.py setup.cfg requirements*.txt; do
  git ls-files | grep -E "(^$f$|/$f$)" || true
done
fd -t f '(^pyproject.toml$|^setup.py$|^setup.cfg$|^requirements.*\.txt$)' . | sed -n '1,200p'

Repository: constructorfabric/insight

Length of output: 4097


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'schema_loader.py outline and relevant contents:\n'
ast-grep outline src/ingestion/connectors/crm/salesforce/source_salesforce/schema_loader.py --view expanded || true
cat -n src/ingestion/connectors/crm/salesforce/source_salesforce/schema_loader.py | sed -n '1,260p'

printf '\nconftest.py contents relevant to Salesforce fields:\n'
cat -n src/ingestion/connectors/crm/salesforce/tests/conftest.py | sed -n '1,260p'

Repository: constructorfabric/insight

Length of output: 8152


Apply the Salesforce compound address mapping consistently. queryAll returns these address fields as nested JSON objects, while envelope() preserves compound values in raw_data without stringifying them. Update Account.BillingAddress, Account.ShippingAddress, Lead.Address, and Contact.MailingAddress / Contact.OtherAddress to match the shared address type, or remove them from the declared columns if the connector only exposes them via raw_data.

📍 Affects 3 files
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/Account.schema.json#L105-L110 (this comment)
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/Contact.schema.json#L117-L122
  • src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/Lead.schema.json#L123-L128
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/Account.schema.json`
around lines 105 - 110, The Salesforce compound address fields are declared as
strings but are returned as nested objects. Update Account.schema.json lines
105-110, Contact.schema.json lines 117-122, and Lead.schema.json lines 123-128
for BillingAddress, ShippingAddress, Address, MailingAddress, and OtherAddress
to use the shared compound address type consistently, or remove these declared
columns if they are exposed only through raw_data.

Comment on lines +123 to +128
"Address": {
"type": [
"string",
"null"
]
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find compound address/location fields declared as strings in the static Salesforce schemas.
set -euo pipefail

fd -e json . src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas --exec \
  jq -r --arg f '{}' '
    .properties
    | to_entries[]
    | select(.key | test("^(Address|BillingAddress|ShippingAddress|MailingAddress|OtherAddress|Location)$"))
    | "\($f): \(.key) -> \(.value.type | tostring)"
  ' {}

Repository: constructorfabric/insight

Length of output: 1044


🌐 Web query:

airbyte-cdk TypeTransformer DefaultSchemaNormalization object value declared string coercion behavior

💡 Result:

In the Airbyte Python CDK, DefaultSchemaNormalization is a transformation configuration applied via the Transformer class to automatically mutate record data to align with a stream's declared JSON schema [1][2]. When DefaultSchemaNormalization is enabled for a stream, the CDK attempts to cast incoming data values to match the types defined in the stream's schema [1][2]. Regarding the behavior for specific types: - Automatic Casting: If the source API returns a value that does not match the schema type (e.g., a non-string value for a field declared as a "string"), the CDK automatically casts it to the correct type [1][2]. - Object Value Behavior: The DefaultSchemaNormalization supports transformation of complex types, including nested objects and arrays [1][2]. If a field is defined as an "object" in the schema, the transformer maintains the structure while ensuring the nested fields align with the declared schema [1]. - Failure to Cast: If a value cannot be successfully cast to the target schema type (for example, attempting to cast a non-numeric string like "asdf" to an integer), the field will retain its original value rather than being coerced or set to null [1][2]. - Limitations: Default normalization does not apply to types defined as an array of multiple types (except for "null") or fields governed by oneOf or anyOf keywords [1][2]. To use this feature, you configure it within your stream class by setting the transformer property: transformer = Transformer(TransformConfig.DefaultSchemaNormalization) This transformation is disabled by default and must be explicitly enabled on a per-stream basis [1][2]. If both default and custom transformations are needed, they can be combined using bitwise OR (e.g., TransformConfig.DefaultSchemaNormalization | TransformConfig.CustomSchemaNormalization), in which case the default normalization is always executed before the custom transformation [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Candidate files"
fd -e json User.schema.json src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas || true
fd -e py 'source\.py$' src/ingestion/connectors/crm/salesforce/source_salesforce || true

echo
echo "## User schema address context"
file="$(fd -e json User.schema.json src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas | head -n 1)"
if [ -n "${file:-}" ]; then
  sed -n '90,145p' "$file" | cat -n
fi

echo
echo "## Salesforce source schema normalization references"
rg -n "DefaultSchemaNormalization|AddressTransform|TypeTransformer|billingAddress|shippingAddress|mailingAddress|otherAddress|location|address" src/ingestion/connectors/crm/salesforce/source_salesforce -S || true

Repository: constructorfabric/insight

Length of output: 1933


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## source.py outline"
ast-grep outline src/ingestion/connectors/crm/salesforce/source_salesforce/source.py --view compact || true
echo

echo "## source.py relevant imports and stream declarations"
rg -n "class .*\(.*Source|Salesforce|streams\s*=|stream_schemas|TypeTransformer|DefaultSchemaNormalization|User|Account|Contact|Lead|Event" src/ingestion/connectors/crm/salesforce/source_salesforce -S

echo
echo "## User stream/schema references"
rg -n "\bUser\b|stream_schemas/User|stream_schema|User\.schema" src/ingestion/connectors/crm/salesforce/source_salesforce -S

Repository: constructorfabric/insight

Length of output: 36228


🌐 Web query:

Salesforce User object Address field compound field SOQL returns address object Street City

💡 Result:

In Salesforce, the Address field on the User object is a compound field [1][2]. While you can select the Address compound field directly in a SOQL SELECT clause using API version 30.0 or later, it is subject to several important limitations [2][3][4]. Key behaviors for querying the User Address compound field: 1. SELECT Clause Support: You can retrieve the Address field directly in a SOQL query (e.g., SELECT Address FROM User) [2][4]. The returned value is a structured data type (an Address object) that encapsulates components such as Street, City, State, PostalCode, Country, and Geolocation [2][5]. 2. Limitations in WHERE Clauses: You cannot use the compound Address field in a SOQL WHERE clause [3][6]. To filter records based on address data, you must query the individual component fields (e.g., WHERE Street!= null OR City!= null) [7]. 3. API and Tooling Restrictions: Compound fields are generally accessible via the SOAP and REST APIs [2][4]. However, they are not supported by the Bulk API [8]. If you are working with tools or integrations that rely on the Bulk API, you must explicitly query the individual component fields (Street, City, etc.) instead of the compound Address field [8]. 4. Read-Only Behavior: The Address compound field is read-only [1][4]. Updates to address information must be performed by writing to the individual component fields (e.g., Street, City, State, etc.) rather than the compound field itself [3][4]. If your application requires backward compatibility with API versions earlier than 30.0, or if you need to support environments where compound fields are not supported (like the Bulk API), you should always write your queries to select and filter by the individual component fields [2][8].

Citations:


🌐 Web query:

Airbyte Python CDK TypeTransformer DefaultSchemaNormalization string type union array oneOf cast behavior source record values

💡 Result:

In the Airbyte Python CDK, the DefaultSchemaNormalization transformation (enabled via the TypeTransformer) performs automatic type casting to align record values with the stream's declared JSON schema [1][2]. However, this normalization mechanism has specific limitations regarding complex types [1][2]. Types described as an array of multiple types (except "null") and types defined under oneOf or anyOf keywords are explicitly excluded from this automatic transformation [1][2]. Consequently, if a field's schema utilizes oneOf or union types to allow for multiple possible data structures or types, the DefaultSchemaNormalization will not attempt to cast or mutate those values [1][2]. The source record values for these fields will remain in their original state [1][2]. If you require specific transformation logic for these complex types, you must implement a custom schema type transformation by using TransformConfig.CustomSchemaNormalization and registering a custom transformation function [1][2].

Citations:


Declare Address as an object, or remove it.

User.Address is a Salesforce compound object, so ["string","null"] does not match the API shape. Default schema normalization does not transform non-string values back into strings for fields declared as a union type, so raw_data keeps the object but Address remains mismatched. Remove Address to avoid a misleading column, or declare it as ["object", "null"] if the column must stay.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/ingestion/connectors/crm/salesforce/source_salesforce/stream_schemas/User.schema.json`
around lines 123 - 128, Update the User schema’s Address property to match
Salesforce’s compound-object shape by changing its type union from string/null
to object/null, or remove the Address property if it should not be exposed as a
column; do not leave it declared as a string.

Comment on lines 113 to +129
for record in super().read_records(sync_mode, cursor_field, stream_slice, stream_state):
if isinstance(record, Mapping):
yield envelope(
record,
tenant_id=self._tenant_id,
source_id=self._source_id,
custom_field_names=self._custom_field_names,
declared_fields=self.declared_fields,
collision_seen=self._envelope_collisions_seen,
)
else:
# State / log / trace messages pass through untouched.
yield record

def _sf_properties(self) -> Mapping[str, Any]:
@property
def declared_fields(self) -> FrozenSet[str]:
"""SF fields this stream emits as Bronze columns; the rest ride in ``raw_data``."""
return declared_field_names(self.name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find get_json_schema call sites in the installed airbyte-cdk read path.
set -euo pipefail

fd -t d '^airbyte_cdk$' / 2>/dev/null | head -1 | while read -r cdk; do
  rg -n -C3 'get_json_schema\s*\(' "$cdk/sources" | head -60
done

Repository: constructorfabric/insight

Length of output: 163


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate relevant files =="
fd -a 'streams.py|schema_loader.py|pyproject.toml' . | sed 's#^\./##' | sort

echo
echo "== Current streams.py context =="
if [ -f src/ingestion/connectors/crm/salesforce/source_salesforce/streams.py ]; then
  nl -ba src/ingestion/connectors/crm/salesforce/source_salesforce/streams.py | sed -n '1,180p'
fi

echo
echo "== Current schema_loader.py context =="
if [ -f src/ingestion/connectors/crm/salesforce/source_salesforce/schema_loader.py ]; then
  nl -ba src/ingestion/connectors/crm/salesforce/source_salesforce/schema_loader.py | sed -n '1,220p'
fi

echo
echo "== Dependency references =="
for f in pyproject.toml src/ingestion/connectors/crm/salesforce source_salesforce; do
  [ -f "$f" ] || continue
  echo "--- $f ---"
  rg -n 'airbyte|cdc|salesforce|source_salesforce|requirements|uv|pdm|poetry' "$f" || true
done

echo
echo "== Schema property counts for User and related files =="
fd 'User\.schema\.json|schema.*json' src/ingestion/connectors/crm/salesforce/source_salesforce -t f -x sh -c 'echo "--- $1 ---"; python3 - "$1" <<"PY"
import json, pathlib
p=pathlib.Path(sys.argv[1])
data=json.loads(p.read_text())
if "properties" in data:
    print("properties:", len(data["properties"]))
for k in data.get("required", []):
    print(k)
PY
' sh {} \; | sed -n '1,220p'

Repository: constructorfabric/insight

Length of output: 1494


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path("src/ingestion/connectors/crm/salesforce/source_salesforce/streams.py")
print(f"streams.py exists: {p.exists()}")
if p.exists():
    for i, line in enumerate(p.read_text().splitlines(), 1):
        if 1 <= i <= 180:
            print(f"{i:4d}\t{line}")
PY

echo
python3 - <<'PY'
from pathlib import Path
p = Path("src/ingestion/connectors/crm/salesforce/source_salesforce/schema_loader.py")
print(f"schema_loader.py exists: {p.exists()}")
if p.exists():
    for i, line in enumerate(p.read_text().splitlines(), 1):
        if 1 <= i <= 260:
            print(f"{i:4d}\t{line}")
PY

echo
echo "== Salesforce connector dependency references =="
for f in pyproject.toml src/ingestion/connectors/crm/salesforce/pyproject.toml src/ingestion/connectors/crm/salesforce/source_salesforce; do
  [ -f "$f" ] || continue
  echo "--- $f ---"
  rg -n 'airbyte|cdc|salesforce|source_salesforce|requirements|uv|pdm|poetry' "$f" || true
done

echo
echo "== Schema property counts for relevant schema files =="
python3 - <<'PY'
from pathlib import Path
import json

for p in Path("src/ingestion/connectors/crm/salesforce/source_salesforce").rglob("*.schema.json"):
    data = json.loads(p.read_text())
    props = data.get("properties", {})
    print(f"{p}: {len(props)}")
PY

Repository: constructorfabric/insight

Length of output: 11375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import subprocess
for cmd in [
    ["uv", "pip", "show", "airbyte-cdk"],
    ["python", "-m", "pip", "show", "airbyte-cdk"],
    ["pip", "show", "airbyte-cdk"],
]:
    result = subprocess.run(cmd, capture_output=True, text=True)
    print("CMD"," ".join(cmd))
    print("rc", result.returncode)
    print(result.stdout.splitlines()[:12] or result.stderr.splitlines()[:12])
    print()
PY

Repository: constructorfabric/insight

Length of output: 679


🌐 Web query:

airbyte-cdk source_streams.py read_records get_json_schema call path

💡 Result:

In the Airbyte Python CDK, the get_json_schema() method is the primary mechanism for defining a stream's schema [1][2]. It is not strictly part of the read_records call path for fetching data, but rather a configuration method used by the Airbyte platform to understand the data structure of the stream before and during the synchronization process [1][2][3]. The call path for read_records and get_json_schema can be summarized as follows: 1. Initialization and Catalog Processing: When an Airbyte sync starts, the source reads a ConfiguredAirbyteCatalog [3]. The Airbyte platform uses the get_json_schema() method of each Stream instance to validate or determine the schema for the requested streams [1][4]. 2. Stream Definition: By default, get_json_schema() looks for a JSON file in the schemas/ directory matching the snake-cased name of the stream class (e.g., schemas/employee_benefits.json for a class named EmployeeBenefits) [1][2]. Developers can override this method to return a dictionary directly if they need to generate the schema dynamically [2][5]. 3. The read_records Execution: The read_records method is responsible for fetching and emitting data [1][6]. It is distinct from get_json_schema(). During a sync, the source class iterates through the catalog, identifies the stream instance, and invokes read_records to perform the actual data extraction (e.g., hitting an API endpoint) [6][3]. 4. Interaction: While read_records typically does not call get_json_schema() itself to fetch data, custom implementations may sometimes invoke get_json_schema() within internal logic to inspect properties for filtering or building requests [4]. However, these are implementation-specific uses rather than a required part of the standard CDK data-reading call path [1][4]. In summary, get_json_schema() informs the system about the data structure, while read_records() implements the logic to stream the data, and they function as independent components of the Stream lifecycle [1][6][7].

Citations:


Cache declared_fields before iterating records.

read_records calls self.declared_fields for each JSON record; this performs declared_field_names(self.name), which currently rebuilds the frozenset over _load(stream_name)["properties"] every time. User has 185 schema properties, so hoist self.declared_fields outside the loop and cache declared_field_names() in schema_loader.py.

get_json_schema() is part of catalog/schema discovery, not the per-record read path, and already calls _load(stream_name) once before deepcopy/injection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/connectors/crm/salesforce/source_salesforce/streams.py` around
lines 113 - 129, Cache the declared field set before the record loop in
read_records and reuse it when calling envelope, rather than resolving
self.declared_fields for each record. Update declared_field_names in
schema_loader.py to cache the frozenset derived from
_load(stream_name)["properties"], while leaving get_json_schema’s existing
single _load call unchanged.

Comment on lines +35 to +44
def test_first_use_authenticates(self, sf):
sf.login = Mock(side_effect=lambda: setattr(sf, "access_token", "tok"))
assert sf._token_provider.get_token() == "tok"
sf.login.assert_called_once()

def test_first_login_failure_surfaces(self, sf):
sf.login = Mock(side_effect=RequestException("no creds"))
with pytest.raises(RequestException):
sf._token_provider.get_token()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add type hints to the changed test definitions.

The changed test methods need typed fixture parameters and -> None. _sf_stub needs a -> Salesforce return annotation.

  • src/ingestion/connectors/crm/salesforce/tests/test_api.py#L35-L44: add fixture parameter types and -> None.
  • src/ingestion/connectors/crm/salesforce/tests/test_api.py#L184-L190: add fixture parameter types and -> None.
  • src/ingestion/connectors/crm/salesforce/tests/test_api.py#L213-L238: add fixture parameter types and -> None.
  • src/ingestion/connectors/crm/salesforce/tests/test_api.py#L261-L277: add fixture parameter types and -> None.
  • src/ingestion/connectors/crm/salesforce/tests/test_source.py#L201-L208: add fixture parameter types and -> None.
  • src/ingestion/connectors/crm/salesforce/tests/test_source.py#L233-L255: add -> None to the test method and -> Salesforce to _sf_stub.
  • src/ingestion/connectors/crm/salesforce/tests/test_source.py#L270-L274: add fixture parameter types and -> None.
  • src/ingestion/connectors/crm/salesforce/tests/test_source.py#L332-L340: add fixture parameter types and -> None.

As per coding guidelines, "src/ingestion/**/*.{py,pyi}: Put type hints on every function and method signature; do not allow bare Any to escape."

📍 Affects 2 files
  • src/ingestion/connectors/crm/salesforce/tests/test_api.py#L35-L44 (this comment)
  • src/ingestion/connectors/crm/salesforce/tests/test_api.py#L184-L190
  • src/ingestion/connectors/crm/salesforce/tests/test_api.py#L213-L238
  • src/ingestion/connectors/crm/salesforce/tests/test_api.py#L261-L277
  • src/ingestion/connectors/crm/salesforce/tests/test_source.py#L201-L208
  • src/ingestion/connectors/crm/salesforce/tests/test_source.py#L233-L255
  • src/ingestion/connectors/crm/salesforce/tests/test_source.py#L270-L274
  • src/ingestion/connectors/crm/salesforce/tests/test_source.py#L332-L340
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/connectors/crm/salesforce/tests/test_api.py` around lines 35 -
44, Update every changed test method in
src/ingestion/connectors/crm/salesforce/tests/test_api.py ranges 35-44, 184-190,
213-238, and 261-277, and
src/ingestion/connectors/crm/salesforce/tests/test_source.py ranges 201-208,
233-255, 270-274, and 332-340, adding appropriate fixture parameter types and ->
None return annotations. In test_source.py range 233-255, also annotate _sf_stub
with -> Salesforce; ensure all affected function and method signatures are fully
typed without bare Any escaping.

Source: Coding guidelines

Comment on lines +86 to 90
The full cycle — throwaway ClickHouse, fresh `.env`, bootstrap, snapshot re-dump, field-parity audit, cleanup — as a single copy-paste. No credentials needed: every connector discovers on fake config values. **Overwrites `.env`** next to the scripts.

```bash
cd src/ingestion/scripts/bootstrap-db

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the fork pull request documentation.

The workflow now runs validation for fork pull requests without CRM secrets. Line 132 still says that fork pull requests are skipped because the lane needs repository secrets. Update Line 132 to state that forks receive validation and the regenerated snapshot artifact, while regen-pr remains same-repository only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/scripts/bootstrap-db/README.md` around lines 86 - 90, Update
the fork pull request documentation near the workflow description to state that
fork PRs run validation and receive the regenerated snapshot artifact without
CRM secrets, while limiting regen-pr to same-repository pull requests.

Comment on lines +12 to +20
ALTER TABLE silver.class_crm_accounts DROP COLUMN IF EXISTS custom_fields;

ALTER TABLE silver.class_crm_activities DROP COLUMN IF EXISTS custom_fields;

ALTER TABLE silver.class_crm_contacts DROP COLUMN IF EXISTS custom_fields;

ALTER TABLE silver.class_crm_deals DROP COLUMN IF EXISTS custom_fields;

ALTER TABLE silver.class_crm_users DROP COLUMN IF EXISTS custom_fields;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Migrate existing bronze tables to raw_data.

CREATE TABLE IF NOT EXISTS does not modify existing bronze tables. This deploy path drops custom_fields only from staging and silver tables. Existing bronze_hubspot.* and bronze_salesforce.* tables retain custom_fields and lack raw_data.

Before connectors emit the new envelope, alter every existing bronze table to drop custom_fields and add raw_data after collected_at. This preserves the required positional column order.

  • src/ingestion/scripts/migrations/20260805000000_crm-drop-custom-fields.sql#L12-L20: add the bronze schema upgrade to the deploy migration path.
  • src/ingestion/scripts/apply-ch-migrations.sh#L102-L114: include HubSpot and Salesforce bronze relations in contract healing.
  • src/ingestion/scripts/connectors-ddl/hubspot.sql#L31-L619: keep these declarations as the target schema and provide matching ALTER TABLE statements for existing tables.
  • src/ingestion/scripts/connectors-ddl/salesforce.sql#L77-L736: keep these declarations as the target schema and provide matching ALTER TABLE statements for existing tables.
🧰 Tools
🪛 Squawk (2.61.0)

[warning] 12-12: Dropping a column may break existing clients.

(ban-drop-column)


[warning] 14-14: Dropping a column may break existing clients.

(ban-drop-column)


[warning] 16-16: Dropping a column may break existing clients.

(ban-drop-column)


[warning] 18-18: Dropping a column may break existing clients.

(ban-drop-column)


[warning] 20-20: Dropping a column may break existing clients.

(ban-drop-column)

📍 Affects 4 files
  • src/ingestion/scripts/migrations/20260805000000_crm-drop-custom-fields.sql#L12-L20 (this comment)
  • src/ingestion/scripts/apply-ch-migrations.sh#L102-L114
  • src/ingestion/scripts/connectors-ddl/hubspot.sql#L31-L619
  • src/ingestion/scripts/connectors-ddl/salesforce.sql#L77-L736
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/scripts/migrations/20260805000000_crm-drop-custom-fields.sql`
around lines 12 - 20, Migrate existing bronze tables from custom_fields to
raw_data while preserving raw_data immediately after collected_at. In
src/ingestion/scripts/migrations/20260805000000_crm-drop-custom-fields.sql
(lines 12-20), add ALTER statements for every bronze_hubspot and
bronze_salesforce relation; in src/ingestion/scripts/apply-ch-migrations.sh
(lines 102-114), include those relations in contract healing. Keep the target
declarations in src/ingestion/scripts/connectors-ddl/hubspot.sql (lines 31-619)
and src/ingestion/scripts/connectors-ddl/salesforce.sql (lines 77-736), adding
matching existing-table ALTER statements there.

@aleksdotbar
aleksdotbar added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit 0ee0fad Aug 5, 2026
53 checks passed
@aleksdotbar
aleksdotbar deleted the refactor/crm-static-schema-raw-data branch August 5, 2026 06:52
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.

3 participants