@@ -138,18 +138,39 @@ def _create_initial_state(
138138 store_ids : list [str ],
139139 start_date : AwareDatetime ,
140140 use_backfill : bool ,
141+ use_multi_store_keys : bool ,
141142) -> ResourceState :
142- """Create initial state for a resource. State is always dict-based, keyed by store_id."""
143+ """Create initial state for a resource.
144+
145+ State format depends on use_multi_store_keys:
146+ - False (1 store): Flat state format {"inc": {"cursor": "..."}}
147+ - True (multiple stores): Dict-based {"inc": {"store_id": {"cursor": "..."}}}
148+
149+ This follows the same pattern as source-stripe-native: flat state for single account,
150+ dict-based for multiple accounts. Transitioning requires backfill.
151+ """
143152 cutoff = datetime .now (tz = UTC )
144153
145- if use_backfill :
154+ if use_multi_store_keys :
155+ # Dict-based state for multiple stores
156+ if use_backfill :
157+ return ResourceState (
158+ inc = {sid : ResourceState .Incremental (cursor = cutoff ) for sid in store_ids }, # type: ignore[arg-type]
159+ backfill = {sid : ResourceState .Backfill (next_page = dt_to_str (start_date ), cutoff = cutoff ) for sid in store_ids }, # type: ignore[arg-type]
160+ )
146161 return ResourceState (
147- inc = {sid : ResourceState .Incremental (cursor = cutoff ) for sid in store_ids }, # type: ignore[arg-type]
148- backfill = {sid : ResourceState .Backfill (next_page = dt_to_str (start_date ), cutoff = cutoff ) for sid in store_ids }, # type: ignore[arg-type]
162+ inc = {sid : ResourceState .Incremental (cursor = start_date ) for sid in store_ids }, # type: ignore[arg-type]
163+ )
164+ else :
165+ # Flat state for single store (backward compatible with legacy)
166+ if use_backfill :
167+ return ResourceState (
168+ inc = ResourceState .Incremental (cursor = cutoff ),
169+ backfill = ResourceState .Backfill (next_page = dt_to_str (start_date ), cutoff = cutoff ),
170+ )
171+ return ResourceState (
172+ inc = ResourceState .Incremental (cursor = start_date ),
149173 )
150- return ResourceState (
151- inc = {sid : ResourceState .Incremental (cursor = start_date ) for sid in store_ids }, # type: ignore[arg-type]
152- )
153174
154175
155176def _reconcile_connector_state (
@@ -161,48 +182,53 @@ def _reconcile_connector_state(
161182) -> None :
162183 """Reconcile connector state to ensure all stores have proper state entries.
163184
164- This handles adding new stores to existing dict-based state. Legacy flat state
165- migration is handled earlier in ShopifyOpen.migrate_legacy_state() before
166- Pydantic validation, which prevents hybrid state from being created.
185+ This follows the pattern from source-stripe-native: only add new stores to
186+ existing dict-based state. Flat state (single store) is left unchanged.
187+
188+ State format is determined by _create_initial_state based on use_multi_store_keys:
189+ - Single store: flat state, no reconciliation needed
190+ - Multiple stores: dict-based state, add new stores as needed
167191
168192 Args:
169193 store_ids: List of store IDs that should have state entries.
170194 binding: The capture binding being processed.
171- state: The current state (always dict-based after ShopifyOpen migration) .
195+ state: The current state.
172196 initial_state: The initial state template for new entries.
173197 task: The task for logging and checkpointing.
174198 """
175- # State should always be dict-based at this point (migration happens in ShopifyOpen)
176- if not isinstance (state .inc , dict ) or not isinstance (initial_state .inc , dict ):
177- return
178-
179- should_checkpoint = False
180-
181- for store_id in store_ids :
182- inc_state_exists = store_id in state .inc
183- backfill_state_exists = (
184- isinstance (state .backfill , dict ) and store_id in state .backfill
185- )
186-
187- if not inc_state_exists and not backfill_state_exists :
188- task .log .info (f"Initializing new state for store: { store_id } " )
189- state .inc [store_id ] = deepcopy (initial_state .inc [store_id ])
190- if isinstance (state .backfill , dict ) and isinstance (initial_state .backfill , dict ):
191- state .backfill [store_id ] = deepcopy (initial_state .backfill [store_id ])
192- should_checkpoint = True
193- elif not inc_state_exists and backfill_state_exists :
194- # Edge case: backfill exists but incremental doesn't
195- task .log .info (
196- f"Reinitializing state for store { store_id } due to missing incremental state."
199+ # Only reconcile dict-based state (multiple stores)
200+ # Flat state (single store) doesn't need reconciliation
201+ if (
202+ isinstance (state .inc , dict )
203+ and isinstance (initial_state .inc , dict )
204+ ):
205+ should_checkpoint = False
206+
207+ for store_id in store_ids :
208+ inc_state_exists = store_id in state .inc
209+ backfill_state_exists = (
210+ isinstance (state .backfill , dict ) and store_id in state .backfill
197211 )
198- state .inc [store_id ] = deepcopy (initial_state .inc [store_id ])
199- if isinstance (state .backfill , dict ) and isinstance (initial_state .backfill , dict ):
200- state .backfill [store_id ] = deepcopy (initial_state .backfill [store_id ])
201- should_checkpoint = True
202212
203- if should_checkpoint :
204- task .log .info (f"Checkpointing reconciled state for { binding .stateKey } ." )
205- task .checkpoint (ConnectorState (bindingStateV1 = {binding .stateKey : state }))
213+ if not inc_state_exists and not backfill_state_exists :
214+ task .log .info (f"Initializing new state for store: { store_id } " )
215+ state .inc [store_id ] = deepcopy (initial_state .inc [store_id ])
216+ if isinstance (state .backfill , dict ) and isinstance (initial_state .backfill , dict ):
217+ state .backfill [store_id ] = deepcopy (initial_state .backfill [store_id ])
218+ should_checkpoint = True
219+ elif not inc_state_exists and backfill_state_exists :
220+ # Edge case: backfill exists but incremental doesn't
221+ task .log .info (
222+ f"Reinitializing state for store { store_id } due to missing incremental state."
223+ )
224+ state .inc [store_id ] = deepcopy (initial_state .inc [store_id ])
225+ if isinstance (state .backfill , dict ) and isinstance (initial_state .backfill , dict ):
226+ state .backfill [store_id ] = deepcopy (initial_state .backfill [store_id ])
227+ should_checkpoint = True
228+
229+ if should_checkpoint :
230+ task .log .info (f"Checkpointing reconciled state for { binding .stateKey } ." )
231+ task .checkpoint (ConnectorState (bindingStateV1 = {binding .stateKey : state }))
206232
207233
208234async def _check_plan_allows_pii (http : HTTPMixin , url : str , log : Logger ) -> bool :
@@ -277,15 +303,19 @@ async def all_resources(
277303) -> list [Resource ]:
278304 """Discover all available resources across all configured stores.
279305
280- State is always dict-based internally, keyed by store_id.
281- Collection keys depend on use_multi_store_keys:
282- - True: ["/_meta/store", "/id"] (new captures and multi-store)
283- - False: ["/id"] (legacy single-store captures for backward compatibility)
306+ State and collection key format depends on use_multi_store_keys:
307+ - True: Dict-based state {"inc": {"store_id": {...}}}, keys ["/_meta/store", "/id"]
308+ - False: Flat state {"inc": {"cursor": "..."}}, keys ["/id"]
309+
310+ This follows the pattern from source-stripe-native: new captures always use
311+ dict-based state, legacy single-store captures continue with flat state,
312+ and transitioning requires backfill.
284313
285314 Args:
286- use_multi_store_keys: Whether to include /_meta/store in collection keys.
287- New captures should always pass True. Only pass False for legacy
288- single-store captures that haven't added additional stores.
315+ use_multi_store_keys: Whether to use multi-store format.
316+ True: New captures, existing multi-store captures, or legacy captures
317+ transitioning to multiple stores (after backfill acknowledgment).
318+ False: Legacy single-store captures (backward compatibility).
289319 """
290320 # Build store contexts
291321 store_contexts : dict [str , dict ] = {}
@@ -330,7 +360,7 @@ async def all_resources(
330360 continue
331361
332362 use_backfill = not model .SHOULD_USE_BULK_QUERIES and model .SORT_KEY is not None
333- initial_state = _create_initial_state (stores_with_access , config .start_date , use_backfill )
363+ initial_state = _create_initial_state (stores_with_access , config .start_date , use_backfill , use_multi_store_keys )
334364
335365 def create_open_fn (
336366 model : type [ShopifyGraphQLResource ],
0 commit comments