diff --git a/airbyte-integrations/connectors/source-granola/AGENTS.md b/airbyte-integrations/connectors/source-granola/AGENTS.md index 1c73c4328aa1..1b1b1842ad2d 100644 --- a/airbyte-integrations/connectors/source-granola/AGENTS.md +++ b/airbyte-integrations/connectors/source-granola/AGENTS.md @@ -6,13 +6,9 @@ For general guidance on contributing to Airbyte connectors, see the [Connector D ## Incremental Stream Considerations -The Granola API connector has 2 streams: `notes` (incremental with `created_at` cursor) and `detailed_notes` (child of notes via `SubstreamPartitionRouter`). No FR parent streams remain. +The Granola API connector has 2 streams: `notes` (incremental with `updated_at` cursor) and `detailed_notes` (child of notes via `SubstreamPartitionRouter`). No FR parent streams remain. | Stream | Volume Tier | Relationship | Cursor Field | API Incremental Support | Current Status | Notes | |---|---|---|---|---|---|---| -| notes | medium | top-level parent | created_at | created_at | incremental | | -| detailed_notes | medium | child | none | none | deferred_child | | - -### Future incremental stream candidates - -- **Child streams (1 streams):** `detailed_notes` — partitioned via `SubstreamPartitionRouter`. A follow-up session should evaluate incremental support. +| notes | medium | top-level parent | updated_at | updated_after | incremental | | +| detailed_notes | medium | child | none | none | full child re-read | `incremental_dependency` is not enabled because it could skip detail updates when a parent cursor does not advance; all parent notes are re-read to keep details complete. | diff --git a/airbyte-integrations/connectors/source-granola/CONTRIBUTING.md b/airbyte-integrations/connectors/source-granola/CONTRIBUTING.md index 80b90b69fbb0..f0ee9b0715e5 100644 --- a/airbyte-integrations/connectors/source-granola/CONTRIBUTING.md +++ b/airbyte-integrations/connectors/source-granola/CONTRIBUTING.md @@ -4,13 +4,9 @@ For general guidance on contributing to Airbyte connectors, see the [Connector D ## Incremental Stream Considerations -The Granola API connector has 2 streams: `notes` (incremental with `created_at` cursor) and `detailed_notes` (child of notes via `SubstreamPartitionRouter`). No FR parent streams remain. +The Granola API connector has 2 streams: `notes` (incremental with `updated_at` cursor) and `detailed_notes` (child of notes via `SubstreamPartitionRouter`). No FR parent streams remain. | Stream | Volume Tier | Relationship | Cursor Field | API Incremental Support | Current Status | Notes | |---|---|---|---|---|---|---| -| notes | medium | top-level parent | created_at | created_at | incremental | | -| detailed_notes | medium | child | none | none | deferred_child | | - -### Future incremental stream candidates - -- **Child streams (1 streams):** `detailed_notes` — partitioned via `SubstreamPartitionRouter`. A follow-up session should evaluate incremental support. +| notes | medium | top-level parent | updated_at | updated_after | incremental | | +| detailed_notes | medium | child | none | none | full child re-read | `incremental_dependency` is not enabled because it could skip detail updates when a parent cursor does not advance; all parent notes are re-read to keep details complete. | diff --git a/airbyte-integrations/connectors/source-granola/integration_tests/sample_state.json b/airbyte-integrations/connectors/source-granola/integration_tests/sample_state.json index d8387fafe34e..eeb1aad5e006 100644 --- a/airbyte-integrations/connectors/source-granola/integration_tests/sample_state.json +++ b/airbyte-integrations/connectors/source-granola/integration_tests/sample_state.json @@ -2,7 +2,7 @@ { "type": "STREAM", "stream": { - "stream_state": { "created_at": "2024-01-01" }, + "stream_state": { "updated_at": "2024-01-01T00:00:00Z" }, "stream_descriptor": { "name": "notes" } } } diff --git a/airbyte-integrations/connectors/source-granola/manifest.yaml b/airbyte-integrations/connectors/source-granola/manifest.yaml index effe82f99aa1..31ff4b8d62ec 100644 --- a/airbyte-integrations/connectors/source-granola/manifest.yaml +++ b/airbyte-integrations/connectors/source-granola/manifest.yaml @@ -61,7 +61,7 @@ definitions: stop_condition: "{{ not response.get('cursor') or not response.get('hasMore', False) }}" incremental_sync: type: DatetimeBasedCursor - cursor_field: created_at + cursor_field: updated_at cursor_datetime_formats: - "%Y-%m-%dT%H:%M:%SZ" - "%Y-%m-%dT%H:%M:%S.%fZ" @@ -74,7 +74,7 @@ definitions: start_time_option: type: RequestOption inject_into: request_parameter - field_name: created_after + field_name: updated_after schema_loader: type: InlineSchemaLoader schema: @@ -196,6 +196,10 @@ schemas: type: string description: The creation time of the note in ISO 8601 format. format: date-time + updated_at: + type: string + description: The last update time of the note in ISO 8601 format. + format: date-time additionalProperties: true required: - id diff --git a/airbyte-integrations/connectors/source-granola/metadata.yaml b/airbyte-integrations/connectors/source-granola/metadata.yaml index 418f6b315731..f649099a53e9 100644 --- a/airbyte-integrations/connectors/source-granola/metadata.yaml +++ b/airbyte-integrations/connectors/source-granola/metadata.yaml @@ -7,7 +7,7 @@ data: connectorSubtype: api connectorType: source definitionId: 9023923c-002f-4131-9554-3ebdf56540a4 - dockerImageTag: 0.2.12 + dockerImageTag: 0.3.0 dockerRepository: airbyte/source-granola documentationUrl: https://docs.airbyte.com/integrations/sources/granola githubIssueLabel: source-granola @@ -46,6 +46,14 @@ data: url: https://docs.granola.ai/introduction type: api_reference releases: + breakingChanges: + 0.3.0: + message: "The `notes` stream now uses `updated_at` as the cursor field instead of `created_at`, so edits to existing notes are captured during incremental syncs. Users syncing the `notes` stream must refresh the source schema, clear the `notes` stream's data, and re-sync. See the [migration guide](https://docs.airbyte.com/integrations/sources/granola-migrations) for details." + upgradeDeadline: "2026-08-26" + scopedImpact: + - scopeType: stream + impactedScopes: + - notes rolloutConfiguration: enableProgressiveRollout: false metadataSpecVersion: "1.0" diff --git a/airbyte-integrations/connectors/source-granola/unit_tests/test_notes_incremental_sync.py b/airbyte-integrations/connectors/source-granola/unit_tests/test_notes_incremental_sync.py index d023dcd3c930..827db50a6fb2 100644 --- a/airbyte-integrations/connectors/source-granola/unit_tests/test_notes_incremental_sync.py +++ b/airbyte-integrations/connectors/source-granola/unit_tests/test_notes_incremental_sync.py @@ -34,21 +34,8 @@ def _parse_timestamp(value: str) -> datetime: def _notes_response(request, notes): query = parse_qs(urlparse(request.url).query) - created_after = _parse_timestamp(query["created_after"][0]) - created_before_value = query.get("created_before", [None])[0] - created_before = ( - _parse_timestamp(created_before_value) - if created_before_value and "T" in created_before_value - else datetime.strptime(created_before_value, "%Y-%m-%d").replace(tzinfo=timezone.utc) - if created_before_value - else None - ) - filtered_notes = [ - note - for note in notes - if _parse_timestamp(note["created_at"]) >= created_after - and (created_before is None or _parse_timestamp(note["created_at"]) < created_before) - ] + updated_after = _parse_timestamp(query["updated_after"][0]) + filtered_notes = [note for note in notes if _parse_timestamp(note["updated_at"]) >= updated_after] return {"notes": filtered_notes, "cursor": "", "hasMore": False} @@ -64,9 +51,21 @@ def _read_notes(notes, config=_CONFIG, state=None): def test_boundary_date_note_is_not_dropped(): notes = [ - {"id": "before-boundary", "created_at": "2026-01-29T23:59:59Z"}, - {"id": "on-boundary", "created_at": "2026-01-30T00:00:00.123Z"}, - {"id": "after-boundary", "created_at": "2026-01-31T00:00:00Z"}, + { + "id": "before-boundary", + "created_at": "2026-01-29T23:59:59Z", + "updated_at": "2026-01-29T23:59:59Z", + }, + { + "id": "on-boundary", + "created_at": "2026-01-30T00:00:00.123Z", + "updated_at": "2026-01-30T00:00:00.123Z", + }, + { + "id": "after-boundary", + "created_at": "2026-01-31T00:00:00Z", + "updated_at": "2026-01-31T00:00:00Z", + }, ] output, _ = _read_notes(notes) @@ -79,26 +78,82 @@ def test_boundary_date_note_is_not_dropped(): def test_notes_request_is_unbounded(): - notes = [{"id": "note-1", "created_at": "2026-01-02T00:00:00Z"}] + notes = [ + { + "id": "note-1", + "created_at": "2026-01-02T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + } + ] output, requests = _read_notes(notes) assert len(output.records) == 1 assert len(requests) == 1 query = parse_qs(urlparse(requests[0].url).query) - assert query["created_after"] == ["2026-01-01T00:00:00Z"] + assert query["updated_after"] == ["2026-01-01T00:00:00Z"] + assert "created_after" not in query assert "created_before" not in query def test_legacy_date_state_is_accepted_and_emits_iso_state(): - notes = [{"id": "note-2", "created_at": "2026-06-02T12:34:56.123Z"}] - state = StateBuilder().with_stream_state("notes", {"created_at": "2026-06-01"}).build() + notes = [ + { + "id": "note-2", + "created_at": "2026-06-02T12:34:56.123Z", + "updated_at": "2026-06-02T12:34:56.123Z", + } + ] + state = StateBuilder().with_stream_state("notes", {"updated_at": "2026-06-01"}).build() config = {"api_key": "test-api-key", "start_date": "2026-01-01"} output, requests = _read_notes(notes, config=config, state=state) query = parse_qs(urlparse(requests[0].url).query) - assert query["created_after"] == ["2026-06-01T00:00:00Z"] + assert query["updated_after"] == ["2026-06-01T00:00:00Z"] assert len(output.records) == 1 latest_state = output.state_messages[-1].state.stream.stream_state - assert latest_state.created_at == "2026-06-02T12:34:56Z" + assert latest_state.updated_at == "2026-06-02T12:34:56Z" + + +def test_updated_at_cursor_replicates_later_edits_only(): + notes = [ + { + "id": "edited-note", + "created_at": "2026-05-01T00:00:00Z", + "updated_at": "2026-06-02T00:00:00Z", + }, + { + "id": "stale-note", + "created_at": "2026-05-01T00:00:00Z", + "updated_at": "2026-05-31T23:59:59Z", + }, + ] + state = StateBuilder().with_stream_state("notes", {"updated_at": "2026-06-01T00:00:00Z"}).build() + + output, requests = _read_notes(notes, state=state) + + query = parse_qs(urlparse(requests[0].url).query) + assert query["updated_after"] == ["2026-06-01T00:00:00Z"] + assert "created_after" not in query + assert "created_before" not in query + assert [record.record.data["id"] for record in output.records] == ["edited-note"] + latest_state = output.state_messages[-1].state.stream.stream_state + assert latest_state.updated_at == "2026-06-02T00:00:00Z" + + +def test_created_at_state_restarts_from_start_date(): + notes = [ + { + "id": "note-after-reset", + "created_at": "2026-01-02T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + } + ] + state = StateBuilder().with_stream_state("notes", {"created_at": "2026-06-01"}).build() + + output, requests = _read_notes(notes, state=state) + + query = parse_qs(urlparse(requests[0].url).query) + assert query["updated_after"] == ["2026-01-01T00:00:00Z"] + assert len(output.records) == 1 diff --git a/docs/integrations/sources/granola-migrations.md b/docs/integrations/sources/granola-migrations.md new file mode 100644 index 000000000000..50ade74db38b --- /dev/null +++ b/docs/integrations/sources/granola-migrations.md @@ -0,0 +1,24 @@ +import MigrationGuide from '@site/static/_migration_guides_upgrade_guide.md'; + +# Granola Migration Guide + +## Upgrading to 0.3.0 + +The `notes` stream now uses `updated_at` as its incremental cursor field instead of `created_at`. Incremental syncs now include notes edited after their original creation. + +### Who is affected + +Users syncing the `notes` stream in incremental mode are affected. The stored state from the previous version uses the `created_at` cursor and is incompatible with the new `updated_at` cursor. + +### Steps to migrate + +1. Update the connector to version 0.3.0. +2. Refresh the source schema. +3. Clear the `notes` stream's data. +4. Re-sync the `notes` stream. + +The `detailed_notes` stream remains a full child re-read over all parent notes. It does not use `incremental_dependency` because that option could skip detail updates when a parent cursor does not advance after Granola rewrites a note summary. + +## Connector upgrade guide + + diff --git a/docs/integrations/sources/granola.md b/docs/integrations/sources/granola.md index 4e0e6dfebe37..0b5fd65b47ea 100644 --- a/docs/integrations/sources/granola.md +++ b/docs/integrations/sources/granola.md @@ -125,6 +125,7 @@ For programmatic configuration, use these parameter names: | Version | Date | Pull Request | Subject | | :------ | :--- | :----------- | :------ | +| 0.3.0 | 2026-08-12 | [84281](https://github.com/airbytehq/airbyte/pull/84281) | Capture notes edited after their original creation during incremental syncs | | 0.2.12 | 2026-08-12 | [84279](https://github.com/airbytehq/airbyte/pull/84279) | Prevent dropping notes created on incremental window boundary dates | | 0.2.11 | 2026-08-11 | [83964](https://github.com/airbytehq/airbyte/pull/83964) | Update dependencies | | 0.2.10 | 2026-08-04 | [83481](https://github.com/airbytehq/airbyte/pull/83481) | Update dependencies |