@tokenoftrust/storefront-runner 2.0.3 → 2.0.5

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 (178) hide show
  1. package/apps/storefront/.astro/content.d.ts +159 -0
  2. package/apps/storefront/.astro/integrations/_astrojs_cloudflare/cloudflare.d.ts +1 -0
  3. package/apps/storefront/.astro/types.d.ts +3 -0
  4. package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
  5. package/apps/storefront/node_modules/.bin/acorn +43 -0
  6. package/apps/storefront/node_modules/.bin/astro +43 -0
  7. package/apps/storefront/node_modules/.bin/astro-check +43 -0
  8. package/apps/storefront/node_modules/.bin/browserslist +43 -0
  9. package/apps/storefront/node_modules/.bin/cf-wrangler +43 -0
  10. package/apps/storefront/node_modules/.bin/depcruise +43 -0
  11. package/apps/storefront/node_modules/.bin/depcruise-baseline +43 -0
  12. package/apps/storefront/node_modules/.bin/depcruise-fmt +43 -0
  13. package/apps/storefront/node_modules/.bin/depcruise-wrap-stream-in-html +43 -0
  14. package/apps/storefront/node_modules/.bin/dependency-cruise +43 -0
  15. package/apps/storefront/node_modules/.bin/dependency-cruiser +43 -0
  16. package/apps/storefront/node_modules/.bin/drizzle-kit +43 -0
  17. package/apps/storefront/node_modules/.bin/esbuild +43 -0
  18. package/apps/storefront/node_modules/.bin/jiti +43 -0
  19. package/apps/storefront/node_modules/.bin/playwright +43 -0
  20. package/apps/storefront/node_modules/.bin/playwright-core +43 -0
  21. package/apps/storefront/node_modules/.bin/prettier +43 -0
  22. package/apps/storefront/node_modules/.bin/rollup +43 -0
  23. package/apps/storefront/node_modules/.bin/tsc +43 -0
  24. package/apps/storefront/node_modules/.bin/tsserver +43 -0
  25. package/apps/storefront/node_modules/.bin/tsx +43 -0
  26. package/apps/storefront/node_modules/.bin/vite +43 -0
  27. package/apps/storefront/node_modules/.bin/vitest +43 -0
  28. package/apps/storefront/node_modules/.bin/workerd +43 -0
  29. package/apps/storefront/node_modules/.bin/wrangler +43 -0
  30. package/apps/storefront/node_modules/.bin/wrangler2 +43 -0
  31. package/apps/storefront/node_modules/.bin/yaml +43 -0
  32. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js +58 -0
  33. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js.map +1 -0
  34. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js +741 -0
  35. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js.map +1 -0
  36. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js +2549 -0
  37. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js.map +1 -0
  38. package/apps/storefront/node_modules/.vite/deps/_metadata.json +150 -0
  39. package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js +434 -0
  40. package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js.map +1 -0
  41. package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  42. package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  43. package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js +2705 -0
  44. package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  45. package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  46. package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  47. package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  48. package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  49. package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js +1535 -0
  50. package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js.map +1 -0
  51. package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js +48 -0
  52. package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js.map +1 -0
  53. package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js +42 -0
  54. package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js.map +1 -0
  55. package/apps/storefront/node_modules/.vite/deps/package.json +3 -0
  56. package/apps/storefront/node_modules/.vite/deps/preact.js +310 -0
  57. package/apps/storefront/node_modules/.vite/deps/preact.js.map +1 -0
  58. package/apps/storefront/node_modules/.vite/deps/preact_compat.js +420 -0
  59. package/apps/storefront/node_modules/.vite/deps/preact_compat.js.map +1 -0
  60. package/apps/storefront/node_modules/.vite/deps/preact_compat_jsx-runtime.js +4 -0
  61. package/apps/storefront/node_modules/.vite/deps/preact_debug.js +236 -0
  62. package/apps/storefront/node_modules/.vite/deps/preact_debug.js.map +1 -0
  63. package/apps/storefront/node_modules/.vite/deps/preact_devtools.js +12 -0
  64. package/apps/storefront/node_modules/.vite/deps/preact_devtools.js.map +1 -0
  65. package/apps/storefront/node_modules/.vite/deps/preact_hooks.js +192 -0
  66. package/apps/storefront/node_modules/.vite/deps/preact_hooks.js.map +1 -0
  67. package/apps/storefront/node_modules/.vite/deps/preact_jsx-dev-runtime.js +3 -0
  68. package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js +92 -0
  69. package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js.map +1 -0
  70. package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js +59 -0
  71. package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js.map +1 -0
  72. package/apps/storefront/node_modules/.vite/deps/rolldown-runtime-BPOCksWG.js +24 -0
  73. package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js +189 -0
  74. package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js.map +1 -0
  75. package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js +13 -0
  76. package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js.map +1 -0
  77. package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js +458 -0
  78. package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js.map +1 -0
  79. package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js +1016 -0
  80. package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js.map +1 -0
  81. package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js +32 -0
  82. package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js.map +1 -0
  83. package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js +113 -0
  84. package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js.map +1 -0
  85. package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +8 -0
  86. package/apps/storefront/node_modules/.vite/deps_ssr/package.json +3 -0
  87. package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
  88. package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
  89. package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
  90. package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
  91. package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
  92. package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
  93. package/apps/storefront/src/layouts/Layout.astro +65 -7
  94. package/apps/storefront/src/lib/apps/registryAccess.ts +45 -0
  95. package/apps/storefront/src/lib/auth/adminEntry.ts +4 -4
  96. package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
  97. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
  98. package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
  99. package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
  100. package/apps/storefront/src/lib/rawChrome.ts +73 -14
  101. package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
  102. package/apps/storefront/src/lib/shared/capability.ts +68 -0
  103. package/apps/storefront/src/lib/shared/developerInvites.ts +1250 -0
  104. package/apps/storefront/src/lib/shared/invitePreflight.ts +123 -0
  105. package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
  106. package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
  107. package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
  108. package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
  109. package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
  110. package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
  111. package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
  112. package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
  113. package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
  114. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
  115. package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
  116. package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
  117. package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
  118. package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
  119. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +38 -4
  120. package/apps/storefront/src/lib/webhooks/emit.ts +1 -1
  121. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +1 -1
  122. package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
  123. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -1
  124. package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
  125. package/apps/storefront/src/pages/index.astro +1 -1
  126. package/apps/storefront/src/pages/products/[handle].astro +1 -1
  127. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
  128. package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
  129. package/package.json +1 -1
  130. package/packages/public-runtime/node_modules/.bin/tsc +43 -0
  131. package/packages/public-runtime/node_modules/.bin/tsserver +43 -0
  132. package/packages/public-runtime/node_modules/.bin/vitest +43 -0
  133. package/packages/public-runtime/src/chrome.ts +18 -7
  134. package/packages/public-runtime/src/index.ts +10 -0
  135. package/packages/public-runtime/src/integration/index.ts +1 -0
  136. package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
  137. package/packages/public-runtime/src/interaction-tracking.ts +364 -0
  138. package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
  139. package/pnpm-workspace.yaml +3 -0
  140. package/scripts/tenant/validate.mjs +306 -1
  141. package/scripts/tot-dev.mjs +59 -3
  142. package/tenants/home/public/js/storefront.js +6 -87
  143. package/apps/storefront/src/lib/apps/adminService.ts +0 -116
  144. package/apps/storefront/src/lib/apps/adminSession.ts +0 -214
  145. package/apps/storefront/src/lib/apps/apiAuth.ts +0 -91
  146. package/apps/storefront/src/lib/apps/apiRoute.ts +0 -35
  147. package/apps/storefront/src/lib/apps/catalogMapper.ts +0 -39
  148. package/apps/storefront/src/lib/apps/consentConfigStore.ts +0 -228
  149. package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +0 -447
  150. package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +0 -367
  151. package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +0 -315
  152. package/apps/storefront/src/lib/apps/healthAggregate.ts +0 -66
  153. package/apps/storefront/src/lib/apps/integrationLifecycle.ts +0 -614
  154. package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +0 -125
  155. package/apps/storefront/src/lib/apps/integrationReadModel.ts +0 -563
  156. package/apps/storefront/src/lib/apps/integrationsTab.ts +0 -547
  157. package/apps/storefront/src/lib/apps/integrationsView.ts +0 -106
  158. package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +0 -599
  159. package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +0 -170
  160. package/apps/storefront/src/lib/apps/tokenIssuer.ts +0 -121
  161. package/apps/storefront/src/lib/apps/tokenVerifier.ts +0 -148
  162. package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +0 -16
  163. package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +0 -105
  164. package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +0 -177
  165. package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +0 -251
  166. package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +0 -241
  167. package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +0 -211
  168. package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +0 -409
  169. package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +0 -368
  170. package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +0 -188
  171. package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +0 -291
  172. package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +0 -95
  173. package/apps/storefront/src/lib/content-edit/applyDiff.ts +0 -91
  174. package/apps/storefront/src/lib/content-edit/client.ts +0 -286
  175. package/apps/storefront/src/lib/content-edit/index.ts +0 -14
  176. package/apps/storefront/src/lib/content-edit/route.ts +0 -25
  177. package/apps/storefront/src/lib/content-edit/types.ts +0 -19
  178. package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +0 -164
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Scheduled PRUNE + ROLLUP for the per-tenant raw-interaction D1 stores
3
+ * (`dt-storage-per-tenant-d1`).
4
+ *
5
+ * WHAT THIS DOES, per tenant, per sweep:
6
+ * 1. READ the rows aging out of the 90-day window (`ingested_at < cutoff`,
7
+ * the SERVER receive clock — never the client `ts`, which a beacon can
8
+ * skew arbitrarily).
9
+ * 2. ROLL UP those rows via the already-built, pure `aggregateInteractions()`
10
+ * and write them to the dedicated `INTERACTION_ANALYTICS` dataset via
11
+ * the already-built `writeInteractionAnalytics()` — durable long-term
12
+ * aggregates survive the delete below. Both are reused verbatim from
13
+ * `interactionStore.ts`, never reimplemented here.
14
+ * 3. DELETE those same rows. `DELETE FROM interaction_events WHERE
15
+ * ingested_at < ?` is an index range scan
16
+ * (`idx_interaction_events_ingested_at`), not a table scan.
17
+ * 4. MEASURE the database's current size (`PRAGMA page_count * page_size`)
18
+ * and warn when a tenant is approaching D1's 10GB-per-database ceiling —
19
+ * the point is to surface this BEFORE writes start failing, not after.
20
+ * 5. WRITE BACK the `tracking_tenant_databases` bookkeeping columns
21
+ * (`last_pruned_at`, `pruned_rows`, `size_bytes`, `size_checked_at`) that
22
+ * `dt-storage-per-tenant-d1` added for exactly this purpose.
23
+ *
24
+ * WORK QUEUE. One sweep processes a bounded BATCH of tenants
25
+ * (`ORDER BY last_pruned_at ASC` — SQLite sorts NULL first, so a tenant that
26
+ * has never been pruned is swept immediately), not the whole fleet in one
27
+ * invocation. A frequent-enough cron (see `workers/pruneRollupWorker.ts`)
28
+ * cycles fairly through every tenant over time instead of risking one run
29
+ * exceeding the Worker's CPU/wall-clock budget as the tenant count grows.
30
+ *
31
+ * NOT DONE HERE (by design, per the binding decisions this unit follows):
32
+ * - No DROP-based time-partitioned schema. The DROP-vs-DELETE cost
33
+ * asymmetry noted in `d1-cost-basis-verified` is UNVERIFIED inference;
34
+ * this sticks to the straightforward `DELETE ... WHERE ingested_at < ?`
35
+ * against the single `interaction_events` table dt-storage-per-tenant-d1
36
+ * already built. A future optimization pass can revisit it once verified.
37
+ * - No outbound alert transport. Same posture as `activity/alerts.ts`: this
38
+ * module DETECTS a tenant approaching the ceiling and logs it
39
+ * (`console.warn`, grep-able in Workers Logs) plus returns it in the
40
+ * structured sweep result — where it goes from there (a dashboard, a
41
+ * polled endpoint) is a sink's job, not this module's.
42
+ *
43
+ * BEST-EFFORT PER TENANT. One tenant's prune failing (a REST error, a
44
+ * transient D1 fault) is caught, logged, and recorded in that tenant's
45
+ * outcome — it never aborts the rest of the batch.
46
+ */
47
+ import type { Queryable } from "../d1/catalog.js";
48
+ import type { AnalyticsEngineLike } from "../activity/store.js";
49
+ import { fromD1Rest, type D1ControlConfig } from "./d1Control.js";
50
+ import { aggregateInteractions, writeInteractionAnalytics } from "./interactionStore.js";
51
+ import type { StampedInteractionEvent } from "./collectorIngest.js";
52
+
53
+ /** The settled retention window (`settled-operational-choices`). */
54
+ export const DEFAULT_RETENTION_DAYS = 90;
55
+
56
+ /** How many tenants one sweep invocation processes. */
57
+ export const DEFAULT_BATCH_SIZE = 25;
58
+
59
+ /** D1's documented per-database ceiling. */
60
+ export const D1_SIZE_CEILING_BYTES = 10 * 1024 * 1024 * 1024;
61
+
62
+ /** Warn at 80% of the ceiling — well before writes start failing. */
63
+ const DEFAULT_SIZE_WARNING_RATIO = 0.8;
64
+
65
+ /** One row of the prune work queue. */
66
+ export interface TenantPruneRow {
67
+ tenant_id: string;
68
+ database_uuid: string;
69
+ last_pruned_at: string | null;
70
+ }
71
+
72
+ export interface PruneSweepDeps {
73
+ /** The shared STOREFRONT_APPS_DB handle holding the directory table. */
74
+ apps: Queryable;
75
+ /** D1 REST credentials + transport, for reaching each tenant's own database. */
76
+ control: D1ControlConfig;
77
+ /** The dedicated interaction AE dataset. Absent ⇒ rollup is skipped, prune
78
+ * still runs (never block retention on an unavailable aggregates sink). */
79
+ analytics?: AnalyticsEngineLike;
80
+ /** ISO-8601 "now" — injected so the sweep is deterministic under test. */
81
+ now: string;
82
+ retentionDays?: number;
83
+ batchSize?: number;
84
+ /** Absolute byte threshold for the ceiling warning; defaults to 80% of
85
+ * `D1_SIZE_CEILING_BYTES`. */
86
+ sizeWarningBytes?: number;
87
+ }
88
+
89
+ export interface TenantPruneOutcome {
90
+ tenantId: string;
91
+ prunedRows: number;
92
+ aggregatesWritten: number;
93
+ sizeBytes: number | null;
94
+ /** True when `sizeBytes` is at/above the warning threshold. */
95
+ sizeWarning: boolean;
96
+ /** Set only when this tenant's sweep failed; the batch still continues. */
97
+ error?: string;
98
+ }
99
+
100
+ export interface PruneSweepResult {
101
+ tenantsSwept: number;
102
+ totalPrunedRows: number;
103
+ totalAggregatesWritten: number;
104
+ tenants: TenantPruneOutcome[];
105
+ }
106
+
107
+ /** Map one raw `interaction_events` row (snake_case D1 columns) back to the
108
+ * `StampedInteractionEvent` shape `aggregateInteractions()` expects. PURE. */
109
+ export function rawRowToEvent(row: Record<string, unknown>): StampedInteractionEvent {
110
+ return {
111
+ type: "click",
112
+ el: String(row.el ?? ""),
113
+ action: (row.action as string | null) ?? null,
114
+ placement: (row.placement as string | null) ?? null,
115
+ scope: (row.scope as string | null) ?? null,
116
+ index: (row.idx as number | null) ?? null,
117
+ ts: String(row.ts ?? ""),
118
+ correlationId: String(row.correlation_id ?? ""),
119
+ seq: Number(row.seq ?? 0),
120
+ tenant_id: String(row.tenant_id ?? ""),
121
+ stream: "interaction",
122
+ ingested_at: String(row.ingested_at ?? ""),
123
+ };
124
+ }
125
+
126
+ /** The prune work queue: oldest-`last_pruned_at`-first (NULL — never pruned —
127
+ * sorts first in SQLite ASC). Never throws. */
128
+ export async function listPruneCandidates(
129
+ apps: Queryable,
130
+ batchSize: number,
131
+ ): Promise<TenantPruneRow[]> {
132
+ try {
133
+ return await apps.all<TenantPruneRow>(
134
+ `SELECT tenant_id, database_uuid, last_pruned_at
135
+ FROM tracking_tenant_databases
136
+ ORDER BY last_pruned_at ASC
137
+ LIMIT ?`,
138
+ batchSize,
139
+ );
140
+ } catch (err) {
141
+ console.error(
142
+ `[tracking:prune] directory read failed: ${err instanceof Error ? err.message : String(err)}`,
143
+ );
144
+ return [];
145
+ }
146
+ }
147
+
148
+ /** `PRAGMA page_count * page_size` — the current on-disk size of one tenant's
149
+ * database. Returns null (never throws) when the pragmas are unavailable. */
150
+ async function readDatabaseSizeBytes(db: Queryable): Promise<number | null> {
151
+ try {
152
+ const pageCount = await db.first<{ page_count: number }>("PRAGMA page_count");
153
+ const pageSize = await db.first<{ page_size: number }>("PRAGMA page_size");
154
+ if (!pageCount || !pageSize) return null;
155
+ const count = Number(pageCount.page_count);
156
+ const size = Number(pageSize.page_size);
157
+ if (!Number.isFinite(count) || !Number.isFinite(size)) return null;
158
+ return count * size;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /** Prune + roll up ONE tenant's database. Never throws. */
165
+ export async function pruneTenant(
166
+ row: TenantPruneRow,
167
+ deps: PruneSweepDeps,
168
+ ): Promise<TenantPruneOutcome> {
169
+ const retentionDays = deps.retentionDays ?? DEFAULT_RETENTION_DAYS;
170
+ const sizeWarningBytes =
171
+ deps.sizeWarningBytes ?? Math.floor(D1_SIZE_CEILING_BYTES * DEFAULT_SIZE_WARNING_RATIO);
172
+ const cutoff = new Date(
173
+ Date.parse(deps.now) - retentionDays * 24 * 60 * 60 * 1000,
174
+ ).toISOString();
175
+
176
+ try {
177
+ const db = fromD1Rest(deps.control, row.database_uuid);
178
+
179
+ // Read the aging-out rows BEFORE deleting them — the rollup needs them.
180
+ const aging = await db.all<Record<string, unknown>>(
181
+ `SELECT tenant_id, stream, type, el, action, placement, scope, idx, correlation_id, seq, ts, ingested_at
182
+ FROM interaction_events WHERE ingested_at < ?`,
183
+ cutoff,
184
+ );
185
+
186
+ let aggregatesWritten = 0;
187
+ if (aging.length > 0) {
188
+ if (deps.analytics) {
189
+ const aggregates = aggregateInteractions(aging.map(rawRowToEvent));
190
+ writeInteractionAnalytics(deps.analytics, aggregates);
191
+ aggregatesWritten = aggregates.length;
192
+ }
193
+ // The DELETE itself never depends on the rollup succeeding — retention
194
+ // must not stall because the aggregates sink is unavailable.
195
+ await db.run(`DELETE FROM interaction_events WHERE ingested_at < ?`, cutoff);
196
+ }
197
+
198
+ const sizeBytes = await readDatabaseSizeBytes(db);
199
+ const sizeWarning = sizeBytes != null && sizeBytes >= sizeWarningBytes;
200
+ if (sizeWarning) {
201
+ console.warn(
202
+ `[tracking:prune] tenant ${row.tenant_id} database at ${sizeBytes} bytes — ` +
203
+ `approaching the ${D1_SIZE_CEILING_BYTES}-byte D1 per-database ceiling`,
204
+ );
205
+ }
206
+
207
+ await deps.apps.run(
208
+ `UPDATE tracking_tenant_databases
209
+ SET last_pruned_at = ?, pruned_rows = pruned_rows + ?, size_bytes = ?, size_checked_at = ?
210
+ WHERE tenant_id = ?`,
211
+ deps.now,
212
+ aging.length,
213
+ sizeBytes,
214
+ deps.now,
215
+ row.tenant_id,
216
+ );
217
+
218
+ return {
219
+ tenantId: row.tenant_id,
220
+ prunedRows: aging.length,
221
+ aggregatesWritten,
222
+ sizeBytes,
223
+ sizeWarning,
224
+ };
225
+ } catch (err) {
226
+ const message = err instanceof Error ? err.message : String(err);
227
+ console.error(`[tracking:prune] sweep failed for ${row.tenant_id}: ${message}`);
228
+ return {
229
+ tenantId: row.tenant_id,
230
+ prunedRows: 0,
231
+ aggregatesWritten: 0,
232
+ sizeBytes: null,
233
+ sizeWarning: false,
234
+ error: message,
235
+ };
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Run one bounded batch of the prune sweep: pull the oldest-pruned tenants
241
+ * off the work queue and prune+roll-up each in turn. Sequential, not
242
+ * parallel — keeps one invocation's D1 REST call volume predictable rather
243
+ * than fanning every tenant in the batch out concurrently.
244
+ */
245
+ export async function runPruneSweep(deps: PruneSweepDeps): Promise<PruneSweepResult> {
246
+ const batchSize = deps.batchSize ?? DEFAULT_BATCH_SIZE;
247
+ const candidates = await listPruneCandidates(deps.apps, batchSize);
248
+
249
+ const tenants: TenantPruneOutcome[] = [];
250
+ for (const row of candidates) {
251
+ tenants.push(await pruneTenant(row, deps));
252
+ }
253
+
254
+ return {
255
+ tenantsSwept: tenants.length,
256
+ totalPrunedRows: tenants.reduce((n, t) => n + t.prunedRows, 0),
257
+ totalAggregatesWritten: tenants.reduce((n, t) => n + t.aggregatesWritten, 0),
258
+ tenants,
259
+ };
260
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Publish-time `data-tot-el` safety net — the FALLBACK identity path for
3
+ * content that never flows through git review.
4
+ *
5
+ * `dt-id-writeback` (a sibling unit) is the PRIMARY path: a git-time rewriter
6
+ * that proposes `data-tot-el` for git-tracked tenant content and writes it
7
+ * back to source as a reviewable diff. This module exists for everything
8
+ * that bypasses that review — AI-edit drafts materialized into
9
+ * `dev-draft:` KV (see `DevDraftContentProvider`, lib/storyblok/provider.ts)
10
+ * and direct KV-published edits (`PublishedContentProvider`'s overlay over
11
+ * the customization store). Content sourced from `LocalContentProvider`
12
+ * (build-bundled from git-tracked `tenants/<id>/content/*`) is explicitly
13
+ * OUT of scope here — it is dt-id-writeback's job, and re-stamping it here
14
+ * would fight a human-reviewed, permanently-committed id.
15
+ *
16
+ * Shares the SAME structural derivation engine
17
+ * (`@tot/public-runtime`'s tracking-id-derivation.ts) the git-time rewriter
18
+ * uses, fed from an `HTMLRewriter` element/end-tag callback pair instead of
19
+ * a `parse5` tree — see the "Feeding it" section of
20
+ * docs/architecture/interaction-tracking-attribute-contract.md and
21
+ * dt-id-derivation's handoff note ("keep an ancestor stack across
22
+ * HTMLRewriter element/end-tag callbacks and derive at the open tag; one
23
+ * deriver per document").
24
+ *
25
+ * NO DOM-TEXT READS: only `element.tagName` and `element.attributes` are
26
+ * ever read here, matching the payload guardrail
27
+ * tracking-id-derivation.ts's own module doc documents and tests (a source
28
+ * sweep + a closed attribute allowlist) — never `element.text`, never a
29
+ * child's character data.
30
+ */
31
+ import {
32
+ createTrackingIdDeriver,
33
+ isTrackableNode,
34
+ TRACKING_ATTR_EL,
35
+ type StructuralNode,
36
+ } from "@tot/public-runtime";
37
+
38
+ /**
39
+ * HTML elements with no end tag. Never pushed onto the ancestor stack, and
40
+ * `onEndTag` is never registered for them — HTMLRewriter would never call a
41
+ * callback registered on a void element's (nonexistent) end tag.
42
+ */
43
+ const VOID_TAGS: ReadonlySet<string> = new Set([
44
+ "area",
45
+ "base",
46
+ "br",
47
+ "col",
48
+ "embed",
49
+ "hr",
50
+ "img",
51
+ "input",
52
+ "link",
53
+ "meta",
54
+ "param",
55
+ "source",
56
+ "track",
57
+ "wbr",
58
+ ]);
59
+
60
+ /**
61
+ * Minimal shape of the Workers `HTMLRewriter` Element handler this module
62
+ * needs. Deliberately NOT `@cloudflare/workers-types` (not installed in this
63
+ * repo — see apps/storefront/env.d.ts's own note) — a local, narrow surface
64
+ * so this file typechecks in both the Workers runtime and plain Node/vitest.
65
+ */
66
+ interface RewriterElement {
67
+ tagName: string;
68
+ attributes: IterableIterator<[string, string]>;
69
+ /** True for a void element or one written self-closed (`<foo />`) — either
70
+ * way, no end tag will ever fire for it. */
71
+ selfClosing?: boolean;
72
+ setAttribute(name: string, value: string): void;
73
+ onEndTag(handler: () => void | Promise<void>): void;
74
+ }
75
+
76
+ interface MinimalHTMLRewriter {
77
+ on(selector: string, handlers: { element(el: RewriterElement): void }): MinimalHTMLRewriter;
78
+ transform(response: Response): Response;
79
+ }
80
+
81
+ type HTMLRewriterCtor = new () => MinimalHTMLRewriter;
82
+
83
+ function getHtmlRewriterCtor(): HTMLRewriterCtor | null {
84
+ const ctor = (globalThis as Record<string, unknown>).HTMLRewriter;
85
+ return typeof ctor === "function" ? (ctor as HTMLRewriterCtor) : null;
86
+ }
87
+
88
+ /**
89
+ * True when the Workers `HTMLRewriter` global is available in this runtime.
90
+ * Absent in plain Node (vitest, `astro check`, a bare `node` script) — every
91
+ * caller must treat a false return as "pass the content through unchanged",
92
+ * never throw. Exported so tests can assert the no-op fallback explicitly.
93
+ */
94
+ export function hasHtmlRewriter(): boolean {
95
+ return getHtmlRewriterCtor() !== null;
96
+ }
97
+
98
+ /** Build the closed-allowlist-fed attrs bag HTMLRewriter's Element exposes. */
99
+ function attrsOf(element: RewriterElement): Record<string, string> {
100
+ const attrs: Record<string, string> = {};
101
+ for (const [name, value] of element.attributes) attrs[name] = value;
102
+ return attrs;
103
+ }
104
+
105
+ /**
106
+ * Stamp `data-tot-el` onto every trackable element in `html` that doesn't
107
+ * already declare one — a single streaming pass, ONE deriver for the whole
108
+ * document (so ids stay unique within it), maintaining an outermost-first
109
+ * open-tag stack across element/end-tag callbacks the same way a `parse5`
110
+ * tree walk would.
111
+ *
112
+ * An element that already carries `data-tot-el` (confidence: "declared") is
113
+ * left completely untouched — its value is only `reserve()`-d into this
114
+ * document's deriver, so a later element in the SAME pass can never propose
115
+ * a colliding id. There is no pre-scan pass (this repo has no prior art for
116
+ * one over raw tenant HTML at publish time — see the module doc): a
117
+ * declared id that appears LATER in the document than an already-derived
118
+ * proposal is a known, accepted single-pass limitation, no worse than
119
+ * shipping no identity for this content at all.
120
+ *
121
+ * No-op (returns `html` unchanged) when `HTMLRewriter` isn't available in
122
+ * this runtime.
123
+ */
124
+ export async function stampTrackingIdsAtPublish(html: string): Promise<string> {
125
+ const HTMLRewriter = getHtmlRewriterCtor();
126
+ if (!HTMLRewriter) return html;
127
+
128
+ const deriver = createTrackingIdDeriver();
129
+ // Outermost-first open-tag stack, shared across every element/end-tag
130
+ // callback for the WHOLE document — one deriver, one stack, per document
131
+ // (dt-id-derivation's handoff note).
132
+ const stack: StructuralNode[] = [];
133
+
134
+ const rewriter = new HTMLRewriter().on("*", {
135
+ element(element: RewriterElement) {
136
+ const tag = element.tagName.toLowerCase();
137
+ const attrs = attrsOf(element);
138
+ const node: StructuralNode = { tag, attrs };
139
+ const existing = attrs[TRACKING_ATTR_EL];
140
+
141
+ if (existing && existing.trim().length > 0) {
142
+ // confidence: "declared" — already identified. Leave it alone; just
143
+ // reserve it so nothing derived later in this pass can collide.
144
+ deriver.reserve(existing);
145
+ } else if (isTrackableNode(node)) {
146
+ const ancestors = stack.slice();
147
+ const derived = deriver.derive({ node, ancestors });
148
+ element.setAttribute(TRACKING_ATTR_EL, derived.el);
149
+ }
150
+
151
+ if (!VOID_TAGS.has(tag) && !element.selfClosing) {
152
+ stack.push(node);
153
+ element.onEndTag(() => {
154
+ stack.pop();
155
+ });
156
+ }
157
+ },
158
+ });
159
+
160
+ const rewritten = rewriter.transform(new Response(html));
161
+ return await rewritten.text();
162
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Resolve (and, on first sight, provision) ONE TENANT'S OWN raw-interaction D1
3
+ * database.
4
+ *
5
+ * The isolation claim of `dt-storage-per-tenant-d1` rests on this module: the
6
+ * ONLY way the write path gets a database handle is `resolveTenantStore()`,
7
+ * which takes a tenant id and returns a `Queryable` bound to that tenant's
8
+ * database UUID and nothing else. There is no handle in this codebase that
9
+ * spans two tenants' raw rows, so a forgotten `WHERE tenant_id = ?` cannot leak
10
+ * across tenants — the wrong-tenant rows are not in the database being queried.
11
+ *
12
+ * THE THREE STEPS, all idempotent and all cached per isolate:
13
+ * 1. NAME — derive the database name deterministically from the tenant id
14
+ * (`<prefix>-<sanitized tenant>-<8 hex of sha256(tenant)>`). The
15
+ * hash suffix is what keeps the mapping injective after
16
+ * sanitizing: two distinct tenant ids that sanitize to the same
17
+ * slug still get different databases.
18
+ * 2. DIRECTORY — read `tracking_tenant_databases` in the shared
19
+ * STOREFRONT_APPS_DB for an existing `(name, uuid)`. Present ⇒
20
+ * done, zero control-plane calls on the hot path.
21
+ * 3. PROVISION — absent ⇒ find-or-create the D1 database over REST, apply the
22
+ * idempotent schema, stamp `interaction_store_meta`, and record
23
+ * the mapping in the directory. Concurrent first-beacons race
24
+ * benignly: `ensureDatabase` re-resolves by name and the
25
+ * directory write is an upsert.
26
+ *
27
+ * BEST-EFFORT, NEVER THROWS. Every entry point returns `undefined` when the
28
+ * store cannot be reached (no REST credentials, no apps-DB directory, a
29
+ * control-plane error) — "sink unavailable, skip it", the posture
30
+ * `activity/store.ts` already sets for an unbound sink. A telemetry beacon must
31
+ * never fail because storage is unconfigured.
32
+ */
33
+ import { sha256Hex } from "@tot/public-runtime";
34
+ import { readEnv } from "../env.js";
35
+ import type { Queryable } from "../d1/catalog.js";
36
+ import {
37
+ ensureDatabase,
38
+ fromD1Rest,
39
+ type D1ControlConfig,
40
+ } from "./d1Control.js";
41
+ import {
42
+ INTERACTION_D1_SCHEMA,
43
+ INTERACTION_SCHEMA_VERSION,
44
+ interactionMetaUpsert,
45
+ } from "./interactionSchema.js";
46
+
47
+ /** Fallback name prefix when `TRACKING_D1_NAME_PREFIX` is unset. */
48
+ const DEFAULT_NAME_PREFIX = "tot-storefront-dt";
49
+
50
+ /** D1 database names are limited; keep the readable slug well inside it. */
51
+ const MAX_SLUG_LEN = 32;
52
+
53
+ /** A resolved per-tenant store: the tenant it belongs to and its query handle. */
54
+ export interface TenantInteractionStore {
55
+ tenantId: string;
56
+ databaseName: string;
57
+ databaseUuid: string;
58
+ db: Queryable;
59
+ }
60
+
61
+ /** One directory row of `tracking_tenant_databases`. */
62
+ export interface TenantDatabaseRecord {
63
+ tenant_id: string;
64
+ database_name: string;
65
+ database_uuid: string;
66
+ schema_version: number;
67
+ }
68
+
69
+ /**
70
+ * Deterministic, injective database name for a tenant. The readable slug is for
71
+ * humans in the Cloudflare dashboard; the hash suffix is what guarantees two
72
+ * different tenant ids never collide onto one database even when their slugs do.
73
+ */
74
+ export async function tenantDatabaseName(tenantId: string, prefix: string): Promise<string> {
75
+ const slug =
76
+ tenantId
77
+ .toLowerCase()
78
+ .replace(/[^a-z0-9]+/g, "-")
79
+ .replace(/^-+|-+$/g, "")
80
+ .slice(0, MAX_SLUG_LEN)
81
+ .replace(/-+$/, "") || "tenant";
82
+ const digest = (await sha256Hex(tenantId)).slice(0, 8);
83
+ return `${prefix}-${slug}-${digest}`;
84
+ }
85
+
86
+ /** The configured name prefix for this environment. */
87
+ export async function trackingNamePrefix(): Promise<string> {
88
+ return (await readEnv("TRACKING_D1_NAME_PREFIX")) ?? DEFAULT_NAME_PREFIX;
89
+ }
90
+
91
+ /** Read a tenant's directory row, or null. Never throws. */
92
+ export async function readTenantDatabase(
93
+ apps: Queryable,
94
+ tenantId: string,
95
+ ): Promise<TenantDatabaseRecord | null> {
96
+ try {
97
+ return await apps.first<TenantDatabaseRecord>(
98
+ `SELECT tenant_id, database_name, database_uuid, schema_version
99
+ FROM tracking_tenant_databases WHERE tenant_id = ?`,
100
+ tenantId,
101
+ );
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /** Upsert a tenant's directory row. Never throws. */
108
+ export async function recordTenantDatabase(
109
+ apps: Queryable,
110
+ record: { tenantId: string; databaseName: string; databaseUuid: string; at: string },
111
+ ): Promise<void> {
112
+ try {
113
+ await apps.run(
114
+ `INSERT INTO tracking_tenant_databases
115
+ (tenant_id, database_name, database_uuid, schema_version, created_at, updated_at, pruned_rows)
116
+ VALUES (?,?,?,?,?,?,0)
117
+ ON CONFLICT(tenant_id) DO UPDATE SET
118
+ database_name = excluded.database_name,
119
+ database_uuid = excluded.database_uuid,
120
+ schema_version = excluded.schema_version,
121
+ updated_at = excluded.updated_at`,
122
+ record.tenantId,
123
+ record.databaseName,
124
+ record.databaseUuid,
125
+ INTERACTION_SCHEMA_VERSION,
126
+ record.at,
127
+ record.at,
128
+ );
129
+ } catch (err) {
130
+ console.error(
131
+ `[tracking:interaction] directory write failed for ${record.tenantId}: ${
132
+ err instanceof Error ? err.message : String(err)
133
+ }`,
134
+ );
135
+ }
136
+ }
137
+
138
+ /** Apply the idempotent per-tenant schema and stamp its meta row. */
139
+ export async function applyInteractionSchema(
140
+ db: Queryable,
141
+ tenantId: string,
142
+ at: string,
143
+ ): Promise<void> {
144
+ await db.exec(INTERACTION_D1_SCHEMA);
145
+ const meta = interactionMetaUpsert(tenantId, at);
146
+ await db.run(meta.sql, ...meta.params);
147
+ }
148
+
149
+ /** Injected seams — so the whole resolution is testable without Cloudflare. */
150
+ export interface ResolveDeps {
151
+ /** The shared STOREFRONT_APPS_DB handle holding the directory table. */
152
+ apps: Queryable;
153
+ /** D1 REST credentials + transport. */
154
+ control: D1ControlConfig;
155
+ /** Environment-scoped database-name prefix. */
156
+ namePrefix: string;
157
+ /** ISO-8601 "now", for the directory + meta rows. */
158
+ at: string;
159
+ }
160
+
161
+ /**
162
+ * Resolve a tenant's store, provisioning it on first sight. Returns undefined
163
+ * when the store cannot be reached — never throws.
164
+ *
165
+ * A resolved handle is cached per isolate (`storeCache`) so the steady state is
166
+ * ZERO control-plane and ZERO directory calls per beacon.
167
+ */
168
+ const storeCache = new Map<string, TenantInteractionStore>();
169
+
170
+ /** Test seam: drop the per-isolate resolution cache. */
171
+ export function clearTenantStoreCache(): void {
172
+ storeCache.clear();
173
+ }
174
+
175
+ export async function resolveTenantStore(
176
+ tenantId: string,
177
+ deps: ResolveDeps,
178
+ ): Promise<TenantInteractionStore | undefined> {
179
+ if (!tenantId) return undefined;
180
+ const cached = storeCache.get(tenantId);
181
+ if (cached) return cached;
182
+
183
+ try {
184
+ const existing = await readTenantDatabase(deps.apps, tenantId);
185
+ if (existing && existing.schema_version >= INTERACTION_SCHEMA_VERSION) {
186
+ const store: TenantInteractionStore = {
187
+ tenantId,
188
+ databaseName: existing.database_name,
189
+ databaseUuid: existing.database_uuid,
190
+ db: fromD1Rest(deps.control, existing.database_uuid),
191
+ };
192
+ storeCache.set(tenantId, store);
193
+ return store;
194
+ }
195
+
196
+ // First sight for this tenant (or a schema bump): provision.
197
+ const databaseName = existing?.database_name ?? (await tenantDatabaseName(tenantId, deps.namePrefix));
198
+ const databaseUuid = existing?.database_uuid ?? (await ensureDatabase(deps.control, databaseName));
199
+ const db = fromD1Rest(deps.control, databaseUuid);
200
+ await applyInteractionSchema(db, tenantId, deps.at);
201
+ await recordTenantDatabase(deps.apps, { tenantId, databaseName, databaseUuid, at: deps.at });
202
+
203
+ const store: TenantInteractionStore = { tenantId, databaseName, databaseUuid, db };
204
+ storeCache.set(tenantId, store);
205
+ return store;
206
+ } catch (err) {
207
+ console.error(
208
+ `[tracking:interaction] store resolution failed for ${tenantId}: ${
209
+ err instanceof Error ? err.message : String(err)
210
+ }`,
211
+ );
212
+ return undefined;
213
+ }
214
+ }