@uptimizr/db 2.0.1 → 2.1.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 (171) hide show
  1. package/AGENTS.md +228 -3
  2. package/README.md +13 -0
  3. package/dist/duckdb/migrations.d.ts.map +1 -1
  4. package/dist/duckdb/migrations.js +169 -0
  5. package/dist/duckdb/migrations.js.map +1 -1
  6. package/dist/duckdb/panelSpecs.d.ts +29 -0
  7. package/dist/duckdb/panelSpecs.d.ts.map +1 -0
  8. package/dist/duckdb/panelSpecs.js +104 -0
  9. package/dist/duckdb/panelSpecs.js.map +1 -0
  10. package/dist/duckdb/projectMetadata.d.ts +43 -0
  11. package/dist/duckdb/projectMetadata.d.ts.map +1 -0
  12. package/dist/duckdb/projectMetadata.js +222 -0
  13. package/dist/duckdb/projectMetadata.js.map +1 -0
  14. package/dist/duckdb/subscriptions.d.ts +45 -0
  15. package/dist/duckdb/subscriptions.d.ts.map +1 -0
  16. package/dist/duckdb/subscriptions.js +202 -0
  17. package/dist/duckdb/subscriptions.js.map +1 -0
  18. package/dist/index.d.ts +18 -4
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +48 -3
  21. package/dist/index.js.map +1 -1
  22. package/dist/insights/anomalies.d.ts +213 -0
  23. package/dist/insights/anomalies.d.ts.map +1 -0
  24. package/dist/insights/anomalies.js +340 -0
  25. package/dist/insights/anomalies.js.map +1 -0
  26. package/dist/insights/baseline.d.ts +51 -0
  27. package/dist/insights/baseline.d.ts.map +1 -0
  28. package/dist/insights/baseline.js +48 -0
  29. package/dist/insights/baseline.js.map +1 -0
  30. package/dist/insights/buckets.d.ts +114 -0
  31. package/dist/insights/buckets.d.ts.map +1 -0
  32. package/dist/insights/buckets.js +220 -0
  33. package/dist/insights/buckets.js.map +1 -0
  34. package/dist/insights/changepoint.d.ts +130 -0
  35. package/dist/insights/changepoint.d.ts.map +1 -0
  36. package/dist/insights/changepoint.js +209 -0
  37. package/dist/insights/changepoint.js.map +1 -0
  38. package/dist/insights/evaluate.d.ts +63 -0
  39. package/dist/insights/evaluate.d.ts.map +1 -0
  40. package/dist/insights/evaluate.js +158 -0
  41. package/dist/insights/evaluate.js.map +1 -0
  42. package/dist/insights/health.d.ts +219 -0
  43. package/dist/insights/health.d.ts.map +1 -0
  44. package/dist/insights/health.js +349 -0
  45. package/dist/insights/health.js.map +1 -0
  46. package/dist/insights/index.d.ts +45 -0
  47. package/dist/insights/index.d.ts.map +1 -0
  48. package/dist/insights/index.js +40 -0
  49. package/dist/insights/index.js.map +1 -0
  50. package/dist/insights/measures.d.ts +222 -0
  51. package/dist/insights/measures.d.ts.map +1 -0
  52. package/dist/insights/measures.js +431 -0
  53. package/dist/insights/measures.js.map +1 -0
  54. package/dist/insights/movers.d.ts +123 -0
  55. package/dist/insights/movers.d.ts.map +1 -0
  56. package/dist/insights/movers.js +166 -0
  57. package/dist/insights/movers.js.map +1 -0
  58. package/dist/insights/significance.d.ts +221 -0
  59. package/dist/insights/significance.d.ts.map +1 -0
  60. package/dist/insights/significance.js +531 -0
  61. package/dist/insights/significance.js.map +1 -0
  62. package/dist/insights/stats.d.ts +104 -0
  63. package/dist/insights/stats.d.ts.map +1 -0
  64. package/dist/insights/stats.js +181 -0
  65. package/dist/insights/stats.js.map +1 -0
  66. package/dist/insights/windows.d.ts +95 -0
  67. package/dist/insights/windows.d.ts.map +1 -0
  68. package/dist/insights/windows.js +97 -0
  69. package/dist/insights/windows.js.map +1 -0
  70. package/dist/metadata.d.ts +178 -1
  71. package/dist/metadata.d.ts.map +1 -1
  72. package/dist/metadata.js +80 -0
  73. package/dist/metadata.js.map +1 -1
  74. package/dist/narrative/build.d.ts +78 -0
  75. package/dist/narrative/build.d.ts.map +1 -0
  76. package/dist/narrative/build.js +437 -0
  77. package/dist/narrative/build.js.map +1 -0
  78. package/dist/narrative/index.d.ts +14 -0
  79. package/dist/narrative/index.d.ts.map +1 -0
  80. package/dist/narrative/index.js +13 -0
  81. package/dist/narrative/index.js.map +1 -0
  82. package/dist/narrative/text.d.ts +25 -0
  83. package/dist/narrative/text.d.ts.map +1 -0
  84. package/dist/narrative/text.js +41 -0
  85. package/dist/narrative/text.js.map +1 -0
  86. package/dist/parity/cases.d.ts +3 -0
  87. package/dist/parity/cases.d.ts.map +1 -1
  88. package/dist/parity/cases.js +387 -7
  89. package/dist/parity/cases.js.map +1 -1
  90. package/dist/parity/compare.d.ts +19 -0
  91. package/dist/parity/compare.d.ts.map +1 -1
  92. package/dist/parity/compare.js +19 -0
  93. package/dist/parity/compare.js.map +1 -1
  94. package/dist/parity/fixtures.d.ts.map +1 -1
  95. package/dist/parity/fixtures.js +25 -0
  96. package/dist/parity/fixtures.js.map +1 -1
  97. package/dist/query/aggregations.d.ts +46 -1
  98. package/dist/query/aggregations.d.ts.map +1 -1
  99. package/dist/query/aggregations.js +86 -0
  100. package/dist/query/aggregations.js.map +1 -1
  101. package/dist/query/clickhouseDialect.d.ts.map +1 -1
  102. package/dist/query/clickhouseDialect.js +5 -1
  103. package/dist/query/clickhouseDialect.js.map +1 -1
  104. package/dist/query/customEventVocabulary.d.ts +46 -0
  105. package/dist/query/customEventVocabulary.d.ts.map +1 -0
  106. package/dist/query/customEventVocabulary.js +127 -0
  107. package/dist/query/customEventVocabulary.js.map +1 -0
  108. package/dist/query/dsl/builders.d.ts +38 -0
  109. package/dist/query/dsl/builders.d.ts.map +1 -0
  110. package/dist/query/dsl/builders.js +35 -0
  111. package/dist/query/dsl/builders.js.map +1 -0
  112. package/dist/query/dsl/compare.d.ts +167 -0
  113. package/dist/query/dsl/compare.d.ts.map +1 -0
  114. package/dist/query/dsl/compare.js +358 -0
  115. package/dist/query/dsl/compare.js.map +1 -0
  116. package/dist/query/dsl/compile.d.ts +89 -0
  117. package/dist/query/dsl/compile.d.ts.map +1 -0
  118. package/dist/query/dsl/compile.js +174 -0
  119. package/dist/query/dsl/compile.js.map +1 -0
  120. package/dist/query/dsl/explain.d.ts +115 -0
  121. package/dist/query/dsl/explain.d.ts.map +1 -0
  122. package/dist/query/dsl/explain.js +143 -0
  123. package/dist/query/dsl/explain.js.map +1 -0
  124. package/dist/query/dsl/generic.d.ts +127 -0
  125. package/dist/query/dsl/generic.d.ts.map +1 -0
  126. package/dist/query/dsl/generic.js +323 -0
  127. package/dist/query/dsl/generic.js.map +1 -0
  128. package/dist/query/dsl/index.d.ts +30 -0
  129. package/dist/query/dsl/index.d.ts.map +1 -0
  130. package/dist/query/dsl/index.js +23 -0
  131. package/dist/query/dsl/index.js.map +1 -0
  132. package/dist/query/dsl/order.d.ts +41 -0
  133. package/dist/query/dsl/order.d.ts.map +1 -0
  134. package/dist/query/dsl/order.js +55 -0
  135. package/dist/query/dsl/order.js.map +1 -0
  136. package/dist/query/dsl/significance.d.ts +130 -0
  137. package/dist/query/dsl/significance.d.ts.map +1 -0
  138. package/dist/query/dsl/significance.js +224 -0
  139. package/dist/query/dsl/significance.js.map +1 -0
  140. package/dist/query/index.d.ts +2 -0
  141. package/dist/query/index.d.ts.map +1 -1
  142. package/dist/query/index.js +8 -0
  143. package/dist/query/index.js.map +1 -1
  144. package/dist/query/summary/index.d.ts +4 -2
  145. package/dist/query/summary/index.d.ts.map +1 -1
  146. package/dist/query/summary/index.js +2 -1
  147. package/dist/query/summary/index.js.map +1 -1
  148. package/dist/query/summary/labels.d.ts +101 -0
  149. package/dist/query/summary/labels.d.ts.map +1 -0
  150. package/dist/query/summary/labels.js +261 -0
  151. package/dist/query/summary/labels.js.map +1 -0
  152. package/dist/query/summary/reading.d.ts.map +1 -1
  153. package/dist/query/summary/reading.js +21 -2
  154. package/dist/query/summary/reading.js.map +1 -1
  155. package/dist/query/summary/schema.d.ts +250 -20
  156. package/dist/query/summary/schema.d.ts.map +1 -1
  157. package/dist/query/summary/schema.js +135 -123
  158. package/dist/query/summary/schema.js.map +1 -1
  159. package/dist/query/summary/summarize.d.ts.map +1 -1
  160. package/dist/query/summary/summarize.js +91 -5
  161. package/dist/query/summary/summarize.js.map +1 -1
  162. package/dist/query/summary/types.d.ts +77 -2
  163. package/dist/query/summary/types.d.ts.map +1 -1
  164. package/dist/query/types.d.ts +47 -1
  165. package/dist/query/types.d.ts.map +1 -1
  166. package/dist/subscriptions.d.ts +208 -0
  167. package/dist/subscriptions.d.ts.map +1 -0
  168. package/dist/subscriptions.js +153 -0
  169. package/dist/subscriptions.js.map +1 -0
  170. package/llms.txt +9 -0
  171. package/package.json +8 -8
package/AGENTS.md CHANGED
@@ -11,7 +11,9 @@ The OSS storage contracts plus the single-file **DuckDB** store (ADR 0020):
11
11
  table (hot fields promoted to columns; the full event preserved as JSON in `payload` so reads
12
12
  stay replay-complete) plus `projects` / `api_keys` (stored only as SHA-256 hashes, each key
13
13
  carrying a capability set, an optional label and an optional per-key rate limit) and the
14
- `agent_audit` trail.
14
+ `agent_audit` trail, the scene registry (`scene_representations`, `scene_regions`) and the
15
+ project-metadata tables `annotations` / `glossary` / `saved_analyses` (ADR 0051 §5) and
16
+ `panel_specs` (ADR 0051 §7).
15
17
  - **Engine-neutral contracts** — the dialect-agnostic query layer (`buildX` + `Dialect`), the
16
18
  neutral event-row mapper (`toEventRow`, `formatUtcTimestamp`), and the metadata types
17
19
  (`Project`, `ApiKeyRecord`, `SceneRepresentation*`). An optional, separately-licensed
@@ -77,6 +79,24 @@ every tool (collector + CLIs) shares one canonical file regardless of cwd.
77
79
  `toApiKeyColumns()` so ordering, validation and the per-key rate-limit columns stay consistent
78
80
  across all four engines. `query:raw` is only ever honoured by a collector running with
79
81
  `ENABLE_RAW_SESSION_RETENTION` (ADR 0003).
82
+ - **Metadata tables are writable; the events table is not.** `annotations`, `glossary`,
83
+ `saved_analyses` and `panel_specs` are the only rows a request can write besides events
84
+ (ADR 0051 §5/§7/§9). Their accessors enforce the per-project caps in `METADATA_LIMITS` at write
85
+ time and throw `MetadataLimitError` when a project is full — the collector turns that into a
86
+ `409`. Never add a path that updates or deletes an event row.
87
+ - **`panel_specs` is the one metadata table with an in-place update** (ADR 0051 §7). A spec is one
88
+ closed JSON document — a metric id, a chart name, some column names — that no read path queries
89
+ into, stored beside only what the store must filter (`project_id`), order (`created_at`) and
90
+ address (`id`). `duckdbCreatePanelSpec` / `duckdbListPanelSpecs` / `duckdbUpdatePanelSpec` /
91
+ `duckdbDeletePanelSpec` are the accessors; `parsePanelSpec` maps the JSON column back and returns
92
+ `null` for a row that is not an object, which the listing drops rather than failing the whole
93
+ grid on. Two things differ from the other three tables and must stay that way: the listing is
94
+ **oldest first**, because these are grid positions rather than a feed and a new pin must not
95
+ reshuffle somebody's dashboard, and an update keeps the row's id **and its original
96
+ `authorKind` / `authorKeyId`** — who pinned a panel is a fact about when it appeared, and an edit
97
+ does not change it. Migrations: DuckDB `0048_panel_specs` + `0049_panel_specs_idx`, Postgres
98
+ `0020_panel_specs`, SQL Server `0022_panel_specs` + `0023_panel_specs_idx`, ClickHouse
99
+ `0017_panel_specs`.
80
100
  - **The audit log records key ids, never keys.** `agent_audit` rows carry `key_id`; serialize
81
101
  parameters with `serializeAuditParams()` (drops credential-shaped keys, bounds the document)
82
102
  before they reach a store, and clamp the endpoint with `clampAuditTool()`.
@@ -105,6 +125,26 @@ Its own **package**, not a subpath here, because this one depends on the ~37 MB
105
125
  **only** `zod` plus a type-only `@uptimizr/schema` declaration, performs no I/O and holds no store
106
126
  or dialect reference.
107
127
 
128
+ ### Custom-event vocabulary (ADR 0051 §5)
129
+
130
+ `buildCustomEventVocabulary` is the one aggregation whose SQL output is **not** what the API serves.
131
+ Key _enumeration_ over an open JSON object is the single JSON operation the four supported engines
132
+ have no portable spelling for (`json_keys` / `JSONExtractKeys` / `jsonb_object_keys` / `OPENJSON`),
133
+ and `Dialect.jsonText` cannot express it — it reads a value at a _known_ path. So the query does what
134
+ SQL is good at (counts and distinct sessions per `custom` event name, plus that name's most recent
135
+ `payload` documents, bounded by `CUSTOM_EVENT_VOCABULARY_SAMPLE_ROWS` = 20 per name), and
136
+ `foldCustomEventVocabulary(rows)` — pure, unit-tested, no dialect — turns the sampled payloads into
137
+ one row per name with the union of prop keys and a coarse type each.
138
+
139
+ - Every store calls the fold in its `customEventVocabulary` method. The **raw payload never leaves
140
+ the store layer**: the collector serves the folded rows, and only key names and value kinds, never
141
+ a prop value (ADR 0003).
142
+ - The registry `row` for `custom_event_vocabulary` therefore describes the **folded** row; the parity
143
+ case compares the counts and the sampling shape and excludes `sample_payload`, which is engine
144
+ formatted (Postgres normalises it through `jsonb`).
145
+ - Do not add a `jsonKeys` member to `Dialect` to "fix" this without a good reason: four dialects would
146
+ have to agree on key ordering, type coercion and NULL handling for a discovery read.
147
+
108
148
  ### Numeric coercion at the store edge (ADR 0051 §2)
109
149
 
110
150
  Numeric columns are strict `z.number()` — the schema describes the API, not the wire. Every
@@ -148,14 +188,42 @@ samples", not `0`, which is why nine perf/resource metrics declare nullable colu
148
188
  from one `preSerialization` hook, so no route handler knows they exist.
149
189
 
150
190
  ```ts
151
- import { summarizeRows, tableResult, clusterCells, wilsonInterval } from "@uptimizr/db/summary";
191
+ import {
192
+ summarizeRows,
193
+ tableResult,
194
+ clusterCells,
195
+ labelClusters,
196
+ labelPoint,
197
+ isWorldSpatialMetric,
198
+ wilsonInterval,
199
+ } from "@uptimizr/db/summary";
152
200
  ```
153
201
 
202
+ The **Zod mirrors** of the envelopes (`tableResultSchema`, `resultSummarySchema`,
203
+ `resultEnvelopeSchema`, `resultFormatSchema`) are still exported from the same subpath, but they
204
+ are now defined in [`@uptimizr/metrics`](../metrics) and re-exported here: `@uptimizr/agent-core`
205
+ and `@uptimizr/mcp` describe the same envelopes in their tool output schemas and may not depend on
206
+ this package. Change a shape **there**, never here.
207
+
154
208
  The `grain` selects the shape — `ranked` (top rows + `rest`), `series` (first/last/min/max/trend/
155
209
  slope over the `axis` column), `clusters` (`clusterCells`: deterministic greedy merge of adjacent
156
210
  occupied cells above a density threshold, 8-neighbourhood in 2D / 26 in 3D), or `record` (the single
157
211
  row plus its `rateOf` rates). Everything is bounded by `limits.maxSummaryRows`.
158
212
 
213
+ ### Spatial labelling (`labels.ts`, ADR 0051 §2 / sketch §B.2)
214
+
215
+ Pass a `SummaryContext.scene` — `{ id, regions: [{ id, bounds }], meshes: [{ name, aabb }] }`, which
216
+ the collector loads from the scene registry once per request — and every cluster of a **world-space**
217
+ grid is labelled: `region` (smallest containing region by volume), `regions[]` (every containing id,
218
+ ascending), `nearestMesh` (a proxy box containing the centroid, else the nearest box centre within
219
+ `cellSize × 2`) and `distance` (world units; `0` when contained). A containing region also upgrades
220
+ `drill.region` from an ad-hoc world box to the region **id**, which `?region=` resolves server-side.
221
+
222
+ `isWorldSpatialMetric(metric)` says whether a metric is worth loading a scene for: only the voxel
223
+ (`vx/vy/vz`) and ground-bin (`gx/gz`) grids are world-space — the viewport pointer/UV bins and the
224
+ angular view-direction grid are not, and are left untouched. Cost is `O(clusters × boxes)` with a
225
+ per-axis early exit; `spatialLabels.test.ts` holds the largest shape under 50 ms.
226
+
159
227
  **Rules for agents:**
160
228
 
161
229
  - A metric's summary comes from its **column semantics**, never from a per-metric special case. If a
@@ -169,8 +237,164 @@ row plus its `rateOf` rates). Everything is bounded by `limits.maxSummaryRows`.
169
237
  rows is meaningless, and a share derived from it is worse than none.
170
238
  - Clustering must stay a pure function of the cell _set_: accumulate in sorted coordinate order so a
171
239
  reordered input is bit-identical (`summary.test.ts` rotates every fixture).
240
+ - Labelling must stay a pure function of the region/mesh _sets_ too: break every tie by id or name
241
+ ascending, never by array order, and report `null` rather than a far-away guess.
172
242
  - Adding `format` to a metric's `filters` and to the collector's querystring is one change — the
173
243
  collector's `registryRoutes.test.ts` fails if they drift.
244
+ - The collector's `format` default is **`full`** and must stay that way — it is what keeps the
245
+ feature invisible to the dashboard. The generated agent tools apply their own `table` default
246
+ and send it explicitly (`DEFAULT_TOOL_FORMAT` in `@uptimizr/agent-core`).
247
+
248
+ ## Query DSL (ADR 0051 §3)
249
+
250
+ `compileQuery(projectId, query, dialect)` turns a validated `queryV1` document (the Zod grammar in
251
+ `@uptimizr/schema`, the registry validation in `@uptimizr/metrics`) into an ordinary `QuerySpec`, by
252
+ way of the metric's **existing** builder: the registry names the `build*`, `FILTER_TARGETS` names the
253
+ option field each filter drives, and the builder renders what it has always rendered.
254
+
255
+ ```ts
256
+ import { compileMetric, compileQuery, toBuilderOptions } from "@uptimizr/db";
257
+
258
+ // A store's whole DSL implementation:
259
+ runMetric: (projectId, metric, options) =>
260
+ runDuckdbQuery(db, compileMetric(metric, projectId, options, duckdbDialect));
261
+ ```
262
+
263
+ - **No second SQL path.** The spec a DSL query compiles to is byte-identical to the one the canned
264
+ endpoint runs — `src/__tests__/queryDsl.test.ts` asserts that for every aggregation on all four
265
+ dialects, and `PARITY_CASES` carries `dsl:*` cases that execute compiled specs against the same
266
+ golden. Everything parity already proves about a builder holds for the DSL.
267
+ - **It does not validate.** By the time a query reaches `compileQuery` it has passed
268
+ `queryV1Schema` and `validateQuery`. The two `throw`s are guards against a caller skipping that.
269
+ - **Two values must be resolved first**, by whoever has a store: a `filters.region` given as a
270
+ registered region id (→ its bounds) and a spatial `cellSize` derived from the scene's extent. Pass
271
+ them as the `QueryResolution` argument.
272
+ - **The generic tier is the second compiler**, not a second _path_: `compileGenericGroupBy`
273
+ (`query/dsl/generic.ts`) renders `SELECT <dims>, <measures> … GROUP BY <dims>` for a metric that
274
+ declares `genericGroupBy`, at any grain it declares. Everything variable in that SQL comes from
275
+ registry data — the event types, the scope predicate, the measures, each dimension's expression —
276
+ and every caller-supplied value is a bound parameter. `compileMetric` dispatches on
277
+ `options.tier`, which only `toBuilderOptions` sets, so a store calling `runMetric` with a plain
278
+ option bag keeps the delegated behaviour it has always had.
279
+ - **Four pure layers sit on top**, none of which runs a query: `compareRows` /
280
+ `summarizeComparison` (join two runs of the same spec on the dimension key),
281
+ `twoProportionZ` / `welchT` (is the difference real — pinned to published table values in
282
+ `querySignificance.test.ts`), `explainQuery` (the plan and its warnings), and `applyOrder`
283
+ (an honest re-sort of a delegated result, with `ORDER_AFTER_CAP_CAVEAT` when the builder's own
284
+ cap had already chosen the rows).
285
+ - **`explain` shows the SQL because there is nothing in it to redact.** `explainSpec` lists
286
+ parameters by name and logical type and never by value; the SQL text carries placeholders only,
287
+ which is exactly the property a reader uses `explain` to check.
288
+
289
+ ## Session narrative (ADR 0051 §7)
290
+
291
+ `buildSessionNarrative(events, opts)` compacts one session's `AnyEvent[]` into an ordered,
292
+ bounded account of what it did; `renderSessionNarrativeText(narrative)` renders it as one line per
293
+ entry. Both are **pure** — no store, no request, no I/O — and live in `src/narrative/`.
294
+
295
+ ```ts
296
+ import { buildSessionNarrative, renderSessionNarrativeText } from "@uptimizr/db";
297
+
298
+ const narrative = buildSessionNarrative(events, { minDwellMs: 2000, maxEntries: 200 });
299
+ narrative.entries; // ordered { tMs, kind, summary, refs }, ending with the `summary` entry
300
+ narrative.totals; // { events, durationMs, scenes, meshes, interactions, dips, errors }
301
+ console.log(renderSessionNarrativeText(narrative));
302
+ ```
303
+
304
+ Shapes, defaults and hard caps live in `@uptimizr/metrics` (`NARRATIVE_LIMITS`,
305
+ `sessionNarrativeEntrySchema`) so the collector route, the generated `session_narrative` tool and
306
+ this implementation cannot drift.
307
+
308
+ **It is an allow-list, not a redactor** (ADR 0003). It reads only: relative timestamps, scene ids,
309
+ mesh names, interaction kinds and input sources, custom-event/input-action names, FPS, truncated
310
+ runtime-error messages, diagnostic category/severity, capability transitions, and the rendering
311
+ engine. It never reads `visitorId`, `url`, `pageMeta`, `user`, any position/ray/UV, any other
312
+ `device` field, or custom-event property values (keys only, unless `includeCustomProps`). Keep it
313
+ that way when adding an event type — adding a field to the switch is a privacy decision.
314
+
315
+ ### Insight primitives (ADR 0051 §4, `src/insights/`)
316
+
317
+ `baseline`, `movers`, `anomalies`, `significance` and `scene_health` — "what is normal here",
318
+ "what changed", "_when_ did it go wrong", "is that change real" and "which scene should I look at
319
+ first" — as five derived registry metrics. The shape of the directory is the design:
320
+
321
+ ```ts
322
+ import {
323
+ buildMetricBuckets, // the ONE dialect-authored query both primitives consume
324
+ computeBaseline,
325
+ rankMovers,
326
+ resolveBaselineWindow,
327
+ resolveMoversWindows,
328
+ BUCKET_MEASURES, // metric id -> how its primary column buckets
329
+ BUCKETABLE_METRIC_IDS,
330
+ MOVERS_DEFAULT_METRICS,
331
+ MOVERS_MAX_METRICS,
332
+ detectAnomalies, // #306: robust z per bucket + CUSUM change-points
333
+ contributorDimensionFor, // the ONE dimension a metric's excess may be split by
334
+ contributorWindows, // the windows attribution is allowed to re-scan
335
+ attributeContributor,
336
+ ANOMALY_MAX_CONTRIBUTOR_SCANS,
337
+ // --- significance / scene health (#307) ---
338
+ computeSignificance, // picks the test from the measure: proportions / Poisson / Welch
339
+ computeSceneHealth, // six weighted factors, each traceable to its metric
340
+ rankSceneHealth,
341
+ resolveHealthWindows,
342
+ HEALTH_FACTORS, // the fixed factor catalog, with default weights
343
+ BUCKET_MEASURE_VARIANTS, // named auxiliary series: a rate denominator, an FPS tail
344
+ } from "@uptimizr/db";
345
+ ```
346
+
347
+ **Rules for agents:**
348
+
349
+ - **No statistic may be computed in SQL.** Five dialects disagree about `quantile`, `median` and
350
+ `stddev`; an insight that changes with the storage engine is not an insight. The query returns raw
351
+ per-bucket values and `stats.ts` does the rest in TypeScript.
352
+ - `buildMetricBuckets` lives in `src/insights/`, **not** in `query/aggregations.ts`. The `build*`
353
+ exports of that module are the registry's closed list of _metrics_, each of which must have its own
354
+ entry and endpoint; this is the shared _input_ of two metrics and has neither.
355
+ - A metric is bucketable only if it has an entry in `measures.ts`, and that entry's `column` must be
356
+ the metric's `comparable.primary` (asserted in `src/__tests__/insights.test.ts`). Widening the
357
+ catalog is additive: add the measure, add a parity case, done.
358
+ - A metric may also declare **named auxiliary series** (`BUCKET_MEASURE_VARIANTS`): a rate
359
+ denominator, an FPS tail, the numerator of a ratio whose metric has no headline series at all.
360
+ A variant is **never** caller input — `significance` picks `denominator` from the registry and
361
+ `scene_health` reads a fixed factor catalog — and declaring one does **not** make the metric
362
+ bucketable: `isBucketableMetric` still answers about the metric's own headline column, so
363
+ `baseline` and `movers` keep rejecting `jank_rate` and `xr_abandonment` as before.
364
+ - **Which test `significance` runs is derived, not configured**: a declared rate (a `rateOf`
365
+ headline column plus a `denominator` variant) gets a two-proportion z with Wilson/Newcombe
366
+ intervals, a `count`/`sessions` aggregate gets an exact Poisson rate test, and everything else
367
+ gets Welch's t over the per-bucket values. Welch's `n` is the **bucket count**, never the event
368
+ count: samples inside one day are not independent.
369
+ - **Every `scene_health` factor must stay traceable.** A factor row carries the metric id, the raw
370
+ value, the project baseline it was compared with and the weight it took. A factor that could not
371
+ be measured reports `score: null` with a reason and is excluded from the mean — never defaulted
372
+ to 50, and never dropped from the row.
373
+ - **Never approximate a series to widen the catalog.** Funnels, cohort metrics and anything defined
374
+ by the relationship between consecutive events have no faithful per-bucket form; a `400` naming the
375
+ ids that do is a better answer than a plausible wrong number.
376
+ - Predicates come from a **closed vocabulary** over promoted columns, with constant values bound as
377
+ parameters. Do not add a free-SQL escape hatch.
378
+ - Window bounds snap **down** to whole buckets, so the day or hour in progress is excluded and a
379
+ default reference really is an equal window. `resolveHealthWindows` is the deliberate exception
380
+ (it rounds `until` **up**): every health factor is a rate or a percentile, which a partial bucket
381
+ does not distort, and flooring would make the score answer about yesterday.
382
+ - `movers` reports a sub-`minSample` delta with `aboveMinSample: false` and ranks it below every
383
+ gated mover. It must never be dropped — "we cannot tell" and "nothing changed" are different
384
+ answers — and never reported as a finding.
385
+ - Cost is bounded by `MOVERS_MAX_METRICS` (one grouped scan per scanned metric). Raising it is a
386
+ deliberate change, not a default.
387
+ - `anomalies` (#306) adds **one** piece of per-dialect SQL and no more: an optional `groupBy` on
388
+ `buildMetricBuckets` that adds one promoted column to the `SELECT`/`GROUP BY`. The column comes
389
+ from the measure's own `splitBy`, a compile-time union — never from request input — and the shape
390
+ has its own `metricBuckets:split*` parity cases on all four engines.
391
+ - `anomalies`' `z` divides by the MAD **rescaled to a standard deviation** (`MAD_TO_SIGMA`), because
392
+ `sensitivity` is a threshold and an uncalibrated one reports ordinary days. `movers` divides by the
393
+ raw MAD because it ranks. Do not "unify" them without moving the default with it.
394
+ - Attribution is capped at `ANOMALY_MAX_CONTRIBUTOR_SCANS` extra grouped scans per request, whatever
395
+ the data looks like. The cap, not the data, is what bounds the endpoint.
396
+ - `significance` and `scene_health` (ADR 0051 §4) slot in here the same way: a new pure module over
397
+ the same bucket series.
174
398
 
175
399
  ## Cross-engine parity (ADR 0020)
176
400
 
@@ -203,7 +427,8 @@ service). The scale tier reuses the same exported `PARITY_CASES`/golden to run
203
427
  `day` strings (`YYYY-MM-DD`) render identically and are compared.
204
428
 
205
429
  When adding an aggregation or event type, extend `PARITY_EVENTS`/`PARITY_CASES` with golden so both
206
- engines stay covered.
430
+ engines stay covered. The `metricBuckets:*` cases cover the insight bucket series — one per aggregate
431
+ shape its measure catalog can render, which is what makes `baseline`/`movers` portable.
207
432
 
208
433
  ## More
209
434
 
package/README.md CHANGED
@@ -18,6 +18,12 @@ The OSS storage contracts plus the single-file **DuckDB** store:
18
18
  `scene_regions` (named, labelled boxes per scene, keyed `(project_id, scene_id, region_id)`).
19
19
  `duckdbPutSceneRegions` replaces a scene's whole set in one transaction; `duckdbGetSceneRegions`
20
20
  / `duckdbListSceneRegions` read it back.
21
+ - **Project metadata** (ADR 0051 §5) — `annotations`, `glossary` and `saved_analyses`: notes,
22
+ definitions and saved questions written by a project's own people and agents. These are the only
23
+ rows a request can write besides events; the collector gates each write behind the `annotate`
24
+ capability and audits it, and nothing on this path can write, alter or delete an event. The
25
+ accessors enforce the per-project caps in `METADATA_LIMITS` and throw `MetadataLimitError` when a
26
+ project is full.
21
27
 
22
28
  This package carries **no ClickHouse/Postgres dependency**. Optional scale adapters such as
23
29
  `@uptimizr/db-clickhouse` compose these contracts behind the same interface. Server/Node only — no
@@ -150,6 +156,13 @@ for a `bin`/`voxel` grid (`clusterCells` — a deterministic greedy merge of adj
150
156
  above a density threshold, 8-neighbourhood in 2D and 26 in 3D), and the `record` itself plus its
151
157
  `rateOf` rates for a single-row metric. Everything is capped at `limits.maxSummaryRows`.
152
158
 
159
+ The Zod mirrors of the envelopes — `tableResultSchema(row)`, `resultSummarySchema`,
160
+ `resultEnvelopeSchema(full, row)`, `resultFormatSchema` — are exported from the same subpath as
161
+ always, but are **defined in [`@uptimizr/metrics`](../metrics)** and re-exported here.
162
+ `@uptimizr/agent-core` and `@uptimizr/mcp` describe the same envelopes in their tool output
163
+ schemas and cannot depend on this package (it carries the DuckDB driver), so the shapes have one
164
+ definition, in the package both sides already share.
165
+
153
166
  Two invariants worth knowing before extending it:
154
167
 
155
168
  - **`reading` is templated, not generated.** It is assembled from `ColumnSemantics` alone, so the
@@ -1 +1 @@
1
- {"version":3,"file":"migrations.d.ts","sourceRoot":"","sources":["../../src/duckdb/migrations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,EAAE,aAAa,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAoaxE,CAAC;AAEF;;GAEG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAQvE"}
1
+ {"version":3,"file":"migrations.d.ts","sourceRoot":"","sources":["../../src/duckdb/migrations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,EAAE,aAAa,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CA6kBxE,CAAC;AAEF;;GAEG;AACH,wBAAsB,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAQvE"}
@@ -428,6 +428,175 @@ export const DUCKDB_MIGRATIONS = [
428
428
  );
429
429
  `,
430
430
  },
431
+ // Project metadata (ADR 0051 §5 / sketch §E.2): the three tables that let a
432
+ // person or an agent leave something behind — a note on a spike, a definition
433
+ // of a name, a question worth re-asking. Metadata only: the `events` table is
434
+ // untouched and no event type exists for any of this.
435
+ //
436
+ // `since`/`until` are nullable TIMESTAMPs (a standing note has neither);
437
+ // `author_kind` is the collector's decision ('user' | 'agent'), `author_key_id`
438
+ // the id of the API key that carried the write — never the key or its hash.
439
+ {
440
+ id: "0039_annotations",
441
+ sql: /* sql */ `
442
+ CREATE TABLE IF NOT EXISTS annotations (
443
+ id VARCHAR PRIMARY KEY,
444
+ project_id VARCHAR NOT NULL,
445
+ target_kind VARCHAR NOT NULL,
446
+ target_id VARCHAR,
447
+ since TIMESTAMP,
448
+ until TIMESTAMP,
449
+ text VARCHAR NOT NULL,
450
+ author_kind VARCHAR NOT NULL DEFAULT 'user',
451
+ author_key_id VARCHAR,
452
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
453
+ updated_at TIMESTAMP NOT NULL DEFAULT now()
454
+ );
455
+ `,
456
+ },
457
+ {
458
+ id: "0040_annotations_idx",
459
+ sql: /* sql */ `
460
+ CREATE INDEX IF NOT EXISTS annotations_project_created_idx
461
+ ON annotations (project_id, created_at);
462
+ `,
463
+ },
464
+ // Glossary: what a name means *in this project*. Keyed by (project, term), so
465
+ // a write is an idempotent upsert and the term is the identity.
466
+ {
467
+ id: "0041_glossary",
468
+ sql: /* sql */ `
469
+ CREATE TABLE IF NOT EXISTS glossary (
470
+ project_id VARCHAR NOT NULL,
471
+ term VARCHAR NOT NULL,
472
+ meaning VARCHAR NOT NULL,
473
+ updated_at TIMESTAMP NOT NULL DEFAULT now(),
474
+ PRIMARY KEY (project_id, term)
475
+ );
476
+ `,
477
+ },
478
+ // Saved analyses: a titled question plus what was concluded from it. `query`
479
+ // is JSON text the collector stores but does not interpret (the DSL lands
480
+ // separately); it is bounded at the edge before it gets here.
481
+ {
482
+ id: "0042_saved_analyses",
483
+ sql: /* sql */ `
484
+ CREATE TABLE IF NOT EXISTS saved_analyses (
485
+ id VARCHAR PRIMARY KEY,
486
+ project_id VARCHAR NOT NULL,
487
+ title VARCHAR NOT NULL,
488
+ query VARCHAR NOT NULL DEFAULT '{}',
489
+ conclusion VARCHAR,
490
+ author_kind VARCHAR NOT NULL DEFAULT 'user',
491
+ author_key_id VARCHAR,
492
+ created_at TIMESTAMP NOT NULL DEFAULT now()
493
+ );
494
+ `,
495
+ },
496
+ {
497
+ id: "0043_saved_analyses_idx",
498
+ sql: /* sql */ `
499
+ CREATE INDEX IF NOT EXISTS saved_analyses_project_created_idx
500
+ ON saved_analyses (project_id, created_at);
501
+ `,
502
+ },
503
+ // --- Conditional subscriptions (#311, ADR 0051 §6 / sketch §F.1–F.2) ------
504
+ // One row per standing question: which registry metric, over what window, with
505
+ // what predicate, and where a firing is delivered. The declaration itself is a
506
+ // closed Zod union validated at the request boundary and never queried *into*,
507
+ // so it lives in one JSON `config` column; the scalars beside it are exactly
508
+ // what the store must filter (`project_id`, `enabled`), order (`created_at`) or
509
+ // update (`last_fired_at`, `last_error`, `failures`).
510
+ //
511
+ // `webhook_secret` is the shared HMAC key. It is deliberately NOT hashed — a
512
+ // one-way digest cannot sign an outbound body — and is the one column no read
513
+ // path ever selects into a record: `duckdb/subscriptions.ts` reads it only in
514
+ // `getWebhookSecret`, and every API response carries a masked placeholder.
515
+ {
516
+ id: "0044_subscriptions",
517
+ sql: /* sql */ `
518
+ CREATE TABLE IF NOT EXISTS subscriptions (
519
+ id VARCHAR PRIMARY KEY,
520
+ project_id VARCHAR NOT NULL,
521
+ name VARCHAR NOT NULL,
522
+ metric VARCHAR NOT NULL,
523
+ config VARCHAR NOT NULL,
524
+ webhook_secret VARCHAR,
525
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
526
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
527
+ updated_at TIMESTAMP NOT NULL DEFAULT now(),
528
+ last_fired_at TIMESTAMP,
529
+ last_error VARCHAR,
530
+ failures BIGINT NOT NULL DEFAULT 0
531
+ );
532
+ `,
533
+ },
534
+ {
535
+ id: "0045_subscriptions_idx",
536
+ sql: /* sql */ `
537
+ CREATE INDEX IF NOT EXISTS subscriptions_project_idx
538
+ ON subscriptions (project_id, created_at);
539
+ `,
540
+ },
541
+ // The bounded firing log: `{ subscriptionId, at, payload }`, last 100 per
542
+ // subscription. The trim runs in the same transaction as the insert
543
+ // (`recordSubscriptionEvent`), so the bound is never observed broken.
544
+ //
545
+ // `at` is a DuckDB keyword, so it is double-quoted here and in every accessor.
546
+ // The column name matches the other three engines, where it needs no quoting.
547
+ {
548
+ id: "0046_subscription_events",
549
+ sql: /* sql */ `
550
+ CREATE TABLE IF NOT EXISTS subscription_events (
551
+ id VARCHAR PRIMARY KEY,
552
+ subscription_id VARCHAR NOT NULL,
553
+ project_id VARCHAR NOT NULL,
554
+ "at" TIMESTAMP NOT NULL DEFAULT now(),
555
+ payload VARCHAR NOT NULL DEFAULT '{}'
556
+ );
557
+ `,
558
+ },
559
+ {
560
+ id: "0047_subscription_events_idx",
561
+ sql: /* sql */ `
562
+ CREATE INDEX IF NOT EXISTS subscription_events_sub_at_idx
563
+ ON subscription_events (subscription_id, "at");
564
+ `,
565
+ },
566
+ // --- Declarative panel specs (#315, ADR 0051 §7 / sketch §G.3) ------------
567
+ // What an agent leaves behind when an answer is worth keeping: a query, a
568
+ // chart name and a one-line reading. The spec is a closed document validated
569
+ // at the request boundary and never queried *into*, so — like a
570
+ // subscription's `config` — it lives in one JSON column and nothing about it
571
+ // is promoted to a column of its own.
572
+ //
573
+ // `author_kind` / `author_key_id` mirror the other three metadata tables: the
574
+ // collector's decision about who wrote the row, and the id of the key that
575
+ // carried it (never the key or its hash). `updated_at` earns its place
576
+ // because a spec is the one metadata row that is genuinely *edited* —
577
+ // retitling a pinned panel or widening it to full span should keep the panel
578
+ // where it is rather than mint a new one at the top of the grid.
579
+ {
580
+ id: "0048_panel_specs",
581
+ sql: /* sql */ `
582
+ CREATE TABLE IF NOT EXISTS panel_specs (
583
+ id VARCHAR PRIMARY KEY,
584
+ project_id VARCHAR NOT NULL,
585
+ spec VARCHAR NOT NULL,
586
+ author_kind VARCHAR NOT NULL DEFAULT 'user',
587
+ author_key_id VARCHAR,
588
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
589
+ updated_at TIMESTAMP NOT NULL DEFAULT now()
590
+ );
591
+ `,
592
+ },
593
+ {
594
+ id: "0049_panel_specs_idx",
595
+ sql: /* sql */ `
596
+ CREATE INDEX IF NOT EXISTS panel_specs_project_created_idx
597
+ ON panel_specs (project_id, created_at);
598
+ `,
599
+ },
431
600
  ];
432
601
  /**
433
602
  * Apply all DuckDB migrations in order. Idempotent — safe to run on every boot.
@@ -1 +1 @@
1
- {"version":3,"file":"migrations.js","sourceRoot":"","sources":["../../src/duckdb/migrations.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"migrations.js","sourceRoot":"","sources":["../../src/duckdb/migrations.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ import { type CreatePanelSpecInput, type MetadataListOptions, type PanelSpecRecord, type UpdatePanelSpecInput } from "../metadata.js";
2
+ import type { DuckdbClient } from "./client.js";
3
+ /**
4
+ * Pin one panel and return the stored row.
5
+ *
6
+ * Refuses with {@link MetadataLimitError} once the project holds
7
+ * {@link METADATA_LIMITS.panelSpecs} panels. The cap is lower than the other
8
+ * metadata tables' on purpose: every spec is a query the dashboard runs on
9
+ * every load, so the bound is about what a grid can usefully hold.
10
+ */
11
+ export declare function createPanelSpec(client: DuckdbClient, projectId: string, input: CreatePanelSpecInput): Promise<PanelSpecRecord>;
12
+ /**
13
+ * A project's pinned panels, **oldest first**.
14
+ *
15
+ * The opposite order to the other metadata listings, and deliberately so: these
16
+ * are grid positions, not a feed. Newest-first would move every panel down the
17
+ * dashboard each time somebody pinned one, which is exactly the instability a
18
+ * person notices and a saved layout cannot survive.
19
+ */
20
+ export declare function listPanelSpecs(client: DuckdbClient, projectId: string, opts?: MetadataListOptions): Promise<PanelSpecRecord[]>;
21
+ /**
22
+ * Replace one panel's spec, keeping its id, its place in the grid and its
23
+ * original authorship. Returns `null` when the id is unknown (or belongs to
24
+ * another project — the two are deliberately indistinguishable).
25
+ */
26
+ export declare function updatePanelSpec(client: DuckdbClient, projectId: string, id: string, input: UpdatePanelSpecInput): Promise<PanelSpecRecord | null>;
27
+ /** Unpin one panel. Returns whether a row was removed. */
28
+ export declare function deletePanelSpec(client: DuckdbClient, projectId: string, id: string): Promise<boolean>;
29
+ //# sourceMappingURL=panelSpecs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"panelSpecs.d.ts","sourceRoot":"","sources":["../../src/duckdb/panelSpecs.ts"],"names":[],"mappings":"AACA,OAAO,EAKL,KAAK,oBAAoB,EAEzB,KAAK,mBAAmB,EACxB,KAAK,eAAe,EACpB,KAAK,oBAAoB,EAC1B,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA0EhD;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,oBAAoB,GAC1B,OAAO,CAAC,eAAe,CAAC,CAuB1B;AAED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,IAAI,GAAE,mBAAwB,GAC7B,OAAO,CAAC,eAAe,EAAE,CAAC,CAc5B;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,oBAAoB,GAC1B,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAajC;AAED,0DAA0D;AAC1D,wBAAsB,eAAe,CACnC,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,OAAO,CAAC,CAYlB"}
@@ -0,0 +1,104 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { METADATA_LIMITS, MetadataLimitError, clampMetadataLimit, parsePanelSpec, } from "../metadata.js";
3
+ const PANEL_SPEC_COLS = `id, project_id, spec, author_kind, author_key_id,
4
+ epoch_ms(created_at) AS created_at_ms, epoch_ms(updated_at) AS updated_at_ms`;
5
+ /**
6
+ * Map one row, or `null` when its `spec` column is not a JSON object.
7
+ *
8
+ * A row that cannot be parsed cannot be drawn, and a listing that threw on one
9
+ * would take the whole dashboard grid down with it. Dropping it is the same
10
+ * choice ADR 0041's remote-panel loader makes for a module that fails to
11
+ * import: one bad entry never blocks the rest.
12
+ */
13
+ function rowToPanelSpec(row) {
14
+ const spec = parsePanelSpec(row.spec);
15
+ if (spec == null)
16
+ return null;
17
+ return {
18
+ id: row.id,
19
+ projectId: row.project_id,
20
+ spec,
21
+ authorKind: row.author_kind,
22
+ authorKeyId: row.author_key_id,
23
+ createdAt: new Date(Number(row.created_at_ms)),
24
+ updatedAt: new Date(Number(row.updated_at_ms)),
25
+ };
26
+ }
27
+ /** `SELECT count(*)` as a plain number, whatever width the driver returns. */
28
+ async function countRows(client, sql, params) {
29
+ const rows = await client.all(sql, params);
30
+ return Number(rows[0]?.n ?? 0);
31
+ }
32
+ /** Read one row back by id, for the create/update paths. */
33
+ async function readOne(client, id) {
34
+ const rows = await client.all(`SELECT ${PANEL_SPEC_COLS} FROM panel_specs WHERE id = $id`, { id });
35
+ // Written on this connection a moment ago, from a document that serialized,
36
+ // so both the row and its parse are guaranteed here.
37
+ return rowToPanelSpec(rows[0]);
38
+ }
39
+ /**
40
+ * Pin one panel and return the stored row.
41
+ *
42
+ * Refuses with {@link MetadataLimitError} once the project holds
43
+ * {@link METADATA_LIMITS.panelSpecs} panels. The cap is lower than the other
44
+ * metadata tables' on purpose: every spec is a query the dashboard runs on
45
+ * every load, so the bound is about what a grid can usefully hold.
46
+ */
47
+ export async function createPanelSpec(client, projectId, input) {
48
+ const count = await countRows(client, `SELECT count(*) AS n FROM panel_specs WHERE project_id = $projectId`, { projectId });
49
+ if (count >= METADATA_LIMITS.panelSpecs) {
50
+ throw new MetadataLimitError("panelSpecs", METADATA_LIMITS.panelSpecs);
51
+ }
52
+ const id = randomUUID();
53
+ await client.run(`INSERT INTO panel_specs
54
+ (id, project_id, spec, author_kind, author_key_id, created_at, updated_at)
55
+ VALUES ($id, $projectId, $spec, $authorKind, $authorKeyId, now(), now())`, {
56
+ id,
57
+ projectId,
58
+ spec: JSON.stringify(input.spec),
59
+ authorKind: input.authorKind,
60
+ authorKeyId: input.authorKeyId,
61
+ });
62
+ return readOne(client, id);
63
+ }
64
+ /**
65
+ * A project's pinned panels, **oldest first**.
66
+ *
67
+ * The opposite order to the other metadata listings, and deliberately so: these
68
+ * are grid positions, not a feed. Newest-first would move every panel down the
69
+ * dashboard each time somebody pinned one, which is exactly the instability a
70
+ * person notices and a saved layout cannot survive.
71
+ */
72
+ export async function listPanelSpecs(client, projectId, opts = {}) {
73
+ const limit = clampMetadataLimit(opts.limit, METADATA_LIMITS.panelSpecs, METADATA_LIMITS.panelSpecs);
74
+ const rows = await client.all(`SELECT ${PANEL_SPEC_COLS}
75
+ FROM panel_specs WHERE project_id = $projectId
76
+ ORDER BY created_at ASC, id ASC
77
+ LIMIT ${limit}`, { projectId });
78
+ return rows.map(rowToPanelSpec).filter((record) => record !== null);
79
+ }
80
+ /**
81
+ * Replace one panel's spec, keeping its id, its place in the grid and its
82
+ * original authorship. Returns `null` when the id is unknown (or belongs to
83
+ * another project — the two are deliberately indistinguishable).
84
+ */
85
+ export async function updatePanelSpec(client, projectId, id, input) {
86
+ const before = await countRows(client, `SELECT count(*) AS n FROM panel_specs WHERE project_id = $projectId AND id = $id`, { projectId, id });
87
+ if (before === 0)
88
+ return null;
89
+ await client.run(`UPDATE panel_specs SET spec = $spec, updated_at = now()
90
+ WHERE project_id = $projectId AND id = $id`, { projectId, id, spec: JSON.stringify(input.spec) });
91
+ return readOne(client, id);
92
+ }
93
+ /** Unpin one panel. Returns whether a row was removed. */
94
+ export async function deletePanelSpec(client, projectId, id) {
95
+ const before = await countRows(client, `SELECT count(*) AS n FROM panel_specs WHERE project_id = $projectId AND id = $id`, { projectId, id });
96
+ if (before === 0)
97
+ return false;
98
+ await client.run(`DELETE FROM panel_specs WHERE project_id = $projectId AND id = $id`, {
99
+ projectId,
100
+ id,
101
+ });
102
+ return true;
103
+ }
104
+ //# sourceMappingURL=panelSpecs.js.map