loopable / platform /harness /meta_store.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
51693ae verified
Raw
History Blame Contribute Delete
28.8 kB
"""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())