File size: 28,779 Bytes
aef86ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
"""meta_store.py β€” the REST loader for a mirror whose only other loader is XML-RPC (W31-T48).



    python platform/harness/meta_store.py --sync                 pull into tenant #0's mirror

    python platform/harness/meta_store.py --sync --tenant gtmlab

    python platform/harness/meta_store.py --status               what is in the mirror now



β›” WHY THIS IS A SIBLING AND NOT A ROW IN `datastore.ENTITIES`. `datastore.sync_entity` is

hardwired to XML-RPC β€” it does `import core.odoo as O` and speaks `search_read` β€” so no amount of

spec data makes it fetch over HTTPS. What IS reusable is everything *below* the fetch: the

per-tenant file (`path_for`), the process-singleton connection (`connect`), and the

delete-then-insert upsert. This module borrows those and brings its own reader. **The mirror is one

store with two loaders, not two stores.**



⭐ THE SHAPE OF THE WHOLE THING, because it is the owner's actual request: *"we just need to pull

the data into our template database for Meta"*, working *"just like Odoo"*. Odoo's path is

`XML-RPC -> DuckDB mirror -> odoo_relational -> ut_odoo_* locked grids`. Meta's is

`Graph -> DuckDB mirror -> meta_relational -> ut_meta_* locked grids`. Same middle, same end, one

different first hop.



⚠ EVERY COLUMN NAME BELOW WAS MEASURED, NOT READ OFF A DOC (R8). `proto/meta-entity-fields.json`

carries the run: each level's list is what the API ACCEPTED when asked, on a real ad account.

  Ad Account 45 Β· Campaign 35 Β· Ad Set 54 Β· Ad 36 Β· Creative 58 Β· Insights 57

β›” AND `ACCEPTED` IS NOT `RETURNED`. Graph omits null fields from a response, so a 20-field ask

came back with 14 keys. Building a schema from what came back would drop ~30% of the columns with

nothing going red β€” which is exactly the "do not drop any column" instruction, broken silently.

The lists below are therefore the ASKED-AND-ACCEPTED set, and a column with no value is NULL.



⚠ `owner` IS ABSENT FROM THE AD ACCOUNT LIST AND THAT IS A REPORTED LIMIT, NOT AN OMISSION: it

answers `403 (#200) Requires business_management permission`, which this token does not carry. One

column of 285. R6's second sentence β€” a limit that cannot be removed gets stated with its cause.



β›” IDS ARE TEXT, ALWAYS. A Meta object id is a 17-digit decimal string; `120273975028650555` is

larger than 2^53, so any float or JS-number path silently corrupts it. Same ruling as `ig_id`

(W26/R3), for the same reason, and it is why every `id` column here is VARCHAR.

"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

_HERE = Path(__file__).resolve().parent
if str(_HERE.parent) not in sys.path:
    sys.path.insert(0, str(_HERE.parent))

from harness import datastore                                    # noqa: E402

GRAPH_VERSION = os.environ.get("META_GRAPH_VERSION") or "v21.0"
GRAPH = f"https://graph.facebook.com/{GRAPH_VERSION}"

#: Numeric columns, by name. Everything else is VARCHAR β€” including ids (see the header) and
#: including anything Graph returns as a nested object, which is stored as compact JSON text.
_INT = {"impressions", "reach", "clicks", "unique_clicks", "inline_link_clicks",
        "inline_post_engagement", "full_view_impressions", "estimated_ad_recallers",
        "account_status", "age", "timezone_id", "timezone_offset_hours_utc", "io_number",
        "min_daily_budget", "min_campaign_group_spend_cap"}
_DBL = {"spend", "social_spend", "ctr", "unique_ctr", "cpc", "cpm", "cpp", "frequency",
        "inline_link_click_ctr", "outbound_clicks_ctr", "amount_spent", "balance", "spend_cap",
        "daily_budget", "lifetime_budget", "budget_remaining", "bid_amount", "canvas_avg_view_time",
        "daily_min_spend_target", "daily_spend_cap", "lifetime_min_spend_target",
        "lifetime_spend_cap", "lifetime_imps"}

#: ⭐ THE MEASURED CATALOG. `edge` is the connection on the ad account; `None` means the account
#: itself. `parent` names the column that ties a row to its parent, used by nothing here and by
#: the grid links in `meta_relational`.
ACCOUNT_FIELDS = (
    "account_id account_status age amount_spent balance business_city business_country_code "
    "business_name business_state business_street business_street2 business_zip capabilities "
    "created_time currency disable_reason end_advertiser end_advertiser_name funding_source "
    "has_migrated_permissions id io_number is_attribution_spec_system_default "
    "is_direct_deals_enabled is_notifications_enabled is_personal is_prepay_account "
    "is_tax_id_required line_numbers media_agency min_campaign_group_spend_cap min_daily_budget "
    "name offsite_pixels_tos_accepted partner spend_cap tax_id tax_id_status tax_id_type "
    "timezone_id timezone_name timezone_offset_hours_utc tos_accepted user_tasks user_tos_accepted"
).split()

CAMPAIGN_FIELDS = (
    "account_id bid_strategy boosted_object_id budget_rebalance_flag budget_remaining buying_type "
    "campaign_group_active_time can_create_brand_lift_study can_use_spend_cap configured_status "
    "created_time daily_budget effective_status id is_skadnetwork_attribution issues_info "
    "last_budget_toggling_time lifetime_budget name objective pacing_type primary_attribution "
    "promoted_object smart_promotion_type source_campaign source_campaign_id special_ad_categories "
    "special_ad_category special_ad_category_country spend_cap start_time status stop_time "
    "topline_id updated_time"
).split()

ADSET_FIELDS = (
    "account_id adlabels adset_schedule asset_feed_id attribution_spec bid_adjustments bid_amount "
    "bid_constraints bid_info bid_strategy billing_event budget_remaining campaign "
    "campaign_active_time campaign_attribution campaign_id configured_status created_time "
    "creative_sequence daily_budget daily_min_spend_target daily_spend_cap destination_type "
    "effective_status end_time frequency_control_specs id instagram_actor_id is_dynamic_creative "
    "issues_info learning_stage_info lifetime_budget lifetime_imps lifetime_min_spend_target "
    "lifetime_spend_cap multi_optimization_goal_weight name optimization_goal "
    "optimization_sub_event pacing_type promoted_object recurring_budget_semantics review_feedback "
    "rf_prediction_id source_adset source_adset_id start_time status targeting "
    "targeting_optimization_types time_based_ad_rotation_id_blocks "
    "time_based_ad_rotation_intervals updated_time use_new_app_click"
).split()

AD_FIELDS = (
    "account_id ad_active_time ad_review_feedback ad_schedule_end_time ad_schedule_start_time "
    "adlabels adset adset_id bid_amount bid_info bid_type campaign campaign_id configured_status "
    "conversion_domain created_time creative demolink_hash display_sequence effective_status "
    "engagement_audience failed_delivery_checks id issues_info last_updated_by_app_id name "
    "preview_shareable_link priority recommendations source_ad source_ad_id status targeting "
    "tracking_and_conversion_with_defaults tracking_specs updated_time"
).split()

CREATIVE_FIELDS = (
    "account_id actor_id adlabels applink_treatment asset_feed_spec authorization_category body "
    "branded_content_sponsor_page_id bundle_folder_id call_to_action_type categorization_criteria "
    "category_media_source collaborative_ads_lsb_image_bank_id degrees_of_freedom_spec "
    "destination_set_id dynamic_ad_voice effective_authorization_category "
    "effective_instagram_media_id effective_object_story_id enable_direct_install "
    "enable_launch_instant_app id image_crops image_hash image_url instagram_actor_id "
    "instagram_permalink_url instagram_story_id instagram_user_id interactive_components_spec "
    "link_deep_link_url link_destination_display_url link_og_id link_url "
    "messenger_sponsored_message name object_id object_store_url object_story_id object_story_spec "
    "object_type object_url place_page_set_id platform_customizations playable_asset_id "
    "portrait_customizations product_set_id recommender_settings source_instagram_media_id status "
    "template_url template_url_spec thumbnail_id thumbnail_url title url_tags "
    "use_page_actor_override video_id"
).split()

INSIGHT_FIELDS = (
    "account_currency account_id account_name action_values actions ad_id ad_name adset_id "
    "adset_name attribution_setting buying_type campaign_id campaign_name canvas_avg_view_time "
    "clicks conversion_rate_ranking conversion_values conversions cost_per_action_type "
    "cost_per_inline_link_click cost_per_thruplay cost_per_unique_click cpc cpm cpp ctr date_start "
    "date_stop engagement_rate_ranking estimated_ad_recallers frequency full_view_impressions "
    "impressions inline_link_click_ctr inline_link_clicks inline_post_engagement objective "
    "optimization_goal outbound_clicks outbound_clicks_ctr purchase_roas quality_ranking reach "
    "social_spend spend unique_clicks unique_ctr unique_outbound_clicks "
    "video_avg_time_watched_actions video_p100_watched_actions video_p25_watched_actions "
    "video_p50_watched_actions video_p75_watched_actions video_p95_watched_actions "
    "video_play_actions video_thruplay_watched_actions website_purchase_roas"
).split()

#: table -> (graph edge on the account | None, measured fields, parent column)
SPECS = {
    "meta_ad_accounts": (None, ACCOUNT_FIELDS, None),
    "meta_campaigns": ("campaigns", CAMPAIGN_FIELDS, "account_id"),
    "meta_adsets": ("adsets", ADSET_FIELDS, "campaign_id"),
    "meta_ads": ("ads", AD_FIELDS, "adset_id"),
    "meta_creatives": ("adcreatives", CREATIVE_FIELDS, "account_id"),
}

#: The daily Insights grain (R2). One row per (ad, day) β€” the id is synthesised because Insights
#: has no id of its own, and it must be STABLE so a re-run updates instead of appending.
INSIGHTS_TABLE = "meta_insights_daily"
INSIGHTS_LEVEL = os.environ.get("META_INSIGHTS_LEVEL") or "ad"

#: β›” INSIGHTS IS FETCHED IN TIME SLICES, AND THE REASON IS MEASURED, NOT DEFENSIVE. All 57 fields
#: at ad level over `last_90d` with a 100-row page answers **HTTP 500 "An unknown error occurred"**
#: β€” Graph's way of saying the synchronous query is too heavy (the async report-run API is the
#: other answer, and it costs a poll loop this does not need). The SAME 57 fields over 7 days at
#: page 25 answer 200. So the window is walked in slices with every column intact:
#:   57 fields Β· ad level Β· 7d Β· limit 25   -> 200, 25 rows
#:   57 fields Β· account level Β· 7d          -> 200, 7 rows
#: ⚠ Narrowing the FIELD list would also have "fixed" it, and that is the wrong fix twice over β€”
#: it drops columns R2 requires, and it does so invisibly.
INSIGHTS_DAYS = int(os.environ.get("META_INSIGHTS_DAYS") or 90)
INSIGHTS_SLICE = int(os.environ.get("META_INSIGHTS_SLICE_DAYS") or 7)
INSIGHTS_PAGE = int(os.environ.get("META_INSIGHTS_PAGE") or 25)


def _slices(days, size):
    """[(since, until)] covering the last `days`, oldest first, in `size`-day windows."""
    from datetime import date, timedelta
    end = date.today()
    start = end - timedelta(days=max(1, days) - 1)
    out, cur = [], start
    while cur <= end:
        stop = min(cur + timedelta(days=max(1, size) - 1), end)
        out.append((cur.isoformat(), stop.isoformat()))
        cur = stop + timedelta(days=1)
    return out

#: ⚠ A REAL CEILING, AND R6's SECOND SENTENCE APPLIES TO IT. Graph pages at 25-100 rows; this is
#: the number of PAGES a single edge may walk before the loader stops and SAYS it stopped. It is
#: not a row cap on a connected source (R6 forbids that) β€” it is a runaway guard, and reaching it
#: is reported as a problem, never absorbed.
MAX_PAGES = int(os.environ.get("META_MAX_PAGES") or 200)
PAGE = int(os.environ.get("META_PAGE_SIZE") or 100)


class MetaError(RuntimeError):
    """A Graph refusal carrying Meta's own words. Safe to print: no token ever reaches it."""


def token():
    """The Meta token from the environment or gitignored `platform/.env`; "" when absent.



    ⚠ Same resolver `aios-web/api/connectors_meta.py` uses. Duplicated deliberately and minimally:

    `platform/` must not import from `aios-web/api/`, which is the layering rule this repo keeps

    (`core` never imports up). Fifteen lines is the price of that boundary.

    """
    tok = os.environ.get("META_ADS_ACCESS_TOKEN") or ""
    if tok:
        return tok.strip()
    env = _HERE.parent / ".env"
    if env.exists():
        for line in env.read_text(encoding="utf-8", errors="replace").splitlines():
            if line.strip().startswith("META_ADS_ACCESS_TOKEN"):
                _, _, v = line.partition("=")
                return v.strip().strip('"').strip("'")
    return ""


def _get(path, tok, **params):
    params["access_token"] = tok
    url = f"{GRAPH}/{path.lstrip('/')}?" + urllib.parse.urlencode(params)
    req = urllib.request.Request(url, headers={"User-Agent": "aios-meta-store/1"})
    try:
        with urllib.request.urlopen(req, timeout=120) as r:
            return json.loads(r.read().decode("utf-8", "replace"))
    except urllib.error.HTTPError as e:
        try:
            msg = (json.loads(e.read().decode("utf-8", "replace")).get("error") or {}
                   ).get("message") or ""
        except Exception:
            msg = ""
        raise MetaError(f"HTTP {e.code} on /{path.lstrip('/')}: {msg[:200]}") from None
    except Exception as e:
        raise MetaError(f"{type(e).__name__} on /{path.lstrip('/')}") from None


#: Graph's own words when a page is too heavy. It arrives as an HTTP **500**, not a 4xx, which is
#: why it cannot be treated as "the server is broken, give up".
_TOO_MUCH = "reduce the amount of data"

#: β›” THE ADS-MANAGEMENT RATE LIMIT, WHICH IS PER AD ACCOUNT AND NOT PER TOKEN. Measured: after a
#: heavy backfill Graph answers **HTTP 400 "There have been too many calls to this ad-account.
#: Wait a bit and try again."** It is a 4xx, so nothing about the status code says "retry" β€” the
#: MESSAGE is the only signal, which is why it is matched here rather than inferred from a code.
#: ⚠ It persists for minutes, so the backoff is measured in minutes and BOUNDED: after
#: `_RATE_TRIES` waits the loader STOPS and reports what it got, rather than sitting in a retry
#: loop nobody can see. A partial mirror that says it is partial beats a hung sync.
_RATE_LIMITED = "too many calls"
_RATE_TRIES = int(os.environ.get("META_RATE_TRIES") or 3)
_RATE_WAIT = int(os.environ.get("META_RATE_WAIT_S") or 90)


def _walk(path, tok, log, **params):
    """Every page of an edge, paged by CURSOR under our own parameters.



    β›” IT DOES NOT FOLLOW GRAPH'S `paging.next` URL, AND THAT IS THE WHOLE POINT. Measured: the

    first call to `/adcreatives` at limit=100 answers **HTTP 500 "Please reduce the amount of data

    you're asking for"**, the retry at limit=25 succeeds β€” and then `next` carries the ORIGINAL

    limit=100 and fails again on page 2. A backoff that cannot reach every page is not a backoff.

    Re-issuing each page ourselves with `after=<cursor>` keeps the reduced limit for the whole walk.



    ⭐ It shrinks the PAGE, never the FIELD LIST. Dropping columns to make a request fit is the

    silent omission R2 forbids, and nothing downstream could see it. Fewer rows per call, always

    every column per row.



    -> (rows, hit_page_cap)

    """
    out, pages = [], 0
    limit = int(params.pop("limit", None) or PAGE)
    after, waited = None, 0
    while True:
        call = dict(params, limit=limit)
        if after:
            call["after"] = after
        try:
            body = _get(path, tok, **call)
        except MetaError as e:
            if _TOO_MUCH in str(e) and limit > 5:
                limit = max(5, limit // 4)
                log(f"    page too heavy for /{path} - retrying at limit={limit} "
                    f"(a payload ceiling, not a row cap; every column is still asked for)")
                continue
            if _RATE_LIMITED in str(e).lower() and waited < _RATE_TRIES:
                waited += 1
                log(f"    rate-limited on /{path} (per AD ACCOUNT, not per token) - waiting "
                    f"{_RATE_WAIT}s, attempt {waited}/{_RATE_TRIES}")
                time.sleep(_RATE_WAIT)
                continue
            if _RATE_LIMITED in str(e).lower():
                log(f"    !! GIVING UP on /{path} after {waited} waits: still rate-limited. "
                    f"{len(out)} row(s) collected so far are kept. Cause: the ads-management "
                    f"limit is per ad account and persists for minutes. Fix: re-run "
                    f"`--sync --only <table>` later; the upsert is idempotent.")
                return out, True
            raise
        out.extend(body.get("data") or [])
        pages += 1
        after = ((body.get("paging") or {}).get("cursors") or {}).get("after")
        has_next = bool((body.get("paging") or {}).get("next")) and bool(after)
        if not has_next:
            return out, False
        if pages >= MAX_PAGES:
            log(f"    !! STOPPED at MAX_PAGES={MAX_PAGES} on /{path} with more pages left. "
                f"Cause: a runaway guard, not a row cap. Fix: raise META_MAX_PAGES, or narrow the "
                f"window with META_INSIGHTS_DAYS.")
            return out, True


def _cell(value):
    """One Graph value -> one DuckDB cell. Nested structures become compact JSON TEXT rather than

    being dropped: R2 says every field the API returns, and `targeting` is a field."""
    if value is None or isinstance(value, (str, int, float, bool)):
        return value
    return json.dumps(value, separators=(",", ":"), ensure_ascii=False)


def _coltype(name, rows=None):
    """The DuckDB type for one column β€” decided by the DATA when there is data, by the name only

    as a fallback.



    β›” THE NAME LIST WAS WRONG AND ONLY THE API COULD SAY SO. `cost_per_unique_click`,

    `cost_per_action_type`, `purchase_roas` and friends READ like money and are **arrays of

    action-type objects** on the Insights edge:

        [{"action_type":"outbound_click","value":"1.459854"}]

    Typed DOUBLE from `_DBL`, the insert died with `Conversion Error: Could not convert string

    '[{...}]' to DOUBLE` β€” after five entity tables had already been written, so the sync looked

    like it worked and then blew up on the last table.

    ⭐ Same principle the field CATALOG is built on, applied one layer down: **ask the response,

    do not assert from a list.** A value that ever arrives as a list or dict is JSON TEXT, because

    that is what `_cell` stores; anything else falls back to the measured name hints.

    """
    if rows:
        seen, kinds = 0, set()
        for r in rows:
            v = r.get(name)
            if v is None or v == "":
                continue
            kinds.add("json" if isinstance(v, (list, dict)) else
                      "bool" if isinstance(v, bool) else
                      "int" if isinstance(v, int) else
                      "float" if isinstance(v, float) else "str")
            seen += 1
            if seen >= 200:
                break
        if kinds:
            if "json" in kinds or "str" in kinds:
                return "VARCHAR"                              # a JSON blob, or a numeric STRING
            if kinds <= {"int", "bool"}:
                return "BIGINT" if name in _INT else ("VARCHAR" if "bool" in kinds else "BIGINT")
            return "DOUBLE"
    if name in _INT:
        return "BIGINT"
    if name in _DBL:
        return "DOUBLE"
    return "VARCHAR"                                          # ids included β€” see the header


def _ensure(con, table, fields, rows=None):
    """Create or widen the table, with every column typed from `rows` when they are available.



    ⚠ AN EXISTING COLUMN WHOSE TYPE IS NOW WRONG IS REBUILT, NOT PATCHED. DuckDB cannot retype a

    column in place, and this table is DERIVED data that can be re-pulled in minutes β€” so a type

    disagreement drops and recreates rather than limping on with a column that refuses every

    insert. The alternative is a mirror that is permanently unwritable for one bad guess.

    """
    want = {f: _coltype(f, rows) for f in fields}
    have = {r[1]: str(r[2]).upper() for r in con.execute(f"PRAGMA table_info('{table}')").fetchall()}
    if have:
        clash = [f for f, ty in want.items() if f in have and have[f] != ty
                 and not (have[f].startswith("VARCHAR") and ty == "VARCHAR")]
        if clash:
            con.execute(f"DROP TABLE {table}")
            have = {}
    if not have:
        cols = ", ".join(f"{f} {want[f]}" for f in fields)
        con.execute(f"CREATE TABLE IF NOT EXISTS {table} (id VARCHAR PRIMARY KEY, {cols})"
                    if "id" not in fields else
                    f"CREATE TABLE IF NOT EXISTS {table} ({cols})")
        return
    for f in fields:
        if f not in have:
            con.execute(f"ALTER TABLE {table} ADD COLUMN {f} {want[f]}")


def _upsert(con, table, fields, rows):
    """Delete-then-insert by id β€” the same idempotence `datastore._upsert` gives the Odoo half, so

    a re-sync updates in place and can never append a second copy of the same object."""
    if not rows:
        return 0
    cols = list(fields)
    ids = [str(r.get("id") or "") for r in rows]
    q = ",".join("?" for _ in ids)
    con.execute(f"DELETE FROM {table} WHERE id IN ({q})", ids)
    con.executemany(
        f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({', '.join('?' for _ in cols)})",
        [[_cell(r.get(c)) for c in cols] for r in rows])
    return len(rows)


def sync(tenant_key="royal-imports", tok=None, log=print, insights=True, insights_days=None):
    """Pull every level into the tenant's mirror. -> a report dict; raises only on a bad token.



    Idempotent: re-running updates rows in place. Safe to call at boot and on the resync loop,

    exactly as `odoo_relational.refresh` is.

    """
    tok = tok or token()
    # β›” THE WINDOW IS A PARAMETER, NOT AN ENVIRONMENT READ AT CALL TIME β€” and the difference cost a
    # live deploy. `INSIGHTS_DAYS` binds at IMPORT (module scope), so `main._pull_meta`'s
    # `os.environ.setdefault("META_INSIGHTS_DAYS", "7")` executed AFTER this module was already
    # imported and changed nothing: every boot pulled **90 days**, not 7, which is the slow path
    # that trips the per-ad-account rate limit and never finishes. The comment beside that call
    # claimed a short window the code could not deliver.
    # ⭐ The shape: **a knob read at import cannot be turned by a caller at runtime.** Passing it
    # makes the caller's intent effective instead of aspirational; the env var stays the DEFAULT.
    days = int(insights_days or INSIGHTS_DAYS)
    report = {"tenant": tenant_key, "tables": {}, "problems": [], "accounts": []}
    if not tok:
        report["problems"].append(
            "META_ADS_ACCESS_TOKEN is not set in the environment or in platform/.env, so nothing "
            "was pulled. This is a missing CREDENTIAL, not a missing capability.")
        return report

    path = datastore.path_for(tenant_key)
    if Path(datastore.DB_PATH) != Path(path):
        datastore.use_path(path)
    con = datastore.connect()
    log(f"  mirror: {Path(path).name}")

    accts, _ = _walk("me/adaccounts", tok, log, fields="id,name", limit=PAGE)
    report["accounts"] = [a.get("id") for a in accts]
    if not accts:
        report["problems"].append("the token reaches no ad accounts")
        return report

    # ── the five entity levels ────────────────────────────────────────────────────────────────
    for table, (edge, fields, _parent) in SPECS.items():
        rows, capped = [], False
        for acct in accts:
            aid = acct["id"]
            if edge is None:
                rows.append(_get(aid, tok, fields=",".join(fields)))
            else:
                got, hit = _walk(f"{aid}/{edge}", tok, log, fields=",".join(fields), limit=PAGE)
                rows.extend(got)
                capped = capped or hit
        _ensure(con, table, fields, rows)
        n = _upsert(con, table, fields, rows)
        total = con.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
        report["tables"][table] = {"pulled": n, "in_mirror": total, "capped": capped}
        log(f"  {table:<20} pulled {n:>6}   mirror total {total:>6}"
            + ("   !! PAGE CAP HIT" if capped else ""))
        if capped:
            report["problems"].append(f"{table}: stopped at MAX_PAGES={MAX_PAGES}")

    # ── the daily Insights grain ──────────────────────────────────────────────────────────────
    if insights:
        rows, capped = [], False
        for acct in accts:
            for since, until in _slices(days, INSIGHTS_SLICE):
                got, hit = _walk(f"{acct['id']}/insights", tok, log,
                                 fields=",".join(INSIGHT_FIELDS), level=INSIGHTS_LEVEL,
                                 time_increment="1", limit=INSIGHTS_PAGE,
                                 time_range=json.dumps({"since": since, "until": until}))
                rows.extend(got)
                capped = capped or hit
        # β›” Insights rows carry no id. The key must be STABLE across runs or every re-sync
        # appends a second copy of the same day β€” so it is composed from the grain itself.
        for r in rows:
            r["id"] = ":".join(str(r.get(k) or "") for k in
                               (f"{INSIGHTS_LEVEL}_id", "date_start", "date_stop"))
        fields = ["id"] + INSIGHT_FIELDS
        _ensure(con, INSIGHTS_TABLE, fields, rows)
        n = _upsert(con, INSIGHTS_TABLE, fields, rows)
        total = con.execute(f"SELECT count(*) FROM {INSIGHTS_TABLE}").fetchone()[0]
        report["tables"][INSIGHTS_TABLE] = {"pulled": n, "in_mirror": total, "capped": capped}
        log(f"  {INSIGHTS_TABLE:<20} pulled {n:>6}   mirror total {total:>6}"
            + ("   !! PAGE CAP HIT" if capped else ""))

    con.execute("INSERT OR REPLACE INTO _sync_state VALUES (?,?,?,?,?,?)",
                ["meta", "done", 0, f"{days}d/{INSIGHTS_SLICE}d@{INSIGHTS_LEVEL}",
                 sum(t["in_mirror"] for t in report["tables"].values()),
                 time.strftime("%Y-%m-%d %H:%M:%S")])
    return report


def status(tenant_key="royal-imports"):
    """What is in the mirror right now, per table. Never fetches."""
    path = datastore.path_for(tenant_key)
    if Path(datastore.DB_PATH) != Path(path):
        datastore.use_path(path)
    out = {}
    con = datastore.connect()
    for table in list(SPECS) + [INSIGHTS_TABLE]:
        try:
            out[table] = con.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
        except Exception:
            out[table] = None                                 # table absent = never synced
    return out


def main(argv=None):
    ap = argparse.ArgumentParser(description="Pull Meta Ads into the tenant's DuckDB mirror.")
    ap.add_argument("--sync", action="store_true")
    ap.add_argument("--status", action="store_true")
    ap.add_argument("--tenant", default="royal-imports")
    ap.add_argument("--no-insights", action="store_true")
    ap.add_argument("--insights-days", type=int, default=None,
                    help="override the Insights window for THIS run (default INSIGHTS_DAYS); the env var is the default, this is the caller's say")
    a = ap.parse_args(argv)
    if a.status:
        for k, v in status(a.tenant).items():
            print(f"  {k:<20} {'(never synced)' if v is None else v}")
        return 0
    if not a.sync:
        ap.print_help()
        return 2
    rep = sync(a.tenant, insights=not a.no_insights, insights_days=a.insights_days)
    for p in rep["problems"]:
        print("  PROBLEM:", p)
    print(f"  accounts: {len(rep['accounts'])}   tables: {len(rep['tables'])}")
    return 1 if rep["problems"] else 0


if __name__ == "__main__":
    sys.exit(main())