@vibes.diy/api-svc 5.0.1 → 5.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/cf-serve.d.ts +4 -1
  2. package/cf-serve.js +23 -0
  3. package/cf-serve.js.map +1 -1
  4. package/create-handler.d.ts +9 -1
  5. package/create-handler.js +5 -0
  6. package/create-handler.js.map +1 -1
  7. package/evento-handler-manifest.js +25 -0
  8. package/evento-handler-manifest.js.map +1 -1
  9. package/index.d.ts +8 -0
  10. package/index.js +8 -0
  11. package/index.js.map +1 -1
  12. package/intern/backend-db-callback.js +56 -0
  13. package/intern/backend-db-callback.js.map +1 -1
  14. package/intern/backend-egress.d.ts +1 -1
  15. package/intern/backend-egress.js +74 -22
  16. package/intern/backend-egress.js.map +1 -1
  17. package/intern/backend-policy-version.d.ts +1 -1
  18. package/intern/backend-policy-version.js +1 -1
  19. package/intern/delete-access-gate.d.ts +4 -0
  20. package/intern/delete-access-gate.js +46 -5
  21. package/intern/delete-access-gate.js.map +1 -1
  22. package/intern/egress-platform-list.d.ts +1 -0
  23. package/intern/egress-platform-list.js +4 -0
  24. package/intern/egress-platform-list.js.map +1 -1
  25. package/intern/emit-notification.d.ts +2 -1
  26. package/intern/emit-notification.js +1 -0
  27. package/intern/emit-notification.js.map +1 -1
  28. package/intern/ensure-app-metadata.js +4 -0
  29. package/intern/ensure-app-metadata.js.map +1 -1
  30. package/intern/ensure-chat-id.js +13 -2
  31. package/intern/ensure-chat-id.js.map +1 -1
  32. package/intern/ensure-slug-binding.js +20 -2
  33. package/intern/ensure-slug-binding.js.map +1 -1
  34. package/intern/notify-social-edge.d.ts +9 -0
  35. package/intern/notify-social-edge.js +45 -0
  36. package/intern/notify-social-edge.js.map +1 -0
  37. package/intern/pre-allocate.d.ts +1 -0
  38. package/intern/pre-allocate.js +1 -0
  39. package/intern/pre-allocate.js.map +1 -1
  40. package/intern/process-access-bindings.js +12 -2
  41. package/intern/process-access-bindings.js.map +1 -1
  42. package/intern/put-access-gate.js +25 -3
  43. package/intern/put-access-gate.js.map +1 -1
  44. package/intern/read-anonymous-seed-docs.js +1 -1
  45. package/intern/read-anonymous-seed-docs.js.map +1 -1
  46. package/intern/recovery.d.ts +7 -1
  47. package/intern/recovery.js +5 -5
  48. package/intern/recovery.js.map +1 -1
  49. package/intern/render-vibe.js +7 -3
  50. package/intern/render-vibe.js.map +1 -1
  51. package/intern/slot-assembler.d.ts +2 -0
  52. package/intern/slot-assembler.js +9 -3
  53. package/intern/slot-assembler.js.map +1 -1
  54. package/intern/write-apps.js +9 -2
  55. package/intern/write-apps.js.map +1 -1
  56. package/package.json +16 -15
  57. package/public/access-function.js +4 -1
  58. package/public/access-function.js.map +1 -1
  59. package/public/access-helpers.js +5 -0
  60. package/public/access-helpers.js.map +1 -1
  61. package/public/app-documents-broadcast.d.ts +8 -0
  62. package/public/app-documents-broadcast.js +15 -0
  63. package/public/app-documents-broadcast.js.map +1 -1
  64. package/public/app-documents-grant-delta.js +3 -2
  65. package/public/app-documents-grant-delta.js.map +1 -1
  66. package/public/app-documents-read-eventos.js +72 -13
  67. package/public/app-documents-read-eventos.js.map +1 -1
  68. package/public/app-documents-write-eventos.js +28 -11
  69. package/public/app-documents-write-eventos.js.map +1 -1
  70. package/public/app-settings-sql-upsert.js +4 -1
  71. package/public/app-settings-sql-upsert.js.map +1 -1
  72. package/public/audience-arms-store.d.ts +21 -0
  73. package/public/audience-arms-store.js +110 -0
  74. package/public/audience-arms-store.js.map +1 -0
  75. package/public/audience-channels.d.ts +14 -0
  76. package/public/audience-channels.js +71 -0
  77. package/public/audience-channels.js.map +1 -0
  78. package/public/audience-derived-trigger.d.ts +15 -0
  79. package/public/audience-derived-trigger.js +45 -0
  80. package/public/audience-derived-trigger.js.map +1 -0
  81. package/public/audience-key-patch.d.ts +16 -0
  82. package/public/audience-key-patch.js +38 -0
  83. package/public/audience-key-patch.js.map +1 -0
  84. package/public/channel-read-filter.js +4 -2
  85. package/public/channel-read-filter.js.map +1 -1
  86. package/public/curated-new-vibes.d.ts +2 -0
  87. package/public/curated-new-vibes.js +1 -1
  88. package/public/curated-new-vibes.js.map +1 -1
  89. package/public/developer-grants-flow.d.ts +5 -0
  90. package/public/developer-grants-flow.js +218 -0
  91. package/public/developer-grants-flow.js.map +1 -0
  92. package/public/developer-grants.d.ts +12 -0
  93. package/public/developer-grants.js +40 -0
  94. package/public/developer-grants.js.map +1 -0
  95. package/public/ensure-app-settings.js +69 -3
  96. package/public/ensure-app-settings.js.map +1 -1
  97. package/public/grant-reduce.d.ts +4 -2
  98. package/public/grant-reduce.js +11 -3
  99. package/public/grant-reduce.js.map +1 -1
  100. package/public/home-leaderboard.d.ts +2 -0
  101. package/public/home-leaderboard.js +1 -1
  102. package/public/home-leaderboard.js.map +1 -1
  103. package/public/invite-unsubscribe.d.ts +3 -0
  104. package/public/invite-unsubscribe.js +31 -0
  105. package/public/invite-unsubscribe.js.map +1 -0
  106. package/public/related-vibes.d.ts +3 -0
  107. package/public/related-vibes.js +134 -0
  108. package/public/related-vibes.js.map +1 -0
  109. package/public/serv-entry-point.js +2 -2
  110. package/public/serv-entry-point.js.map +1 -1
  111. package/public/social-flow.d.ts +21 -0
  112. package/public/social-flow.js +765 -0
  113. package/public/social-flow.js.map +1 -0
  114. package/public/social-invite-flow.d.ts +3 -0
  115. package/public/social-invite-flow.js +73 -0
  116. package/public/social-invite-flow.js.map +1 -0
  117. package/public/social-invite-signup-match.d.ts +6 -0
  118. package/public/social-invite-signup-match.js +38 -0
  119. package/public/social-invite-signup-match.js.map +1 -0
  120. package/public/social-invite-store.d.ts +70 -0
  121. package/public/social-invite-store.js +172 -0
  122. package/public/social-invite-store.js.map +1 -0
  123. package/public/social-store.d.ts +109 -0
  124. package/public/social-store.js +469 -0
  125. package/public/social-store.js.map +1 -0
  126. package/public/who-am-i.js +5 -3
  127. package/public/who-am-i.js.map +1 -1
  128. package/svc-ws-send-provider.d.ts +2 -0
  129. package/svc-ws-send-provider.js +2 -0
  130. package/svc-ws-send-provider.js.map +1 -1
  131. package/types.d.ts +20 -1
  132. package/types.js.map +1 -1
  133. package/usage-report/README.md +1 -0
  134. package/usage-report/vibe-usage.py +171 -0
package/types.d.ts CHANGED
@@ -2,7 +2,7 @@ import { SuperThis, FPApiToken, DeviceIdCAIf } from "@vibes.diy/identity";
2
2
  import { WSSendProvider } from "./svc-ws-send-provider.js";
3
3
  import { Logger, Result } from "@adviser/cement";
4
4
  import { LLMRequest } from "@vibes.diy/call-ai-v2";
5
- import { type AccessDescriptor, type UserContext, type EvtViewerGrantsChanged, type EvtRequestGrant, type EvtUserNotification, LLMHeaders, MsgBase, VibesAssetStorage, VibesFPApiParameters } from "@vibes.diy/api-types";
5
+ import { type AccessDescriptor, type UserContext, type EvtViewerGrantsChanged, type EvtRequestGrant, type EvtSocialEdge, type EvtUserNotification, LLMHeaders, MsgBase, VibesAssetStorage, VibesFPApiParameters } from "@vibes.diy/api-types";
6
6
  import { DBFlavour, VibesApiTables, VibesSqlite } from "@vibes.diy/api-sql";
7
7
  import { type } from "arktype";
8
8
  import type { AssetGrantSigner } from "./asset-grant.js";
@@ -55,6 +55,13 @@ export interface VibesApiSQLCtx {
55
55
  docId: string;
56
56
  channel?: string;
57
57
  }, senderConnId: string): Promise<void>;
58
+ notifyDocChangedRemote?(evt: {
59
+ ownerHandle: string;
60
+ appSlug: string;
61
+ dbName: string;
62
+ docId: string;
63
+ channels: string[];
64
+ }): Promise<void>;
58
65
  registerDocSubscription?(subscriptionKey: string): Promise<void>;
59
66
  deregisterDocSubscription?(subscriptionKey: string): Promise<void>;
60
67
  notifyDocEphemeral?(evt: {
@@ -69,12 +76,24 @@ export interface VibesApiSQLCtx {
69
76
  notifyRequestGrantChanged?(evt: EvtRequestGrant, senderConnId: string): Promise<void>;
70
77
  registerRequestGrantSubscription?(subscriptionKey: string): Promise<void>;
71
78
  deregisterRequestGrantSubscription?(subscriptionKey: string): Promise<void>;
79
+ notifySocialEdgeChanged?(evt: EvtSocialEdge, senderConnId: string): Promise<void>;
80
+ registerSocialSubscription?(subscriptionKey: string): Promise<void>;
81
+ deregisterSocialSubscription?(subscriptionKey: string): Promise<void>;
72
82
  notifyViewerGrantsChanged?(evt: EvtViewerGrantsChanged, senderConnId: string): Promise<void>;
73
83
  registerViewerGrantsSubscription?(subscriptionKey: string): Promise<void>;
74
84
  deregisterViewerGrantsSubscription?(subscriptionKey: string): Promise<void>;
75
85
  notifyUser?(userId: string, evt: EvtUserNotification, senderConnId: string): Promise<void>;
76
86
  registerUserSubscription?(userId: string): Promise<void>;
77
87
  deregisterUserSubscription?(userId: string): Promise<void>;
88
+ registerAudienceReader?(handle: string): Promise<void>;
89
+ patchAudienceKeysRemote?(targetHandle: string, patch: {
90
+ op: "add" | "remove";
91
+ channels: string[];
92
+ appScope?: {
93
+ ownerHandle: string;
94
+ appSlug: string;
95
+ };
96
+ }): Promise<void>;
78
97
  registerPushToken?(userId: string, token: string, platform: "ios", appVersion: string): Promise<void>;
79
98
  deregisterPushToken?(userId: string, token: string): Promise<void>;
80
99
  invokeAccessFn?(params: {
package/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../jsr/types.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AA6G/B,MAAM,CAAC,MAAM,aAAa,GAAG,IAAI,CAAC;IAChC,IAAI,EAAE,+BAA+B;IACrC,MAAM,EAAE,QAAQ;IAChB,WAAW,EAAE,QAAQ;IACrB,MAAM,EAAE,QAAQ;CACjB,CAAC,CAAC;AAGH,MAAM,UAAU,eAAe,CAAC,GAAY;IAC1C,OAAO,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,YAAY,IAAI,CAAC,MAAM,CAAC,CAAC;AACtD,CAAC;AAED,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,CAAC;IACjC,IAAI,EAAE,8BAA8B;IACpC,MAAM,EAAE,QAAQ;IAChB,OAAO,EAAE,QAAQ;IACjB,MAAM,EAAE,QAAQ;CACjB,CAAC,CAAC;AAGH,MAAM,UAAU,gBAAgB,CAAC,GAAY;IAC3C,OAAO,CAAC,CAAC,cAAc,CAAC,GAAG,CAAC,YAAY,IAAI,CAAC,MAAM,CAAC,CAAC;AACvD,CAAC;AAED,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC;IACnC,IAAI,EAAE,mCAAmC;IACzC,WAAW,EAAE,aAAa;IAC1B,OAAO,EAAE,cAAc;CACxB,CAAC,CAAC;AAGH,MAAM,UAAU,kBAAkB,CAAC,GAAY;IAC7C,OAAO,CAAC,CAAC,gBAAgB,CAAC,GAAG,CAAC,YAAY,IAAI,CAAC,MAAM,CAAC,CAAC;AACzD,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../jsr/types.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AA0J/B,MAAM,CAAC,MAAM,aAAa,GAAG,IAAI,CAAC;IAChC,IAAI,EAAE,+BAA+B;IACrC,MAAM,EAAE,QAAQ;IAChB,WAAW,EAAE,QAAQ;IACrB,MAAM,EAAE,QAAQ;CACjB,CAAC,CAAC;AAGH,MAAM,UAAU,eAAe,CAAC,GAAY;IAC1C,OAAO,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,YAAY,IAAI,CAAC,MAAM,CAAC,CAAC;AACtD,CAAC;AAED,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,CAAC;IACjC,IAAI,EAAE,8BAA8B;IACpC,MAAM,EAAE,QAAQ;IAChB,OAAO,EAAE,QAAQ;IACjB,MAAM,EAAE,QAAQ;CACjB,CAAC,CAAC;AAGH,MAAM,UAAU,gBAAgB,CAAC,GAAY;IAC3C,OAAO,CAAC,CAAC,cAAc,CAAC,GAAG,CAAC,YAAY,IAAI,CAAC,MAAM,CAAC,CAAC;AACvD,CAAC;AAED,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC;IACnC,IAAI,EAAE,mCAAmC;IACzC,WAAW,EAAE,aAAa;IAC1B,OAAO,EAAE,cAAc;CACxB,CAAC,CAAC;AAGH,MAAM,UAAU,kBAAkB,CAAC,GAAY;IAC7C,OAAO,CAAC,CAAC,gBAAgB,CAAC,GAAG,CAAC,YAAY,IAAI,CAAC,MAAM,CAAC,CAAC;AACzD,CAAC"}
@@ -7,6 +7,7 @@ Files:
7
7
  - `inspect-db.ts`: read-only database inspector for connection info, table listings, table samples, and ad hoc SQL.
8
8
  - `inspect-db-report.ts`: report generator that queries the database and produces the HTML report.
9
9
  - `inspect-db-report-template.ts`: HTML template/rendering module used by the report generator.
10
+ - `vibe-usage.py`: cross-plane usage monitor for a deployed vibe (Postgres writers + Cloudflare visitors + Clerk sign-ups → one funnel). See `agents/vibe-usage-analytics.md`. Run: `python3 vibe-usage.py [HOURS]` (default 48); override `APP_SLUG` / `CF_PATH` env for other apps.
10
11
 
11
12
  Commands:
12
13
 
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-plane usage monitor for a deployed vibe.
3
+
4
+ Pulls last-N-hours activity from all three data planes and prints one funnel:
5
+ - Postgres (AppDocuments): signed-in writers, doc types, new vs returning
6
+ - Cloudflare GraphQL: page visits / distinct IPs (read-only visitors too)
7
+ - Clerk Backend API: new sign-ups + platform baseline
8
+
9
+ This is the scripted form of the runbook: agents/vibe-usage-analytics.md
10
+ Read that for the gotchas (Clerk UA err-1010, CF 1d-window cap + sampling,
11
+ cumulative-vs-rolling-window). Read-only throughout.
12
+
13
+ Usage:
14
+ python3 vibe-usage.py [HOURS] # default 48
15
+ OWNER=owner APP_SLUG=my-app CF_PATH='/vibe/owner/my-app%' python3 vibe-usage.py 168
16
+
17
+ Requires env (present in cloud sessions): CLOUDFLARE_API_TOKEN, CLERK_SECRET_KEY.
18
+ Postgres goes through the repo's db:inspect (prod, read-only).
19
+ """
20
+ import json, os, subprocess, sys, urllib.request, urllib.error
21
+ from datetime import datetime, timedelta, timezone
22
+
23
+ HOURS = int(sys.argv[1]) if len(sys.argv) > 1 else 48
24
+ # App identity in Postgres is keyed by (userSlug, appSlug) — filtering on appSlug
25
+ # alone would aggregate dev/remix/test twins under other handles. OWNER scopes it
26
+ # to the one owner whose /vibe/<owner>/<slug> path the CF_PATH also targets.
27
+ OWNER = os.environ.get("OWNER", "og") # live app owner handle
28
+ APP_SLUG = os.environ.get("APP_SLUG", "pickathon-picker") # live app (owner: og)
29
+ CF_PATH = os.environ.get("CF_PATH", "/vibe/og/pickathon-picker%") # page shell path
30
+ CF_ZONE = os.environ.get("CF_ZONE", "13f147047a38c9d54a576424347cb4be") # vibes.diy
31
+ # Resolve DB_DIR relative to this file so it works from any cwd / worktree.
32
+ DB_DIR = os.path.dirname(os.path.abspath(__file__)) + "/.."
33
+ REPO_ROOT = os.path.abspath(DB_DIR + "/../../..")
34
+
35
+ now = datetime.now(timezone.utc)
36
+ since = now - timedelta(hours=HOURS)
37
+ since_iso = since.strftime("%Y-%m-%dT%H:%M:%SZ")
38
+ now_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ")
39
+ since_ms = int(since.timestamp() * 1000)
40
+
41
+ CF_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN")
42
+ CLERK_KEY = os.environ.get("CLERK_SECRET_KEY")
43
+
44
+
45
+ def db(sql):
46
+ """Run a read-only query via db:inspect, return parsed rows."""
47
+ p = subprocess.run(
48
+ ["pnpm", "--dir", DB_DIR, "run", "db:inspect", "sql", sql],
49
+ capture_output=True, text=True, cwd=REPO_ROOT,
50
+ )
51
+ if "{" not in p.stdout: # surface the real DB/tooling error, not a ValueError
52
+ raise SystemExit(f"db:inspect failed (exit {p.returncode}):\n"
53
+ f"{(p.stderr or p.stdout).strip()}")
54
+ return json.loads(p.stdout[p.stdout.index("{"):]).get("rows", [])
55
+
56
+
57
+ def http_json(url, data=None, headers=None):
58
+ headers = dict(headers or {})
59
+ # Clerk/CF firewall 403s (err 1010) the default python-urllib UA; curl UA passes.
60
+ headers.setdefault("User-Agent", "curl/8.0")
61
+ req = urllib.request.Request(url, data=data, headers=headers)
62
+ try:
63
+ with urllib.request.urlopen(req, timeout=30) as r:
64
+ return json.loads(r.read())
65
+ except urllib.error.HTTPError as e:
66
+ return {"_error": f"{e.code} {e.read()[:200]!r}"}
67
+
68
+
69
+ def cf_graphql(query):
70
+ return http_json(
71
+ "https://api.cloudflare.com/client/v4/graphql",
72
+ data=json.dumps({"query": query}).encode(),
73
+ headers={"Authorization": f"Bearer {CF_TOKEN}", "Content-Type": "application/json"},
74
+ )
75
+
76
+
77
+ def clerk(path):
78
+ return http_json("https://api.clerk.com/v1" + path,
79
+ headers={"Authorization": f"Bearer {CLERK_KEY}"})
80
+
81
+
82
+ print(f"=== {APP_SLUG} — last {HOURS}h ({since_iso} → {now_iso}) ===\n")
83
+
84
+ # ---- 1. Postgres: signed-in activity -------------------------------------
85
+ APP_FILTER = f"""\"userSlug\" = '{OWNER}' AND "appSlug" = '{APP_SLUG}'"""
86
+ totals = db(f"""SELECT count(*) AS writes, count(DISTINCT "userId") AS writers,
87
+ count(DISTINCT "docId") AS docs, coalesce(sum(deleted),0) AS deletes
88
+ FROM "AppDocuments"
89
+ WHERE {APP_FILTER} AND created >= '{since_iso}'""")[0]
90
+ print(f"DATA (signed-in users) [{OWNER}/{APP_SLUG}]")
91
+ print(f" writes={totals['writes']} writers={totals['writers']} "
92
+ f"distinct-docs={totals['docs']} deletes={totals['deletes']}")
93
+
94
+ types = db(f"""SELECT coalesce(data->>'type','(none)') AS t, count(*) AS n,
95
+ count(DISTINCT "userId") AS u FROM "AppDocuments"
96
+ WHERE {APP_FILTER} AND created >= '{since_iso}'
97
+ GROUP BY 1 ORDER BY n DESC""")
98
+ print(" by type: " + ", ".join(f"{r['t']}={r['n']}({r['u']}u)" for r in types))
99
+
100
+ split = db(f"""WITH firsts AS (SELECT "userId", min(created) f FROM "AppDocuments"
101
+ WHERE {APP_FILTER} GROUP BY 1)
102
+ SELECT count(*) FILTER (WHERE f >= '{since_iso}') AS new,
103
+ count(*) FILTER (WHERE f < '{since_iso}') AS returning
104
+ FROM firsts WHERE "userId" IN (
105
+ SELECT DISTINCT "userId" FROM "AppDocuments"
106
+ WHERE {APP_FILTER} AND created >= '{since_iso}')""")[0]
107
+ print(f" writers new-to-app={split['new']} returning={split['returning']}")
108
+
109
+ # ---- 2. Cloudflare: read-only visitors (day-sliced; zone caps at 1d) ------
110
+ print("\nTRAFFIC (all visitors, incl. non-signed-in; sampled + IP counts overlap days)")
111
+ tot_ip = tot_visits = tot_req = 0
112
+ slice_start = since
113
+ while slice_start < now:
114
+ slice_end = min(slice_start + timedelta(hours=24), now)
115
+ q = f'''query {{ viewer {{ zones(filter: {{zoneTag: "{CF_ZONE}"}}) {{
116
+ httpRequestsAdaptiveGroups(limit: 5000, filter: {{
117
+ datetime_geq: "{slice_start.strftime('%Y-%m-%dT%H:%M:%SZ')}",
118
+ datetime_leq: "{slice_end.strftime('%Y-%m-%dT%H:%M:%SZ')}",
119
+ clientRequestPath_like: "{CF_PATH}"}}) {{
120
+ dimensions {{ clientIP }} sum {{ visits }} count }} }} }} }}'''
121
+ res = cf_graphql(q)
122
+ if res.get("errors") or res.get("_error"):
123
+ print(f" [{slice_start:%m-%d %H:%M}] cf error: {res.get('errors') or res.get('_error')}")
124
+ else:
125
+ rows = res["data"]["viewer"]["zones"][0]["httpRequestsAdaptiveGroups"]
126
+ ips = len(rows); v = sum(r["sum"]["visits"] for r in rows); c = sum(r["count"] for r in rows)
127
+ tot_ip += ips; tot_visits += v; tot_req += c
128
+ print(f" [{slice_start:%m-%d %H:%M}→{slice_end:%H:%M}] IPs={ips} visits={v} requests={c}")
129
+ slice_start = slice_end
130
+ print(f" TOTAL: IPs={tot_ip} (summed per-slice — cross-day overlap, not exact uniques)"
131
+ f" visits={tot_visits} requests={tot_req}")
132
+
133
+ # ---- 3. Clerk: sign-ups + baseline ---------------------------------------
134
+ print("\nSIGN-UPS (Clerk, platform-wide)")
135
+ total = clerk("/users/count").get("total_count", "?")
136
+ active = clerk(f"/users/count?last_active_at_since={since_ms}").get("total_count", "?")
137
+ # Page back until we pass the baseline window's start, else the newest 200 would
138
+ # silently cap `new`/`prior` during exactly the spike/long-window runs this targets.
139
+ base_start = since_ms - 14 * 86400000
140
+ users, offset, capped = [], 0, False
141
+ while True:
142
+ page = clerk(f"/users?order_by=-created_at&limit=500&offset={offset}")
143
+ if not isinstance(page, list) or not page:
144
+ break
145
+ users += page
146
+ if page[-1]["created_at"] < base_start: # covered the whole window+baseline
147
+ break
148
+ offset += 500
149
+ if offset >= 5000: # 10k-account safety stop
150
+ capped = True
151
+ break
152
+ new = [u for u in users if u["created_at"] >= since_ms]
153
+ prior = [u for u in users if base_start <= u["created_at"] < since_ms]
154
+ base_per = len(prior) / (14 / (HOURS / 24))
155
+ print(f" total accounts(cumulative)={total} active-in-window={active}")
156
+ cap_note = " ⚠ paging capped at 10k — new/baseline may undercount" if capped else ""
157
+ print(f" new sign-ups={len(new)} (prior baseline ≈{base_per:.1f} per {HOURS}h){cap_note}")
158
+ doms = {}
159
+ for u in new:
160
+ d = (u["email_addresses"][0]["email_address"].split("@")[-1]
161
+ if u.get("email_addresses") else "?")
162
+ doms[d] = doms.get(d, 0) + 1
163
+ if doms:
164
+ print(" domains: " + ", ".join(f"{k}={v}" for k, v in sorted(doms.items(), key=lambda x: -x[1])))
165
+
166
+ # funnel cross-ref: which new Clerk accounts wrote in the app
167
+ writers = {r["userId"] for r in db(f'''SELECT DISTINCT "userId" FROM "AppDocuments"
168
+ WHERE {APP_FILTER} AND created >= '{since_iso}' ''')}
169
+ new_ids = {u["id"] for u in new}
170
+ print(f"\nFUNNEL: ~{tot_visits} visits → {len(new)} sign-ups → "
171
+ f"{len(writers)} active writers ({len(writers & new_ids)} of them brand-new accounts)")