Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions airbyte-integrations/connectors/source-granola/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
10 changes: 3 additions & 7 deletions airbyte-integrations/connectors/source-granola/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
}
Expand Down
8 changes: 6 additions & 2 deletions airbyte-integrations/connectors/source-granola/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion airbyte-integrations/connectors/source-granola/metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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}


Expand All @@ -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)
Expand All @@ -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
24 changes: 24 additions & 0 deletions docs/integrations/sources/granola-migrations.md
Original file line number Diff line number Diff line change
@@ -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

<MigrationGuide />
1 change: 1 addition & 0 deletions docs/integrations/sources/granola.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading