@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.
- package/apps/storefront/.astro/content.d.ts +159 -0
- package/apps/storefront/.astro/integrations/_astrojs_cloudflare/cloudflare.d.ts +1 -0
- package/apps/storefront/.astro/types.d.ts +3 -0
- package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
- package/apps/storefront/node_modules/.bin/acorn +43 -0
- package/apps/storefront/node_modules/.bin/astro +43 -0
- package/apps/storefront/node_modules/.bin/astro-check +43 -0
- package/apps/storefront/node_modules/.bin/browserslist +43 -0
- package/apps/storefront/node_modules/.bin/cf-wrangler +43 -0
- package/apps/storefront/node_modules/.bin/depcruise +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-baseline +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-fmt +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-wrap-stream-in-html +43 -0
- package/apps/storefront/node_modules/.bin/dependency-cruise +43 -0
- package/apps/storefront/node_modules/.bin/dependency-cruiser +43 -0
- package/apps/storefront/node_modules/.bin/drizzle-kit +43 -0
- package/apps/storefront/node_modules/.bin/esbuild +43 -0
- package/apps/storefront/node_modules/.bin/jiti +43 -0
- package/apps/storefront/node_modules/.bin/playwright +43 -0
- package/apps/storefront/node_modules/.bin/playwright-core +43 -0
- package/apps/storefront/node_modules/.bin/prettier +43 -0
- package/apps/storefront/node_modules/.bin/rollup +43 -0
- package/apps/storefront/node_modules/.bin/tsc +43 -0
- package/apps/storefront/node_modules/.bin/tsserver +43 -0
- package/apps/storefront/node_modules/.bin/tsx +43 -0
- package/apps/storefront/node_modules/.bin/vite +43 -0
- package/apps/storefront/node_modules/.bin/vitest +43 -0
- package/apps/storefront/node_modules/.bin/workerd +43 -0
- package/apps/storefront/node_modules/.bin/wrangler +43 -0
- package/apps/storefront/node_modules/.bin/wrangler2 +43 -0
- package/apps/storefront/node_modules/.bin/yaml +43 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js +58 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js +741 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js +2549 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/_metadata.json +150 -0
- package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js +434 -0
- package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js +2705 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
- package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js +1535 -0
- package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js +48 -0
- package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js +42 -0
- package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/package.json +3 -0
- package/apps/storefront/node_modules/.vite/deps/preact.js +310 -0
- package/apps/storefront/node_modules/.vite/deps/preact.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat.js +420 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat_jsx-runtime.js +4 -0
- package/apps/storefront/node_modules/.vite/deps/preact_debug.js +236 -0
- package/apps/storefront/node_modules/.vite/deps/preact_debug.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_devtools.js +12 -0
- package/apps/storefront/node_modules/.vite/deps/preact_devtools.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_hooks.js +192 -0
- package/apps/storefront/node_modules/.vite/deps/preact_hooks.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-dev-runtime.js +3 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js +92 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js +59 -0
- package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/rolldown-runtime-BPOCksWG.js +24 -0
- package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js +189 -0
- package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js +13 -0
- package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js +458 -0
- package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js +1016 -0
- package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js +32 -0
- package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js +113 -0
- package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +8 -0
- package/apps/storefront/node_modules/.vite/deps_ssr/package.json +3 -0
- package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
- package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
- package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
- package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
- package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
- package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
- package/apps/storefront/src/layouts/Layout.astro +65 -7
- package/apps/storefront/src/lib/apps/registryAccess.ts +45 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +4 -4
- package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
- package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
- package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
- package/apps/storefront/src/lib/rawChrome.ts +73 -14
- package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
- package/apps/storefront/src/lib/shared/capability.ts +68 -0
- package/apps/storefront/src/lib/shared/developerInvites.ts +1250 -0
- package/apps/storefront/src/lib/shared/invitePreflight.ts +123 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
- package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
- package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
- package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
- package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
- package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
- package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
- package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
- package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
- package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
- package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
- package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
- package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
- package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +38 -4
- package/apps/storefront/src/lib/webhooks/emit.ts +1 -1
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +1 -1
- package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -1
- package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
- package/apps/storefront/src/pages/index.astro +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +1 -1
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
- package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
- package/package.json +1 -1
- package/packages/public-runtime/node_modules/.bin/tsc +43 -0
- package/packages/public-runtime/node_modules/.bin/tsserver +43 -0
- package/packages/public-runtime/node_modules/.bin/vitest +43 -0
- package/packages/public-runtime/src/chrome.ts +18 -7
- package/packages/public-runtime/src/index.ts +10 -0
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
- package/packages/public-runtime/src/interaction-tracking.ts +364 -0
- package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
- package/pnpm-workspace.yaml +3 -0
- package/scripts/tenant/validate.mjs +306 -1
- package/scripts/tot-dev.mjs +59 -3
- package/tenants/home/public/js/storefront.js +6 -87
- package/apps/storefront/src/lib/apps/adminService.ts +0 -116
- package/apps/storefront/src/lib/apps/adminSession.ts +0 -214
- package/apps/storefront/src/lib/apps/apiAuth.ts +0 -91
- package/apps/storefront/src/lib/apps/apiRoute.ts +0 -35
- package/apps/storefront/src/lib/apps/catalogMapper.ts +0 -39
- package/apps/storefront/src/lib/apps/consentConfigStore.ts +0 -228
- package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +0 -447
- package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +0 -367
- package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +0 -315
- package/apps/storefront/src/lib/apps/healthAggregate.ts +0 -66
- package/apps/storefront/src/lib/apps/integrationLifecycle.ts +0 -614
- package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +0 -125
- package/apps/storefront/src/lib/apps/integrationReadModel.ts +0 -563
- package/apps/storefront/src/lib/apps/integrationsTab.ts +0 -547
- package/apps/storefront/src/lib/apps/integrationsView.ts +0 -106
- package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +0 -599
- package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +0 -170
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +0 -121
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +0 -148
- package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +0 -16
- package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +0 -105
- package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +0 -177
- package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +0 -251
- package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +0 -241
- package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +0 -211
- package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +0 -409
- package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +0 -368
- package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +0 -188
- package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +0 -291
- package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +0 -95
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +0 -91
- package/apps/storefront/src/lib/content-edit/client.ts +0 -286
- package/apps/storefront/src/lib/content-edit/index.ts +0 -14
- package/apps/storefront/src/lib/content-edit/route.ts +0 -25
- package/apps/storefront/src/lib/content-edit/types.ts +0 -19
- 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
|
+
}
|