@agent-native/core-corpus 0.216.0 → 0.218.0

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 (196) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +61 -1
  3. package/corpus/templates/analytics/AGENTS.md +1 -1
  4. package/corpus/templates/analytics/CHANGELOG.md +3 -0
  5. package/corpus/templates/analytics/actions/get-session-replay-summary.ts +9 -65
  6. package/corpus/templates/analytics/actions/list-event-catalog.ts +1 -1
  7. package/corpus/templates/analytics/actions/list-session-outputs.ts +40 -0
  8. package/corpus/templates/analytics/actions/list-session-recordings.ts +17 -4
  9. package/corpus/templates/analytics/actions/view-screen.ts +29 -3
  10. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +38 -167
  11. package/corpus/templates/analytics/app/i18n/en-US.ts +27 -0
  12. package/corpus/templates/analytics/app/pages/sessions/EventCatalogPage.tsx +18 -1
  13. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +3 -1
  14. package/corpus/templates/analytics/app/pages/sessions/SessionOutputs.tsx +197 -0
  15. package/corpus/templates/analytics/app/pages/sessions/SessionsTriagePage.tsx +114 -4
  16. package/corpus/templates/analytics/changelog/2026-10-15-sessions-can-find-people-who-made-a-slides-deck-but-didn-t-p.md +6 -0
  17. package/corpus/templates/analytics/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  18. package/corpus/templates/analytics/changelog/2026-10-16-the-activation-funnel-counts-visitors-who-decline-analytics-.md +6 -0
  19. package/corpus/templates/analytics/server/db/schema.ts +67 -0
  20. package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +9 -0
  21. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +28 -22
  22. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +36 -1
  23. package/corpus/templates/analytics/server/lib/output-outcomes.ts +712 -0
  24. package/corpus/templates/analytics/server/lib/replay-origins.ts +33 -0
  25. package/corpus/templates/analytics/server/lib/session-event-index.ts +20 -0
  26. package/corpus/templates/analytics/server/lib/session-performance.ts +6 -40
  27. package/corpus/templates/analytics/server/lib/session-recording-associations.ts +30 -1
  28. package/corpus/templates/analytics/server/lib/session-replay-triage.ts +99 -0
  29. package/corpus/templates/analytics/server/lib/session-replay.ts +45 -31
  30. package/corpus/templates/analytics/server/lib/sessions-triage-lab.ts +8 -2
  31. package/corpus/templates/analytics/server/plugins/db.ts +46 -0
  32. package/corpus/templates/analytics/shared/output-use.ts +205 -0
  33. package/corpus/templates/analytics/shared/session-events.ts +6 -0
  34. package/corpus/templates/assets/CHANGELOG.md +6 -0
  35. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +51 -117
  36. package/corpus/templates/assets/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  37. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +34 -101
  38. package/corpus/templates/brain/app/routes/sources.tsx +1 -1
  39. package/corpus/templates/brain/vitest.config.ts +7 -0
  40. package/corpus/templates/calendar/CHANGELOG.md +12 -0
  41. package/corpus/templates/calendar/actions/create-event.ts +2 -4
  42. package/corpus/templates/calendar/actions/update-event.ts +35 -6
  43. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +1 -1
  44. package/corpus/templates/calendar/changelog/2026-10-16-booking-links-show-localized-visibility-badges.md +6 -0
  45. package/corpus/templates/calendar/changelog/2026-10-21-solo-events-avoid-notification-approval.md +6 -0
  46. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +62 -101
  47. package/corpus/templates/clips/CHANGELOG.md +7 -20
  48. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +20 -77
  49. package/corpus/templates/clips/app/components/player/share-dialog.tsx +63 -90
  50. package/corpus/templates/clips/app/components/sharing/share-test-support.tsx +180 -0
  51. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +5 -584
  52. package/corpus/templates/clips/changelog/2026-10-16-share-dialogs-make-it-easier-to-see-who-has-access-and-manag.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-10-21-share-panels-describe-general-access-in-a-sentence.md +6 -0
  54. package/corpus/templates/community/qr/package.json +1 -1
  55. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -3
  56. package/corpus/templates/content/CHANGELOG.md +10 -0
  57. package/corpus/templates/content/actions/_browser-document-save-attempt.ts +23 -1
  58. package/corpus/templates/content/actions/_builder-cms-blob-custody.ts +467 -0
  59. package/corpus/templates/content/actions/_builder-cms-execution-lease.ts +17 -0
  60. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +5 -0
  61. package/corpus/templates/content/actions/_builder-cms-read-client.ts +881 -21
  62. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +28 -2
  63. package/corpus/templates/content/actions/_builder-cms-write-client.ts +109 -11
  64. package/corpus/templates/content/actions/_builder-docs-client.ts +1 -0
  65. package/corpus/templates/content/actions/_builder-private-blob.test-fixture.ts +90 -0
  66. package/corpus/templates/content/actions/_builder-source-execution-guard.ts +48 -0
  67. package/corpus/templates/content/actions/_content-save-outcomes.ts +5 -0
  68. package/corpus/templates/content/actions/_database-source-utils.ts +290 -8
  69. package/corpus/templates/content/actions/_database-utils.ts +93 -7
  70. package/corpus/templates/content/actions/_database-view-setup.ts +21 -2
  71. package/corpus/templates/content/actions/_delete-content-space.ts +8 -1
  72. package/corpus/templates/content/actions/_github-source-freshness.ts +30 -4
  73. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +23 -6
  74. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +110 -26
  75. package/corpus/templates/content/actions/change-content-database-source-role.ts +51 -7
  76. package/corpus/templates/content/actions/delete-document.ts +41 -3
  77. package/corpus/templates/content/actions/disconnect-content-database-source.ts +68 -2
  78. package/corpus/templates/content/actions/disconnect-local-folder-source.ts +33 -2
  79. package/corpus/templates/content/actions/execute-builder-source-execution.ts +900 -140
  80. package/corpus/templates/content/actions/get-document.ts +2 -0
  81. package/corpus/templates/content/actions/permanently-delete-document.ts +6 -0
  82. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +155 -13
  83. package/corpus/templates/content/actions/prepare-builder-source-review.ts +299 -73
  84. package/corpus/templates/content/actions/set-live-body-switch.ts +45 -0
  85. package/corpus/templates/content/actions/update-document.ts +139 -20
  86. package/corpus/templates/content/actions/validate-builder-source-execution.ts +135 -26
  87. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +119 -30
  88. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +56 -13
  89. package/corpus/templates/content/app/components/editor/SuggestionText.tsx +18 -3
  90. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +89 -22
  91. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +23 -0
  92. package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +27 -2
  93. package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +32 -8
  94. package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +27 -2
  95. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +1 -0
  96. package/corpus/templates/content/app/components/editor/extensions/SuggestionHighlight.ts +383 -34
  97. package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +31 -0
  98. package/corpus/templates/content/app/components/editor/suggestions/presentation-rebase.ts +82 -4
  99. package/corpus/templates/content/app/global.css +31 -0
  100. package/corpus/templates/content/app/hooks/use-content-database.ts +40 -2
  101. package/corpus/templates/content/app/hooks/use-documents.ts +14 -0
  102. package/corpus/templates/content/app/i18n-data.ts +1 -0
  103. package/corpus/templates/content/changelog/2026-10-16-accepting-a-suggestion-on-a-page-that-changed-since-it-was-s.md +6 -0
  104. package/corpus/templates/content/changelog/2026-10-17-builder-content-avoids-retrying-a-write-when-builder-accepte.md +6 -0
  105. package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +1 -1
  106. package/corpus/templates/content/changelog/2026-10-20-builder-content-sync-keeps-read-and-write-status-accurate-wh.md +6 -0
  107. package/corpus/templates/content/changelog/2026-10-20-suggested-edits-that-add-remove-split-or-merge-paragraphs-li.md +6 -0
  108. package/corpus/templates/content/changelog/2026-10-21-list-rows-can-show-each-pages-first-lines.md +6 -0
  109. package/corpus/templates/content/changelog/2026-10-22-a-collection-linked-to-a-big-github-folder-fills-faster-and-p.md +6 -0
  110. package/corpus/templates/content/docs/product/capabilities/content.source.adapters.md +29 -0
  111. package/corpus/templates/content/parity/matrix.md +42 -42
  112. package/corpus/templates/content/parity/matrix.ts +3 -1
  113. package/corpus/templates/content/server/db/schema.ts +20 -0
  114. package/corpus/templates/content/server/lib/content-trash-purge.ts +15 -1
  115. package/corpus/templates/content/server/lib/live-body-writer.ts +138 -26
  116. package/corpus/templates/content/server/live-copy/binding.ts +31 -0
  117. package/corpus/templates/content/server/live-copy/bounded-task.d.ts +6 -0
  118. package/corpus/templates/content/server/live-copy/exec-db.ts +1 -0
  119. package/corpus/templates/content/server/live-copy/holds.ts +59 -0
  120. package/corpus/templates/content/server/live-copy/persist.ts +656 -0
  121. package/corpus/templates/content/server/live-copy/switch.ts +136 -0
  122. package/corpus/templates/content/server/live-copy/task-names.ts +4 -0
  123. package/corpus/templates/content/server/live-copy/tasks.ts +183 -0
  124. package/corpus/templates/content/server/live-copy/write-cause.ts +85 -0
  125. package/corpus/templates/content/server/live-copy/write.ts +85 -0
  126. package/corpus/templates/content/server/plugins/builder-private-payload-cleanup.ts +22 -0
  127. package/corpus/templates/content/server/plugins/collab.ts +3 -0
  128. package/corpus/templates/content/server/plugins/db.ts +28 -0
  129. package/corpus/templates/content/shared/api.ts +30 -1
  130. package/corpus/templates/content/shared/database-item-layout.ts +54 -22
  131. package/corpus/templates/content/shared/document-intent-merge.ts +16 -9
  132. package/corpus/templates/content/shared/og-description.ts +258 -36
  133. package/corpus/templates/content/shared/suggestion-diff.ts +23 -6
  134. package/corpus/templates/content/shared/suggestion-formatting.ts +44 -14
  135. package/corpus/templates/content/shared/suggestion-text.ts +20 -5
  136. package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +5 -1
  137. package/corpus/templates/design/.generated/bridge/shader-runtime.generated.ts +25 -4
  138. package/corpus/templates/design/CHANGELOG.md +5 -0
  139. package/corpus/templates/design/actions/get-getting-started.ts +53 -0
  140. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +10 -0
  141. package/corpus/templates/design/app/components/design/bridge/native-shader-runtime.bridge.ts +132 -79
  142. package/corpus/templates/design/app/components/design/bridge/native-source-provider.ts +268 -130
  143. package/corpus/templates/design/app/components/design/bridge/native-worker-scene-host.ts +122 -42
  144. package/corpus/templates/design/app/components/design/bridge/shader-runtime.bridge.ts +41 -4
  145. package/corpus/templates/design/app/components/design/design-canvas/text-content-change-result.ts +46 -12
  146. package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +15 -8
  147. package/corpus/templates/design/app/components/layout/GettingStarted.tsx +50 -0
  148. package/corpus/templates/design/app/components/layout/Sidebar.tsx +3 -0
  149. package/corpus/templates/design/app/components/templates/DesignTemplateGrid.tsx +163 -0
  150. package/corpus/templates/design/app/components/templates/DesignTemplateLibrary.tsx +65 -78
  151. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +6 -2
  152. package/corpus/templates/design/app/global.css +6 -0
  153. package/corpus/templates/design/app/i18n/en-US.ts +17 -0
  154. package/corpus/templates/design/app/lib/canvas-load-outcome.ts +147 -0
  155. package/corpus/templates/design/app/lib/canvas-outcome-tracking.ts +531 -0
  156. package/corpus/templates/design/app/lib/canvas-style-kind.ts +50 -0
  157. package/corpus/templates/design/app/pages/DesignSystems.tsx +1 -1
  158. package/corpus/templates/design/app/pages/Index.tsx +28 -67
  159. package/corpus/templates/design/app/pages/design-editor/commands/commit-visual-styles.ts +86 -29
  160. package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +38 -3
  161. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-core.ts +9 -0
  162. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-files-and-saving.ts +2 -0
  163. package/corpus/templates/design/changelog/2026-10-16-design-pages-use-localized-visibility-badges-and-a-shared-ow.md +6 -0
  164. package/corpus/templates/design/changelog/2026-10-21-a-getting-started-checklist-at-the-bottom-of-the-sidebar-tra.md +6 -0
  165. package/corpus/templates/design/changelog/2026-10-21-templates-now-open-a-read-only-preview-from-the-card-and-the.md +6 -0
  166. package/corpus/templates/design/changelog/2026-10-22-collapsed-shader-layers-no-longer-interrupt-other-shaders-on.md +6 -0
  167. package/corpus/templates/design/changelog/2026-10-22-shader-fills-keep-rendering-when-other-screen-content-cannot.md +6 -0
  168. package/corpus/templates/design/scripts/design-export-validation/export-baseline.json +24 -0
  169. package/corpus/templates/design/server/lib/getting-started.ts +267 -0
  170. package/corpus/templates/design/server/plugins/getting-started.ts +5 -0
  171. package/corpus/templates/design/shared/design-template-presets/analytics-dashboard.ts +127 -0
  172. package/corpus/templates/design/shared/design-template-presets/design-studio.ts +98 -0
  173. package/corpus/templates/design/shared/design-template-presets/messaging-app.ts +101 -0
  174. package/corpus/templates/design/shared/design-template-presets/project-tasks.ts +120 -0
  175. package/corpus/templates/design/shared/design-template-presets.ts +8 -0
  176. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  177. package/corpus/templates/dispatch/app/i18n-data.ts +75 -0
  178. package/corpus/templates/dispatch/changelog/2026-10-21-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  179. package/corpus/templates/factory/app/components/layout/Sidebar.tsx +34 -100
  180. package/corpus/templates/forms/app/pages/FormsListPage.tsx +1 -1
  181. package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
  182. package/corpus/templates/plan/CHANGELOG.md +6 -0
  183. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +34 -109
  184. package/corpus/templates/plan/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  185. package/corpus/templates/slides/CHANGELOG.md +7 -0
  186. package/corpus/templates/slides/actions/delete-deck.ts +7 -0
  187. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +1 -1
  188. package/corpus/templates/slides/app/components/design-system/DesignSystemCard.tsx +1 -1
  189. package/corpus/templates/slides/app/pages/Index.tsx +7 -2
  190. package/corpus/templates/slides/changelog/2026-10-16-deck-lists-use-the-shared-ownership-filter-and-visibility-ba.md +6 -0
  191. package/corpus/templates/slides/changelog/2026-10-21-connected-deck-deletion-requires-approval.md +6 -0
  192. package/corpus/templates/slides/scripts/edit-fidelity/client-action.ts +61 -0
  193. package/corpus/templates/slides/scripts/edit-fidelity/run.ts +10 -0
  194. package/package.json +1 -1
  195. package/corpus/templates/chat/app/lib/sidebar-thread-state.ts +0 -29
  196. package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +0 -83
@@ -0,0 +1,712 @@
1
+ import { accessFilter } from "@agent-native/core/sharing";
2
+ import { type AnyColumn, and, eq, inArray, lt, lte, sql } from "drizzle-orm";
3
+ import { alias } from "drizzle-orm/pg-core";
4
+
5
+ import {
6
+ MAX_OUTPUTS_PER_RECORDING,
7
+ OUTPUT_USE_APPS,
8
+ OUTPUT_USE_EVENT_NAMES,
9
+ OUTPUT_USE_WINDOW_DAYS,
10
+ OUTPUT_USE_WINDOW_MS,
11
+ type OutputUseApp,
12
+ isOutputUseApp,
13
+ outputIdOf,
14
+ outputUrl,
15
+ outputUseRule,
16
+ type SessionOutput,
17
+ type SessionRecordingOutputs,
18
+ } from "../../shared/output-use.js";
19
+ import { getDb, schema } from "../db/index.js";
20
+ import { parseAllowedReplayOrigins } from "./replay-origins.js";
21
+ import {
22
+ recordingTenantSql,
23
+ type SessionEventIndexInputRow,
24
+ type SessionEventScope,
25
+ sessionEventTenantKey,
26
+ sessionIdOf,
27
+ stableId,
28
+ viewerReadsRecordingEventsSql,
29
+ viewerTenantKeys,
30
+ warnIndexFailure,
31
+ } from "./session-event-index.js";
32
+ import {
33
+ listRecordingSessionAssociations,
34
+ sessionRecordingAssociationsReady,
35
+ } from "./session-recording-associations.js";
36
+
37
+ /**
38
+ * Output outcomes: the outputs each tenant's apps made, as defined in
39
+ * `shared/output-use.ts`, and when each was first used. Ingest derives them
40
+ * inside the transaction that stores the events, whatever the sink, so no
41
+ * view reads raw events or BigQuery. A write that fails rolls back to a
42
+ * savepoint, and a batch ingest fails to store is lost; either records the
43
+ * event-time span the batch covered. An output
44
+ * whose use window overlaps such a span is unmeasured, never unused: the
45
+ * lost batch could have held its use.
46
+ */
47
+
48
+ const OUTPUT_OUTCOME_RETENTION_BUFFER_DAYS = 2;
49
+ const SESSION_ID_BATCH_SIZE = 1_000;
50
+ const DAY_MS = 24 * 60 * 60_000;
51
+
52
+ let outputOutcomeTablesReady = false;
53
+
54
+ type OutputOutcomeRow = typeof schema.analyticsOutputOutcomes.$inferInsert;
55
+ type OutputOutcomeGapRow =
56
+ typeof schema.analyticsOutputOutcomeGaps.$inferInsert;
57
+
58
+ interface OutputTenant {
59
+ tenantKey: string;
60
+ ownerEmail: string;
61
+ orgId: string | null;
62
+ }
63
+
64
+ async function coverageTableExists(db: any): Promise<boolean> {
65
+ const result = await db.execute(
66
+ sql`SELECT to_regclass('analytics_output_outcome_coverage') AS table_name`,
67
+ );
68
+ const rows = Array.isArray(result) ? result : result?.rows;
69
+ if (!Array.isArray(rows)) {
70
+ throw new Error("Postgres table existence check returned no row array");
71
+ }
72
+ const value = rows[0]?.table_name;
73
+ if (value === null) return false;
74
+ if (typeof value === "string" && value) return true;
75
+ throw new Error("Postgres table existence check returned an invalid value");
76
+ }
77
+
78
+ /**
79
+ * Code deploys before the scheduled migration creates these tables. Until
80
+ * the coverage table (created last) exists nothing is recorded, and reads
81
+ * say so. Tables are only ever added, so only "ready" is cached.
82
+ */
83
+ async function outputOutcomesReady(db: any): Promise<boolean> {
84
+ outputOutcomeTablesReady ||= await coverageTableExists(db);
85
+ return outputOutcomeTablesReady;
86
+ }
87
+
88
+ /** Rows some rule reads, judged by name and app before any parsing. */
89
+ function outputRuleRows(
90
+ rows: readonly SessionEventIndexInputRow[],
91
+ ): SessionEventIndexInputRow[] {
92
+ return rows.filter(
93
+ (row) =>
94
+ OUTPUT_USE_EVENT_NAMES.has(row.eventName) &&
95
+ isOutputUseApp(row.app) &&
96
+ Boolean(row.ownerEmail) &&
97
+ Boolean(row.timestamp),
98
+ );
99
+ }
100
+
101
+ /** Ingest serializes properties itself, so a parse failure is a real error. */
102
+ function parseProperties(properties: string): Record<string, unknown> {
103
+ const parsed: unknown = JSON.parse(properties);
104
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
105
+ ? (parsed as Record<string, unknown>)
106
+ : {};
107
+ }
108
+
109
+ function computeUseBy(madeAt: string): string {
110
+ return new Date(Date.parse(madeAt) + OUTPUT_USE_WINDOW_MS).toISOString();
111
+ }
112
+
113
+ function sortedById<T extends { id: string }>(rows: Iterable<T>): T[] {
114
+ return [...rows].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
115
+ }
116
+
117
+ /**
118
+ * One batch's outcomes, merged the way the upsert merges them with stored
119
+ * rows: the earliest making and its session, and the earliest qualifying use.
120
+ * Every tenant that sent a rule's event gets coverage, even when nothing in
121
+ * the batch qualified.
122
+ */
123
+ export function aggregateOutputOutcomeRows(
124
+ rows: readonly SessionEventIndexInputRow[],
125
+ ): { outcomes: OutputOutcomeRow[]; tenants: OutputTenant[] } {
126
+ const outcomes = new Map<string, OutputOutcomeRow>();
127
+ const tenants = new Map<string, OutputTenant>();
128
+ for (const row of outputRuleRows(rows)) {
129
+ const app = row.app as OutputUseApp;
130
+ const rule = outputUseRule(app)!;
131
+ const made = rule.madeEvents.includes(row.eventName);
132
+ const used = rule.usedEvents.find(
133
+ (event) => event.eventName === row.eventName,
134
+ );
135
+ const orgId = row.orgId || null;
136
+ const tenantKey = sessionEventTenantKey(row.ownerEmail, orgId);
137
+ tenants.set(tenantKey, {
138
+ tenantKey,
139
+ ownerEmail: row.ownerEmail,
140
+ orgId,
141
+ });
142
+ const properties = parseProperties(row.properties);
143
+ const outputId = outputIdOf(properties);
144
+ if (!outputId) continue;
145
+ if (!made && !used?.qualifies(properties)) continue;
146
+
147
+ const id = stableId("aoo", [tenantKey, app, outputId]);
148
+ let outcome = outcomes.get(id);
149
+ if (!outcome) {
150
+ outcome = {
151
+ id,
152
+ tenantKey,
153
+ ownerEmail: row.ownerEmail,
154
+ orgId,
155
+ app,
156
+ outputType: rule.outputType,
157
+ outputId,
158
+ madeAt: null,
159
+ useBy: null,
160
+ madeSessionId: null,
161
+ firstUsedAt: null,
162
+ usedEvent: null,
163
+ lastSeenAt: row.timestamp,
164
+ };
165
+ outcomes.set(id, outcome);
166
+ }
167
+ if (row.timestamp > outcome.lastSeenAt) outcome.lastSeenAt = row.timestamp;
168
+ const sessionId = sessionIdOf(row.sessionId);
169
+ if (made) {
170
+ if (!outcome.madeAt || row.timestamp < outcome.madeAt) {
171
+ outcome.madeAt = row.timestamp;
172
+ outcome.useBy = computeUseBy(row.timestamp);
173
+ outcome.madeSessionId = sessionId ?? outcome.madeSessionId ?? null;
174
+ } else if (!outcome.madeSessionId) {
175
+ outcome.madeSessionId = sessionId;
176
+ }
177
+ } else if (!outcome.firstUsedAt || row.timestamp < outcome.firstUsedAt) {
178
+ outcome.firstUsedAt = row.timestamp;
179
+ outcome.usedEvent = row.eventName;
180
+ }
181
+ }
182
+ // Sorted by id so concurrent batches lock shared rows in the same order.
183
+ return {
184
+ outcomes: sortedById(outcomes.values()),
185
+ tenants: [...tenants.values()].sort((a, b) =>
186
+ a.tenantKey < b.tenantKey ? -1 : a.tenantKey > b.tenantKey ? 1 : 0,
187
+ ),
188
+ };
189
+ }
190
+
191
+ /** One marker per tenant spanning the event times its failed batch held. */
192
+ function outputGapRows(
193
+ rows: readonly SessionEventIndexInputRow[],
194
+ recordedAt: string,
195
+ ): OutputOutcomeGapRow[] {
196
+ const spans = new Map<string, OutputOutcomeGapRow>();
197
+ for (const row of rows) {
198
+ const orgId = row.orgId || null;
199
+ const tenantKey = sessionEventTenantKey(row.ownerEmail, orgId);
200
+ const span = spans.get(tenantKey);
201
+ if (!span) {
202
+ spans.set(tenantKey, {
203
+ id: "",
204
+ tenantKey,
205
+ ownerEmail: row.ownerEmail,
206
+ orgId,
207
+ firstAt: row.timestamp,
208
+ lastAt: row.timestamp,
209
+ recordedAt,
210
+ });
211
+ continue;
212
+ }
213
+ if (row.timestamp < span.firstAt) span.firstAt = row.timestamp;
214
+ if (row.timestamp > span.lastAt) span.lastAt = row.timestamp;
215
+ }
216
+ return sortedById(
217
+ [...spans.values()].map((span) => ({
218
+ ...span,
219
+ id: stableId("aoog", [span.tenantKey, span.firstAt, span.lastAt]),
220
+ })),
221
+ );
222
+ }
223
+
224
+ /**
225
+ * Runs inside the transaction that stores the events. A failure rolls back
226
+ * to a savepoint and never fails ingest; only a failed gap marker does,
227
+ * taking the events with it, so no output silently loses a use. The
228
+ * readiness probe belongs inside the savepoint: a failed query aborts the
229
+ * transaction it runs in.
230
+ */
231
+ export async function recordOutputOutcomes(
232
+ tx: any,
233
+ rows: readonly SessionEventIndexInputRow[],
234
+ receivedAt: string,
235
+ ): Promise<void> {
236
+ const ruleRows = outputRuleRows(rows);
237
+ if (!ruleRows.length) return;
238
+ try {
239
+ await tx.transaction(async (savepoint: any) => {
240
+ if (!(await outputOutcomesReady(savepoint))) return;
241
+ const { outcomes, tenants } = aggregateOutputOutcomeRows(ruleRows);
242
+ if (outcomes.length) {
243
+ const o = schema.analyticsOutputOutcomes;
244
+ const earlierMade = sql`excluded.made_at is not null and (${o.madeAt} is null or excluded.made_at < ${o.madeAt})`;
245
+ const earlierUse = sql`excluded.first_used_at is not null and (${o.firstUsedAt} is null or excluded.first_used_at < ${o.firstUsedAt})`;
246
+ await savepoint
247
+ .insert(o)
248
+ .values(outcomes)
249
+ .onConflictDoUpdate({
250
+ target: [o.tenantKey, o.app, o.outputId],
251
+ set: {
252
+ // least() and greatest() skip nulls in Postgres.
253
+ madeAt: sql`least(${o.madeAt}, excluded.made_at)`,
254
+ useBy: sql`least(${o.useBy}, excluded.use_by)`,
255
+ madeSessionId: sql`case when ${earlierMade} then coalesce(excluded.made_session_id, ${o.madeSessionId}) else coalesce(${o.madeSessionId}, excluded.made_session_id) end`,
256
+ firstUsedAt: sql`least(${o.firstUsedAt}, excluded.first_used_at)`,
257
+ usedEvent: sql`case when ${earlierUse} then excluded.used_event else ${o.usedEvent} end`,
258
+ lastSeenAt: sql`greatest(${o.lastSeenAt}, excluded.last_seen_at)`,
259
+ },
260
+ });
261
+ }
262
+ await savepoint
263
+ .insert(schema.analyticsOutputOutcomeCoverage)
264
+ .values(tenants.map((tenant) => ({ ...tenant, startedAt: receivedAt })))
265
+ .onConflictDoNothing();
266
+ });
267
+ } catch (error) {
268
+ if (!(await coverageTableExists(tx))) {
269
+ warnIndexFailure(
270
+ "Output outcome tables are not migrated yet; events were stored without output outcomes:",
271
+ error,
272
+ );
273
+ return;
274
+ }
275
+ await tx
276
+ .insert(schema.analyticsOutputOutcomeGaps)
277
+ .values(outputGapRows(ruleRows, receivedAt))
278
+ .onConflictDoNothing();
279
+ warnIndexFailure(
280
+ "Output outcome write failed; outputs in its span read as unmeasured:",
281
+ error,
282
+ );
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Ingest rejected or failed to store a batch, and the tracker does not resend
288
+ * it, so any use it held is gone. Records its span outside the rolled-back
289
+ * transaction so outputs it could have used read as unmeasured, not unused.
290
+ */
291
+ export async function recordLostOutputBatch(
292
+ db: any,
293
+ rows: readonly SessionEventIndexInputRow[],
294
+ receivedAt: string,
295
+ ): Promise<void> {
296
+ const ruleRows = outputRuleRows(rows);
297
+ if (!ruleRows.length) return;
298
+ try {
299
+ if (!(await outputOutcomesReady(db))) return;
300
+ await db
301
+ .insert(schema.analyticsOutputOutcomeGaps)
302
+ .values(outputGapRows(ruleRows, receivedAt))
303
+ .onConflictDoNothing();
304
+ } catch (error) {
305
+ console.error(
306
+ "[first-party-analytics] A failed batch's output span was not recorded; a use it held may read as unused:",
307
+ error,
308
+ );
309
+ }
310
+ }
311
+
312
+ /** A gap marker whose span overlaps the use window of the output in `o`. */
313
+ function gapOverlapsOutputSql(o: typeof schema.analyticsOutputOutcomes) {
314
+ const gaps = schema.analyticsOutputOutcomeGaps;
315
+ return sql`${gaps.tenantKey} = ${o.tenantKey}
316
+ and ${gaps.firstAt} <= ${o.useBy}
317
+ and ${gaps.lastAt} >= ${o.madeAt}`;
318
+ }
319
+
320
+ /**
321
+ * A gap marker whose span overlaps the recording in `r`. The lost batch may
322
+ * have held an output that recording's session made, and no row records it.
323
+ */
324
+ function gapOverlapsRecordingSql(r: typeof schema.sessionRecordings) {
325
+ const gaps = schema.analyticsOutputOutcomeGaps;
326
+ return sql`${gaps.tenantKey} = ${recordingTenantSql(r)}
327
+ and ${gaps.firstAt} <= coalesce(${r.endedAt}, ${r.lastIngestedAt}, ${r.startedAt})
328
+ and ${gaps.lastAt} >= ${r.startedAt}`;
329
+ }
330
+
331
+ /** An output stored in `o` that was made but not used once its window closed. */
332
+ function unusedOutputSql(
333
+ o: typeof schema.analyticsOutputOutcomes,
334
+ nowIso: string,
335
+ ) {
336
+ const coverage = schema.analyticsOutputOutcomeCoverage;
337
+ const gaps = schema.analyticsOutputOutcomeGaps;
338
+ return sql`${o.madeAt} is not null
339
+ and ${o.useBy} <= ${nowIso}
340
+ and ${o.madeAt} >= (select ${coverage.startedAt} from ${coverage} where ${coverage.tenantKey} = ${o.tenantKey})
341
+ and (${o.firstUsedAt} is null or ${o.firstUsedAt} > ${o.useBy})
342
+ and not exists (select 1 from ${gaps} where ${gapOverlapsOutputSql(o)})`;
343
+ }
344
+
345
+ /**
346
+ * Conditions on `session_recordings` for "made it, didn't use it": the
347
+ * recording's session made an output of `app` whose use window has closed
348
+ * with no qualifying use. Each lookup is correlated to the recording's own
349
+ * tenant and sessions, so it never widens the recording access filter it is
350
+ * combined with, and a recording shared from another tenant never matches.
351
+ * Nor does one a lost batch overlapped, whose outputs read as unknown.
352
+ */
353
+ export async function unusedOutputConditions(
354
+ scope: SessionEventScope,
355
+ app: OutputUseApp | undefined,
356
+ now = new Date(),
357
+ ) {
358
+ if (!app) return [];
359
+ const db = getDb() as any;
360
+ if (!(await outputOutcomesReady(db))) return [sql`false`];
361
+ const associationsReady = await sessionRecordingAssociationsReady(db);
362
+ const r = schema.sessionRecordings;
363
+ const o = schema.analyticsOutputOutcomes;
364
+ const association = alias(
365
+ schema.sessionRecordingSessionAssociations,
366
+ "unused_output_association",
367
+ );
368
+ const associationPresence = alias(
369
+ schema.sessionRecordingSessionAssociations,
370
+ "unused_output_association_presence",
371
+ );
372
+ const tenant = recordingTenantSql(r);
373
+ const unused = unusedOutputSql(o, now.toISOString());
374
+ const madeUnusedIn = (sessionId: AnyColumn) => sql`exists (
375
+ select 1 from ${o}
376
+ where ${o.tenantKey} = ${tenant}
377
+ and ${o.app} = ${app}
378
+ and ${o.madeSessionId} = ${sessionId}
379
+ and ${unused}
380
+ )`;
381
+ const match = associationsReady
382
+ ? sql`(
383
+ exists (
384
+ select 1 from ${schema.sessionRecordingSessionAssociations} as ${association}
385
+ where ${association.recordingId} = ${r.id}
386
+ and ${madeUnusedIn(association.sessionId)}
387
+ )
388
+ or (
389
+ not exists (
390
+ select 1 from ${schema.sessionRecordingSessionAssociations} as ${associationPresence}
391
+ where ${associationPresence.recordingId} = ${r.id}
392
+ )
393
+ and ${madeUnusedIn(r.sessionId)}
394
+ )
395
+ )`
396
+ : madeUnusedIn(r.sessionId);
397
+ return [
398
+ viewerReadsRecordingEventsSql(r, scope),
399
+ match,
400
+ sql`not exists (select 1 from ${schema.analyticsOutputOutcomeGaps} where ${gapOverlapsRecordingSql(r)})`,
401
+ ];
402
+ }
403
+
404
+ /** Each of the viewer's tenants that has started output tracking, with its start. */
405
+ async function viewerOutputCoverage(
406
+ db: any,
407
+ scope: SessionEventScope,
408
+ ): Promise<Map<string, string>> {
409
+ if (!(await outputOutcomesReady(db))) return new Map();
410
+ const coverage = schema.analyticsOutputOutcomeCoverage;
411
+ const rows: Array<{ tenantKey: string; startedAt: string | null }> = await db
412
+ .select({ tenantKey: coverage.tenantKey, startedAt: coverage.startedAt })
413
+ .from(coverage)
414
+ .where(inArray(coverage.tenantKey, viewerTenantKeys(scope)));
415
+ return new Map(
416
+ rows.flatMap((row) =>
417
+ row.startedAt ? [[row.tenantKey, row.startedAt] as const] : [],
418
+ ),
419
+ );
420
+ }
421
+
422
+ interface StoredOutput {
423
+ tenantKey: string;
424
+ app: string;
425
+ outputType: string;
426
+ outputId: string;
427
+ madeAt: string;
428
+ useBy: string;
429
+ madeSessionId: string;
430
+ firstUsedAt: string | null;
431
+ usedEvent: string | null;
432
+ hasGap: boolean;
433
+ }
434
+
435
+ function outputStatus(
436
+ output: StoredOutput,
437
+ nowIso: string,
438
+ ): SessionOutput["status"] {
439
+ if (output.firstUsedAt && output.firstUsedAt <= output.useBy) return "used";
440
+ if (output.useBy > nowIso) return "open";
441
+ return output.hasGap ? "unmeasured" : "unused";
442
+ }
443
+
444
+ /**
445
+ * The outputs each recording's sessions made, for recordings the viewer can
446
+ * read, such as one list page's rows. A share grants the recording, not its
447
+ * tenant's events, so a recording shared from another tenant reads as null,
448
+ * as do one whose tenant has not started output tracking and one a lost batch
449
+ * overlapped: nothing says whether any of them made anything.
450
+ * Each recording lists at most `MAX_OUTPUTS_PER_RECORDING`, unused ones
451
+ * first, so a row the unused filter matched always lists the output it
452
+ * matched on; the rest are counted in `omittedOutputs`.
453
+ */
454
+ export async function getSessionRecordingOutputs(
455
+ scope: SessionEventScope,
456
+ recordingIds: readonly string[],
457
+ now = new Date(),
458
+ ): Promise<SessionRecordingOutputs> {
459
+ const ids = [...new Set(recordingIds)];
460
+ const db = getDb() as any;
461
+ const coveredTenants = await viewerOutputCoverage(db, scope);
462
+ // Each output is judged against its own tenant's start, so only the latest
463
+ // holds for all.
464
+ const coverageStartedAt = [...coveredTenants.values()].sort().pop() ?? null;
465
+ const outputs: Record<string, SessionOutput[] | null> = {};
466
+ const omittedOutputs: Record<string, number> = {};
467
+ if (!ids.length) return { outputs, omittedOutputs, coverageStartedAt };
468
+ const r = schema.sessionRecordings;
469
+ const keys = schema.analyticsPublicKeys;
470
+ const readable: Array<{
471
+ id: string;
472
+ sessionId: string;
473
+ ownerEmail: string;
474
+ orgId: string | null;
475
+ firstUrl: string | null;
476
+ lastUrl: string | null;
477
+ allowedOrigins: string | null;
478
+ gapped?: boolean;
479
+ }> = await db
480
+ .select({
481
+ id: r.id,
482
+ sessionId: r.sessionId,
483
+ ownerEmail: r.ownerEmail,
484
+ orgId: r.orgId,
485
+ firstUrl: r.firstUrl,
486
+ lastUrl: r.lastUrl,
487
+ allowedOrigins: sql<string | null>`(
488
+ select ${keys.replayAllowedOrigins} from ${keys}
489
+ where ${keys.id} = ${r.publicKeyId}
490
+ )`,
491
+ // Coverage is created after the gap table, so it is safe to read here.
492
+ ...(coveredTenants.size
493
+ ? {
494
+ gapped: sql<boolean>`exists (
495
+ select 1 from ${schema.analyticsOutputOutcomeGaps}
496
+ where ${gapOverlapsRecordingSql(r)}
497
+ )`,
498
+ }
499
+ : {}),
500
+ })
501
+ .from(r)
502
+ .where(
503
+ and(
504
+ accessFilter(r, schema.sessionRecordingShares, {
505
+ userEmail: scope.userEmail,
506
+ orgId: scope.orgId ?? undefined,
507
+ }),
508
+ inArray(r.id, ids),
509
+ ),
510
+ )
511
+ .limit(ids.length);
512
+ const recordings: typeof readable = [];
513
+ for (const recording of readable) {
514
+ const known =
515
+ coveredTenants.has(
516
+ sessionEventTenantKey(recording.ownerEmail, recording.orgId),
517
+ ) && !recording.gapped;
518
+ outputs[recording.id] = known ? [] : null;
519
+ if (known) recordings.push(recording);
520
+ }
521
+ if (!recordings.length) {
522
+ return { outputs, omittedOutputs, coverageStartedAt };
523
+ }
524
+
525
+ const sessionIdsByRecording = new Map<string, string[]>();
526
+ if (await sessionRecordingAssociationsReady(db)) {
527
+ for (const row of await listRecordingSessionAssociations(
528
+ db,
529
+ recordings.map((recording) => recording.id),
530
+ )) {
531
+ const values = sessionIdsByRecording.get(row.recordingId) ?? [];
532
+ values.push(row.sessionId);
533
+ sessionIdsByRecording.set(row.recordingId, values);
534
+ }
535
+ }
536
+ const sessionIdsByTenant = new Map<string, Set<string>>();
537
+ for (const recording of recordings) {
538
+ if (!sessionIdsByRecording.has(recording.id)) {
539
+ sessionIdsByRecording.set(recording.id, [recording.sessionId]);
540
+ }
541
+ const tenantKey = sessionEventTenantKey(
542
+ recording.ownerEmail,
543
+ recording.orgId,
544
+ );
545
+ const tenantSessionIds =
546
+ sessionIdsByTenant.get(tenantKey) ?? new Set<string>();
547
+ for (const sessionId of sessionIdsByRecording.get(recording.id)!) {
548
+ tenantSessionIds.add(sessionId);
549
+ }
550
+ sessionIdsByTenant.set(tenantKey, tenantSessionIds);
551
+ }
552
+
553
+ const nowIso = now.toISOString();
554
+ const o = schema.analyticsOutputOutcomes;
555
+ const coverage = schema.analyticsOutputOutcomeCoverage;
556
+ const gaps = schema.analyticsOutputOutcomeGaps;
557
+ const stored: StoredOutput[] = [];
558
+ const madeBySession = new Map<string, number>();
559
+ for (const [tenantKey, tenantSessionIds] of sessionIdsByTenant) {
560
+ const sessionIds = [...tenantSessionIds];
561
+ for (
562
+ let offset = 0;
563
+ offset < sessionIds.length;
564
+ offset += SESSION_ID_BATCH_SIZE
565
+ ) {
566
+ const batch = sessionIds.slice(offset, offset + SESSION_ID_BATCH_SIZE);
567
+ // Each session's outputs are ranked unused first and cut in SQL, so a
568
+ // session that made thousands reads no more than a recording can list.
569
+ const ranked = db
570
+ .select({
571
+ tenantKey: o.tenantKey,
572
+ app: o.app,
573
+ outputType: o.outputType,
574
+ outputId: o.outputId,
575
+ madeAt: o.madeAt,
576
+ useBy: o.useBy,
577
+ madeSessionId: o.madeSessionId,
578
+ firstUsedAt: o.firstUsedAt,
579
+ usedEvent: o.usedEvent,
580
+ hasGap: sql<boolean>`exists (
581
+ select 1 from ${gaps} where ${gapOverlapsOutputSql(o)}
582
+ )`.as("has_gap"),
583
+ sessionRank: sql<number>`row_number() over (
584
+ partition by ${o.madeSessionId}
585
+ order by case when ${unusedOutputSql(o, nowIso)} then 0 else 1 end, ${o.madeAt}, ${o.outputId}
586
+ )`.as("session_rank"),
587
+ sessionMade:
588
+ sql<number>`count(*) over (partition by ${o.madeSessionId})`.as(
589
+ "session_made",
590
+ ),
591
+ })
592
+ .from(o)
593
+ .where(
594
+ and(
595
+ eq(o.tenantKey, tenantKey),
596
+ inArray(o.madeSessionId, batch),
597
+ inArray(o.app, OUTPUT_USE_APPS),
598
+ sql`${o.madeAt} is not null`,
599
+ sql`${o.madeAt} >= (select ${coverage.startedAt} from ${coverage} where ${coverage.tenantKey} = ${o.tenantKey})`,
600
+ ),
601
+ )
602
+ .as("ranked_outputs");
603
+ const rows = await db
604
+ .select()
605
+ .from(ranked)
606
+ .where(lte(ranked.sessionRank, MAX_OUTPUTS_PER_RECORDING));
607
+ for (const row of rows) {
608
+ stored.push(row);
609
+ madeBySession.set(
610
+ JSON.stringify([row.tenantKey, row.madeSessionId]),
611
+ Number(row.sessionMade),
612
+ );
613
+ }
614
+ }
615
+ }
616
+
617
+ const outputsBySession = new Map<string, StoredOutput[]>();
618
+ for (const output of stored) {
619
+ const key = JSON.stringify([output.tenantKey, output.madeSessionId]);
620
+ const values = outputsBySession.get(key) ?? [];
621
+ values.push(output);
622
+ outputsBySession.set(key, values);
623
+ }
624
+ for (const recording of recordings) {
625
+ const tenantKey = sessionEventTenantKey(
626
+ recording.ownerEmail,
627
+ recording.orgId,
628
+ );
629
+ // An output has one making session, so distinct sessions never list the
630
+ // same output twice.
631
+ const made: StoredOutput[] = [];
632
+ let madeCount = 0;
633
+ for (const sessionId of new Set(sessionIdsByRecording.get(recording.id))) {
634
+ const key = JSON.stringify([tenantKey, sessionId]);
635
+ made.push(...(outputsBySession.get(key) ?? []));
636
+ madeCount += madeBySession.get(key) ?? 0;
637
+ }
638
+ const listed = made
639
+ .map((output) => ({ output, status: outputStatus(output, nowIso) }))
640
+ .sort(
641
+ (a, b) =>
642
+ Number(b.status === "unused") - Number(a.status === "unused") ||
643
+ a.output.madeAt.localeCompare(b.output.madeAt) ||
644
+ a.output.outputId.localeCompare(b.output.outputId),
645
+ )
646
+ .slice(0, MAX_OUTPUTS_PER_RECORDING)
647
+ .sort(
648
+ (a, b) =>
649
+ a.output.madeAt.localeCompare(b.output.madeAt) ||
650
+ a.output.outputId.localeCompare(b.output.outputId),
651
+ );
652
+ if (madeCount > listed.length) {
653
+ omittedOutputs[recording.id] = madeCount - listed.length;
654
+ }
655
+ const pages = {
656
+ firstUrl: recording.firstUrl,
657
+ lastUrl: recording.lastUrl,
658
+ allowedOrigins: parseAllowedReplayOrigins(recording.allowedOrigins),
659
+ };
660
+ outputs[recording.id] = listed.map(({ output, status }) => ({
661
+ app: output.app as OutputUseApp,
662
+ outputType: output.outputType,
663
+ outputId: output.outputId,
664
+ madeAt: output.madeAt,
665
+ useBy: output.useBy,
666
+ status,
667
+ usedEvent: status === "used" ? output.usedEvent : null,
668
+ usedAt: status === "used" ? output.firstUsedAt : null,
669
+ url: outputUrl(output.app, output.outputId, pages),
670
+ }));
671
+ }
672
+ return { outputs, omittedOutputs, coverageStartedAt };
673
+ }
674
+
675
+ /**
676
+ * An outcome outlives the replays its session recorded by the use window and
677
+ * a little more, so a retained recording never loses its outputs.
678
+ */
679
+ export async function pruneOutputOutcomes(
680
+ replayRetentionDays: number,
681
+ now = new Date(),
682
+ ): Promise<{ outputs: number }> {
683
+ const db = getDb() as any;
684
+ if (!(await outputOutcomesReady(db))) return { outputs: 0 };
685
+ const cutoff = new Date(
686
+ now.getTime() -
687
+ (replayRetentionDays +
688
+ OUTPUT_USE_WINDOW_DAYS +
689
+ OUTPUT_OUTCOME_RETENTION_BUFFER_DAYS) *
690
+ DAY_MS,
691
+ ).toISOString();
692
+ // guard:allow-unscoped -- retention intentionally sweeps expired output outcomes across tenants.
693
+ const result = await db
694
+ .delete(schema.analyticsOutputOutcomes)
695
+ .where(lt(schema.analyticsOutputOutcomes.lastSeenAt, cutoff));
696
+ // A late event keeps an old output's row past the cutoff, so a gap is kept
697
+ // while any remaining output overlaps it; otherwise that output reads unused.
698
+ // guard:allow-unscoped -- retention intentionally sweeps expired gap markers across tenants.
699
+ await db
700
+ .delete(schema.analyticsOutputOutcomeGaps)
701
+ .where(
702
+ and(
703
+ lt(schema.analyticsOutputOutcomeGaps.lastAt, cutoff),
704
+ sql`not exists (select 1 from ${schema.analyticsOutputOutcomes} where ${gapOverlapsOutputSql(schema.analyticsOutputOutcomes)})`,
705
+ ),
706
+ );
707
+ return { outputs: Number(result?.rowCount ?? 0) };
708
+ }
709
+
710
+ export function __resetOutputOutcomesForTests(): void {
711
+ outputOutcomeTablesReady = false;
712
+ }